From 463facbe5ada1c9550691b7be91411d64b95fea0 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 18:23:58 +0000 Subject: [PATCH] v0.3.0: local free check-in with actionable countdown notification (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/app.json | 4 +- .../android/src/main/AndroidManifest.xml | 9 ++ .../modules/bbpnotify/BbpActionReceiver.kt | 31 ++++ .../expo/modules/bbpnotify/BbpNotifyModule.kt | 148 +++++++++++------- app/modules/bbp-notify/index.ts | 29 ++++ app/src/features/checkin/checkin.ts | 135 ++++++++++++++++ app/src/features/checkin/checkinStore.ts | 32 ++++ app/src/navigation/RootNavigator.tsx | 3 + app/src/screens/MeterDetailScreen.tsx | 33 +++- app/src/screens/StartSessionScreen.tsx | 3 + 10 files changed, 368 insertions(+), 59 deletions(-) create mode 100644 app/modules/bbp-notify/android/src/main/AndroidManifest.xml create mode 100644 app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt create mode 100644 app/src/features/checkin/checkin.ts create mode 100644 app/src/features/checkin/checkinStore.ts diff --git a/app/app.json b/app/app.json index d6aed01..204b435 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.2.6", + "version": "0.3.0", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 16, + "versionCode": 17, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/modules/bbp-notify/android/src/main/AndroidManifest.xml b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..60c738b --- /dev/null +++ b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt new file mode 100644 index 0000000..6647f2a --- /dev/null +++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt @@ -0,0 +1,31 @@ +package expo.modules.bbpnotify + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat + +/** + * Handles the check-in notification's "End" / "Pay" buttons, even when the app + * process is dead. It records the choice in SharedPreferences (read by JS via + * `consumePendingAction` on next foreground) and, for "Pay", relaunches the app + * so the check-in can hand off to the paid-session flow. + */ +class BbpActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val prefs = context.getSharedPreferences(BbpNotifyModule.PREFS_NAME, Context.MODE_PRIVATE) + when (intent.action) { + BbpNotifyModule.ACTION_END -> { + prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply() + NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID) + } + BbpNotifyModule.ACTION_PAY -> { + prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply() + context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch -> + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launch) + } + } + } + } +} diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt index d13a962..914fae9 100644 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt +++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt @@ -4,6 +4,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -11,63 +12,32 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition /** - * A tiny, self-contained module that posts an ongoing notification whose "time" - * is a native Android chronometer counting DOWN to the session's end. The system - * ticks it every second with no app CPU/battery — works while the app is closed. + * Posts an ongoing notification whose "time" is a native Android chronometer + * counting DOWN to an end time — the system ticks it every second with no app + * CPU/battery, so it works while the app is closed. * - * Uses only platform APIs (NotificationCompat) — no third-party dependencies. + * Two flavours: a plain paid-session countdown (`showCountdown`) and a free + * check-in countdown (`showCheckin`) that adds "End" / "Pay" action buttons. + * The buttons fire a BroadcastReceiver ([BbpActionReceiver]) that records the + * choice in SharedPreferences; JS reads it via `consumePendingAction` on the + * next foreground. Uses only platform APIs — no third-party dependencies. */ class BbpNotifyModule : Module() { override fun definition() = ModuleDefinition { Name("BbpNotify") - // Returns a short diagnostic string so the JS layer can log exactly what - // happened (posted / notifications-disabled / exception). "Nothing appears" - // bugs are otherwise invisible because notify() fails silently. + // Returns a short diagnostic string so JS can log exactly what happened + // (posted / notifications-disabled / exception) — notify() fails silently. AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double -> - val ctx = appContext.reactContext - ?: return@AsyncFunction "no-context" - ensureChannel(ctx) + val ctx = appContext.reactContext ?: return@AsyncFunction "no-context" + postCountdown(ctx, title, body, endTimeMillis, null, null) + } - val mgr = NotificationManagerCompat.from(ctx) - val enabled = mgr.areNotificationsEnabled() - - val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) - .setContentTitle(title) - .setContentText(body) - .setSmallIcon(R.drawable.bbp_stat_parking) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setShowWhen(true) - .setWhen(endTimeMillis.toLong()) - .setUsesChronometer(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_STATUS) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - builder.setChronometerCountDown(true) - } - - ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch -> - builder.setContentIntent( - PendingIntent.getActivity( - ctx, - 0, - launch, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ), - ) - } - - return@AsyncFunction try { - mgr.notify(NOTIF_ID, builder.build()) - "posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}" - } catch (e: SecurityException) { - // POST_NOTIFICATIONS not granted. - "security-exception enabled=$enabled msg=${e.message}" - } catch (e: Exception) { - "exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}" - } + // Same ticking countdown, plus "End" and "Pay" action buttons. + AsyncFunction("showCheckin") { + title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String -> + val ctx = appContext.reactContext ?: return@AsyncFunction "no-context" + postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel) } AsyncFunction("clear") { @@ -76,17 +46,81 @@ class BbpNotifyModule : Module() { NotificationManagerCompat.from(ctx).cancel(NOTIF_ID) } } + + // Read + clear the action a notification button set while the app was away. + // Returns "end", "pay", or "". + AsyncFunction("consumePendingAction") { + val ctx = appContext.reactContext ?: return@AsyncFunction "" + val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val action = prefs.getString(PREF_PENDING, "") ?: "" + if (action.isNotEmpty()) prefs.edit().remove(PREF_PENDING).apply() + action + } + } + + private fun postCountdown( + ctx: Context, + title: String, + body: String, + endTimeMillis: Double, + endLabel: String?, + payLabel: String?, + ): String { + ensureChannel(ctx) + val mgr = NotificationManagerCompat.from(ctx) + val enabled = mgr.areNotificationsEnabled() + + val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(R.drawable.bbp_stat_parking) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(true) + .setWhen(endTimeMillis.toLong()) + .setUsesChronometer(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_STATUS) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setChronometerCountDown(true) + } + + ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch -> + builder.setContentIntent( + PendingIntent.getActivity( + ctx, 0, launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + } + + if (endLabel != null) builder.addAction(0, endLabel, actionIntent(ctx, ACTION_END)) + if (payLabel != null) builder.addAction(0, payLabel, actionIntent(ctx, ACTION_PAY)) + + return try { + mgr.notify(NOTIF_ID, builder.build()) + "posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}" + } catch (e: SecurityException) { + "security-exception enabled=$enabled msg=${e.message}" + } catch (e: Exception) { + "exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}" + } + } + + private fun actionIntent(ctx: Context, action: String): PendingIntent { + val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action) + return PendingIntent.getBroadcast( + ctx, action.hashCode(), intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } private fun ensureChannel(ctx: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val mgr = ctx.getSystemService(NotificationManager::class.java) if (mgr.getNotificationChannel(CHANNEL_ID) == null) { - val channel = NotificationChannel( - CHANNEL_ID, - "Active parking", - NotificationManager.IMPORTANCE_LOW, - ) + val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW) channel.setShowBadge(false) mgr.createNotificationChannel(channel) } @@ -95,6 +129,10 @@ class BbpNotifyModule : Module() { companion object { private const val CHANNEL_ID = "session-status" - private const val NOTIF_ID = 42421 + const val NOTIF_ID = 42421 + const val PREFS_NAME = "bbp_notify" + const val PREF_PENDING = "pending_action" + const val ACTION_END = "expo.modules.bbpnotify.END" + const val ACTION_PAY = "expo.modules.bbpnotify.PAY" } } diff --git a/app/modules/bbp-notify/index.ts b/app/modules/bbp-notify/index.ts index ee513a8..cf085e4 100644 --- a/app/modules/bbp-notify/index.ts +++ b/app/modules/bbp-notify/index.ts @@ -7,10 +7,22 @@ interface BbpNotifyNative { * to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true"). */ showCountdown(title: string, body: string, endTimeMillis: number): Promise; + /** Same ticking countdown, plus "End" / "Pay" action buttons. */ + showCheckin( + title: string, + body: string, + endTimeMillis: number, + endLabel: string, + payLabel: string, + ): Promise; /** Remove the countdown notification. */ clear(): Promise; + /** Read + clear the action a notification button set: 'end' | 'pay' | ''. */ + consumePendingAction(): Promise; } +export type PendingAction = 'end' | 'pay' | null; + // Android-only, and only present in a build that includes the native module // (returns null in Expo Go / other platforms — callers degrade gracefully). const native = @@ -30,6 +42,23 @@ export async function showCountdown( return native.showCountdown(title, body, endTimeMillis); } +export async function showCheckin( + title: string, + body: string, + endTimeMillis: number, + endLabel: string, + payLabel: string, +): Promise { + if (!native) return 'no-native-module'; + return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel); +} + export async function clearCountdown(): Promise { await native?.clear(); } + +/** Read + clear a notification-button action taken while the app was away. */ +export async function consumePendingAction(): Promise { + const a = await native?.consumePendingAction(); + return a === 'end' || a === 'pay' ? a : null; +} diff --git a/app/src/features/checkin/checkin.ts b/app/src/features/checkin/checkin.ts new file mode 100644 index 0000000..fd15133 --- /dev/null +++ b/app/src/features/checkin/checkin.ts @@ -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 { + 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 { + 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 { + 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 { + 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>(); + 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]); +} diff --git a/app/src/features/checkin/checkinStore.ts b/app/src/features/checkin/checkinStore.ts new file mode 100644 index 0000000..adf0f32 --- /dev/null +++ b/app/src/features/checkin/checkinStore.ts @@ -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 { + const raw = await AsyncStorage.getItem(KEY); + return raw ? (JSON.parse(raw) as CheckinState) : null; +} + +export async function setCheckin(state: CheckinState): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(state)); +} + +export async function clearCheckinState(): Promise { + await AsyncStorage.removeItem(KEY); +} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index 304cc9b..9ea996c 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -26,6 +26,7 @@ import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { AdminScreen } from '@/screens/AdminScreen'; import { useTheme } from '@/theme/ThemeContext'; import { useSessionStatusSync } from '@/notifications/sessionStatus'; +import { useCheckinSync } from '@/features/checkin/checkin'; import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; export type RootStackParamList = { @@ -73,6 +74,8 @@ const TAB_ICONS: Record = { function Tabs() { // Keep the ongoing "time left" status notification fresh (on open + foreground). useSessionStatusSync(); + // Apply check-in notification actions (End/Pay) + keep its countdown in sync. + useCheckinSync(); return ( ({ diff --git a/app/src/screens/MeterDetailScreen.tsx b/app/src/screens/MeterDetailScreen.tsx index 4c9075c..07c9bcf 100644 --- a/app/src/screens/MeterDetailScreen.tsx +++ b/app/src/screens/MeterDetailScreen.tsx @@ -11,12 +11,14 @@ import { getAdminToken } from '@/api/adminStore'; import { deleteLabel, getCachedLabel, + labelHours, labelText, refreshLabels, setLabel, type LabelKind, type ZoneLabel, } from '@/api/zoneLabels'; +import { startCheckin } from '@/features/checkin/checkin'; /** Admin labeling buttons — kind, or 'clear' to remove. */ const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [ @@ -133,6 +135,18 @@ export function MeterDetailScreen() { Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`); }; + const freeHours = label ? labelHours(label.kind) : null; + + const onCheckin = async () => { + if (!freeHours) return; + await startCheckin(z, freeHours); + Alert.alert( + 'Checked in', + `Free parking for ${freeHours}h. You'll get a heads-up before it ends — with buttons to pay or end.`, + [{ text: 'OK', onPress: () => navigation.navigate('Tabs') }], + ); + }; + const firstSpace = z.Spaces?.[0]; const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot); @@ -244,11 +258,25 @@ export function MeterDetailScreen() { + {freeHours ? ( + + + Check in (free · {freeHours}h) + + + ) : null} + + {label?.kind === 'pay_immediate' ? ( + Pay immediately — no free window here. + ) : null} + navigation.navigate('StartSession', { zone: z })} > - Start parking session + + Start parking session + ); @@ -273,4 +301,5 @@ const styles = StyleSheet.create({ badgeText: { fontSize: 13, fontWeight: '700' }, chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 }, + note: { marginTop: 12, fontSize: 13, textAlign: 'center' }, }); diff --git a/app/src/screens/StartSessionScreen.tsx b/app/src/screens/StartSessionScreen.tsx index 522b713..53d77cd 100644 --- a/app/src/screens/StartSessionScreen.tsx +++ b/app/src/screens/StartSessionScreen.tsx @@ -16,6 +16,7 @@ import { ps } from '@/api/client'; import { useTheme } from '@/theme/ThemeContext'; import { scheduleExpiryReminder } from '@/notifications/localReminders'; import { showSessionStatus } from '@/notifications/sessionStatus'; +import { endCheckin } from '@/features/checkin/checkin'; import { logLine } from '@/features/diagnostics/fileLogger'; import type { RootStackParamList } from '@/navigation/RootNavigator'; import { @@ -274,6 +275,8 @@ export function StartSessionScreen() { minCreditAmount: zone.MinimumAmount, meterTypeId: zone.MeterTypeId!, }); + // Paid session supersedes any free check-in (they share the notification). + await endCheckin(); // Schedule the local expiry reminder from the purchased end time. const end = parseApiTime(selected.EndTime); if (end) {