diff --git a/src/hooks/usePhotoGallery.test.ts b/src/hooks/usePhotoGallery.test.ts index 6d0a1ad..85877bf 100644 --- a/src/hooks/usePhotoGallery.test.ts +++ b/src/hooks/usePhotoGallery.test.ts @@ -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)); @@ -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({ @@ -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([]); @@ -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' }); @@ -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); }); }); diff --git a/src/hooks/usePhotoGallery.ts b/src/hooks/usePhotoGallery.ts index 5f9160c..4696fb0 100644 --- a/src/hooks/usePhotoGallery.ts +++ b/src/hooks/usePhotoGallery.ts @@ -6,19 +6,20 @@ import { Preferences } from '@capacitor/preferences'; import { isPlatform } from '@ionic/react'; import { useState, useEffect } from 'react'; -const PHOTO_STORAGE = 'photos'; export function usePhotoGallery(): { + addNewToGallery: () => Promise; deletePhoto: (photo: UserPhoto) => Promise; photos: UserPhoto[]; - takePhoto: () => Promise; } { const [photos, setPhotos] = useState([]); + 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) { @@ -26,39 +27,46 @@ export function usePhotoGallery(): { 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 => { - 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, @@ -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), @@ -82,6 +89,23 @@ export function usePhotoGallery(): { } }; + const base64FromPath = async (path: string): Promise => { + 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); @@ -89,19 +113,20 @@ export function usePhotoGallery(): { // 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, }; } @@ -109,20 +134,3 @@ export interface UserPhoto { filepath: string; webviewPath?: string; } - -export async function base64FromPath(path: string): Promise { - 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); - }); -} diff --git a/src/pages/Tab2.test.tsx b/src/pages/Tab2.test.tsx index 355f069..9079e3c 100644 --- a/src/pages/Tab2.test.tsx +++ b/src/pages/Tab2.test.tsx @@ -9,7 +9,7 @@ import Tab2 from './Tab2'; const gallery = vi.hoisted(() => ({ photos: [] as UserPhoto[], - takePhoto: vi.fn(), + addNewToGallery: vi.fn(), deletePhoto: vi.fn(), })); @@ -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 () => { diff --git a/src/pages/Tab2.tsx b/src/pages/Tab2.tsx index f80ca63..e8d2084 100644 --- a/src/pages/Tab2.tsx +++ b/src/pages/Tab2.tsx @@ -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(); return ( @@ -30,12 +30,13 @@ const Tab2: React.FC = () => { Photo Gallery - + Photo Gallery + {photos.map((photo, index) => ( @@ -49,7 +50,7 @@ const Tab2: React.FC = () => { - takePhoto()}> + addNewToGallery()}> @@ -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)} - /> + > );