All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking" map as a georeferenced overlay, and lets you track your time on any of its areas without ever touching the ParkSmarter/IPS API. Georeferencing (tools/citymap/) - The PDF carries no geo metadata, so the page->WebMercator affine is recovered by fitting the drawing to OSM street centrelines. - pdftocairo writes stroked street segments with per-path matrix() transforms in local coords while filled lots are absolute; both are handled. The five legend swatches share the real geometry's colours and are identified by stroke-width and position, then dropped. - 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because they are angled bays along the old rail corridor, on no named road at all. - Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows the same jog at the fitted scale, which independently confirms the fit. App - Map tab: "City map" layer in the legend's colours, tappable. - "Park here" pins the car from GPS and auto-detects the containing area (40 m snap). With no fix it asks you to tap the spot instead, so the pin never depends on GPS working. - The pin lives in its own storage key, not inside the session: pinning the car without starting a timer must survive backing out of the screen. - Durations cap at the posted limit — a 2-hour space is not offered a 4-hour timer. Lots and no-limit spots get the long options. - Reuses the existing foreground-service countdown. The second notification button reads "+1 hr" for a city area rather than "Extend": there is nothing to buy, so it edits the local timer and says so. - Account -> Align city map: nudge/scale/rotate the whole overlay against a live GPS fix. Save-on-phone needs no admin token, since the person who can see the misalignment is the one standing on the street. Server - parking_areas + map_overlay tables, public read, admin replace-all. The areas come from one source document, so replacement is wholesale rather than an upsert. Dropped geometryCenter from the geo module: on the real data it returns a point in the water for the crescent City Beach lot and mid-block for L-shaped runs. Nothing used it. Tests: 8 geometry tests in app/, 5 area/overlay tests in server/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
7.9 KiB
TypeScript
230 lines
7.9 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
import type { RouteProp } from '@react-navigation/native';
|
|
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
|
import { useTheme } from '@/theme/ThemeContext';
|
|
import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
|
|
import {
|
|
endActiveParking,
|
|
extendAreaParking,
|
|
startAreaParking,
|
|
} from '@/features/session/activeParking';
|
|
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
|
|
|
type AreaRoute = RouteProp<RootStackParamList, 'CityArea'>;
|
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|
|
|
function fmtHours(h: number): string {
|
|
if (h < 1) return `${Math.round(h * 60)} min`;
|
|
return h === 1 ? '1 hour' : `${h} hours`;
|
|
}
|
|
|
|
function fmtClock(ms: number): string {
|
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
}
|
|
|
|
function fmtRemaining(ms: number): string {
|
|
const mins = Math.max(0, Math.round(ms / 60_000));
|
|
const h = Math.floor(mins / 60);
|
|
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
|
}
|
|
|
|
/**
|
|
* One area from the city's printed parking map: what the sign says, and a timer
|
|
* for it.
|
|
*
|
|
* Nothing on this screen talks to ParkSmarter. The area came from the local
|
|
* database and the countdown is the phone's own clock, so this works with no
|
|
* account, no signal, and in Anonymous Mode.
|
|
*/
|
|
export function CityAreaScreen() {
|
|
const { area, spot } = useRoute<AreaRoute>().params;
|
|
const navigation = useNavigation<Nav>();
|
|
const { colors } = useTheme();
|
|
const [active, setActive] = useState<ActiveParking | null>(null);
|
|
|
|
const options = areaDurationOptions(area);
|
|
const [hours, setHours] = useState<number>(options[0]);
|
|
const free = areaIsFree(area.kind);
|
|
const parkedHere = active?.area?.id === area.id;
|
|
|
|
const reload = useCallback(() => {
|
|
void getActiveParking().then(setActive);
|
|
}, []);
|
|
useFocusEffect(reload);
|
|
|
|
// Re-read while the screen is open so "time left" doesn't sit frozen at whatever
|
|
// it was when you opened it. The notification is the real live countdown; this
|
|
// just keeps the screen from lying.
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const id = setInterval(reload, 30_000);
|
|
return () => clearInterval(id);
|
|
}, [active, reload]);
|
|
|
|
useEffect(() => {
|
|
navigation.setOptions({ title: area.label });
|
|
}, [navigation, area.label]);
|
|
|
|
const start = async () => {
|
|
if (active && !parkedHere) {
|
|
const where = active.zoneName;
|
|
const ok = await new Promise<boolean>((resolve) =>
|
|
Alert.alert(
|
|
'Already tracking',
|
|
`You're tracking parking at ${where}. Replace it with this spot?`,
|
|
[
|
|
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
|
|
{ text: 'Replace', style: 'destructive', onPress: () => resolve(true) },
|
|
],
|
|
),
|
|
);
|
|
if (!ok) return;
|
|
}
|
|
await startAreaParking({ area, hours, spot });
|
|
navigation.navigate('Tabs');
|
|
};
|
|
|
|
const stop = async () => {
|
|
await endActiveParking();
|
|
reload();
|
|
};
|
|
|
|
const s = styles(colors);
|
|
|
|
return (
|
|
<ScrollView style={s.screen} contentContainerStyle={s.content}>
|
|
<View style={s.card}>
|
|
<View style={s.chipRow}>
|
|
<View style={[s.swatch, { backgroundColor: area.color }]} />
|
|
<Text style={s.chipText}>{area.legend}</Text>
|
|
</View>
|
|
<Text style={s.name}>{area.name}</Text>
|
|
<Text style={s.sub}>
|
|
{free ? 'Free parking' : 'Paid — pay at the kiosk or by permit'}
|
|
{area.hours > 0 ? ` · ${fmtHours(area.hours)} posted limit` : ' · no posted time limit'}
|
|
</Text>
|
|
</View>
|
|
|
|
{spot ? (
|
|
<View style={s.card}>
|
|
<Text style={s.sectionTitle}>Your car</Text>
|
|
<Text style={s.sub}>
|
|
Pinned at {spot.latitude.toFixed(5)}, {spot.longitude.toFixed(5)}
|
|
</Text>
|
|
<Text style={s.hint}>
|
|
{spot.manual ? 'Placed by hand.' : 'From GPS.'} The pin stays on the map until you end
|
|
the session.
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{parkedHere && active ? (
|
|
<View style={s.card}>
|
|
<Text style={s.sectionTitle}>Tracking now</Text>
|
|
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
|
<Text style={s.sub}>Until {fmtClock(active.endMs)}</Text>
|
|
<View style={s.row}>
|
|
<TouchableOpacity
|
|
style={s.secondary}
|
|
onPress={async () => {
|
|
await extendAreaParking();
|
|
reload();
|
|
}}
|
|
>
|
|
<Text style={s.secondaryText}>+1 hour</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={s.danger} onPress={stop}>
|
|
<Text style={s.primaryText}>End</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<View style={s.card}>
|
|
<Text style={s.sectionTitle}>Track my time here</Text>
|
|
<View style={s.row}>
|
|
{options.map((h) => (
|
|
<TouchableOpacity
|
|
key={h}
|
|
style={[s.pick, hours === h && { borderColor: colors.primary, borderWidth: 2 }]}
|
|
onPress={() => setHours(h)}
|
|
>
|
|
<Text style={[s.pickText, hours === h && { color: colors.primary }]}>
|
|
{h < 1 ? `${Math.round(h * 60)}m` : `${h}h`}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
<Text style={s.hint}>
|
|
{area.hours > 0
|
|
? `The sign says ${fmtHours(area.hours)}. The countdown runs on this phone only — it doesn't buy or reserve anything.`
|
|
: "No posted limit here — pick however long you'll be. The countdown runs on this phone only."}
|
|
</Text>
|
|
<TouchableOpacity style={s.primary} onPress={start}>
|
|
<Text style={s.primaryText}>Start {fmtHours(hours)} timer</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
|
|
StyleSheet.create({
|
|
screen: { flex: 1, backgroundColor: c.bg },
|
|
content: { padding: 16, gap: 12 },
|
|
card: {
|
|
backgroundColor: c.card,
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
gap: 8,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
},
|
|
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
swatch: { width: 22, height: 12, borderRadius: 3 },
|
|
chipText: { color: c.subtext, fontSize: 13, fontWeight: '600' },
|
|
name: { color: c.text, fontSize: 20, fontWeight: '700' },
|
|
sub: { color: c.subtext, fontSize: 14 },
|
|
hint: { color: c.subtext, fontSize: 12, lineHeight: 17 },
|
|
sectionTitle: { color: c.text, fontSize: 15, fontWeight: '700' },
|
|
big: { color: c.text, fontSize: 28, fontWeight: '700' },
|
|
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
|
|
pick: {
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
backgroundColor: c.bg,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
minWidth: 56,
|
|
alignItems: 'center',
|
|
},
|
|
pickText: { color: c.text, fontWeight: '600' },
|
|
primary: {
|
|
backgroundColor: c.primary,
|
|
borderRadius: 10,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
marginTop: 4,
|
|
},
|
|
primaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
secondary: {
|
|
flex: 1,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
alignItems: 'center',
|
|
},
|
|
secondaryText: { color: c.text, fontWeight: '700' },
|
|
danger: {
|
|
flex: 1,
|
|
backgroundColor: c.danger,
|
|
borderRadius: 10,
|
|
paddingVertical: 12,
|
|
alignItems: 'center',
|
|
},
|
|
});
|