localgreenchain/components/mobile/PullToRefresh.tsx
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

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;