feat(settings): optional preferred map app for the navigate button (#2423)

This commit is contained in:
Maurice
2026-10-01 12:52:58 +02:00
parent a416d41cda
commit 855c0a4b24
32 changed files with 199 additions and 2 deletions
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget, showsAppleMaps, showsGeoUri } from './placeNavigation'
import { getNavigationTargets, navigationAppChoices, navigationTargetLabel, openNavigationTarget, preferredNavAppOptions, showsAppleMaps, showsGeoUri, withPreferredApp } from './placeNavigation'
import { useSettingsStore } from '../../store/settingsStore'
import type { Place } from '../../types'
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
@@ -163,3 +164,34 @@ describe('openNavigationTarget', () => {
expect(assigned).toEqual([target.url])
})
})
describe('preferred map app (#2423)', () => {
afterEach(() => {
useSettingsStore.setState(s => ({ settings: { ...s.settings, preferred_nav_app: '' } }))
})
it('FE-PLANNER-NAV-2423-1: unset keeps every app on offer', () => {
expect(getNavigationTargets(place()).length).toBeGreaterThan(1)
})
it('FE-PLANNER-NAV-2423-2: a picked app is the only target, so the button opens it straight away', () => {
useSettingsStore.setState(s => ({ settings: { ...s.settings, preferred_nav_app: 'waze' } }))
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['waze'])
})
it('FE-PLANNER-NAV-2423-3: an app this place cannot be opened in falls back to the full list', () => {
const targets = [{ id: 'google' as const, label: 'Google Maps', url: 'g' }, { id: 'osm' as const, label: 'OpenStreetMap', url: 'o' }]
expect(withPreferredApp(targets, 'amap')).toEqual(targets)
expect(withPreferredApp(targets, null)).toEqual(targets)
expect(withPreferredApp(targets, 'osm')).toEqual([targets[1]])
})
it('FE-PLANNER-NAV-2423-4: the settings options start with asking and name every app', () => {
const options = preferredNavAppOptions(k => k)
expect(options[0]).toEqual({ value: '', label: 'settings.preferredNavAppAsk' })
expect(options.slice(1).map(o => o.value)).toEqual(navigationAppChoices().map(a => a.id))
expect(options.map(o => o.value)).toContain('google')
})
})
@@ -4,6 +4,7 @@ import { getAmapUrlForPlace } from './placeAmap'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
import { useSettingsStore } from '../../store/settingsStore'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
@@ -80,6 +81,49 @@ export function showsAppleMaps(): boolean {
export function getNavigationTargets(
place: PlaceLike | null | undefined,
detailsUrl?: string | null,
): NavigationTarget[] {
return withPreferredApp(allNavigationTargets(place, detailsUrl), useSettingsStore.getState().settings.preferred_nav_app)
}
/**
* The traveller's preferred map app (#2423), when they picked one in settings:
* just that target, so every navigate button opens it straight away. Unset, or
* an app this place cannot be opened in (Amap outside China, Waze without
* coordinates), keeps the full list.
*/
export function withPreferredApp(targets: NavigationTarget[], preferred: string | null | undefined): NavigationTarget[] {
if (!preferred) return targets
const match = targets.find(target => target.id === preferred)
return match ? [match] : targets
}
/**
* The apps the settings picker offers (#2423), narrowed by platform the same
* way the list on a place is: Apple Maps not on Android, the generic geo: entry
* only there. Amap stays in, it is simply skipped for places outside China.
*/
export function navigationAppChoices(): { id: NavigationAppId; label: string; labelKey?: string }[] {
const choices: { id: NavigationAppId; label: string; labelKey?: string }[] = [
{ id: 'google', label: 'Google Maps' },
{ id: 'waze', label: 'Waze' },
]
if (showsAppleMaps()) choices.push({ id: 'apple', label: 'Apple Maps' })
choices.push({ id: 'osm', label: 'OpenStreetMap' }, { id: 'comaps', label: 'CoMaps' }, { id: 'amap', label: '高德地图' })
if (showsGeoUri()) choices.push({ id: 'geo', label: 'geo:', labelKey: 'inspector.otherMapApp' })
return choices
}
/** The settings options (#2423): "ask every time" first, then each app from navigationAppChoices. */
export function preferredNavAppOptions(t: Translate): { value: string; label: string }[] {
return [
{ value: '', label: t('settings.preferredNavAppAsk') },
...navigationAppChoices().map(app => ({ value: app.id, label: app.labelKey ? t(app.labelKey) : app.label })),
]
}
function allNavigationTargets(
place: PlaceLike | null | undefined,
detailsUrl?: string | null,
): NavigationTarget[] {
if (!place) return []
const targets: NavigationTarget[] = []
@@ -4,6 +4,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore, DEFAULT_SETTINGS } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { preferredNavAppOptions } from '../Planner/placeNavigation'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import Section from './Section'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
@@ -327,6 +328,20 @@ export default function DisplaySettingsTab(): React.ReactElement {
</Section>
<Section title={t('settings.general.travelMap')} icon={Map}>
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.preferredNavApp')}</label>
<CustomSelect
value={settings.preferred_nav_app || ''}
onChange={async v => {
try { await updateSetting('preferred_nav_app', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={preferredNavAppOptions(t)}
/>
<p className="text-xs text-content-faint mt-2">{t('settings.preferredNavAppHint')}</p>
</div>
{/* Date first in day headings (#1953) */}
<OnOffSetting label={t('settings.dayDateFirst')} hint={t('settings.dayDateFirstHint')} on={settings.day_date_first === true} onChange={value => saveOnOff('day_date_first', value)} />
@@ -9,9 +9,10 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, type StartPage } from '../.
import type { Settings, DistanceUnit, WeekStart } from '../../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../../utils/calendarWeek'
import { MSetCard, MSetEyebrow, MSetSelectRow, MSetSegments, MSetRow } from './MSettingsUi'
import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow } from './MSettingsUi'
import MToggle from '../../components/MToggle'
import MSetPickerSheet from './MSetPickerSheet'
import { preferredNavAppOptions } from '../../../components/Planner/placeNavigation'
/**
* "General" settings section — the demo's Language & region and Travel & map
@@ -24,6 +25,9 @@ export default function MSettingsGeneral() {
const [currencyOpen, setCurrencyOpen] = useState(false)
const [langOpen, setLangOpen] = useState(false)
const [startTabOpen, setStartTabOpen] = useState(false)
const [navAppOpen, setNavAppOpen] = useState(false)
const navAppOptions = preferredNavAppOptions(t)
const navAppValue = settings.preferred_nav_app || ''
const save = async (key: keyof Settings, value: Settings[keyof Settings]) => {
try {
@@ -169,6 +173,13 @@ export default function MSettingsGeneral() {
</MSetCard>
<MSetCard title={t('settings.general.travelMap')} icon={Map} className="mt-3">
<MSetEyebrow className="mb-[5px]">{t('settings.preferredNavApp')}</MSetEyebrow>
<MSetSelectRow
label={navAppOptions.find(o => o.value === navAppValue)?.label ?? navAppOptions[0].label}
trailing={chevron}
onClick={() => setNavAppOpen(true)}
/>
<MSetHint className="mb-[10px]">{t('settings.preferredNavAppHint')}</MSetHint>
<div className="-mt-[6px]">
{travelRows.map((row) => (
<MSetRow
@@ -208,6 +219,15 @@ export default function MSettingsGeneral() {
options={SUPPORTED_LANGUAGES.map((l) => ({ value: l.value, label: l.label }))}
/>
<MSetPickerSheet
open={navAppOpen}
onClose={() => setNavAppOpen(false)}
title={t('settings.preferredNavApp')}
value={navAppValue}
onSelect={(v) => save('preferred_nav_app', v)}
options={navAppOptions}
/>
<MSetPickerSheet
open={startTabOpen}
onClose={() => setStartTabOpen(false)}
+5
View File
@@ -138,6 +138,11 @@ export interface Settings {
blur_booking_codes?: boolean
/** Day headings lead with the calendar date and name the trip day second (#1953). */
day_date_first?: boolean
/**
* Map app every navigate button opens straight away (#2423), as a
* NavigationAppId. Empty or absent keeps the picker with every app.
*/
preferred_nav_app?: string
/** Places not planned into any day are drawn as small markers without their photo (#2024). */
map_compact_unplanned?: boolean
map_booking_labels?: boolean