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

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);
};
}