diff --git a/README.md b/README.md index 444c391..2ec5a53 100644 --- a/README.md +++ b/README.md @@ -39,59 +39,14 @@ bigbrainparking/ | --- | --- | --- | | Phone + password login | ✅ wired | tokens in OS keystore | | Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) | -| City parking map overlay (2h/3h/4h/no-limit/lots) | ✅ wired | the city's printed map, georeferenced — **never touches the IPS API** | -| "Park here" pin + time tracking on any city area | ✅ wired | GPS or hand-placed pin, auto-detects the area, local countdown | | Last-lot + My-location search | ✅ wired | opens on your last session's lot; GPS sent only via explicit "My location" | | QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera | | Save / share kiosks | ✅ wired | local (no server favorites API exists) | | 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 | -| 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 | | UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs | -## The city parking map - -The **City map** layer on the Map tab is the City of Sandpoint's printed *Downtown & -Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend: -2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all. - -**The free areas never touch ParkSmarter.** They live in the local database (bundled with -the app, refreshed from the zone-labels server, cached on-device), the countdown is the -phone's own clock, and the notification is the same foreground service every other session -uses. So tracking your time on a free city spot works with no account, no signal, no -payment, and in Anonymous Mode. - -The **green city lots are the exception** — they're the map's only paid category, and paying -for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking -you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park -here" says so rather than reporting nothing nearby.) A single lot can be flipped back via -the server's `requiresAccount` field if it turns out to take payment another way. - -Two ways to start: - -- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix - (garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin - stays on the map until you end the session, because "where did I leave the car" is half - the point. -- **Tap a coloured segment** — pick the block directly, no pin needed. - -Either way you choose how long to track, capped at the posted limit (a 2-hour space won't -offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing notification's -second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it -edits the local timer and says so. - -The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour, -end it, and see recent ones — with no account and no network, because that is the only -place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and -failing to reach them (offline, or signed out) never hides the local half. - -The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m -(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF). -Because a few metres is the difference between two sides of a street, **Account → Align city -map** lets you nudge the whole overlay against a live GPS fix and save it — on the phone, or -published to the server for every device if you hold the admin token. - ## Privacy BigBrainParking sends your location to ParkSmarter **only** when you explicitly tap "My @@ -120,35 +75,12 @@ in `app/app.json` to point elsewhere. ## Notifications on GrapheneOS -Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services — -so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for -any *future* server-initiated messages; nothing time-based needs it. - -**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or -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. +Session-expiry reminders are scheduled **entirely on-device** from each session's end time +(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play +Services. They work fully offline. Configure the lead time (default 15 min) in +**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 +*future* server-initiated messages; nothing time-based needs it. ## Distribution via Obtainium (self-hosted) diff --git a/app/app.json b/app/app.json index 361913f..9738ac2 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.6.4", + "version": "0.4.1", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 25, + "versionCode": 19, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/modules/bbp-notify/android/src/main/AndroidManifest.xml b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml index c28d9dd..60c738b 100644 --- a/app/modules/bbp-notify/android/src/main/AndroidManifest.xml +++ b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml @@ -1,37 +1,9 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt index 0071678..6647f2a 100644 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt +++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt @@ -3,28 +3,24 @@ package expo.modules.bbpnotify import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.core.app.NotificationManagerCompat /** - * Handles the notification's "End" / "Extend" buttons, even when the app process is - * dead. Each button records its choice in [BbpSessionStore]; JS picks it up via - * `consumePendingAction` the next time it runs and finishes the job on its side - * (clearing local state, cancelling the expiry reminder, opening the pay screen). + * 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) { - ACTION_END -> { - // Ending is immediate and local: drop the session and take the notification - // down with the service, without waiting for the app to be opened. - BbpSessionStore.setPending(context, "end") - BbpSessionStore.clear(context) - BbpSessionService.stop(context) + BbpNotifyModule.ACTION_END -> { + prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply() + NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID) } - ACTION_EXTEND -> { - // 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") + 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) @@ -32,9 +28,4 @@ class BbpActionReceiver : BroadcastReceiver() { } } } - - companion object { - const val ACTION_END = "expo.modules.bbpnotify.END" - const val ACTION_EXTEND = "expo.modules.bbpnotify.EXTEND" - } } diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt deleted file mode 100644 index c795efc..0000000 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt +++ /dev/null @@ -1,31 +0,0 @@ -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) - } -} diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt deleted file mode 100644 index 29b61dc..0000000 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt +++ /dev/null @@ -1,92 +0,0 @@ -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, - ) - } -} diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt index e968b07..914fae9 100644 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt +++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt @@ -1,63 +1,138 @@ 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.ModuleDefinition /** - * JS bridge for the ongoing parking notification. + * 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. * - * The module itself holds no state — it writes the session to [BbpSessionStore] and - * lets [BbpSessionService] render it. That keeps the app process out of the loop: - * the notification is just as correct after the JS engine is gone as it is while - * the user is looking at the app. Uses only platform APIs, no third-party deps. + * 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") - /** - * Post (or replace) the countdown for an active session. Returns a short - * 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 -> + // 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" - 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) + postCountdown(ctx, title, body, endTimeMillis, null, null) } - /** No session: drop the record and stop the service, taking the notification with it. */ - AsyncFunction("clearSession") { - // No bare `return@AsyncFunction` here: the lambda's inferred return type is - // Any?, so an early return of Unit doesn't type-check. - appContext.reactContext?.let { ctx -> - BbpSessionStore.clear(ctx) - BbpSessionService.stop(ctx) + // Same ticking countdown, plus "End" and "Pay" action buttons. + AsyncFunction("showCheckin") { + title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String -> + val ctx = appContext.reactContext ?: return@AsyncFunction "no-context" + postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel) + } + + AsyncFunction("clear") { + val ctx = appContext.reactContext + if (ctx != null) { + NotificationManagerCompat.from(ctx).cancel(NOTIF_ID) } } - /** Read + clear the action a notification button set. Returns "end", "extend", or "". */ + // 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 "" - BbpSessionStore.consumePending(ctx) - } - - /** True when a live session record exists — lets JS reconcile after a cold start. */ - AsyncFunction("hasActiveSession") { - val ctx = appContext.reactContext ?: return@AsyncFunction false - BbpSessionStore.load(ctx) != null + val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val action = prefs.getString(PREF_PENDING, "") ?: "" + if (action.isNotEmpty()) prefs.edit().remove(PREF_PENDING).apply() + action } } + + private fun postCountdown( + ctx: Context, + title: String, + body: String, + endTimeMillis: Double, + endLabel: String?, + payLabel: String?, + ): String { + ensureChannel(ctx) + val mgr = NotificationManagerCompat.from(ctx) + val enabled = mgr.areNotificationsEnabled() + + val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(R.drawable.bbp_stat_parking) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(true) + .setWhen(endTimeMillis.toLong()) + .setUsesChronometer(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_STATUS) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setChronometerCountDown(true) + } + + ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch -> + builder.setContentIntent( + PendingIntent.getActivity( + ctx, 0, launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + } + + if (endLabel != null) builder.addAction(0, endLabel, actionIntent(ctx, ACTION_END)) + if (payLabel != null) builder.addAction(0, payLabel, actionIntent(ctx, ACTION_PAY)) + + return try { + mgr.notify(NOTIF_ID, builder.build()) + "posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}" + } catch (e: SecurityException) { + "security-exception enabled=$enabled msg=${e.message}" + } catch (e: Exception) { + "exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}" + } + } + + private fun actionIntent(ctx: Context, action: String): PendingIntent { + val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action) + return PendingIntent.getBroadcast( + ctx, action.hashCode(), intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun ensureChannel(ctx: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val mgr = ctx.getSystemService(NotificationManager::class.java) + if (mgr.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW) + 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" + } } diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt deleted file mode 100644 index e3d43ad..0000000 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt +++ /dev/null @@ -1,149 +0,0 @@ -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}" - } - } - } -} diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt deleted file mode 100644 index 7267253..0000000 --- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt +++ /dev/null @@ -1,86 +0,0 @@ -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 - } -} diff --git a/app/modules/bbp-notify/index.ts b/app/modules/bbp-notify/index.ts index cdf9d1f..cf085e4 100644 --- a/app/modules/bbp-notify/index.ts +++ b/app/modules/bbp-notify/index.ts @@ -3,26 +3,25 @@ import { requireOptionalNativeModule } from 'expo-modules-core'; interface BbpNotifyNative { /** - * Post/replace the ongoing session notification: a native chronometer counting - * down to endTimeMillis, pinned by a foreground service, with "End" and "Extend" - * action buttons. Returns a short diagnostic string (e.g. "service-started …"). + * Post/replace an ongoing notification with a native chronometer counting down + * to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true"). */ - showSession( + showCountdown(title: string, body: string, endTimeMillis: number): Promise; + /** Same ticking countdown, plus "End" / "Pay" action buttons. */ + showCheckin( title: string, body: string, endTimeMillis: number, endLabel: string, - extendLabel: string, + payLabel: string, ): Promise; - /** No active session: stop the service and remove the notification. */ - clearSession(): Promise; - /** Read + clear the action a notification button set: 'end' | 'extend' | ''. */ + /** Remove the countdown notification. */ + clear(): Promise; + /** Read + clear the action a notification button set: 'end' | 'pay' | ''. */ consumePendingAction(): Promise; - /** Whether the native side still holds a live (unexpired) session record. */ - hasActiveSession(): Promise; } -export type PendingAction = 'end' | 'extend' | null; +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). @@ -34,27 +33,32 @@ const native = /** True when the native ticking-countdown module is available. */ export const hasNativeCountdown = native != null; -export async function showSession( +export async function showCountdown( + title: string, + body: string, + endTimeMillis: number, +): Promise { + if (!native) return 'no-native-module'; + return native.showCountdown(title, body, endTimeMillis); +} + +export async function showCheckin( title: string, body: string, endTimeMillis: number, endLabel: string, - extendLabel: string, + payLabel: string, ): Promise { if (!native) return 'no-native-module'; - return native.showSession(title, body, endTimeMillis, endLabel, extendLabel); + return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel); } -export async function clearSession(): Promise { - await native?.clearSession(); +export async function clearCountdown(): Promise { + await native?.clear(); } /** Read + clear a notification-button action taken while the app was away. */ export async function consumePendingAction(): Promise { const a = await native?.consumePendingAction(); - return a === 'end' || a === 'extend' ? a : null; -} - -export async function hasActiveSession(): Promise { - return (await native?.hasActiveSession()) ?? false; + return a === 'end' || a === 'pay' ? a : null; } diff --git a/app/package.json b/app/package.json index 6975cda..ec8ccb8 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,6 @@ "android": "expo run:android", "prebuild": "expo prebuild --platform android --clean", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test test/*.test.ts", "ios": "expo run:ios" }, "dependencies": { @@ -38,7 +37,6 @@ "devDependencies": { "@types/react": "~19.0.0", "babel-plugin-module-resolver": "^5.0.2", - "tsx": "^4.23.12", "typescript": "~5.4.0" } } diff --git a/app/src/api/parkingAreas.ts b/app/src/api/parkingAreas.ts deleted file mode 100644 index 6aad019..0000000 --- a/app/src/api/parkingAreas.ts +++ /dev/null @@ -1,234 +0,0 @@ -import Constants from 'expo-constants'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getAdminToken } from './adminStore'; -import bundled from '@/features/citymap/parkingAreas.json'; -import { - adjustGeometry, - overlayAnchor, - IDENTITY_OVERLAY, - type AreaGeometry, - type OverlayAdjust, -} from '@/features/citymap/geo'; - -/** - * The colour-coded areas from the City of Sandpoint's printed Downtown & - * Waterfront parking map, georeferenced. - * - * This is city geography, not ParkSmarter data — no call in this file, or in - * anything that uses it, ever reaches the IPS API. That is the whole point: - * tracking your time on one of these spots has to work with no account, no - * network, and no payment. - * - * Three sources, in order: the server (editable without an app release), the - * on-device cache (so it works offline), and a copy bundled with the app (so a - * fresh install works before the server has ever been reached). - */ - -export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit'; - -export interface ParkingArea { - id: string; - kind: AreaKind; - /** Human name, e.g. "N 3rd Ave · Cedar St to Oak St". */ - name: string; - /** Short category label, e.g. "3-hour". */ - label: string; - /** The map legend's own wording, e.g. "3-hour or permit". */ - legend: string; - /** Default tracked duration in hours; 0 for the no-limit category. */ - hours: number; - color: string; - shape: 'line' | 'polygon'; - geometry: AreaGeometry; - /** - * Overrides the by-category default in [areaRequiresAccount]. Only set this to - * correct a specific lot — e.g. one that turns out to be kiosk- or permit-only. - */ - requiresAccount?: boolean; -} - -export interface AreaData { - areas: ParkingArea[]; - overlay: OverlayAdjust; - /** Where the set came from, for the diagnostics screen. */ - source: 'server' | 'cache' | 'bundled'; -} - -const CACHE_KEY = 'ps_parking_areas'; -const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, ''); - -export class ParkingAreasError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - this.name = 'ParkingAreasError'; - } -} - -/** Colours match the printed map's legend. */ -export const AREA_COLORS: Record = { - green_lot: '#75b259', - free_2h: '#d367cc', - limit_3h: '#ccc542', - limit_4h: '#f78b08', - no_limit: '#c3c4c2', -}; - -/** Whether parking in this category costs money. Only the city lots do. */ -export function areaIsFree(kind: AreaKind): boolean { - return kind !== 'green_lot'; -} - -/** - * Whether you need a ParkSmarter account to park here. - * - * The city lots are the map's only paid category ("City lots — Paid hourly or - * permit"); paying for them means ParkSmarter, so they're no use to someone - * browsing without an account. Everything else is free with a posted time limit - * and needs nothing. A single lot can override this if it turns out to take - * payment some other way. - */ -export function areaRequiresAccount(area: ParkingArea): boolean { - return area.requiresAccount ?? area.kind === 'green_lot'; -} - -/** - * Durations offered when starting tracking, the posted limit first. - * - * Time-limited spots stop at the posted limit — offering to track 4 hours in a - * 2-hour space would just be scheduling a ticket. Lots and unlimited spots have - * no posted ceiling, so they get the long options. - */ -export function areaDurationOptions(area: ParkingArea): number[] { - if (area.kind === 'no_limit' || area.kind === 'green_lot') return [1, 2, 3, 4, 8, 12]; - const posted = area.hours || 2; - return [...new Set([posted, 3, 2, 1, 0.5].filter((h) => h <= posted))].sort((a, b) => b - a); -} - -const BUNDLED: ParkingArea[] = (bundled as any).features.map((f: any) => ({ - ...f.properties, - geometry: f.geometry, -})); - -/* ------------------------------------------------------------------- cache */ - -let memCache: AreaData | null = null; - -async function readCache(): Promise { - const raw = await AsyncStorage.getItem(CACHE_KEY); - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as { areas: ParkingArea[]; overlay: OverlayAdjust }; - if (!parsed.areas?.length) return null; - return { areas: parsed.areas, overlay: parsed.overlay ?? IDENTITY_OVERLAY, source: 'cache' }; - } catch { - return null; - } -} - -async function writeCache(areas: ParkingArea[], overlay: OverlayAdjust): Promise { - await AsyncStorage.setItem(CACHE_KEY, JSON.stringify({ areas, overlay })); -} - -/* -------------------------------------------------------------------- read */ - -/** - * The area set, without the overlay correction applied. Never throws and never - * blocks on the network — the bundled copy is always a valid answer. - */ -export async function loadAreaData(): Promise { - if (memCache) return memCache; - const cached = await readCache(); - memCache = cached ?? { areas: BUNDLED, overlay: IDENTITY_OVERLAY, source: 'bundled' }; - return memCache; -} - -/** - * The area set ready to draw and hit-test, with the alignment correction baked - * in. Display and hit-testing must use the same geometry or tapping a segment - * would select a different one than the one under your finger. - */ -export async function getAdjustedAreas(): Promise { - const data = await loadAreaData(); - return { ...data, areas: applyOverlay(data.areas, data.overlay) }; -} - -/** Apply an overlay correction to a set of areas (also used for live preview). */ -export function applyOverlay(areas: ParkingArea[], overlay: OverlayAdjust): ParkingArea[] { - const anchor = overlayAnchor(areas.map((a) => a.geometry)); - return areas.map((a) => ({ ...a, geometry: adjustGeometry(a.geometry, overlay, anchor) })); -} - -/** Pull the areas + overlay from the server and cache them. */ -export async function refreshAreas(): Promise { - if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured'); - const res = await fetch(`${BASE_URL}/api/areas`); - if (!res.ok) throw new ParkingAreasError('areas fetch failed', res.status); - const body = (await res.json()) as { areas?: ParkingArea[]; overlay?: OverlayAdjust }; - - // An empty server (not yet seeded) must not wipe a working local map. - if (!body.areas?.length) return loadAreaData(); - - const overlay = body.overlay ?? IDENTITY_OVERLAY; - await writeCache(body.areas, overlay); - memCache = { areas: body.areas, overlay, source: 'server' }; - return memCache; -} - -/* ------------------------------------------------------------------- admin */ - -async function authHeaders(): Promise> { - const token = await getAdminToken(); - if (!token) throw new ParkingAreasError('not authenticated as admin'); - return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; -} - -/** Push the app's bundled area set to the server, seeding or resetting it. */ -export async function publishBundledAreas(): Promise { - if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured'); - const res = await fetch(`${BASE_URL}/api/areas`, { - method: 'PUT', - headers: await authHeaders(), - body: JSON.stringify({ areas: BUNDLED }), - }); - if (!res.ok) throw new ParkingAreasError('publish failed', res.status); - const body = (await res.json()) as { replaced?: number }; - memCache = null; - await refreshAreas().catch(() => {}); - return body.replaced ?? 0; -} - -/** Persist an alignment correction so every device picks it up. */ -export async function saveOverlay(o: Omit): Promise { - if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured'); - const res = await fetch(`${BASE_URL}/api/areas/overlay`, { - method: 'PUT', - headers: await authHeaders(), - body: JSON.stringify(o), - }); - if (!res.ok) throw new ParkingAreasError('overlay save failed', res.status); - const saved = (await res.json()) as OverlayAdjust; - const data = await loadAreaData(); - await writeCache(data.areas, saved); - memCache = { ...data, overlay: saved }; - return saved; -} - -/** - * Store an overlay correction on this device only. Lets the alignment be fixed - * without the admin token — the nudge is useful to anyone standing on the street. - */ -export async function saveOverlayLocally(o: Omit): Promise { - const data = await loadAreaData(); - const overlay = { ...o, updatedAt: Date.now() }; - await writeCache(data.areas, overlay); - memCache = { ...data, overlay }; -} - -/** Drop the cache so the next read falls back to bundled/server. */ -export async function resetAreaCache(): Promise { - memCache = null; - await AsyncStorage.removeItem(CACHE_KEY); -} diff --git a/app/src/api/parseTime.ts b/app/src/api/parseTime.ts deleted file mode 100644 index 391cc89..0000000 --- a/app/src/api/parseTime.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * 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); -} diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx index 4f894a4..1d94e82 100644 --- a/app/src/auth/AuthContext.tsx +++ b/app/src/auth/AuthContext.tsx @@ -31,21 +31,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [validity, setValidity] = useState(null); const [error, setError] = useState(null); - // Let the non-React modules see the mode. Kept in sync here rather than read - // from storage: anonymous mode is deliberately not persisted across restarts. - useEffect(() => { - authBus.isAnonymous = status === 'anonymous'; - }, [status]); - // Any 401 from the API (expired/rotated token) bounces us back to sign-in. useEffect(() => { authBus.onUnauthorized = () => { - // ...except in Anonymous Mode, where there is no session to expire. A 401 - // there just means something asked ParkSmarter a question it had no - // business asking, and bouncing to sign-in would make the app unusable - // without an account — which is the whole point of the mode. Read at call - // time, so this stays correct as the status changes. - if (authBus.isAnonymous) return; setError('Your session expired — please sign in again.'); setStatus('signedOut'); }; diff --git a/app/src/auth/authBus.ts b/app/src/auth/authBus.ts index 02c1a3b..075d9fd 100644 --- a/app/src/auth/authBus.ts +++ b/app/src/auth/authBus.ts @@ -2,13 +2,5 @@ * Tiny bridge so the API client (created at module load) can notify the React * auth layer when a 401 happens, without a circular import. AuthProvider * registers a handler; the client calls it via app/src/api/client.ts. - * - * `isAnonymous` mirrors the auth status for the non-React modules that need it. - * It matters because the 401 hook is global: it fires on every unauthorized - * response whether or not the caller caught the error, so without this a single - * stray ParkSmarter call in Anonymous Mode throws the user to the sign-in screen. */ -export const authBus: { - onUnauthorized?: () => void; - isAnonymous: boolean; -} = { isAnonymous: false }; +export const authBus: { onUnauthorized?: () => void } = {}; diff --git a/app/src/features/checkin/checkin.ts b/app/src/features/checkin/checkin.ts new file mode 100644 index 0000000..fd15133 --- /dev/null +++ b/app/src/features/checkin/checkin.ts @@ -0,0 +1,135 @@ +import { useEffect } from 'react'; +import { AppState, Platform } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import * as Notifications from 'expo-notifications'; +import type { Zone } from 'parksmarter-client'; +import type { RootStackParamList } from '@/navigation/RootNavigator'; +import { + showCheckin, + clearCountdown, + consumePendingAction, + hasNativeCountdown, +} from '../../../modules/bbp-notify'; +import { getReminderLeadMinutes, getRemindersEnabled, getCountdownEnabled } from '@/features/notifications/reminderPrefs'; +import { ensureNotificationPermission } from '@/notifications/localReminders'; +import { logLine } from '@/features/diagnostics/fileLogger'; +import { labelHours, type LabelKind } from '@/api/zoneLabels'; +import { getCheckin, setCheckin, clearCheckinState, type CheckinState } from './checkinStore'; + +const ALERT_ID = 'checkin-alert'; +const FALLBACK_NOTIF_ID = 'checkin-status'; + +function fmtTime(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +} + +/** Post (or re-post) the ongoing ticking check-in notification with End/Pay buttons. */ +async function postCheckinNotification(s: CheckinState): Promise { + if (!(await getCountdownEnabled())) return; + const ends = fmtTime(s.endMs); + if (hasNativeCountdown) { + const diag = await showCheckin(`Free parking · ${s.zoneName}`, `Free until ${ends}`, s.endMs, 'End', 'Pay'); + logLine(`[CHECKIN] native: ${diag}`); + return; + } + // Fallback (Expo Go / non-native): a static ongoing notification, no buttons. + await Notifications.scheduleNotificationAsync({ + identifier: FALLBACK_NOTIF_ID, + content: { + title: `Free parking · ${s.zoneName}`, + body: `Free until ${ends}`, + sticky: true, + autoDismiss: false, + data: { kind: 'session-status' }, + }, + trigger: + Platform.OS === 'android' + ? { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(Date.now() + 400), channelId: 'session-status' } + : null, + }); +} + +async function scheduleCheckinAlert(s: CheckinState): Promise { + await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {}); + if (!(await getRemindersEnabled())) return; + const fireAt = s.endMs - s.leadMinutes * 60_000; + if (fireAt <= Date.now()) return; + await Notifications.scheduleNotificationAsync({ + identifier: ALERT_ID, + content: { + title: 'Free parking ending soon', + body: `${s.zoneName}: your free time ends at ${fmtTime(s.endMs)}. Pay to extend or move your car.`, + data: { kind: 'checkin-alert' }, + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.DATE, + date: new Date(fireAt), + channelId: 'session-reminders', + }, + }); +} + +/** Start a local free check-in for `hours` at the given zone. */ +export async function startCheckin(zone: Zone, hours: number): Promise { + const now = Date.now(); + const state: CheckinState = { + zone, + zoneName: zone.ZoneName ?? 'Parking', + startMs: now, + endMs: now + hours * 3_600_000, + kind: `free_${hours}h` as LabelKind, + leadMinutes: await getReminderLeadMinutes(), + }; + await setCheckin(state); + await ensureNotificationPermission(); + await postCheckinNotification(state); + await scheduleCheckinAlert(state); +} + +/** End the active check-in and clear its notifications. */ +export async function endCheckin(): Promise { + await clearCheckinState(); + await clearCountdown().catch(() => {}); + await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {}); + await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {}); +} + +/** Hours of free time for a label kind, or null for pay-immediate. */ +export { labelHours }; + +/** + * Keep the check-in in sync on foreground: apply any notification-button action + * (End clears it; Pay hands off to the paid flow), drop expired check-ins, and + * re-post the ongoing notification (e.g. after a reboot). Mirrors useSessionStatusSync. + */ +export function useCheckinSync(): void { + const navigation = useNavigation>(); + useEffect(() => { + const run = async () => { + const action = await consumePendingAction(); + if (action === 'end') { + await endCheckin(); + return; + } + if (action === 'pay') { + const s = await getCheckin(); + await endCheckin(); + if (s?.zone) navigation.navigate('StartSession', { zone: s.zone }); + return; + } + const s = await getCheckin(); + if (!s) return; + if (s.endMs <= Date.now()) { + await endCheckin(); + } else { + await postCheckinNotification(s); + } + }; + void run(); + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') void run(); + }); + return () => sub.remove(); + }, [navigation]); +} diff --git a/app/src/features/checkin/checkinStore.ts b/app/src/features/checkin/checkinStore.ts new file mode 100644 index 0000000..adf0f32 --- /dev/null +++ b/app/src/features/checkin/checkinStore.ts @@ -0,0 +1,32 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Zone } from 'parksmarter-client'; +import type { LabelKind } from '@/api/zoneLabels'; + +/** + * A local, API-free "check-in" for a free time-limited space. The full Zone is + * stored so the notification's "Pay" button can hand off straight into the paid + * flow without re-fetching. Only one active check-in at a time. + */ +const KEY = 'ps_checkin'; + +export interface CheckinState { + zone: Zone; + zoneName: string; + startMs: number; + endMs: number; + kind: LabelKind; + leadMinutes: number; +} + +export async function getCheckin(): Promise { + const raw = await AsyncStorage.getItem(KEY); + return raw ? (JSON.parse(raw) as CheckinState) : null; +} + +export async function setCheckin(state: CheckinState): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(state)); +} + +export async function clearCheckinState(): Promise { + await AsyncStorage.removeItem(KEY); +} diff --git a/app/src/features/citymap/geo.ts b/app/src/features/citymap/geo.ts deleted file mode 100644 index a62baec..0000000 --- a/app/src/features/citymap/geo.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Geometry for the city parking-map overlay. - * - * Everything here is plain arithmetic on lon/lat — no turf, no geo library. The - * covered area is ten blocks of downtown Sandpoint, so a local flat-earth - * approximation is accurate to well under a metre, and hit-testing has to run on - * every map tap. - */ - -export type LonLat = [number, number]; - -/** GeoJSON geometry as it comes from the server or the bundled map. */ -export type AreaGeometry = - | { type: 'LineString'; coordinates: LonLat[] } - | { type: 'Polygon'; coordinates: LonLat[][] }; - -export interface OverlayAdjust { - /** Ground metres east. */ - dxMeters: number; - /** Ground metres north. */ - dyMeters: number; - /** Multiplier about the overlay centroid. */ - scale: number; - /** Degrees counter-clockwise about the overlay centroid. */ - rotationDeg: number; - updatedAt: number; -} - -export const IDENTITY_OVERLAY: OverlayAdjust = { - dxMeters: 0, - dyMeters: 0, - scale: 1, - rotationDeg: 0, - updatedAt: 0, -}; - -export function isIdentity(o: OverlayAdjust): boolean { - return o.dxMeters === 0 && o.dyMeters === 0 && o.scale === 1 && o.rotationDeg === 0; -} - -const M_PER_DEG_LAT = 110574; -const metresPerDegLon = (lat: number) => 111320 * Math.cos((lat * Math.PI) / 180); - -/* ------------------------------------------------------------------ distance */ - -/** Metres between two lon/lat points (flat-earth; exact enough downtown). */ -export function distanceMeters(a: LonLat, b: LonLat): number { - const mx = metresPerDegLon((a[1] + b[1]) / 2); - const dx = (a[0] - b[0]) * mx; - const dy = (a[1] - b[1]) * M_PER_DEG_LAT; - return Math.hypot(dx, dy); -} - -/** Metres from `p` to the segment a→b. */ -function distToSegment(p: LonLat, a: LonLat, b: LonLat): number { - const mx = metresPerDegLon(p[1]); - const px = p[0] * mx; - const py = p[1] * M_PER_DEG_LAT; - const ax = a[0] * mx; - const ay = a[1] * M_PER_DEG_LAT; - const bx = b[0] * mx; - const by = b[1] * M_PER_DEG_LAT; - const dx = bx - ax; - const dy = by - ay; - const len2 = dx * dx + dy * dy; - const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); - return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); -} - -function ringContains(p: LonLat, ring: LonLat[]): boolean { - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const [xi, yi] = ring[i]; - const [xj, yj] = ring[j]; - if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) { - inside = !inside; - } - } - return inside; -} - -/** - * Metres from a point to a geometry — 0 when the point is inside a polygon, so - * "which area am I in" and "which area is nearest" are the same question. - */ -export function distanceToGeometry(p: LonLat, g: AreaGeometry): number { - if (g.type === 'Polygon') { - const [outer, ...holes] = g.coordinates; - if (!outer?.length) return Infinity; - if (ringContains(p, outer) && !holes.some((h) => ringContains(p, h))) return 0; - let best = Infinity; - for (const ring of g.coordinates) { - for (let i = 1; i < ring.length; i++) best = Math.min(best, distToSegment(p, ring[i - 1], ring[i])); - } - return best; - } - const line = g.coordinates; - if (line.length === 1) return distanceMeters(p, line[0]); - let best = Infinity; - for (let i = 1; i < line.length; i++) best = Math.min(best, distToSegment(p, line[i - 1], line[i])); - return best; -} - -/* ------------------------------------------------------------------ overlay */ - -/** - * Apply the whole-overlay correction: scale and rotate about `anchor`, then shift. - * - * The georeference was fitted to OpenStreetMap and is good to a few metres, but - * "a few metres" is the difference between two sides of a street. This is what - * lets that be corrected from the phone against a live GPS fix, with no rebuild. - */ -export function adjustGeometry(g: AreaGeometry, o: OverlayAdjust, anchor: LonLat): AreaGeometry { - if (isIdentity(o)) return g; - - const mx = metresPerDegLon(anchor[1]); - const cosT = Math.cos((o.rotationDeg * Math.PI) / 180); - const sinT = Math.sin((o.rotationDeg * Math.PI) / 180); - - const move = (p: LonLat): LonLat => { - // Into local metres relative to the anchor, transform, and back out. - const ex = (p[0] - anchor[0]) * mx; - const ny = (p[1] - anchor[1]) * M_PER_DEG_LAT; - const rx = (ex * cosT - ny * sinT) * o.scale + o.dxMeters; - const ry = (ex * sinT + ny * cosT) * o.scale + o.dyMeters; - return [anchor[0] + rx / mx, anchor[1] + ry / M_PER_DEG_LAT]; - }; - - return g.type === 'Polygon' - ? { type: 'Polygon', coordinates: g.coordinates.map((r) => r.map(move)) } - : { type: 'LineString', coordinates: g.coordinates.map(move) }; -} - -/** Centroid of every vertex in the set — the anchor scale and rotation turn about. */ -export function overlayAnchor(geoms: AreaGeometry[]): LonLat { - let x = 0; - let y = 0; - let n = 0; - for (const g of geoms) { - for (const [lon, lat] of g.type === 'Polygon' ? g.coordinates.flat() : g.coordinates) { - x += lon; - y += lat; - n++; - } - } - return n ? [x / n, y / n] : [0, 0]; -} diff --git a/app/src/features/citymap/parkingAreas.json b/app/src/features/citymap/parkingAreas.json deleted file mode 100644 index 2745c48..0000000 --- a/app/src/features/citymap/parkingAreas.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"FeatureCollection","features":[{"type":"Feature","id":"sp-000","geometry":{"type":"LineString","coordinates":[[-116.5503661,48.2782914],[-116.5503661,48.2793557]]},"properties":{"id":"sp-000","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"N 3rd Ave \u00b7 Poplar St to Fir St"}},{"type":"Feature","id":"sp-001","geometry":{"type":"LineString","coordinates":[[-116.5519169,48.2782914],[-116.5519169,48.2793557]]},"properties":{"id":"sp-001","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"N 4th Ave \u00b7 Poplar St to Fir St"}},{"type":"Feature","id":"sp-002","geometry":{"type":"LineString","coordinates":[[-116.5533041,48.2771426],[-116.5488263,48.2771426]]},"properties":{"id":"sp-002","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 2nd Ave \u00b7 at Alder St"}},{"type":"Feature","id":"sp-003","geometry":{"type":"LineString","coordinates":[[-116.5533041,48.2781829],[-116.5520634,48.2781829]]},"properties":{"id":"sp-003","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Poplar St \u00b7 N 5th Ave to N 4th Ave"}},{"type":"Feature","id":"sp-004","geometry":{"type":"LineString","coordinates":[[-116.5503566,48.2772164],[-116.5503566,48.2780943]]},"properties":{"id":"sp-004","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 3rd Ave \u00b7 Alder St to Poplar St"}},{"type":"Feature","id":"sp-005","geometry":{"type":"LineString","coordinates":[[-116.5536782,48.2750842],[-116.5555487,48.2750916]]},"properties":{"id":"sp-005","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 6th Ave \u00b7 at Oak St"}},{"type":"Feature","id":"sp-006","geometry":{"type":"LineString","coordinates":[[-116.5536782,48.2740439],[-116.5547212,48.2740439]]},"properties":{"id":"sp-006","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Church St \u00b7 at N 5th Ave"}},{"type":"Feature","id":"sp-007","geometry":{"type":"LineString","coordinates":[[-116.5520595,48.2731143],[-116.5533405,48.273129]]},"properties":{"id":"sp-007","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Pine St \u00b7 N 4th Ave to N 5th Ave"}},{"type":"Feature","id":"sp-008","geometry":{"type":"LineString","coordinates":[[-116.5513089,48.2731216],[-116.5505494,48.2731216]]},"properties":{"id":"sp-008","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Pine St \u00b7 S 4th Ave to S 3rd Ave"}},{"type":"Feature","id":"sp-009","geometry":{"type":"LineString","coordinates":[[-116.550436,48.2729224],[-116.5504247,48.2721994]]},"properties":{"id":"sp-009","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"S 3rd Ave \u00b7 Pine St to Lake St"}},{"type":"Feature","id":"sp-010","geometry":{"type":"LineString","coordinates":[[-116.5504304,48.2721584],[-116.5478514,48.2721846]]},"properties":{"id":"sp-010","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"S 1st Ave \u00b7 at Lake St"}},{"type":"Feature","id":"sp-011","geometry":{"type":"LineString","coordinates":[[-116.5476359,48.2721699],[-116.5468538,48.2721699]]},"properties":{"id":"sp-011","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"E Lake St \u00b7 at S 1st Ave"}},{"type":"Feature","id":"sp-012","geometry":{"type":"LineString","coordinates":[[-116.5528053,48.2760991],[-116.5519098,48.2760991]]},"properties":{"id":"sp-012","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"Cedar St \u00b7 at N 4th Ave"}},{"type":"Feature","id":"sp-013","geometry":{"type":"LineString","coordinates":[[-116.5556205,48.2732176],[-116.5556205,48.2770688]]},"properties":{"id":"sp-013","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"N 6th Ave \u00b7 Pine St to Alder St"}},{"type":"Feature","id":"sp-014","geometry":{"type":"LineString","coordinates":[[-116.5536782,48.2771426],[-116.5554844,48.2771426]]},"properties":{"id":"sp-014","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Alder St \u00b7 N 5th Ave to N 6th Ave"}},{"type":"Feature","id":"sp-015","geometry":{"type":"LineString","coordinates":[[-116.5519386,48.2772283],[-116.5519386,48.2781062]]},"properties":{"id":"sp-015","kind":"limit_4h","label":"4-hour","legend":"4-hour or permit","hours":4,"color":"#f78b08","shape":"line","name":"N 4th Ave \u00b7 Alder St to Poplar St"}},{"type":"Feature","id":"sp-016","geometry":{"type":"LineString","coordinates":[[-116.5518485,48.276088],[-116.5479474,48.2760991],[-116.5477493,48.2760102],[-116.5477493,48.2731216]]},"properties":{"id":"sp-016","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"N 1st Ave \u00b7 Cedar St to S 1st Ave"}},{"type":"Feature","id":"sp-017","geometry":{"type":"LineString","coordinates":[[-116.548736,48.2760324],[-116.548736,48.2731216]]},"properties":{"id":"sp-017","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Pine St \u00b7 at N 2nd Ave"}},{"type":"Feature","id":"sp-018","geometry":{"type":"LineString","coordinates":[[-116.5486065,48.2749405],[-116.5478556,48.274777]]},"properties":{"id":"sp-018","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Main St \u00b7 N 2nd Ave to N 1st Ave"}},{"type":"Feature","id":"sp-019","geometry":{"type":"LineString","coordinates":[[-116.5502626,48.2740447],[-116.5488165,48.2740447]]},"properties":{"id":"sp-019","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Church St \u00b7 N 3rd Ave to N 2nd Ave"}},{"type":"Feature","id":"sp-020","geometry":{"type":"LineString","coordinates":[[-116.5488511,48.2749881],[-116.5498283,48.2751437]]},"properties":{"id":"sp-020","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Oak St \u00b7 at Main St"}},{"type":"Feature","id":"sp-021","geometry":{"type":"LineString","coordinates":[[-116.5503479,48.2760324],[-116.5503479,48.275538]]},"properties":{"id":"sp-021","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"N 3rd Ave \u00b7 Cedar St to Main St"}},{"type":"Feature","id":"sp-022","geometry":{"type":"Polygon","coordinates":[[[-116.5492389,48.2741203],[-116.5501074,48.2741203],[-116.5501074,48.2749357],[-116.5492389,48.2749357],[-116.5492389,48.2741203]]]},"properties":{"id":"sp-022","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Oak St"}},{"type":"Feature","id":"sp-023","geometry":{"type":"Polygon","coordinates":[[[-116.549816,48.2754086],[-116.5501662,48.2754086],[-116.5501662,48.2755119],[-116.549816,48.2755119],[-116.549816,48.2754086]]]},"properties":{"id":"sp-023","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off N 3rd Ave"}},{"type":"Feature","id":"sp-024","geometry":{"type":"Polygon","coordinates":[[[-116.5455778,48.2731979],[-116.5452046,48.2731979],[-116.5452493,48.2734081],[-116.5452383,48.2734255],[-116.5452274,48.2734429],[-116.5452165,48.2734603],[-116.5452055,48.2734776],[-116.5451946,48.273495],[-116.5451837,48.2735124],[-116.5451727,48.2735297],[-116.5451618,48.2735471],[-116.5452244,48.2738554],[-116.5455924,48.2738238],[-116.5455287,48.2735099],[-116.5455387,48.2734968],[-116.5455486,48.2734836],[-116.5455586,48.2734704],[-116.5455685,48.2734572],[-116.5455785,48.2734441],[-116.5455884,48.2734309],[-116.5455984,48.2734177],[-116.5456084,48.2734045],[-116.5456045,48.2733787],[-116.5456007,48.2733529],[-116.5455968,48.2733271],[-116.545593,48.2733012],[-116.5455892,48.2732754],[-116.5455854,48.2732496],[-116.5455816,48.2732238],[-116.5455778,48.2731979]]]},"properties":{"id":"sp-024","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Sand Creek Byway"}},{"type":"Feature","id":"sp-025","geometry":{"type":"Polygon","coordinates":[[[-116.5443801,48.2733396],[-116.5443162,48.2733406],[-116.5442523,48.2733417],[-116.5441885,48.2733428],[-116.5441246,48.2733438],[-116.5440607,48.2733449],[-116.5439968,48.273346],[-116.5439329,48.273347],[-116.543869,48.2733481],[-116.5438725,48.2733643],[-116.543876,48.2733805],[-116.5438794,48.2733967],[-116.5438829,48.2734129],[-116.5438863,48.2734291],[-116.5438898,48.2734453],[-116.5438933,48.2734615],[-116.5438968,48.2734776],[-116.5438351,48.2734754],[-116.5437734,48.2734732],[-116.5437117,48.273471],[-116.54365,48.2734688],[-116.5435884,48.2734666],[-116.5435267,48.2734644],[-116.5434651,48.2734621],[-116.5434034,48.27346],[-116.5435702,48.2738474],[-116.5436354,48.2738531],[-116.5436966,48.2738589],[-116.5437537,48.2738646],[-116.5438067,48.2738702],[-116.5438557,48.2738756],[-116.5439005,48.2738809],[-116.5439414,48.2738859],[-116.5439781,48.2738906],[-116.5439995,48.2738934],[-116.5440227,48.2738964],[-116.544048,48.2738995],[-116.5440754,48.2739025],[-116.544105,48.2739055],[-116.544137,48.2739083],[-116.5441714,48.2739107],[-116.5442083,48.2739127],[-116.544221,48.2739133],[-116.5442374,48.2739139],[-116.5442573,48.2739145],[-116.5442804,48.273915],[-116.5443065,48.2739153],[-116.5443353,48.2739154],[-116.5443665,48.2739151],[-116.5443999,48.2739144],[-116.5444222,48.2739137],[-116.5444437,48.2739128],[-116.5444644,48.2739119],[-116.5444843,48.2739108],[-116.5445034,48.2739096],[-116.5445216,48.2739083],[-116.544539,48.273907],[-116.5445555,48.2739056],[-116.5445336,48.2738349],[-116.5445116,48.2737641],[-116.5444897,48.2736934],[-116.5444678,48.2736226],[-116.5444459,48.2735519],[-116.5444239,48.2734811],[-116.544402,48.2734103],[-116.5443801,48.2733396]]]},"properties":{"id":"sp-025","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Bridge St"}},{"type":"Feature","id":"sp-026","geometry":{"type":"Polygon","coordinates":[[[-116.5410174,48.2720238],[-116.5412053,48.2720167],[-116.5414279,48.2720384],[-116.5416715,48.2720848],[-116.5419226,48.2721517],[-116.5421676,48.2722352],[-116.542393,48.2723311],[-116.542585,48.2724354],[-116.5427301,48.2725441],[-116.5427859,48.2726311],[-116.542826,48.272699],[-116.5428531,48.2727516],[-116.5428704,48.2727922],[-116.5428806,48.2728246],[-116.542887,48.2728521],[-116.5428922,48.2728785],[-116.5428995,48.2729071],[-116.5429167,48.2729575],[-116.5429365,48.2730038],[-116.5429578,48.2730458],[-116.5429795,48.273083],[-116.5430004,48.2731152],[-116.5430194,48.273142],[-116.5430355,48.2731632],[-116.5430475,48.2731783],[-116.5430754,48.2732158],[-116.5431076,48.2732635],[-116.5431445,48.2733232],[-116.5431864,48.2733962],[-116.5432336,48.2734842],[-116.5432867,48.2735888],[-116.543346,48.2737115],[-116.5434117,48.2738539],[-116.5434305,48.2738783],[-116.5434439,48.2738994],[-116.5434524,48.2739173],[-116.5434568,48.2739324],[-116.5434576,48.273945],[-116.5434555,48.2739552],[-116.5434511,48.2739635],[-116.5434451,48.2739699],[-116.5434271,48.2739797],[-116.5434013,48.2739844],[-116.5433665,48.2739847],[-116.5433212,48.2739812],[-116.5432643,48.2739745],[-116.5431944,48.2739651],[-116.5431103,48.2739537],[-116.5430108,48.2739409],[-116.542993,48.2739387],[-116.5429614,48.2739349],[-116.5429179,48.2739299],[-116.5428641,48.273924],[-116.5428019,48.2739174],[-116.542733,48.2739106],[-116.5426591,48.2739038],[-116.5425821,48.2738974],[-116.5425339,48.2738938],[-116.5424954,48.2738913],[-116.5424621,48.2738894],[-116.5424298,48.2738879],[-116.5423938,48.2738861],[-116.5423498,48.2738838],[-116.5422934,48.2738805],[-116.5422201,48.2738757],[-116.5421078,48.2738677],[-116.542017,48.2738603],[-116.5419448,48.2738534],[-116.5418882,48.2738469],[-116.5418442,48.2738405],[-116.5418098,48.2738342],[-116.541782,48.2738279],[-116.541758,48.2738213],[-116.5417212,48.2738099],[-116.5416872,48.2737981],[-116.5416556,48.2737858],[-116.5416259,48.2737731],[-116.5415977,48.2737601],[-116.5415707,48.2737468],[-116.5415444,48.2737334],[-116.5415185,48.2737199],[-116.541474,48.2736954],[-116.5414391,48.2736741],[-116.5414102,48.2736543],[-116.541384,48.2736346],[-116.5413568,48.2736136],[-116.5413251,48.2735898],[-116.5412856,48.2735617],[-116.5412345,48.2735278],[-116.5411344,48.2734637],[-116.5410532,48.2734124],[-116.5409892,48.2733725],[-116.5409407,48.2733428],[-116.5409059,48.2733219],[-116.5408832,48.2733086],[-116.5408708,48.2733015],[-116.540867,48.2732995],[-116.5408537,48.2732923],[-116.5408381,48.2732834],[-116.5408213,48.2732727],[-116.5408046,48.2732604],[-116.5407891,48.2732463],[-116.5407759,48.2732307],[-116.5407663,48.2732133],[-116.5407613,48.2731943],[-116.5407608,48.2731846],[-116.5407616,48.2731739],[-116.5407638,48.2731629],[-116.5407675,48.2731522],[-116.5407729,48.2731422],[-116.54078,48.2731334],[-116.5407891,48.2731265],[-116.5408002,48.2731219],[-116.5408258,48.2731212],[-116.5408555,48.2731313],[-116.5408897,48.2731507],[-116.540929,48.2731782],[-116.5409738,48.2732125],[-116.5410245,48.2732522],[-116.5410815,48.2732962],[-116.5411455,48.273343],[-116.5411854,48.273371],[-116.5412329,48.2734033],[-116.5412879,48.2734387],[-116.5413503,48.2734764],[-116.5414203,48.2735152],[-116.5414976,48.2735543],[-116.5415824,48.2735927],[-116.5416745,48.2736293],[-116.5417589,48.2736584],[-116.5418403,48.2736828],[-116.5419176,48.2737029],[-116.54199,48.2737193],[-116.5420563,48.2737322],[-116.5421157,48.2737421],[-116.542167,48.2737495],[-116.5422093,48.2737546],[-116.5422822,48.2737619],[-116.542352,48.2737668],[-116.5424185,48.2737698],[-116.5424816,48.2737709],[-116.5425409,48.2737706],[-116.5425961,48.273769],[-116.5426471,48.2737665],[-116.5426934,48.2737634],[-116.5426926,48.2737556],[-116.5426918,48.2737469],[-116.5426907,48.2737375],[-116.5426895,48.2737274],[-116.542688,48.2737165],[-116.5426864,48.2737049],[-116.5426845,48.2736927],[-116.5426823,48.27368],[-116.54267,48.2736201],[-116.5426551,48.2735641],[-116.5426381,48.2735117],[-116.5426194,48.2734626],[-116.5425995,48.2734164],[-116.5425789,48.2733728],[-116.5425581,48.2733315],[-116.5425376,48.2732922],[-116.5424936,48.2732094],[-116.542455,48.2731382],[-116.54242,48.2730747],[-116.5423867,48.2730153],[-116.5423531,48.272956],[-116.5423172,48.2728931],[-116.5422772,48.2728228],[-116.5422312,48.2727414],[-116.5421714,48.2726344],[-116.5421221,48.2725473],[-116.5420805,48.2724775],[-116.5420438,48.2724221],[-116.5420088,48.2723785],[-116.5419728,48.2723441],[-116.5419329,48.2723161],[-116.5418861,48.2722919],[-116.5418586,48.2722801],[-116.5418267,48.2722677],[-116.5417908,48.2722544],[-116.541751,48.2722404],[-116.5417076,48.2722256],[-116.5416607,48.2722099],[-116.5416106,48.2721934],[-116.5415575,48.272176],[-116.5415085,48.27216],[-116.5414654,48.2721461],[-116.5414275,48.2721342],[-116.5413943,48.2721243],[-116.5413651,48.2721163],[-116.5413393,48.2721102],[-116.5413164,48.2721059],[-116.5412958,48.2721035],[-116.5412306,48.2721014],[-116.5411714,48.2721047],[-116.5411178,48.2721111],[-116.5410696,48.2721183],[-116.5410264,48.2721241],[-116.5409877,48.2721263],[-116.5409532,48.2721226],[-116.5409227,48.2721108],[-116.5409138,48.272105],[-116.5409057,48.2720984],[-116.5408986,48.2720912],[-116.5408929,48.2720836],[-116.5408887,48.2720757],[-116.5408866,48.2720678],[-116.5408867,48.2720601],[-116.5408894,48.2720528],[-116.5408958,48.2720451],[-116.5409055,48.2720384],[-116.5409183,48.2720327],[-116.5409339,48.2720281],[-116.5409519,48.2720249],[-116.540972,48.2720229],[-116.540994,48.2720226],[-116.5410174,48.2720238]]]},"properties":{"id":"sp-026","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"City lot"}},{"type":"Feature","id":"sp-027","geometry":{"type":"Polygon","coordinates":[[[-116.5454933,48.2796814],[-116.5460255,48.2796034],[-116.5460806,48.2797625],[-116.5455483,48.2798405],[-116.5454933,48.2796814]]]},"properties":{"id":"sp-027","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Sandpoint Ave"}},{"type":"Feature","id":"sp-028","geometry":{"type":"Polygon","coordinates":[[[-116.541093,48.2759292],[-116.5444657,48.2756121],[-116.5444386,48.2754902],[-116.5410659,48.2758073],[-116.541093,48.2759292]]]},"properties":{"id":"sp-028","kind":"green_lot","label":"City lot","legend":"Paid hourly or permit","hours":2,"color":"#75b259","shape":"polygon","name":"Lot off Dock St"}},{"type":"Feature","id":"sp-029","geometry":{"type":"LineString","coordinates":[[-116.5507735,48.2740382],[-116.553392,48.2740382]]},"properties":{"id":"sp-029","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Church St \u00b7 at N 5th Ave"}},{"type":"Feature","id":"sp-030","geometry":{"type":"LineString","coordinates":[[-116.5497959,48.2750906],[-116.5533223,48.2750906]]},"properties":{"id":"sp-030","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 5th Ave \u00b7 at Oak St"}},{"type":"Feature","id":"sp-031","geometry":{"type":"LineString","coordinates":[[-116.5533921,48.2757951],[-116.5504418,48.275299]]},"properties":{"id":"sp-031","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Main St \u00b7 N 5th Ave to N 3rd Ave"}},{"type":"Feature","id":"sp-032","geometry":{"type":"LineString","coordinates":[[-116.5519606,48.2771015],[-116.5519606,48.2762306]]},"properties":{"id":"sp-032","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 4th Ave \u00b7 Alder St to Cedar St"}},{"type":"Feature","id":"sp-033","geometry":{"type":"LineString","coordinates":[[-116.5503661,48.2771091],[-116.5503661,48.2762382]]},"properties":{"id":"sp-033","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 3rd Ave \u00b7 Alder St to Cedar St"}},{"type":"Feature","id":"sp-034","geometry":{"type":"LineString","coordinates":[[-116.5487666,48.2771091],[-116.5487666,48.276223]]},"properties":{"id":"sp-034","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 2nd Ave \u00b7 Alder St to Cedar St"}},{"type":"Feature","id":"sp-035","geometry":{"type":"LineString","coordinates":[[-116.5519606,48.2739431],[-116.5519606,48.2731478]]},"properties":{"id":"sp-035","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Pine St \u00b7 at N 4th Ave"}},{"type":"Feature","id":"sp-036","geometry":{"type":"LineString","coordinates":[[-116.5503661,48.2739735],[-116.5503661,48.2731705]]},"properties":{"id":"sp-036","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"N 3rd Ave \u00b7 Church St to Pine St"}},{"type":"Feature","id":"sp-037","geometry":{"type":"LineString","coordinates":[[-116.55018,48.2731099],[-116.5484691,48.2731175]]},"properties":{"id":"sp-037","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"Pine St \u00b7 N 3rd Ave to N 2nd Ave"}},{"type":"Feature","id":"sp-038","geometry":{"type":"LineString","coordinates":[[-116.5491442,48.2730721],[-116.5491208,48.2721858]]},"properties":{"id":"sp-038","kind":"limit_3h","label":"3-hour","legend":"3-hour or permit","hours":3,"color":"#ccc542","shape":"line","name":"S 2nd Ave \u00b7 Pine St to Lake St"}},{"type":"Feature","id":"sp-039","geometry":{"type":"LineString","coordinates":[[-116.5542223,48.2756805],[-116.5542292,48.2756529],[-116.5542372,48.2756242],[-116.5542462,48.2755943],[-116.5542565,48.2755632],[-116.5542681,48.2755309],[-116.5542812,48.2754974],[-116.5542957,48.2754626],[-116.5543119,48.2754265],[-116.5543283,48.2753923],[-116.5543453,48.2753592],[-116.5543628,48.2753272],[-116.5543807,48.2752964],[-116.5543989,48.2752666],[-116.5544174,48.275238],[-116.5544358,48.2752105],[-116.5544543,48.2751841]]},"properties":{"id":"sp-039","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Off-street bays near Oak St"}},{"type":"Feature","id":"sp-040","geometry":{"type":"LineString","coordinates":[[-116.554545,48.2749596],[-116.5545662,48.2749163],[-116.5545916,48.2748706],[-116.5546212,48.2748226],[-116.5546548,48.2747726],[-116.5546923,48.2747207],[-116.5547334,48.2746671],[-116.554778,48.2746121],[-116.554826,48.2745557],[-116.554877,48.2744986],[-116.5549275,48.2744448],[-116.5549776,48.2743943],[-116.5550273,48.2743471],[-116.5550764,48.2743031],[-116.5551249,48.2742625],[-116.5551728,48.274225],[-116.55522,48.2741909]]},"properties":{"id":"sp-040","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Off-street bays near N 6th Ave"}},{"type":"Feature","id":"sp-046","geometry":{"type":"LineString","coordinates":[[-116.5514456,48.2721699],[-116.5514456,48.2730004]]},"properties":{"id":"sp-046","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"S 4th Ave \u00b7 Lake St to Pine St"}},{"type":"Feature","id":"sp-047","geometry":{"type":"LineString","coordinates":[[-116.5491075,48.2709522],[-116.5491075,48.2720888]]},"properties":{"id":"sp-047","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"S 2nd Ave \u00b7 Superior St to Lake St"}},{"type":"Feature","id":"sp-048","geometry":{"type":"LineString","coordinates":[[-116.5503479,48.2709522],[-116.5503479,48.2720888]]},"properties":{"id":"sp-048","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"S 3rd Ave \u00b7 Superior St to Lake St"}},{"type":"Feature","id":"sp-049","geometry":{"type":"LineString","coordinates":[[-116.5502389,48.2708911],[-116.5492155,48.2708911]]},"properties":{"id":"sp-049","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Superior St \u00b7 S 3rd Ave to S 2nd Ave"}},{"type":"Feature","id":"sp-050","geometry":{"type":"LineString","coordinates":[[-116.5489537,48.2708911],[-116.5479304,48.2708911]]},"properties":{"id":"sp-050","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Superior St \u00b7 S 2nd Ave to S 1st Ave"}},{"type":"Feature","id":"sp-051","geometry":{"type":"LineString","coordinates":[[-116.5542223,48.2721699],[-116.5542223,48.2730036]]},"properties":{"id":"sp-051","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Euclid Ave \u00b7 Lake St to Pine St"}},{"type":"Feature","id":"sp-052","geometry":{"type":"LineString","coordinates":[[-116.548615,48.2740439],[-116.5478919,48.2740439]]},"properties":{"id":"sp-052","kind":"free_2h","label":"2-hour free","legend":"Permits not valid","hours":2,"color":"#d367cc","shape":"line","name":"Church St \u00b7 N 2nd Ave to N 1st Ave"}},{"type":"Feature","id":"sp-053","geometry":{"type":"LineString","coordinates":[[-116.5504281,48.2781829],[-116.5518711,48.2781829]]},"properties":{"id":"sp-053","kind":"no_limit","label":"No time limit","legend":"No posted time limit","hours":0,"color":"#c3c4c2","shape":"line","name":"Poplar St \u00b7 N 3rd Ave to N 4th Ave"}}],"metadata":{"source":"City of Sandpoint \u2014 Downtown & Waterfront Public Parking map","generated":"from downtown_and_waterfront_public_parking_map.pdf","georeference":"affine page->WebMercator fitted to OSM street centrelines"}} \ No newline at end of file diff --git a/app/src/features/location/useLocation.ts b/app/src/features/location/useLocation.ts index c546634..43eb230 100644 --- a/app/src/features/location/useLocation.ts +++ b/app/src/features/location/useLocation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import * as Location from 'expo-location'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -7,20 +7,6 @@ export interface Coords { longitude: number; } -export interface UseLocationOptions { - /** - * Re-read the OS fix this often, in ms. Omit (or 0) for a single fix at mount. - * A fix goes stale as soon as you drive a block, so any screen that shows - * "where am I" for more than a moment wants this. - */ - intervalMs?: number; - /** - * Poll only while true. Callers pass screen focus AND app foreground: polling - * a map nobody is looking at spends battery on an answer no one reads. - */ - active?: boolean; -} - const LAST_LOC_KEY = 'ps_last_location'; /** Persist the most recent fix so the "near my last location" button works cold. */ @@ -35,54 +21,32 @@ export async function getLastKnownSavedLocation(): Promise { /** * Foreground location. On GrapheneOS this uses the OS location provider directly * (no Google Play Services). We prefer a fast last-known fix, then refine. - * - * `updatedAt` is when `coords` was actually read, so callers can tell a fresh fix - * from one that has been sitting there since the screen opened. */ -export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) { +export function useLocation() { const [coords, setCoords] = useState(null); - const [updatedAt, setUpdatedAt] = useState(0); const [granted, setGranted] = useState(null); const [error, setError] = useState(null); - // Read inside refresh() without making it a dependency — refresh is the - // interval's callback, and a changing identity would restart the timer on - // every fix, so it would never actually reach the interval. - const haveFix = useRef(false); - const permitted = useRef(false); const refresh = useCallback(async () => { try { - if (!permitted.current) { - const { status } = await Location.requestForegroundPermissionsAsync(); - const ok = status === 'granted'; - permitted.current = ok; - setGranted(ok); - if (!ok) { - setError('Location permission denied.'); - return null; - } + const { status } = await Location.requestForegroundPermissionsAsync(); + const ok = status === 'granted'; + setGranted(ok); + if (!ok) { + setError('Location permission denied.'); + return null; } - // Only worth it before we have anything to show: on a later poll the - // last-known fix is usually older than the one we already hold, and - // publishing it would make the dot jump backwards. - if (!haveFix.current) { - const last = await Location.getLastKnownPositionAsync(); - if (last) { - const c = { latitude: last.coords.latitude, longitude: last.coords.longitude }; - haveFix.current = true; - setCoords(c); - setUpdatedAt(Date.now()); - void saveLastLocation(c); - } + const last = await Location.getLastKnownPositionAsync(); + if (last) { + const c = { latitude: last.coords.latitude, longitude: last.coords.longitude }; + setCoords(c); + void saveLastLocation(c); } const cur = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced, }); const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude }; - haveFix.current = true; setCoords(c); - setUpdatedAt(Date.now()); - setError(null); void saveLastLocation(c); return c; } catch (e: any) { @@ -92,19 +56,8 @@ export function useLocation({ intervalMs = 0, active = true }: UseLocationOption }, []); useEffect(() => { - if (!active) return; - // Denied is denied — polling it every 30s just burns wake-ups to be told no. - if (granted === false) return; - // Re-activating (screen focused, app foregrounded) is exactly when the held - // fix is most likely to be stale, so read one straight away rather than - // waiting out a whole interval. void refresh(); - if (!intervalMs) return; - const id = setInterval(() => { - void refresh(); - }, intervalMs); - return () => clearInterval(id); - }, [active, granted, intervalMs, refresh]); + }, [refresh]); - return { coords, updatedAt, granted, error, refresh }; + return { coords, granted, error, refresh }; } diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts deleted file mode 100644 index 373b351..0000000 --- a/app/src/features/session/activeParking.ts +++ /dev/null @@ -1,378 +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 { ps } from '@/api/client'; -import { authBus } from '@/auth/authBus'; -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 type { ParkingArea } from '@/api/parkingAreas'; -import { recordLocalSession } from './localHistory'; -import { - clearActiveParking, - getActiveParking, - setActiveParking, - setParkedPin, - type ActiveParking, - type ParkedSpot, -} 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'; -/** How much a city-map session's "extend" button adds, and what it's labelled. */ -const EXTEND_MINUTES = 60; -const EXTEND_LABEL = '+1 hr'; - -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 { - 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}`; - // What the second button does depends on what it *can* do. City-map parking has - // no ParkSmarter zone to buy time in, so there it adds an hour to the local - // timer and says so; a real zone gets the purchase screen. - const extendLabel = p.area ? EXTEND_LABEL : 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 { - await clearSession().catch(() => {}); - await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {}); - await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {}); -} - -/* ---------------------------------------------------------------- reminders */ - -async function scheduleExpiryReminder(p: ActiveParking): Promise { - 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 { - 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 { - 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); -} - -/** - * Start tracking time on an area from the city parking map. - * - * Deliberately the whole story: no ParkSmarter call, no account, no network. The - * area came from the local database, the clock is the phone's, and the countdown - * is the same foreground service every other session uses. Works in Anonymous - * Mode, offline, and with the IPS API down. - */ -export async function startAreaParking(args: { - area: ParkingArea; - hours: number; - spot?: ParkedSpot; -}): Promise { - const now = Date.now(); - const state: ActiveParking = { - // Only the city lots cost money; everything else on the map is free parking - // that merely has a posted time limit. - kind: args.area.kind === 'green_lot' ? 'paid' : 'free', - area: { - id: args.area.id, - kind: args.area.kind, - name: args.area.name, - legend: args.area.legend, - color: args.area.color, - }, - spot: args.spot, - zoneName: args.area.name, - startMs: now, - endMs: now + Math.round(args.hours * 3_600_000), - leadMinutes: await getReminderLeadMinutes(), - }; - await setActiveParking(state); - if (args.spot) await setParkedPin(args.spot); - await postNotification(state); - await scheduleExpiryReminder(state); - logLine(`[PARKING] city area ${args.area.id} (${args.area.kind}) for ${args.hours}h`); -} - -/** - * Drop the "where is my car" pin. Deliberately independent of any session — you - * can pin the car without starting a timer, and the pin has to survive that. - */ -export async function pinParkedSpot(spot: ParkedSpot): Promise { - await setParkedPin(spot); - const current = await getActiveParking(); - if (current) await setActiveParking({ ...current, spot }); -} - -/** - * Add time to a city-map session's local timer. There is nothing to buy here — - * the app is only tracking a clock — so extending is a local edit, not a purchase. - */ -export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise { - const current = await getActiveParking(); - if (!current) return; - // Extend from now if it already lapsed, so "+1 hr" always means a full hour. - const from = Math.max(current.endMs, Date.now()); - const next = { ...current, endMs: from + minutes * 60_000 }; - await setActiveParking(next); - await postNotification(next); - await scheduleExpiryReminder(next); -} - -/** - * 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 { - // Write it to history before dropping it. A local session has no server copy, so - // if it isn't recorded here it is simply gone. - const current = await getActiveParking(); - if (current) await recordLocalSession(current); - 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 { - 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 { - 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; - } - - // "+1 hr" on a city-map session is a local edit, not a purchase — handle it here - // and stay put rather than sending the user to a payment screen for a free spot. - if (action === 'extend' && current?.area) { - logLine(`[PARKING] notification "${EXTEND_LABEL}" pressed on city area ${current.area.id}`); - await extendAreaParking(); - return; - } - - // A city-map session is never on the ParkSmarter server, so don't let a stale - // server session overwrite it. In Anonymous Mode there is no account to ask at - // all — asking anyway would 401 on every single foreground. - if (!current && !authBus.isAnonymous) 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>(); - - 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 { - const current = await getActiveParking(); - if (current && current.endMs > Date.now()) await postNotification(current); - else await clearNotification(); -} diff --git a/app/src/features/session/activeParkingStore.ts b/app/src/features/session/activeParkingStore.ts deleted file mode 100644 index 979a512..0000000 --- a/app/src/features/session/activeParkingStore.ts +++ /dev/null @@ -1,104 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { Zone } from 'parksmarter-client'; -import type { LabelKind } from '@/api/zoneLabels'; -import type { AreaKind } from '@/api/parkingAreas'; - -/** - * 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'; -/** - * The parked pin lives outside the session on purpose: "where is my car" outlives - * "am I tracking time". You can drop a pin without starting a timer, and it has to - * still be there when you come back to the map. - */ -const PIN_KEY = 'ps_parked_pin'; - -export type ParkingKind = 'paid' | 'free'; - -/** - * Where you parked, when the spot came from the city map rather than ParkSmarter. - * Held by value — the whole point is that the countdown keeps working with no - * network, no account and no IPS call, so it can't depend on a lookup. - */ -export interface ParkedArea { - id: string; - kind: AreaKind; - name: string; - /** The map legend's own wording, shown on the session screen. */ - legend: string; - color: string; -} - -/** The pin for "where is my car", dropped from GPS or placed by hand. */ -export interface ParkedSpot { - latitude: number; - longitude: number; - /** True when the user placed it themselves because GPS wasn't usable. */ - manual: boolean; -} - -export interface ActiveParking { - kind: ParkingKind; - /** - * Absent for a paid session discovered from the server (the active-session API - * returns no zone) and for city-map parking, which has no ParkSmarter zone at - * all. "Extend" falls back accordingly. - */ - zone?: Zone; - /** Set instead of `zone` when parked on a city-map area. */ - area?: ParkedArea; - /** Where the car actually is. Independent of `area` — you can pin without one. */ - spot?: ParkedSpot; - 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 { - 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; - 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 { - await AsyncStorage.setItem(KEY, JSON.stringify(state)); -} - -export async function clearActiveParking(): Promise { - // The pin goes with it: ending a session means you drove away, and a pin left - // behind would point at a space you no longer occupy. - await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY, PIN_KEY]); -} - -/** Where the car is, whether or not a timer is running. */ -export async function getParkedPin(): Promise { - const raw = await AsyncStorage.getItem(PIN_KEY); - return raw ? (JSON.parse(raw) as ParkedSpot) : null; -} - -export async function setParkedPin(spot: ParkedSpot | null): Promise { - if (spot) await AsyncStorage.setItem(PIN_KEY, JSON.stringify(spot)); - else await AsyncStorage.removeItem(PIN_KEY); -} diff --git a/app/src/features/session/localHistory.ts b/app/src/features/session/localHistory.ts deleted file mode 100644 index 0ce68cc..0000000 --- a/app/src/features/session/localHistory.ts +++ /dev/null @@ -1,81 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { AreaKind } from '@/api/parkingAreas'; -import type { ActiveParking, ParkedSpot, ParkingKind } from './activeParkingStore'; - -/** - * History for the sessions ParkSmarter never sees. - * - * A city-map timer or a free check-in exists only on this phone, so if it isn't - * recorded here it vanishes the moment it ends — there is no server to ask. Paid - * ParkSmarter sessions are deliberately excluded: those already come back from the - * account, and storing them too would show every one of them twice. - */ - -const KEY = 'ps_local_session_history'; -/** Enough to cover months of parking without letting the record grow forever. */ -const MAX = 50; - -export interface LocalSessionRecord { - /** Start time doubles as the id — there is only ever one session at a time. */ - id: string; - kind: ParkingKind; - zoneName: string; - areaId?: string; - areaKind?: AreaKind; - color?: string; - legend?: string; - startMs: number; - /** When it was due to end. */ - plannedEndMs: number; - /** When it actually ended. */ - endedAtMs: number; - /** True when the user ended it before the clock ran out. */ - endedEarly: boolean; - spot?: ParkedSpot; -} - -/** True when ParkSmarter has no record of this session, so we must keep our own. */ -export function isLocalOnly(p: ActiveParking): boolean { - return !p.transactionId; -} - -export async function getLocalHistory(): Promise { - const raw = await AsyncStorage.getItem(KEY); - if (!raw) return []; - try { - const list = JSON.parse(raw) as LocalSessionRecord[]; - return Array.isArray(list) ? list : []; - } catch { - return []; - } -} - -/** Record a finished local session. No-op for anything ParkSmarter already has. */ -export async function recordLocalSession(p: ActiveParking): Promise { - if (!isLocalOnly(p)) return; - const endedAtMs = Date.now(); - const record: LocalSessionRecord = { - id: String(p.startMs), - kind: p.kind, - zoneName: p.zoneName, - areaId: p.area?.id, - areaKind: p.area?.kind, - color: p.area?.color, - legend: p.area?.legend, - startMs: p.startMs, - plannedEndMs: p.endMs, - endedAtMs, - endedEarly: endedAtMs < p.endMs - 60_000, // a minute's slack for timer wake-up - spot: p.spot, - }; - const list = await getLocalHistory(); - // Guard against double-recording: ending can be driven from the notification and - // the screen at nearly the same moment. - const deduped = list.filter((r) => r.id !== record.id); - deduped.unshift(record); - await AsyncStorage.setItem(KEY, JSON.stringify(deduped.slice(0, MAX))); -} - -export async function clearLocalHistory(): Promise { - await AsyncStorage.removeItem(KEY); -} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index 118da2a..57da0bd 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -24,21 +24,15 @@ import { StartSessionScreen } from '@/screens/StartSessionScreen'; import { SessionDetailScreen } from '@/screens/SessionDetailScreen'; import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { AdminScreen } from '@/screens/AdminScreen'; -import { CityAreaScreen } from '@/screens/CityAreaScreen'; -import { MapAlignScreen } from '@/screens/MapAlignScreen'; import { useTheme } from '@/theme/ThemeContext'; -import { useActiveParkingSync } from '@/features/session/activeParking'; -import type { ParkingArea } from '@/api/parkingAreas'; -import type { ParkedSpot } from '@/features/session/activeParkingStore'; +import { useSessionStatusSync } from '@/notifications/sessionStatus'; +import { useCheckinSync } from '@/features/checkin/checkin'; import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; export type RootStackParamList = { Tabs: undefined; MeterDetail: { zone: Zone }; StartSession: { zone: Zone }; - /** An area from the city's printed parking map — no ParkSmarter zone involved. */ - CityArea: { area: ParkingArea; spot?: ParkedSpot }; - MapAlign: undefined; SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' }; About: undefined; Profile: undefined; @@ -78,9 +72,10 @@ const TAB_ICONS: Record = { }; function Tabs() { - // Keep the ongoing countdown in sync (on open + every foreground) and apply any - // End/Extend the user pressed on the notification while the app was away. - useActiveParkingSync(); + // 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 ( ({ @@ -158,17 +153,7 @@ export function RootNavigator() { component={DiagnosticsScreen} options={{ title: 'Diagnostics' }} /> - - ) : ( diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts index 6e5af97..2b39cd0 100644 --- a/app/src/notifications/localReminders.ts +++ b/app/src/notifications/localReminders.ts @@ -1,5 +1,9 @@ import { Platform } from 'react-native'; import * as Notifications from 'expo-notifications'; +import { + getReminderLeadMinutes, + getRemindersEnabled, +} from '@/features/notifications/reminderPrefs'; /** * Session-expiry reminders are purely LOCAL scheduled notifications: the app knows @@ -7,9 +11,7 @@ import * as Notifications from 'expo-notifications'; * No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS. */ -/** 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; +const CHANNEL_ID = 'session-reminders'; /** Android 8+ needs a notification channel; ensure it exists once. */ async function ensureChannel(): Promise { @@ -46,6 +48,48 @@ 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 { + 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 { + await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`); +} + /** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */ export async function sendTestReminder(seconds = 10): Promise { if (!(await ensureNotificationPermission())) return false; diff --git a/app/src/notifications/sessionStatus.ts b/app/src/notifications/sessionStatus.ts new file mode 100644 index 0000000..98d4c08 --- /dev/null +++ b/app/src/notifications/sessionStatus.ts @@ -0,0 +1,159 @@ +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 { + 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 { + 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 { + 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 { + 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(); + }, []); +} diff --git a/app/src/screens/AccountScreen.tsx b/app/src/screens/AccountScreen.tsx index e3f1732..759ae8b 100644 --- a/app/src/screens/AccountScreen.tsx +++ b/app/src/screens/AccountScreen.tsx @@ -71,11 +71,6 @@ export function AccountScreen() { navigation.navigate('Diagnostics')} /> - navigation.navigate('MapAlign')} - /> ; -type Nav = NativeStackNavigationProp; - -function fmtHours(h: number): string { - if (h < 1) return `${Math.round(h * 60)} min`; - return h === 1 ? '1 hour' : `${h} hours`; -} - -function fmtClock(ms: number): string { - return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); -} - -function fmtRemaining(ms: number): string { - const mins = Math.max(0, Math.round(ms / 60_000)); - const h = Math.floor(mins / 60); - return h ? `${h}h ${mins % 60}m` : `${mins}m`; -} - -/** - * One area from the city's printed parking map: what the sign says, and a timer - * for it. - * - * Nothing on this screen talks to ParkSmarter. The area came from the local - * database and the countdown is the phone's own clock, so this works with no - * account, no signal, and in Anonymous Mode. - */ -export function CityAreaScreen() { - const { area, spot } = useRoute().params; - const navigation = useNavigation