v0.5.0: persistent parking notification — foreground service, End/Extend
All checks were successful
build-apk / build (push) Successful in 28m55s
All checks were successful
build-apk / build (push) Successful in 28m55s
The ongoing countdown now appears whenever a car is parked, paid or free,
and sticks around the way ntfy's does on GrapheneOS.
Why it wasn't showing at all on a paid park: StartSessionScreen carried its
own stricter copy of parseApiTime that demanded MM-DD-YYYY + AM/PM. The
dual-format fix from deb78bf only landed in sessionStatus.ts, so when the
API returned the other format the parser returned null and the notification
was simply never posted. There is now one parser (api/parseTime.ts), and the
failure logs instead of going silent.
Persistence: a plain notify() was never enough — Android 14+ lets the user
swipe an ongoing notification away, and nothing brought it back after a
reboot. BbpSessionService is a real foreground service (type specialUse) that
owns the notification, plus a BOOT_COMPLETED receiver to restore it; specialUse
is one of the types Android 14/15 still allow to start from BOOT_COMPLETED.
The service's life is exactly the session's life: it stops itself, removing
the notification, on End, at expiry, or when there's no session to show.
Buttons: paid sessions previously got no actions at all (only free check-ins
did). Both now get End and Extend/Pay. End stops tracking — honest about the
fact that ParkSmarter has no stop-session endpoint, so bought time keeps
running at the meter. Extend opens the purchase screen for that exact zone.
The active session (with its Zone) is persisted locally, so the countdown and
Extend survive reboot, offline, and Anonymous Mode instead of depending on a
round-trip. sessionStatus.ts + features/checkin collapse into one owner,
features/session/activeParking — one record, one notification.
Verified: :bbp-notify:compileDebugKotlin passes, and the merged app manifest
carries the service (specialUse + FGS subtype property), both receivers, and
the FOREGROUND_SERVICE/SPECIAL_USE/RECEIVE_BOOT_COMPLETED permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
796674bca4
commit
ad55559f55
21 changed files with 897 additions and 564 deletions
36
README.md
36
README.md
|
|
@ -44,6 +44,7 @@ bigbrainparking/
|
||||||
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
||||||
| Active / past sessions | ✅ wired | list views + tap for full receipt |
|
| Active / past sessions | ✅ wired | list views + tap for full receipt |
|
||||||
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
|
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
|
||||||
|
| Ongoing parking countdown | ✅ wired | foreground service; ticks down, **End** / **Extend** buttons, survives reboot |
|
||||||
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
|
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
|
||||||
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
|
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
|
||||||
|
|
||||||
|
|
@ -75,12 +76,35 @@ in `app/app.json` to point elsewhere.
|
||||||
|
|
||||||
## Notifications on GrapheneOS
|
## Notifications on GrapheneOS
|
||||||
|
|
||||||
Session-expiry reminders are scheduled **entirely on-device** from each session's end time
|
Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services —
|
||||||
(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play
|
so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for
|
||||||
Services. They work fully offline. Configure the lead time (default 15 min) in
|
any *future* server-initiated messages; nothing time-based needs it.
|
||||||
**Account → Notifications**, where a **"Send a test reminder"** button lets you confirm it
|
|
||||||
fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any
|
**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or
|
||||||
*future* server-initiated messages; nothing time-based needs it.
|
a free check-in — a persistent notification shows the time left and ticks down, with
|
||||||
|
**End** and **Extend** buttons. It is held up by a real **foreground service**
|
||||||
|
(`modules/bbp-notify`, type `specialUse`), which is what makes it stick on GrapheneOS the
|
||||||
|
way ntfy's does: it survives the app being killed, can't be swiped away, and a
|
||||||
|
`BOOT_COMPLETED` receiver brings it back after a reboot. The service's life is exactly the
|
||||||
|
session's life — it stops itself, removing the notification, on **End**, when the meter
|
||||||
|
runs out, or whenever there's no session to show.
|
||||||
|
|
||||||
|
The countdown itself costs no battery: the end time is handed to Android as a
|
||||||
|
[chronometer](https://developer.android.com/reference/android/app/Notification.Builder#setChronometerCountDown(boolean)),
|
||||||
|
and the system redraws the ticking text with the app closed and no timer of its own.
|
||||||
|
|
||||||
|
- **End** stops tracking and clears the notification. On a *free check-in* that genuinely
|
||||||
|
ends it. On a *paid* session it only stops the display — ParkSmarter has no stop-session
|
||||||
|
endpoint, so time you already bought keeps running at the meter either way.
|
||||||
|
- **Extend** (labeled **Pay** on a free check-in) opens the purchase screen for that exact
|
||||||
|
zone. The active session is stored locally *with its zone*, so this works offline, in
|
||||||
|
Anonymous Mode, and after a reboot. Extending doesn't end anything until the purchase
|
||||||
|
actually goes through.
|
||||||
|
|
||||||
|
**Expiry reminders** fire a configurable lead time (default 15 min) before the end, via
|
||||||
|
`AlarmManager`. Configure them in **Account → Notifications**, where a **"Send a test
|
||||||
|
reminder"** button lets you confirm they fire on your phone; the same screen has a toggle
|
||||||
|
for the ongoing countdown.
|
||||||
|
|
||||||
## Distribution via Obtainium (self-hosted)
|
## Distribution via Obtainium (self-hosted)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.4.1",
|
"version": "0.5.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": 19,
|
"versionCode": 20,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,37 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- The countdown is pinned by a foreground service so it survives the app being
|
||||||
|
killed and can be restored after a reboot. "specialUse" is the right type: a
|
||||||
|
user-visible parking timer isn't media, location, or data sync. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<!-- Fully-qualified name: relative ".Name" would resolve against the app
|
<service
|
||||||
|
android:name="expo.modules.bbpnotify.BbpSessionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Ongoing countdown for the parking session the user paid for or checked into, with end/extend actions." />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Fully-qualified names: a relative ".Name" would resolve against the app
|
||||||
package, not this module's namespace, once manifests are merged. -->
|
package, not this module's namespace, once manifests are merged. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="expo.modules.bbpnotify.BbpActionReceiver"
|
android:name="expo.modules.bbpnotify.BbpActionReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- BOOT_COMPLETED and MY_PACKAGE_REPLACED are protected system broadcasts, so
|
||||||
|
the system still delivers them to a non-exported receiver. -->
|
||||||
|
<receiver
|
||||||
|
android:name="expo.modules.bbpnotify.BbpBootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,28 @@ package expo.modules.bbpnotify
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the check-in notification's "End" / "Pay" buttons, even when the app
|
* Handles the notification's "End" / "Extend" buttons, even when the app process is
|
||||||
* process is dead. It records the choice in SharedPreferences (read by JS via
|
* dead. Each button records its choice in [BbpSessionStore]; JS picks it up via
|
||||||
* `consumePendingAction` on next foreground) and, for "Pay", relaunches the app
|
* `consumePendingAction` the next time it runs and finishes the job on its side
|
||||||
* so the check-in can hand off to the paid-session flow.
|
* (clearing local state, cancelling the expiry reminder, opening the pay screen).
|
||||||
*/
|
*/
|
||||||
class BbpActionReceiver : BroadcastReceiver() {
|
class BbpActionReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val prefs = context.getSharedPreferences(BbpNotifyModule.PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
BbpNotifyModule.ACTION_END -> {
|
ACTION_END -> {
|
||||||
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply()
|
// Ending is immediate and local: drop the session and take the notification
|
||||||
NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID)
|
// down with the service, without waiting for the app to be opened.
|
||||||
|
BbpSessionStore.setPending(context, "end")
|
||||||
|
BbpSessionStore.clear(context)
|
||||||
|
BbpSessionService.stop(context)
|
||||||
}
|
}
|
||||||
BbpNotifyModule.ACTION_PAY -> {
|
ACTION_EXTEND -> {
|
||||||
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply()
|
// Extending does NOT end anything — the time already bought keeps running,
|
||||||
|
// and the purchase might be abandoned. Leave the session and its countdown
|
||||||
|
// alone; JS replaces them if and when more time is actually bought.
|
||||||
|
BbpSessionStore.setPending(context, "extend")
|
||||||
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
||||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
context.startActivity(launch)
|
context.startActivity(launch)
|
||||||
|
|
@ -28,4 +32,9 @@ class BbpActionReceiver : BroadcastReceiver() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_END = "expo.modules.bbpnotify.END"
|
||||||
|
const val ACTION_EXTEND = "expo.modules.bbpnotify.EXTEND"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package expo.modules.bbpnotify
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the parking countdown back after a reboot or an app update, without the
|
||||||
|
* user having to open the app.
|
||||||
|
*
|
||||||
|
* `specialUse` is one of the foreground-service types Android 14/15 still allow to
|
||||||
|
* be started from BOOT_COMPLETED, so the service can claim its notification here.
|
||||||
|
* [BbpSessionStore.load] returns null for an already-expired session, so a stale
|
||||||
|
* record from yesterday's parking never resurrects itself.
|
||||||
|
*/
|
||||||
|
class BbpBootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BbpSessionStore.load(context) == null) {
|
||||||
|
// No live session — make sure an expired record doesn't linger.
|
||||||
|
if (BbpSessionStore.hasRecord(context)) BbpSessionStore.clear(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
BbpSessionService.start(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package expo.modules.bbpnotify
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the ongoing parking notification.
|
||||||
|
*
|
||||||
|
* The remaining time is an Android **chronometer counting down** to the session
|
||||||
|
* end: `setWhen(endMs)` + `setUsesChronometer` + `setChronometerCountDown` hand the
|
||||||
|
* end time to the system, which redraws the ticking text itself every second. The
|
||||||
|
* app burns no CPU and needs no timer of its own, so the countdown stays accurate
|
||||||
|
* while the app is closed.
|
||||||
|
*
|
||||||
|
* Shared by the foreground service and by the boot receiver's fallback path.
|
||||||
|
*/
|
||||||
|
internal object BbpNotification {
|
||||||
|
const val CHANNEL_ID = "session-status"
|
||||||
|
const val NOTIF_ID = 42421
|
||||||
|
|
||||||
|
fun ensureChannel(ctx: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||||
|
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(ctx: Context, s: BbpSessionStore.Session): Notification {
|
||||||
|
ensureChannel(ctx)
|
||||||
|
val builder = base(ctx)
|
||||||
|
.setContentTitle(s.title)
|
||||||
|
.setContentText(s.body)
|
||||||
|
.setWhen(s.endMs)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.addAction(0, s.endLabel, actionIntent(ctx, BbpActionReceiver.ACTION_END))
|
||||||
|
builder.addAction(0, s.extendLabel, actionIntent(ctx, BbpActionReceiver.ACTION_EXTEND))
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bare notification for the start-then-immediately-stop path. A service launched
|
||||||
|
* with startForegroundService() must call startForeground() within a few seconds or
|
||||||
|
* the system kills the process, and that holds even when we've just discovered there
|
||||||
|
* is no session to show — so we post this and remove it in the same breath. It is
|
||||||
|
* never on screen long enough to be seen.
|
||||||
|
*/
|
||||||
|
fun placeholder(ctx: Context): Notification {
|
||||||
|
ensureChannel(ctx)
|
||||||
|
return base(ctx).setContentTitle("Parking").build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base(ctx: Context) =
|
||||||
|
NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,138 +1,63 @@
|
||||||
package expo.modules.bbpnotify
|
package expo.modules.bbpnotify
|
||||||
|
|
||||||
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
|
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts an ongoing notification whose "time" is a native Android chronometer
|
* JS bridge for the ongoing parking notification.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Two flavours: a plain paid-session countdown (`showCountdown`) and a free
|
* The module itself holds no state — it writes the session to [BbpSessionStore] and
|
||||||
* check-in countdown (`showCheckin`) that adds "End" / "Pay" action buttons.
|
* lets [BbpSessionService] render it. That keeps the app process out of the loop:
|
||||||
* The buttons fire a BroadcastReceiver ([BbpActionReceiver]) that records the
|
* the notification is just as correct after the JS engine is gone as it is while
|
||||||
* choice in SharedPreferences; JS reads it via `consumePendingAction` on the
|
* the user is looking at the app. Uses only platform APIs, no third-party deps.
|
||||||
* 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 JS can log exactly what happened
|
/**
|
||||||
// (posted / notifications-disabled / exception) — notify() fails silently.
|
* Post (or replace) the countdown for an active session. Returns a short
|
||||||
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
* diagnostic string so JS can log exactly what happened — notifications and
|
||||||
|
* service starts both fail silently otherwise.
|
||||||
|
*/
|
||||||
|
AsyncFunction("showSession") {
|
||||||
|
title: String, body: String, endTimeMillis: Double, endLabel: String, extendLabel: String ->
|
||||||
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
||||||
postCountdown(ctx, title, body, endTimeMillis, null, null)
|
val endMs = endTimeMillis.toLong()
|
||||||
|
if (endMs <= System.currentTimeMillis()) {
|
||||||
|
// Nothing to count down to — treat it as "no session" rather than posting
|
||||||
|
// a notification that is already at zero.
|
||||||
|
BbpSessionStore.clear(ctx)
|
||||||
|
BbpSessionService.stop(ctx)
|
||||||
|
return@AsyncFunction "expired"
|
||||||
|
}
|
||||||
|
BbpSessionStore.save(
|
||||||
|
ctx,
|
||||||
|
BbpSessionStore.Session(title, body, endMs, endLabel, extendLabel),
|
||||||
|
)
|
||||||
|
BbpSessionService.start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same ticking countdown, plus "End" and "Pay" action buttons.
|
/** No session: drop the record and stop the service, taking the notification with it. */
|
||||||
AsyncFunction("showCheckin") {
|
AsyncFunction("clearSession") {
|
||||||
title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String ->
|
// No bare `return@AsyncFunction` here: the lambda's inferred return type is
|
||||||
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
// Any?, so an early return of Unit doesn't type-check.
|
||||||
postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel)
|
appContext.reactContext?.let { ctx ->
|
||||||
}
|
BbpSessionStore.clear(ctx)
|
||||||
|
BbpSessionService.stop(ctx)
|
||||||
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.
|
/** Read + clear the action a notification button set. Returns "end", "extend", or "". */
|
||||||
// Returns "end", "pay", or "".
|
|
||||||
AsyncFunction("consumePendingAction") {
|
AsyncFunction("consumePendingAction") {
|
||||||
val ctx = appContext.reactContext ?: return@AsyncFunction ""
|
val ctx = appContext.reactContext ?: return@AsyncFunction ""
|
||||||
val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
BbpSessionStore.consumePending(ctx)
|
||||||
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 ->
|
/** True when a live session record exists — lets JS reconcile after a cold start. */
|
||||||
builder.setContentIntent(
|
AsyncFunction("hasActiveSession") {
|
||||||
PendingIntent.getActivity(
|
val ctx = appContext.reactContext ?: return@AsyncFunction false
|
||||||
ctx, 0, launch,
|
BbpSessionStore.load(ctx) != null
|
||||||
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)
|
|
||||||
channel.setShowBadge(false)
|
|
||||||
mgr.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHANNEL_ID = "session-status"
|
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package expo.modules.bbpnotify
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that owns the ongoing parking notification.
|
||||||
|
*
|
||||||
|
* A plain `notify()` was not enough: on Android 14+ the user can swipe an ongoing
|
||||||
|
* notification away, and nothing brings it back after a reboot. A foreground
|
||||||
|
* service pins the notification for as long as the service runs — the same reason
|
||||||
|
* ntfy's notification stays put on GrapheneOS.
|
||||||
|
*
|
||||||
|
* The service's lifetime is exactly the session's lifetime. It runs while there is
|
||||||
|
* an active session and fully stops — removing the notification — the moment there
|
||||||
|
* isn't one: on "End", when the countdown reaches zero, or when JS clears it. It
|
||||||
|
* never lingers with nothing to show.
|
||||||
|
*/
|
||||||
|
class BbpSessionService : Service() {
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var expiryTask: Runnable? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// A null intent means the system restarted us after killing the process
|
||||||
|
// (START_STICKY) — fall through and rebuild from the store.
|
||||||
|
val stopping = intent?.action == ACTION_STOP
|
||||||
|
val session = if (stopping) null else BbpSessionStore.load(this)
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
// Satisfy the startForegroundService() contract before standing down, or the
|
||||||
|
// system kills us for not calling startForeground() in time.
|
||||||
|
goForeground(BbpNotification.placeholder(this))
|
||||||
|
if (!stopping) BbpSessionStore.clear(this) // expired record — don't leave it around
|
||||||
|
shutdown()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!goForeground(BbpNotification.build(this, session))) {
|
||||||
|
// The system refused to let us hold a foreground service right now. The
|
||||||
|
// countdown matters more than the pinning, so leave the notification posted
|
||||||
|
// on its own and stand the service down rather than crash.
|
||||||
|
postDirectly(this)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
scheduleExpiry(session.endMs)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* startForeground() can still throw on Android 12+ even after startForegroundService()
|
||||||
|
* was accepted (background-start restrictions are evaluated here too). An uncaught
|
||||||
|
* throw would crash the app, so report the failure instead and let the caller fall back.
|
||||||
|
*/
|
||||||
|
private fun goForeground(notification: android.app.Notification): Boolean =
|
||||||
|
try {
|
||||||
|
startForeground(BbpNotification.NOTIF_ID, notification)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the moment the meter runs out, so a finished session never leaves a dead
|
||||||
|
* "0:00" notification pinned to the shade. A posted callback is enough here — a
|
||||||
|
* foreground service is alive to run it, no alarm permission needed.
|
||||||
|
*/
|
||||||
|
private fun scheduleExpiry(endMs: Long) {
|
||||||
|
expiryTask?.let { handler.removeCallbacks(it) }
|
||||||
|
val task = Runnable {
|
||||||
|
BbpSessionStore.clear(this)
|
||||||
|
shutdown()
|
||||||
|
}
|
||||||
|
expiryTask = task
|
||||||
|
handler.postDelayed(task, (endMs - System.currentTimeMillis()).coerceAtLeast(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shutdown() {
|
||||||
|
expiryTask?.let { handler.removeCallbacks(it) }
|
||||||
|
expiryTask = null
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
expiryTask?.let { handler.removeCallbacks(it) }
|
||||||
|
expiryTask = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_STOP = "expo.modules.bbpnotify.STOP_SERVICE"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (or refresh) the service from the stored session. Returns a short
|
||||||
|
* diagnostic string — JS logs it, because a failure here is otherwise silent.
|
||||||
|
*/
|
||||||
|
fun start(ctx: Context): String {
|
||||||
|
val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
||||||
|
val intent = Intent(ctx, BbpSessionService::class.java)
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
ctx.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
ctx.startService(intent)
|
||||||
|
}
|
||||||
|
"service-started enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Background-start restrictions can refuse the service (Android 12+). The
|
||||||
|
// countdown still matters more than the pinning, so fall back to posting the
|
||||||
|
// notification directly; it just becomes swipe-dismissable.
|
||||||
|
val fallback = postDirectly(ctx)
|
||||||
|
"service-failed=${e.javaClass.simpleName} fallback=$fallback enabled=$enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(ctx: Context) {
|
||||||
|
try {
|
||||||
|
ctx.startService(Intent(ctx, BbpSessionService::class.java).setAction(ACTION_STOP))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// The service may already be gone; make sure the notification is too.
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(ctx).cancel(BbpNotification.NOTIF_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last-resort path: the plain ongoing notification, with no service pinning it. */
|
||||||
|
fun postDirectly(ctx: Context): String {
|
||||||
|
val session = BbpSessionStore.load(ctx) ?: return "no-session"
|
||||||
|
return try {
|
||||||
|
NotificationManagerCompat.from(ctx)
|
||||||
|
.notify(BbpNotification.NOTIF_ID, BbpNotification.build(ctx, session))
|
||||||
|
"posted"
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
"no-permission"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"failed=${e.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package expo.modules.bbpnotify
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single active-parking record the foreground service renders.
|
||||||
|
*
|
||||||
|
* It lives in SharedPreferences rather than in memory because every other actor
|
||||||
|
* here can run without the JS app: the service after a system restart, the boot
|
||||||
|
* receiver after a reboot, the action receiver after the process is gone. They
|
||||||
|
* all read the same row, so there is exactly one source of truth for "is there a
|
||||||
|
* session, and what does its notification say".
|
||||||
|
*/
|
||||||
|
internal object BbpSessionStore {
|
||||||
|
const val PREFS = "bbp_notify"
|
||||||
|
|
||||||
|
private const val K_TITLE = "title"
|
||||||
|
private const val K_BODY = "body"
|
||||||
|
private const val K_END = "end_ms"
|
||||||
|
private const val K_END_LABEL = "end_label"
|
||||||
|
private const val K_EXTEND_LABEL = "extend_label"
|
||||||
|
private const val K_PENDING = "pending_action"
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val endMs: Long,
|
||||||
|
val endLabel: String,
|
||||||
|
val extendLabel: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun save(ctx: Context, s: Session) {
|
||||||
|
prefs(ctx).edit()
|
||||||
|
.putString(K_TITLE, s.title)
|
||||||
|
.putString(K_BODY, s.body)
|
||||||
|
.putLong(K_END, s.endMs)
|
||||||
|
.putString(K_END_LABEL, s.endLabel)
|
||||||
|
.putString(K_EXTEND_LABEL, s.extendLabel)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current session, or null when there isn't one. A record whose end time has
|
||||||
|
* already passed reads as null so an expired session can never re-post itself
|
||||||
|
* (e.g. a reboot hours after the meter ran out).
|
||||||
|
*/
|
||||||
|
fun load(ctx: Context): Session? {
|
||||||
|
val p = prefs(ctx)
|
||||||
|
val end = p.getLong(K_END, 0L)
|
||||||
|
if (end <= System.currentTimeMillis()) return null
|
||||||
|
return Session(
|
||||||
|
title = p.getString(K_TITLE, "") ?: "",
|
||||||
|
body = p.getString(K_BODY, "") ?: "",
|
||||||
|
endMs = end,
|
||||||
|
endLabel = p.getString(K_END_LABEL, "End") ?: "End",
|
||||||
|
extendLabel = p.getString(K_EXTEND_LABEL, "Extend") ?: "Extend",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a record exists at all, expired or not — used to decide if cleanup is needed. */
|
||||||
|
fun hasRecord(ctx: Context): Boolean = prefs(ctx).contains(K_END)
|
||||||
|
|
||||||
|
fun clear(ctx: Context) {
|
||||||
|
prefs(ctx).edit()
|
||||||
|
.remove(K_TITLE)
|
||||||
|
.remove(K_BODY)
|
||||||
|
.remove(K_END)
|
||||||
|
.remove(K_END_LABEL)
|
||||||
|
.remove(K_EXTEND_LABEL)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPending(ctx: Context, action: String) {
|
||||||
|
prefs(ctx).edit().putString(K_PENDING, action).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read + clear the action a notification button recorded while the app was away. */
|
||||||
|
fun consumePending(ctx: Context): String {
|
||||||
|
val p = prefs(ctx)
|
||||||
|
val action = p.getString(K_PENDING, "") ?: ""
|
||||||
|
if (action.isNotEmpty()) p.edit().remove(K_PENDING).apply()
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,26 @@ import { requireOptionalNativeModule } from 'expo-modules-core';
|
||||||
|
|
||||||
interface BbpNotifyNative {
|
interface BbpNotifyNative {
|
||||||
/**
|
/**
|
||||||
* Post/replace an ongoing notification with a native chronometer counting down
|
* Post/replace the ongoing session notification: a native chronometer counting
|
||||||
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
|
* down to endTimeMillis, pinned by a foreground service, with "End" and "Extend"
|
||||||
|
* action buttons. Returns a short diagnostic string (e.g. "service-started …").
|
||||||
*/
|
*/
|
||||||
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
|
showSession(
|
||||||
/** Same ticking countdown, plus "End" / "Pay" action buttons. */
|
|
||||||
showCheckin(
|
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
endTimeMillis: number,
|
endTimeMillis: number,
|
||||||
endLabel: string,
|
endLabel: string,
|
||||||
payLabel: string,
|
extendLabel: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
/** Remove the countdown notification. */
|
/** No active session: stop the service and remove the notification. */
|
||||||
clear(): Promise<void>;
|
clearSession(): Promise<void>;
|
||||||
/** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
|
/** Read + clear the action a notification button set: 'end' | 'extend' | ''. */
|
||||||
consumePendingAction(): Promise<string>;
|
consumePendingAction(): Promise<string>;
|
||||||
|
/** Whether the native side still holds a live (unexpired) session record. */
|
||||||
|
hasActiveSession(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PendingAction = 'end' | 'pay' | null;
|
export type PendingAction = 'end' | 'extend' | 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).
|
||||||
|
|
@ -33,32 +34,27 @@ const native =
|
||||||
/** True when the native ticking-countdown module is available. */
|
/** True when the native ticking-countdown module is available. */
|
||||||
export const hasNativeCountdown = native != null;
|
export const hasNativeCountdown = native != null;
|
||||||
|
|
||||||
export async function showCountdown(
|
export async function showSession(
|
||||||
title: string,
|
|
||||||
body: string,
|
|
||||||
endTimeMillis: number,
|
|
||||||
): Promise<string> {
|
|
||||||
if (!native) return 'no-native-module';
|
|
||||||
return native.showCountdown(title, body, endTimeMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function showCheckin(
|
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
endTimeMillis: number,
|
endTimeMillis: number,
|
||||||
endLabel: string,
|
endLabel: string,
|
||||||
payLabel: string,
|
extendLabel: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!native) return 'no-native-module';
|
if (!native) return 'no-native-module';
|
||||||
return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
|
return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCountdown(): Promise<void> {
|
export async function clearSession(): Promise<void> {
|
||||||
await native?.clear();
|
await native?.clearSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read + clear a notification-button action taken while the app was away. */
|
/** Read + clear a notification-button action taken while the app was away. */
|
||||||
export async function consumePendingAction(): Promise<PendingAction> {
|
export async function consumePendingAction(): Promise<PendingAction> {
|
||||||
const a = await native?.consumePendingAction();
|
const a = await native?.consumePendingAction();
|
||||||
return a === 'end' || a === 'pay' ? a : null;
|
return a === 'end' || a === 'extend' ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasActiveSession(): Promise<boolean> {
|
||||||
|
return (await native?.hasActiveSession()) ?? false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
app/src/api/parseTime.ts
Normal file
30
app/src/api/parseTime.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
|
||||||
|
* /api/Session + estimates: "07-15-2026 05:00 PM" dashes, 12h, AM/PM
|
||||||
|
* /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
|
||||||
|
* Both are device-local wall-clock time. Hermes' Date() can't parse either, so we
|
||||||
|
* build the Date from explicit components.
|
||||||
|
*
|
||||||
|
* This is the single parser for both — an earlier stricter copy that required
|
||||||
|
* dashes and AM/PM silently returned null on the other format, which meant no
|
||||||
|
* countdown notification at all for sessions whose end time came back that way.
|
||||||
|
*/
|
||||||
|
export function parseApiTime(s?: string | null): Date | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = String(s).match(
|
||||||
|
/(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
|
||||||
|
);
|
||||||
|
if (!m) {
|
||||||
|
const d = new Date(s);
|
||||||
|
return Number.isNaN(+d) ? null : d;
|
||||||
|
}
|
||||||
|
let hr = parseInt(m[4], 10);
|
||||||
|
const ap = m[7];
|
||||||
|
if (ap) {
|
||||||
|
const pm = /pm/i.test(ap);
|
||||||
|
if (pm && hr !== 12) hr += 12;
|
||||||
|
if (!pm && hr === 12) hr = 0;
|
||||||
|
}
|
||||||
|
const sec = m[6] ? parseInt(m[6], 10) : 0;
|
||||||
|
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
|
||||||
|
}
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
291
app/src/features/session/activeParking.ts
Normal file
291
app/src/features/session/activeParking.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
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 { ps } from '@/api/client';
|
||||||
|
import { parseApiTime } from '@/api/parseTime';
|
||||||
|
import type { LabelKind } from '@/api/zoneLabels';
|
||||||
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
import {
|
||||||
|
ensureNotificationPermission,
|
||||||
|
REMINDER_CHANNEL_ID,
|
||||||
|
} from '@/notifications/localReminders';
|
||||||
|
import {
|
||||||
|
getCountdownEnabled,
|
||||||
|
getReminderLeadMinutes,
|
||||||
|
getRemindersEnabled,
|
||||||
|
} from '@/features/notifications/reminderPrefs';
|
||||||
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
|
import {
|
||||||
|
clearActiveParking,
|
||||||
|
getActiveParking,
|
||||||
|
setActiveParking,
|
||||||
|
type ActiveParking,
|
||||||
|
} from './activeParkingStore';
|
||||||
|
import {
|
||||||
|
clearSession,
|
||||||
|
consumePendingAction,
|
||||||
|
hasNativeCountdown,
|
||||||
|
showSession,
|
||||||
|
} from '../../../modules/bbp-notify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that happens while a car is parked, paid or free, lives here.
|
||||||
|
*
|
||||||
|
* There is exactly one active parking session at a time, so there is exactly one
|
||||||
|
* ongoing notification. Both entry points (buying time, checking into a free
|
||||||
|
* space) write the same record and post the same countdown, which is why the
|
||||||
|
* notification behaves identically whichever way you parked.
|
||||||
|
*
|
||||||
|
* The notification itself is owned by a native foreground service — see
|
||||||
|
* modules/bbp-notify. JS's job is to keep the record truthful; the service
|
||||||
|
* renders it and stops itself the moment there's nothing to show.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One session at a time, so one reminder id. */
|
||||||
|
const EXPIRY_REMINDER_ID = 'parking-expiry';
|
||||||
|
/** Fallback ongoing notification for Expo Go, where the native module is absent. */
|
||||||
|
const FALLBACK_NOTIF_ID = 'parking-status';
|
||||||
|
|
||||||
|
function fmtTime(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ posting */
|
||||||
|
|
||||||
|
/** Post (or replace) the ongoing ticking notification for a session. */
|
||||||
|
async function postNotification(p: ActiveParking): Promise<void> {
|
||||||
|
if (!(await getCountdownEnabled())) {
|
||||||
|
logLine('[PARKING] countdown disabled — not posting');
|
||||||
|
await clearNotification();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureNotificationPermission();
|
||||||
|
|
||||||
|
const ends = fmtTime(p.endMs);
|
||||||
|
const free = p.kind === 'free';
|
||||||
|
const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
|
||||||
|
const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
|
||||||
|
// Free time isn't bought, so the button that buys time reads "Pay"; on a paid
|
||||||
|
// session it genuinely extends what you already have.
|
||||||
|
const extendLabel = free ? 'Pay' : 'Extend';
|
||||||
|
|
||||||
|
if (hasNativeCountdown) {
|
||||||
|
const diag = await showSession(title, body, p.endMs, 'End', extendLabel);
|
||||||
|
logLine(`[PARKING] ${p.kind} native: ${diag}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expo Go / no native module: a static ongoing notification, no buttons and no
|
||||||
|
// ticking, refreshed whenever the app is foregrounded.
|
||||||
|
logLine('[PARKING] no native module — static fallback notification');
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
identifier: FALLBACK_NOTIF_ID,
|
||||||
|
content: {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
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 clearNotification(): Promise<void> {
|
||||||
|
await clearSession().catch(() => {});
|
||||||
|
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- reminders */
|
||||||
|
|
||||||
|
async function scheduleExpiryReminder(p: ActiveParking): Promise<void> {
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||||
|
if (!(await getRemindersEnabled())) return;
|
||||||
|
const fireAt = p.endMs - p.leadMinutes * 60_000;
|
||||||
|
if (fireAt <= Date.now()) return;
|
||||||
|
|
||||||
|
const free = p.kind === 'free';
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
identifier: EXPIRY_REMINDER_ID,
|
||||||
|
content: {
|
||||||
|
title: free ? 'Free parking ending soon' : 'Parking expiring soon',
|
||||||
|
body: `${p.zoneName}: ${free ? 'your free time ends' : 'your session ends'} at ${fmtTime(
|
||||||
|
p.endMs,
|
||||||
|
)}. Extend if you need more time.`,
|
||||||
|
data: { kind: 'parking-expiry' },
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||||
|
date: new Date(fireAt),
|
||||||
|
channelId: REMINDER_CHANNEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ start / stop */
|
||||||
|
|
||||||
|
/** Begin tracking a paid session that ParkSmarter has just confirmed. */
|
||||||
|
export async function startPaidSession(args: {
|
||||||
|
zone: Zone;
|
||||||
|
endTime: Date;
|
||||||
|
transactionId?: string | number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const state: ActiveParking = {
|
||||||
|
kind: 'paid',
|
||||||
|
zone: args.zone,
|
||||||
|
zoneName: args.zone.ZoneName ?? 'Parking',
|
||||||
|
startMs: Date.now(),
|
||||||
|
endMs: args.endTime.getTime(),
|
||||||
|
transactionId: args.transactionId != null ? String(args.transactionId) : undefined,
|
||||||
|
leadMinutes: await getReminderLeadMinutes(),
|
||||||
|
};
|
||||||
|
await setActiveParking(state);
|
||||||
|
await postNotification(state);
|
||||||
|
await scheduleExpiryReminder(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a local free check-in for `hours` at the given zone. */
|
||||||
|
export async function startFreeCheckin(
|
||||||
|
zone: Zone,
|
||||||
|
hours: number,
|
||||||
|
labelKind?: LabelKind,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const state: ActiveParking = {
|
||||||
|
kind: 'free',
|
||||||
|
zone,
|
||||||
|
zoneName: zone.ZoneName ?? 'Parking',
|
||||||
|
startMs: now,
|
||||||
|
endMs: now + hours * 3_600_000,
|
||||||
|
labelKind: labelKind ?? (`free_${hours}h` as LabelKind),
|
||||||
|
leadMinutes: await getReminderLeadMinutes(),
|
||||||
|
};
|
||||||
|
await setActiveParking(state);
|
||||||
|
await postNotification(state);
|
||||||
|
await scheduleExpiryReminder(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop tracking the active session and take its notification down.
|
||||||
|
*
|
||||||
|
* For a free check-in this genuinely ends it. For a paid session it only stops
|
||||||
|
* tracking — ParkSmarter has no stop-session endpoint, so the time you bought
|
||||||
|
* keeps running at the meter whether or not the app is showing it.
|
||||||
|
*/
|
||||||
|
export async function endActiveParking(): Promise<void> {
|
||||||
|
await clearActiveParking();
|
||||||
|
await clearNotification();
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- syncing */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the server whether a paid session is running. Only used when nothing is
|
||||||
|
* tracked locally — a session bought on another device, or before this version
|
||||||
|
* started persisting them. The active-session API returns no Zone, so a session
|
||||||
|
* found this way has no zone to extend into.
|
||||||
|
*/
|
||||||
|
async function discoverPaidSession(): Promise<ActiveParking | null> {
|
||||||
|
try {
|
||||||
|
const res = await ps.getActiveParkingSessions();
|
||||||
|
const soonest = (res.ParkingSession ?? [])
|
||||||
|
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
||||||
|
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
||||||
|
.sort((a, b) => a.end!.getTime() - b.end!.getTime())[0];
|
||||||
|
if (!soonest) return null;
|
||||||
|
|
||||||
|
const found: ActiveParking = {
|
||||||
|
kind: 'paid',
|
||||||
|
zoneName: soonest.s.ZoneName ?? 'Parking',
|
||||||
|
startMs: parseApiTime(soonest.s.StartTime)?.getTime() ?? Date.now(),
|
||||||
|
endMs: soonest.end!.getTime(),
|
||||||
|
transactionId:
|
||||||
|
soonest.s.TransactionID != null ? String(soonest.s.TransactionID) : undefined,
|
||||||
|
leadMinutes: await getReminderLeadMinutes(),
|
||||||
|
};
|
||||||
|
logLine(`[PARKING] discovered server session ending ${new Date(found.endMs).toISOString()}`);
|
||||||
|
await setActiveParking(found);
|
||||||
|
await scheduleExpiryReminder(found);
|
||||||
|
return found;
|
||||||
|
} catch (e: any) {
|
||||||
|
logLine(`[PARKING] discover failed: ${e?.serverMessage ?? e?.message ?? e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the record, its notification and any button the user pressed while the
|
||||||
|
* app was away. Safe to call repeatedly; it's the app's foreground heartbeat.
|
||||||
|
*/
|
||||||
|
export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promise<void> {
|
||||||
|
const action = await consumePendingAction();
|
||||||
|
|
||||||
|
if (action === 'end') {
|
||||||
|
logLine('[PARKING] notification "End" pressed');
|
||||||
|
await endActiveParking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = await getActiveParking();
|
||||||
|
|
||||||
|
// The meter ran out: the service already removed its own notification when the
|
||||||
|
// countdown hit zero, so this just clears the record behind it.
|
||||||
|
if (current && current.endMs <= Date.now()) {
|
||||||
|
await endActiveParking();
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) current = await discoverPaidSession();
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
await postNotification(current);
|
||||||
|
} else {
|
||||||
|
await clearNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'extend') {
|
||||||
|
logLine('[PARKING] notification "Extend" pressed');
|
||||||
|
onExtend(current?.zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the tab navigator: sync on open and on every foreground. */
|
||||||
|
export function useActiveParkingSync(): void {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const run = () =>
|
||||||
|
void syncActiveParking((zone) => {
|
||||||
|
// "Extend" means "sell me more time for this exact spot" — go straight to
|
||||||
|
// the purchase screen for the stored zone. A server-discovered session has
|
||||||
|
// no zone, so fall back to the sessions list rather than guessing.
|
||||||
|
if (zone) navigation.navigate('StartSession', { zone });
|
||||||
|
else navigation.navigate('Tabs');
|
||||||
|
});
|
||||||
|
|
||||||
|
run();
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') run();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [navigation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn the countdown notification on/off from Settings without touching the record. */
|
||||||
|
export async function refreshParkingNotification(): Promise<void> {
|
||||||
|
const current = await getActiveParking();
|
||||||
|
if (current && current.endMs > Date.now()) await postNotification(current);
|
||||||
|
else await clearNotification();
|
||||||
|
}
|
||||||
57
app/src/features/session/activeParkingStore.ts
Normal file
57
app/src/features/session/activeParkingStore.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
import type { LabelKind } from '@/api/zoneLabels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one parking session the app is currently tracking — paid or a local free
|
||||||
|
* check-in. Persisting it locally is what lets the countdown come back after a
|
||||||
|
* reboot, offline, or in Anonymous Mode: the notification no longer depends on a
|
||||||
|
* ParkSmarter round-trip to know a session exists.
|
||||||
|
*
|
||||||
|
* The full Zone is stored so the notification's "Extend" button can hand straight
|
||||||
|
* into the paid flow without re-fetching the meter.
|
||||||
|
*/
|
||||||
|
const KEY = 'ps_active_parking';
|
||||||
|
/** Pre-0.5 free check-ins lived here; read once so an in-flight check-in survives the upgrade. */
|
||||||
|
const LEGACY_CHECKIN_KEY = 'ps_checkin';
|
||||||
|
|
||||||
|
export type ParkingKind = 'paid' | 'free';
|
||||||
|
|
||||||
|
export interface ActiveParking {
|
||||||
|
kind: ParkingKind;
|
||||||
|
/**
|
||||||
|
* Absent only for a paid session discovered from the server (the active-session
|
||||||
|
* API returns no zone), in which case "Extend" falls back to the Sessions tab.
|
||||||
|
*/
|
||||||
|
zone?: Zone;
|
||||||
|
zoneName: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
/** Paid only: the ParkSmarter transaction id, for receipts/debugging. */
|
||||||
|
transactionId?: string;
|
||||||
|
/** Free only: which zone-label this check-in came from. */
|
||||||
|
labelKind?: LabelKind;
|
||||||
|
/** Minutes before expiry to fire the local reminder. */
|
||||||
|
leadMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveParking(): Promise<ActiveParking | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as ActiveParking;
|
||||||
|
|
||||||
|
const legacy = await AsyncStorage.getItem(LEGACY_CHECKIN_KEY);
|
||||||
|
if (!legacy) return null;
|
||||||
|
const c = JSON.parse(legacy) as Omit<ActiveParking, 'kind'>;
|
||||||
|
const migrated: ActiveParking = { ...c, kind: 'free' };
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(migrated));
|
||||||
|
await AsyncStorage.removeItem(LEGACY_CHECKIN_KEY);
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveParking(state: ActiveParking): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearActiveParking(): Promise<void> {
|
||||||
|
await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY]);
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,7 @@ import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
||||||
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
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 { useActiveParkingSync } from '@/features/session/activeParking';
|
||||||
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 = {
|
||||||
|
|
@ -72,10 +71,9 @@ 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 countdown in sync (on open + every foreground) and apply any
|
||||||
useSessionStatusSync();
|
// End/Extend the user pressed on the notification while the app was away.
|
||||||
// Apply check-in notification actions (End/Pay) + keep its countdown in sync.
|
useActiveParkingSync();
|
||||||
useCheckinSync();
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import {
|
|
||||||
getReminderLeadMinutes,
|
|
||||||
getRemindersEnabled,
|
|
||||||
} from '@/features/notifications/reminderPrefs';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
|
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
|
||||||
|
|
@ -11,7 +7,9 @@ import {
|
||||||
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
|
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CHANNEL_ID = 'session-reminders';
|
/** Exported so the active-parking module can schedule onto the same channel. */
|
||||||
|
export const REMINDER_CHANNEL_ID = 'session-reminders';
|
||||||
|
const CHANNEL_ID = REMINDER_CHANNEL_ID;
|
||||||
|
|
||||||
/** Android 8+ needs a notification channel; ensure it exists once. */
|
/** Android 8+ needs a notification channel; ensure it exists once. */
|
||||||
async function ensureChannel(): Promise<void> {
|
async function ensureChannel(): Promise<void> {
|
||||||
|
|
@ -48,48 +46,6 @@ Notifications.setNotificationHandler({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ScheduleReminderArgs {
|
|
||||||
transactionId: string | number;
|
|
||||||
zoneName: string;
|
|
||||||
/** When the parking session ends. */
|
|
||||||
endTime: Date;
|
|
||||||
/** Override the user's configured lead time (minutes before end). */
|
|
||||||
leadMinutes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule an expiry reminder using the user's Notifications preferences
|
|
||||||
* (lead time + enabled). Returns the notification id, or null if disabled/late.
|
|
||||||
*/
|
|
||||||
export async function scheduleExpiryReminder(
|
|
||||||
args: ScheduleReminderArgs,
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!(await getRemindersEnabled())) return null;
|
|
||||||
const leadMinutes = args.leadMinutes ?? (await getReminderLeadMinutes());
|
|
||||||
const lead = leadMinutes * 60 * 1000;
|
|
||||||
const fireAt = new Date(args.endTime.getTime() - lead);
|
|
||||||
if (fireAt.getTime() <= Date.now()) return null; // already too late
|
|
||||||
|
|
||||||
await ensureChannel();
|
|
||||||
return Notifications.scheduleNotificationAsync({
|
|
||||||
identifier: `session-${args.transactionId}`,
|
|
||||||
content: {
|
|
||||||
title: 'Parking expiring soon',
|
|
||||||
body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
|
|
||||||
data: { transactionId: String(args.transactionId) },
|
|
||||||
},
|
|
||||||
trigger: {
|
|
||||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
|
||||||
date: fireAt,
|
|
||||||
channelId: CHANNEL_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelExpiryReminder(transactionId: string | number): Promise<void> {
|
|
||||||
await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
|
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
|
||||||
export async function sendTestReminder(seconds = 10): Promise<boolean> {
|
export async function sendTestReminder(seconds = 10): Promise<boolean> {
|
||||||
if (!(await ensureNotificationPermission())) return false;
|
if (!(await ensureNotificationPermission())) return false;
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { AppState, Platform } from 'react-native';
|
|
||||||
import * as Notifications from 'expo-notifications';
|
|
||||||
import { ps } from '@/api/client';
|
|
||||||
import { getCountdownEnabled } from '@/features/notifications/reminderPrefs';
|
|
||||||
import { ensureNotificationPermission } from '@/notifications/localReminders';
|
|
||||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
|
||||||
import { hasNativeCountdown, showCountdown, clearCountdown } from '../../modules/bbp-notify';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ongoing "time left" status notification while a parking session is active.
|
|
||||||
*
|
|
||||||
* Preferred path: a tiny native module (modules/bbp-notify) posts a notification
|
|
||||||
* whose time is an Android **chronometer counting down** to the session end — the
|
|
||||||
* system ticks it every second with no app running (glance without opening the app).
|
|
||||||
* If that native module isn't present, we fall back to an expo-notifications
|
|
||||||
* ongoing notification showing the (static) expiry time, refreshed on foreground.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const CHANNEL = 'session-status';
|
|
||||||
const NOTIF_ID = 'session-status';
|
|
||||||
|
|
||||||
async function ensureExpoChannel(): Promise<void> {
|
|
||||||
if (Platform.OS !== 'android') return;
|
|
||||||
await Notifications.setNotificationChannelAsync(CHANNEL, {
|
|
||||||
name: 'Active parking',
|
|
||||||
importance: Notifications.AndroidImportance.LOW,
|
|
||||||
showBadge: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
|
|
||||||
* /api/Session (past): "07-15-2026 05:00 PM" dashes, 12h, AM/PM
|
|
||||||
* /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
|
|
||||||
* Both are device-local wall-clock time. Hermes' Date() can't parse either, so
|
|
||||||
* we build the Date from explicit components.
|
|
||||||
*/
|
|
||||||
export function parseApiTime(s?: string | null): Date | null {
|
|
||||||
if (!s) return null;
|
|
||||||
const m = String(s).match(
|
|
||||||
/(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
|
|
||||||
);
|
|
||||||
if (!m) {
|
|
||||||
const d = new Date(s);
|
|
||||||
return Number.isNaN(+d) ? null : d;
|
|
||||||
}
|
|
||||||
let hr = parseInt(m[4], 10);
|
|
||||||
const ap = m[7];
|
|
||||||
if (ap) {
|
|
||||||
const pm = /pm/i.test(ap);
|
|
||||||
if (pm && hr !== 12) hr += 12;
|
|
||||||
if (!pm && hr === 12) hr = 0;
|
|
||||||
}
|
|
||||||
const sec = m[6] ? parseInt(m[6], 10) : 0;
|
|
||||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtLeft(min: number): string {
|
|
||||||
if (min <= 0) return 'expired';
|
|
||||||
const h = Math.floor(min / 60);
|
|
||||||
const m = min % 60;
|
|
||||||
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Post (or replace) the ongoing status notification for a session end time. */
|
|
||||||
export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise<void> {
|
|
||||||
if (!(await getCountdownEnabled())) {
|
|
||||||
logLine('[STATUS] skip: countdown disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const granted = await ensureNotificationPermission();
|
|
||||||
const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
||||||
logLine(
|
|
||||||
`[STATUS] show native=${hasNativeCountdown} perm=${granted} end=${args.endTime.toISOString()}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasNativeCountdown) {
|
|
||||||
// Native chronometer: a live ticking countdown, updated by the OS.
|
|
||||||
try {
|
|
||||||
const diag = await showCountdown(
|
|
||||||
`Parking · ${args.zoneName}`,
|
|
||||||
`Expires ${ends}`,
|
|
||||||
args.endTime.getTime(),
|
|
||||||
);
|
|
||||||
logLine(`[STATUS] native result: ${diag}`);
|
|
||||||
} catch (e: any) {
|
|
||||||
logLine(`[STATUS] native threw: ${e?.message ?? e}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: static expiry-time notification, refreshed on foreground.
|
|
||||||
await ensureExpoChannel();
|
|
||||||
const remainMin = Math.round((args.endTime.getTime() - Date.now()) / 60000);
|
|
||||||
await Notifications.scheduleNotificationAsync({
|
|
||||||
identifier: NOTIF_ID,
|
|
||||||
content: {
|
|
||||||
title: remainMin > 0 ? `Parking: ${fmtLeft(remainMin)} left` : 'Parking expired',
|
|
||||||
body: `${args.zoneName} · expires ${ends}`,
|
|
||||||
sticky: true,
|
|
||||||
autoDismiss: false,
|
|
||||||
data: { kind: 'session-status' },
|
|
||||||
},
|
|
||||||
trigger:
|
|
||||||
Platform.OS === 'android'
|
|
||||||
? {
|
|
||||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
|
||||||
date: new Date(Date.now() + 400),
|
|
||||||
channelId: CHANNEL,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionStatus(): Promise<void> {
|
|
||||||
await clearCountdown().catch(() => {});
|
|
||||||
await Notifications.dismissNotificationAsync(NOTIF_ID).catch(() => {});
|
|
||||||
await Notifications.cancelScheduledNotificationAsync(NOTIF_ID).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch active sessions and re-post the status for the soonest-expiring one,
|
|
||||||
* or clear it if there are none / the feature is off.
|
|
||||||
*/
|
|
||||||
export async function refreshSessionStatus(): Promise<void> {
|
|
||||||
if (!(await getCountdownEnabled())) {
|
|
||||||
await clearSessionStatus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await ps.getActiveParkingSessions();
|
|
||||||
logLine(`[STATUS] active sessions raw: ${JSON.stringify(res.ParkingSession ?? [])}`);
|
|
||||||
const withEnd = (res.ParkingSession ?? [])
|
|
||||||
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
|
||||||
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
|
||||||
.sort((a, b) => a.end!.getTime() - b.end!.getTime());
|
|
||||||
logLine(`[STATUS] parsed ${withEnd.length} future-end session(s) of ${(res.ParkingSession ?? []).length}`);
|
|
||||||
if (withEnd.length === 0) {
|
|
||||||
await clearSessionStatus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { s, end } = withEnd[0];
|
|
||||||
await showSessionStatus({ zoneName: s.ZoneName ?? s.Zone ?? 'Parking', endTime: end! });
|
|
||||||
} catch (e: any) {
|
|
||||||
logLine(`[STATUS] refresh failed: ${e?.serverMessage ?? e?.message ?? e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the status notification fresh: on mount and each time the app is foregrounded. */
|
|
||||||
export function useSessionStatusSync(): void {
|
|
||||||
useEffect(() => {
|
|
||||||
void refreshSessionStatus();
|
|
||||||
const sub = AppState.addEventListener('change', (state) => {
|
|
||||||
if (state === 'active') void refreshSessionStatus();
|
|
||||||
});
|
|
||||||
return () => sub.remove();
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
type LabelKind,
|
type LabelKind,
|
||||||
type ZoneLabel,
|
type ZoneLabel,
|
||||||
} from '@/api/zoneLabels';
|
} from '@/api/zoneLabels';
|
||||||
import { startCheckin } from '@/features/checkin/checkin';
|
import { startFreeCheckin } from '@/features/session/activeParking';
|
||||||
|
|
||||||
/** 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' }> = [
|
||||||
|
|
@ -140,7 +140,7 @@ export function MeterDetailScreen() {
|
||||||
const labeledHours = label ? labelHours(label.kind) : null;
|
const labeledHours = label ? labelHours(label.kind) : null;
|
||||||
|
|
||||||
const doCheckin = async (hours: number) => {
|
const doCheckin = async (hours: number) => {
|
||||||
await startCheckin(z, hours);
|
await startFreeCheckin(z, hours, label?.kind);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Checked in',
|
'Checked in',
|
||||||
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
|
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { sendTestReminder } from '@/notifications/localReminders';
|
import { sendTestReminder } from '@/notifications/localReminders';
|
||||||
import { refreshSessionStatus, clearSessionStatus } from '@/notifications/sessionStatus';
|
import { refreshParkingNotification } from '@/features/session/activeParking';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LEAD_MINUTES,
|
DEFAULT_LEAD_MINUTES,
|
||||||
LEAD_STEP,
|
LEAD_STEP,
|
||||||
|
|
@ -38,7 +38,7 @@ export function NotificationsScreen() {
|
||||||
|
|
||||||
const toggleCountdown = (v: boolean) => {
|
const toggleCountdown = (v: boolean) => {
|
||||||
setCountdown(v);
|
setCountdown(v);
|
||||||
void setCountdownEnabled(v).then(() => (v ? refreshSessionStatus() : clearSessionStatus()));
|
void setCountdownEnabled(v).then(refreshParkingNotification);
|
||||||
};
|
};
|
||||||
|
|
||||||
const bump = (delta: number) => {
|
const bump = (delta: number) => {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,8 @@ import { useNavigation, useRoute } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
import { parseApiTime } from '@/api/parseTime';
|
||||||
import { showSessionStatus } from '@/notifications/sessionStatus';
|
import { startPaidSession } from '@/features/session/activeParking';
|
||||||
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 {
|
||||||
|
|
@ -107,18 +106,6 @@ export async function buildSingleLadder(base: {
|
||||||
return { free: false, flat: false, ladder };
|
return { free: false, flat: false, ladder };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
|
|
||||||
function parseApiTime(s?: string): Date | null {
|
|
||||||
if (!s) return null;
|
|
||||||
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
|
||||||
if (!m) return null;
|
|
||||||
let hr = parseInt(m[4], 10);
|
|
||||||
const pm = /pm/i.test(m[6]);
|
|
||||||
if (pm && hr !== 12) hr += 12;
|
|
||||||
if (!pm && hr === 12) hr = 0;
|
|
||||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StartSessionScreen() {
|
export function StartSessionScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
|
@ -275,18 +262,18 @@ 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).
|
// Becomes the one active parking session, replacing any free check-in (and,
|
||||||
await endCheckin();
|
// when this purchase is an extension, the session it extends). That posts the
|
||||||
// Schedule the local expiry reminder from the purchased end time.
|
// ongoing countdown notification and schedules the expiry reminder.
|
||||||
const end = parseApiTime(selected.EndTime);
|
const end = parseApiTime(selected.EndTime);
|
||||||
if (end) {
|
if (end) {
|
||||||
await scheduleExpiryReminder({
|
await startPaidSession({
|
||||||
transactionId: (res as any)?.TransactionID ?? Date.now(),
|
zone,
|
||||||
zoneName: zone.ZoneName ?? 'Parking',
|
|
||||||
endTime: end,
|
endTime: end,
|
||||||
|
transactionId: (res as any)?.TransactionID,
|
||||||
});
|
});
|
||||||
// Ongoing "time left" status notification (glanceable countdown).
|
} else {
|
||||||
await showSessionStatus({ zoneName: zone.ZoneName ?? 'Parking', endTime: end });
|
logLine(`[SESSION] no countdown: unparseable EndTime "${selected.EndTime}"`);
|
||||||
}
|
}
|
||||||
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
||||||
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue