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