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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
|
@ -11,24 +12,61 @@ 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)
|
||||
}
|
||||
|
||||
// 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 enabled = mgr.areNotificationsEnabled()
|
||||
|
||||
|
|
@ -51,42 +89,38 @@ class BbpNotifyModule : Module() {
|
|||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
ctx,
|
||||
0,
|
||||
launch,
|
||||
ctx, 0, launch,
|
||||
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())
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("clear") {
|
||||
val ctx = appContext.reactContext
|
||||
if (ctx != null) {
|
||||
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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 { 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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue