From 894ac0307223a50b0b7f50d86b98fbdfbe196756 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 21:04:55 -0400 Subject: [PATCH] feat: add refresh button on web + swipe-to-refresh on mobile (#61) Web: add a RefreshCw button next to the list title in TaskList that calls router.refresh() with a spin animation. Mobile: add RefreshControl to DraggableFlatList on both inbox and list detail screens, using the app's blue accent color. Also deduplicate list insert values in sync/route.ts (review feedback). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(tabs)/index.tsx | 17 ++++++++++++++++- app/list/[id].tsx | 17 ++++++++++++++++- web/src/app/api/sync/route.ts | 30 ++++++++++++------------------ web/src/components/TaskList.tsx | 26 +++++++++++++++++++++++--- web/src/i18n/en.json | 1 + web/src/i18n/fr.json | 1 + 6 files changed, 69 insertions(+), 23 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 53a6bd8..d2c4a12 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native'; +import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native'; import { useRouter } from 'expo-router'; import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; @@ -44,6 +44,7 @@ export default function InboxScreen() { const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; const isDraggingRef = useRef(false); + const [refreshing, setRefreshing] = useState(false); const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); @@ -70,6 +71,12 @@ export default function InboxScreen() { return () => clearInterval(interval); }, [loadTasks]); + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await loadTasks(); + setRefreshing(false); + }, [loadTasks]); + const handleToggle = async (id: string) => { await toggleComplete(id); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); @@ -201,6 +208,14 @@ export default function InboxScreen() { onDragBegin={() => { isDraggingRef.current = true; }} onDragEnd={handleDragEnd} activationDistance={canDrag ? 0 : 10000} + refreshControl={ + + } /> )} diff --git a/app/list/[id].tsx b/app/list/[id].tsx index e81c045..e653816 100644 --- a/app/list/[id].tsx +++ b/app/list/[id].tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native'; +import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, @@ -61,6 +61,7 @@ export default function ListDetailScreen() { const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; const isDraggingRef = useRef(false); + const [refreshing, setRefreshing] = useState(false); const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); @@ -95,6 +96,12 @@ export default function ListDetailScreen() { return () => clearInterval(interval); }, [loadData]); + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await loadData(); + setRefreshing(false); + }, [loadData]); + const handleToggle = async (taskId: string) => { await toggleComplete(taskId); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); @@ -249,6 +256,14 @@ export default function ListDetailScreen() { onDragBegin={() => { isDraggingRef.current = true; }} onDragEnd={handleDragEnd} activationDistance={canDrag ? 0 : 10000} + refreshControl={ + + } /> )} diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index b0b35f9..56541cd 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -200,6 +200,16 @@ async function processOperation(op: SyncOperation, userId: string) { const d = (data as Record) || {}; const incomingIsInbox = d.isInbox as boolean | undefined; + const listValues = { + id: entityId, + userId, + name: d.name as string || 'Untitled', + color: d.color as string | undefined, + icon: d.icon as string | undefined, + position: d.position as number | undefined, + isInbox: incomingIsInbox, + }; + // If the incoming list is an inbox, check for an existing inbox and merge if (incomingIsInbox) { await db.transaction(async (tx) => { @@ -219,26 +229,10 @@ async function processOperation(op: SyncOperation, userId: string) { .where(eq(slLists.id, existingInbox.id)); } - await tx.insert(slLists).values({ - id: entityId, - userId, - name: d.name as string || 'Untitled', - color: d.color as string | undefined, - icon: d.icon as string | undefined, - position: d.position as number | undefined, - isInbox: incomingIsInbox, - }).onConflictDoNothing(); + await tx.insert(slLists).values(listValues).onConflictDoNothing(); }); } else { - await db.insert(slLists).values({ - id: entityId, - userId, - name: d.name as string || 'Untitled', - color: d.color as string | undefined, - icon: d.icon as string | undefined, - position: d.position as number | undefined, - isInbox: incomingIsInbox, - }).onConflictDoNothing(); + await db.insert(slLists).values(listValues).onConflictDoNothing(); } } else if (action === 'update') { await verifyOwnership(slLists, entityId, userId); diff --git a/web/src/components/TaskList.tsx b/web/src/components/TaskList.tsx index 7139eea..310d5e7 100644 --- a/web/src/components/TaskList.tsx +++ b/web/src/components/TaskList.tsx @@ -4,8 +4,9 @@ import type { Task } from "@/lib/types"; import { TaskItem } from "./TaskItem"; import { TaskForm } from "./TaskForm"; import { FilterBar } from "./FilterBar"; -import { ClipboardList } from "lucide-react"; -import { Suspense } from "react"; +import { ClipboardList, RefreshCw } from "lucide-react"; +import { Suspense, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; interface TaskListProps { @@ -17,12 +18,31 @@ interface TaskListProps { export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) { const { t } = useTranslation(); + const router = useRouter(); + const [refreshing, setRefreshing] = useState(false); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + router.refresh(); + // Brief visual feedback + setTimeout(() => setRefreshing(false), 500); + }, [router]); return (
{/* Header */}
-

{listName}

+
+

{listName}

+ +
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 1de949f..857b386 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -20,6 +20,7 @@ "subtaskPlaceholder": "New subtask...", "notesPlaceholder": "Notes...", "empty": "No tasks", + "refresh": "Refresh", "edit": "Edit", "save": "Save", "cancel": "Cancel", diff --git a/web/src/i18n/fr.json b/web/src/i18n/fr.json index 7367b4a..4e659c0 100644 --- a/web/src/i18n/fr.json +++ b/web/src/i18n/fr.json @@ -20,6 +20,7 @@ "subtaskPlaceholder": "Nouvelle sous-tâche...", "notesPlaceholder": "Notes...", "empty": "Aucune tâche", + "refresh": "Rafraîchir", "edit": "Modifier", "save": "Enregistrer", "cancel": "Annuler",