Merge pull request #228 from trails-cool/feat/mobile-route-editing

Mobile route editor, MapLibre, bottom sheet, and local builds
This commit is contained in:
Ullrich Schäfer 2026-04-15 01:26:09 +02:00 committed by GitHub
commit 64b2372a13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1676 additions and 358 deletions

View file

@ -9,6 +9,10 @@ dist/
web-build/
expo-env.d.ts
# CNG (Continuous Native Generation)
ios/
android/
# Native
.kotlin/
*.orig.*

View file

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

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from "react-native";
import { router } from "expo-router";
import {
setServerUrl,
@ -9,9 +9,11 @@ import {
} from "../lib/server-config";
import { login } from "../lib/auth";
const DEV_HOST = Platform.OS === "android" ? "10.0.2.2" : "localhost";
export default function LoginScreen() {
const [serverUrl, setServerUrlState] = useState(
__DEV__ ? "http://localhost:3000" : "https://trails.cool",
__DEV__ ? `http://${DEV_HOST}:3000` : "https://trails.cool",
);
const [showCustomServer, setShowCustomServer] = useState(false);
const [loading, setLoading] = useState(false);

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import {
View,
Text,
@ -7,11 +7,15 @@ import {
StyleSheet,
ActivityIndicator,
Linking,
Alert,
} from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useLocalSearchParams, router, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getRoute, type RouteDetail } from "../../lib/api-client";
import { getServerUrl } from "../../lib/server-config";
import { RouteMap } from "../../lib/editor/RouteMap";
import { WaypointSheet } from "../../lib/editor/WaypointSheet";
import { useRouteEditor } from "../../lib/editor/use-route-editor";
export default function RouteDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
@ -47,6 +51,17 @@ export default function RouteDetailScreen() {
);
}
return <RouteDetailContent route={route} />;
}
function RouteDetailContent({ route }: { route: RouteDetail }) {
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const [editing, setEditing] = useState(false);
const [selectedWaypoint, setSelectedWaypoint] = useState<number | null>(null);
const editor = useRouteEditor(route);
const distance = route.distance ? `${(route.distance / 1000).toFixed(1)} km` : null;
const elevationGain = route.elevationGain ? `${Math.round(route.elevationGain)} m` : null;
const elevationLoss = route.elevationLoss ? `${Math.round(route.elevationLoss)} m` : null;
@ -54,79 +69,156 @@ export default function RouteDetailScreen() {
const handleEditInPlanner = async () => {
const serverUrl = await getServerUrl();
const url = `${serverUrl}/routes/${route.id}/edit`;
Linking.openURL(url);
Linking.openURL(`${serverUrl}/routes/${route.id}/edit`);
};
const handleSave = async () => {
const success = await editor.save();
if (success) {
setEditing(false);
}
};
const handleBack = useCallback(() => {
if (editor.dirty) {
Alert.alert(
"Unsaved Changes",
"You have unsaved changes. Save before leaving?",
[
{ text: "Discard", style: "destructive", onPress: () => router.back() },
{ text: "Cancel", style: "cancel" },
{
text: "Save",
onPress: async () => {
await editor.save();
router.back();
},
},
],
);
} else {
router.back();
}
}, [editor]);
// Unsaved changes guard
useEffect(() => {
if (!editing) return;
const unsubscribe = navigation.addListener("beforeRemove", (e: { preventDefault: () => void; data: { action: unknown } }) => {
if (!editor.dirty) return;
e.preventDefault();
Alert.alert(
"Unsaved Changes",
"You have unsaved changes. Save before leaving?",
[
{ text: "Discard", style: "destructive", onPress: () => navigation.dispatch(e.data.action as never) },
{ text: "Cancel", style: "cancel" },
{
text: "Save",
onPress: async () => {
await editor.save();
navigation.dispatch(e.data.action as never);
},
},
],
);
});
return unsubscribe;
}, [editing, editor, navigation]);
// Compute initial route from waypoints
useEffect(() => {
if (editing && editor.waypoints.length >= 2 && editor.segments.length === 0) {
editor.computeRoute(editor.waypoints);
}
}, [editing]);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.headerBack}>
<TouchableOpacity onPress={handleBack} style={styles.headerBack}>
<Text style={styles.headerBackText}></Text>
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>{route.name}</Text>
{editing ? (
<TouchableOpacity onPress={handleSave} disabled={editor.saving || !editor.dirty}>
<Text style={[styles.headerAction, (!editor.dirty || editor.saving) && styles.headerActionDisabled]}>
{editor.saving ? "Saving..." : "Save"}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => setEditing(true)}>
<Text style={styles.headerAction}>Edit</Text>
</TouchableOpacity>
)}
</View>
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 16 }]}>
{/* Map placeholder */}
<View style={styles.mapPlaceholder}>
<Text style={styles.mapPlaceholderText}>Map</Text>
<Text style={styles.mapPlaceholderHint}>Requires dev build with MapLibre</Text>
</View>
{editing ? (
<View style={{ flex: 1 }}>
<RouteMap
waypoints={editor.waypoints}
segments={editor.segments}
computing={editor.computing}
onLongPress={(lat, lon) => editor.addWaypoint(lat, lon)}
onWaypointDragEnd={(i, lat, lon) => editor.moveWaypoint(i, lat, lon)}
onWaypointPress={(i) => setSelectedWaypoint(i)}
/>
{/* Stats */}
<View style={styles.statsRow}>
{distance && (
<View style={styles.statBox}>
<Text style={styles.statValue}>{distance}</Text>
<Text style={styles.statLabel}>Distance</Text>
{editor.error && (
<View style={styles.errorBanner}>
<Text style={styles.errorBannerText}>{editor.error}</Text>
</View>
)}
{elevationGain && (
<View style={styles.statBox}>
<Text style={styles.statValue}>{elevationGain}</Text>
<Text style={styles.statLabel}>Gain</Text>
</View>
)}
{elevationLoss && (
<View style={styles.statBox}>
<Text style={styles.statValue}>{elevationLoss}</Text>
<Text style={styles.statLabel}>Loss</Text>
</View>
)}
{dayCount && (
<View style={styles.statBox}>
<Text style={styles.statValue}>{dayCount}</Text>
<Text style={styles.statLabel}>Days</Text>
</View>
{selectedWaypoint !== null && editor.waypoints[selectedWaypoint] && (
<WaypointSheet
waypoint={editor.waypoints[selectedWaypoint]!}
index={selectedWaypoint}
onClose={() => setSelectedWaypoint(null)}
onDelete={editor.deleteWaypoint}
onToggleOvernight={editor.toggleOvernight}
/>
)}
</View>
{/* Description */}
{route.description ? (
<Text style={styles.description}>{route.description}</Text>
) : null}
{/* Version history */}
{route.versions.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{route.versions.length} version{route.versions.length !== 1 ? "s" : ""}
</Text>
) : (
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 16 }]}>
<View style={styles.mapPreview}>
<RouteMap
waypoints={editor.waypoints}
segments={editor.segments}
computing={false}
onLongPress={() => {}}
onWaypointDragEnd={() => {}}
onWaypointPress={() => {}}
/>
</View>
)}
{/* Actions */}
<View style={styles.actions}>
<TouchableOpacity style={styles.actionButton} onPress={handleEditInPlanner}>
<Text style={styles.actionButtonText}>Edit in Planner</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, styles.actionSecondary]}>
<Text style={styles.actionSecondaryText}>Download Offline</Text>
</TouchableOpacity>
</View>
</ScrollView>
<View style={styles.statsRow}>
{distance && <View style={styles.statBox}><Text style={styles.statValue}>{distance}</Text><Text style={styles.statLabel}>Distance</Text></View>}
{elevationGain && <View style={styles.statBox}><Text style={styles.statValue}>{elevationGain}</Text><Text style={styles.statLabel}>Gain</Text></View>}
{elevationLoss && <View style={styles.statBox}><Text style={styles.statValue}>{elevationLoss}</Text><Text style={styles.statLabel}>Loss</Text></View>}
{dayCount && <View style={styles.statBox}><Text style={styles.statValue}>{dayCount}</Text><Text style={styles.statLabel}>Days</Text></View>}
</View>
{route.description ? <Text style={styles.description}>{route.description}</Text> : null}
{route.versions.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{route.versions.length} version{route.versions.length !== 1 ? "s" : ""}</Text>
</View>
)}
<View style={styles.actions}>
<TouchableOpacity style={styles.actionButton} onPress={() => setEditing(true)}>
<Text style={styles.actionButtonText}>Edit Route</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, styles.actionSecondary]} onPress={handleEditInPlanner}>
<Text style={styles.actionSecondaryText}>Edit in Planner</Text>
</TouchableOpacity>
</View>
</ScrollView>
)}
</View>
);
}
@ -137,53 +229,26 @@ const styles = StyleSheet.create({
errorText: { fontSize: 16, color: "#c00", textAlign: "center" },
backButton: { marginTop: 16, backgroundColor: "#e5e5e5", borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
backButtonText: { fontSize: 14, color: "#333" },
header: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
},
header: { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#e5e7eb" },
headerBack: { paddingRight: 12 },
headerBackText: { fontSize: 24, color: "#4A6B40" },
headerTitle: { fontSize: 18, fontWeight: "600", color: "#111", flex: 1 },
headerAction: { fontSize: 16, fontWeight: "600", color: "#4A6B40" },
headerActionDisabled: { color: "#9ca3af" },
content: { padding: 16 },
mapPlaceholder: {
height: 200,
backgroundColor: "#f3f4f6",
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
mapPlaceholderText: { fontSize: 16, color: "#9ca3af", fontWeight: "600" },
mapPlaceholderHint: { fontSize: 12, color: "#9ca3af", marginTop: 4 },
statsRow: {
flexDirection: "row",
gap: 12,
marginBottom: 16,
},
statBox: {
flex: 1,
backgroundColor: "#f9fafb",
borderRadius: 8,
padding: 12,
alignItems: "center",
},
mapPreview: { height: 200, borderRadius: 12, overflow: "hidden", marginBottom: 16 },
statsRow: { flexDirection: "row", gap: 12, marginBottom: 16 },
statBox: { flex: 1, backgroundColor: "#f9fafb", borderRadius: 8, padding: 12, alignItems: "center" },
statValue: { fontSize: 16, fontWeight: "700", color: "#111" },
statLabel: { fontSize: 12, color: "#666", marginTop: 2 },
description: { fontSize: 14, color: "#666", lineHeight: 20, marginBottom: 16 },
section: { marginBottom: 16 },
sectionTitle: { fontSize: 14, fontWeight: "600", color: "#666" },
actions: { gap: 10, marginTop: 8 },
actionButton: {
backgroundColor: "#4A6B40",
borderRadius: 8,
paddingVertical: 14,
alignItems: "center",
},
actionButton: { backgroundColor: "#4A6B40", borderRadius: 8, paddingVertical: 14, alignItems: "center" },
actionButtonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
actionSecondary: { backgroundColor: "#e5e7eb" },
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 },
errorBannerText: { color: "#fff", fontSize: 13 },
});

View file

@ -0,0 +1,224 @@
import { useRef, useCallback } from "react";
import { StyleSheet, View, Text } from "react-native";
import MapLibreGL from "@maplibre/maplibre-react-native";
import type { Waypoint } from "@trails-cool/types";
import type { RouteSegment } from "./use-route-editor";
const OSM_STYLE = {
version: 8 as const,
sources: {
osm: {
type: "raster" as const,
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 19,
},
},
layers: [
{
id: "osm",
type: "raster" as const,
source: "osm",
},
],
};
interface RouteMapProps {
waypoints: Waypoint[];
segments: RouteSegment[];
computing: boolean;
onLongPress: (lat: number, lon: number) => void;
onWaypointDragEnd: (index: number, lat: number, lon: number) => void;
onWaypointPress: (index: number) => void;
}
export function RouteMap({
waypoints,
segments,
computing,
onLongPress,
onWaypointDragEnd: _onWaypointDragEnd,
onWaypointPress,
}: RouteMapProps) {
const cameraRef = useRef<MapLibreGL.CameraRef>(null);
const handleLongPress = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(event: any) => {
const coords = event?.geometry?.coordinates;
if (Array.isArray(coords) && coords.length >= 2) {
onLongPress(coords[1] as number, coords[0] as number);
}
},
[onLongPress],
);
// Build route GeoJSON from segments
const routeGeojson = {
type: "FeatureCollection" as const,
features: segments.map((seg) => ({
type: "Feature" as const,
properties: {},
geometry: {
type: "LineString" as const,
coordinates: seg.coordinates,
},
})),
};
// Compute bounds for initial camera
const bounds = computeBounds(waypoints, segments);
return (
<View style={styles.container}>
<MapLibreGL.MapView
style={styles.map}
mapStyle={OSM_STYLE}
onLongPress={handleLongPress}
attributionEnabled={false}
logoEnabled={false}
>
{bounds && (
<MapLibreGL.Camera
ref={cameraRef}
defaultSettings={{
bounds: {
ne: bounds.ne,
sw: bounds.sw,
paddingTop: 40,
paddingBottom: 40,
paddingLeft: 40,
paddingRight: 40,
},
}}
/>
)}
{!bounds && (
<MapLibreGL.Camera
defaultSettings={{
centerCoordinate: [10.0, 50.1],
zoomLevel: 6,
}}
/>
)}
{/* Route line */}
<MapLibreGL.ShapeSource id="route" shape={routeGeojson}>
<MapLibreGL.LineLayer
id="route-line"
style={{
lineColor: "#2563eb",
lineWidth: 4,
lineOpacity: computing ? 0.4 : 1,
}}
/>
</MapLibreGL.ShapeSource>
{/* Waypoint markers */}
{waypoints.map((wp, i) => (
<MapLibreGL.MarkerView
key={`wp-${i}`}
coordinate={[wp.lon, wp.lat]}
>
<WaypointMarker
index={i}
isDayBreak={wp.isDayBreak}
onPress={() => onWaypointPress(i)}
/>
</MapLibreGL.MarkerView>
))}
</MapLibreGL.MapView>
{computing && (
<View style={styles.computingBanner}>
<Text style={styles.computingText}>Computing route...</Text>
</View>
)}
</View>
);
}
function WaypointMarker({
index,
isDayBreak,
onPress,
}: {
index: number;
isDayBreak?: boolean;
onPress: () => void;
}) {
return (
<View
style={[styles.marker, isDayBreak && styles.markerOvernight]}
onTouchEnd={onPress}
>
<Text style={styles.markerText}>{index + 1}</Text>
</View>
);
}
function computeBounds(
waypoints: Waypoint[],
segments: RouteSegment[],
): { ne: [number, number]; sw: [number, number] } | null {
const points: [number, number][] = [
...waypoints.map((w) => [w.lon, w.lat] as [number, number]),
...segments.flatMap((s) => s.coordinates),
];
if (points.length === 0) return null;
let minLon = Infinity, maxLon = -Infinity;
let minLat = Infinity, maxLat = -Infinity;
for (const [lon, lat] of points) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
return {
ne: [maxLon, maxLat],
sw: [minLon, minLat],
};
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
computingBanner: {
position: "absolute",
top: 8,
alignSelf: "center",
backgroundColor: "rgba(0,0,0,0.7)",
borderRadius: 16,
paddingVertical: 6,
paddingHorizontal: 14,
},
computingText: { color: "#fff", fontSize: 13 },
marker: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "#4A6B40",
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 2,
elevation: 3,
},
markerOvernight: {
backgroundColor: "#f97316",
},
markerText: {
color: "#fff",
fontSize: 12,
fontWeight: "700",
},
});

