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
138 lines
3.9 KiB
TypeScript
138 lines
3.9 KiB
TypeScript
import * as React from 'react';
|
|
import classNames from 'classnames';
|
|
|
|
interface PullToRefreshProps {
|
|
onRefresh: () => Promise<void>;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const [startY, setStartY] = React.useState(0);
|
|
const [pullDistance, setPullDistance] = React.useState(0);
|
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
|
const [isPulling, setIsPulling] = React.useState(false);
|
|
|
|
const threshold = 80;
|
|
const maxPull = 120;
|
|
const resistance = 2.5;
|
|
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
// Only start if scrolled to top
|
|
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
|
setStartY(e.touches[0].clientY);
|
|
setIsPulling(true);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: React.TouchEvent) => {
|
|
if (!isPulling || isRefreshing) return;
|
|
|
|
const currentY = e.touches[0].clientY;
|
|
const diff = (currentY - startY) / resistance;
|
|
|
|
if (diff > 0) {
|
|
const distance = Math.min(maxPull, diff);
|
|
setPullDistance(distance);
|
|
|
|
// Prevent default scroll when pulling
|
|
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = async () => {
|
|
if (!isPulling || isRefreshing) return;
|
|
|
|
setIsPulling(false);
|
|
|
|
if (pullDistance >= threshold) {
|
|
setIsRefreshing(true);
|
|
setPullDistance(60); // Keep indicator visible during refresh
|
|
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
setPullDistance(0);
|
|
}
|
|
} else {
|
|
setPullDistance(0);
|
|
}
|
|
};
|
|
|
|
const progress = Math.min(1, pullDistance / threshold);
|
|
const rotation = pullDistance * 3;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={classNames('relative overflow-auto', className)}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* Pull indicator */}
|
|
<div
|
|
className="absolute left-1/2 transform -translate-x-1/2 z-10 transition-opacity"
|
|
style={{
|
|
top: pullDistance - 40,
|
|
opacity: pullDistance > 10 ? 1 : 0,
|
|
}}
|
|
>
|
|
<div
|
|
className={classNames(
|
|
'w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center',
|
|
{ 'animate-spin': isRefreshing }
|
|
)}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={classNames('h-6 w-6 text-green-600 transition-transform', {
|
|
'animate-spin': isRefreshing,
|
|
})}
|
|
style={{ transform: isRefreshing ? undefined : `rotate(${rotation}deg)` }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pull text */}
|
|
{pullDistance > 10 && !isRefreshing && (
|
|
<div
|
|
className="absolute left-1/2 transform -translate-x-1/2 text-sm text-gray-500 transition-opacity z-10"
|
|
style={{
|
|
top: pullDistance + 5,
|
|
opacity: progress,
|
|
}}
|
|
>
|
|
{pullDistance >= threshold ? 'Release to refresh' : 'Pull to refresh'}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div
|
|
className="transition-transform"
|
|
style={{
|
|
transform: `translateY(${pullDistance}px)`,
|
|
transitionDuration: isPulling ? '0ms' : '200ms',
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PullToRefresh;
|