Use @gorhom/bottom-sheet for waypoint sheet

Replace custom View-based bottom sheet with @gorhom/bottom-sheet:
- Native gesture-driven drag, snap points, pan-down-to-close
- Proper safe area handling built in
- Wrap app with GestureHandlerRootView
- Add react-native-reanimated plugin to Expo config

Adds react-native-reanimated and react-native-gesture-handler as
native dependencies (requires new EAS build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-14 23:42:21 +02:00
parent fe07fbdf59
commit bdde95e69b
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 782 additions and 250 deletions

View file

@ -44,6 +44,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
project: "mobile", project: "mobile",
}], }],
"expo-sqlite", "expo-sqlite",
"react-native-reanimated",
], ],
extra: { extra: {
eas: { eas: {

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Stack, router } from "expo-router"; import { Stack, router } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Sentry, initSentry } from "../lib/sentry"; import { Sentry, initSentry } from "../lib/sentry";
import { isAuthenticated } from "../lib/auth"; import { isAuthenticated } from "../lib/auth";
import { startVersionCheck } from "../lib/version-check"; import { startVersionCheck } from "../lib/version-check";
@ -22,7 +23,11 @@ function RootLayout() {
if (!checked) return null; if (!checked) return null;
return <Stack screenOptions={{ headerShown: false }} />; return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }} />
</GestureHandlerRootView>
);
} }
export default Sentry.wrap(RootLayout); export default Sentry.wrap(RootLayout);

View file

@ -172,15 +172,13 @@ function RouteDetailContent({ route }: { route: RouteDetail }) {
)} )}
{selectedWaypoint !== null && editor.waypoints[selectedWaypoint] && ( {selectedWaypoint !== null && editor.waypoints[selectedWaypoint] && (
<View style={[styles.sheetOverlay, { paddingBottom: insets.bottom }]}> <WaypointSheet
<WaypointSheet waypoint={editor.waypoints[selectedWaypoint]!}
waypoint={editor.waypoints[selectedWaypoint]!} index={selectedWaypoint}
index={selectedWaypoint} onClose={() => setSelectedWaypoint(null)}
onClose={() => setSelectedWaypoint(null)} onDelete={editor.deleteWaypoint}
onDelete={editor.deleteWaypoint} onToggleOvernight={editor.toggleOvernight}
onToggleOvernight={editor.toggleOvernight} />
/>
</View>
)} )}
</View> </View>
) : ( ) : (
@ -253,5 +251,4 @@ const styles = StyleSheet.create({
actionSecondaryText: { color: "#333", fontSize: 16, fontWeight: "600" }, actionSecondaryText: { color: "#333", fontSize: 16, fontWeight: "600" },
errorBanner: { position: "absolute", bottom: 16, alignSelf: "center", backgroundColor: "rgba(220,38,38,0.9)", borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 }, errorBanner: { position: "absolute", bottom: 16, alignSelf: "center", backgroundColor: "rgba(220,38,38,0.9)", borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 },
errorBannerText: { color: "#fff", fontSize: 13 }, errorBannerText: { color: "#fff", fontSize: 13 },
sheetOverlay: { position: "absolute", bottom: 0, left: 0, right: 0 },
}); });

View file

