From c5cb39c27b7ea611e387435dc8d6b31da950ceee Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 12:41:57 +0100 Subject: [PATCH] feat(mobile): one Maps button for every map app, actions as one glass group The day plan offered Google Maps and CoMaps as two tiles. One Maps button now opens the day in whichever app the traveller picks: Google Maps, Apple Maps with the stops between as waypoints, CoMaps, Waze and on Android any other map app, the last two to the first stop, which they say. The preferred map app from the settings opens straight away. The day's actions sit together on one glass surface as round icons over their labels, and the up-next card wears the same surface as the timeline card under it. --- .../components/Planner/dayRouteApps.test.ts | 49 +++++++++++ client/src/components/Planner/dayRouteApps.ts | 87 +++++++++++++++++++ .../screens/trip/plan/MPlanTimeline.tsx | 57 +++++++----- .../screens/trip/plan/MRouteAppsSheet.tsx | 63 ++++++++++++++ .../screens/trip/plan/useMPlanTimeline.ts | 14 ++- .../screens/trip/roadtrip/MRoadtripTab.tsx | 2 +- .../unit/mobile/trip/MPlanTimeline.test.tsx | 43 +++++++-- .../unit/mobile/trip/useMPlanTimeline.test.ts | 7 ++ shared/src/i18n/ar/mobileTrip.ts | 3 + shared/src/i18n/az/mobileTrip.ts | 3 + shared/src/i18n/br/mobileTrip.ts | 3 + shared/src/i18n/ca/mobileTrip.ts | 3 + shared/src/i18n/cs/mobileTrip.ts | 3 + shared/src/i18n/de/mobileTrip.ts | 3 + shared/src/i18n/en/mobileTrip.ts | 3 + shared/src/i18n/es/mobileTrip.ts | 3 + shared/src/i18n/et/mobileTrip.ts | 3 + shared/src/i18n/fr/mobileTrip.ts | 3 + shared/src/i18n/gr/mobileTrip.ts | 3 + shared/src/i18n/he/mobileTrip.ts | 3 + shared/src/i18n/hu/mobileTrip.ts | 3 + shared/src/i18n/id/mobileTrip.ts | 3 + shared/src/i18n/it/mobileTrip.ts | 3 + shared/src/i18n/ja/mobileTrip.ts | 3 + shared/src/i18n/ko/mobileTrip.ts | 3 + shared/src/i18n/nl/mobileTrip.ts | 3 + shared/src/i18n/pl/mobileTrip.ts | 3 + shared/src/i18n/ru/mobileTrip.ts | 3 + shared/src/i18n/sk/mobileTrip.ts | 3 + shared/src/i18n/sv/mobileTrip.ts | 3 + shared/src/i18n/th/mobileTrip.ts | 3 + shared/src/i18n/tr/mobileTrip.ts | 3 + shared/src/i18n/uk/mobileTrip.ts | 3 + shared/src/i18n/vi/mobileTrip.ts | 3 + shared/src/i18n/zh-TW/mobileTrip.ts | 3 + shared/src/i18n/zh/mobileTrip.ts | 3 + 36 files changed, 378 insertions(+), 28 deletions(-) create mode 100644 client/src/components/Planner/dayRouteApps.test.ts create mode 100644 client/src/components/Planner/dayRouteApps.ts create mode 100644 client/src/mobile/screens/trip/plan/MRouteAppsSheet.tsx diff --git a/client/src/components/Planner/dayRouteApps.test.ts b/client/src/components/Planner/dayRouteApps.test.ts new file mode 100644 index 000000000..44eb9aecc --- /dev/null +++ b/client/src/components/Planner/dayRouteApps.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { dayRouteTargets, generateAppleMapsUrl, generateGeoUrl, generateWazeUrl } from './dayRouteApps'; + +const A = { lat: 35.71, lng: 139.79, name: 'Senso-ji' }; +const B = { lat: 35.72, lng: 139.77, name: 'Ueno Park' }; +const C = { lat: 35.66, lng: 139.7, name: 'Shibuya' }; + +function userAgent(ua: string) { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(ua); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('dayRouteApps', () => { + it('opens one stop in Apple Maps as a named pin', () => { + expect(generateAppleMapsUrl([A])).toBe('https://maps.apple.com/?ll=35.71,139.79&q=Senso-ji'); + }); + + it('hands Apple Maps the whole day, the stops between as waypoints, in the travel mode', () => { + expect(generateAppleMapsUrl([A, B, C], 'walking')).toBe( + 'https://maps.apple.com/directions?source=35.71,139.79&destination=35.66,139.7&waypoint=35.72,139.77&mode=walking' + ); + }); + + it('sends Waze and geo: to the first stop, since they take one place', () => { + expect(generateWazeUrl([A, B])).toBe('https://waze.com/ul?ll=35.71,139.79&navigate=yes'); + expect(generateGeoUrl([A, B])).toBe('geo:35.71,139.79?q=35.71,139.79(Senso-ji)'); + expect(generateWazeUrl([])).toBeNull(); + }); + + it('offers Apple Maps on an iPhone and marks the one-place apps for a multi-stop day', () => { + userAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)'); + const targets = dayRouteTargets([A, B], 'driving'); + expect(targets.map((t) => t.id)).toEqual(['google', 'apple', 'comaps', 'waze']); + expect(targets.find((t) => t.id === 'waze')?.firstStopOnly).toBe(true); + expect(targets.find((t) => t.id === 'google')?.firstStopOnly).toBeUndefined(); + }); + + it('offers the generic map-app link on Android instead of Apple Maps', () => { + userAgent('Mozilla/5.0 (Linux; Android 15)'); + expect(dayRouteTargets([A], 'driving').map((t) => t.id)).toEqual(['google', 'comaps', 'waze', 'geo']); + // A single stop is the whole day, so nothing is cut short. + expect(dayRouteTargets([A], 'driving').some((t) => t.firstStopOnly)).toBe(false); + }); + + it('offers nothing for a day without a located stop', () => { + expect(dayRouteTargets([], 'driving')).toEqual([]); + }); +}); diff --git a/client/src/components/Planner/dayRouteApps.ts b/client/src/components/Planner/dayRouteApps.ts new file mode 100644 index 000000000..9501eaf4d --- /dev/null +++ b/client/src/components/Planner/dayRouteApps.ts @@ -0,0 +1,87 @@ +import { + generateCoMapsUrl, + generateGoogleMapsUrl, + type NamedWaypoint, + type RouteProfileKey, +} from '../Map/RouteCalculator'; +import { showsAppleMaps, showsGeoUri, type NavigationAppId } from './placeNavigation'; + +/** + * The map apps a day's route can be handed to, behind the plan's one Maps + * button. Same stops as every other export (dayExportStops), so the app shows + * the day the plan shows. + * + * Not every app takes a whole route. Google Maps, Apple Maps and CoMaps do (each + * in its own way, see the builders); Waze and the generic geo: link navigate to + * one place only, so they lead to the first stop and say so rather than quietly + * dropping the rest of the day. + */ +export interface DayRouteTarget { + id: NavigationAppId; + /** Product name, never translated; the generic entry carries labelKey instead. */ + label: string; + labelKey?: string; + url: string; + /** True when the app only gets the first stop, not the whole route. */ + firstStopOnly?: boolean; +} + +/** Apple Maps' directions modes. Cycling exists there; anything else drives. */ +function appleMode(profile: RouteProfileKey): string { + if (profile === 'walking') return 'walking'; + if (profile === 'cycling') return 'cycling'; + return 'driving'; +} + +const point = (p: NamedWaypoint) => `${p.lat},${p.lng}`; +const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || point(p)); + +/** + * Apple Maps for a day: one stop opens as a pin with its name, more go as + * directions with the stops between as waypoints (Apple's unified Maps URLs, + * which iOS, macOS and the web version all read). + */ +export function generateAppleMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null { + const valid = places.filter((p) => p.lat != null && p.lng != null); + if (valid.length === 0) return null; + if (valid.length === 1) return `https://maps.apple.com/?ll=${point(valid[0])}&q=${label(valid[0])}`; + const [first, ...rest] = valid; + const last = rest[rest.length - 1]; + const between = rest + .slice(0, -1) + .map((p) => `&waypoint=${point(p)}`) + .join(''); + return `https://maps.apple.com/directions?source=${point(first)}&destination=${point(last)}${between}&mode=${appleMode(profile)}`; +} + +/** Waze navigates to one place; the day's first stop is where the drive starts. */ +export function generateWazeUrl(places: NamedWaypoint[]): string | null { + const first = places.find((p) => p.lat != null && p.lng != null); + return first ? `https://waze.com/ul?ll=${point(first)}&navigate=yes` : null; +} + +/** The generic Android link, handed to whichever map app is installed; one place too. */ +export function generateGeoUrl(places: NamedWaypoint[]): string | null { + const first = places.find((p) => p.lat != null && p.lng != null); + return first ? `geo:${point(first)}?q=${point(first)}(${label(first)})` : null; +} + +/** Every app the day can be opened in on this device, in the order they are offered. */ +export function dayRouteTargets(stops: NamedWaypoint[], profile: RouteProfileKey): DayRouteTarget[] { + const out: DayRouteTarget[] = []; + const push = (target: Omit, url: string | null) => { + if (url) out.push({ ...target, url }); + }; + push({ id: 'google', label: 'Google Maps' }, generateGoogleMapsUrl(stops)); + if (showsAppleMaps()) push({ id: 'apple', label: 'Apple Maps' }, generateAppleMapsUrl(stops, profile)); + push({ id: 'comaps', label: 'CoMaps' }, generateCoMapsUrl(stops, profile)); + const located = stops.filter((p) => p.lat != null && p.lng != null).length; + push({ id: 'waze', label: 'Waze', firstStopOnly: located > 1 }, generateWazeUrl(stops)); + if (showsGeoUri()) { + push( + { id: 'geo', label: 'geo:', labelKey: 'inspector.otherMapApp', firstStopOnly: located > 1 }, + generateGeoUrl(stops) + ); + } + return out; +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index 6b3fbb88d..b286fba16 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type MouseEvent } from 'react' -import { ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut, MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2 } from 'lucide-react' +import { ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, LogIn, LogOut, Map as MapIcon, MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { useContextMenu, ContextMenu } from '../../../../components/shared/ContextMenu' import MarkdownText from '../../../../components/shared/MarkdownText' @@ -22,8 +22,9 @@ import type { MPlanTimelineProps } from '../MTripShell' import type { MergedItem } from '../../../../utils/dayMerge' import type { RouteSegment } from '../../../../types' import type { Assignment } from '../../../../types' -import type { ComponentType, ReactNode } from 'react' -import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon' +import type { ReactNode } from 'react' +import { useSettingsStore } from '../../../../store/settingsStore' +import MRouteAppsSheet, { openRouteTarget } from './MRouteAppsSheet' import MDayPager from '../../../components/MDayPager' /** @@ -181,11 +182,23 @@ function PlanDayPage({ planner, shell, dayId: pageDayId, active, editing, legMen const chrome = { editing, t, language: tl.language, timeFormat: tl.timeFormat } + // One Maps button for every app the day can be opened in: the preferred app + // from settings (#2423) opens straight away, otherwise the sheet asks. + const [mapsOpen, setMapsOpen] = useState(false) + const preferredNavApp = useSettingsStore(s => s.settings.preferred_nav_app) + const openMaps = () => { + const preferred = preferredNavApp ? tl.routeTargets.find(target => target.id === preferredNavApp) : undefined + if (preferred) openRouteTarget(preferred) + else setMapsOpen(true) + } + return ( - // One column under the day chips: the UP-NEXT card (go mode) or the edit - // header, then the timeline card filling the rest, 12px apart whatever height - // the card above it has. A day without an up-next starts the timeline right - // under the chips. + <> + setMapsOpen(false)} targets={tl.routeTargets} t={t} /> + {/* One column under the day chips: the UP-NEXT card (go mode) or the edit + header, then the timeline card filling the rest, 12px apart whatever height + the card above it has. A day without an up-next starts the timeline right + under the chips. */}
{!editing && } {editing && } @@ -308,7 +321,9 @@ function PlanDayPage({ planner, shell, dayId: pageDayId, active, editing, legMen )} {editing && ( -
+ // The day's actions as one group on one glass surface, the way iOS groups + // its controls: each a round icon over its label. +
void tl.optimize()} /> - {tl.canExportRoute && ( - <> - - - + {tl.canExportRoute && tl.routeTargets.length > 0 && ( + )}
)}
+ ) } @@ -347,7 +360,9 @@ function UpNextCard({ tl, t, onOpen }: { ) } diff --git a/client/src/mobile/screens/trip/plan/MRouteAppsSheet.tsx b/client/src/mobile/screens/trip/plan/MRouteAppsSheet.tsx new file mode 100644 index 000000000..dc668620e --- /dev/null +++ b/client/src/mobile/screens/trip/plan/MRouteAppsSheet.tsx @@ -0,0 +1,63 @@ +import { ChevronRight, MapPinned } from 'lucide-react'; +import type { DayRouteTarget } from '../../../../components/Planner/dayRouteApps'; +import type { TranslationFn } from '../../../../types'; +import MSheet from '../../../components/MSheet'; + +/** Opens a target: a web link in a new tab (the app takes it over), geo: in place. */ +export function openRouteTarget(target: DayRouteTarget): void { + if (target.url.startsWith('geo:')) window.location.assign(target.url); + else window.open(target.url, '_blank', 'noopener,noreferrer'); +} + +/** + * The plan's one Maps button, asked: which app should get the day. A docked + * sheet of the apps this device can open, each a full-width row; the ones that + * take a single place say they only get the first stop. + */ +export default function MRouteAppsSheet({ + open, + onClose, + targets, + t, +}: { + open: boolean; + onClose: () => void; + targets: DayRouteTarget[]; + t: TranslationFn; +}) { + return ( + +
+
+
+
+ {targets.map((target) => ( + + ))} +
+
+
+ ); +} diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index 45a4aad42..37c8f4ca0 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -9,6 +9,7 @@ import { dayWeatherAnchor } from '../../../../utils/dayWeather' import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder' import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds, yieldTimedBookings } from '../../../../utils/dayMerge' import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute' +import { dayRouteTargets } from '../../../../components/Planner/dayRouteApps' import type { PlannedTransitLeg, TransitLeg } from '../../../../components/Planner/transitLeg' import { useTransitLegPlanner } from '../../../../components/Planner/useTransitLegPlanner' import { dayItemOverrun } from '../../../../components/Tours/tourDayTime' @@ -316,6 +317,17 @@ export function useMPlanTimeline(planner: TripPlanner, forDayId?: number | null) [day, days, dayAssignments, tripAccommodations, settings, dayCarrier], ) + // The apps behind the plan's one Maps button, with the same bookended stops. + const routeTargets = useMemo( + () => (day + ? dayRouteTargets( + dayExportStops(day, days, dayAssignments, tripAccommodations, settings.optimize_from_accommodation !== false, dayCarrier), + day.default_transport_mode ?? routeProfile, + ) + : []), + [day, days, dayAssignments, tripAccommodations, settings, dayCarrier, routeProfile], + ) + const exportGoogleMaps = useCallback(() => { if (!day) return // Bookend the exported route with the day's accommodation the same way the @@ -401,7 +413,7 @@ export function useMPlanTimeline(planner: TripPlanner, forDayId?: number | null) moveRow, removeAssignment, editAssignment, editTransport, openTransitJourney, moveRowTo, addPlace, addBooking, addTransport, - optimize, canExportRoute, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf, + optimize, canExportRoute, routeTargets, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf, routeModeOptions, setLegMode, transitLegFor, planTransitLeg, journeyLate, tourOverrun, } } diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index f925cc5ea..44d7f7b66 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -401,7 +401,7 @@ function UpNext({ planner, shell, rt, stageDayId, onOpen }: { const targets = place ? getNavigationTargets(place) : [] return ( -
+