280 lines
8.9 KiB
TypeScript
280 lines
8.9 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Linking,
|
|
Platform,
|
|
Pressable,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import MapView, { Marker, PROVIDER_GOOGLE, Region } from 'react-native-maps';
|
|
import * as Location from 'expo-location';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { useT } from '@/i18n';
|
|
import { AppBar } from '@/components/AppBar';
|
|
import { Button } from '@/components/ui';
|
|
import {
|
|
ensureGeocodePermission,
|
|
ensureLocationPermission,
|
|
openPermissionSettings,
|
|
} from '@/lib/permissions';
|
|
import { setPendingLocation } from '@/lib/pendingLocation';
|
|
import { colors, fontSize, radii, shadow, spacing } from '@/theme';
|
|
|
|
/** Fallback view when the property has no pin yet: Milan. */
|
|
const DEFAULT_REGION: Region = {
|
|
latitude: 45.4642,
|
|
longitude: 9.19,
|
|
latitudeDelta: 0.08,
|
|
longitudeDelta: 0.08,
|
|
};
|
|
|
|
export default function LocationPickerScreen() {
|
|
const router = useRouter();
|
|
const t = useT();
|
|
const params = useLocalSearchParams<{ lat?: string; lon?: string; address?: string }>();
|
|
const mapRef = useRef<MapView | null>(null);
|
|
|
|
const initial =
|
|
params.lat && params.lon
|
|
? { latitude: Number(params.lat), longitude: Number(params.lon) }
|
|
: null;
|
|
|
|
const [pin, setPin] = useState<{ latitude: number; longitude: number } | null>(initial);
|
|
const [label, setLabel] = useState<string | null>(null);
|
|
const [place, setPlace] = useState<Location.LocationGeocodedAddress | null>(null);
|
|
const [busy, setBusy] = useState<'gps' | 'address' | null>(null);
|
|
|
|
const region: Region = pin
|
|
? { ...pin, latitudeDelta: 0.01, longitudeDelta: 0.01 }
|
|
: DEFAULT_REGION;
|
|
|
|
// With no pin yet, derive one from the address already typed in the form.
|
|
// Silent: runs only if permission is already granted.
|
|
useEffect(() => {
|
|
if (pin || !params.address) return;
|
|
(async () => {
|
|
if (await ensureGeocodePermission(false)) {
|
|
void locateFromAddress(params.address as string, true);
|
|
}
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!pin) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
// The label is a nicety, so stay silent without permission.
|
|
if (!(await ensureGeocodePermission(false))) return;
|
|
const [found] = await Location.reverseGeocodeAsync(pin);
|
|
if (cancelled || !found) return;
|
|
setPlace(found);
|
|
setLabel(
|
|
[found.street, found.streetNumber, found.postalCode, found.city]
|
|
.filter(Boolean)
|
|
.join(', ') || null,
|
|
);
|
|
} catch {
|
|
// reverse geocoding is a nicety, never a blocker
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [pin?.latitude, pin?.longitude]);
|
|
|
|
function moveTo(next: { latitude: number; longitude: number }) {
|
|
setPin(next);
|
|
setPlace(null); // stale until the new point is reverse-geocoded
|
|
setLabel(null);
|
|
mapRef.current?.animateToRegion({ ...next, latitudeDelta: 0.01, longitudeDelta: 0.01 }, 400);
|
|
}
|
|
|
|
async function locateFromAddress(address: string, silent = false) {
|
|
// User-initiated, so prompting here is expected.
|
|
if (!(await ensureGeocodePermission(!silent))) {
|
|
if (!silent) openPermissionSettings('permissions.locationBody');
|
|
return;
|
|
}
|
|
setBusy('address');
|
|
try {
|
|
const [hit] = await Location.geocodeAsync(address);
|
|
if (hit) moveTo({ latitude: hit.latitude, longitude: hit.longitude });
|
|
else if (!silent) Alert.alert(t('location.notFound'), t('location.notFoundBody'));
|
|
} catch (e: any) {
|
|
if (!silent) Alert.alert(t('location.notFound'), e?.message ?? t('common.tryAgain'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
async function useCurrentPosition() {
|
|
setBusy('gps');
|
|
try {
|
|
// With location off device-wide, iOS rejects the request without a
|
|
// dialog and the app has no row in its own settings — send them elsewhere.
|
|
if (!(await Location.hasServicesEnabledAsync())) {
|
|
Alert.alert(t('location.servicesOff'), t('location.servicesOffBody'), [
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{ text: t('permissions.openSettings'), onPress: () => void Linking.openSettings() },
|
|
]);
|
|
return;
|
|
}
|
|
if (!(await ensureLocationPermission(true))) {
|
|
openPermissionSettings('permissions.locationBody');
|
|
return;
|
|
}
|
|
// A cold GPS fix can hang indefinitely; fall back to the last known one.
|
|
const pos =
|
|
(await Promise.race([
|
|
Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }),
|
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 12000)),
|
|
])) ?? (await Location.getLastKnownPositionAsync());
|
|
if (!pos) {
|
|
Alert.alert(t('location.gpsFailed'), t('common.tryAgain'));
|
|
return;
|
|
}
|
|
moveTo({ latitude: pos.coords.latitude, longitude: pos.coords.longitude });
|
|
} catch (e: any) {
|
|
Alert.alert(t('location.gpsFailed'), e?.message ?? t('common.tryAgain'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
function confirm() {
|
|
if (!pin) return;
|
|
// Street and city travel back too, so the form can offer to fill them in.
|
|
setPendingLocation({
|
|
...pin,
|
|
address: place
|
|
? [place.street, place.streetNumber].filter(Boolean).join(' ') || place.name || null
|
|
: null,
|
|
postalCode: place?.postalCode ?? null,
|
|
city: place?.city ?? null,
|
|
country: place?.country ?? null,
|
|
});
|
|
router.back();
|
|
}
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
|
|
<AppBar title={t('location.title')} back />
|
|
|
|
<View style={styles.mapWrap}>
|
|
<MapView
|
|
ref={mapRef}
|
|
// Google on Android needs the API key in app.json; iOS uses Apple Maps.
|
|
provider={Platform.OS === 'android' ? PROVIDER_GOOGLE : undefined}
|
|
style={StyleSheet.absoluteFill}
|
|
initialRegion={region}
|
|
onPress={(e) => setPin(e.nativeEvent.coordinate)}
|
|
>
|
|
{pin ? (
|
|
<Marker
|
|
coordinate={pin}
|
|
draggable
|
|
onDragEnd={(e) => setPin(e.nativeEvent.coordinate)}
|
|
pinColor={colors.teal600}
|
|
/>
|
|
) : null}
|
|
</MapView>
|
|
|
|
{!pin ? (
|
|
<View style={styles.hintOverlay} pointerEvents="none">
|
|
<Text style={styles.hintText}>{t('location.tapHint')}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<Pressable style={styles.gpsButton} onPress={useCurrentPosition}>
|
|
{busy === 'gps' ? (
|
|
<ActivityIndicator color={colors.teal700} />
|
|
) : (
|
|
<Ionicons name="locate" size={20} color={colors.teal700} />
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={styles.panel}>
|
|
<View style={styles.readout}>
|
|
<Ionicons name="pin" size={16} color={colors.teal600} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.coords}>
|
|
{pin
|
|
? `${pin.latitude.toFixed(6)}, ${pin.longitude.toFixed(6)}`
|
|
: t('location.noPin')}
|
|
</Text>
|
|
{label ? (
|
|
<Text style={styles.label} numberOfLines={1}>
|
|
{label}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
{params.address ? (
|
|
<Button
|
|
title={t('location.fromAddress')}
|
|
variant="ghost"
|
|
icon="search"
|
|
block
|
|
loading={busy === 'address'}
|
|
onPress={() => locateFromAddress(params.address as string)}
|
|
style={{ marginBottom: spacing[3] }}
|
|
/>
|
|
) : null}
|
|
|
|
<Button
|
|
title={t('location.confirm')}
|
|
icon="checkmark"
|
|
size="lg"
|
|
block
|
|
disabled={!pin}
|
|
onPress={confirm}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
mapWrap: { flex: 1, overflow: 'hidden' },
|
|
hintOverlay: {
|
|
position: 'absolute',
|
|
top: spacing[4],
|
|
alignSelf: 'center',
|
|
backgroundColor: 'rgba(18,27,36,0.78)',
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: radii.pill,
|
|
},
|
|
hintText: { color: colors.onDark, fontSize: fontSize.sm, fontWeight: '600' },
|
|
gpsButton: {
|
|
position: 'absolute',
|
|
right: spacing[4],
|
|
bottom: spacing[4],
|
|
width: 46,
|
|
height: 46,
|
|
borderRadius: 23,
|
|
backgroundColor: colors.surface,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
...shadow.md,
|
|
},
|
|
panel: {
|
|
backgroundColor: colors.surface,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
padding: spacing[4],
|
|
paddingBottom: spacing[6],
|
|
},
|
|
readout: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: spacing[4] },
|
|
coords: { fontSize: fontSize.md, fontWeight: '600', color: colors.ink },
|
|
label: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 },
|
|
});
|