BigBrainParking/app/src/screens/CityAreaScreen.tsx
Erik 148e1635d3
All checks were successful
build-apk / build (push) Successful in 10m38s
v0.6.0: city parking-map overlay + local time tracking, no IPS API
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>
2026-08-13 03:28:18 +00:00

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