diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx new file mode 100644 index 0000000..2420692 --- /dev/null +++ b/src/components/PullToRefreshIndicator.tsx @@ -0,0 +1,55 @@ +/** + * Visual indicator for pull-to-refresh gesture. + * Shows a spinner/arrow that responds to pull distance. + */ + +import React from "react"; + +interface PullToRefreshIndicatorProps { + pullDistance: number; + isRefreshing: boolean; + isPastThreshold?: boolean; + threshold?: number; +} + +export function PullToRefreshIndicator({ + pullDistance, + isRefreshing, + isPastThreshold: isPastThresholdProp, + threshold = 80, +}: PullToRefreshIndicatorProps) { + if (pullDistance === 0 && !isRefreshing) return null; + + const progress = Math.min(pullDistance / threshold, 1); + const isPastThreshold = isPastThresholdProp ?? progress >= 1; + const rotation = isPastThreshold ? 180 : progress * 180; + + return ( +
+
+ {isRefreshing ? ( + 💩 + ) : ( + + 💩 + + )} +
+
+ ); +} diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts index e2ea1fe..e800a53 100644 --- a/src/hooks/usePullToRefresh.ts +++ b/src/hooks/usePullToRefresh.ts @@ -1,98 +1,119 @@ /** - * usePullToRefresh - Pull-to-refresh gesture for mobile. - * Also exports PullToRefreshIndicator component. + * Pull-to-refresh hook for mobile touch interactions. + * + * Since Convex uses reactive queries (data is always live), this hook + * provides visual feedback that data is fresh rather than actually + * re-fetching. It can optionally call a callback for additional refresh logic. */ -import React, { useRef, useState, useEffect, useCallback } from "react"; -const MAX_PULL = 120; +import { useState, useRef, useCallback, useEffect } from "react"; -interface UsePullToRefreshOptions { - onRefresh: () => Promise | void; +interface PullToRefreshOptions { + /** Called when user completes a pull-to-refresh gesture */ + onRefresh?: () => Promise | void; + /** Minimum pull distance in px to trigger refresh (default: 80) */ threshold?: number; + /** Max pull distance in px (default: 150) */ + maxPull?: number; + /** Whether pull-to-refresh is enabled (default: true) */ + enabled?: boolean; } -interface UsePullToRefreshResult { - pullRef: React.RefObject; - pullDistance: number; - isRefreshing: boolean; -} +export function usePullToRefresh(options: PullToRefreshOptions = {}) { + const { + onRefresh, + threshold = 80, + maxPull = 150, + enabled = true, + } = options; -export function usePullToRefresh({ onRefresh, threshold = 80 }: UsePullToRefreshOptions): UsePullToRefreshResult { - const pullRef = useRef(null); const [pullDistance, setPullDistance] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); - const startY = useRef(0); - const tracking = useRef(false); - - const handleTouchStart = useCallback((e: TouchEvent) => { - const el = pullRef.current; - if (!el || isRefreshing) return; - if (el.scrollTop <= 0) { - startY.current = e.touches[0].clientY; - tracking.current = true; - } - }, [isRefreshing]); + const [isPastThreshold, setIsPastThreshold] = useState(false); - const handleTouchMove = useCallback((e: TouchEvent) => { - if (!tracking.current || isRefreshing) return; - const dy = e.touches[0].clientY - startY.current; - if (dy > 0) { - setPullDistance(Math.min(dy * 0.5, MAX_PULL)); - } else { - tracking.current = false; - setPullDistance(0); - } - }, [isRefreshing]); + const touchStartY = useRef(null); + const pullRef = useRef(null); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (!enabled || isRefreshing) return; + // Only start pull if scrolled to top + const scrollEl = document.scrollingElement || document.documentElement; + if (scrollEl.scrollTop > 5) return; + touchStartY.current = e.touches[0].clientY; + }, + [enabled, isRefreshing] + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!enabled || isRefreshing || touchStartY.current === null) return; + + const currentY = e.touches[0].clientY; + const diff = currentY - touchStartY.current; + + if (diff > 0) { + const distance = Math.min(diff * 0.5, maxPull); + setPullDistance(distance); + setIsPastThreshold(distance >= threshold); + if (diff > 10) { + e.preventDefault(); + } + } else { + setPullDistance(0); + setIsPastThreshold(false); + } + }, + [enabled, isRefreshing, threshold, maxPull] + ); const handleTouchEnd = useCallback(async () => { - if (!tracking.current) return; - tracking.current = false; - if (pullDistance >= threshold && !isRefreshing) { + if (!enabled || touchStartY.current === null) return; + touchStartY.current = null; + + if (isPastThreshold) { + setPullDistance(threshold * 0.6); setIsRefreshing(true); - setPullDistance(threshold * 0.5); + try { - await onRefresh(); + if (onRefresh) { + await onRefresh(); + } else { + await new Promise((resolve) => setTimeout(resolve, 600)); + } } finally { - setIsRefreshing(false); setPullDistance(0); + setIsRefreshing(false); + setIsPastThreshold(false); } } else { setPullDistance(0); + setIsPastThreshold(false); } - }, [pullDistance, isRefreshing, onRefresh, threshold]); + }, [enabled, isPastThreshold, threshold, onRefresh]); useEffect(() => { - const el = pullRef.current; - if (!el) return; - el.addEventListener("touchstart", handleTouchStart, { passive: true }); - el.addEventListener("touchmove", handleTouchMove, { passive: true }); - el.addEventListener("touchend", handleTouchEnd, { passive: true }); + const container = pullRef.current; + if (!container || !enabled) return; + + container.addEventListener("touchstart", handleTouchStart, { passive: true }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd, { passive: true }); + return () => { - el.removeEventListener("touchstart", handleTouchStart); - el.removeEventListener("touchmove", handleTouchMove); - el.removeEventListener("touchend", handleTouchEnd); + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); }; - }, [handleTouchStart, handleTouchMove, handleTouchEnd]); + }, [enabled, handleTouchStart, handleTouchMove, handleTouchEnd]); - return { pullRef, pullDistance, isRefreshing }; + return { + pullRef, + pullDistance, + isRefreshing, + isPastThreshold, + }; } -/** - * Visual indicator for pull-to-refresh state. - */ -export function PullToRefreshIndicator({ pullDistance, isRefreshing, threshold = 80 }: { pullDistance: number; isRefreshing: boolean; threshold?: number }) { - if (pullDistance <= 0 && !isRefreshing) return null; - const ready = pullDistance >= threshold; - return React.createElement("div", { - style: { - height: `${pullDistance}px`, - transition: "height 0.2s ease-out", - overflow: "hidden", - display: "flex", - alignItems: "center", - justifyContent: "center", - }, - }, React.createElement("span", { - className: `text-2xl ${isRefreshing ? "animate-spin" : ""}`, - }, isRefreshing ? "⟳" : ready ? "↓ Release" : "↓ Pull")); -} +// Re-export the indicator component for convenience +export { PullToRefreshIndicator } from "../components/PullToRefreshIndicator"; diff --git a/src/pages/ListView.tsx b/src/pages/ListView.tsx index 6c21e6b..1b88920 100644 --- a/src/pages/ListView.tsx +++ b/src/pages/ListView.tsx @@ -23,7 +23,8 @@ import { canEdit, canInvite, canDeleteList } from "../lib/permissions"; import { groupByAisle, classifyItem } from "../lib/groceryAisles"; import { useCategories } from "../hooks/useCategories"; import { shareList } from "../lib/share"; -import { usePullToRefresh, PullToRefreshIndicator } from "../hooks/usePullToRefresh"; +import { usePullToRefresh } from "../hooks/usePullToRefresh"; +import { PullToRefreshIndicator } from "../components/PullToRefreshIndicator"; import { AddItemInput } from "../components/AddItemInput"; import { ListItem } from "../components/ListItem"; import { CollaboratorList } from "../components/sharing/CollaboratorList"; @@ -522,6 +523,17 @@ export function ListView() { shortcuts, }); + // Pull-to-refresh for mobile — Convex queries are reactive, so this + // just provides visual feedback that data is live/fresh + const { pullRef, pullDistance, isRefreshing, isPastThreshold } = usePullToRefresh({ + onRefresh: async () => { + haptic('light'); + // Brief delay for visual feedback — data is already live via Convex + await new Promise(resolve => setTimeout(resolve, 500)); + }, + enabled: viewMode === "list", + }); + // Reset focus when items change significantly useEffect(() => { if (focusedIndex !== null && focusedIndex >= sortedItems.length) { @@ -608,8 +620,6 @@ export function ListView() { ref={pullRef} className="max-w-3xl mx-auto" > - {/* Pull-to-refresh indicator */} - {/* Header - Redesigned for less crowding */}
@@ -776,6 +786,13 @@ export function ListView() {
)} + {/* Pull-to-refresh indicator */} + + {/* Amber progress bar */} {totalCount > 0 && (