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>
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { AppState, Platform } from 'react-native';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import * as Notifications from 'expo-notifications';
|
|
import type { Zone } from 'parksmarter-client';
|
|
import { ps } from '@/api/client';
|
|
import { parseApiTime } from '@/api/parseTime';
|
|
import type { LabelKind } from '@/api/zoneLabels';
|
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
|
import {
|
|
ensureNotificationPermission,
|
|
REMINDER_CHANNEL_ID,
|
|
} from '@/notifications/localReminders';
|
|
import {
|
|
getCountdownEnabled,
|
|
getReminderLeadMinutes,
|
|
getRemindersEnabled,
|
|
} from '@/features/notifications/reminderPrefs';
|
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
|
import type { ParkingArea } from '@/api/parkingAreas';
|
|
import {
|
|
clearActiveParking,
|
|
getActiveParking,
|
|
setActiveParking,
|
|
setParkedPin,
|
|
type ActiveParking,
|
|
type ParkedSpot,
|
|
} from './activeParkingStore';
|
|
import {
|
|
clearSession,
|
|
consumePendingAction,
|
|
hasNativeCountdown,
|
|
showSession,
|
|
} from '../../../modules/bbp-notify';
|
|
|
|
/**
|
|
* Everything that happens while a car is parked, paid or free, lives here.
|
|
*
|
|
* There is exactly one active parking session at a time, so there is exactly one
|
|
* ongoing notification. Both entry points (buying time, checking into a free
|
|
* space) write the same record and post the same countdown, which is why the
|
|
* notification behaves identically whichever way you parked.
|
|
*
|
|
* The notification itself is owned by a native foreground service — see
|
|
* modules/bbp-notify. JS's job is to keep the record truthful; the service
|
|
* renders it and stops itself the moment there's nothing to show.
|
|
*/
|
|
|
|
/** One session at a time, so one reminder id. */
|
|
const EXPIRY_REMINDER_ID = 'parking-expiry';
|
|
/** Fallback ongoing notification for Expo Go, where the native module is absent. */
|
|
const FALLBACK_NOTIF_ID = 'parking-status';
|
|
/** How much a city-map session's "extend" button adds, and what it's labelled. */
|
|
const EXTEND_MINUTES = 60;
|
|
const EXTEND_LABEL = '+1 hr';
|
|
|
|
function fmtTime(ms: number): string {
|
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ posting */
|
|
|
|
/** Post (or replace) the ongoing ticking notification for a session. */
|
|
async function postNotification(p: ActiveParking): Promise<void> {
|
|
if (!(await getCountdownEnabled())) {
|
|
logLine('[PARKING] countdown disabled — not posting');
|
|
await clearNotification();
|
|
return;
|
|
}
|
|
await ensureNotificationPermission();
|
|
|
|
const ends = fmtTime(p.endMs);
|
|
const free = p.kind === 'free';
|
|
const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
|
|
const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
|
|
// What the second button does depends on what it *can* do. City-map parking has
|
|
// no ParkSmarter zone to buy time in, so there it adds an hour to the local
|
|
// timer and says so; a real zone gets the purchase screen.
|
|
const extendLabel = p.area ? EXTEND_LABEL : free ? 'Pay' : 'Extend';
|
|
|
|
if (hasNativeCountdown) {
|
|
const diag = await showSession(title, body, p.endMs, 'End', extendLabel);
|
|
logLine(`[PARKING] ${p.kind} native: ${diag}`);
|
|
return;
|
|
}
|
|
|
|
// Expo Go / no native module: a static ongoing notification, no buttons and no
|
|
// ticking, refreshed whenever the app is foregrounded.
|
|
logLine('[PARKING] no native module — static fallback notification');
|
|
await Notifications.scheduleNotificationAsync({
|
|
identifier: FALLBACK_NOTIF_ID,
|
|
content: {
|
|
title,
|
|
body,
|
|
sticky: true,
|
|
autoDismiss: false,
|
|
data: { kind: 'session-status' },
|
|
},
|
|
trigger:
|
|
Platform.OS === 'android'
|
|
? {
|
|
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
|
date: new Date(Date.now() + 400),
|
|
channelId: 'session-status',
|
|
}
|
|
: null,
|
|
});
|
|
}
|
|
|
|
async function clearNotification(): Promise<void> {
|
|
await clearSession().catch(() => {});
|
|
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
|
await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- reminders */
|
|
|
|
async function scheduleExpiryReminder(p: ActiveParking): Promise<void> {
|
|
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
|
if (!(await getRemindersEnabled())) return;
|
|
const fireAt = p.endMs - p.leadMinutes * 60_000;
|
|
if (fireAt <= Date.now()) return;
|
|
|
|
const free = p.kind === 'free';
|
|
await Notifications.scheduleNotificationAsync({
|
|
identifier: EXPIRY_REMINDER_ID,
|
|
content: {
|
|
title: free ? 'Free parking ending soon' : 'Parking expiring soon',
|
|
body: `${p.zoneName}: ${free ? 'your free time ends' : 'your session ends'} at ${fmtTime(
|
|
p.endMs,
|
|
)}. Extend if you need more time.`,
|
|
data: { kind: 'parking-expiry' },
|
|
},
|
|
trigger: {
|
|
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
|
date: new Date(fireAt),
|
|
channelId: REMINDER_CHANNEL_ID,
|
|
},
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------ start / stop */
|
|
|
|
/** Begin tracking a paid session that ParkSmarter has just confirmed. */
|
|
export async function startPaidSession(args: {
|
|
zone: Zone;
|
|
endTime: Date;
|
|
transactionId?: string | number;
|
|
}): Promise<void> {
|
|
const state: ActiveParking = {
|
|
kind: 'paid',
|
|
zone: args.zone,
|
|
zoneName: args.zone.ZoneName ?? 'Parking',
|
|
startMs: Date.now(),
|
|
endMs: args.endTime.getTime(),
|
|
transactionId: args.transactionId != null ? String(args.transactionId) : undefined,
|
|
leadMinutes: await getReminderLeadMinutes(),
|
|
};
|
|
await setActiveParking(state);
|
|
await postNotification(state);
|
|
await scheduleExpiryReminder(state);
|
|
}
|
|
|
|
/** Start a local free check-in for `hours` at the given zone. */
|
|
export async function startFreeCheckin(
|
|
zone: Zone,
|
|
hours: number,
|
|
labelKind?: LabelKind,
|
|
): Promise<void> {
|
|
const now = Date.now();
|
|
const state: ActiveParking = {
|
|
kind: 'free',
|
|
zone,
|
|
zoneName: zone.ZoneName ?? 'Parking',
|
|
startMs: now,
|
|
endMs: now + hours * 3_600_000,
|
|
labelKind: labelKind ?? (`free_${hours}h` as LabelKind),
|
|
leadMinutes: await getReminderLeadMinutes(),
|
|
};
|
|
await setActiveParking(state);
|
|
await postNotification(state);
|
|
await scheduleExpiryReminder(state);
|
|
}
|
|
|
|
/**
|
|
* Start tracking time on an area from the city parking map.
|
|
*
|
|
* Deliberately the whole story: no ParkSmarter call, no account, no network. The
|
|
* area came from the local database, the clock is the phone's, and the countdown
|
|
* is the same foreground service every other session uses. Works in Anonymous
|
|
* Mode, offline, and with the IPS API down.
|
|
*/
|
|
export async function startAreaParking(args: {
|
|
area: ParkingArea;
|
|
hours: number;
|
|
spot?: ParkedSpot;
|
|
}): Promise<void> {
|
|
const now = Date.now();
|
|
const state: ActiveParking = {
|
|
// Only the city lots cost money; everything else on the map is free parking
|
|
// that merely has a posted time limit.
|
|
kind: args.area.kind === 'green_lot' ? 'paid' : 'free',
|
|
area: {
|
|
id: args.area.id,
|
|
kind: args.area.kind,
|
|
name: args.area.name,
|
|
legend: args.area.legend,
|
|
color: args.area.color,
|
|
},
|
|
spot: args.spot,
|
|
zoneName: args.area.name,
|
|
startMs: now,
|
|
endMs: now + Math.round(args.hours * 3_600_000),
|
|
leadMinutes: await getReminderLeadMinutes(),
|
|
};
|
|
await setActiveParking(state);
|
|
if (args.spot) await setParkedPin(args.spot);
|
|
await postNotification(state);
|
|
await scheduleExpiryReminder(state);
|
|
logLine(`[PARKING] city area ${args.area.id} (${args.area.kind}) for ${args.hours}h`);
|
|
}
|
|
|
|
/**
|
|
* Drop the "where is my car" pin. Deliberately independent of any session — you
|
|
* can pin the car without starting a timer, and the pin has to survive that.
|
|
*/
|
|
export async function pinParkedSpot(spot: ParkedSpot): Promise<void> {
|
|
await setParkedPin(spot);
|
|
const current = await getActiveParking();
|
|
if (current) await setActiveParking({ ...current, spot });
|
|
}
|
|
|
|
/**
|
|
* Add time to a city-map session's local timer. There is nothing to buy here —
|
|
* the app is only tracking a clock — so extending is a local edit, not a purchase.
|
|
*/
|
|
export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void> {
|
|
const current = await getActiveParking();
|
|
if (!current) return;
|
|
// Extend from now if it already lapsed, so "+1 hr" always means a full hour.
|
|
const from = Math.max(current.endMs, Date.now());
|
|
const next = { ...current, endMs: from + minutes * 60_000 };
|
|
await setActiveParking(next);
|
|
await postNotification(next);
|
|
await scheduleExpiryReminder(next);
|
|
}
|
|
|
|
/**
|
|
* Stop tracking the active session and take its notification down.
|
|
*
|
|
* For a free check-in this genuinely ends it. For a paid session it only stops
|
|
* tracking — ParkSmarter has no stop-session endpoint, so the time you bought
|
|
* keeps running at the meter whether or not the app is showing it.
|
|
*/
|
|
export async function endActiveParking(): Promise<void> {
|
|
await clearActiveParking();
|
|
await clearNotification();
|
|
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- syncing */
|
|
|
|
/**
|
|
* Ask the server whether a paid session is running. Only used when nothing is
|
|
* tracked locally — a session bought on another device, or before this version
|
|
* started persisting them. The active-session API returns no Zone, so a session
|
|
* found this way has no zone to extend into.
|
|
*/
|
|
async function discoverPaidSession(): Promise<ActiveParking | null> {
|
|
try {
|
|
const res = await ps.getActiveParkingSessions();
|
|
const soonest = (res.ParkingSession ?? [])
|
|
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
|
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
|
.sort((a, b) => a.end!.getTime() - b.end!.getTime())[0];
|
|
if (!soonest) return null;
|
|
|
|
const found: ActiveParking = {
|
|
kind: 'paid',
|
|
zoneName: soonest.s.ZoneName ?? 'Parking',
|
|
startMs: parseApiTime(soonest.s.StartTime)?.getTime() ?? Date.now(),
|
|
endMs: soonest.end!.getTime(),
|
|
transactionId:
|
|
soonest.s.TransactionID != null ? String(soonest.s.TransactionID) : undefined,
|
|
leadMinutes: await getReminderLeadMinutes(),
|
|
};
|
|
logLine(`[PARKING] discovered server session ending ${new Date(found.endMs).toISOString()}`);
|
|
await setActiveParking(found);
|
|
await scheduleExpiryReminder(found);
|
|
return found;
|
|
} catch (e: any) {
|
|
logLine(`[PARKING] discover failed: ${e?.serverMessage ?? e?.message ?? e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconcile the record, its notification and any button the user pressed while the
|
|
* app was away. Safe to call repeatedly; it's the app's foreground heartbeat.
|
|
*/
|
|
export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promise<void> {
|
|
const action = await consumePendingAction();
|
|
|
|
if (action === 'end') {
|
|
logLine('[PARKING] notification "End" pressed');
|
|
await endActiveParking();
|
|
return;
|
|
}
|
|
|
|
let current = await getActiveParking();
|
|
|
|
// The meter ran out: the service already removed its own notification when the
|
|
// countdown hit zero, so this just clears the record behind it.
|
|
if (current && current.endMs <= Date.now()) {
|
|
await endActiveParking();
|
|
current = null;
|
|
}
|
|
|
|
// "+1 hr" on a city-map session is a local edit, not a purchase — handle it here
|
|
// and stay put rather than sending the user to a payment screen for a free spot.
|
|
if (action === 'extend' && current?.area) {
|
|
logLine(`[PARKING] notification "${EXTEND_LABEL}" pressed on city area ${current.area.id}`);
|
|
await extendAreaParking();
|
|
return;
|
|
}
|
|
|
|
// A city-map session is never on the ParkSmarter server, so don't let a stale
|
|
// server session overwrite it.
|
|
if (!current) current = await discoverPaidSession();
|
|
|
|
if (current) {
|
|
await postNotification(current);
|
|
} else {
|
|
await clearNotification();
|
|
}
|
|
|
|
if (action === 'extend') {
|
|
logLine('[PARKING] notification "Extend" pressed');
|
|
onExtend(current?.zone);
|
|
}
|
|
}
|
|
|
|
/** Called by the tab navigator: sync on open and on every foreground. */
|
|
export function useActiveParkingSync(): void {
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
|
|
|
useEffect(() => {
|
|
const run = () =>
|
|
void syncActiveParking((zone) => {
|
|
// "Extend" means "sell me more time for this exact spot" — go straight to
|
|
// the purchase screen for the stored zone. A server-discovered session has
|
|
// no zone, so fall back to the sessions list rather than guessing.
|
|
if (zone) navigation.navigate('StartSession', { zone });
|
|
else navigation.navigate('Tabs');
|
|
});
|
|
|
|
run();
|
|
const sub = AppState.addEventListener('change', (state) => {
|
|
if (state === 'active') run();
|
|
});
|
|
return () => sub.remove();
|
|
}, [navigation]);
|
|
}
|
|
|
|
/** Turn the countdown notification on/off from Settings without touching the record. */
|
|
export async function refreshParkingNotification(): Promise<void> {
|
|
const current = await getActiveParking();
|
|
if (current && current.endMs > Date.now()) await postNotification(current);
|
|
else await clearNotification();
|
|
}
|