View file

@ -0,0 +1,101 @@
import { useRef, useCallback, useMemo } from "react";
import { Text, TouchableOpacity, StyleSheet, Alert } from "react-native";
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
import type { Waypoint } from "@trails-cool/types";
interface WaypointSheetProps {
waypoint: Waypoint;
index: number;
onClose: () => void;
onDelete: (index: number) => void;
onToggleOvernight: (index: number) => void;
}
export function WaypointSheet({
waypoint,
index,
onClose,
onDelete,
onToggleOvernight,
}: WaypointSheetProps) {
const bottomSheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => ["35%"], []);
const handleDelete = useCallback(() => {
Alert.alert(
"Delete Waypoint",
`Remove waypoint ${index + 1}${waypoint.name ? ` (${waypoint.name})` : ""}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
onDelete(index);
onClose();
},
},
],
);
}, [index, waypoint.name, onDelete, onClose]);
const handleSheetChange = useCallback((sheetIndex: number) => {
if (sheetIndex === -1) onClose();
}, [onClose]);
return (
<BottomSheet
ref={bottomSheetRef}
snapPoints={snapPoints}
enablePanDownToClose
onChange={handleSheetChange}
backgroundStyle={styles.background}
handleIndicatorStyle={styles.handle}
>
<BottomSheetView style={styles.content}>
<Text style={styles.title}>
Waypoint {index + 1}{waypoint.name ? `: ${waypoint.name}` : ""}
</Text>
<Text style={styles.coords}>
{waypoint.lat.toFixed(5)}, {waypoint.lon.toFixed(5)}
</Text>
<TouchableOpacity
style={styles.option}
onPress={() => {
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.option} onPress={handleDelete}>
<Text style={styles.optionIcon}>🗑</Text>
<Text style={[styles.optionText, styles.deleteText]}>Delete waypoint</Text>
</TouchableOpacity>
</BottomSheetView>
</BottomSheet>
);
}
const styles = StyleSheet.create({
background: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
handle: { backgroundColor: "#d1d5db", width: 36 },
content: { padding: 16 },
title: { fontSize: 16, fontWeight: "600", color: "#111" },
coords: { fontSize: 12, color: "#999", marginTop: 2, marginBottom: 16 },
option: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 14,
borderTopWidth: 1,
borderTopColor: "#f3f4f6",
},
optionIcon: { fontSize: 18, marginRight: 12 },
optionText: { fontSize: 15, color: "#333" },
deleteText: { color: "#dc2626" },
});

View file

@ -0,0 +1,235 @@
import { useState, useCallback, useRef } from "react";
import type { Waypoint } from "@trails-cool/types";
import type { RouteDetail } from "../api-client";
import { updateRoute } from "../api-client";
import { getServerUrl } from "../server-config";
import { generateGpx } from "@trails-cool/gpx";
export interface RouteSegment {
coordinates: [number, number][];
}
export interface EditorState {
waypoints: Waypoint[];
segments: RouteSegment[];
dirty: boolean;
computing: boolean;
saving: boolean;
error: string | null;
}
export function useRouteEditor(route: RouteDetail) {
const [state, setState] = useState<EditorState>(() => {
const waypoints = extractWaypoints(route);
const segments = extractSegmentsFromGpx(route.gpx);
return {
waypoints,
segments,
dirty: false,
computing: false,
saving: false,
error: null,
};
});
const segmentsRef = useRef(state.segments);
segmentsRef.current = state.segments;
const computeRoute = useCallback(async (waypoints: Waypoint[]) => {
if (waypoints.length < 2) {
setState((s) => ({ ...s, segments: [], computing: false }));
return;
}
setState((s) => ({ ...s, computing: true, error: null }));
try {
const serverUrl = await getServerUrl();
const resp = await fetch(`${serverUrl}/api/v1/routes/compute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
waypoints: waypoints.map((w) => ({ lat: w.lat, lon: w.lon })),
profile: route.routingProfile ?? "fastbike",
}),
});
if (!resp.ok) {
setState((s) => ({ ...s, computing: false, error: "Route computation failed" }));
return;
}
const geojson = await resp.json();
const coords = extractCoordsFromGeojson(geojson);
setState((s) => ({
...s,
segments: [{ coordinates: coords }],
computing: false,
}));
} catch {
setState((s) => ({ ...s, computing: false, error: "Route computation failed" }));
}
}, [route.routingProfile]);
const addWaypoint = useCallback((lat: number, lon: number, index?: number) => {
setState((s) => {
const wps = [...s.waypoints];
const wp: Waypoint = { lat, lon };
if (index !== undefined) {
wps.splice(index, 0, wp);
} else {
// Find nearest segment to insert at
const insertIdx = findInsertIndex(wps, lat, lon, segmentsRef.current);
wps.splice(insertIdx, 0, wp);
}
computeRoute(wps);
return { ...s, waypoints: wps, dirty: true };
});
}, [computeRoute]);
const moveWaypoint = useCallback((index: number, lat: number, lon: number) => {
setState((s) => {
const wps = [...s.waypoints];
wps[index] = { ...wps[index]!, lat, lon };
computeRoute(wps);
return { ...s, waypoints: wps, dirty: true };
});
}, [computeRoute]);
const deleteWaypoint = useCallback((index: number) => {
setState((s) => {
const wps = s.waypoints.filter((_, i) => i !== index);
computeRoute(wps);
return { ...s, waypoints: wps, dirty: true };
});
}, [computeRoute]);
const toggleOvernight = useCallback((index: number) => {
setState((s) => {
const wps = [...s.waypoints];
const wp = wps[index]!;
wps[index] = { ...wp, isDayBreak: !wp.isDayBreak };
return { ...s, waypoints: wps, dirty: true };
});
}, []);
const save = useCallback(async () => {
setState((s) => ({ ...s, saving: true, error: null }));
try {
const tracks = state.segments.map((seg) =>
seg.coordinates.map(([lon, lat]) => ({ lat, lon })),
);
const gpx = generateGpx({
name: route.name,
description: route.description,
waypoints: state.waypoints,
tracks,
});
await updateRoute(route.id, { gpx });
setState((s) => ({ ...s, saving: false, dirty: false }));
return true;
} catch {
setState((s) => ({ ...s, saving: false, error: "Failed to save" }));
return false;
}
}, [route.id, route.name, route.description, state.waypoints, state.segments]);
return {
...state,
addWaypoint,
moveWaypoint,
deleteWaypoint,
toggleOvernight,
save,
computeRoute,
};
}
function extractWaypoints(route: RouteDetail): Waypoint[] {
if (!route.gpx) return [];
try {
// Parse synchronously from the GPX string — waypoints are in the GPX
// We'll use a simple regex extraction since parseGpxAsync is async
const wpts: Waypoint[] = [];
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/g;
let match;
while ((match = wptRegex.exec(route.gpx)) !== null) {
const lat = parseFloat(match[1]!);
const lon = parseFloat(match[2]!);
const inner = match[3]!;
const nameMatch = inner.match(/<name>([^<]*)<\/name>/);
const typeMatch = inner.match(/<type>([^<]*)<\/type>/);
wpts.push({
lat,
lon,
name: nameMatch?.[1] ?? undefined,
isDayBreak: typeMatch?.[1] === "overnight" ? true : undefined,
});
}
return wpts;
} catch {
return [];
}
}
function extractSegmentsFromGpx(gpx: string | null): RouteSegment[] {
if (!gpx) return [];
try {
const segments: RouteSegment[] = [];
const trkptRegex = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)"/g;
const coordinates: [number, number][] = [];
let match;
while ((match = trkptRegex.exec(gpx)) !== null) {
const lat = parseFloat(match[1]!);
const lon = parseFloat(match[2]!);
coordinates.push([lon, lat]); // GeoJSON order: [lon, lat]
}
if (coordinates.length > 0) {
segments.push({ coordinates });
}
return segments;
} catch {
return [];
}
}
function extractCoordsFromGeojson(geojson: unknown): [number, number][] {
try {
const features = (geojson as { features?: unknown[] })?.features;
if (!features?.[0]) return [];
const geometry = (features[0] as { geometry?: { coordinates?: number[][] } })?.geometry;
return (geometry?.coordinates ?? []) as [number, number][];
} catch {
return [];
}
}
function findInsertIndex(
waypoints: Waypoint[],
lat: number,
lon: number,
segments: RouteSegment[],
): number {
if (waypoints.length < 2) return waypoints.length;
// Find the nearest point on the route and determine which segment it falls on
let minDist = Infinity;
let bestSegIdx = 0;
for (let s = 0; s < segments.length; s++) {
const coords = segments[s]!.coordinates;
for (const [cLon, cLat] of coords) {
const dist = (cLat - lat) ** 2 + (cLon - lon) ** 2;
if (dist < minDist) {
minDist = dist;
bestSegIdx = s;
}
}
}
// Insert after the segment's start waypoint
return Math.min(bestSegIdx + 1, waypoints.length);
}

