Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/hooks/usePhotoGallery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('loading saved photos', () => {
});
});

describe('takePhoto', () => {
describe('addNewToGallery', () => {
it('saves a web photo and adds it to the front of the gallery', async () => {
const { result } = await renderGallery([{ filepath: 'older.jpeg' }]);
await waitFor(() => expect(result.current.photos).toHaveLength(1));
Expand All @@ -104,7 +104,7 @@ describe('takePhoto', () => {
vi.fn().mockResolvedValue({ blob: async () => new Blob(['image-bytes'], { type: 'image/jpeg' }) }),
);

await act(() => result.current.takePhoto());
await act(() => result.current.addNewToGallery());

expect(Camera.getPhoto).toHaveBeenCalledWith({ resultType: 'uri', source: 'CAMERA', quality: 100 });
expect(Filesystem.writeFile).toHaveBeenCalledWith({
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('takePhoto', () => {
},
);

await expect(act(() => result.current.takePhoto())).rejects.toBeDefined();
await expect(act(() => result.current.addNewToGallery())).rejects.toBeDefined();

expect(Filesystem.writeFile).not.toHaveBeenCalled();
expect(result.current.photos).toEqual([]);
Expand All @@ -163,7 +163,7 @@ describe('takePhoto', () => {
});
vi.mocked(Filesystem.readFile).mockResolvedValue({ data: 'BASE64DATA' });

await act(() => result.current.takePhoto());
await act(() => result.current.addNewToGallery());

expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'file:///tmp/photo.jpeg' });
expect(Filesystem.writeFile).toHaveBeenCalledWith({ path: `${NOW}.jpeg`, data: 'BASE64DATA', directory: 'DATA' });
Expand All @@ -172,15 +172,16 @@ describe('takePhoto', () => {
]);
});

it('converts native file data returned as a Blob to text', async () => {
it('passes native file data returned as a Blob through to the filesystem unchanged', async () => {
setHybrid(true);
const { result } = await renderGallery(null);
vi.mocked(Camera.getPhoto).mockResolvedValue({ path: 'file:///tmp/photo.jpeg', format: 'jpeg', saved: false });
vi.mocked(Filesystem.readFile).mockResolvedValue({ data: new Blob(['BLOBDATA']) });
const data = new Blob(['BLOBDATA']);
vi.mocked(Filesystem.readFile).mockResolvedValue({ data });

await act(() => result.current.takePhoto());
await act(() => result.current.addNewToGallery());

expect(Filesystem.writeFile).toHaveBeenCalledWith(expect.objectContaining({ data: 'BLOBDATA' }));
expect(vi.mocked(Filesystem.writeFile).mock.calls[0][0].data).toBe(data);
});
});

Expand Down
80 changes: 44 additions & 36 deletions src/hooks/usePhotoGallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,59 +6,67 @@ import { Preferences } from '@capacitor/preferences';
import { isPlatform } from '@ionic/react';
import { useState, useEffect } from 'react';

const PHOTO_STORAGE = 'photos';
export function usePhotoGallery(): {
addNewToGallery: () => Promise<void>;
deletePhoto: (photo: UserPhoto) => Promise<void>;
photos: UserPhoto[];
takePhoto: () => Promise<void>;
} {
const [photos, setPhotos] = useState<UserPhoto[]>([]);

const PHOTO_STORAGE = 'photos';

useEffect(() => {
const loadSaved = async () => {
const { value } = await Preferences.get({ key: PHOTO_STORAGE });
const { value: photoList } = await Preferences.get({ key: PHOTO_STORAGE });
const photosInPreferences = (photoList ? JSON.parse(photoList) : []) as UserPhoto[];

const photosInPreferences = (value ? JSON.parse(value) : []) as UserPhoto[];
// If running on the web...
if (!isPlatform('hybrid')) {
for (const photo of photosInPreferences) {
const file = await Filesystem.readFile({
path: photo.filepath,
directory: Directory.Data,
});
// Web platform only: Load the photo as base64 data
photo.webviewPath = `data:image/jpeg;base64,${file.data}`;
}
}

setPhotos(photosInPreferences);
};

loadSaved();
}, []);

const takePhoto = async () => {
const photo = await Camera.getPhoto({
resultType: CameraResultType.Uri,
source: CameraSource.Camera,
quality: 100,
const addNewToGallery = async () => {
// Take a photo
const capturedPhoto = await Camera.getPhoto({
resultType: CameraResultType.Uri, // file-based data; provides best performance
source: CameraSource.Camera, // automatically take a new photo with the camera
quality: 100, // highest quality (0 to 100)
});

const fileName = new Date().getTime() + '.jpeg';
const savedFileImage = await savePicture(photo, fileName);
const newPhotos = [savedFileImage, ...photos];
// Save the picture and add it to photo collection
const savedImageFile = await savePicture(capturedPhoto, fileName);

const newPhotos = [savedImageFile, ...photos];
setPhotos(newPhotos);

Preferences.set({ key: PHOTO_STORAGE, value: JSON.stringify(newPhotos) });
};

const savePicture = async (photo: Photo, fileName: string): Promise<UserPhoto> => {
let base64Data: string;
// "hybrid" will detect Cordova or Capacitor;
let base64Data: string | Blob;
// "hybrid" will detect mobile - iOS or Android
if (isPlatform('hybrid')) {
const file = await Filesystem.readFile({
path: photo.path!,
});
base64Data = typeof file.data === 'string' ? file.data : await file.data.text();
base64Data = file.data;
} else {
base64Data = await base64FromPath(photo.webPath!);
}

const savedFile = await Filesystem.writeFile({
path: fileName,
data: base64Data,
Expand All @@ -67,7 +75,6 @@ export function usePhotoGallery(): {

if (isPlatform('hybrid')) {
// Display the new image by rewriting the 'file://' path to HTTP
// Details: https://ionicframework.com/docs/building/webview#file-protocol
return {
filepath: savedFile.uri,
webviewPath: Capacitor.convertFileSrc(savedFile.uri),
Expand All @@ -82,47 +89,48 @@ export function usePhotoGallery(): {
}
};

const base64FromPath = async (path: string): Promise<string> => {
const response = await fetch(path);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject('method did not return a string');
}
};
reader.readAsDataURL(blob);
});
};

const deletePhoto = async (photo: UserPhoto) => {
// Remove this photo from the Photos reference data array
const newPhotos = photos.filter((p) => p.filepath !== photo.filepath);

// Update photos array cache by overwriting the existing photo array
Preferences.set({ key: PHOTO_STORAGE, value: JSON.stringify(newPhotos) });

// delete photo file from filesystem
const filename = photo.filepath.substr(photo.filepath.lastIndexOf('/') + 1);
// Delete photo file from filesystem
const filename = photo.filepath.slice(photo.filepath.lastIndexOf('/') + 1);
await Filesystem.deleteFile({
path: filename,
directory: Directory.Data,
});

setPhotos(newPhotos);
};

return {
addNewToGallery,
deletePhoto,
photos,
takePhoto,
};
}

export interface UserPhoto {
filepath: string;
webviewPath?: string;
}

export async function base64FromPath(path: string): Promise<string> {
const response = await fetch(path);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject('method did not return a string');
}
};
reader.readAsDataURL(blob);
});
}
4 changes: 2 additions & 2 deletions src/pages/Tab2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Tab2 from './Tab2';

const gallery = vi.hoisted(() => ({
photos: [] as UserPhoto[],
takePhoto: vi.fn(),
addNewToGallery: vi.fn(),
deletePhoto: vi.fn(),
}));

Expand Down Expand Up @@ -42,7 +42,7 @@ describe('Tab2', () => {

fireEvent.click(container.querySelector('ion-fab-button') as Element);

expect(gallery.takePhoto).toHaveBeenCalledTimes(1);
expect(gallery.addNewToGallery).toHaveBeenCalledTimes(1);
});

it('opens the photo actions when a photo is tapped', async () => {
Expand Down
14 changes: 9 additions & 5 deletions src/pages/Tab2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import {
IonActionSheet,
} from '@ionic/react';
import { camera, trash, close } from 'ionicons/icons';
import React, { useState } from 'react';
import { useState } from 'react';

import type { UserPhoto } from '../hooks/usePhotoGallery';
import { usePhotoGallery } from '../hooks/usePhotoGallery';
import './Tab2.css';

const Tab2: React.FC = () => {
const { deletePhoto, photos, takePhoto } = usePhotoGallery();
const { photos, addNewToGallery, deletePhoto } = usePhotoGallery();
const [photoToDelete, setPhotoToDelete] = useState<UserPhoto>();

return (
Expand All @@ -30,12 +30,13 @@ const Tab2: React.FC = () => {
<IonTitle>Photo Gallery</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Photo Gallery</IonTitle>
</IonToolbar>
</IonHeader>

<IonGrid>
<IonRow>
{photos.map((photo, index) => (
Expand All @@ -49,7 +50,7 @@ const Tab2: React.FC = () => {
</IonGrid>

<IonFab vertical="bottom" horizontal="center" slot="fixed">
<IonFabButton onClick={() => takePhoto()}>
<IonFabButton onClick={() => addNewToGallery()}>
<IonIcon icon={camera}></IonIcon>
</IonFabButton>
</IonFab>
Expand All @@ -72,10 +73,13 @@ const Tab2: React.FC = () => {
text: 'Cancel',
icon: close,
role: 'cancel',
handler: () => {
// Nothing to do, action sheet is automatically closed
},
},
]}
onDidDismiss={() => setPhotoToDelete(undefined)}
/>
></IonActionSheet>
</IonContent>
</IonPage>
);
Expand Down
Loading