localgreenchain/lib/mobile/camera.ts
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
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
2025-11-23 03:56:30 +00:00

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;