Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/components/PullToRefreshIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Visual indicator for pull-to-refresh gesture.
* Shows a spinner/arrow that responds to pull distance.
*/

import React from "react";

Check failure on line 6 in src/components/PullToRefreshIndicator.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

'React' is declared but its value is never read.

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 (
<div
className="flex items-center justify-center overflow-hidden transition-[height] duration-200 ease-out"
style={{ height: `${pullDistance}px` }}
>
<div
className={`flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200 ${
isRefreshing
? "bg-amber-100 dark:bg-amber-900/30"
: isPastThreshold
? "bg-amber-100 dark:bg-amber-900/30 scale-110"
: "bg-gray-100 dark:bg-gray-800"
}`}
style={{ opacity: Math.min(progress * 1.5, 1) }}
>
{isRefreshing ? (
<span className="text-xl animate-spin">💩</span>
) : (
<span
className="text-xl transition-transform duration-150 ease-out"
style={{ transform: `rotate(${rotation}deg)` }}
>
💩
</span>
)}
</div>
</div>
);
}
161 changes: 91 additions & 70 deletions src/hooks/usePullToRefresh.ts
Original file line number Diff line number Diff line change
@@ -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> | void;
interface PullToRefreshOptions {
/** Called when user completes a pull-to-refresh gesture */
onRefresh?: () => Promise<void> | 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<HTMLDivElement | null>;
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<HTMLDivElement | null>(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<number | null>(null);
const pullRef = useRef<HTMLDivElement>(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";
23 changes: 20 additions & 3 deletions src/pages/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
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";
Expand Down Expand Up @@ -82,7 +83,7 @@
// Convex auto-refreshes via subscriptions; add slight delay for UX feedback
await new Promise<void>((resolve) => setTimeout(resolve, 600));
}, []);
const { pullRef, pullDistance, isRefreshing } = usePullToRefresh({

Check failure on line 86 in src/pages/ListView.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

Cannot redeclare block-scoped variable 'isRefreshing'.

Check failure on line 86 in src/pages/ListView.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

Cannot redeclare block-scoped variable 'pullDistance'.

Check failure on line 86 in src/pages/ListView.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

Cannot redeclare block-scoped variable 'pullRef'.
onRefresh: handlePullRefresh,
threshold: 80,
});
Expand Down Expand Up @@ -522,6 +523,17 @@
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({

Check failure on line 528 in src/pages/ListView.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

Cannot redeclare block-scoped variable 'isRefreshing'.

Check failure on line 528 in src/pages/ListView.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

Cannot redeclare block-scoped variable 'pullDistance'.

Check failure on line 528 in src/pages/ListView.tsx

View workflow job for this annotation

GitHub Actions / Build Web App

Cannot redeclare block-scoped variable 'pullRef'.
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) {
Expand Down Expand Up @@ -608,8 +620,6 @@
ref={pullRef}
className="max-w-3xl mx-auto"
>
{/* Pull-to-refresh indicator */}
<PullToRefreshIndicator pullDistance={pullDistance} isRefreshing={isRefreshing} />
{/* Header - Redesigned for less crowding */}
<div className="mb-6">
<div className="flex items-start gap-3">
Expand Down Expand Up @@ -776,6 +786,13 @@
</div>
)}

{/* Pull-to-refresh indicator */}
<PullToRefreshIndicator
pullDistance={pullDistance}
isRefreshing={isRefreshing}
isPastThreshold={isPastThreshold}
/>

{/* Amber progress bar */}
{totalCount > 0 && (
<div className="mb-4 bg-amber-100 dark:bg-amber-900/30 rounded-full h-2.5 overflow-hidden">
Expand Down
Loading