@ -1,5 +1,6 @@
import { View, Text, TouchableOpacity, StyleSheet, Alert } from "react-native"; import { useRef, useCallback, useMemo } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text, TouchableOpacity, StyleSheet, Alert } from "react-native";
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
import type { Waypoint } from "@trails-cool/types"; import type { Waypoint } from "@trails-cool/types";
interface WaypointSheetProps { interface WaypointSheetProps {
@ -17,9 +18,10 @@ export function WaypointSheet({
onDelete, onDelete,
onToggleOvernight, onToggleOvernight,
}: WaypointSheetProps) { }: WaypointSheetProps) {
const insets = useSafeAreaInsets(); const bottomSheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => ["35%"], []);
const handleDelete = () => { const handleDelete = useCallback(() => {
Alert.alert( Alert.alert(
"Delete Waypoint", "Delete Waypoint",
`Remove waypoint ${index + 1}${waypoint.name ? ` (${waypoint.name})` : ""}?`, `Remove waypoint ${index + 1}${waypoint.name ? ` (${waypoint.name})` : ""}?`,
@ -35,63 +37,55 @@ export function WaypointSheet({
}, },
], ],
); );
}; }, [index, waypoint.name, onDelete, onClose]);
const handleSheetChange = useCallback((sheetIndex: number) => {
if (sheetIndex === -1) onClose();
}, [onClose]);
return ( return (
<View style={[styles.container, { paddingBottom: 16 + insets.bottom }]}> <BottomSheet
<View style={styles.handle} /> ref={bottomSheetRef}
<Text style={styles.title}> snapPoints={snapPoints}
Waypoint {index + 1}{waypoint.name ? `: ${waypoint.name}` : ""} enablePanDownToClose
</Text> onChange={handleSheetChange}
<Text style={styles.coords}> backgroundStyle={styles.background}
{waypoint.lat.toFixed(5)}, {waypoint.lon.toFixed(5)} handleIndicatorStyle={styles.handle}
</Text> >
<BottomSheetView style={styles.content}>
<TouchableOpacity <Text style={styles.title}>
style={styles.option} Waypoint {index + 1}{waypoint.name ? `: ${waypoint.name}` : ""}
onPress={() => { </Text>
onToggleOvernight(index); <Text style={styles.coords}>
onClose(); {waypoint.lat.toFixed(5)}, {waypoint.lon.toFixed(5)}
}}
>
<Text style={styles.optionIcon}>{waypoint.isDayBreak ? "☀️" : "🌙"}</Text>
<Text style={styles.optionText}>
{waypoint.isDayBreak ? "Remove overnight stop" : "Mark as overnight stop"}
</Text> </Text>
</TouchableOpacity>
<TouchableOpacity style={styles.option} onPress={handleDelete}> <TouchableOpacity
<Text style={styles.optionIcon}>🗑</Text> style={styles.option}
<Text style={[styles.optionText, styles.deleteText]}>Delete waypoint</Text> onPress={() => {
</TouchableOpacity> onToggleOvernight(index);
onClose();
}}
>
<Text style={styles.optionIcon}>{waypoint.isDayBreak ? "☀️" : "🌙"}</Text>
<Text style={styles.optionText}>
{waypoint.isDayBreak ? "Remove overnight stop" : "Mark as overnight stop"}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.closeButton} onPress={onClose}> <TouchableOpacity style={styles.option} onPress={handleDelete}>
<Text style={styles.closeText}>Close</Text> <Text style={styles.optionIcon}>🗑</Text>
</TouchableOpacity> <Text style={[styles.optionText, styles.deleteText]}>Delete waypoint</Text>
</View> </TouchableOpacity>
</BottomSheetView>
</BottomSheet>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { background: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
backgroundColor: "#fff", handle: { backgroundColor: "#d1d5db", width: 36 },
borderTopLeftRadius: 16, content: { padding: 16 },
borderTopRightRadius: 16,
padding: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5,
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: "#d1d5db",
alignSelf: "center",
marginBottom: 12,
},
title: { fontSize: 16, fontWeight: "600", color: "#111" }, title: { fontSize: 16, fontWeight: "600", color: "#111" },
coords: { fontSize: 12, color: "#999", marginTop: 2, marginBottom: 16 }, coords: { fontSize: 12, color: "#999", marginTop: 2, marginBottom: 16 },
option: { option: {
@ -104,12 +98,4 @@ const styles = StyleSheet.create({
optionIcon: { fontSize: 18, marginRight: 12 }, optionIcon: { fontSize: 18, marginRight: 12 },
optionText: { fontSize: 15, color: "#333" }, optionText: { fontSize: 15, color: "#333" },
deleteText: { color: "#dc2626" }, deleteText: { color: "#dc2626" },
closeButton: {
marginTop: 12,
alignItems: "center",
paddingVertical: 12,
backgroundColor: "#f3f4f6",
borderRadius: 8,
},
closeText: { fontSize: 15, color: "#666" },
}); });

View file

@ -28,6 +28,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@gorhom/bottom-sheet": "^5.2.9",
"@maplibre/maplibre-react-native": "^10.4.2", "@maplibre/maplibre-react-native": "^10.4.2",
"@sentry/cli": "^3.3.5", "@sentry/cli": "^3.3.5",
"@sentry/react-native": "~7.11.0", "@sentry/react-native": "~7.11.0",
@ -50,6 +51,8 @@
"expo-web-browser": "~55.0.14", "expo-web-browser": "~55.0.14",
"react": "catalog:", "react": "catalog:",
"react-native": "0.83.4", "react-native": "0.83.4",
"react-native-gesture-handler": "^2.31.1",
"react-native-reanimated": "^4.3.0",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"use-latest-callback": "^0.3.3", "use-latest-callback": "^0.3.3",
@ -57,10 +60,10 @@
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react-native": "^13.3.3", "@testing-library/react-native": "^13.3.3",
"react-test-renderer": "^19.2.5",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "~19.2.14", "@types/react": "~19.2.14",
"jest-expo": "^55.0.15", "jest-expo": "^55.0.15",
"react-test-renderer": "^19.2.5",
"typescript": "~5.9.2" "typescript": "~5.9.2"
} }
} }

896
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff