diff --git a/package-lock.json b/package-lock.json index 3b4aa17..2bec38e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@react-navigation/drawer": "^7.10.2", "expo": "~54.0.33", "expo-constants": "~18.0.13", + "expo-haptics": "~15.0.8", "expo-linking": "~8.0.12", "expo-location": "~19.0.8", "expo-router": "~6.0.23", @@ -12932,6 +12933,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linking": { "version": "8.0.12", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", diff --git a/package.json b/package.json index 365d3cd..d89b401 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@react-navigation/drawer": "^7.10.2", "expo": "~54.0.33", "expo-constants": "~18.0.13", + "expo-haptics": "~15.0.8", "expo-linking": "~8.0.12", "expo-location": "~19.0.8", "expo-router": "~6.0.23", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index dbcf825..e18dcd2 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,12 +1,15 @@ import { router } from "expo-router" import { type ReactElement, useCallback, useState } from "react" -import { FlatList, Pressable } from "react-native" +import { Alert, Pressable, View } from "react-native" import { SafeAreaView } from "react-native-safe-area-context" import Card from "#design/elements/Card" import Typography from "#design/elements/Typography" import { shapes, spacing, type ThemeColors } from "#design/foundations" +import DraggableList from "#design/patterns/DraggableList" +import SwipeToDelete from "#design/patterns/SwipeToDelete" +import { haptics } from "#shared/haptics" import { useAddCurrentLocation } from "#shared/location" import { ColorPickerModal, @@ -20,7 +23,7 @@ const REFRESH_SETTLE_MS = 800 export default function Home(): ReactElement { const styles = useThemedStyles(createStyles) - const { data: savedLocations } = useSavedLocations() + const { data: savedLocations, remove, reorder } = useSavedLocations() const { add: addCurrentLocation, currentLocation, @@ -41,56 +44,80 @@ export default function Home(): ReactElement { }, REFRESH_SETTLE_MS) }, []) + const confirmRemove = useCallback( + (location: SavedLocation) => { + Alert.alert("Remove location", `Remove ${location.name}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + remove(location.id) + haptics.success() + }, + }, + ]) + }, + [remove], + ) + const renderItem = useCallback( - ({ item }: { item: SavedLocation }) => ( - { - router.push(`/locations/${item.id}`) + (item: SavedLocation) => ( + { + confirmRemove(item) }} - reloadToken={reloadToken} - /> + > + { + router.push(`/locations/${item.id}`) + }} + reloadToken={reloadToken} + /> + ), - [reloadToken], + [confirmRemove, reloadToken], ) const showPrompt = isOnline && !isCurrentLocationSaved && currentLocationError === null + const header = ( + + Your locations + {showPrompt ? ( + { + setIsPickingColor(true) + }} + style={({ pressed }) => pressed && styles.promptPressed} + > + + + {isLoadingCurrentLocation + ? "Locating…" + : currentLocation === null + ? "Unable to read location" + : `Add your location: ${currentLocation.name}`} + + + + ) : null} + + ) + return ( - location.id} - ListHeaderComponent={ - <> - Your locations - {showPrompt ? ( - { - setIsPickingColor(true) - }} - style={({ pressed }) => pressed && styles.promptPressed} - > - - - {isLoadingCurrentLocation - ? "Locating…" - : currentLocation === null - ? "Unable to read location" - : `Add your location: ${currentLocation.name}`} - - - - ) : null} - - } onRefresh={handleRefresh} + onReorder={reorder} refreshing={refreshing} renderItem={renderItem} - showsVerticalScrollIndicator={false} /> {currentLocation === null ? null : ( @@ -111,13 +138,12 @@ export default function Home(): ReactElement { } const createStyles = (colors: ThemeColors) => ({ - content: { - gap: spacing.between, - paddingHorizontal: spacing.between, - paddingVertical: spacing.between, - }, container: { backgroundColor: colors.background, + flex: 1, + }, + header: { + gap: spacing.between, }, prompt: { alignItems: "flex-start" as const, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 50870ee..b592c65 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,6 +1,8 @@ import { Stack } from "expo-router" import { StatusBar } from "expo-status-bar" import { type ReactElement } from "react" +import { StyleSheet } from "react-native" +import { GestureHandlerRootView } from "react-native-gesture-handler" import { SavedLocationsProvider } from "#shared/locations" import { OfflineBanner } from "#shared/network" @@ -8,14 +10,23 @@ import { RESOLVED_THEME, SettingsProvider, useTheme } from "#shared/settings" export default function Layout(): ReactElement { return ( - - - - - + // Root wrapper required by react-native-gesture-handler. + + + + + + + ) } +const styles = StyleSheet.create({ + root: { + flex: 1, + }, +}) + function ThemedRoot(): ReactElement { const { colors, resolvedTheme } = useTheme() diff --git a/src/shared/design/elements/Icon/constants.ts b/src/shared/design/elements/Icon/constants.ts index c9b9340..20669c6 100644 --- a/src/shared/design/elements/Icon/constants.ts +++ b/src/shared/design/elements/Icon/constants.ts @@ -5,6 +5,7 @@ import { faHeart, faHouse, faMagnifyingGlass, + faTrash, } from "@fortawesome/free-solid-svg-icons" export const APP_ICONS: Record = { @@ -13,4 +14,5 @@ export const APP_ICONS: Record = { home: faHouse, search: faMagnifyingGlass, settings: faGear, + trash: faTrash, } diff --git a/src/shared/design/patterns/DraggableList/DraggableList.test.tsx b/src/shared/design/patterns/DraggableList/DraggableList.test.tsx new file mode 100644 index 0000000..698b8e7 --- /dev/null +++ b/src/shared/design/patterns/DraggableList/DraggableList.test.tsx @@ -0,0 +1,60 @@ +import { render } from "@testing-library/react-native" +import { type ReactNode } from "react" +import { Text } from "react-native" + +import DraggableList from "./DraggableList" + +jest.mock("react-native-gesture-handler", () => { + const chain = { + activateAfterLongPress: () => chain, + onStart: () => chain, + onUpdate: () => chain, + onEnd: () => chain, + } + return { + Gesture: { Pan: () => chain }, + GestureDetector: ({ children }: { children?: ReactNode }) => children, + } +}) + +jest.mock("react-native-reanimated", () => { + const { View } = jest.requireActual("react-native") + return { + __esModule: true, + default: { View }, + useSharedValue: (value: unknown) => ({ value }), + useAnimatedStyle: () => ({}), + withTiming: (value: unknown) => value, + } +}) + +jest.mock("react-native-worklets", () => ({ scheduleOnRN: jest.fn() })) + +jest.mock("#shared/haptics", () => ({ haptics: { tap: jest.fn() } })) + +type Row = { id: string; label: string } + +const DATA: Row[] = [ + { id: "a", label: "Madrid" }, + { id: "b", label: "Lisbon" }, + { id: "c", label: "Paris" }, +] + +describe("Design > Patterns > DraggableList", () => { + it("renders the header and a row per item", () => { + const { getByText } = render( + Your locations} + keyExtractor={(item) => item.id} + onReorder={jest.fn()} + renderItem={(item) => {item.label}} + />, + ) + + expect(getByText("Your locations")).toBeTruthy() + expect(getByText("Madrid")).toBeTruthy() + expect(getByText("Lisbon")).toBeTruthy() + expect(getByText("Paris")).toBeTruthy() + }) +}) diff --git a/src/shared/design/patterns/DraggableList/DraggableList.tsx b/src/shared/design/patterns/DraggableList/DraggableList.tsx new file mode 100644 index 0000000..69a7655 --- /dev/null +++ b/src/shared/design/patterns/DraggableList/DraggableList.tsx @@ -0,0 +1,212 @@ +import { type ReactElement, useCallback, useState } from "react" +import { + type LayoutChangeEvent, + RefreshControl, + ScrollView, + StyleSheet, +} from "react-native" +import { Gesture, GestureDetector } from "react-native-gesture-handler" +import Animated, { + type SharedValue, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated" +import { scheduleOnRN } from "react-native-worklets" + +import { spacing } from "#design/foundations" +import { haptics } from "#shared/haptics" + +import { LONG_PRESS_MS, SHIFT_DURATION_MS } from "./constants" +import { type DraggableListProps } from "./types" + +type DraggableRowProps = { + index: number + count: number + gap: number + activeIndex: SharedValue + translationY: SharedValue + stride: SharedValue + onMeasure?: (height: number) => void + onDragStart: () => void + onDrop: (from: number, to: number) => void + children: ReactElement +} + +function DraggableRow({ + index, + count, + gap, + activeIndex, + translationY, + stride, + onMeasure, + onDragStart, + onDrop, + children, +}: DraggableRowProps): ReactElement { + const style = useAnimatedStyle(() => { + const active = activeIndex.value + if (active === -1) { + return { transform: [{ translateY: 0 }], zIndex: 0 } + } + if (index === active) { + // The dragged row follows the finger and lifts above the rest. + return { transform: [{ translateY: translationY.value }], zIndex: 20 } + } + + const step = stride.value + const hover = + step > 0 + ? Math.max( + 0, + Math.min(count - 1, active + Math.round(translationY.value / step)), + ) + : active + let shift = 0 + if (index > active && index <= hover) { + shift = -step + } else if (index < active && index >= hover) { + shift = step + } + + return { + transform: [ + { translateY: withTiming(shift, { duration: SHIFT_DURATION_MS }) }, + ], + zIndex: 0, + } + }) + + const pan = Gesture.Pan() + .activateAfterLongPress(LONG_PRESS_MS) + .onStart(() => { + activeIndex.value = index + scheduleOnRN(onDragStart) + scheduleOnRN(haptics.tap) + }) + .onUpdate((event) => { + translationY.value = event.translationY + }) + .onEnd(() => { + const step = stride.value + const to = + step > 0 + ? Math.max( + 0, + Math.min( + count - 1, + index + Math.round(translationY.value / step), + ), + ) + : index + scheduleOnRN(onDrop, index, to) + activeIndex.value = -1 + translationY.value = 0 + }) + + const handleLayout = (event: LayoutChangeEvent): void => { + onMeasure?.(event.nativeEvent.layout.height) + } + + return ( + + + {children} + + + ) +} + +// Long-press a row to drag it; the dragged row follows the finger while the +// others slide to open a slot. Drops commit the new order via onReorder. Rows +// are assumed uniform height (measured from the first one) — fine for a short +// list of equal cards. Not virtualized, so keep lists small. +export default function DraggableList({ + data, + keyExtractor, + renderItem, + onReorder, + gap = spacing.between, + header, + refreshing, + onRefresh, + contentStyle, +}: DraggableListProps): ReactElement { + const activeIndex = useSharedValue(-1) + const translationY = useSharedValue(0) + const stride = useSharedValue(0) + const [dragging, setDragging] = useState(false) + + const handleDragStart = useCallback(() => { + setDragging(true) + }, []) + + const handleDrop = useCallback( + (from: number, to: number) => { + setDragging(false) + if (from === to) { + return + } + + const keys = data.map(keyExtractor) + const [moved] = keys.splice(from, 1) + keys.splice(to, 0, moved) + onReorder(keys) + }, + [data, keyExtractor, onReorder], + ) + + const handleMeasure = useCallback( + (height: number) => { + stride.value = height + gap + }, + [gap, stride], + ) + + return ( + + ) + } + scrollEnabled={!dragging} + showsVerticalScrollIndicator={false} + > + {header !== undefined && ( + {header} + )} + + {data.map((item, index) => ( + + {renderItem(item)} + + ))} + + ) +} + +const styles = StyleSheet.create({ + content: { + padding: spacing.between, + }, +}) diff --git a/src/shared/design/patterns/DraggableList/constants.ts b/src/shared/design/patterns/DraggableList/constants.ts new file mode 100644 index 0000000..a7ef6c0 --- /dev/null +++ b/src/shared/design/patterns/DraggableList/constants.ts @@ -0,0 +1,5 @@ +// Hold this long before a press turns into a drag, so taps/scroll still work. +export const LONG_PRESS_MS = 220 + +// How fast displaced rows slide to make room for the dragged one. +export const SHIFT_DURATION_MS = 150 diff --git a/src/shared/design/patterns/DraggableList/index.ts b/src/shared/design/patterns/DraggableList/index.ts new file mode 100644 index 0000000..7c0d52f --- /dev/null +++ b/src/shared/design/patterns/DraggableList/index.ts @@ -0,0 +1,3 @@ +export { default } from "./DraggableList" + +export type { DraggableListProps } from "./types" diff --git a/src/shared/design/patterns/DraggableList/types.ts b/src/shared/design/patterns/DraggableList/types.ts new file mode 100644 index 0000000..5064426 --- /dev/null +++ b/src/shared/design/patterns/DraggableList/types.ts @@ -0,0 +1,16 @@ +import { type ReactElement } from "react" +import { type StyleProp, type ViewStyle } from "react-native" + +export type DraggableListProps = { + data: T[] + keyExtractor: (item: T) => string + renderItem: (item: T) => ReactElement + // Called on drop with the full key order after the move. + onReorder: (orderedKeys: string[]) => void + // Vertical space between rows; also feeds the drag stride. + gap?: number + header?: ReactElement + refreshing?: boolean + onRefresh?: () => void + contentStyle?: StyleProp +} diff --git a/src/shared/design/patterns/SwipeToDelete/SwipeToDelete.test.tsx b/src/shared/design/patterns/SwipeToDelete/SwipeToDelete.test.tsx new file mode 100644 index 0000000..ce6b27d --- /dev/null +++ b/src/shared/design/patterns/SwipeToDelete/SwipeToDelete.test.tsx @@ -0,0 +1,63 @@ +import { render } from "@testing-library/react-native" +import { type ReactNode } from "react" +import { Text, View } from "react-native" + +import SwipeToDelete from "./SwipeToDelete" + +// Gesture + animation libs are native-bound; stub them so the row renders. +jest.mock("react-native-gesture-handler", () => { + const chain = { + activeOffsetX: () => chain, + failOffsetY: () => chain, + onUpdate: () => chain, + onEnd: () => chain, + } + return { + Gesture: { Pan: () => chain }, + GestureDetector: ({ children }: { children?: ReactNode }) => children, + } +}) + +jest.mock("react-native-reanimated", () => { + const { View: RNView } = jest.requireActual("react-native") + return { + __esModule: true, + default: { View: RNView }, + useSharedValue: (value: unknown) => ({ value }), + useAnimatedStyle: () => ({}), + withTiming: (value: unknown) => value, + } +}) + +jest.mock("react-native-worklets", () => ({ scheduleOnRN: jest.fn() })) + +jest.mock("@fortawesome/react-native-fontawesome", () => ({ + FontAwesomeIcon: () => null, +})) + +jest.mock("#shared/settings", () => ({ + useThemedStyles: (factory: (colors: { negative: string }) => unknown) => + factory({ negative: "#dc2626" }), +})) + +describe("Design > Patterns > SwipeToDelete", () => { + it("renders its child row", () => { + const { getByText } = render( + + Madrid + , + ) + + expect(getByText("Madrid")).toBeTruthy() + }) + + it("renders children without the swipe affordance when disabled", () => { + const { getByTestId } = render( + + + , + ) + + expect(getByTestId("child")).toBeTruthy() + }) +}) diff --git a/src/shared/design/patterns/SwipeToDelete/SwipeToDelete.tsx b/src/shared/design/patterns/SwipeToDelete/SwipeToDelete.tsx new file mode 100644 index 0000000..b8ad189 --- /dev/null +++ b/src/shared/design/patterns/SwipeToDelete/SwipeToDelete.tsx @@ -0,0 +1,111 @@ +import { type ReactElement } from "react" +import { type LayoutChangeEvent, View } from "react-native" +import { Gesture, GestureDetector } from "react-native-gesture-handler" +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated" +import { scheduleOnRN } from "react-native-worklets" + +import Icon from "#design/elements/Icon" +import { shapes, spacing, type ThemeColors } from "#design/foundations" +import { haptics } from "#shared/haptics" +import { useThemedStyles } from "#shared/settings" + +import { HORIZONTAL_SLOP, TRIGGER_RATIO } from "./constants" +import { type SwipeToDeleteProps } from "./types" + +// Swipe a row right to reveal a delete backdrop that grows to fill the gap, then +// commit past TRIGGER_RATIO of the width. onDelete fires on commit (callers +// confirm before removing); the row snaps back. +export default function SwipeToDelete({ + children, + onDelete, + disabled = false, +}: SwipeToDeleteProps): ReactElement { + const styles = useThemedStyles(createStyles) + const translateX = useSharedValue(0) + const rowWidth = useSharedValue(0) + // Buzz once per threshold crossing, not every frame. + const armed = useSharedValue(false) + + const rowStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })) + + const backgroundStyle = useAnimatedStyle(() => ({ + width: translateX.value, + })) + + // Bail out after all hooks have run. + if (disabled) { + return <>{children} + } + + const onLayout = (event: LayoutChangeEvent): void => { + rowWidth.value = event.nativeEvent.layout.width + } + + const pan = Gesture.Pan() + .activeOffsetX([-HORIZONTAL_SLOP, HORIZONTAL_SLOP]) + .failOffsetY([-HORIZONTAL_SLOP, HORIZONTAL_SLOP]) + .onUpdate((event) => { + const width = rowWidth.value + // Right swipes only, clamped to the row width. + translateX.value = Math.max(0, Math.min(event.translationX, width)) + const passed = width > 0 && translateX.value >= width * TRIGGER_RATIO + if (passed && !armed.value) { + armed.value = true + scheduleOnRN(haptics.tap) + } else if (!passed && armed.value) { + armed.value = false + } + }) + .onEnd(() => { + if ( + rowWidth.value > 0 && + translateX.value >= rowWidth.value * TRIGGER_RATIO + ) { + scheduleOnRN(onDelete) + } + armed.value = false + translateX.value = withTiming(0) + }) + + return ( + + + + + + + {children} + + + ) +} + +const createStyles = (colors: ThemeColors) => ({ + container: { + justifyContent: "center" as const, + overflow: "hidden" as const, + borderRadius: shapes.borderRadius, + }, + action: { + position: "absolute" as const, + left: 0, + top: 0, + bottom: 0, + flexDirection: "row" as const, + alignItems: "center" as const, + justifyContent: "flex-start" as const, + overflow: "hidden" as const, + paddingLeft: spacing.inside, + backgroundColor: colors.negative, + }, +}) diff --git a/src/shared/design/patterns/SwipeToDelete/constants.ts b/src/shared/design/patterns/SwipeToDelete/constants.ts new file mode 100644 index 0000000..7506c8b --- /dev/null +++ b/src/shared/design/patterns/SwipeToDelete/constants.ts @@ -0,0 +1,3 @@ +export const TRIGGER_RATIO = 0.5 +// Horizontal travel before the pan activates, so vertical scrolling wins first. +export const HORIZONTAL_SLOP = 12 diff --git a/src/shared/design/patterns/SwipeToDelete/index.ts b/src/shared/design/patterns/SwipeToDelete/index.ts new file mode 100644 index 0000000..f32be33 --- /dev/null +++ b/src/shared/design/patterns/SwipeToDelete/index.ts @@ -0,0 +1,3 @@ +export { default } from "./SwipeToDelete" + +export type { SwipeToDeleteProps } from "./types" diff --git a/src/shared/design/patterns/SwipeToDelete/types.ts b/src/shared/design/patterns/SwipeToDelete/types.ts new file mode 100644 index 0000000..31c78f9 --- /dev/null +++ b/src/shared/design/patterns/SwipeToDelete/types.ts @@ -0,0 +1,8 @@ +import { type ReactNode } from "react" + +export type SwipeToDeleteProps = { + children: ReactNode + // Fired on swipe-commit; callers confirm before removing, not delete outright. + onDelete: () => void + disabled?: boolean +} diff --git a/src/shared/haptics/haptics.test.ts b/src/shared/haptics/haptics.test.ts new file mode 100644 index 0000000..4e60a16 --- /dev/null +++ b/src/shared/haptics/haptics.test.ts @@ -0,0 +1,46 @@ +import * as Haptics from "expo-haptics" +import { Platform } from "react-native" + +import { haptics } from "./haptics" + +jest.mock("expo-haptics", () => ({ + impactAsync: jest.fn(() => Promise.resolve()), + notificationAsync: jest.fn(() => Promise.resolve()), + ImpactFeedbackStyle: { Light: "light" }, + NotificationFeedbackType: { Success: "success" }, +})) + +describe("Haptics", () => { + afterEach(() => { + jest.clearAllMocks() + Platform.OS = "ios" + }) + + it("maps each intent to its expo-haptics call", () => { + haptics.tap() + expect(Haptics.impactAsync).toHaveBeenCalledWith( + Haptics.ImpactFeedbackStyle.Light, + ) + + haptics.success() + expect(Haptics.notificationAsync).toHaveBeenCalledWith( + Haptics.NotificationFeedbackType.Success, + ) + }) + + it("skips haptics on web", () => { + Platform.OS = "web" + + haptics.tap() + haptics.success() + + expect(Haptics.impactAsync).not.toHaveBeenCalled() + expect(Haptics.notificationAsync).not.toHaveBeenCalled() + }) + + it("never throws when the effect rejects", () => { + ;(Haptics.impactAsync as jest.Mock).mockRejectedValueOnce(new Error("off")) + + expect(() => haptics.tap()).not.toThrow() + }) +}) diff --git a/src/shared/haptics/haptics.ts b/src/shared/haptics/haptics.ts new file mode 100644 index 0000000..ed56f3b --- /dev/null +++ b/src/shared/haptics/haptics.ts @@ -0,0 +1,23 @@ +import * as Haptics from "expo-haptics" +import { Platform } from "react-native" + +// Intent-named wrapper over expo-haptics. Best-effort: skips web and swallows +// errors (e.g. iOS Low Power Mode) so a missing buzz never breaks an action. +function run(effect: () => Promise): void { + if (Platform.OS === "web") { + return + } + + void effect().catch(() => { + // best-effort + }) +} + +export const haptics = { + tap: (): void => + run(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)), + success: (): void => + run(() => + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success), + ), +} diff --git a/src/shared/haptics/index.ts b/src/shared/haptics/index.ts new file mode 100644 index 0000000..bc5dc48 --- /dev/null +++ b/src/shared/haptics/index.ts @@ -0,0 +1 @@ +export { haptics } from "./haptics" diff --git a/src/shared/locations/savedLocations.tsx b/src/shared/locations/savedLocations.tsx index a9b125d..a6647e7 100644 --- a/src/shared/locations/savedLocations.tsx +++ b/src/shared/locations/savedLocations.tsx @@ -38,6 +38,7 @@ export type UseSavedLocationsResult = { isLoading: boolean isSaved: (id: string) => boolean remove: (id: string) => void + reorder: (orderedIds: string[]) => void setColor: (id: string, color: string) => void toggle: (location: Location, color?: string) => void } @@ -86,6 +87,23 @@ export function SavedLocationsProvider({ [setData], ) + const reorder = useCallback( + (orderedIds: string[]) => { + setData((current) => { + const byId = new Map(current.map((entry) => [entry.id, entry])) + const ordered = orderedIds + .map((id) => byId.get(id)) + .filter((entry): entry is SavedLocation => entry !== undefined) + // Keep any not present in the order list (defensive) at the end. + const missing = current.filter( + (entry) => !orderedIds.includes(entry.id), + ) + return [...ordered, ...missing] + }) + }, + [setData], + ) + const setColor = useCallback( (id: string, color: string) => { setData((current) => @@ -118,10 +136,22 @@ export function SavedLocationsProvider({ isLoading, isSaved, remove, + reorder, setColor, toggle, }), - [add, data, error, findById, isLoading, isSaved, remove, setColor, toggle], + [ + add, + data, + error, + findById, + isLoading, + isSaved, + remove, + reorder, + setColor, + toggle, + ], ) return {children}