From 80bcf4a5f29ab926c2208d5efb4c19087c600323 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 7 Sep 2025 19:22:28 +0200 Subject: feat: Enhance StopSheet component with error handling and loading states - Added skeleton loading state to StopSheet for better UX during data fetch. - Implemented error handling with descriptive messages for network and server errors. - Introduced manual refresh functionality to reload stop estimates. - Updated styles for loading and error states. - Created StopSheetSkeleton and TimetableSkeleton components for consistent loading indicators. feat: Improve StopList component with loading indicators and network data fetching - Integrated loading state for StopList while fetching stops from the network. - Added skeleton loading indicators for favourite and recent stops. - Refactored data fetching logic to include favourite and recent stops with full data. - Enhanced user experience with better loading and error handling. feat: Update Timetable component with loading and error handling - Added loading skeletons to Timetable for improved user experience. - Implemented error handling for timetable data fetching. - Refactored data loading logic to handle errors gracefully and provide retry options. chore: Update package dependencies - Upgraded react-router, lucide-react, and other dependencies to their latest versions. - Updated types for TypeScript compatibility. --- src/frontend/app/components/PullToRefresh.tsx | 160 ++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) (limited to 'src/frontend/app/components/PullToRefresh.tsx') diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx index e69de29..f8ea590 100644 --- a/src/frontend/app/components/PullToRefresh.tsx +++ b/src/frontend/app/components/PullToRefresh.tsx @@ -0,0 +1,160 @@ +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { motion, useMotionValue, useTransform } from "framer-motion"; +import { RefreshCw } from "lucide-react"; +import "./PullToRefresh.css"; + +interface PullToRefreshProps { + onRefresh: () => Promise | void; + isRefreshing?: boolean; + children: React.ReactNode; + threshold?: number; +} + +export const PullToRefresh: React.FC = ({ + onRefresh, + isRefreshing = false, + children, + threshold = 60, +}) => { + const [isActive, setIsActive] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const containerRef = useRef(null); + const startY = useRef(0); + + const y = useMotionValue(0); + const opacity = useTransform(y, [0, threshold], [0, 1]); + const scale = useTransform(y, [0, threshold], [0.5, 1]); + const rotate = useTransform(y, [0, threshold], [0, 180]); + + const isAtPageTop = useCallback(() => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; + return scrollTop <= 10; // Increased tolerance to 10px + }, []); + + const handleTouchStart = useCallback((e: TouchEvent) => { + // Very strict check - must be at absolute top + const windowScroll = window.pageYOffset || window.scrollY || 0; + const htmlScroll = document.documentElement.scrollTop; + const bodyScroll = document.body.scrollTop; + const containerScroll = containerRef.current?.scrollTop || 0; + const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; + const maxScroll = Math.max(windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll); + + if (maxScroll > 0 || isRefreshing) { + setIsPulling(false); + setIsActive(false); + return; + } + + startY.current = e.touches[0].clientY; + setIsPulling(true); + }, [isRefreshing]); + + const handleTouchMove = useCallback((e: TouchEvent) => { + if (!isPulling) return; + + // Continuously check if we're still at the top during the gesture + const windowScroll = window.pageYOffset || window.scrollY || 0; + const htmlScroll = document.documentElement.scrollTop; + const bodyScroll = document.body.scrollTop; + const containerScroll = containerRef.current?.scrollTop || 0; + const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; + const maxScroll = Math.max(windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll); + + if (maxScroll > 10) { + // Cancel pull-to-refresh if we've scrolled away from top + setIsPulling(false); + setIsActive(false); + y.set(0); + return; + } + + const currentY = e.touches[0].clientY; + const pullDistance = currentY - startY.current; + + if (pullDistance > 0) { + // Only prevent default when the event is cancelable + if (e.cancelable) { + e.preventDefault(); + } + + const dampedDistance = Math.min(pullDistance * 0.5, threshold * 1.2); + y.set(dampedDistance); + + if (dampedDistance >= threshold && !isActive) { + setIsActive(true); + // Only vibrate if user activation is available and vibrate is supported + if (navigator.vibrate && navigator.userActivation?.hasBeenActive) { + navigator.vibrate(50); + } + } else if (dampedDistance < threshold && isActive) { + setIsActive(false); + } + } else { + // Reset if pulling up + y.set(0); + setIsActive(false); + } + }, [isPulling, threshold, isActive, y]); + + const handleTouchEnd = useCallback(async () => { + if (!isPulling) return; + + setIsPulling(false); + + if (isActive && y.get() >= threshold && !isRefreshing) { + try { + await onRefresh(); + } catch (error) { + console.error('Refresh failed:', error); + } + } + + // Always reset state + setIsActive(false); + y.set(0); + startY.current = 0; + }, [isPulling, isActive, threshold, isRefreshing, onRefresh, y]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Use passive: false for touchmove to allow preventDefault + container.addEventListener('touchstart', handleTouchStart, { passive: true }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd, { passive: true }); + + return () => { + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchStart, handleTouchMove, handleTouchEnd]); + + return ( +
+ {/* Simple indicator */} + {isPulling && ( + + + + + + )} + + {/* Normal content - no transform interference */} +
+ {children} +
+
+ ); +}; -- cgit v1.3