Files
casadoc-app/app/property/location.tsx
T
2026-08-26 23:01:07 +03:00

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 },
});