v0.3.0: local free check-in with actionable countdown notification (Phase C)
All checks were successful
build-apk / build (push) Successful in 40m22s

For a zone labeled free_2h/3h/4h, "Check in (free · Xh)" starts a local, API-free
countdown to the free limit — no ParkSmarter call. The bbp-notify module gains a
showCheckin() that posts the ticking chronometer with "End" / "Pay" buttons, a
BbpActionReceiver (declared in the module's new AndroidManifest.xml) that records
the tap in SharedPreferences and, for Pay, relaunches the app; consumePendingAction()
is drained on foreground by useCheckinSync() — End clears the check-in, Pay hands
off to StartSession for the stored zone. A pre-expiry alert reuses the existing
reminder lead-minutes. Starting a paid session supersedes an active check-in.

Also: MeterDetail shows the free/pay-immediate label and (admins) labeling chips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-24 18:23:58 +00:00
parent eeaf09ea0e
commit 463facbe5a
10 changed files with 368 additions and 59 deletions

View file

@ -0,0 +1,135 @@
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 type { RootStackParamList } from '@/navigation/RootNavigator';
import {
showCheckin,
clearCountdown,
consumePendingAction,
hasNativeCountdown,
} from '../../../modules/bbp-notify';
import { getReminderLeadMinutes, getRemindersEnabled, getCountdownEnabled } from '@/features/notifications/reminderPrefs';
import { ensureNotificationPermission } from '@/notifications/localReminders';
import { logLine } from '@/features/diagnostics/fileLogger';
import { labelHours, type LabelKind } from '@/api/zoneLabels';
import { getCheckin, setCheckin, clearCheckinState, type CheckinState } from './checkinStore';
const ALERT_ID = 'checkin-alert';
const FALLBACK_NOTIF_ID = 'checkin-status';
function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/** Post (or re-post) the ongoing ticking check-in notification with End/Pay buttons. */
async function postCheckinNotification(s: CheckinState): Promise<void> {
if (!(await getCountdownEnabled())) return;
const ends = fmtTime(s.endMs);
if (hasNativeCountdown) {
const diag = await showCheckin(`Free parking · ${s.zoneName}`, `Free until ${ends}`, s.endMs, 'End', 'Pay');
logLine(`[CHECKIN] native: ${diag}`);
return;
}
// Fallback (Expo Go / non-native): a static ongoing notification, no buttons.
await Notifications.scheduleNotificationAsync({
identifier: FALLBACK_NOTIF_ID,
content: {
title: `Free parking · ${s.zoneName}`,
body: `Free until ${ends}`,
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 scheduleCheckinAlert(s: CheckinState): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
if (!(await getRemindersEnabled())) return;
const fireAt = s.endMs - s.leadMinutes * 60_000;
if (fireAt <= Date.now()) return;
await Notifications.scheduleNotificationAsync({
identifier: ALERT_ID,
content: {
title: 'Free parking ending soon',
body: `${s.zoneName}: your free time ends at ${fmtTime(s.endMs)}. Pay to extend or move your car.`,
data: { kind: 'checkin-alert' },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(fireAt),
channelId: 'session-reminders',
},
});
}
/** Start a local free check-in for `hours` at the given zone. */
export async function startCheckin(zone: Zone, hours: number): Promise<void> {
const now = Date.now();
const state: CheckinState = {
zone,
zoneName: zone.ZoneName ?? 'Parking',
startMs: now,
endMs: now + hours * 3_600_000,
kind: `free_${hours}h` as LabelKind,
leadMinutes: await getReminderLeadMinutes(),
};
await setCheckin(state);
await ensureNotificationPermission();
await postCheckinNotification(state);
await scheduleCheckinAlert(state);
}
/** End the active check-in and clear its notifications. */
export async function endCheckin(): Promise<void> {
await clearCheckinState();
await clearCountdown().catch(() => {});
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
}
/** Hours of free time for a label kind, or null for pay-immediate. */
export { labelHours };
/**
* Keep the check-in in sync on foreground: apply any notification-button action
* (End clears it; Pay hands off to the paid flow), drop expired check-ins, and
* re-post the ongoing notification (e.g. after a reboot). Mirrors useSessionStatusSync.
*/
export function useCheckinSync(): void {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
useEffect(() => {
const run = async () => {
const action = await consumePendingAction();
if (action === 'end') {
await endCheckin();
return;
}
if (action === 'pay') {
const s = await getCheckin();
await endCheckin();
if (s?.zone) navigation.navigate('StartSession', { zone: s.zone });
return;
}
const s = await getCheckin();
if (!s) return;
if (s.endMs <= Date.now()) {
await endCheckin();
} else {
await postCheckinNotification(s);
}
};
void run();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') void run();
});
return () => sub.remove();
}, [navigation]);
}

View file

@ -0,0 +1,32 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Zone } from 'parksmarter-client';
import type { LabelKind } from '@/api/zoneLabels';
/**
* A local, API-free "check-in" for a free time-limited space. The full Zone is
* stored so the notification's "Pay" button can hand off straight into the paid
* flow without re-fetching. Only one active check-in at a time.
*/
const KEY = 'ps_checkin';
export interface CheckinState {
zone: Zone;
zoneName: string;
startMs: number;
endMs: number;
kind: LabelKind;
leadMinutes: number;
}
export async function getCheckin(): Promise<CheckinState | null> {
const raw = await AsyncStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as CheckinState) : null;
}
export async function setCheckin(state: CheckinState): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(state));
}
export async function clearCheckinState(): Promise<void> {
await AsyncStorage.removeItem(KEY);
}