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 ( -
+