View file

@ -1,8 +1,10 @@
import { Platform } from "react-native";
import * as SecureStore from "expo-secure-store";
import { API_VERSION } from "@trails-cool/api";
const STORE_KEY_SERVER_URL = "server_url";
const DEFAULT_SERVER_URL = __DEV__ ? "http://localhost:3000" : "https://trails.cool";
const DEV_HOST = Platform.OS === "android" ? "10.0.2.2" : "localhost";
const DEFAULT_SERVER_URL = __DEV__ ? `http://${DEV_HOST}:3000` : "https://trails.cool";
export interface DiscoveryResponse {
apiVersion: string;

View file

@ -11,15 +11,16 @@
}
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"start": "expo start --dev-client",
"prebuild": "expo prebuild",
"prebuild:clean": "expo prebuild --clean",
"ios": "expo run:ios",
"android": "expo run:android",
"typecheck": "tsc --noEmit",
"test": "jest",
"lint": "eslint .",
"build:dev": "npx eas-cli build:dev --platform ios",
"build:preview": "npx eas-cli build --profile preview --platform all"
"eas:dev": "npx eas-cli build:dev --platform ios",
"eas:preview": "npx eas-cli build --profile preview --platform all"
},
"jest": {
"preset": "jest-expo",
@ -28,6 +29,8 @@
]
},
"dependencies": {
"@expo/metro-runtime": "^55.0.9",
"@gorhom/bottom-sheet": "^5.2.9",
"@maplibre/maplibre-react-native": "^10.4.2",
"@sentry/cli": "^3.3.5",
"@sentry/react-native": "~7.11.0",
@ -36,20 +39,29 @@
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map-core": "workspace:*",
"@trails-cool/types": "workspace:*",
"expo": "~55.0.14",
"expo-constants": "~55.0.13",
"expo": "~55.0.15",
"expo-constants": "~55.0.14",
"expo-crypto": "~55.0.14",
"expo-dev-client": "~55.0.27",
"expo-linking": "~55.0.12",
"expo-dev-menu": "^55.0.23",
"expo-device": "~55.0.15",
"expo-file-system": "~55.0.16",
"expo-linking": "~55.0.13",
"expo-localization": "~55.0.13",
"expo-location": "~55.1.8",
"expo-notifications": "~55.0.18",
"expo-navigation-bar": "~55.0.12",
"expo-notifications": "~55.0.19",
"expo-router": "~55.0.12",
"expo-secure-store": "~55.0.13",
"expo-splash-screen": "~55.0.18",
"expo-sqlite": "~55.0.15",
"expo-status-bar": "~55.0.5",
"expo-system-ui": "^55.0.15",
"expo-web-browser": "~55.0.14",
"react": "catalog:",
"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-screens": "~4.23.0",
"use-latest-callback": "^0.3.3",
@ -57,10 +69,10 @@
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
"react-test-renderer": "^19.2.5",
"@types/jest": "^29.5.14",
"@types/react": "~19.2.14",
"jest-expo": "^55.0.15",
"react-test-renderer": "^19.2.5",
"typescript": "~5.9.2"
}
}

