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 && (