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
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.2.6",
|
"version": "0.3.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"android": {
|
"android": {
|
||||||
"package": "top.mowden.bigbrainparking",
|
"package": "top.mowden.bigbrainparking",
|
||||||
"versionCode": 16,
|
"versionCode": 17,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
@ -11,24 +12,61 @@ import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tiny, self-contained module that posts an ongoing notification whose "time"
|
* Posts an ongoing notification whose "time" is a native Android chronometer
|
||||||
* is a native Android chronometer counting DOWN to the session's end. The system
|
* counting DOWN to an end time — the system ticks it every second with no app
|
||||||
* ticks it every second with no app CPU/battery — works while the app is closed.
|
* 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() {
|
class BbpNotifyModule : Module() {
|
||||||
override fun definition() = ModuleDefinition {
|
override fun definition() = ModuleDefinition {
|
||||||
Name("BbpNotify")
|
Name("BbpNotify")
|
||||||
|
|
||||||
// Returns a short diagnostic string so the JS layer can log exactly what
|
// Returns a short diagnostic string so JS can log exactly what happened
|
||||||
// happened (posted / notifications-disabled / exception). "Nothing appears"
|
// (posted / notifications-disabled / exception) — notify() fails silently.
|
||||||
// bugs are otherwise invisible because notify() fails silently.
|
|
||||||
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
||||||
val ctx = appContext.reactContext
|
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
||||||
?: return@AsyncFunction "no-context"
|
postCountdown(ctx, title, body, endTimeMillis, null, null)
|
||||||
ensureChannel(ctx)
|
}
|
||||||
|
|
||||||
|
// 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") {
|
||||||
|
val ctx = appContext.reactContext
|
||||||
|
if (ctx != null) {
|
||||||
|
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 mgr = NotificationManagerCompat.from(ctx)
|
||||||
val enabled = mgr.areNotificationsEnabled()
|
val enabled = mgr.areNotificationsEnabled()
|
||||||
|
|
||||||
|
|
@ -51,42 +89,38 @@ class BbpNotifyModule : Module() {
|
||||||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||||
builder.setContentIntent(
|
builder.setContentIntent(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
ctx,
|
ctx, 0, launch,
|
||||||
0,
|
|
||||||
launch,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return@AsyncFunction try {
|
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())
|
mgr.notify(NOTIF_ID, builder.build())
|
||||||
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS not granted.
|
|
||||||
"security-exception enabled=$enabled msg=${e.message}"
|
"security-exception enabled=$enabled msg=${e.message}"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
|
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("clear") {
|
private fun actionIntent(ctx: Context, action: String): PendingIntent {
|
||||||
val ctx = appContext.reactContext
|
val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
|
||||||
if (ctx != null) {
|
return PendingIntent.getBroadcast(
|
||||||
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
|
ctx, action.hashCode(), intent,
|
||||||
}
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureChannel(ctx: Context) {
|
private fun ensureChannel(ctx: Context) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
|
||||||
CHANNEL_ID,
|
|
||||||
"Active parking",
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
)
|
|
||||||
channel.setShowBadge(false)
|
channel.setShowBadge(false)
|
||||||
mgr.createNotificationChannel(channel)
|
mgr.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +129,10 @@ class BbpNotifyModule : Module() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CHANNEL_ID = "session-status"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,22 @@ interface BbpNotifyNative {
|
||||||
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
|
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
|
||||||
*/
|
*/
|
||||||
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
|
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. */
|
/** Remove the countdown notification. */
|
||||||
clear(): Promise<void>;
|
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
|
// Android-only, and only present in a build that includes the native module
|
||||||
// (returns null in Expo Go / other platforms — callers degrade gracefully).
|
// (returns null in Expo Go / other platforms — callers degrade gracefully).
|
||||||
const native =
|
const native =
|
||||||
|
|
@ -30,6 +42,23 @@ export async function showCountdown(
|
||||||
return native.showCountdown(title, body, endTimeMillis);
|
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> {
|
export async function clearCountdown(): Promise<void> {
|
||||||
await native?.clear();
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
||||||
import { AdminScreen } from '@/screens/AdminScreen';
|
import { AdminScreen } from '@/screens/AdminScreen';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { useSessionStatusSync } from '@/notifications/sessionStatus';
|
import { useSessionStatusSync } from '@/notifications/sessionStatus';
|
||||||
|
import { useCheckinSync } from '@/features/checkin/checkin';
|
||||||
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
|
@ -73,6 +74,8 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
||||||
function Tabs() {
|
function Tabs() {
|
||||||
// Keep the ongoing "time left" status notification fresh (on open + foreground).
|
// Keep the ongoing "time left" status notification fresh (on open + foreground).
|
||||||
useSessionStatusSync();
|
useSessionStatusSync();
|
||||||
|
// Apply check-in notification actions (End/Pay) + keep its countdown in sync.
|
||||||
|
useCheckinSync();
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ import { getAdminToken } from '@/api/adminStore';
|
||||||
import {
|
import {
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
getCachedLabel,
|
getCachedLabel,
|
||||||
|
labelHours,
|
||||||
labelText,
|
labelText,
|
||||||
refreshLabels,
|
refreshLabels,
|
||||||
setLabel,
|
setLabel,
|
||||||
type LabelKind,
|
type LabelKind,
|
||||||
type ZoneLabel,
|
type ZoneLabel,
|
||||||
} from '@/api/zoneLabels';
|
} from '@/api/zoneLabels';
|
||||||
|
import { startCheckin } from '@/features/checkin/checkin';
|
||||||
|
|
||||||
/** Admin labeling buttons — kind, or 'clear' to remove. */
|
/** Admin labeling buttons — kind, or 'clear' to remove. */
|
||||||
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
|
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.`);
|
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 firstSpace = z.Spaces?.[0];
|
||||||
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
|
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
|
||||||
|
|
||||||
|
|
@ -244,11 +258,25 @@ export function MeterDetailScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</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
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
style={[styles.button, { backgroundColor: freeHours ? colors.card : colors.primary }]}
|
||||||
onPress={() => navigation.navigate('StartSession', { zone: z })}
|
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>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
@ -273,4 +301,5 @@ const styles = StyleSheet.create({
|
||||||
badgeText: { fontSize: 13, fontWeight: '700' },
|
badgeText: { fontSize: 13, fontWeight: '700' },
|
||||||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
|
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
|
||||||
|
note: { marginTop: 12, fontSize: 13, textAlign: 'center' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { ps } from '@/api/client';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
||||||
import { showSessionStatus } from '@/notifications/sessionStatus';
|
import { showSessionStatus } from '@/notifications/sessionStatus';
|
||||||
|
import { endCheckin } from '@/features/checkin/checkin';
|
||||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import {
|
import {
|
||||||
|
|
@ -274,6 +275,8 @@ export function StartSessionScreen() {
|
||||||
minCreditAmount: zone.MinimumAmount,
|
minCreditAmount: zone.MinimumAmount,
|
||||||
meterTypeId: zone.MeterTypeId!,
|
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.
|
// Schedule the local expiry reminder from the purchased end time.
|
||||||
const end = parseApiTime(selected.EndTime);
|
const end = parseApiTime(selected.EndTime);
|
||||||
if (end) {
|
if (end) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue