This implements the mobile optimization agent (P3 - Enhancement) with: PWA Configuration: - Add next-pwa integration with offline caching strategies - Create web app manifest for installability - Add service worker with background sync support - Create offline fallback page Mobile Components: - BottomNav: Touch-friendly bottom navigation bar - MobileHeader: Responsive header with back navigation - InstallPrompt: Smart PWA install prompt (iOS & Android) - SwipeableCard: Gesture-based swipeable cards - PullToRefresh: Native-like pull to refresh - QRScanner: Camera-based QR code scanning Mobile Library: - camera.ts: Camera access and photo capture utilities - offline.ts: IndexedDB-based offline storage and sync - gestures.ts: Touch gesture detection (swipe, pinch, tap) - pwa.ts: PWA status, install prompts, service worker management Mobile Pages: - /m: Mobile dashboard with quick actions and stats - /m/scan: QR code scanner for plant lookup - /m/quick-add: Streamlined plant registration form - /m/profile: User profile with offline status Dependencies added: next-pwa, idb
254 lines
6 KiB
TypeScript
254 lines
6 KiB
TypeScript
/**
|
|
* Mobile Camera Utilities
|
|
* Provides camera access, photo capture, and image processing for mobile devices
|
|
*/
|
|
|
|
export interface CameraConfig {
|
|
facingMode?: 'user' | 'environment';
|
|
width?: number;
|
|
height?: number;
|
|
aspectRatio?: number;
|
|
}
|
|
|
|
export interface CapturedImage {
|
|
blob: Blob;
|
|
dataUrl: string;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export class CameraService {
|
|
private stream: MediaStream | null = null;
|
|
private videoElement: HTMLVideoElement | null = null;
|
|
|
|
async checkCameraAvailability(): Promise<boolean> {
|
|
try {
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
return devices.some((device) => device.kind === 'videoinput');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async requestPermission(): Promise<PermissionState> {
|
|
try {
|
|
const result = await navigator.permissions.query({ name: 'camera' as PermissionName });
|
|
return result.state;
|
|
} catch {
|
|
// Fallback: try to access camera to trigger permission prompt
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
return 'granted';
|
|
} catch {
|
|
return 'denied';
|
|
}
|
|
}
|
|
}
|
|
|
|
async startCamera(videoElement: HTMLVideoElement, config: CameraConfig = {}): Promise<void> {
|
|
const {
|
|
facingMode = 'environment',
|
|
width = 1280,
|
|
height = 720,
|
|
aspectRatio,
|
|
} = config;
|
|
|
|
const constraints: MediaStreamConstraints = {
|
|
video: {
|
|
facingMode,
|
|
width: { ideal: width },
|
|
height: { ideal: height },
|
|
...(aspectRatio && { aspectRatio }),
|
|
},
|
|
};
|
|
|
|
try {
|
|
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
videoElement.srcObject = this.stream;
|
|
this.videoElement = videoElement;
|
|
await videoElement.play();
|
|
} catch (error) {
|
|
throw new Error(`Failed to start camera: ${(error as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async capturePhoto(quality = 0.9): Promise<CapturedImage> {
|
|
if (!this.videoElement || !this.stream) {
|
|
throw new Error('Camera not started');
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const video = this.videoElement;
|
|
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new Error('Failed to get canvas context');
|
|
}
|
|
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) {
|
|
reject(new Error('Failed to capture photo'));
|
|
return;
|
|
}
|
|
|
|
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
|
|
|
resolve({
|
|
blob,
|
|
dataUrl,
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
});
|
|
},
|
|
'image/jpeg',
|
|
quality
|
|
);
|
|
});
|
|
}
|
|
|
|
async switchCamera(): Promise<void> {
|
|
if (!this.videoElement) {
|
|
throw new Error('Camera not started');
|
|
}
|
|
|
|
const currentTrack = this.stream?.getVideoTracks()[0];
|
|
const currentFacingMode = currentTrack?.getSettings().facingMode;
|
|
const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
|
|
|
|
this.stopCamera();
|
|
await this.startCamera(this.videoElement, { facingMode: newFacingMode });
|
|
}
|
|
|
|
stopCamera(): void {
|
|
if (this.stream) {
|
|
this.stream.getTracks().forEach((track) => track.stop());
|
|
this.stream = null;
|
|
}
|
|
|
|
if (this.videoElement) {
|
|
this.videoElement.srcObject = null;
|
|
this.videoElement = null;
|
|
}
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.stream !== null && this.stream.active;
|
|
}
|
|
}
|
|
|
|
// Image processing utilities
|
|
export async function cropImage(
|
|
image: CapturedImage,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number
|
|
): Promise<CapturedImage> {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new Error('Failed to get canvas context');
|
|
}
|
|
|
|
const img = await loadImage(image.dataUrl);
|
|
ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) {
|
|
reject(new Error('Failed to crop image'));
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
blob,
|
|
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
|
width,
|
|
height,
|
|
});
|
|
},
|
|
'image/jpeg',
|
|
0.9
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function resizeImage(
|
|
image: CapturedImage,
|
|
maxWidth: number,
|
|
maxHeight: number
|
|
): Promise<CapturedImage> {
|
|
const img = await loadImage(image.dataUrl);
|
|
|
|
let { width, height } = img;
|
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
|
|
|
if (ratio < 1) {
|
|
width = Math.round(width * ratio);
|
|
height = Math.round(height * ratio);
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new Error('Failed to get canvas context');
|
|
}
|
|
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) {
|
|
reject(new Error('Failed to resize image'));
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
blob,
|
|
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
|
width,
|
|
height,
|
|
});
|
|
},
|
|
'image/jpeg',
|
|
0.9
|
|
);
|
|
});
|
|
}
|
|
|
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = reject;
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
// Singleton instance
|
|
let cameraInstance: CameraService | null = null;
|
|
|
|
export function getCamera(): CameraService {
|
|
if (!cameraInstance) {
|
|
cameraInstance = new CameraService();
|
|
}
|
|
return cameraInstance;
|
|
}
|
|
|
|
export default CameraService;
|