Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions __tests__/mapLink.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
49 changes: 48 additions & 1 deletion src/map/ElementDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootStackParamList, 'ElementDetail'>;
Expand Down Expand Up @@ -119,7 +120,7 @@ function ModalContents({
) : null}
<View style={styles.heroBody}>
{element.location?.address ? (
<Text style={styles.subtitle}>{element.location.address}</Text>
<LocationLine location={element.location} />
) : null}
{element.completed ? (
// Neutral, with a tick doing the work the green used to: status
Expand Down Expand Up @@ -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<ElementDetail['location']>;
}) {
const theme = useTheme();
const styles = useMemo(() => makeStyles(theme), [theme]);
const url = mapUrl(location);

if (!url) {
return <Text style={styles.subtitle}>{location.address}</Text>;
}

return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Open ${location.address} in a map app`}
hitSlop={8}
onPress={() => openMap(url, webMapUrl(location))}
style={({pressed}) => pressed && styles.linkPressed}>
<Text style={[styles.subtitle, styles.subtitleLink]}>
{location.address}
</Text>
</Pressable>
);
}

// `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,
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/map/mapLink.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading