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
268 lines
7.2 KiB
TypeScript
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;
|
|
}
|