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

@ -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",

View file

@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Fully-qualified name: relative ".Name" would resolve against the app
package, not this module's namespace, once manifests are merged. -->
<receiver
android:name="expo.modules.bbpnotify.BbpActionReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -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)
}
}
}
}
}

View file

@ -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"
}
}

View file

@ -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<string>;
/** Same ticking countdown, plus "End" / "Pay" action buttons. */
showCheckin(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
payLabel: string,
): Promise<string>;
/** Remove the countdown notification. */
clear(): Promise<void>;
/** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
consumePendingAction(): Promise<string>;
}
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<string> {
if (!native) return 'no-native-module';
return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
}
export async function clearCountdown(): Promise<void> {
await native?.clear();
}
/** Read + clear a notification-button action taken while the app was away. */
export async function consumePendingAction(): Promise<PendingAction> {
const a = await native?.consumePendingAction();
return a === 'end' || a === 'pay' ? a : null;
}

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);
}

View file

@ -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<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
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 (
<Tab.Navigator
screenOptions={({ route }) => ({

View file

@ -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() {
</Text>
</TouchableOpacity>
{freeHours ? (
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
<Text style={[styles.buttonText, { color: '#fff' }]}>
Check in (free · {freeHours}h)
</Text>
</TouchableOpacity>
) : null}
{label?.kind === 'pay_immediate' ? (
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately no free window here.</Text>
) : null}
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]}
style={[styles.button, { backgroundColor: freeHours ? colors.card : colors.primary }]}
onPress={() => navigation.navigate('StartSession', { zone: z })}
>
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
<Text style={[styles.buttonText, { color: freeHours ? colors.text : '#fff' }]}>
Start parking session
</Text>
</TouchableOpacity>
</ScrollView>
);
@ -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' },
});

View file

@ -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) {