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
257 lines
7.2 KiB
TypeScript
257 lines
7.2 KiB
TypeScript
/**
|
|
* Mobile Gesture Utilities
|
|
* Provides touch gesture detection and handling
|
|
*/
|
|
|
|
export interface GestureEvent {
|
|
type: 'swipe' | 'pinch' | 'rotate' | 'tap' | 'longpress' | 'doubletap';
|
|
direction?: 'up' | 'down' | 'left' | 'right';
|
|
scale?: number;
|
|
rotation?: number;
|
|
center?: { x: number; y: number };
|
|
velocity?: { x: number; y: number };
|
|
}
|
|
|
|
export interface GestureHandlers {
|
|
onSwipe?: (direction: 'up' | 'down' | 'left' | 'right', velocity: number) => void;
|
|
onPinch?: (scale: number) => void;
|
|
onRotate?: (angle: number) => void;
|
|
onTap?: (x: number, y: number) => void;
|
|
onLongPress?: (x: number, y: number) => void;
|
|
onDoubleTap?: (x: number, y: number) => void;
|
|
}
|
|
|
|
export interface GestureConfig {
|
|
swipeThreshold?: number;
|
|
swipeVelocityThreshold?: number;
|
|
longPressDelay?: number;
|
|
doubleTapDelay?: number;
|
|
pinchThreshold?: number;
|
|
}
|
|
|
|
const defaultConfig: Required<GestureConfig> = {
|
|
swipeThreshold: 50,
|
|
swipeVelocityThreshold: 0.3,
|
|
longPressDelay: 500,
|
|
doubleTapDelay: 300,
|
|
pinchThreshold: 0.1,
|
|
};
|
|
|
|
export function createGestureHandler(
|
|
element: HTMLElement,
|
|
handlers: GestureHandlers,
|
|
config: GestureConfig = {}
|
|
): () => void {
|
|
const cfg = { ...defaultConfig, ...config };
|
|
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let startTime = 0;
|
|
let longPressTimer: NodeJS.Timeout | null = null;
|
|
let lastTapTime = 0;
|
|
let initialDistance = 0;
|
|
let initialAngle = 0;
|
|
|
|
const getDistance = (touches: TouchList): number => {
|
|
if (touches.length < 2) return 0;
|
|
const dx = touches[1].clientX - touches[0].clientX;
|
|
const dy = touches[1].clientY - touches[0].clientY;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
};
|
|
|
|
const getAngle = (touches: TouchList): number => {
|
|
if (touches.length < 2) return 0;
|
|
const dx = touches[1].clientX - touches[0].clientX;
|
|
const dy = touches[1].clientY - touches[0].clientY;
|
|
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
};
|
|
|
|
const handleTouchStart = (e: TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
startX = touch.clientX;
|
|
startY = touch.clientY;
|
|
startTime = Date.now();
|
|
|
|
// Long press detection
|
|
if (handlers.onLongPress) {
|
|
longPressTimer = setTimeout(() => {
|
|
handlers.onLongPress!(startX, startY);
|
|
// Vibrate on long press if available
|
|
if ('vibrate' in navigator) {
|
|
navigator.vibrate(50);
|
|
}
|
|
}, cfg.longPressDelay);
|
|
}
|
|
|
|
// Pinch/rotate initialization
|
|
if (e.touches.length === 2) {
|
|
initialDistance = getDistance(e.touches);
|
|
initialAngle = getAngle(e.touches);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
// Cancel long press on move
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
longPressTimer = null;
|
|
}
|
|
|
|
// Pinch detection
|
|
if (e.touches.length === 2 && (handlers.onPinch || handlers.onRotate)) {
|
|
const currentDistance = getDistance(e.touches);
|
|
const currentAngle = getAngle(e.touches);
|
|
|
|
if (handlers.onPinch && initialDistance > 0) {
|
|
const scale = currentDistance / initialDistance;
|
|
if (Math.abs(scale - 1) > cfg.pinchThreshold) {
|
|
handlers.onPinch(scale);
|
|
}
|
|
}
|
|
|
|
if (handlers.onRotate) {
|
|
const rotation = currentAngle - initialAngle;
|
|
handlers.onRotate(rotation);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = (e: TouchEvent) => {
|
|
// Clear long press timer
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
longPressTimer = null;
|
|
}
|
|
|
|
const touch = e.changedTouches[0];
|
|
const endX = touch.clientX;
|
|
const endY = touch.clientY;
|
|
const endTime = Date.now();
|
|
|
|
const deltaX = endX - startX;
|
|
const deltaY = endY - startY;
|
|
const deltaTime = endTime - startTime;
|
|
|
|
// Swipe detection
|
|
if (handlers.onSwipe) {
|
|
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
|
|
|
|
if (velocity > cfg.swipeVelocityThreshold) {
|
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
if (Math.abs(deltaX) > cfg.swipeThreshold) {
|
|
handlers.onSwipe(deltaX > 0 ? 'right' : 'left', velocity);
|
|
}
|
|
} else {
|
|
if (Math.abs(deltaY) > cfg.swipeThreshold) {
|
|
handlers.onSwipe(deltaY > 0 ? 'down' : 'up', velocity);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tap / Double tap detection
|
|
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) {
|
|
const now = Date.now();
|
|
|
|
if (handlers.onDoubleTap && now - lastTapTime < cfg.doubleTapDelay) {
|
|
handlers.onDoubleTap(endX, endY);
|
|
lastTapTime = 0;
|
|
} else if (handlers.onTap) {
|
|
lastTapTime = now;
|
|
// Delay tap to wait for potential double tap
|
|
if (handlers.onDoubleTap) {
|
|
setTimeout(() => {
|
|
if (lastTapTime === now) {
|
|
handlers.onTap!(endX, endY);
|
|
}
|
|
}, cfg.doubleTapDelay);
|
|
} else {
|
|
handlers.onTap(endX, endY);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTouchCancel = () => {
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
longPressTimer = null;
|
|
}
|
|
};
|
|
|
|
// Add event listeners
|
|
element.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
element.addEventListener('touchmove', handleTouchMove, { passive: true });
|
|
element.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
element.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
|
|
|
// Return cleanup function
|
|
return () => {
|
|
element.removeEventListener('touchstart', handleTouchStart);
|
|
element.removeEventListener('touchmove', handleTouchMove);
|
|
element.removeEventListener('touchend', handleTouchEnd);
|
|
element.removeEventListener('touchcancel', handleTouchCancel);
|
|
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
}
|
|
};
|
|
}
|
|
|
|
// React hook for gestures
|
|
export function useGestures(
|
|
ref: React.RefObject<HTMLElement>,
|
|
handlers: GestureHandlers,
|
|
config?: GestureConfig
|
|
): void {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const handlersRef = { current: handlers };
|
|
handlersRef.current = handlers;
|
|
|
|
const element = ref.current;
|
|
if (!element) return;
|
|
|
|
// Note: In actual React usage, this should be inside a useEffect
|
|
createGestureHandler(element, handlersRef.current, config);
|
|
}
|
|
|
|
// Haptic feedback utility
|
|
export function triggerHaptic(type: 'light' | 'medium' | 'heavy' = 'light'): void {
|
|
if (!('vibrate' in navigator)) return;
|
|
|
|
const patterns: Record<typeof type, number | number[]> = {
|
|
light: 10,
|
|
medium: 25,
|
|
heavy: [50, 50, 50],
|
|
};
|
|
|
|
navigator.vibrate(patterns[type]);
|
|
}
|
|
|
|
// Prevent pull-to-refresh on specific elements
|
|
export function preventPullToRefresh(element: HTMLElement): () => void {
|
|
let startY = 0;
|
|
|
|
const handleTouchStart = (e: TouchEvent) => {
|
|
startY = e.touches[0].clientY;
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
const currentY = e.touches[0].clientY;
|
|
const scrollTop = element.scrollTop;
|
|
|
|
// Prevent if at top and pulling down
|
|
if (scrollTop === 0 && currentY > startY) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
element.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
element.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
|
|
return () => {
|
|
element.removeEventListener('touchstart', handleTouchStart);
|
|
element.removeEventListener('touchmove', handleTouchMove);
|
|
};
|
|
}
|