v0.3.0: local free check-in with actionable countdown notification (Phase C)
All checks were successful
build-apk / build (push) Successful in 40m22s
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:
parent
eeaf09ea0e
commit
463facbe5a
10 changed files with 368 additions and 59 deletions
135
app/src/features/checkin/checkin.ts
Normal file
135
app/src/features/checkin/checkin.ts
Normal 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]);
|
||||
}
|
||||
32
app/src/features/checkin/checkinStore.ts
Normal file
32
app/src/features/checkin/checkinStore.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue