diff --git a/__tests__/mapLink.test.ts b/__tests__/mapLink.test.ts new file mode 100644 index 0000000..c020826 --- /dev/null +++ b/__tests__/mapLink.test.ts @@ -0,0 +1,43 @@ +/** + * @format + */ + +import {mapUrl} from '../src/map/mapLink'; + +test('a location with nothing to point at gets no link', () => { + expect(mapUrl(null)).toBeNull(); + expect(mapUrl(undefined)).toBeNull(); + expect(mapUrl({})).toBeNull(); + expect(mapUrl({address: ' '})).toBeNull(); +}); + +test('coordinates drop a pin labelled with the address', () => { + expect( + mapUrl({address: 'Plaça de Catalunya', latitude: 41.3874, longitude: 2.17}), + ).toBe('geo:41.3874,2.17?q=41.3874%2C2.17(Pla%C3%A7a%20de%20Catalunya)'); +}); + +test('coordinates alone still point somewhere', () => { + expect(mapUrl({latitude: 41.3874, longitude: 2.17})).toBe( + 'geo:41.3874,2.17?q=41.3874%2C2.17', + ); +}); + +test('an address without coordinates is left for the map app to geocode', () => { + expect(mapUrl({address: '10 Downing St, London'})).toBe( + 'geo:0,0?q=10%20Downing%20St%2C%20London', + ); +}); + +test('half a coordinate pair is no coordinate pair', () => { + expect(mapUrl({address: 'Somewhere', latitude: 41.3874})).toBe( + 'geo:0,0?q=Somewhere', + ); + expect( + mapUrl({address: 'Somewhere', latitude: Number.NaN, longitude: 2.17}), + ).toBe('geo:0,0?q=Somewhere'); +}); + +test('a null island location is a real place, not a missing one', () => { + expect(mapUrl({latitude: 0, longitude: 0})).toBe('geo:0,0?q=0%2C0'); +}); diff --git a/src/map/ElementDetailScreen.tsx b/src/map/ElementDetailScreen.tsx index 99a28e4..cac8e8a 100644 --- a/src/map/ElementDetailScreen.tsx +++ b/src/map/ElementDetailScreen.tsx @@ -22,6 +22,7 @@ import {photoImageSource} from '../photos/photoImageSource'; import type {Theme} from '../theme/colors'; import {useTheme} from '../theme/useTheme'; import {formatSchedule} from './formatSchedule'; +import {mapUrl, webMapUrl} from './mapLink'; import {metadataRows} from './metadataRows'; type Props = NativeStackScreenProps; @@ -119,7 +120,7 @@ function ModalContents({ ) : null} {element.location?.address ? ( - {element.location.address} + ) : null} {element.completed ? ( // Neutral, with a tick doing the work the green used to: status @@ -255,6 +256,47 @@ function ModalContents({ ); } +// The address, tappable: it hands the place to whichever map app the phone has +// rather than picking one for the user. Styled as a link — underlined, not hue +// alone — because a line of grey subtitle text gives no hint it does anything. +function LocationLine({ + location, +}: { + location: NonNullable; +}) { + const theme = useTheme(); + const styles = useMemo(() => makeStyles(theme), [theme]); + const url = mapUrl(location); + + if (!url) { + return {location.address}; + } + + return ( + openMap(url, webMapUrl(location))} + style={({pressed}) => pressed && styles.linkPressed}> + + {location.address} + + + ); +} + +// `geo:` is the whole point — it's what makes Android offer the chooser — but a +// phone with no map app installed has nothing to hand it to, and openURL +// rejects. Fall back to a web map there rather than surfacing a failure. +async function openMap(url: string, fallback: string | null) { + try { + await Linking.openURL(url); + } catch { + if (fallback) await Linking.openURL(fallback).catch(() => {}); + } +} + function Section({ title, children, @@ -344,6 +386,11 @@ const makeStyles = (theme: Theme) => fontSize: 13, color: theme.muted, }, + subtitleLink: { + color: theme.accentText, + textDecorationLine: 'underline', + textDecorationColor: theme.accentText, + }, completedTag: { alignSelf: 'flex-start', backgroundColor: theme.lineFill, diff --git a/src/map/mapLink.ts b/src/map/mapLink.ts new file mode 100644 index 0000000..8058fd4 --- /dev/null +++ b/src/map/mapLink.ts @@ -0,0 +1,56 @@ +// Build the URL that hands an element's location to whatever map app the phone +// has. A `geo:` URI is deliberately vendor-neutral: Android resolves it through +// the system chooser, so Google Maps, Organic Maps, and anything else installed +// all get a turn, rather than us hard-coding one of them. + +export type LinkableLocation = { + address?: string | null; + latitude?: number | null; + longitude?: number | null; +}; + +export function mapUrl( + location: LinkableLocation | null | undefined, +): string | null { + if (!location) return null; + const address = location.address?.trim(); + const {latitude, longitude} = location; + + if (isCoordinate(latitude) && isCoordinate(longitude)) { + // The coordinates ride along twice: bare in the scheme so an app that + // ignores the query still lands in the right place, and inside `q` so the + // ones that honour it drop a labelled pin instead of only centring there. + const query = address + ? `${latitude},${longitude}(${address})` + : `${latitude},${longitude}`; + return `geo:${latitude},${longitude}?q=${encodeURIComponent(query)}`; + } + + // No coordinates — let the map app geocode the address itself. `0,0` is the + // documented placeholder for a search-only geo URI. + if (address) return `geo:0,0?q=${encodeURIComponent(address)}`; + + return null; +} + +// Where to go when the phone has no app registered for `geo:` at all — a plain +// web map, which the browser can always take. Vendor-specific by necessity; +// it's the fallback, never the first choice. +export function webMapUrl( + location: LinkableLocation | null | undefined, +): string | null { + if (!location) return null; + const address = location.address?.trim(); + const {latitude, longitude} = location; + + const query = + isCoordinate(latitude) && isCoordinate(longitude) + ? `${latitude},${longitude}` + : address; + if (!query) return null; + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} + +function isCoordinate(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value); +}