diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml
index 34cdd7d..db592dd 100644
--- a/.forgejo/workflows/build-apk.yml
+++ b/.forgejo/workflows/build-apk.yml
@@ -22,6 +22,23 @@ jobs:
# A purpose-built RN Android image (Android SDK + NDK + Node). Runner needs Docker.
container:
image: reactnativecommunity/react-native-android:latest
+ # Hard caps so the build can't starve the 20GB/4-core host: 2 CPUs, 8 GB RAM,
+ # and NO container swap (--memory-swap == --memory) so it OOM-fails cleanly
+ # instead of thrashing the disk with swap. With the JVM caps below it uses ~5 GB.
+ options: --cpus=2 --memory=8g --memory-swap=8g
+ # Persistent caches (whitelisted in the runner's config.yaml valid_volumes).
+ # Gradle home = downloaded Maven deps + build cache; npm = package cache.
+ # Repeat builds skip the re-download/re-compile that dominate a cold build.
+ volumes:
+ - bigbrainparking-gradle:/opt/cache/gradle
+ - bigbrainparking-npm:/opt/cache/npm
+ env:
+ GRADLE_USER_HOME: /opt/cache/gradle
+ npm_config_cache: /opt/cache/npm
+ # Applies to EVERY JVM the build spawns (Gradle workers, artifact transforms,
+ # Kotlin daemon) — the runner container has no IPv6 route, so without this the
+ # transform workers try dl.google.com over IPv6 and get "Network is unreachable".
+ JAVA_TOOL_OPTIONS: "-Djava.net.preferIPv4Stack=true"
steps:
- uses: actions/checkout@v4
@@ -31,11 +48,26 @@ jobs:
- name: Build the API client
run: npm run build --workspace parksmarter-client
- # RN 0.79 wants a specific NDK; install it if the image doesn't ship it (best-effort).
- - name: Ensure NDK
+ # Unit tests (mocked fetch) gate the build; integration tests skip here
+ # because the runner can't reach the IP-restricted non-prod instances.
+ - name: Run client tests
+ run: npm test --workspace parksmarter-client
+
+ # Pre-install the NDK + CMake the RN native modules need. Do it here (not via
+ # Gradle's mid-build auto-install) and retry: the SDK mirror sometimes serves
+ # a truncated archive ("Archive is not a ZIP archive"), which self-heals on a
+ # clean retry. Clearing the partial dir between tries avoids reusing the bad file.
+ - name: Ensure NDK + CMake
run: |
yes | sdkmanager --licenses >/dev/null 2>&1 || true
- sdkmanager "ndk;27.1.12297006" >/dev/null 2>&1 || true
+ ok=0
+ for i in 1 2 3; do
+ if sdkmanager "ndk;27.1.12297006" "cmake;3.22.1"; then ok=1; break; fi
+ echo "sdkmanager attempt $i failed; clearing partial CMake and retrying"
+ rm -rf "${ANDROID_HOME:-/opt/android}/cmake/3.22.1" "${ANDROID_SDK_ROOT:-/opt/android}/cmake/3.22.1" 2>/dev/null || true
+ sleep 5
+ done
+ [ "$ok" = 1 ] || echo "WARNING: NDK/CMake pre-install did not confirm; the build may retry it."
- name: Restore signing keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "${{ github.workspace }}/app/release.keystore"
@@ -44,6 +76,22 @@ jobs:
working-directory: app
run: CI=1 npx expo prebuild --platform android --no-install
+ # The real memory hog is the Kotlin daemon (default ~5.4 GB heap EACH, several
+ # spawned). Cap every JVM in gradle.properties so the build fits comfortably.
+ - name: Cap Gradle/Kotlin memory
+ working-directory: app/android
+ run: |
+ cat >> gradle.properties <<'PROPS'
+
+ # --- CI resource caps (host is small; don't thrash swap) ---
+ org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Djava.net.preferIPv4Stack=true
+ org.gradle.workers.max=2
+ org.gradle.parallel=false
+ org.gradle.daemon=false
+ org.gradle.caching=true
+ kotlin.daemon.jvmargs=-Xmx1536m
+ PROPS
+
- name: Assemble signed release
working-directory: app/android
env:
@@ -51,10 +99,14 @@ jobs:
BBP_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
BBP_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
BBP_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- # Force IPv4: the runner's container has no IPv6 route, so dl.google.com
- # (Google Maven) is "Network is unreachable" over its default AAAA record.
- GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true -Dorg.gradle.jvmargs=-Djava.net.preferIPv4Stack=true"
- run: ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon -Djava.net.preferIPv4Stack=true
+ # Also cap the no-daemon launcher JVM (org.gradle.jvmargs targets the daemon).
+ GRADLE_OPTS: "-Xmx1536m -Djava.net.preferIPv4Stack=true"
+ # --max-workers=2 limits concurrent tasks; container --cpus=2 caps clang -j.
+ # --build-cache reuses task outputs from GRADLE_USER_HOME across runs.
+ run: >-
+ ./gradlew :app:assembleRelease
+ -PreactNativeArchitectures=arm64-v8a
+ --no-daemon --max-workers=2 --build-cache
# Self-contained publish via the Forgejo API (no external action to fetch).
- name: Publish release + upload APK
diff --git a/README.md b/README.md
index d4f4341..444c391 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
# BigBrainParking
An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system,
@@ -35,14 +39,69 @@ bigbrainparking/
| --- | --- | --- |
| Phone + password login | ✅ wired | tokens in OS keystore |
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
-| "Near my last location" proximity search | ✅ wired | caches last GPS fix |
+| 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 |
-| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) |
+| 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
+location" and search; it opens on your last parking lot instead of your GPS, and ships **no**
+analytics/tracking (no Segment/Amplitude/Firebase/Sentry, no ad-ID, no Google services).
+For a plain-language comparison with the official app — which auto-sends your GPS on map
+open and bundles that tracking stack — see
+**[docs/OFFICIAL_APP_PRIVACY.md](docs/OFFICIAL_APP_PRIVACY.md)**. What reaches the API and
+what never does is also spelled out in the app's **About** page.
+
## Build & run (dev)
Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device)
@@ -61,12 +120,35 @@ in `app/app.json` to point elsewhere.
## Notifications on GrapheneOS
-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.
+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.
## Distribution via Obtainium (self-hosted)
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..a3f70dc
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,3 @@
+
+# local native module build outputs
+modules/**/android/build/
diff --git a/app/app.json b/app/app.json
index abe5610..361913f 100644
--- a/app/app.json
+++ b/app/app.json
@@ -3,17 +3,18 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
- "version": "0.1.1",
+ "version": "0.6.4",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
+ "versionCode": 25,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
- "backgroundColor": "#0F2A33"
+ "backgroundColor": "#FFFFFF"
},
"permissions": [
"ACCESS_COARSE_LOCATION",
@@ -43,6 +44,7 @@
],
"extra": {
"psEnvironment": "prodv2",
+ "zoneLabelsApiUrl": "https://bigbrainparking.mowden.top",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
"unifiedPushDefaultDistributor": "io.heckel.ntfy",
diff --git a/app/assets/adaptive-icon.png b/app/assets/adaptive-icon.png
index f7e90a2..f964385 100644
Binary files a/app/assets/adaptive-icon.png and b/app/assets/adaptive-icon.png differ
diff --git a/app/assets/icon.png b/app/assets/icon.png
index c5fabb3..b8b8210 100644
Binary files a/app/assets/icon.png and b/app/assets/icon.png differ
diff --git a/app/modules/bbp-notify/android/build.gradle b/app/modules/bbp-notify/android/build.gradle
new file mode 100644
index 0000000..4ca807c
--- /dev/null
+++ b/app/modules/bbp-notify/android/build.gradle
@@ -0,0 +1,27 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+group = 'expo.modules.bbpnotify'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useDefaultAndroidSdkVersions()
+useExpoPublishing()
+
+android {
+ namespace "expo.modules.bbpnotify"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ implementation "androidx.core:core-ktx:1.13.1"
+}
diff --git a/app/modules/bbp-notify/android/src/main/AndroidManifest.xml b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c28d9dd
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt
new file mode 100644
index 0000000..0071678
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt
@@ -0,0 +1,40 @@
+package expo.modules.bbpnotify
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/**
+ * 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).
+ */
+class BbpActionReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ 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)
+ }
+ 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")
+ context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
+ launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(launch)
+ }
+ }
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..c795efc
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt
@@ -0,0 +1,31 @@
+package expo.modules.bbpnotify
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/**
+ * Brings the parking countdown back after a reboot or an app update, without the
+ * user having to open the app.
+ *
+ * `specialUse` is one of the foreground-service types Android 14/15 still allow to
+ * be started from BOOT_COMPLETED, so the service can claim its notification here.
+ * [BbpSessionStore.load] returns null for an already-expired session, so a stale
+ * record from yesterday's parking never resurrects itself.
+ */
+class BbpBootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
+ else -> return
+ }
+
+ if (BbpSessionStore.load(context) == null) {
+ // No live session — make sure an expired record doesn't linger.
+ if (BbpSessionStore.hasRecord(context)) BbpSessionStore.clear(context)
+ return
+ }
+
+ BbpSessionService.start(context)
+ }
+}
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
new file mode 100644
index 0000000..29b61dc
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt
@@ -0,0 +1,92 @@
+package expo.modules.bbpnotify
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+
+/**
+ * Builds the ongoing parking notification.
+ *
+ * The remaining time is an Android **chronometer counting down** to the session
+ * end: `setWhen(endMs)` + `setUsesChronometer` + `setChronometerCountDown` hand the
+ * end time to the system, which redraws the ticking text itself every second. The
+ * app burns no CPU and needs no timer of its own, so the countdown stays accurate
+ * while the app is closed.
+ *
+ * Shared by the foreground service and by the boot receiver's fallback path.
+ */
+internal object BbpNotification {
+ const val CHANNEL_ID = "session-status"
+ const val NOTIF_ID = 42421
+
+ fun ensureChannel(ctx: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val mgr = ctx.getSystemService(NotificationManager::class.java)
+ if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+ val channel =
+ NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
+ channel.setShowBadge(false)
+ mgr.createNotificationChannel(channel)
+ }
+ }
+
+ fun build(ctx: Context, s: BbpSessionStore.Session): Notification {
+ ensureChannel(ctx)
+ val builder = base(ctx)
+ .setContentTitle(s.title)
+ .setContentText(s.body)
+ .setWhen(s.endMs)
+ .setShowWhen(true)
+ .setUsesChronometer(true)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ builder.setChronometerCountDown(true)
+ }
+
+ ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
+ builder.setContentIntent(
+ PendingIntent.getActivity(
+ ctx, 0, launch,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ }
+
+ builder.addAction(0, s.endLabel, actionIntent(ctx, BbpActionReceiver.ACTION_END))
+ builder.addAction(0, s.extendLabel, actionIntent(ctx, BbpActionReceiver.ACTION_EXTEND))
+ return builder.build()
+ }
+
+ /**
+ * A bare notification for the start-then-immediately-stop path. A service launched
+ * with startForegroundService() must call startForeground() within a few seconds or
+ * the system kills the process, and that holds even when we've just discovered there
+ * is no session to show — so we post this and remove it in the same breath. It is
+ * never on screen long enough to be seen.
+ */
+ fun placeholder(ctx: Context): Notification {
+ ensureChannel(ctx)
+ return base(ctx).setContentTitle("Parking").build()
+ }
+
+ private fun base(ctx: Context) =
+ NotificationCompat.Builder(ctx, CHANNEL_ID)
+ .setSmallIcon(R.drawable.bbp_stat_parking)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+
+ private fun actionIntent(ctx: Context, action: String): PendingIntent {
+ val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
+ return PendingIntent.getBroadcast(
+ ctx, action.hashCode(), intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+}
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
new file mode 100644
index 0000000..e968b07
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt
@@ -0,0 +1,63 @@
+package expo.modules.bbpnotify
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+/**
+ * JS bridge for the ongoing parking notification.
+ *
+ * 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.
+ */
+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 ->
+ 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)
+ }
+
+ /** 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)
+ }
+ }
+
+ /** Read + clear the action a notification button set. Returns "end", "extend", 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
+ }
+ }
+}
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
new file mode 100644
index 0000000..e3d43ad
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt
@@ -0,0 +1,149 @@
+package expo.modules.bbpnotify
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+
+/**
+ * Foreground service that owns the ongoing parking notification.
+ *
+ * A plain `notify()` was not enough: on Android 14+ the user can swipe an ongoing
+ * notification away, and nothing brings it back after a reboot. A foreground
+ * service pins the notification for as long as the service runs — the same reason
+ * ntfy's notification stays put on GrapheneOS.
+ *
+ * The service's lifetime is exactly the session's lifetime. It runs while there is
+ * an active session and fully stops — removing the notification — the moment there
+ * isn't one: on "End", when the countdown reaches zero, or when JS clears it. It
+ * never lingers with nothing to show.
+ */
+class BbpSessionService : Service() {
+ private val handler = Handler(Looper.getMainLooper())
+ private var expiryTask: Runnable? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // A null intent means the system restarted us after killing the process
+ // (START_STICKY) — fall through and rebuild from the store.
+ val stopping = intent?.action == ACTION_STOP
+ val session = if (stopping) null else BbpSessionStore.load(this)
+
+ if (session == null) {
+ // Satisfy the startForegroundService() contract before standing down, or the
+ // system kills us for not calling startForeground() in time.
+ goForeground(BbpNotification.placeholder(this))
+ if (!stopping) BbpSessionStore.clear(this) // expired record — don't leave it around
+ shutdown()
+ return START_NOT_STICKY
+ }
+
+ if (!goForeground(BbpNotification.build(this, session))) {
+ // The system refused to let us hold a foreground service right now. The
+ // countdown matters more than the pinning, so leave the notification posted
+ // on its own and stand the service down rather than crash.
+ postDirectly(this)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ scheduleExpiry(session.endMs)
+ return START_STICKY
+ }
+
+ /**
+ * startForeground() can still throw on Android 12+ even after startForegroundService()
+ * was accepted (background-start restrictions are evaluated here too). An uncaught
+ * throw would crash the app, so report the failure instead and let the caller fall back.
+ */
+ private fun goForeground(notification: android.app.Notification): Boolean =
+ try {
+ startForeground(BbpNotification.NOTIF_ID, notification)
+ true
+ } catch (e: Exception) {
+ false
+ }
+
+ /**
+ * Stop the moment the meter runs out, so a finished session never leaves a dead
+ * "0:00" notification pinned to the shade. A posted callback is enough here — a
+ * foreground service is alive to run it, no alarm permission needed.
+ */
+ private fun scheduleExpiry(endMs: Long) {
+ expiryTask?.let { handler.removeCallbacks(it) }
+ val task = Runnable {
+ BbpSessionStore.clear(this)
+ shutdown()
+ }
+ expiryTask = task
+ handler.postDelayed(task, (endMs - System.currentTimeMillis()).coerceAtLeast(0L))
+ }
+
+ private fun shutdown() {
+ expiryTask?.let { handler.removeCallbacks(it) }
+ expiryTask = null
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ expiryTask?.let { handler.removeCallbacks(it) }
+ expiryTask = null
+ super.onDestroy()
+ }
+
+ companion object {
+ const val ACTION_STOP = "expo.modules.bbpnotify.STOP_SERVICE"
+
+ /**
+ * Start (or refresh) the service from the stored session. Returns a short
+ * diagnostic string — JS logs it, because a failure here is otherwise silent.
+ */
+ fun start(ctx: Context): String {
+ val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
+ val intent = Intent(ctx, BbpSessionService::class.java)
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ctx.startForegroundService(intent)
+ } else {
+ ctx.startService(intent)
+ }
+ "service-started enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
+ } catch (e: Exception) {
+ // Background-start restrictions can refuse the service (Android 12+). The
+ // countdown still matters more than the pinning, so fall back to posting the
+ // notification directly; it just becomes swipe-dismissable.
+ val fallback = postDirectly(ctx)
+ "service-failed=${e.javaClass.simpleName} fallback=$fallback enabled=$enabled"
+ }
+ }
+
+ fun stop(ctx: Context) {
+ try {
+ ctx.startService(Intent(ctx, BbpSessionService::class.java).setAction(ACTION_STOP))
+ } catch (_: Exception) {
+ // The service may already be gone; make sure the notification is too.
+ }
+ NotificationManagerCompat.from(ctx).cancel(BbpNotification.NOTIF_ID)
+ }
+
+ /** Last-resort path: the plain ongoing notification, with no service pinning it. */
+ fun postDirectly(ctx: Context): String {
+ val session = BbpSessionStore.load(ctx) ?: return "no-session"
+ return try {
+ NotificationManagerCompat.from(ctx)
+ .notify(BbpNotification.NOTIF_ID, BbpNotification.build(ctx, session))
+ "posted"
+ } catch (e: SecurityException) {
+ "no-permission"
+ } catch (e: Exception) {
+ "failed=${e.javaClass.simpleName}"
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..7267253
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt
@@ -0,0 +1,86 @@
+package expo.modules.bbpnotify
+
+import android.content.Context
+
+/**
+ * The single active-parking record the foreground service renders.
+ *
+ * It lives in SharedPreferences rather than in memory because every other actor
+ * here can run without the JS app: the service after a system restart, the boot
+ * receiver after a reboot, the action receiver after the process is gone. They
+ * all read the same row, so there is exactly one source of truth for "is there a
+ * session, and what does its notification say".
+ */
+internal object BbpSessionStore {
+ const val PREFS = "bbp_notify"
+
+ private const val K_TITLE = "title"
+ private const val K_BODY = "body"
+ private const val K_END = "end_ms"
+ private const val K_END_LABEL = "end_label"
+ private const val K_EXTEND_LABEL = "extend_label"
+ private const val K_PENDING = "pending_action"
+
+ data class Session(
+ val title: String,
+ val body: String,
+ val endMs: Long,
+ val endLabel: String,
+ val extendLabel: String,
+ )
+
+ private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ fun save(ctx: Context, s: Session) {
+ prefs(ctx).edit()
+ .putString(K_TITLE, s.title)
+ .putString(K_BODY, s.body)
+ .putLong(K_END, s.endMs)
+ .putString(K_END_LABEL, s.endLabel)
+ .putString(K_EXTEND_LABEL, s.extendLabel)
+ .apply()
+ }
+
+ /**
+ * The current session, or null when there isn't one. A record whose end time has
+ * already passed reads as null so an expired session can never re-post itself
+ * (e.g. a reboot hours after the meter ran out).
+ */
+ fun load(ctx: Context): Session? {
+ val p = prefs(ctx)
+ val end = p.getLong(K_END, 0L)
+ if (end <= System.currentTimeMillis()) return null
+ return Session(
+ title = p.getString(K_TITLE, "") ?: "",
+ body = p.getString(K_BODY, "") ?: "",
+ endMs = end,
+ endLabel = p.getString(K_END_LABEL, "End") ?: "End",
+ extendLabel = p.getString(K_EXTEND_LABEL, "Extend") ?: "Extend",
+ )
+ }
+
+ /** True when a record exists at all, expired or not — used to decide if cleanup is needed. */
+ fun hasRecord(ctx: Context): Boolean = prefs(ctx).contains(K_END)
+
+ fun clear(ctx: Context) {
+ prefs(ctx).edit()
+ .remove(K_TITLE)
+ .remove(K_BODY)
+ .remove(K_END)
+ .remove(K_END_LABEL)
+ .remove(K_EXTEND_LABEL)
+ .apply()
+ }
+
+ fun setPending(ctx: Context, action: String) {
+ prefs(ctx).edit().putString(K_PENDING, action).apply()
+ }
+
+ /** Read + clear the action a notification button recorded while the app was away. */
+ fun consumePending(ctx: Context): String {
+ val p = prefs(ctx)
+ val action = p.getString(K_PENDING, "") ?: ""
+ if (action.isNotEmpty()) p.edit().remove(K_PENDING).apply()
+ return action
+ }
+}
diff --git a/app/modules/bbp-notify/android/src/main/res/drawable/bbp_stat_parking.xml b/app/modules/bbp-notify/android/src/main/res/drawable/bbp_stat_parking.xml
new file mode 100644
index 0000000..f19321c
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/res/drawable/bbp_stat_parking.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/modules/bbp-notify/expo-module.config.json b/app/modules/bbp-notify/expo-module.config.json
new file mode 100644
index 0000000..6db51db
--- /dev/null
+++ b/app/modules/bbp-notify/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.bbpnotify.BbpNotifyModule"]
+ }
+}
diff --git a/app/modules/bbp-notify/index.ts b/app/modules/bbp-notify/index.ts
new file mode 100644
index 0000000..cdf9d1f
--- /dev/null
+++ b/app/modules/bbp-notify/index.ts
@@ -0,0 +1,60 @@
+import { Platform } from 'react-native';
+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 …").
+ */
+ showSession(
+ title: string,
+ body: string,
+ endTimeMillis: number,
+ endLabel: string,
+ extendLabel: string,
+ ): Promise;
+ /** No active session: stop the service and remove the notification. */
+ clearSession(): Promise;
+ /** Read + clear the action a notification button set: 'end' | 'extend' | ''. */
+ consumePendingAction(): Promise;
+ /** Whether the native side still holds a live (unexpired) session record. */
+ hasActiveSession(): Promise;
+}
+
+export type PendingAction = 'end' | 'extend' | null;
+
+// Android-only, and only present in a build that includes the native module
+// (returns null in Expo Go / other platforms — callers degrade gracefully).
+const native =
+ Platform.OS === 'android'
+ ? (requireOptionalNativeModule('BbpNotify') as BbpNotifyNative | null)
+ : null;
+
+/** True when the native ticking-countdown module is available. */
+export const hasNativeCountdown = native != null;
+
+export async function showSession(
+ title: string,
+ body: string,
+ endTimeMillis: number,
+ endLabel: string,
+ extendLabel: string,
+): Promise {
+ if (!native) return 'no-native-module';
+ return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
+}
+
+export async function clearSession(): Promise {
+ await native?.clearSession();
+}
+
+/** 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;
+}
diff --git a/app/modules/bbp-notify/package.json b/app/modules/bbp-notify/package.json
new file mode 100644
index 0000000..0c3869e
--- /dev/null
+++ b/app/modules/bbp-notify/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "bbp-notify",
+ "version": "0.1.0",
+ "description": "Native ongoing parking countdown notification (Android chronometer) — zero third-party deps.",
+ "main": "index.ts",
+ "private": true
+}
diff --git a/app/package.json b/app/package.json
index ec8ccb8..6975cda 100644
--- a/app/package.json
+++ b/app/package.json
@@ -8,6 +8,7 @@
"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": {
@@ -37,6 +38,7 @@
"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/adminStore.ts b/app/src/api/adminStore.ts
new file mode 100644
index 0000000..62b8619
--- /dev/null
+++ b/app/src/api/adminStore.ts
@@ -0,0 +1,18 @@
+import * as SecureStore from 'expo-secure-store';
+
+/**
+ * The zone-labels admin password, kept in the OS keystore (never AsyncStorage).
+ * Its presence is what unlocks the labeling controls; it's sent as a Bearer token
+ * to the bigbrainparking.mowden.top API on writes.
+ */
+const ADMIN_KEY = 'ps_admin_token';
+
+export function getAdminToken(): Promise {
+ return SecureStore.getItemAsync(ADMIN_KEY);
+}
+
+export function setAdminToken(token: string | null): Promise {
+ return token
+ ? SecureStore.setItemAsync(ADMIN_KEY, token)
+ : SecureStore.deleteItemAsync(ADMIN_KEY);
+}
diff --git a/app/src/api/parkingAreas.ts b/app/src/api/parkingAreas.ts
new file mode 100644
index 0000000..6aad019
--- /dev/null
+++ b/app/src/api/parkingAreas.ts
@@ -0,0 +1,234 @@
+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
new file mode 100644
index 0000000..391cc89
--- /dev/null
+++ b/app/src/api/parseTime.ts
@@ -0,0 +1,30 @@
+/**
+ * Parse a ParkSmarter date into a local Date. The API uses TWO formats:
+ * /api/Session + estimates: "07-15-2026 05:00 PM" dashes, 12h, AM/PM
+ * /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
+ * Both are device-local wall-clock time. Hermes' Date() can't parse either, so we
+ * build the Date from explicit components.
+ *
+ * This is the single parser for both — an earlier stricter copy that required
+ * dashes and AM/PM silently returned null on the other format, which meant no
+ * countdown notification at all for sessions whose end time came back that way.
+ */
+export function parseApiTime(s?: string | null): Date | null {
+ if (!s) return null;
+ const m = String(s).match(
+ /(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
+ );
+ if (!m) {
+ const d = new Date(s);
+ return Number.isNaN(+d) ? null : d;
+ }
+ let hr = parseInt(m[4], 10);
+ const ap = m[7];
+ if (ap) {
+ const pm = /pm/i.test(ap);
+ if (pm && hr !== 12) hr += 12;
+ if (!pm && hr === 12) hr = 0;
+ }
+ const sec = m[6] ? parseInt(m[6], 10) : 0;
+ return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
+}
diff --git a/app/src/api/zoneLabels.ts b/app/src/api/zoneLabels.ts
new file mode 100644
index 0000000..90bdbba
--- /dev/null
+++ b/app/src/api/zoneLabels.ts
@@ -0,0 +1,142 @@
+import Constants from 'expo-constants';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { getAdminToken } from './adminStore';
+
+/**
+ * Client for the BigBrainParking zone-labels API (bigbrainparking.mowden.top).
+ * Reads are public and cached locally so check-in works offline; writes require
+ * the admin token. This is a plain fetch wrapper — the ParkSmarter HttpClient is
+ * too domain-specific (its own auth headers / rolling tokens) to reuse here.
+ */
+
+export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
+
+export interface ZoneLabel {
+ zoneId: string;
+ customerId: string | null;
+ zoneName: string | null;
+ kind: LabelKind;
+ note: string | null;
+ updatedAt: number;
+ updatedBy: string | null;
+}
+
+const CACHE_KEY = 'ps_zone_labels';
+const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
+
+export class ZoneLabelsError extends Error {
+ constructor(
+ message: string,
+ readonly status?: number,
+ ) {
+ super(message);
+ this.name = 'ZoneLabelsError';
+ }
+}
+
+/** Hours of free parking for a kind, or null for pay-immediate. */
+export function labelHours(kind: LabelKind): number | null {
+ return kind === 'free_2h' ? 2 : kind === 'free_3h' ? 3 : kind === 'free_4h' ? 4 : null;
+}
+
+/** Short human label for a kind. */
+export function labelText(kind: LabelKind): string {
+ switch (kind) {
+ case 'free_2h':
+ return 'Free · 2h limit';
+ case 'free_3h':
+ return 'Free · 3h limit';
+ case 'free_4h':
+ return 'Free · 4h limit';
+ case 'pay_immediate':
+ return 'Pay immediately';
+ }
+}
+
+let memCache: Record | null = null;
+
+async function loadCache(): Promise> {
+ if (memCache) return memCache;
+ const raw = await AsyncStorage.getItem(CACHE_KEY);
+ memCache = raw ? (JSON.parse(raw) as Record) : {};
+ return memCache;
+}
+
+async function saveCache(map: Record): Promise {
+ memCache = map;
+ await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(map));
+}
+
+function ensureConfigured(): void {
+ if (!BASE_URL) throw new ZoneLabelsError('zoneLabelsApiUrl is not configured');
+}
+
+async function authHeaders(): Promise> {
+ const token = await getAdminToken();
+ if (!token) throw new ZoneLabelsError('not authenticated as admin');
+ return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
+}
+
+/** Pull all labels and refresh the local cache. */
+export async function refreshLabels(): Promise {
+ ensureConfigured();
+ const res = await fetch(`${BASE_URL}/api/labels`);
+ if (!res.ok) throw new ZoneLabelsError('labels fetch failed', res.status);
+ const body = (await res.json()) as { labels?: ZoneLabel[] };
+ const map: Record = {};
+ for (const l of body.labels ?? []) map[String(l.zoneId)] = l;
+ await saveCache(map);
+}
+
+/** Cached label for a zone, or null. Never hits the network. */
+export async function getCachedLabel(
+ zoneId: number | string | null | undefined,
+): Promise {
+ if (zoneId == null) return null;
+ const map = await loadCache();
+ return map[String(zoneId)] ?? null;
+}
+
+export async function setLabel(
+ zoneId: number | string,
+ kind: LabelKind,
+ meta: { zoneName?: string | null; customerId?: string | number | null } = {},
+): Promise {
+ ensureConfigured();
+ const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
+ method: 'PUT',
+ headers: await authHeaders(),
+ body: JSON.stringify({
+ kind,
+ zoneName: meta.zoneName ?? null,
+ customerId: meta.customerId ?? null,
+ }),
+ });
+ if (!res.ok) throw new ZoneLabelsError('set label failed', res.status);
+ const label = (await res.json()) as ZoneLabel;
+ const map = await loadCache();
+ map[String(zoneId)] = label;
+ await saveCache(map);
+ return label;
+}
+
+export async function deleteLabel(zoneId: number | string): Promise {
+ ensureConfigured();
+ const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
+ method: 'DELETE',
+ headers: await authHeaders(),
+ });
+ if (!res.ok && res.status !== 404) throw new ZoneLabelsError('delete failed', res.status);
+ const map = await loadCache();
+ delete map[String(zoneId)];
+ await saveCache(map);
+}
+
+/** Verify a candidate admin token against the server (for the settings "Test" button). */
+export async function verifyAdmin(token: string): Promise {
+ ensureConfigured();
+ const res = await fetch(`${BASE_URL}/api/whoami`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ return res.ok;
+}
diff --git a/app/src/api/zoneMirror.ts b/app/src/api/zoneMirror.ts
new file mode 100644
index 0000000..9521565
--- /dev/null
+++ b/app/src/api/zoneMirror.ts
@@ -0,0 +1,34 @@
+import Constants from 'expo-constants';
+import type { Zone } from 'parksmarter-client';
+import { getAdminToken } from './adminStore';
+
+/**
+ * The zone-location mirror on bigbrainparking.mowden.top. Anonymous users read
+ * areas from here (no ParkSmarter login). Admins push the zones they pull
+ * (authed) from ParkSmarter so the mirror stays populated.
+ */
+const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
+
+export async function getMirrorZones(): Promise {
+ if (!BASE_URL) return [];
+ const res = await fetch(`${BASE_URL}/api/zones`);
+ if (!res.ok) throw new Error(`zones fetch failed ${res.status}`);
+ const body = (await res.json()) as { zones?: Zone[] };
+ return body.zones ?? [];
+}
+
+/** Push authed-pulled zones to the mirror. No-ops unless an admin token is set. */
+export async function syncZones(zones: Zone[]): Promise {
+ if (!BASE_URL || zones.length === 0) return;
+ const token = await getAdminToken();
+ if (!token) return; // only admins populate the mirror
+ try {
+ await fetch(`${BASE_URL}/api/zones/sync`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ zones }),
+ });
+ } catch {
+ /* best-effort — never block the UI on a mirror push */
+ }
+}
diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx
index 3b5e098..4f894a4 100644
--- a/app/src/auth/AuthContext.tsx
+++ b/app/src/auth/AuthContext.tsx
@@ -9,13 +9,18 @@ import { ps } from '@/api/client';
import { authBus } from '@/auth/authBus';
import type { ApplicationValidityResponse } from 'parksmarter-client';
-type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
+type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'anonymous';
interface AuthState {
status: AuthStatus;
validity: ApplicationValidityResponse | null;
login: (phoneNumber: string, password: string) => Promise;
logout: () => Promise;
+ /** Enter the app without a ParkSmarter login (browse + free check-in only). */
+ enterAnonymous: () => void;
+ /** Leave anonymous mode and show the sign-in screen (e.g. to pay). */
+ requireLogin: () => void;
+ isAnonymous: boolean;
error: string | null;
}
@@ -26,9 +31,21 @@ 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');
};
@@ -37,18 +54,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
}, []);
- // On launch: bootstrap (seeds SessionId + feature flags) and probe for an
- // existing token by attempting an authenticated read.
+ // On launch: bootstrap (seeds SessionId + feature flags), then verify any
+ // stored token BEFORE showing the app. A stale/expired token doesn't 401 —
+ // /api/User just returns an empty body, so getUserDetail() resolves to
+ // undefined without throwing. Require real user data to count as signed in,
+ // and clear the dead token otherwise, so an expired session lands on the
+ // login screen at open instead of failing later on the map.
useEffect(() => {
(async () => {
try {
const v = await ps.getApplicationValidity();
setValidity(v);
const existing = await ps.tokens.getAuthToken();
- if (existing) {
- await ps.getUserDetail(); // 401 throws -> treated as signed out
+ if (!existing) {
+ setStatus('signedOut');
+ return;
+ }
+ const user = await ps.getUserDetail().catch(() => null);
+ const valid = !!(user && (user.PersonalPhone || user.PersonalEmailAddress));
+ if (valid) {
setStatus('signedIn');
} else {
+ await ps.logoutLocal(); // drop the dead token so the login screen is clean
setStatus('signedOut');
}
} catch {
@@ -75,8 +102,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setStatus('signedOut');
};
+ const enterAnonymous = () => {
+ setError(null);
+ setStatus('anonymous');
+ };
+
+ const requireLogin = () => {
+ setError(null);
+ setStatus('signedOut');
+ };
+
const value = useMemo(
- () => ({ status, validity, login, logout, error }),
+ () => ({
+ status,
+ validity,
+ login,
+ logout,
+ enterAnonymous,
+ requireLogin,
+ isAnonymous: status === 'anonymous',
+ error,
+ }),
[status, validity, error],
);
diff --git a/app/src/auth/authBus.ts b/app/src/auth/authBus.ts
index 075d9fd..02c1a3b 100644
--- a/app/src/auth/authBus.ts
+++ b/app/src/auth/authBus.ts
@@ -2,5 +2,13 @@
* 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 } = {};
+export const authBus: {
+ onUnauthorized?: () => void;
+ isAnonymous: boolean;
+} = { isAnonymous: false };
diff --git a/app/src/features/citymap/geo.ts b/app/src/features/citymap/geo.ts
new file mode 100644
index 0000000..a62baec
--- /dev/null
+++ b/app/src/features/citymap/geo.ts
@@ -0,0 +1,147 @@
+/**
+ * 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
new file mode 100644
index 0000000..2745c48
--- /dev/null
+++ b/app/src/features/citymap/parkingAreas.json
@@ -0,0 +1 @@
+{"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 43eb230..c546634 100644
--- a/app/src/features/location/useLocation.ts
+++ b/app/src/features/location/useLocation.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import * as Location from 'expo-location';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -7,6 +7,20 @@ 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. */
@@ -21,32 +35,54 @@ 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() {
+export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) {
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 {
- const { status } = await Location.requestForegroundPermissionsAsync();
- const ok = status === 'granted';
- setGranted(ok);
- if (!ok) {
- setError('Location permission denied.');
- return null;
+ 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 last = await Location.getLastKnownPositionAsync();
- if (last) {
- const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
- setCoords(c);
- void saveLastLocation(c);
+ // 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 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) {
@@ -56,8 +92,19 @@ export function useLocation() {
}, []);
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();
- }, [refresh]);
+ if (!intervalMs) return;
+ const id = setInterval(() => {
+ void refresh();
+ }, intervalMs);
+ return () => clearInterval(id);
+ }, [active, granted, intervalMs, refresh]);
- return { coords, granted, error, refresh };
+ return { coords, updatedAt, granted, error, refresh };
}
diff --git a/app/src/features/notifications/reminderPrefs.ts b/app/src/features/notifications/reminderPrefs.ts
index 2c8de3b..052eeff 100644
--- a/app/src/features/notifications/reminderPrefs.ts
+++ b/app/src/features/notifications/reminderPrefs.ts
@@ -35,3 +35,19 @@ export async function getRemindersEnabled(): Promise {
export async function setRemindersEnabled(on: boolean): Promise {
await AsyncStorage.setItem(ENABLED_KEY, String(on));
}
+
+/**
+ * When true, an ongoing "time left" status notification is shown while a
+ * parking session is active, so you can glance at remaining time without
+ * opening the app. Default ON.
+ */
+const COUNTDOWN_KEY = 'ps_session_countdown_enabled';
+
+export async function getCountdownEnabled(): Promise {
+ const raw = await AsyncStorage.getItem(COUNTDOWN_KEY);
+ return raw == null ? true : raw === 'true';
+}
+
+export async function setCountdownEnabled(on: boolean): Promise {
+ await AsyncStorage.setItem(COUNTDOWN_KEY, String(on));
+}
diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts
new file mode 100644
index 0000000..373b351
--- /dev/null
+++ b/app/src/features/session/activeParking.ts
@@ -0,0 +1,378 @@
+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
new file mode 100644
index 0000000..979a512
--- /dev/null
+++ b/app/src/features/session/activeParkingStore.ts
@@ -0,0 +1,104 @@
+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
new file mode 100644
index 0000000..0ce68cc
--- /dev/null
+++ b/app/src/features/session/localHistory.ts
@@ -0,0 +1,81 @@
+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 21df729..118da2a 100644
--- a/app/src/navigation/RootNavigator.tsx
+++ b/app/src/navigation/RootNavigator.tsx
@@ -21,20 +21,32 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen';
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
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 type { Zone } from 'parksmarter-client';
+import { useActiveParkingSync } from '@/features/session/activeParking';
+import type { ParkingArea } from '@/api/parkingAreas';
+import type { ParkedSpot } from '@/features/session/activeParkingStore';
+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;
Vehicles: undefined;
PaymentMethods: undefined;
Notifications: undefined;
Diagnostics: undefined;
+ Admin: undefined;
};
export type TabParamList = {
@@ -66,6 +78,9 @@ 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();
return (
({
@@ -107,7 +122,7 @@ export function RootNavigator() {
return (
- {status === 'signedIn' ? (
+ {status === 'signedIn' || status === 'anonymous' ? (
+
@@ -138,6 +158,17 @@ export function RootNavigator() {
component={DiagnosticsScreen}
options={{ title: 'Diagnostics' }}
/>
+
+
+
) : (
diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts
index da4b8ef..6e5af97 100644
--- a/app/src/notifications/localReminders.ts
+++ b/app/src/notifications/localReminders.ts
@@ -1,9 +1,5 @@
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
@@ -11,7 +7,9 @@ import {
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
*/
-const CHANNEL_ID = 'session-reminders';
+/** Exported so the active-parking module can schedule onto the same channel. */
+export const REMINDER_CHANNEL_ID = 'session-reminders';
+const CHANNEL_ID = REMINDER_CHANNEL_ID;
/** Android 8+ needs a notification channel; ensure it exists once. */
async function ensureChannel(): Promise {
@@ -33,58 +31,21 @@ export async function ensureNotificationPermission(): Promise {
}
Notifications.setNotificationHandler({
- handleNotification: async () => ({
- // shouldShowAlert is the legacy field; Banner/List are the newer split.
- shouldShowAlert: true,
- shouldShowBanner: true,
- shouldShowList: true,
- shouldPlaySound: true,
- shouldSetBadge: false,
- }),
+ handleNotification: async (notification) => {
+ // The ongoing "time left" status refreshes on every foreground — keep it
+ // quiet (no banner/sound), just present in the shade.
+ const quiet = (notification.request.content.data as any)?.kind === 'session-status';
+ return {
+ // shouldShowAlert is the legacy field; Banner/List are the newer split.
+ shouldShowAlert: !quiet,
+ shouldShowBanner: !quiet,
+ shouldShowList: true,
+ shouldPlaySound: !quiet,
+ shouldSetBadge: false,
+ };
+ },
});
-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/screens/AboutScreen.tsx b/app/src/screens/AboutScreen.tsx
index 6bfc85f..50c8094 100644
--- a/app/src/screens/AboutScreen.tsx
+++ b/app/src/screens/AboutScreen.tsx
@@ -1,6 +1,7 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useRef, useState } from 'react';
import {
Image,
+ Linking,
Modal,
Pressable,
ScrollView,
@@ -9,24 +10,17 @@ import {
TouchableOpacity,
View,
} from 'react-native';
-import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
const TAPS_TO_UNLOCK = 7;
+const REPO_URL = 'https://git.mowden.top/hank/BigBrainParking';
export function AboutScreen() {
const { colors } = useTheme();
- const [about, setAbout] = useState(null);
const [showJoel, setShowJoel] = useState(false);
const taps = useRef(0);
const resetTimer = useRef | null>(null);
- useEffect(() => {
- ps.getAbout()
- .then((r) => setAbout(r.Value ?? ''))
- .catch(() => setAbout(''));
- }, []);
-
// Tap the title 7 times (within a rolling window) to summon Joel.
const onTitleTap = () => {
taps.current += 1;
@@ -44,7 +38,41 @@ export function AboutScreen() {
About BigBrainParking
- {about ?? 'Loading…'}
+
+ BigBrainParking is a free, open-source,
+ de-Googled client for the ParkSmarter parking system, built to run on GrapheneOS
+ without any Google services. It’s an independent project — not affiliated with,
+ endorsed by, or supported by IPS Group / ParkSmarter.
+
+
+ Linking.openURL(REPO_URL)} style={{ marginTop: 12 }}>
+ Source code & issues ›
+ {REPO_URL}
+
+
+ What gets sent to ParkSmarter (IPS)
+
+ This app talks only to ParkSmarter’s own API, and only sends what a request needs:
+ {'\n'}• Sign-in (your phone number + password) and your account data — vehicles, cards,
+ and session history — the same as the official app.
+ {'\n'}• To find meters, a single map coordinate: the point the map is centered on when you
+ search. Your device GPS is sent only if you tap
+ “My location” to center the map on yourself and then search; otherwise it’s wherever you’ve
+ panned to, or your last parking lot.
+ {'\n'}• To start a session: the meter, your vehicle and card, the times, and the amount.
+ No location is attached.
+
+
+ What it does not do
+
+ {'\n'}• Your phone’s GPS is never sent to the API{' '}
+ unless you deliberately tap “My location” and search. The map opens on your last parking lot
+ (from your history — the meter’s location, not your GPS), and “Last lot” does the same. GPS
+ otherwise only draws your dot on the map and is never attached to sign-in, sessions,
+ vehicles, or payments.
+ {'\n'}• No background location, no tracking, no device ID / IMEI / advertising identifiers,
+ and no Google services. Session-expiry reminders are scheduled entirely on your device.
+
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
@@ -62,7 +90,10 @@ export function AboutScreen() {
const styles = StyleSheet.create({
container: { padding: 20 },
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
+ section: { fontSize: 17, fontWeight: '700', marginTop: 22, marginBottom: 6 },
body: { fontSize: 15, lineHeight: 22, color: '#333' },
+ link: { fontSize: 15, fontWeight: '700' },
+ linkUrl: { fontSize: 13, marginTop: 2 },
meta: { fontSize: 12, color: '#999', marginTop: 24 },
joelBackdrop: {
flex: 1,
diff --git a/app/src/screens/AccountScreen.tsx b/app/src/screens/AccountScreen.tsx
index dbcfd90..e3f1732 100644
--- a/app/src/screens/AccountScreen.tsx
+++ b/app/src/screens/AccountScreen.tsx
@@ -11,7 +11,7 @@ type Nav = NativeStackNavigationProp;
export function AccountScreen() {
const { colors, mode, toggle } = useTheme();
- const { logout } = useAuth();
+ const { logout, isAnonymous, requireLogin } = useAuth();
const navigation = useNavigation