diff --git a/apps/mobile/app/routes/[id].tsx b/apps/mobile/app/routes/[id].tsx index e641fbe..4bc2026 100644 --- a/apps/mobile/app/routes/[id].tsx +++ b/apps/mobile/app/routes/[id].tsx @@ -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 ; +} + +function RouteDetailContent({ route }: { route: RouteDetail }) { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const [editing, setEditing] = useState(false); + const [selectedWaypoint, setSelectedWaypoint] = useState(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,158 @@ 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 ( {/* Header */} - router.back()} style={styles.headerBack}> + {route.name} + {editing ? ( + + + {editor.saving ? "Saving..." : "Save"} + + + ) : ( + setEditing(true)}> + Edit + + )} - - {/* Map placeholder */} - - Map - Requires dev build with MapLibre - + {editing ? ( + + editor.addWaypoint(lat, lon)} + onWaypointDragEnd={(i, lat, lon) => editor.moveWaypoint(i, lat, lon)} + onWaypointPress={(i) => setSelectedWaypoint(i)} + /> - {/* Stats */} - - {distance && ( - - {distance} - Distance + {editor.error && ( + + {editor.error} )} - {elevationGain && ( - - {elevationGain} - Gain - - )} - {elevationLoss && ( - - {elevationLoss} - Loss - - )} - {dayCount && ( - - {dayCount} - Days + + {selectedWaypoint !== null && editor.waypoints[selectedWaypoint] && ( + + setSelectedWaypoint(null)} + onDelete={editor.deleteWaypoint} + onToggleOvernight={editor.toggleOvernight} + /> )} - - {/* Description */} - {route.description ? ( - {route.description} - ) : null} - - {/* Version history */} - {route.versions.length > 0 && ( - - - {route.versions.length} version{route.versions.length !== 1 ? "s" : ""} - + ) : ( + + + {}} + onWaypointDragEnd={() => {}} + onWaypointPress={() => {}} + /> - )} - {/* Actions */} - - - Edit in Planner - - - Download Offline - - - + + {distance && {distance}Distance} + {elevationGain && {elevationGain}Gain} + {elevationLoss && {elevationLoss}Loss} + {dayCount && {dayCount}Days} + + + {route.description ? {route.description} : null} + + {route.versions.length > 0 && ( + + {route.versions.length} version{route.versions.length !== 1 ? "s" : ""} + + )} + + + setEditing(true)}> + Edit Route + + + Edit in Planner + + + + )} ); } @@ -137,53 +231,27 @@ 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 }, + sheetOverlay: { position: "absolute", bottom: 0, left: 0, right: 0 }, }); diff --git a/apps/mobile/lib/editor/RouteMap.tsx b/apps/mobile/lib/editor/RouteMap.tsx new file mode 100644 index 0000000..57171e1 --- /dev/null +++ b/apps/mobile/lib/editor/RouteMap.tsx @@ -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(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 ( + + + {bounds && ( + + )} + + {!bounds && ( + + )} + + {/* Route line */} + + + + + {/* Waypoint markers */} + {waypoints.map((wp, i) => ( + + onWaypointPress(i)} + /> + + ))} + + + {computing && ( + + Computing route... + + )} + + ); +} + +function WaypointMarker({ + index, + isDayBreak, + onPress, +}: { + index: number; + isDayBreak?: boolean; + onPress: () => void; +}) { + return ( + + {index + 1} + + ); +} + +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", + }, +}); diff --git a/apps/mobile/lib/editor/WaypointSheet.tsx b/apps/mobile/lib/editor/WaypointSheet.tsx new file mode 100644 index 0000000..03b7f9d --- /dev/null +++ b/apps/mobile/lib/editor/WaypointSheet.tsx @@ -0,0 +1,113 @@ +import { View, Text, TouchableOpacity, StyleSheet, Alert } from "react-native"; +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 handleDelete = () => { + Alert.alert( + "Delete Waypoint", + `Remove waypoint ${index + 1}${waypoint.name ? ` (${waypoint.name})` : ""}?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + onDelete(index); + onClose(); + }, + }, + ], + ); + }; + + return ( + + + + Waypoint {index + 1}{waypoint.name ? `: ${waypoint.name}` : ""} + + + {waypoint.lat.toFixed(5)}, {waypoint.lon.toFixed(5)} + + + { + onToggleOvernight(index); + onClose(); + }} + > + {waypoint.isDayBreak ? "☀️" : "🌙"} + + {waypoint.isDayBreak ? "Remove overnight stop" : "Mark as overnight stop"} + + + + + 🗑️ + Delete waypoint + + + + Close + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#fff", + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + padding: 16, + paddingBottom: 32, + 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" }, + 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" }, + closeButton: { + marginTop: 12, + alignItems: "center", + paddingVertical: 12, + backgroundColor: "#f3f4f6", + borderRadius: 8, + }, + closeText: { fontSize: 15, color: "#666" }, +}); diff --git a/apps/mobile/lib/editor/use-route-editor.ts b/apps/mobile/lib/editor/use-route-editor.ts new file mode 100644 index 0000000..5ba4475 --- /dev/null +++ b/apps/mobile/lib/editor/use-route-editor.ts @@ -0,0 +1,213 @@ +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(() => { + const waypoints = extractWaypoints(route); + 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 = /]*>([\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>/); + const typeMatch = inner.match(/([^<]*)<\/type>/); + wpts.push({ + lat, + lon, + name: nameMatch?.[1] ?? undefined, + isDayBreak: typeMatch?.[1] === "overnight" ? true : undefined, + }); + } + return wpts; + } 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); +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index c0155bb..dc95243 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,6 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"] } } diff --git a/openspec/changes/mobile-app/tasks.md b/openspec/changes/mobile-app/tasks.md index 8b06019..971d3f4 100644 --- a/openspec/changes/mobile-app/tasks.md +++ b/openspec/changes/mobile-app/tasks.md @@ -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