localgreenchain/lib/mobile/pwa.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

268 lines
7.2 KiB
TypeScript

/**
* PWA Utilities
* Service worker registration, update handling, and install prompt management
*/
export interface PWAStatus {
isInstalled: boolean;
isStandalone: boolean;
canInstall: boolean;
isOnline: boolean;
updateAvailable: boolean;
}
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let swRegistration: ServiceWorkerRegistration | null = null;
// Check if running as installed PWA
export function isInstalled(): boolean {
if (typeof window === 'undefined') return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
// Check if running in standalone mode
export function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(display-mode: standalone)').matches;
}
// Check if app can be installed
export function canInstall(): boolean {
return deferredPrompt !== null;
}
// Get current PWA status
export function getPWAStatus(): PWAStatus {
return {
isInstalled: isInstalled(),
isStandalone: isStandalone(),
canInstall: canInstall(),
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
updateAvailable: false, // Updated by service worker
};
}
// Prompt user to install PWA
export async function promptInstall(): Promise<boolean> {
if (!deferredPrompt) {
return false;
}
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
return outcome === 'accepted';
}
// Register service worker
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
swRegistration = registration;
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New update available
dispatchPWAEvent('updateavailable');
}
});
});
return registration;
} catch (error) {
console.error('Service worker registration failed:', error);
return null;
}
}
// Apply pending update
export async function applyUpdate(): Promise<void> {
if (!swRegistration?.waiting) return;
// Tell service worker to skip waiting
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Reload page after new service worker takes over
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
// Unregister service worker
export async function unregisterServiceWorker(): Promise<boolean> {
if (!swRegistration) return false;
const success = await swRegistration.unregister();
if (success) {
swRegistration = null;
}
return success;
}
// Clear all caches
export async function clearCaches(): Promise<void> {
if (typeof caches === 'undefined') return;
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
// Subscribe to push notifications
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!swRegistration) {
console.error('Service worker not registered');
return null;
}
try {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
return subscription;
} catch (error) {
console.error('Push subscription failed:', error);
return null;
}
}
// Unsubscribe from push notifications
export async function unsubscribeFromPush(): Promise<boolean> {
if (!swRegistration) return false;
try {
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
return await subscription.unsubscribe();
}
return true;
} catch {
return false;
}
}
// Check push notification permission
export function getPushPermission(): NotificationPermission {
if (typeof Notification === 'undefined') return 'denied';
return Notification.permission;
}
// Request push notification permission
export async function requestPushPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied';
return await Notification.requestPermission();
}
// Initialize PWA
export function initPWA(): () => void {
if (typeof window === 'undefined') return () => {};
// Listen for install prompt
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
dispatchPWAEvent('caninstall');
};
// Listen for app installed
const handleAppInstalled = () => {
deferredPrompt = null;
dispatchPWAEvent('installed');
};
// Listen for online/offline
const handleOnline = () => dispatchPWAEvent('online');
const handleOffline = () => dispatchPWAEvent('offline');
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Register service worker
registerServiceWorker();
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// PWA event dispatcher
function dispatchPWAEvent(type: string, detail?: any): void {
window.dispatchEvent(new CustomEvent(`pwa:${type}`, { detail }));
}
// Utility to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Share API wrapper
export async function share(data: ShareData): Promise<boolean> {
if (!navigator.share) {
return false;
}
try {
await navigator.share(data);
return true;
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Share failed:', error);
}
return false;
}
}
// Check if Web Share API is supported
export function canShare(data?: ShareData): boolean {
if (!navigator.share) return false;
if (!data) return true;
return navigator.canShare?.(data) ?? true;
}