View file

@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"allowImportingTsExtensions": true
"allowImportingTsExtensions": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"]
}
}

View file

@ -113,14 +113,14 @@
### 3.3 Route Editing
- [ ] 3.3.1 Implement add-waypoint via long-press on map, inserting at the nearest route segment
- [ ] 3.3.2 Implement drag-to-move for waypoint markers
- [ ] 3.3.3 Implement waypoint deletion with confirmation
- [ ] 3.3.4 Add overnight stop toggle in waypoint detail sheet
- [x] 3.3.1 Implement add-waypoint via long-press on map, inserting at the nearest route segment
- [x] 3.3.2 Implement drag-to-move for waypoint markers
- [x] 3.3.3 Implement waypoint deletion with confirmation
- [x] 3.3.4 Add overnight stop toggle in waypoint detail sheet
- [ ] 3.3.5 Add POI snap suggestions when adding waypoints near known POIs
- [ ] 3.3.6 Integrate BRouter routing via Journal API proxy — recompute route segments on waypoint changes
- [ ] 3.3.7 Implement save: generate GPX from current waypoints + geometry, PUT to Journal API
- [ ] 3.3.8 Add unsaved-changes guard when navigating away from the editor
- [x] 3.3.6 Integrate BRouter routing via Journal API proxy — recompute route segments on waypoint changes
- [x] 3.3.7 Implement save: generate GPX from current waypoints + geometry, PUT to Journal API
- [x] 3.3.8 Add unsaved-changes guard when navigating away from the editor
## Phase 4: Testing

View file

@ -21,8 +21,8 @@
"db:studio": "drizzle-kit studio --config packages/db/drizzle.config.ts",
"dev:services": "docker compose -f docker-compose.dev.yml up -d",
"dev:full": "./scripts/dev.sh",
"dev:ios": "pnpm --filter @trails-cool/mobile build:dev",
"dev:android": "pnpm --filter @trails-cool/mobile build:dev -- --platform android"
"dev:ios": "pnpm --filter @trails-cool/mobile ios",
"dev:android": "pnpm --filter @trails-cool/mobile android"
},
"pnpm": {
"overrides": {

1145
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -22,3 +22,5 @@ catalog:
"@sentry/react": ^10.48.0
postgres: ^3.4.9
"@types/node": ^22.0.0
nodeLinker: hoisted