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 { 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 { await clearSession().catch(() => {}); await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {}); await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {}); } /* ---------------------------------------------------------------- reminders */ async function scheduleExpiryReminder(p: ActiveParking): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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>(); 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 { const current = await getActiveParking(); if (current && current.endMs > Date.now()) await postNotification(current); else await clearNotification(); }