Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 718c72dde1 | |||
| 4217e88338 | |||
| 6af3dad762 | |||
| 7fc92d24e4 | |||
| 148e1635d3 | |||
| ad55559f55 | |||
| 796674bca4 | |||
| 4a5660e7e1 | |||
| 463facbe5a | |||
| eeaf09ea0e | |||
| c14081d85f | |||
| 2434879804 | |||
| 2bdb59b49a | |||
| deb78bf952 | |||
| 2b973348bc | |||
| 9323bbd6c6 | |||
| d0f31d5278 | |||
| b9bd6bd1c6 | |||
| 73079ca659 | |||
| b1620ed19e | |||
| 8a5408d193 | |||
| f10dbbc45c | |||
| 0a6ca53c04 | |||
| 70047b3964 |
77 changed files with 8706 additions and 270 deletions
100
README.md
100
README.md
|
|
@ -1,3 +1,7 @@
|
|||
<p align="center">
|
||||
<img src="app/assets/icon.png" alt="BigBrainParking — big-brain driver" width="220">
|
||||
</p>
|
||||
|
||||
# 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)
|
||||
|
||||
|
|
|
|||
3
app/.gitignore
vendored
Normal file
3
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
# local native module build outputs
|
||||
modules/**/android/build/
|
||||
|
|
@ -3,18 +3,18 @@
|
|||
"name": "BigBrainParking",
|
||||
"slug": "bigbrainparking",
|
||||
"scheme": "bigbrainparking",
|
||||
"version": "0.1.9",
|
||||
"version": "0.6.4",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"icon": "./assets/icon.png",
|
||||
"android": {
|
||||
"package": "top.mowden.bigbrainparking",
|
||||
"versionCode": 9,
|
||||
"versionCode": 25,
|
||||
"edgeToEdgeEnabled": true,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0F2A33"
|
||||
"backgroundColor": "#FFFFFF"
|
||||
},
|
||||
"permissions": [
|
||||
"ACCESS_COARSE_LOCATION",
|
||||
|
|
@ -44,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",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 369 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 745 KiB |
27
app/modules/bbp-notify/android/build.gradle
Normal file
27
app/modules/bbp-notify/android/build.gradle
Normal file
|
|
@ -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"
|
||||
}
|
||||
37
app/modules/bbp-notify/android/src/main/AndroidManifest.xml
Normal file
37
app/modules/bbp-notify/android/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- The countdown is pinned by a foreground service so it survives the app being
|
||||
killed and can be restored after a reboot. "specialUse" is the right type: a
|
||||
user-visible parking timer isn't media, location, or data sync. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="expo.modules.bbpnotify.BbpSessionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Ongoing countdown for the parking session the user paid for or checked into, with end/extend actions." />
|
||||
</service>
|
||||
|
||||
<!-- Fully-qualified names: a relative ".Name" would resolve against the app
|
||||
package, not this module's namespace, once manifests are merged. -->
|
||||
<receiver
|
||||
android:name="expo.modules.bbpnotify.BbpActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- BOOT_COMPLETED and MY_PACKAGE_REPLACED are protected system broadcasts, so
|
||||
the system still delivers them to a non-exported receiver. -->
|
||||
<receiver
|
||||
android:name="expo.modules.bbpnotify.BbpBootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Brings the parking countdown back after a reboot or an app update, without the
|
||||
* user having to open the app.
|
||||
*
|
||||
* `specialUse` is one of the foreground-service types Android 14/15 still allow to
|
||||
* be started from BOOT_COMPLETED, so the service can claim its notification here.
|
||||
* [BbpSessionStore.load] returns null for an already-expired session, so a stale
|
||||
* record from yesterday's parking never resurrects itself.
|
||||
*/
|
||||
class BbpBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
|
||||
else -> return
|
||||
}
|
||||
|
||||
if (BbpSessionStore.load(context) == null) {
|
||||
// No live session — make sure an expired record doesn't linger.
|
||||
if (BbpSessionStore.hasRecord(context)) BbpSessionStore.clear(context)
|
||||
return
|
||||
}
|
||||
|
||||
BbpSessionService.start(context)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Builds the ongoing parking notification.
|
||||
*
|
||||
* The remaining time is an Android **chronometer counting down** to the session
|
||||
* end: `setWhen(endMs)` + `setUsesChronometer` + `setChronometerCountDown` hand the
|
||||
* end time to the system, which redraws the ticking text itself every second. The
|
||||
* app burns no CPU and needs no timer of its own, so the countdown stays accurate
|
||||
* while the app is closed.
|
||||
*
|
||||
* Shared by the foreground service and by the boot receiver's fallback path.
|
||||
*/
|
||||
internal object BbpNotification {
|
||||
const val CHANNEL_ID = "session-status"
|
||||
const val NOTIF_ID = 42421
|
||||
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel =
|
||||
NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.setShowBadge(false)
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun build(ctx: Context, s: BbpSessionStore.Session): Notification {
|
||||
ensureChannel(ctx)
|
||||
val builder = base(ctx)
|
||||
.setContentTitle(s.title)
|
||||
.setContentText(s.body)
|
||||
.setWhen(s.endMs)
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
builder.setChronometerCountDown(true)
|
||||
}
|
||||
|
||||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
ctx, 0, launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
builder.addAction(0, s.endLabel, actionIntent(ctx, BbpActionReceiver.ACTION_END))
|
||||
builder.addAction(0, s.extendLabel, actionIntent(ctx, BbpActionReceiver.ACTION_EXTEND))
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare notification for the start-then-immediately-stop path. A service launched
|
||||
* with startForegroundService() must call startForeground() within a few seconds or
|
||||
* the system kills the process, and that holds even when we've just discovered there
|
||||
* is no session to show — so we post this and remove it in the same breath. It is
|
||||
* never on screen long enough to be seen.
|
||||
*/
|
||||
fun placeholder(ctx: Context): Notification {
|
||||
ensureChannel(ctx)
|
||||
return base(ctx).setContentTitle("Parking").build()
|
||||
}
|
||||
|
||||
private fun base(ctx: Context) =
|
||||
NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
|
||||
private fun actionIntent(ctx: Context, action: String): PendingIntent {
|
||||
val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
|
||||
return PendingIntent.getBroadcast(
|
||||
ctx, action.hashCode(), intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
|
||||
/**
|
||||
* Foreground service that owns the ongoing parking notification.
|
||||
*
|
||||
* A plain `notify()` was not enough: on Android 14+ the user can swipe an ongoing
|
||||
* notification away, and nothing brings it back after a reboot. A foreground
|
||||
* service pins the notification for as long as the service runs — the same reason
|
||||
* ntfy's notification stays put on GrapheneOS.
|
||||
*
|
||||
* The service's lifetime is exactly the session's lifetime. It runs while there is
|
||||
* an active session and fully stops — removing the notification — the moment there
|
||||
* isn't one: on "End", when the countdown reaches zero, or when JS clears it. It
|
||||
* never lingers with nothing to show.
|
||||
*/
|
||||
class BbpSessionService : Service() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var expiryTask: Runnable? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// A null intent means the system restarted us after killing the process
|
||||
// (START_STICKY) — fall through and rebuild from the store.
|
||||
val stopping = intent?.action == ACTION_STOP
|
||||
val session = if (stopping) null else BbpSessionStore.load(this)
|
||||
|
||||
if (session == null) {
|
||||
// Satisfy the startForegroundService() contract before standing down, or the
|
||||
// system kills us for not calling startForeground() in time.
|
||||
goForeground(BbpNotification.placeholder(this))
|
||||
if (!stopping) BbpSessionStore.clear(this) // expired record — don't leave it around
|
||||
shutdown()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (!goForeground(BbpNotification.build(this, session))) {
|
||||
// The system refused to let us hold a foreground service right now. The
|
||||
// countdown matters more than the pinning, so leave the notification posted
|
||||
// on its own and stand the service down rather than crash.
|
||||
postDirectly(this)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
scheduleExpiry(session.endMs)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* startForeground() can still throw on Android 12+ even after startForegroundService()
|
||||
* was accepted (background-start restrictions are evaluated here too). An uncaught
|
||||
* throw would crash the app, so report the failure instead and let the caller fall back.
|
||||
*/
|
||||
private fun goForeground(notification: android.app.Notification): Boolean =
|
||||
try {
|
||||
startForeground(BbpNotification.NOTIF_ID, notification)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the moment the meter runs out, so a finished session never leaves a dead
|
||||
* "0:00" notification pinned to the shade. A posted callback is enough here — a
|
||||
* foreground service is alive to run it, no alarm permission needed.
|
||||
*/
|
||||
private fun scheduleExpiry(endMs: Long) {
|
||||
expiryTask?.let { handler.removeCallbacks(it) }
|
||||
val task = Runnable {
|
||||
BbpSessionStore.clear(this)
|
||||
shutdown()
|
||||
}
|
||||
expiryTask = task
|
||||
handler.postDelayed(task, (endMs - System.currentTimeMillis()).coerceAtLeast(0L))
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
expiryTask?.let { handler.removeCallbacks(it) }
|
||||
expiryTask = null
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
expiryTask?.let { handler.removeCallbacks(it) }
|
||||
expiryTask = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "expo.modules.bbpnotify.STOP_SERVICE"
|
||||
|
||||
/**
|
||||
* Start (or refresh) the service from the stored session. Returns a short
|
||||
* diagnostic string — JS logs it, because a failure here is otherwise silent.
|
||||
*/
|
||||
fun start(ctx: Context): String {
|
||||
val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
||||
val intent = Intent(ctx, BbpSessionService::class.java)
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
"service-started enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||
} catch (e: Exception) {
|
||||
// Background-start restrictions can refuse the service (Android 12+). The
|
||||
// countdown still matters more than the pinning, so fall back to posting the
|
||||
// notification directly; it just becomes swipe-dismissable.
|
||||
val fallback = postDirectly(ctx)
|
||||
"service-failed=${e.javaClass.simpleName} fallback=$fallback enabled=$enabled"
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
try {
|
||||
ctx.startService(Intent(ctx, BbpSessionService::class.java).setAction(ACTION_STOP))
|
||||
} catch (_: Exception) {
|
||||
// The service may already be gone; make sure the notification is too.
|
||||
}
|
||||
NotificationManagerCompat.from(ctx).cancel(BbpNotification.NOTIF_ID)
|
||||
}
|
||||
|
||||
/** Last-resort path: the plain ongoing notification, with no service pinning it. */
|
||||
fun postDirectly(ctx: Context): String {
|
||||
val session = BbpSessionStore.load(ctx) ?: return "no-session"
|
||||
return try {
|
||||
NotificationManagerCompat.from(ctx)
|
||||
.notify(BbpNotification.NOTIF_ID, BbpNotification.build(ctx, session))
|
||||
"posted"
|
||||
} catch (e: SecurityException) {
|
||||
"no-permission"
|
||||
} catch (e: Exception) {
|
||||
"failed=${e.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The single active-parking record the foreground service renders.
|
||||
*
|
||||
* It lives in SharedPreferences rather than in memory because every other actor
|
||||
* here can run without the JS app: the service after a system restart, the boot
|
||||
* receiver after a reboot, the action receiver after the process is gone. They
|
||||
* all read the same row, so there is exactly one source of truth for "is there a
|
||||
* session, and what does its notification say".
|
||||
*/
|
||||
internal object BbpSessionStore {
|
||||
const val PREFS = "bbp_notify"
|
||||
|
||||
private const val K_TITLE = "title"
|
||||
private const val K_BODY = "body"
|
||||
private const val K_END = "end_ms"
|
||||
private const val K_END_LABEL = "end_label"
|
||||
private const val K_EXTEND_LABEL = "extend_label"
|
||||
private const val K_PENDING = "pending_action"
|
||||
|
||||
data class Session(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val endMs: Long,
|
||||
val endLabel: String,
|
||||
val extendLabel: String,
|
||||
)
|
||||
|
||||
private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun save(ctx: Context, s: Session) {
|
||||
prefs(ctx).edit()
|
||||
.putString(K_TITLE, s.title)
|
||||
.putString(K_BODY, s.body)
|
||||
.putLong(K_END, s.endMs)
|
||||
.putString(K_END_LABEL, s.endLabel)
|
||||
.putString(K_EXTEND_LABEL, s.extendLabel)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The current session, or null when there isn't one. A record whose end time has
|
||||
* already passed reads as null so an expired session can never re-post itself
|
||||
* (e.g. a reboot hours after the meter ran out).
|
||||
*/
|
||||
fun load(ctx: Context): Session? {
|
||||
val p = prefs(ctx)
|
||||
val end = p.getLong(K_END, 0L)
|
||||
if (end <= System.currentTimeMillis()) return null
|
||||
return Session(
|
||||
title = p.getString(K_TITLE, "") ?: "",
|
||||
body = p.getString(K_BODY, "") ?: "",
|
||||
endMs = end,
|
||||
endLabel = p.getString(K_END_LABEL, "End") ?: "End",
|
||||
extendLabel = p.getString(K_EXTEND_LABEL, "Extend") ?: "Extend",
|
||||
)
|
||||
}
|
||||
|
||||
/** True when a record exists at all, expired or not — used to decide if cleanup is needed. */
|
||||
fun hasRecord(ctx: Context): Boolean = prefs(ctx).contains(K_END)
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
prefs(ctx).edit()
|
||||
.remove(K_TITLE)
|
||||
.remove(K_BODY)
|
||||
.remove(K_END)
|
||||
.remove(K_END_LABEL)
|
||||
.remove(K_EXTEND_LABEL)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setPending(ctx: Context, action: String) {
|
||||
prefs(ctx).edit().putString(K_PENDING, action).apply()
|
||||
}
|
||||
|
||||
/** Read + clear the action a notification button recorded while the app was away. */
|
||||
fun consumePending(ctx: Context): String {
|
||||
val p = prefs(ctx)
|
||||
val action = p.getString(K_PENDING, "") ?: ""
|
||||
if (action.isNotEmpty()) p.edit().remove(K_PENDING).apply()
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!--
|
||||
Notification small icon: a white-on-transparent timer glyph (Material "timer").
|
||||
Small icons MUST be a simple monochrome drawable — the app launcher mipmap
|
||||
(applicationInfo.icon) is an adaptive/full-colour icon and is dropped as an
|
||||
invalid small icon on Android 13+/GrapheneOS, so we ship our own here.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M15,1L9,1L9,3L15,3L15,1ZM11,14L13,14L13,8L11,8L11,14ZM19.03,7.39L20.45,5.97C20.02,5.46 19.55,4.98 19.04,4.56L17.62,5.98C16.07,4.74 14.12,4 12,4C7.03,4 3,8.03 3,13C3,17.97 7.02,22 12,22C16.98,22 21,17.97 21,13C21,10.88 20.26,8.93 19.03,7.39ZM12,20C8.13,20 5,16.87 5,13C5,9.13 8.13,6 12,6C15.87,6 19,9.13 19,13C19,16.87 15.87,20 12,20Z" />
|
||||
</vector>
|
||||
6
app/modules/bbp-notify/expo-module.config.json
Normal file
6
app/modules/bbp-notify/expo-module.config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.bbpnotify.BbpNotifyModule"]
|
||||
}
|
||||
}
|
||||
60
app/modules/bbp-notify/index.ts
Normal file
60
app/modules/bbp-notify/index.ts
Normal file
|
|
@ -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<string>;
|
||||
/** No active session: stop the service and remove the notification. */
|
||||
clearSession(): Promise<void>;
|
||||
/** Read + clear the action a notification button set: 'end' | 'extend' | ''. */
|
||||
consumePendingAction(): Promise<string>;
|
||||
/** Whether the native side still holds a live (unexpired) session record. */
|
||||
hasActiveSession(): Promise<boolean>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
if (!native) return 'no-native-module';
|
||||
return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
|
||||
}
|
||||
|
||||
export async function clearSession(): Promise<void> {
|
||||
await native?.clearSession();
|
||||
}
|
||||
|
||||
/** Read + clear a notification-button action taken while the app was away. */
|
||||
export async function consumePendingAction(): Promise<PendingAction> {
|
||||
const a = await native?.consumePendingAction();
|
||||
return a === 'end' || a === 'extend' ? a : null;
|
||||
}
|
||||
|
||||
export async function hasActiveSession(): Promise<boolean> {
|
||||
return (await native?.hasActiveSession()) ?? false;
|
||||
}
|
||||
7
app/modules/bbp-notify/package.json
Normal file
7
app/modules/bbp-notify/package.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
app/src/api/adminStore.ts
Normal file
18
app/src/api/adminStore.ts
Normal file
|
|
@ -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<string | null> {
|
||||
return SecureStore.getItemAsync(ADMIN_KEY);
|
||||
}
|
||||
|
||||
export function setAdminToken(token: string | null): Promise<void> {
|
||||
return token
|
||||
? SecureStore.setItemAsync(ADMIN_KEY, token)
|
||||
: SecureStore.deleteItemAsync(ADMIN_KEY);
|
||||
}
|
||||
234
app/src/api/parkingAreas.ts
Normal file
234
app/src/api/parkingAreas.ts
Normal file
|
|
@ -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<AreaKind, string> = {
|
||||
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<AreaData | null> {
|
||||
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<void> {
|
||||
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<AreaData> {
|
||||
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<AreaData> {
|
||||
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<AreaData> {
|
||||
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<Record<string, string>> {
|
||||
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<number> {
|
||||
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<OverlayAdjust, 'updatedAt'>): Promise<OverlayAdjust> {
|
||||
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<OverlayAdjust, 'updatedAt'>): Promise<void> {
|
||||
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<void> {
|
||||
memCache = null;
|
||||
await AsyncStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
30
app/src/api/parseTime.ts
Normal file
30
app/src/api/parseTime.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
|
||||
* /api/Session + estimates: "07-15-2026 05:00 PM" dashes, 12h, AM/PM
|
||||
* /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
|
||||
* Both are device-local wall-clock time. Hermes' Date() can't parse either, so we
|
||||
* build the Date from explicit components.
|
||||
*
|
||||
* This is the single parser for both — an earlier stricter copy that required
|
||||
* dashes and AM/PM silently returned null on the other format, which meant no
|
||||
* countdown notification at all for sessions whose end time came back that way.
|
||||
*/
|
||||
export function parseApiTime(s?: string | null): Date | null {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(
|
||||
/(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
|
||||
);
|
||||
if (!m) {
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(+d) ? null : d;
|
||||
}
|
||||
let hr = parseInt(m[4], 10);
|
||||
const ap = m[7];
|
||||
if (ap) {
|
||||
const pm = /pm/i.test(ap);
|
||||
if (pm && hr !== 12) hr += 12;
|
||||
if (!pm && hr === 12) hr = 0;
|
||||
}
|
||||
const sec = m[6] ? parseInt(m[6], 10) : 0;
|
||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
|
||||
}
|
||||
142
app/src/api/zoneLabels.ts
Normal file
142
app/src/api/zoneLabels.ts
Normal file
|
|
@ -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<string, ZoneLabel> | null = null;
|
||||
|
||||
async function loadCache(): Promise<Record<string, ZoneLabel>> {
|
||||
if (memCache) return memCache;
|
||||
const raw = await AsyncStorage.getItem(CACHE_KEY);
|
||||
memCache = raw ? (JSON.parse(raw) as Record<string, ZoneLabel>) : {};
|
||||
return memCache;
|
||||
}
|
||||
|
||||
async function saveCache(map: Record<string, ZoneLabel>): Promise<void> {
|
||||
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<Record<string, string>> {
|
||||
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<void> {
|
||||
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<string, ZoneLabel> = {};
|
||||
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<ZoneLabel | null> {
|
||||
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<ZoneLabel> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
ensureConfigured();
|
||||
const res = await fetch(`${BASE_URL}/api/whoami`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
34
app/src/api/zoneMirror.ts
Normal file
34
app/src/api/zoneMirror.ts
Normal file
|
|
@ -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<Zone[]> {
|
||||
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<void> {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
logout: () => Promise<void>;
|
||||
/** 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<ApplicationValidityResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(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');
|
||||
};
|
||||
|
|
@ -85,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<AuthState>(
|
||||
() => ({ status, validity, login, logout, error }),
|
||||
() => ({
|
||||
status,
|
||||
validity,
|
||||
login,
|
||||
logout,
|
||||
enterAnonymous,
|
||||
requireLogin,
|
||||
isAnonymous: status === 'anonymous',
|
||||
error,
|
||||
}),
|
||||
[status, validity, error],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
147
app/src/features/citymap/geo.ts
Normal file
147
app/src/features/citymap/geo.ts
Normal file
|
|
@ -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];
|
||||
}
|
||||
1
app/src/features/citymap/parkingAreas.json
Normal file
1
app/src/features/citymap/parkingAreas.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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<Coords | null> {
|
|||
/**
|
||||
* 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<Coords | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState(0);
|
||||
const [granted, setGranted] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,3 +35,19 @@ export async function getRemindersEnabled(): Promise<boolean> {
|
|||
export async function setRemindersEnabled(on: boolean): Promise<void> {
|
||||
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<boolean> {
|
||||
const raw = await AsyncStorage.getItem(COUNTDOWN_KEY);
|
||||
return raw == null ? true : raw === 'true';
|
||||
}
|
||||
|
||||
export async function setCountdownEnabled(on: boolean): Promise<void> {
|
||||
await AsyncStorage.setItem(COUNTDOWN_KEY, String(on));
|
||||
}
|
||||
|
|
|
|||
378
app/src/features/session/activeParking.ts
Normal file
378
app/src/features/session/activeParking.ts
Normal file
|
|
@ -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<void> {
|
||||
if (!(await getCountdownEnabled())) {
|
||||
logLine('[PARKING] countdown disabled — not posting');
|
||||
await clearNotification();
|
||||
return;
|
||||
}
|
||||
await ensureNotificationPermission();
|
||||
|
||||
const ends = fmtTime(p.endMs);
|
||||
const free = p.kind === 'free';
|
||||
const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
|
||||
const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
|
||||
// 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<void> {
|
||||
await clearSession().catch(() => {});
|
||||
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||
await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- reminders */
|
||||
|
||||
async function scheduleExpiryReminder(p: ActiveParking): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||
if (!(await getRemindersEnabled())) return;
|
||||
const fireAt = p.endMs - p.leadMinutes * 60_000;
|
||||
if (fireAt <= Date.now()) return;
|
||||
|
||||
const free = p.kind === 'free';
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: EXPIRY_REMINDER_ID,
|
||||
content: {
|
||||
title: free ? 'Free parking ending soon' : 'Parking expiring soon',
|
||||
body: `${p.zoneName}: ${free ? 'your free time ends' : 'your session ends'} at ${fmtTime(
|
||||
p.endMs,
|
||||
)}. Extend if you need more time.`,
|
||||
data: { kind: 'parking-expiry' },
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: new Date(fireAt),
|
||||
channelId: REMINDER_CHANNEL_ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ start / stop */
|
||||
|
||||
/** Begin tracking a paid session that ParkSmarter has just confirmed. */
|
||||
export async function startPaidSession(args: {
|
||||
zone: Zone;
|
||||
endTime: Date;
|
||||
transactionId?: string | number;
|
||||
}): Promise<void> {
|
||||
const state: ActiveParking = {
|
||||
kind: 'paid',
|
||||
zone: args.zone,
|
||||
zoneName: args.zone.ZoneName ?? 'Parking',
|
||||
startMs: Date.now(),
|
||||
endMs: args.endTime.getTime(),
|
||||
transactionId: args.transactionId != null ? String(args.transactionId) : undefined,
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
await setActiveParking(state);
|
||||
await postNotification(state);
|
||||
await scheduleExpiryReminder(state);
|
||||
}
|
||||
|
||||
/** Start a local free check-in for `hours` at the given zone. */
|
||||
export async function startFreeCheckin(
|
||||
zone: Zone,
|
||||
hours: number,
|
||||
labelKind?: LabelKind,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const state: ActiveParking = {
|
||||
kind: 'free',
|
||||
zone,
|
||||
zoneName: zone.ZoneName ?? 'Parking',
|
||||
startMs: now,
|
||||
endMs: now + hours * 3_600_000,
|
||||
labelKind: labelKind ?? (`free_${hours}h` as LabelKind),
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
await setActiveParking(state);
|
||||
await postNotification(state);
|
||||
await scheduleExpiryReminder(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<ActiveParking | null> {
|
||||
try {
|
||||
const res = await ps.getActiveParkingSessions();
|
||||
const soonest = (res.ParkingSession ?? [])
|
||||
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
||||
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
||||
.sort((a, b) => a.end!.getTime() - b.end!.getTime())[0];
|
||||
if (!soonest) return null;
|
||||
|
||||
const found: ActiveParking = {
|
||||
kind: 'paid',
|
||||
zoneName: soonest.s.ZoneName ?? 'Parking',
|
||||
startMs: parseApiTime(soonest.s.StartTime)?.getTime() ?? Date.now(),
|
||||
endMs: soonest.end!.getTime(),
|
||||
transactionId:
|
||||
soonest.s.TransactionID != null ? String(soonest.s.TransactionID) : undefined,
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
logLine(`[PARKING] discovered server session ending ${new Date(found.endMs).toISOString()}`);
|
||||
await setActiveParking(found);
|
||||
await scheduleExpiryReminder(found);
|
||||
return found;
|
||||
} catch (e: any) {
|
||||
logLine(`[PARKING] discover failed: ${e?.serverMessage ?? e?.message ?? e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the record, its notification and any button the user pressed while the
|
||||
* app was away. Safe to call repeatedly; it's the app's foreground heartbeat.
|
||||
*/
|
||||
export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promise<void> {
|
||||
const action = await consumePendingAction();
|
||||
|
||||
if (action === 'end') {
|
||||
logLine('[PARKING] notification "End" pressed');
|
||||
await endActiveParking();
|
||||
return;
|
||||
}
|
||||
|
||||
let current = await getActiveParking();
|
||||
|
||||
// The meter ran out: the service already removed its own notification when the
|
||||
// countdown hit zero, so this just clears the record behind it.
|
||||
if (current && current.endMs <= Date.now()) {
|
||||
await endActiveParking();
|
||||
current = null;
|
||||
}
|
||||
|
||||
// "+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<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
useEffect(() => {
|
||||
const run = () =>
|
||||
void syncActiveParking((zone) => {
|
||||
// "Extend" means "sell me more time for this exact spot" — go straight to
|
||||
// the purchase screen for the stored zone. A server-discovered session has
|
||||
// no zone, so fall back to the sessions list rather than guessing.
|
||||
if (zone) navigation.navigate('StartSession', { zone });
|
||||
else navigation.navigate('Tabs');
|
||||
});
|
||||
|
||||
run();
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') run();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [navigation]);
|
||||
}
|
||||
|
||||
/** Turn the countdown notification on/off from Settings without touching the record. */
|
||||
export async function refreshParkingNotification(): Promise<void> {
|
||||
const current = await getActiveParking();
|
||||
if (current && current.endMs > Date.now()) await postNotification(current);
|
||||
else await clearNotification();
|
||||
}
|
||||
104
app/src/features/session/activeParkingStore.ts
Normal file
104
app/src/features/session/activeParkingStore.ts
Normal file
|
|
@ -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<ActiveParking | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (raw) return JSON.parse(raw) as ActiveParking;
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_CHECKIN_KEY);
|
||||
if (!legacy) return null;
|
||||
const c = JSON.parse(legacy) as Omit<ActiveParking, 'kind'>;
|
||||
const migrated: ActiveParking = { ...c, kind: 'free' };
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(migrated));
|
||||
await AsyncStorage.removeItem(LEGACY_CHECKIN_KEY);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
export async function setActiveParking(state: ActiveParking): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export async function clearActiveParking(): Promise<void> {
|
||||
// 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<ParkedSpot | null> {
|
||||
const raw = await AsyncStorage.getItem(PIN_KEY);
|
||||
return raw ? (JSON.parse(raw) as ParkedSpot) : null;
|
||||
}
|
||||
|
||||
export async function setParkedPin(spot: ParkedSpot | null): Promise<void> {
|
||||
if (spot) await AsyncStorage.setItem(PIN_KEY, JSON.stringify(spot));
|
||||
else await AsyncStorage.removeItem(PIN_KEY);
|
||||
}
|
||||
81
app/src/features/session/localHistory.ts
Normal file
81
app/src/features/session/localHistory.ts
Normal file
|
|
@ -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<LocalSessionRecord[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await AsyncStorage.removeItem(KEY);
|
||||
}
|
||||
|
|
@ -23,13 +23,22 @@ 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 { 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;
|
||||
|
|
@ -37,6 +46,7 @@ export type RootStackParamList = {
|
|||
PaymentMethods: undefined;
|
||||
Notifications: undefined;
|
||||
Diagnostics: undefined;
|
||||
Admin: undefined;
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
|
|
@ -68,6 +78,9 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
|||
};
|
||||
|
||||
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 (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
|
|
@ -109,7 +122,7 @@ export function RootNavigator() {
|
|||
|
||||
return (
|
||||
<NavigationContainer theme={navTheme}>
|
||||
{status === 'signedIn' ? (
|
||||
{status === 'signedIn' || status === 'anonymous' ? (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
|
|
@ -145,6 +158,17 @@ export function RootNavigator() {
|
|||
component={DiagnosticsScreen}
|
||||
options={{ title: 'Diagnostics' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CityArea"
|
||||
component={CityAreaScreen}
|
||||
options={{ title: 'Parking area' }}
|
||||
/>
|
||||
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
|
||||
<Stack.Screen
|
||||
name="MapAlign"
|
||||
component={MapAlignScreen}
|
||||
options={{ title: 'Align city map' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
) : (
|
||||
<LoginScreen />
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
@ -33,58 +31,21 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
|||
}
|
||||
|
||||
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<string | null> {
|
||||
if (!(await getRemindersEnabled())) return null;
|
||||
const leadMinutes = args.leadMinutes ?? (await getReminderLeadMinutes());
|
||||
const lead = leadMinutes * 60 * 1000;
|
||||
const fireAt = new Date(args.endTime.getTime() - lead);
|
||||
if (fireAt.getTime() <= Date.now()) return null; // already too late
|
||||
|
||||
await ensureChannel();
|
||||
return Notifications.scheduleNotificationAsync({
|
||||
identifier: `session-${args.transactionId}`,
|
||||
content: {
|
||||
title: 'Parking expiring soon',
|
||||
body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
|
||||
data: { transactionId: String(args.transactionId) },
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: fireAt,
|
||||
channelId: CHANNEL_ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelExpiryReminder(transactionId: string | number): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
|
||||
}
|
||||
|
||||
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
|
||||
export async function sendTestReminder(seconds = 10): Promise<boolean> {
|
||||
if (!(await ensureNotificationPermission())) return false;
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [showJoel, setShowJoel] = useState(false);
|
||||
const taps = useRef(0);
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | 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,17 @@ export function AboutScreen() {
|
|||
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={[styles.body, { color: colors.text }]}>{about ?? 'Loading…'}</Text>
|
||||
<Text style={[styles.body, { color: colors.text }]}>
|
||||
BigBrainParking is a free, <Text style={{ fontWeight: '700' }}>open-source</Text>,
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity onPress={() => Linking.openURL(REPO_URL)} style={{ marginTop: 12 }}>
|
||||
<Text style={[styles.link, { color: colors.primary }]}>Source code & issues ›</Text>
|
||||
<Text style={[styles.linkUrl, { color: colors.subtext }]}>{REPO_URL}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.section, { color: colors.text }]}>What gets sent to ParkSmarter (IPS)</Text>
|
||||
<Text style={[styles.body, { color: colors.text }]}>
|
||||
|
|
@ -52,16 +56,20 @@ export function AboutScreen() {
|
|||
{'\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. “Near me” just centers the map on your device location first; otherwise it’s
|
||||
wherever you’ve panned/zoomed to.
|
||||
search. Your device GPS is sent <Text style={{ fontWeight: '700' }}>only</Text> 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.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.section, { color: colors.text }]}>What it does not do</Text>
|
||||
<Text style={[styles.body, { color: colors.text }]}>
|
||||
{'\n'}• Your phone’s GPS is used only on-device to position the map, and cached locally so
|
||||
“last location” works — it is never attached to sign-in, sessions, vehicles, or payments.
|
||||
{'\n'}• Your phone’s GPS is <Text style={{ fontWeight: '700' }}>never sent to the API</Text>{' '}
|
||||
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.
|
||||
</Text>
|
||||
|
|
@ -84,6 +92,8 @@ const styles = StyleSheet.create({
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|||
|
||||
export function AccountScreen() {
|
||||
const { colors, mode, toggle } = useTheme();
|
||||
const { logout } = useAuth();
|
||||
const { logout, isAnonymous, requireLogin } = useAuth();
|
||||
const navigation = useNavigation<Nav>();
|
||||
|
||||
const Item = ({
|
||||
|
|
@ -35,21 +35,33 @@ export function AccountScreen() {
|
|||
|
||||
return (
|
||||
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
|
||||
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
|
||||
<Item
|
||||
icon="card"
|
||||
label="Payment methods"
|
||||
onPress={() => navigation.navigate('PaymentMethods')}
|
||||
/>
|
||||
<Item
|
||||
icon="notifications"
|
||||
label="Notifications"
|
||||
onPress={() => navigation.navigate('Notifications')}
|
||||
/>
|
||||
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||
</View>
|
||||
{isAnonymous ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<Item icon="log-in" label="Sign in to pay & sync" onPress={requireLogin} />
|
||||
<Item
|
||||
icon="notifications"
|
||||
label="Notifications"
|
||||
onPress={() => navigation.navigate('Notifications')}
|
||||
/>
|
||||
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
|
||||
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
|
||||
<Item
|
||||
icon="card"
|
||||
label="Payment methods"
|
||||
onPress={() => navigation.navigate('PaymentMethods')}
|
||||
/>
|
||||
<Item
|
||||
icon="notifications"
|
||||
label="Notifications"
|
||||
onPress={() => navigation.navigate('Notifications')}
|
||||
/>
|
||||
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
|
|
@ -59,14 +71,23 @@ export function AccountScreen() {
|
|||
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
||||
</View>
|
||||
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
|
||||
<Item
|
||||
icon="git-compare"
|
||||
label="Align city map"
|
||||
onPress={() => navigation.navigate('MapAlign')}
|
||||
/>
|
||||
<Item
|
||||
icon="shield-checkmark"
|
||||
label="Admin (zone labeling)"
|
||||
onPress={() => navigation.navigate('Admin')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.logout, { borderColor: colors.danger }]}
|
||||
onPress={logout}
|
||||
>
|
||||
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
|
||||
</TouchableOpacity>
|
||||
{isAnonymous ? null : (
|
||||
<TouchableOpacity style={[styles.logout, { borderColor: colors.danger }]} onPress={logout}>
|
||||
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
109
app/src/screens/AdminScreen.tsx
Normal file
109
app/src/screens/AdminScreen.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { getAdminToken, setAdminToken } from '@/api/adminStore';
|
||||
import { verifyAdmin } from '@/api/zoneLabels';
|
||||
|
||||
/**
|
||||
* Enter the zone-labels admin password. Once verified + saved it unlocks the
|
||||
* labeling controls on the meter detail screen. Stored in the OS keystore.
|
||||
*/
|
||||
export function AdminScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [value, setValue] = useState('');
|
||||
const [authed, setAuthed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void getAdminToken().then((t) => setAuthed(!!t));
|
||||
}, []);
|
||||
|
||||
const saveAndTest = async () => {
|
||||
const token = value.trim();
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const ok = await verifyAdmin(token);
|
||||
if (!ok) {
|
||||
Alert.alert('Not accepted', 'That password was rejected by the server.');
|
||||
return;
|
||||
}
|
||||
await setAdminToken(token);
|
||||
setAuthed(true);
|
||||
setValue('');
|
||||
Alert.alert('Admin enabled', 'You can now label zones from the meter screen.');
|
||||
} catch (e: any) {
|
||||
Alert.alert('Could not verify', e?.message ?? 'Check your connection and try again.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
await setAdminToken(null);
|
||||
setAuthed(false);
|
||||
Alert.alert('Signed out', 'Admin labeling is now disabled on this device.');
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||
<Text style={[styles.section, { color: colors.subtext }]}>
|
||||
Zone labeling {authed ? '· enabled' : '· disabled'}
|
||||
</Text>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.help, { color: colors.subtext }]}>
|
||||
The admin password lets you tag zones as free (2h/3h/4h) or pay-immediately.
|
||||
It's stored securely on this device and sent only to
|
||||
bigbrainparking.mowden.top.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text, borderColor: colors.border }]}
|
||||
placeholder={authed ? 'Enter a new password to replace' : 'Admin password'}
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary, opacity: value.trim() ? 1 : 0.5 }]}
|
||||
onPress={saveAndTest}
|
||||
disabled={!value.trim() || busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Verify & save</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{authed ? (
|
||||
<TouchableOpacity style={[styles.signout, { borderColor: colors.danger }]} onPress={signOut}>
|
||||
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out of admin</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' },
|
||||
card: { borderRadius: 12, padding: 16, gap: 12 },
|
||||
help: { fontSize: 13, lineHeight: 19 },
|
||||
input: { borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
|
||||
button: { borderRadius: 10, padding: 14, alignItems: 'center' },
|
||||
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
signout: { marginTop: 24, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
|
||||
});
|
||||
243
app/src/screens/CityAreaScreen.tsx
Normal file
243
app/src/screens/CityAreaScreen.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import {
|
||||
endActiveParking,
|
||||
extendAreaParking,
|
||||
startAreaParking,
|
||||
} from '@/features/session/activeParking';
|
||||
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
||||
|
||||
type AreaRoute = RouteProp<RootStackParamList, 'CityArea'>;
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function fmtHours(h: number): string {
|
||||
if (h < 1) return `${Math.round(h * 60)} min`;
|
||||
return h === 1 ? '1 hour' : `${h} hours`;
|
||||
}
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtRemaining(ms: number): string {
|
||||
const mins = Math.max(0, Math.round(ms / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One area from the city's printed parking map: what the sign says, and a timer
|
||||
* for it.
|
||||
*
|
||||
* Nothing on this screen talks to ParkSmarter. The area came from the local
|
||||
* database and the countdown is the phone's own clock, so this works with no
|
||||
* account, no signal, and in Anonymous Mode.
|
||||
*/
|
||||
export function CityAreaScreen() {
|
||||
const { area, spot } = useRoute<AreaRoute>().params;
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { colors } = useTheme();
|
||||
const { isAnonymous, requireLogin } = useAuth();
|
||||
const [active, setActive] = useState<ActiveParking | null>(null);
|
||||
|
||||
const options = areaDurationOptions(area);
|
||||
const [hours, setHours] = useState<number>(options[0]);
|
||||
const free = areaIsFree(area.kind);
|
||||
const parkedHere = active?.area?.id === area.id;
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void getActiveParking().then(setActive);
|
||||
}, []);
|
||||
useFocusEffect(reload);
|
||||
|
||||
// Re-read while the screen is open so "time left" doesn't sit frozen at whatever
|
||||
// it was when you opened it. The notification is the real live countdown; this
|
||||
// just keeps the screen from lying.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setInterval(reload, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [active, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: area.label });
|
||||
}, [navigation, area.label]);
|
||||
|
||||
const start = async () => {
|
||||
if (active && !parkedHere) {
|
||||
const where = active.zoneName;
|
||||
const ok = await new Promise<boolean>((resolve) =>
|
||||
Alert.alert(
|
||||
'Already tracking',
|
||||
`You're tracking parking at ${where}. Replace it with this spot?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
|
||||
{ text: 'Replace', style: 'destructive', onPress: () => resolve(true) },
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await startAreaParking({ area, hours, spot });
|
||||
navigation.navigate('Tabs');
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
await endActiveParking();
|
||||
reload();
|
||||
};
|
||||
|
||||
const s = styles(colors);
|
||||
|
||||
return (
|
||||
<ScrollView style={s.screen} contentContainerStyle={s.content}>
|
||||
<View style={s.card}>
|
||||
<View style={s.chipRow}>
|
||||
<View style={[s.swatch, { backgroundColor: area.color }]} />
|
||||
<Text style={s.chipText}>{area.legend}</Text>
|
||||
</View>
|
||||
<Text style={s.name}>{area.name}</Text>
|
||||
<Text style={s.sub}>
|
||||
{free ? 'Free parking' : 'Paid — pay at the kiosk or by permit'}
|
||||
{area.hours > 0 ? ` · ${fmtHours(area.hours)} posted limit` : ' · no posted time limit'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{spot ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Your car</Text>
|
||||
<Text style={s.sub}>
|
||||
Pinned at {spot.latitude.toFixed(5)}, {spot.longitude.toFixed(5)}
|
||||
</Text>
|
||||
<Text style={s.hint}>
|
||||
{spot.manual ? 'Placed by hand.' : 'From GPS.'} The pin stays on the map until you end
|
||||
the session.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isAnonymous && areaRequiresAccount(area) ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Sign in to park here</Text>
|
||||
<Text style={s.sub}>
|
||||
This is a paid city lot — parking in it is bought through ParkSmarter, so it needs
|
||||
an account. The free time-limited streets on the map don't.
|
||||
</Text>
|
||||
<TouchableOpacity style={s.primary} onPress={requireLogin}>
|
||||
<Text style={s.primaryText}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : parkedHere && active ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Tracking now</Text>
|
||||
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
||||
<Text style={s.sub}>Until {fmtClock(active.endMs)}</Text>
|
||||
<View style={s.row}>
|
||||
<TouchableOpacity
|
||||
style={s.secondary}
|
||||
onPress={async () => {
|
||||
await extendAreaParking();
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
<Text style={s.secondaryText}>+1 hour</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.danger} onPress={stop}>
|
||||
<Text style={s.primaryText}>End</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Track my time here</Text>
|
||||
<View style={s.row}>
|
||||
{options.map((h) => (
|
||||
<TouchableOpacity
|
||||
key={h}
|
||||
style={[s.pick, hours === h && { borderColor: colors.primary, borderWidth: 2 }]}
|
||||
onPress={() => setHours(h)}
|
||||
>
|
||||
<Text style={[s.pickText, hours === h && { color: colors.primary }]}>
|
||||
{h < 1 ? `${Math.round(h * 60)}m` : `${h}h`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={s.hint}>
|
||||
{area.hours > 0
|
||||
? `The sign says ${fmtHours(area.hours)}. The countdown runs on this phone only — it doesn't buy or reserve anything.`
|
||||
: "No posted limit here — pick however long you'll be. The countdown runs on this phone only."}
|
||||
</Text>
|
||||
<TouchableOpacity style={s.primary} onPress={start}>
|
||||
<Text style={s.primaryText}>Start {fmtHours(hours)} timer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
|
||||
StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: c.bg },
|
||||
content: { padding: 16, gap: 12 },
|
||||
card: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
swatch: { width: 22, height: 12, borderRadius: 3 },
|
||||
chipText: { color: c.subtext, fontSize: 13, fontWeight: '600' },
|
||||
name: { color: c.text, fontSize: 20, fontWeight: '700' },
|
||||
sub: { color: c.subtext, fontSize: 14 },
|
||||
hint: { color: c.subtext, fontSize: 12, lineHeight: 17 },
|
||||
sectionTitle: { color: c.text, fontSize: 15, fontWeight: '700' },
|
||||
big: { color: c.text, fontSize: 28, fontWeight: '700' },
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
|
||||
pick: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.bg,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
minWidth: 56,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pickText: { color: c.text, fontWeight: '600' },
|
||||
primary: {
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
primaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
secondary: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryText: { color: c.text, fontWeight: '700' },
|
||||
danger: {
|
||||
flex: 1,
|
||||
backgroundColor: c.danger,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
|
|
@ -13,7 +14,7 @@ import { useAuth } from '@/auth/AuthContext';
|
|||
import { useTheme } from '@/theme/ThemeContext';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { login, error } = useAuth();
|
||||
const { login, error, enterAnonymous } = useAuth();
|
||||
const { colors } = useTheme();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
|
@ -30,6 +31,25 @@ export function LoginScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const onAnonymous = () => {
|
||||
Alert.alert(
|
||||
'Park without signing in',
|
||||
'Works without a login:\n' +
|
||||
' • Browse parking areas on the map\n' +
|
||||
' • See free (2h/3h/4h) vs pay-immediately zones\n' +
|
||||
' • Start free-parking check-in timers with reminders\n\n' +
|
||||
'Needs a ParkSmarter login:\n' +
|
||||
' • Paying for parking\n' +
|
||||
' • Your active & past sessions\n' +
|
||||
' • Saved vehicles & payment methods\n\n' +
|
||||
'You can sign in anytime from the Account tab.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Continue', onPress: enterAnonymous },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.container, { backgroundColor: colors.bg }]}
|
||||
|
|
@ -77,6 +97,10 @@ export function LoginScreen() {
|
|||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.secondary} onPress={onAnonymous} disabled={busy}>
|
||||
<Text style={[styles.secondaryText, { color: colors.primary }]}>Park without signing in</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>
|
||||
Forgot your password? Reset it via the official app — this build reuses the same
|
||||
account.
|
||||
|
|
@ -101,5 +125,7 @@ const styles = StyleSheet.create({
|
|||
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
secondary: { padding: 14, alignItems: 'center', marginTop: 4 },
|
||||
secondaryText: { fontWeight: '700', fontSize: 15 },
|
||||
hint: { fontSize: 12, textAlign: 'center', marginTop: 16 },
|
||||
});
|
||||
|
|
|
|||
309
app/src/screens/MapAlignScreen.tsx
Normal file
309
app/src/screens/MapAlignScreen.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
ShapeSource,
|
||||
FillLayer,
|
||||
LineLayer,
|
||||
UserLocation,
|
||||
} from '@maplibre/maplibre-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import {
|
||||
applyOverlay,
|
||||
loadAreaData,
|
||||
saveOverlay,
|
||||
saveOverlayLocally,
|
||||
publishBundledAreas,
|
||||
type ParkingArea,
|
||||
} from '@/api/parkingAreas';
|
||||
import { getAdminToken } from '@/api/adminStore';
|
||||
import { IDENTITY_OVERLAY, type OverlayAdjust } from '@/features/citymap/geo';
|
||||
|
||||
const MAP_STYLE_LIGHT =
|
||||
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
|
||||
'https://tiles.openfreemap.org/styles/liberty';
|
||||
const MAP_STYLE_DARK =
|
||||
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
const SANDPOINT: [number, number] = [-116.5533, 48.2766];
|
||||
|
||||
/**
|
||||
* Align the city parking map against reality.
|
||||
*
|
||||
* The overlay was georeferenced by fitting the printed map to OpenStreetMap street
|
||||
* centrelines, which lands within a few metres — but a few metres is the difference
|
||||
* between one side of a street and the other. This is the fix for that: stand on a
|
||||
* marked block, watch the blue dot against the coloured stripe, and nudge until
|
||||
* they agree. No rebuild, and no need to re-derive the fit.
|
||||
*
|
||||
* "Save on this phone" is deliberately available without the admin token — the
|
||||
* person who can see the misalignment is the person standing on the street.
|
||||
*/
|
||||
export function MapAlignScreen() {
|
||||
const { mode, colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const cameraRef = useRef<any>(null);
|
||||
|
||||
const [base, setBase] = useState<ParkingArea[]>([]);
|
||||
const [adj, setAdj] = useState<Omit<OverlayAdjust, 'updatedAt'>>(IDENTITY_OVERLAY);
|
||||
const [step, setStep] = useState(2); // metres per nudge
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [status, setStatus] = useState('Loading the city map…');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const data = await loadAreaData();
|
||||
setBase(data.areas);
|
||||
const { updatedAt, ...rest } = data.overlay;
|
||||
setAdj(rest);
|
||||
setStatus(
|
||||
`${data.areas.length} areas (${data.source})` +
|
||||
(updatedAt ? ` · adjusted ${new Date(updatedAt).toLocaleDateString()}` : ''),
|
||||
);
|
||||
})();
|
||||
void getAdminToken().then((t) => setIsAdmin(!!t));
|
||||
}, []);
|
||||
|
||||
const preview = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: applyOverlay(base, { ...adj, updatedAt: 0 }).map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
id: a.id,
|
||||
geometry: a.geometry,
|
||||
properties: { color: a.color },
|
||||
})),
|
||||
}),
|
||||
[base, adj],
|
||||
);
|
||||
|
||||
const nudge = useCallback(
|
||||
(dx: number, dy: number) => setAdj((a) => ({ ...a, dxMeters: a.dxMeters + dx, dyMeters: a.dyMeters + dy })),
|
||||
[],
|
||||
);
|
||||
|
||||
const bump = useCallback(
|
||||
(key: 'scale' | 'rotationDeg', by: number) =>
|
||||
setAdj((a) => ({ ...a, [key]: Number((a[key] + by).toFixed(4)) })),
|
||||
[],
|
||||
);
|
||||
|
||||
const saveLocal = async () => {
|
||||
await saveOverlayLocally(adj);
|
||||
setStatus('Saved on this phone.');
|
||||
};
|
||||
|
||||
const savePublished = async () => {
|
||||
try {
|
||||
await saveOverlay(adj);
|
||||
setStatus('Published — every device will pick this up.');
|
||||
} catch (e: any) {
|
||||
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
|
||||
}
|
||||
};
|
||||
|
||||
const publishAreas = async () => {
|
||||
try {
|
||||
setStatus('Publishing bundled areas…');
|
||||
const n = await publishBundledAreas();
|
||||
setStatus(`Published ${n} areas to the server.`);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
|
||||
}
|
||||
};
|
||||
|
||||
const s = styles(colors);
|
||||
const dirty =
|
||||
adj.dxMeters !== 0 || adj.dyMeters !== 0 || adj.scale !== 1 || adj.rotationDeg !== 0;
|
||||
|
||||
return (
|
||||
<View style={s.container}>
|
||||
<MapView
|
||||
style={s.map}
|
||||
mapStyle={mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT}
|
||||
rotateEnabled={false}
|
||||
>
|
||||
<Camera ref={cameraRef} defaultSettings={{ centerCoordinate: SANDPOINT, zoomLevel: 16 }} />
|
||||
<UserLocation visible renderMode="normal" />
|
||||
<ShapeSource id="align-preview" shape={preview}>
|
||||
<FillLayer
|
||||
id="align-fills"
|
||||
filter={['==', ['geometry-type'], 'Polygon']}
|
||||
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
|
||||
/>
|
||||
<LineLayer
|
||||
id="align-lines"
|
||||
style={{
|
||||
lineColor: ['get', 'color'],
|
||||
lineWidth: ['interpolate', ['linear'], ['zoom'], 14, 3, 18, 11],
|
||||
lineOpacity: 0.95,
|
||||
lineCap: 'round',
|
||||
}}
|
||||
/>
|
||||
</ShapeSource>
|
||||
</MapView>
|
||||
|
||||
<View style={[s.status, { top: insets.top + 12 }]}>
|
||||
<Text style={s.statusText} numberOfLines={2}>
|
||||
{status}
|
||||
</Text>
|
||||
<Text style={s.readout}>
|
||||
E {adj.dxMeters.toFixed(1)} m · N {adj.dyMeters.toFixed(1)} m · ×
|
||||
{adj.scale.toFixed(3)} · {adj.rotationDeg.toFixed(2)}°
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[s.panel, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={s.padRow}>
|
||||
<View style={s.pad}>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(0, step)}>
|
||||
<Text style={s.keyText}>↑</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.padMid}>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(-step, 0)}>
|
||||
<Text style={s.keyText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.stepKey} onPress={() => setStep(step >= 8 ? 0.5 : step * 2)}>
|
||||
<Text style={s.stepText}>{step} m</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(step, 0)}>
|
||||
<Text style={s.keyText}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(0, -step)}>
|
||||
<Text style={s.keyText}>↓</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={s.fine}>
|
||||
<Text style={s.fineLabel}>Rotate</Text>
|
||||
<View style={s.fineRow}>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', -0.25)}>
|
||||
<Text style={s.keyText}>↺</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', 0.25)}>
|
||||
<Text style={s.keyText}>↻</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={s.fineLabel}>Scale</Text>
|
||||
<View style={s.fineRow}>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', -0.002)}>
|
||||
<Text style={s.keyText}>−</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', 0.002)}>
|
||||
<Text style={s.keyText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.btnRow}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, !dirty && s.btnMuted]}
|
||||
onPress={() => setAdj(IDENTITY_OVERLAY)}
|
||||
>
|
||||
<Text style={s.btnText}>Reset</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.btnPrimary} onPress={saveLocal}>
|
||||
<Text style={s.btnTextOn}>Save on this phone</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{isAdmin ? (
|
||||
<View style={s.btnRow}>
|
||||
<TouchableOpacity style={s.btn} onPress={publishAreas}>
|
||||
<Text style={s.btnText}>Publish areas</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.btnPrimary} onPress={savePublished}>
|
||||
<Text style={s.btnTextOn}>Publish alignment</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
|
||||
StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.bg },
|
||||
map: { flex: 1 },
|
||||
status: {
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 2,
|
||||
},
|
||||
statusText: { color: '#fff', fontSize: 13 },
|
||||
readout: { color: '#c9d6d2', fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||
panel: {
|
||||
backgroundColor: c.card,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
},
|
||||
padRow: { flexDirection: 'row', gap: 16, alignItems: 'center' },
|
||||
pad: { alignItems: 'center', gap: 6 },
|
||||
padMid: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
key: {
|
||||
width: 52,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
backgroundColor: c.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
keyText: { color: c.text, fontSize: 20, fontWeight: '700' },
|
||||
stepKey: {
|
||||
width: 52,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepText: { color: '#fff', fontWeight: '700', fontSize: 13 },
|
||||
fine: { flex: 1, gap: 4 },
|
||||
fineLabel: { color: c.subtext, fontSize: 12, fontWeight: '600' },
|
||||
fineRow: { flexDirection: 'row', gap: 6 },
|
||||
fineKey: {
|
||||
flex: 1,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
backgroundColor: c.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btnRow: { flexDirection: 'row', gap: 8 },
|
||||
btn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnMuted: { opacity: 0.5 },
|
||||
btnPrimary: {
|
||||
flex: 1,
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: { color: c.text, fontWeight: '700' },
|
||||
btnTextOn: { color: '#fff', fontWeight: '700' },
|
||||
});
|
||||
|
|
@ -1,19 +1,44 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
AppState,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
ShapeSource,
|
||||
CircleLayer,
|
||||
FillLayer,
|
||||
LineLayer,
|
||||
UserLocation,
|
||||
} from '@maplibre/maplibre-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { ps } from '@/api/client';
|
||||
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
|
||||
import { useLocation, type Coords } from '@/features/location/useLocation';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
||||
import {
|
||||
areaRequiresAccount,
|
||||
getAdjustedAreas,
|
||||
refreshAreas,
|
||||
type ParkingArea,
|
||||
} from '@/api/parkingAreas';
|
||||
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
|
||||
import {
|
||||
getParkedPin,
|
||||
setParkedPin,
|
||||
type ParkedSpot,
|
||||
} from '@/features/session/activeParkingStore';
|
||||
import { pinParkedSpot } from '@/features/session/activeParking';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
|
|
@ -24,12 +49,47 @@ const MAP_STYLE_DARK =
|
|||
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
// Fallback view when we have no GPS and no cached location (continental US).
|
||||
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
|
||||
const DEFAULT_ZOOM = 4;
|
||||
// Default view when we have no last-session lot: all of downtown Sandpoint, ID.
|
||||
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||
const DEFAULT_ZOOM = 14;
|
||||
|
||||
/**
|
||||
* How often to re-read the GPS while the map is the screen you're looking at.
|
||||
* A single fix at mount goes stale the moment you walk a block, which is the
|
||||
* whole time you'd be looking at this screen.
|
||||
*/
|
||||
const GPS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* How old a fix can be before "Park here" / "My location" stops trusting it and
|
||||
* goes and asks again. A poll and a half, so a fix arriving on schedule is never
|
||||
* treated as stale.
|
||||
*/
|
||||
const FIX_MAX_AGE_MS = 45_000;
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
/**
|
||||
* How far from a city-map area a parked pin can be and still be taken as "that's
|
||||
* where I am". A block is ~170 m, a street ~12 m wide; 40 m picks the right side
|
||||
* of the right street without silently matching a spot two blocks away.
|
||||
*/
|
||||
const AREA_SNAP_METERS = 40;
|
||||
|
||||
/** Nearest city-map area to a point, or null if nothing is close enough. */
|
||||
function areaAt(point: LonLat, areas: ParkingArea[]): ParkingArea | null {
|
||||
let best: ParkingArea | null = null;
|
||||
let bestDist = AREA_SNAP_METERS;
|
||||
for (const a of areas) {
|
||||
const d = distanceToGeometry(point, a.geometry);
|
||||
if (d < bestDist) {
|
||||
best = a;
|
||||
bestDist = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Coerce the server's zone color (hex string, color name, or numeric) into a usable color. */
|
||||
function normalizeColor(v: unknown): string | null {
|
||||
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
|
||||
|
|
@ -47,12 +107,33 @@ export function MapScreen() {
|
|||
const insets = useSafeAreaInsets();
|
||||
const { mode } = useTheme();
|
||||
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||
const { coords, refresh } = useLocation();
|
||||
// Poll the GPS while this screen is actually in front of someone. Focus alone
|
||||
// isn't enough: a backgrounded app stays "focused" on its last tab, and Android
|
||||
// won't give a foreground app's location out to one that isn't.
|
||||
const [focused, setFocused] = useState(true);
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (next) => setForeground(next === 'active'));
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
const { coords, updatedAt, refresh } = useLocation({
|
||||
intervalMs: GPS_REFRESH_MS,
|
||||
active: focused && foreground,
|
||||
});
|
||||
const { isAnonymous } = useAuth();
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
|
||||
|
||||
// The city's printed parking map, georeferenced. Local geography — loading and
|
||||
// tapping these never touches the ParkSmarter API.
|
||||
const [areas, setAreas] = useState<ParkingArea[]>([]);
|
||||
const [showAreas, setShowAreas] = useState(true);
|
||||
// Set while waiting for the user to tap where they parked (the GPS-less path).
|
||||
const [pinning, setPinning] = useState(false);
|
||||
const [spot, setSpot] = useState<ParkedSpot | null>(null);
|
||||
|
||||
const mapRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
|
@ -66,13 +147,36 @@ export function MapScreen() {
|
|||
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
||||
// The native UserLocation dot has its own GPS feed — capture it so "My
|
||||
// location" works even when expo-location can't get a fix (e.g. indoors).
|
||||
const nativeFix = useRef<Coords | null>(null);
|
||||
// Stamped, because that feed goes quiet whenever the map isn't drawing and a
|
||||
// silently stale fix is worse than no fix.
|
||||
const nativeFix = useRef<(Coords & { at: number }) | null>(null);
|
||||
|
||||
// Seed the initial camera target from a cached location so we don't strand at 0,0.
|
||||
// Look up the LAST session's parking-lot coordinate (the meter's own location
|
||||
// from history — never the user's GPS). Used to open the map and by "Last lot".
|
||||
const lastSessionLot = useCallback(async (): Promise<Coords | null> => {
|
||||
// No account, no session history — and asking anyway 401s, which the global
|
||||
// handler would turn into a bogus "session expired" bounce.
|
||||
if (isAnonymous) return null;
|
||||
try {
|
||||
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
|
||||
const s = past.Session?.[0] as Record<string, any> | undefined;
|
||||
const lat = Number(s?.Lat);
|
||||
const lng = Number(s?.Long);
|
||||
if (s && Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
return { latitude: lat, longitude: lng };
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}, [isAnonymous]);
|
||||
|
||||
// Open on your last parking lot — NOT your GPS. Your location is only ever sent
|
||||
// to the API when you explicitly tap "My location", so we never auto-center on it.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const last = await getLastKnownSavedLocation();
|
||||
setInitialCenter(last ?? coords ?? DEFAULT_CENTER);
|
||||
const lot = await lastSessionLot();
|
||||
setInitialCenter(lot ?? DEFAULT_CENTER);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
|
@ -89,47 +193,117 @@ export function MapScreen() {
|
|||
}
|
||||
}, [mapReady, initialCenter]);
|
||||
|
||||
const searchAt = useCallback(async (c: Coords, label: string) => {
|
||||
setLoading(true);
|
||||
setStatus(`Searching ${label}…`);
|
||||
try {
|
||||
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
||||
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
||||
setZones(found);
|
||||
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
|
||||
} catch (e: any) {
|
||||
setZones([]);
|
||||
setStatus(
|
||||
e?.status === 401
|
||||
? 'Session expired — sign in again.'
|
||||
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const searchAt = useCallback(
|
||||
async (c: Coords, label: string) => {
|
||||
setLoading(true);
|
||||
setStatus(`Searching ${label}…`);
|
||||
try {
|
||||
if (isAnonymous) {
|
||||
// No ParkSmarter login — read areas from our mirror (all of them; the
|
||||
// covered area is small). The device GPS is never sent.
|
||||
const all = await getMirrorZones();
|
||||
const found = all.filter((z) => z.Lat != null && z.Long != null);
|
||||
setZones(found);
|
||||
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
|
||||
return;
|
||||
}
|
||||
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
||||
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
||||
setZones(found);
|
||||
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
|
||||
void syncZones(found); // admin-only; no-ops otherwise
|
||||
} catch (e: any) {
|
||||
setZones([]);
|
||||
setStatus(
|
||||
e?.status === 401
|
||||
? 'Session expired — sign in again.'
|
||||
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[isAnonymous],
|
||||
);
|
||||
|
||||
// Anonymous: load the mirrored areas once on open (no ParkSmarter needed).
|
||||
useEffect(() => {
|
||||
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
|
||||
}, [isAnonymous, searchAt]);
|
||||
|
||||
// City parking map: draw the cached/bundled copy immediately so the overlay is
|
||||
// there offline, then quietly refresh from the server behind it.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const local = await getAdjustedAreas();
|
||||
if (alive) setAreas(local.areas);
|
||||
try {
|
||||
await refreshAreas();
|
||||
const fresh = await getAdjustedAreas();
|
||||
if (alive) setAreas(fresh.areas);
|
||||
} catch {
|
||||
/* offline or unseeded — the local copy is already drawn */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Search whatever the map is currently centered on (works with no GPS).
|
||||
// Re-read the pin on every focus: the session may have ended on another screen
|
||||
// (or from the notification), which clears it. Focus also gates the GPS poll.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void getParkedPin().then(setSpot);
|
||||
setFocused(true);
|
||||
return () => setFocused(false);
|
||||
}, []),
|
||||
);
|
||||
|
||||
/**
|
||||
* The freshest fix we can get, in the order it's cheapest to get it: the map's
|
||||
* own dot, then the polled expo fix, then a forced read. Whichever we hold is
|
||||
* only used if it's recent — the point of the poll is that "where am I" answers
|
||||
* with where you are now, not where you were when the screen opened.
|
||||
*/
|
||||
const bestFix = useCallback(async (): Promise<Coords | null> => {
|
||||
const now = Date.now();
|
||||
const n = nativeFix.current;
|
||||
if (n && now - n.at < FIX_MAX_AGE_MS) return { latitude: n.latitude, longitude: n.longitude };
|
||||
if (coords && now - updatedAt < FIX_MAX_AGE_MS) return coords;
|
||||
const fresh = await refresh();
|
||||
if (fresh) return fresh;
|
||||
// Nothing current and nothing new — a stale fix still beats no answer.
|
||||
return n ? { latitude: n.latitude, longitude: n.longitude } : coords;
|
||||
}, [coords, updatedAt, refresh]);
|
||||
|
||||
// Search whatever the map is currently centered on. This only ever sends the
|
||||
// map's center point — never the device GPS. (If you want to search your own
|
||||
// location, tap "My location" to center there first, then Search this area.)
|
||||
const searchThisArea = async () => {
|
||||
let center: [number, number] | null = null;
|
||||
try {
|
||||
const center = await mapRef.current?.getCenter?.(); // [lng, lat]
|
||||
if (center && center.length === 2) {
|
||||
// Commit the current view as the camera's stop so the results re-render
|
||||
// doesn't revert to the last programmatic (e.g. "My location") target.
|
||||
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
|
||||
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
|
||||
return;
|
||||
}
|
||||
const c = await mapRef.current?.getCenter?.(); // [lng, lat]
|
||||
if (Array.isArray(c) && c.length === 2) center = [c[0], c[1]];
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (coords) await searchAt(coords, 'this area');
|
||||
// Fallback is the tracked viewport center — deliberately NOT the GPS fix.
|
||||
if (!center && viewRef.current) center = viewRef.current.center;
|
||||
if (!center) {
|
||||
setStatus('Move the map, then tap “Search this area”.');
|
||||
return;
|
||||
}
|
||||
// Commit the current view as the camera's stop so the results re-render
|
||||
// doesn't revert to the last programmatic (e.g. "My location") target.
|
||||
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
|
||||
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
|
||||
};
|
||||
|
||||
// Recenter on the live GPS fix (if available) and search there. Prefer the
|
||||
// native map fix (the blue dot), then expo-location, then a forced refresh.
|
||||
// Recenter on the live GPS fix (if available) and search there.
|
||||
const goToMyLocation = async () => {
|
||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
||||
const c = await bestFix();
|
||||
if (!c) {
|
||||
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
||||
return;
|
||||
|
|
@ -142,20 +316,162 @@ export function MapScreen() {
|
|||
await searchAt(c, 'you');
|
||||
};
|
||||
|
||||
const searchLastKnown = async () => {
|
||||
const last = (await getLastKnownSavedLocation()) ?? coords;
|
||||
if (!last) {
|
||||
setStatus('No saved location yet — enable location once to cache it.');
|
||||
// Center on the LAST SESSION's parking lot (the meter's own coordinate from
|
||||
// history — never your GPS) and search around it with a ~couple-mile view.
|
||||
const searchLastSessionLot = async () => {
|
||||
if (isAnonymous) {
|
||||
setStatus('Sign in to use your last parking lot — it comes from your account history.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus('Finding your last parking lot…');
|
||||
const lot = await lastSessionLot();
|
||||
if (!lot) {
|
||||
setStatus('No past parking session with a location yet.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
cameraRef.current?.setCamera?.({
|
||||
centerCoordinate: [last.longitude, last.latitude],
|
||||
zoomLevel: 15,
|
||||
centerCoordinate: [lot.longitude, lot.latitude],
|
||||
zoomLevel: 12.5, // ~a couple miles across
|
||||
animationDuration: 600,
|
||||
});
|
||||
await searchAt(lot, 'your last lot');
|
||||
};
|
||||
|
||||
/* -------------------------------------------------- parking-map interaction */
|
||||
|
||||
/**
|
||||
* The paid city lots are ParkSmarter-only, so they're hidden from someone
|
||||
* browsing without an account — showing parking you can't actually buy is worse
|
||||
* than not showing it.
|
||||
*/
|
||||
const hidden = useCallback(
|
||||
(a: ParkingArea) => isAnonymous && areaRequiresAccount(a),
|
||||
[isAnonymous],
|
||||
);
|
||||
|
||||
/** What actually gets drawn and tapped. */
|
||||
const visibleAreas = useMemo(() => areas.filter((a) => !hidden(a)), [areas, hidden]);
|
||||
|
||||
/** Open an area, carrying the pin along if we have one. */
|
||||
const openArea = useCallback(
|
||||
(area: ParkingArea, at?: ParkedSpot) => {
|
||||
navigation.navigate('CityArea', { area, spot: at });
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
/** Drop the pin at `c`, work out which area that is, and open it. */
|
||||
const pinAt = useCallback(
|
||||
(c: Coords, manual: boolean) => {
|
||||
const at: ParkedSpot = { latitude: c.latitude, longitude: c.longitude, manual };
|
||||
setSpot(at);
|
||||
// Persist immediately — the pin is worth keeping even if you never start a
|
||||
// timer, and even if you back out of the screen we're about to open.
|
||||
void pinParkedSpot(at);
|
||||
// Detect against every area, including the ones hidden from this user, so
|
||||
// standing in a paid lot gets an explanation rather than "nothing found".
|
||||
const found = areaAt([c.longitude, c.latitude], areas);
|
||||
if (found && hidden(found)) {
|
||||
setStatus(`Pinned. ${found.name} is a paid city lot — sign in to park there.`);
|
||||
} else if (found) {
|
||||
setStatus(`Parked at ${found.name}`);
|
||||
openArea(found, at);
|
||||
} else {
|
||||
// Pin still stands — you parked somewhere, it's just not on the city map.
|
||||
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
|
||||
}
|
||||
},
|
||||
[areas, hidden, openArea],
|
||||
);
|
||||
|
||||
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
|
||||
// garage, indoors, GPS off) fall back to letting the user tap the spot — the
|
||||
// pin is the point, so it must not depend on the GPS working.
|
||||
const parkHere = async () => {
|
||||
if (pinning) {
|
||||
setPinning(false);
|
||||
setStatus('Pin cancelled.');
|
||||
return;
|
||||
}
|
||||
const c = await bestFix();
|
||||
if (!c) {
|
||||
setPinning(true);
|
||||
setStatus('No GPS fix — tap the map where you parked.');
|
||||
return;
|
||||
}
|
||||
cameraRef.current?.setCamera?.({
|
||||
centerCoordinate: [c.longitude, c.latitude],
|
||||
zoomLevel: 17,
|
||||
animationDuration: 500,
|
||||
});
|
||||
await searchAt(last, 'last location');
|
||||
pinAt(c, false);
|
||||
};
|
||||
|
||||
/** A tap on the map: places the manual pin, but only while we asked for one. */
|
||||
const onMapPress = (e: any) => {
|
||||
if (!pinning) return;
|
||||
const c = e?.geometry?.coordinates;
|
||||
if (!Array.isArray(c) || c.length !== 2) return;
|
||||
setPinning(false);
|
||||
pinAt({ latitude: c[1], longitude: c[0] }, true);
|
||||
};
|
||||
|
||||
/** A tap on your own pin: the only way to take it down without ending a session. */
|
||||
const onSpotPress = () => {
|
||||
if (!spot) return;
|
||||
Alert.alert('Your car', 'Remove the parked pin?', [
|
||||
{ text: 'Keep', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setSpot(null);
|
||||
void setParkedPin(null);
|
||||
setStatus('Pin removed.');
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
/** A tap on a coloured segment: the other start flow, no pin involved. */
|
||||
const onAreaPress = (e: any) => {
|
||||
const id = e?.features?.[0]?.properties?.id;
|
||||
const found = visibleAreas.find((a) => a.id === id);
|
||||
if (found) openArea(found, spot ?? undefined);
|
||||
};
|
||||
|
||||
const areaFeatures = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: visibleAreas.map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
id: a.id,
|
||||
geometry: a.geometry,
|
||||
properties: { id: a.id, color: a.color, kind: a.kind },
|
||||
})),
|
||||
}),
|
||||
[visibleAreas],
|
||||
);
|
||||
|
||||
const spotFeature = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: spot
|
||||
? [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
id: 'parked',
|
||||
geometry: { type: 'Point' as const, coordinates: [spot.longitude, spot.latitude] },
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
[spot],
|
||||
);
|
||||
|
||||
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
|
||||
// than React MarkerViews, which floated and thrashed the camera.
|
||||
const meterFeatures = useMemo(
|
||||
|
|
@ -196,6 +512,7 @@ export function MapScreen() {
|
|||
nativeFix.current = {
|
||||
latitude: loc.coords.latitude,
|
||||
longitude: loc.coords.longitude,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
}}
|
||||
|
|
@ -212,6 +529,7 @@ export function MapScreen() {
|
|||
mapStyle={mapStyle}
|
||||
rotateEnabled={false}
|
||||
onDidFinishLoadingMap={() => setMapReady(true)}
|
||||
onPress={onMapPress}
|
||||
onRegionDidChange={(f: any) => {
|
||||
const c = f?.geometry?.coordinates;
|
||||
const z = f?.properties?.zoomLevel;
|
||||
|
|
@ -223,6 +541,57 @@ export function MapScreen() {
|
|||
{cameraEl}
|
||||
{userLocationEl}
|
||||
|
||||
{/* The city parking map, under the meter pins so pins stay tappable. */}
|
||||
{showAreas ? (
|
||||
<ShapeSource id="city-areas" shape={areaFeatures} onPress={onAreaPress}>
|
||||
<FillLayer
|
||||
id="city-area-fills"
|
||||
filter={['==', ['geometry-type'], 'Polygon']}
|
||||
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
|
||||
/>
|
||||
<LineLayer
|
||||
id="city-area-outlines"
|
||||
filter={['==', ['geometry-type'], 'Polygon']}
|
||||
style={{ lineColor: ['get', 'color'], lineWidth: 1.5, lineOpacity: 0.9 }}
|
||||
/>
|
||||
{/* Street segments, scaled with zoom so they read as painted kerb. */}
|
||||
<LineLayer
|
||||
id="city-area-lines"
|
||||
filter={['==', ['geometry-type'], 'LineString']}
|
||||
style={{
|
||||
lineColor: ['get', 'color'],
|
||||
lineOpacity: 0.95,
|
||||
lineCap: 'round',
|
||||
lineWidth: ['interpolate', ['linear'], ['zoom'], 12, 2, 15, 5, 18, 11],
|
||||
}}
|
||||
/>
|
||||
{/* A fat, near-invisible line purely to make thin segments tappable —
|
||||
a 5 px kerb stripe is far too small a target for a fingertip. */}
|
||||
<LineLayer
|
||||
id="city-area-touch"
|
||||
filter={['==', ['geometry-type'], 'LineString']}
|
||||
style={{ lineColor: '#000000', lineOpacity: 0.01, lineWidth: 24 }}
|
||||
/>
|
||||
</ShapeSource>
|
||||
) : null}
|
||||
|
||||
{/* Where the car is. Drawn above everything — it's the thing you came back for. */}
|
||||
<ShapeSource id="parked-spot" shape={spotFeature} onPress={onSpotPress}>
|
||||
<CircleLayer
|
||||
id="parked-halo"
|
||||
style={{ circleColor: '#1e6f5c', circleOpacity: 0.25, circleRadius: 18 }}
|
||||
/>
|
||||
<CircleLayer
|
||||
id="parked-dot"
|
||||
style={{
|
||||
circleColor: '#1e6f5c',
|
||||
circleStrokeColor: '#ffffff',
|
||||
circleStrokeWidth: 3,
|
||||
circleRadius: 8,
|
||||
}}
|
||||
/>
|
||||
</ShapeSource>
|
||||
|
||||
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
|
||||
<CircleLayer
|
||||
id="meter-circles"
|
||||
|
|
@ -253,15 +622,31 @@ export function MapScreen() {
|
|||
</View>
|
||||
|
||||
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
|
||||
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
|
||||
<Text style={styles.pillText}>Search this area</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
||||
<Text style={styles.pillText}>My location</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
|
||||
<Text style={styles.pillText}>Last</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.row}>
|
||||
<TouchableOpacity
|
||||
style={pinning ? styles.pillActive : styles.pillPrimary}
|
||||
onPress={parkHere}
|
||||
>
|
||||
<Text style={styles.pillText}>{pinning ? 'Tap the map…' : 'Park here'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={showAreas ? styles.pillOn : styles.pill}
|
||||
onPress={() => setShowAreas((v) => !v)}
|
||||
>
|
||||
<Text style={styles.pillText}>City map</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<TouchableOpacity style={styles.pill} onPress={searchThisArea}>
|
||||
<Text style={styles.pillText}>Search this area</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
||||
<Text style={styles.pillText}>My location</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
|
||||
<Text style={styles.pillText}>Last lot</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -287,9 +672,10 @@ const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
bottom: 24,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
row: { flexDirection: 'row', gap: 8 },
|
||||
pill: {
|
||||
backgroundColor: '#444',
|
||||
paddingHorizontal: 14,
|
||||
|
|
@ -302,5 +688,18 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
/** Waiting for the user to tap where they parked. */
|
||||
pillActive: {
|
||||
backgroundColor: '#c07a12',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillOn: {
|
||||
backgroundColor: '#2f6f60',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillText: { color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
|
|
@ -7,6 +7,28 @@ import type { RootStackParamList } from '@/navigation/RootNavigator';
|
|||
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import type { SpacePolicy } from 'parksmarter-client';
|
||||
import { getAdminToken } from '@/api/adminStore';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import {
|
||||
deleteLabel,
|
||||
getCachedLabel,
|
||||
labelHours,
|
||||
labelText,
|
||||
refreshLabels,
|
||||
setLabel,
|
||||
type LabelKind,
|
||||
type ZoneLabel,
|
||||
} from '@/api/zoneLabels';
|
||||
import { startFreeCheckin } from '@/features/session/activeParking';
|
||||
|
||||
/** Admin labeling buttons — kind, or 'clear' to remove. */
|
||||
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
|
||||
{ label: '2h', kind: 'free_2h' },
|
||||
{ label: '3h', kind: 'free_3h' },
|
||||
{ label: '4h', kind: 'free_4h' },
|
||||
{ label: 'Pay now', kind: 'pay_immediate' },
|
||||
{ label: 'Clear', kind: 'clear' },
|
||||
];
|
||||
|
||||
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
|
||||
|
||||
|
|
@ -66,9 +88,48 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|||
export function MeterDetailScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { isAnonymous, requireLogin } = useAuth();
|
||||
const { params } = useRoute<DetailRoute>();
|
||||
const z = params.zone;
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [label, setLabelState] = useState<ZoneLabel | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [savingKind, setSavingKind] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void getAdminToken().then((t) => alive && setIsAdmin(!!t));
|
||||
void getCachedLabel(z.ZoneId).then((l) => alive && setLabelState(l));
|
||||
// Refresh from the server, then re-read this zone's label.
|
||||
void refreshLabels()
|
||||
.then(() => getCachedLabel(z.ZoneId))
|
||||
.then((l) => alive && setLabelState(l))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [z.ZoneId]);
|
||||
|
||||
const applyLabel = async (kind: LabelKind | 'clear') => {
|
||||
if (z.ZoneId == null) return;
|
||||
setSavingKind(kind);
|
||||
try {
|
||||
if (kind === 'clear') {
|
||||
await deleteLabel(z.ZoneId);
|
||||
setLabelState(null);
|
||||
} else {
|
||||
const l = await setLabel(z.ZoneId, kind, {
|
||||
zoneName: z.ZoneName ?? null,
|
||||
customerId: z.CustomerId ?? null,
|
||||
});
|
||||
setLabelState(l);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Could not save label', e?.message ?? 'error');
|
||||
} finally {
|
||||
setSavingKind(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
await saveKiosk(toSavedKiosk(z));
|
||||
|
|
@ -76,6 +137,42 @@ export function MeterDetailScreen() {
|
|||
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
||||
};
|
||||
|
||||
const labeledHours = label ? labelHours(label.kind) : null;
|
||||
|
||||
const doCheckin = async (hours: number) => {
|
||||
await startFreeCheckin(z, hours, label?.kind);
|
||||
Alert.alert(
|
||||
'Checked in',
|
||||
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
|
||||
[{ text: 'OK', onPress: () => navigation.navigate('Tabs') }],
|
||||
);
|
||||
};
|
||||
|
||||
const onPay = () => {
|
||||
if (isAnonymous) {
|
||||
Alert.alert('Sign in to pay', 'Paying for parking needs a ParkSmarter login.', [
|
||||
{ text: 'Not now', style: 'cancel' },
|
||||
{ text: 'Sign in', onPress: requireLogin },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
navigation.navigate('StartSession', { zone: z });
|
||||
};
|
||||
|
||||
// Labeled free zone → check in straight at its known limit. Unlabeled → pick.
|
||||
const onCheckin = () => {
|
||||
if (labeledHours) {
|
||||
void doCheckin(labeledHours);
|
||||
return;
|
||||
}
|
||||
Alert.alert('Free check-in', 'Start a local timer for the free limit here:', [
|
||||
{ text: '2 hours', onPress: () => doCheckin(2) },
|
||||
{ text: '3 hours', onPress: () => doCheckin(3) },
|
||||
{ text: '4 hours', onPress: () => doCheckin(4) },
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
]);
|
||||
};
|
||||
|
||||
const firstSpace = z.Spaces?.[0];
|
||||
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
|
||||
|
||||
|
|
@ -98,6 +195,17 @@ export function MeterDetailScreen() {
|
|||
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: label ? (label.kind === 'pay_immediate' ? '#8a4b00' : '#1b5e20') : colors.card },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: label ? '#fff' : colors.subtext }]}>
|
||||
{label ? labelText(label.kind) : 'Unlabeled'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Field label="Scanner code" value={z.ScannerCode} />
|
||||
<Field label="Serial" value={z.TerminalSerNo} />
|
||||
|
|
@ -136,6 +244,36 @@ export function MeterDetailScreen() {
|
|||
</View>
|
||||
) : null}
|
||||
|
||||
{isAdmin ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>Label this zone (admin)</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{LABEL_CHOICES.map((c) => {
|
||||
const active = label?.kind === c.kind;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={c.label}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: active ? colors.primary : 'transparent',
|
||||
opacity: savingKind != null && !active ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={() => applyLabel(c.kind)}
|
||||
disabled={savingKind != null}
|
||||
>
|
||||
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600' }}>
|
||||
{c.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
|
||||
onPress={onSave}
|
||||
|
|
@ -146,11 +284,20 @@ export function MeterDetailScreen() {
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={() => navigation.navigate('StartSession', { zone: z })}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
|
||||
{label?.kind === 'pay_immediate' ? (
|
||||
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately — no free window here.</Text>
|
||||
) : (
|
||||
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
|
||||
<Text style={[styles.buttonText, { color: '#fff' }]}>
|
||||
{labeledHours ? `Check in (free · ${labeledHours}h)` : 'Check in (free timer)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={[styles.button, { backgroundColor: colors.card }]} onPress={onPay}>
|
||||
<Text style={[styles.buttonText, { color: colors.text }]}>
|
||||
{isAnonymous ? 'Sign in to pay' : 'Start parking session (pay)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
|
|
@ -171,4 +318,9 @@ const styles = StyleSheet.create({
|
|||
policyRate: { fontSize: 13, fontWeight: '600' },
|
||||
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||
buttonText: { fontWeight: '600', fontSize: 16 },
|
||||
badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4 },
|
||||
badgeText: { fontSize: 13, fontWeight: '700' },
|
||||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
|
||||
note: { marginTop: 12, fontSize: 13, textAlign: 'center' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
|
|||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { sendTestReminder } from '@/notifications/localReminders';
|
||||
import { refreshParkingNotification } from '@/features/session/activeParking';
|
||||
import {
|
||||
DEFAULT_LEAD_MINUTES,
|
||||
LEAD_STEP,
|
||||
|
|
@ -10,19 +11,23 @@ import {
|
|||
MIN_LEAD_MINUTES,
|
||||
getReminderLeadMinutes,
|
||||
getRemindersEnabled,
|
||||
getCountdownEnabled,
|
||||
setReminderLeadMinutes,
|
||||
setRemindersEnabled,
|
||||
setCountdownEnabled,
|
||||
} from '@/features/notifications/reminderPrefs';
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
|
||||
const [countdown, setCountdown] = useState(true);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void getRemindersEnabled().then(setEnabled);
|
||||
void getReminderLeadMinutes().then(setLead);
|
||||
void getCountdownEnabled().then(setCountdown);
|
||||
}, []),
|
||||
);
|
||||
|
||||
|
|
@ -31,6 +36,11 @@ export function NotificationsScreen() {
|
|||
void setRemindersEnabled(v);
|
||||
};
|
||||
|
||||
const toggleCountdown = (v: boolean) => {
|
||||
setCountdown(v);
|
||||
void setCountdownEnabled(v).then(refreshParkingNotification);
|
||||
};
|
||||
|
||||
const bump = (delta: number) => {
|
||||
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
|
||||
setLead(next);
|
||||
|
|
@ -53,6 +63,21 @@ export function NotificationsScreen() {
|
|||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.card, marginTop: 12 }]}>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.rowTitle, { color: colors.text }]}>
|
||||
Active-session countdown
|
||||
</Text>
|
||||
<Text style={[styles.rowSub, { color: colors.subtext }]}>
|
||||
An ongoing notification with the time left, so you can glance without opening
|
||||
the app.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch value={countdown} onValueChange={toggleCountdown} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.section, { color: colors.subtext }]}>Remind me</Text>
|
||||
<View style={[styles.card, { backgroundColor: colors.card, opacity: enabled ? 1 : 0.4 }]}>
|
||||
<View style={styles.stepper}>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { getMirrorZones } from '@/api/zoneMirror';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -52,6 +54,7 @@ export function parseScannedCode(raw: string): { code?: string; isAppLink?: bool
|
|||
|
||||
export function ScanScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { isAnonymous } = useAuth();
|
||||
const { hasPermission, requestPermission } = useCameraPermission();
|
||||
const device = useCameraDevice('back');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -66,26 +69,67 @@ export function ScanScreen() {
|
|||
const lookupCode = useCallback(
|
||||
async (code: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
let res = await ps.getMetersByScannerCode(code);
|
||||
let zone = res.Zones?.[0];
|
||||
if (!zone) {
|
||||
res = await ps.getMetersByZoneName(code);
|
||||
// Anonymous: no ParkSmarter API — match against the mirrored areas.
|
||||
if (isAnonymous) {
|
||||
try {
|
||||
const lc = code.toLowerCase();
|
||||
const zone = (await getMirrorZones()).find(
|
||||
(z) =>
|
||||
String(z.ScannerCode ?? '').toLowerCase() === lc ||
|
||||
String(z.ZoneName ?? '').toLowerCase() === lc ||
|
||||
String(z.TerminalSerNo ?? '') === code,
|
||||
);
|
||||
if (zone) {
|
||||
setManualOpen(false);
|
||||
navigation.navigate('MeterDetail', { zone });
|
||||
} else {
|
||||
Alert.alert('Not found', `No mirrored area matches "${code}". Sign in to search live.`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Lookup failed', e?.message ?? 'Could not reach the area mirror.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A scanned/typed code can resolve by terminal serial, scanner code, or
|
||||
// zone name. Unmatched lookups return an error envelope (which the client
|
||||
// throws), and ZoneName is case-sensitive server-side — the official app
|
||||
// queries it lowercased. So try each strategy independently and don't let
|
||||
// one failing attempt abort the others.
|
||||
// ScannerCode is what kiosk QRs encode and what the official app honors;
|
||||
// zone name (case-sensitive) and terminal serial are extra fallbacks.
|
||||
const attempts = [
|
||||
() => ps.getMetersByScannerCode(code),
|
||||
() => ps.getMetersByZoneName(code),
|
||||
() => ps.getMetersByZoneName(code.toLowerCase()),
|
||||
() => ps.getMetersBySerialNumber(code),
|
||||
];
|
||||
let zone:
|
||||
| NonNullable<Awaited<ReturnType<typeof ps.getMetersByZoneName>>['Zones']>[number]
|
||||
| undefined;
|
||||
let anyResponded = false;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const res = await attempt();
|
||||
anyResponded = true;
|
||||
zone = res.Zones?.[0];
|
||||
if (zone) break;
|
||||
} catch {
|
||||
// strategy didn't apply (e.g. error envelope) — try the next one
|
||||
}
|
||||
if (zone) {
|
||||
setManualOpen(false);
|
||||
navigation.navigate('MeterDetail', { zone });
|
||||
} else {
|
||||
Alert.alert('Not found', `No meter matches "${code}".`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Lookup failed', e?.serverMessage ?? e?.message ?? 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
if (zone) {
|
||||
setManualOpen(false);
|
||||
navigation.navigate('MeterDetail', { zone });
|
||||
} else if (anyResponded) {
|
||||
Alert.alert('Not found', `No meter matches "${code}".`);
|
||||
} else {
|
||||
Alert.alert('Lookup failed', 'Could not reach ParkSmarter. Check your connection.');
|
||||
}
|
||||
},
|
||||
[navigation],
|
||||
[navigation, isAnonymous],
|
||||
);
|
||||
|
||||
const onScanned = useCallback(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ type DetailRoute = RouteProp<RootStackParamList, 'SessionDetail'>;
|
|||
const show = (v: unknown) => (v == null || v === '' ? '—' : String(v));
|
||||
const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`);
|
||||
|
||||
/** API's TimeRemaining is in SECONDS — render it as a human "Xh Ym". */
|
||||
const fmtRemaining = (secs: unknown): string | undefined => {
|
||||
const n = Number(secs);
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
const total = Math.round(n / 60);
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
|
||||
};
|
||||
|
||||
export function SessionDetailScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { session, kind } = useRoute<DetailRoute>().params;
|
||||
|
|
@ -90,7 +100,7 @@ export function SessionDetailScreen() {
|
|||
['Space', s.SpaceName ?? s.Space],
|
||||
['Started', s.StartTimeDisplay ?? s.StartTime],
|
||||
['Ends', s.EndTimeDisplay ?? s.EndTime],
|
||||
['Time remaining', s.TimeRemaining != null ? `${s.TimeRemaining} min` : undefined],
|
||||
['Time remaining', fmtRemaining(s.TimeRemaining)],
|
||||
['Amount', dollars(s.Amount)],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,72 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { endActiveParking, extendAreaParking } from '@/features/session/activeParking';
|
||||
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
||||
import {
|
||||
getLocalHistory,
|
||||
isLocalOnly,
|
||||
type LocalSessionRecord,
|
||||
} from '@/features/session/localHistory';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtDate(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function fmtRemaining(ms: number): string {
|
||||
const mins = Math.max(0, Math.round(ms / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
function fmtSpan(from: number, to: number): string {
|
||||
const mins = Math.max(0, Math.round((to - from) / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions, in two halves that must not depend on each other.
|
||||
*
|
||||
* Anything tracked on this phone — a city-map timer, a free check-in — is shown
|
||||
* and managed with no network and no account, because that is the only place it
|
||||
* exists. ParkSmarter's own sessions are layered on top when signed in, and a
|
||||
* failure to reach them (offline, or simply not logged in) must never hide the
|
||||
* local half.
|
||||
*/
|
||||
export function SessionsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { isAnonymous, requireLogin } = useAuth();
|
||||
|
||||
const [local, setLocal] = useState<ActiveParking | null>(null);
|
||||
const [history, setHistory] = useState<LocalSessionRecord[]>([]);
|
||||
const [active, setActive] = useState<ActiveSession[]>([]);
|
||||
const [past, setPast] = useState<PastSession[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
/** On-device only. Never awaits the network, so it works offline and signed out. */
|
||||
const loadLocal = useCallback(async () => {
|
||||
const [a, h] = await Promise.all([getActiveParking(), getLocalHistory()]);
|
||||
setLocal(a && a.endMs > Date.now() ? a : null);
|
||||
setHistory(h);
|
||||
}, []);
|
||||
|
||||
const loadRemote = useCallback(async () => {
|
||||
if (isAnonymous) return;
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
ps.getActiveParkingSessions(),
|
||||
|
|
@ -25,10 +74,22 @@ export function SessionsScreen() {
|
|||
]);
|
||||
setActive(a.ParkingSession ?? []);
|
||||
setPast(p.Session ?? []);
|
||||
setRemoteError(null);
|
||||
} catch (e: any) {
|
||||
// Offline or the API is unhappy. Say so quietly and keep the local half.
|
||||
setRemoteError(e?.serverMessage ?? e?.message ?? 'Could not reach ParkSmarter.');
|
||||
}
|
||||
}, [isAnonymous]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadLocal();
|
||||
await loadRemote();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadLocal, loadRemote]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
|
@ -36,48 +97,159 @@ export function SessionsScreen() {
|
|||
}, [load]),
|
||||
);
|
||||
|
||||
// Keep "time left" honest while the screen sits open.
|
||||
useEffect(() => {
|
||||
if (!local) return;
|
||||
const id = setInterval(() => void loadLocal(), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [local, loadLocal]);
|
||||
|
||||
const s = styles;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ backgroundColor: colors.bg }}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||
>
|
||||
<Text style={[styles.header, { color: colors.text }]}>Active</Text>
|
||||
{active.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||
<Text style={[s.header, { color: colors.text }]}>Tracking on this phone</Text>
|
||||
{local ? (
|
||||
<View style={[s.card, { backgroundColor: colors.primary + '22' }]}>
|
||||
<View style={s.chipRow}>
|
||||
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
|
||||
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
|
||||
</View>
|
||||
<Text style={[s.big, { color: colors.text }]}>
|
||||
{fmtRemaining(local.endMs - Date.now())} left
|
||||
</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{local.area?.legend ? `${local.area.legend} · ` : ''}
|
||||
{local.kind === 'free' ? 'Free' : 'Paid'} until {fmtClock(local.endMs)}
|
||||
{local.spot ? (local.spot.manual ? ' · pin placed by hand' : ' · pinned from GPS') : ''}
|
||||
</Text>
|
||||
<View style={s.row}>
|
||||
{/* Same split as the notification's second button: a local timer can be
|
||||
nudged for free, but a bought session can only be extended by buying
|
||||
more, so that one goes to the purchase screen instead of lying. */}
|
||||
{isLocalOnly(local) ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, { borderColor: colors.border }]}
|
||||
onPress={async () => {
|
||||
await extendAreaParking();
|
||||
await loadLocal();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: '700' }}>+1 hour</Text>
|
||||
</TouchableOpacity>
|
||||
) : local.zone ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('StartSession', { zone: local.zone! })}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: '700' }}>Extend</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={[s.btnFilled, { backgroundColor: colors.danger }]}
|
||||
onPress={async () => {
|
||||
await endActiveParking();
|
||||
await loadLocal();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>End</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[s.hint, { color: colors.subtext }]}>
|
||||
{isLocalOnly(local)
|
||||
? 'Works offline — this timer lives on your phone, not on a server.'
|
||||
: 'Ending stops the countdown here. Time you bought keeps running at the meter.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
active.map((s, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
|
||||
>
|
||||
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} › tap for details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>
|
||||
Nothing being tracked. Start one from the map — “Park here”, or tap a coloured block.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
|
||||
{past.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||
) : (
|
||||
past.map((s, i) => (
|
||||
{history.length > 0 ? (
|
||||
<>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Recent on this phone</Text>
|
||||
{history.map((r) => (
|
||||
<View key={r.id} style={[s.card, { backgroundColor: colors.card }]}>
|
||||
<View style={s.chipRow}>
|
||||
{r.color ? <View style={[s.swatch, { backgroundColor: r.color }]} /> : null}
|
||||
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{r.zoneName}</Text>
|
||||
</View>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{fmtDate(r.startMs)} · {fmtClock(r.startMs)}–{fmtClock(r.endedAtMs)} ·{' '}
|
||||
{fmtSpan(r.startMs, r.endedAtMs)}
|
||||
{r.endedEarly ? ' · ended early' : ' · ran out'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isAnonymous ? (
|
||||
<View style={[s.card, { backgroundColor: colors.card, marginTop: 20 }]}>
|
||||
<Text style={[s.zone, { color: colors.text }]}>Paid ParkSmarter sessions</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext, marginBottom: 10 }]}>
|
||||
Sessions you bought live in your ParkSmarter account. Sign in to see them here —
|
||||
everything above stays on this phone either way.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.card, { backgroundColor: colors.card }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
|
||||
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
|
||||
onPress={requireLogin}
|
||||
>
|
||||
<Text style={[styles.zone, { color: colors.text }]}>
|
||||
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
|
||||
</Text>
|
||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} › tap for receipt
|
||||
</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
|
||||
{remoteError ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
|
||||
) : active.length === 0 ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||
) : (
|
||||
active.map((sess, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[s.card, { backgroundColor: colors.primary + '22' }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'active' })}
|
||||
>
|
||||
<Text style={[s.zone, { color: colors.text }]}>{sess.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{sess.SpaceName ?? sess.Space ?? ''} · ends{' '}
|
||||
{sess.EndTimeDisplay ?? sess.EndTime ?? ''} › tap for details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>History (ParkSmarter)</Text>
|
||||
{remoteError ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>Unavailable offline.</Text>
|
||||
) : past.length === 0 ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||
) : (
|
||||
past.map((sess, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[s.card, { backgroundColor: colors.card }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
|
||||
>
|
||||
<Text style={[s.zone, { color: colors.text }]}>
|
||||
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
|
||||
</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{sess.StartTime ?? ''} · {sess.Amount != null ? `$${sess.Amount}` : ''} › tap for
|
||||
receipt
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
|
@ -86,7 +258,20 @@ export function SessionsScreen() {
|
|||
const styles = StyleSheet.create({
|
||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
empty: { marginBottom: 8 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, gap: 4 },
|
||||
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
swatch: { width: 20, height: 11, borderRadius: 3 },
|
||||
zone: { fontSize: 16, fontWeight: '600' },
|
||||
big: { fontSize: 26, fontWeight: '700' },
|
||||
meta: { fontSize: 13, marginTop: 2 },
|
||||
hint: { fontSize: 12, marginTop: 6 },
|
||||
row: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
btn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnFilled: { flex: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import { useNavigation, useRoute } from '@react-navigation/native';
|
|||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
||||
import { parseApiTime } from '@/api/parseTime';
|
||||
import { startPaidSession } from '@/features/session/activeParking';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import {
|
||||
|
|
@ -105,18 +106,6 @@ export async function buildSingleLadder(base: {
|
|||
return { free: false, flat: false, ladder };
|
||||
}
|
||||
|
||||
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
|
||||
function parseApiTime(s?: string): Date | null {
|
||||
if (!s) return null;
|
||||
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
||||
if (!m) return null;
|
||||
let hr = parseInt(m[4], 10);
|
||||
const pm = /pm/i.test(m[6]);
|
||||
if (pm && hr !== 12) hr += 12;
|
||||
if (!pm && hr === 12) hr = 0;
|
||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
|
||||
}
|
||||
|
||||
export function StartSessionScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
|
|
@ -273,14 +262,18 @@ export function StartSessionScreen() {
|
|||
minCreditAmount: zone.MinimumAmount,
|
||||
meterTypeId: zone.MeterTypeId!,
|
||||
});
|
||||
// Schedule the local expiry reminder from the purchased end time.
|
||||
// Becomes the one active parking session, replacing any free check-in (and,
|
||||
// when this purchase is an extension, the session it extends). That posts the
|
||||
// ongoing countdown notification and schedules the expiry reminder.
|
||||
const end = parseApiTime(selected.EndTime);
|
||||
if (end) {
|
||||
await scheduleExpiryReminder({
|
||||
transactionId: (res as any)?.TransactionID ?? Date.now(),
|
||||
zoneName: zone.ZoneName ?? 'Parking',
|
||||
await startPaidSession({
|
||||
zone,
|
||||
endTime: end,
|
||||
transactionId: (res as any)?.TransactionID,
|
||||
});
|
||||
} else {
|
||||
logLine(`[SESSION] no countdown: unparseable EndTime "${selected.EndTime}"`);
|
||||
}
|
||||
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
||||
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||
|
|
|
|||
152
app/test/geo.test.ts
Normal file
152
app/test/geo.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
adjustGeometry,
|
||||
distanceMeters,
|
||||
distanceToGeometry,
|
||||
overlayAnchor,
|
||||
IDENTITY_OVERLAY,
|
||||
type AreaGeometry,
|
||||
type LonLat,
|
||||
} from '../src/features/citymap/geo';
|
||||
import bundled from '../src/features/citymap/parkingAreas.json';
|
||||
|
||||
/**
|
||||
* The city-map geometry, checked against the real bundled area set.
|
||||
*
|
||||
* This is the maths that decides which street you are tracking time on, so it is
|
||||
* tested against the actual 49 areas rather than toy shapes — the awkward cases
|
||||
* (an L-shaped run down two streets, a crescent-shaped beach lot) only exist in
|
||||
* the real data.
|
||||
*
|
||||
* Run with: npm test --workspace app
|
||||
*/
|
||||
|
||||
interface Area {
|
||||
id: string;
|
||||
shape: 'line' | 'polygon';
|
||||
geometry: AreaGeometry;
|
||||
}
|
||||
|
||||
const areas: Area[] = (bundled as any).features.map((f: any) => ({
|
||||
...f.properties,
|
||||
geometry: f.geometry,
|
||||
}));
|
||||
const geoms: AreaGeometry[] = areas.map((a) => a.geometry);
|
||||
|
||||
/**
|
||||
* Points that genuinely lie on an area: edge midpoints. A centroid is no good —
|
||||
* an L-shaped run's lands mid-block and the crescent City Beach lot's lands in
|
||||
* the water.
|
||||
*/
|
||||
function onGeometry(g: AreaGeometry): LonLat[] {
|
||||
const rings = g.type === 'Polygon' ? g.coordinates : [g.coordinates];
|
||||
const out: LonLat[] = [];
|
||||
for (const r of rings) {
|
||||
for (let i = 1; i < r.length; i++) {
|
||||
out.push([(r[i - 1][0] + r[i][0]) / 2, (r[i - 1][1] + r[i][1]) / 2]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nearest(p: LonLat): { area: Area; dist: number } {
|
||||
let best = areas[0];
|
||||
let bd = Infinity;
|
||||
for (const a of areas) {
|
||||
const d = distanceToGeometry(p, a.geometry);
|
||||
if (d < bd) {
|
||||
best = a;
|
||||
bd = d;
|
||||
}
|
||||
}
|
||||
return { area: best, dist: bd };
|
||||
}
|
||||
|
||||
test('the bundled map has the expected shape', () => {
|
||||
assert.equal(areas.length, 49);
|
||||
assert.ok(areas.some((a) => a.shape === 'polygon'), 'city lots should be polygons');
|
||||
assert.ok(areas.some((a) => a.shape === 'line'), 'on-street runs should be lines');
|
||||
});
|
||||
|
||||
test('a point on an area measures zero distance to it', () => {
|
||||
for (const a of areas) {
|
||||
for (const p of onGeometry(a.geometry)) {
|
||||
const d = distanceToGeometry(p, a.geometry);
|
||||
assert.ok(d < 0.01, `${a.id}: a point on it measured ${d.toFixed(3)} m away`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('hit-testing resolves each area from points on it', () => {
|
||||
for (const a of areas) {
|
||||
for (const p of onGeometry(a.geometry)) {
|
||||
const hit = nearest(p);
|
||||
if (hit.area.id === a.id) continue;
|
||||
// Categories meet at intersections, so an exact tie is acceptable; silently
|
||||
// resolving to something FURTHER away is the bug this guards against.
|
||||
assert.ok(
|
||||
hit.dist < 0.01,
|
||||
`${a.id}: a point on it resolved to ${hit.area.id} at ${hit.dist.toFixed(2)} m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a point off the map does not snap to an area', () => {
|
||||
// East of the highway, across Sand Creek — no mapped parking anywhere near.
|
||||
assert.ok(nearest([-116.5445, 48.2705]).dist > 40);
|
||||
});
|
||||
|
||||
test('the identity overlay is a no-op', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
for (const g of geoms) assert.deepEqual(adjustGeometry(g, IDENTITY_OVERLAY, anchor), g);
|
||||
});
|
||||
|
||||
test('a shift moves every vertex by exactly that distance', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
for (const g of geoms) {
|
||||
const moved = adjustGeometry(g, { ...IDENTITY_OVERLAY, dxMeters: 10 }, anchor);
|
||||
const a = g.type === 'Polygon' ? g.coordinates[0] : g.coordinates;
|
||||
const b = moved.type === 'Polygon' ? moved.coordinates[0] : moved.coordinates;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
assert.ok(Math.abs(distanceMeters(a[i], b[i]) - 10) < 0.05, 'shift distance');
|
||||
assert.ok(b[i][0] > a[i][0], 'positive dxMeters must move east');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('rotation is rigid about the anchor and 360° returns home', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
const g = geoms.find((x) => x.type === 'LineString') as Extract<
|
||||
AreaGeometry,
|
||||
{ type: 'LineString' }
|
||||
>;
|
||||
|
||||
const spun = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 360 }, anchor) as typeof g;
|
||||
for (let i = 0; i < g.coordinates.length; i++) {
|
||||
assert.ok(distanceMeters(g.coordinates[i], spun.coordinates[i]) < 0.01, '360° round trip');
|
||||
}
|
||||
|
||||
const rot = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 5 }, anchor) as typeof g;
|
||||
for (let i = 0; i < g.coordinates.length; i++) {
|
||||
const r0 = distanceMeters(anchor, g.coordinates[i]);
|
||||
const r1 = distanceMeters(anchor, rot.coordinates[i]);
|
||||
assert.ok(Math.abs(r0 - r1) < 0.5, `rotation changed radius ${r0.toFixed(1)} -> ${r1.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('scale is anchored and proportional', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
const far = (geoms.find((x) => x.type === 'LineString') as any).coordinates[0] as LonLat;
|
||||
const scaled = adjustGeometry(
|
||||
{ type: 'LineString', coordinates: [anchor, far] },
|
||||
{ ...IDENTITY_OVERLAY, scale: 2 },
|
||||
anchor,
|
||||
) as Extract<AreaGeometry, { type: 'LineString' }>;
|
||||
|
||||
assert.ok(distanceMeters(scaled.coordinates[0], anchor) < 0.01, 'the anchor must not move');
|
||||
const before = distanceMeters(anchor, far);
|
||||
const after = distanceMeters(anchor, scaled.coordinates[1]);
|
||||
assert.ok(Math.abs(after - 2 * before) < 0.5, `×2: ${before.toFixed(1)} -> ${after.toFixed(1)}`);
|
||||
});
|
||||
156
docs/OFFICIAL_APP_PRIVACY.md
Normal file
156
docs/OFFICIAL_APP_PRIVACY.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# What the official ParkSmarter app does with your privacy
|
||||
|
||||
*A plain-language look at the official **ParkSmarter** app for Android
|
||||
(`com.ipsgroupinc.parksmarter`, version 4.4.0), and how **BigBrainParking** — this
|
||||
open-source alternative — is different.*
|
||||
|
||||
This was written by taking the official app apart: reading its program code, and
|
||||
watching its real network traffic through a proxy. Where we're **sure**, we say
|
||||
so; where something is situational, we say that too. (For the technical teardown
|
||||
with diagrams and evidence, see
|
||||
[OFFICIAL_APP_REVERSE_ENGINEERING.md](OFFICIAL_APP_REVERSE_ENGINEERING.md).)
|
||||
|
||||
---
|
||||
|
||||
## The short version
|
||||
|
||||
- **Your exact location is sent to ParkSmarter's servers when you use the map**
|
||||
to find meters — and the app is built to do this **automatically** when the map
|
||||
opens, not only when you ask it to "find parking near me."
|
||||
- **The app carries one heavy tracking tool — Instabug** — that can record your
|
||||
screen, take screenshots, and log the app's network activity, reporting to an
|
||||
outside company.
|
||||
- **It sends a permanent per-device ID to ParkSmarter.** It is *not* your phone's
|
||||
IMEI (despite the app calling the field `IMEINumber`) — it's Android's device
|
||||
ID. Still a fixed code that identifies your specific phone to ParkSmarter.
|
||||
- **Good news:** it **cannot** track your location while the app is closed.
|
||||
- **BigBrainParking removes the tracking entirely** and only sends your location
|
||||
when *you* deliberately tap "My location" and search.
|
||||
|
||||
---
|
||||
|
||||
## First, why does this matter?
|
||||
|
||||
"Location data" is a record of where you physically are. On its own, one location
|
||||
isn't a big deal. But a *stream* of them — collected quietly and often — reveals
|
||||
where you live, work, worship, and who you visit. Tie that to a permanent ID for
|
||||
your phone and it becomes a profile of you that can be sold, leaked, or
|
||||
subpoenaed. So the questions that matter are: **who collects it, how often, is it
|
||||
tied to your identity, and does it leave in ways you didn't ask for?**
|
||||
|
||||
---
|
||||
|
||||
## When does the official app send your location to ParkSmarter?
|
||||
|
||||
**To ParkSmarter's own servers, your GPS is used for one thing: finding nearby
|
||||
meters.** We confirmed this by watching the traffic: the coordinates go out as a
|
||||
`GET …/api/Meter?Lat=…&Long=…` request, and your location is **not** attached to
|
||||
logging in, starting a session, or paying. That part is reasonable.
|
||||
|
||||
**The catch is *when*.** The app is built to grab your live GPS and ask "what
|
||||
meters are near me?" **automatically when the map opens** — no tap required — as
|
||||
long as you're logged in and have granted location permission. So simply browsing
|
||||
the map quietly sends your precise location to ParkSmarter.
|
||||
|
||||
> **One nuance we saw firsthand:** on a de-Googled phone (GrapheneOS), the map
|
||||
> itself doesn't draw (it needs Google services), which can suppress that
|
||||
> automatic send. On a normal Google phone the auto-send happens as designed.
|
||||
|
||||
**BigBrainParking does the opposite on purpose:** it opens on your **last parking
|
||||
lot** (from your own history, not your GPS), and it only sends your location when
|
||||
you *explicitly* tap **"My location"** and then search.
|
||||
|
||||
---
|
||||
|
||||
## The bigger privacy concerns
|
||||
|
||||
### 1. A heavy tracking tool: Instabug
|
||||
|
||||
The app bundles **Instabug**, a monitoring/bug-reporting toolkit — and it's the
|
||||
serious one. It's capable of:
|
||||
|
||||
- **recording your session / replaying what you did**,
|
||||
- **taking screenshots** of the app,
|
||||
- **logging the app's network requests**,
|
||||
- **tracking your taps and the steps you take** through the app.
|
||||
|
||||
All of this reports to an outside company's servers (`api.instabug.com`). We saw
|
||||
it phone home the moment the app started.
|
||||
|
||||
> **What it is *not*:** we specifically checked and the app does **not** contain
|
||||
> Segment, Amplitude, Sentry, Google Analytics/Firebase-Analytics, an advertising
|
||||
> ID, or Play Store install-tracking. (An earlier version of this note listed
|
||||
> those — that was wrong; Instabug is the actual story.) Google is present only
|
||||
> for **push notifications**.
|
||||
|
||||
> **BigBrainParking has none of this.** No Instabug, no analytics, no ad-ID, no
|
||||
> Google services at all.
|
||||
|
||||
### 2. A permanent device ID sent to ParkSmarter
|
||||
|
||||
When you log in, the app sends ParkSmarter a fixed identifier for your phone,
|
||||
inside a request to `…/api/Device`. The field is **named `IMEINumber`**, which is
|
||||
misleading — **it is not your IMEI**. Modern Android forbids apps from reading the
|
||||
IMEI at all, and this app never tries. The value it actually sends is your
|
||||
phone's **Android device ID** (a code assigned to your device). Unlike a password,
|
||||
it doesn't change, so it can be used to recognize your specific phone over time.
|
||||
It goes only to ParkSmarter, not to a third party.
|
||||
|
||||
> **BigBrainParking never reads or sends any device ID.**
|
||||
|
||||
### 3. Other capabilities worth knowing about
|
||||
|
||||
- **Microphone & camera permissions** — camera is for scanning kiosk QR codes;
|
||||
microphone is unusual for a parking app (may be an unused library leftover).
|
||||
- **Wi-Fi & phone-state access** — used by a networking library to report your
|
||||
connection type and carrier name; it does **not** read your IMEI.
|
||||
- **Bluetooth** — for talking to parking meters directly.
|
||||
- **Google push messaging (FCM)** — notifications route through Google.
|
||||
|
||||
---
|
||||
|
||||
## To be fair — what it does *not* do
|
||||
|
||||
- **No background location tracking.** The app has the everyday location
|
||||
permission but **not** the "all the time / background" one, so it can't follow
|
||||
you when it's closed.
|
||||
- **No IMEI, no advertising ID, no Segment/Amplitude/Sentry.**
|
||||
- Your location going to ParkSmarter is genuinely limited to the "find nearby
|
||||
meters" feature — it isn't stapled onto payments or your account details.
|
||||
|
||||
---
|
||||
|
||||
## Side-by-side
|
||||
|
||||
| | Official ParkSmarter app | BigBrainParking |
|
||||
| --- | --- | --- |
|
||||
| Sends GPS to ParkSmarter | **Automatically** when the map opens | **Only** when you tap "My location" and search |
|
||||
| Opens the map on… | Your current GPS location | Your **last parking lot** (not your GPS) |
|
||||
| Third-party tracking SDKs | **Instabug** (session replay, screenshots, network logs) | **None** |
|
||||
| Advertising ID / cross-app tracking | **No** (none present) | **Never** |
|
||||
| Sends a permanent device ID | **Yes** — Android device ID, sent as `IMEINumber` | **Never** |
|
||||
| Reads your hardware IMEI | **No** (not possible on modern Android) | **Never** |
|
||||
| Google services required | Yes (push notifications) | **None** (runs on GrapheneOS) |
|
||||
| Background location | No | No |
|
||||
| Source code you can inspect | No (closed) | **Yes** (this repo) |
|
||||
|
||||
---
|
||||
|
||||
## How we checked (for the curious)
|
||||
|
||||
- **Permissions** come from the app's own `AndroidManifest`. Confirmed present:
|
||||
precise + approximate location, read-phone-state, Wi-Fi, microphone, camera,
|
||||
Bluetooth, Google push. Confirmed **absent**: background location.
|
||||
- **The device-ID and location behavior** were read from the app's decompiled
|
||||
code (it's a React Native / Expo app, so the logic is in a JavaScript bundle)
|
||||
**and confirmed on the wire** with a live proxy capture: the `…/api/Device`
|
||||
request carrying the Android ID as `IMEINumber`, and the `…/api/Meter?Lat=…`
|
||||
request carrying GPS.
|
||||
- **The SDK list** (Instabug present; Segment/Amplitude/Sentry/Firebase-Analytics/
|
||||
ad-ID absent) comes from searching the app's bundled code and its network
|
||||
endpoints.
|
||||
|
||||
*Not affiliated with or endorsed by IPS Group / ParkSmarter. This is an
|
||||
independent, good-faith analysis of a publicly distributed app for the purpose of
|
||||
building a privacy-respecting alternative. Findings reflect version 4.4.0 and
|
||||
could change in later versions.*
|
||||
277
docs/OFFICIAL_APP_REVERSE_ENGINEERING.md
Normal file
277
docs/OFFICIAL_APP_REVERSE_ENGINEERING.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# Reverse-engineering the official ParkSmarter app
|
||||
|
||||
A technical teardown of the official **Park Smarter** Android app
|
||||
(`com.ipsgroupinc.parksmarter`, **v4.4.0**, versionCode 170), focused on **what
|
||||
data leaves the device, when, and to whom** — with special attention to the
|
||||
**device identifier** and **GPS location**. Combines static decompilation with a
|
||||
live man-in-the-middle capture.
|
||||
|
||||
> Independent, good-faith security/privacy research on a publicly distributed
|
||||
> app, for the purpose of building a privacy-respecting alternative
|
||||
> (BigBrainParking). Not affiliated with or endorsed by IPS Group. Findings
|
||||
> reflect v4.4.0. The full decompiled source is kept in a **private** mirror; this
|
||||
> report quotes only the excerpts needed as evidence.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
| Claim | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| The app reads your hardware **IMEI** | **False** | No `TelephonyManager.getImei/getDeviceId` anywhere; `react-native-device-info` isn't even bundled; and Android 10+ blocks IMEI for normal apps regardless. |
|
||||
| The app sends a **persistent device ID** to its backend | **True** | `PUT /api/Device` carries the **Android SSAID** in a field misleadingly named `IMEINumber`, plus the push token as `DeviceID`. |
|
||||
| The app sends your **GPS** to its servers | **True**, for meter search only | `GET /api/Meter?Lat=…&Long=…`; captured live. Not attached to login/session/payment. |
|
||||
| GPS is sent **automatically** on map open | **True in code**, situational at runtime | Wired to map-ready when authenticated + permission granted; on GrapheneOS the map fails to render (needs Play Services), which suppresses the auto-fetch. |
|
||||
| Bundles Segment / Amplitude / Firebase-Analytics / Sentry | **False** | None present. |
|
||||
| Bundles heavy telemetry | **True** | **Instabug** — session replay, screenshot capture, network-request logging, visual user-steps → `api.instabug.com`. |
|
||||
| Advertising ID / install-referrer | **False** | Neither is present. |
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
Both static and dynamic analysis were used.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Park+Smarter_4.4.0.xapk<br/>(base + 3 config splits)"] --> B[jadx 1.5.1<br/>dex → Java]
|
||||
A --> C["hermes-dec<br/>index.android.bundle → JS"]
|
||||
A --> D[apktool 2.10.0<br/>manifest + resources]
|
||||
A --> E["APKEditor merge → 1 universal APK<br/>+ apk-mitm (trust user CA)"]
|
||||
E --> F["mitmproxy on device<br/>(live HTTPS capture)"]
|
||||
B --> G[(Findings)]
|
||||
C --> G
|
||||
D --> G
|
||||
F --> G
|
||||
```
|
||||
|
||||
- **jadx** decompiled the dex → Java. This is only the RN framework + native
|
||||
modules + SDKs; the app's own logic is **not** here.
|
||||
- The app is **React Native / Expo SDK 53**, so its business logic ships as
|
||||
**Hermes bytecode** (`assets/index.android.bundle`, Hermes v96). **hermes-dec**
|
||||
recovered readable pseudocode (`decomp.js`) with de-obfuscated function names.
|
||||
- **apktool** decoded the manifest and resources.
|
||||
- For the live capture, the split APKs were merged into one universal APK
|
||||
(APKEditor) and repackaged to trust a user CA (`network_security_config`), then
|
||||
driven through **mitmproxy**. (Merging first was necessary: patching only the
|
||||
base split renumbers its resource IDs and desyncs the untouched `config.hdpi`
|
||||
split, which crashes the app on the first text field.)
|
||||
|
||||
---
|
||||
|
||||
## App architecture & network model
|
||||
|
||||
Expo SDK 53 RN app. All API traffic goes through one request builder that stamps
|
||||
these headers on **every** call:
|
||||
|
||||
| Header | Value | Notes |
|
||||
|---|---|---|
|
||||
| `Application_Token` | `B66EEDDA-…` (static) | App-wide, embedded in the bundle |
|
||||
| `X-Request-Id` | random UUID | Per request |
|
||||
| `Auth_Token` | rolling | Rotates on each response |
|
||||
| `ParkSmarter_SessionId` | per session | |
|
||||
| `Content-Type` | `application/json` | POST/PUT only |
|
||||
| `User-Agent` | `okhttp/4.12.0` | RN's HTTP stack |
|
||||
|
||||
Five hard-coded environments (bundle string literals):
|
||||
|
||||
- Prod: `https://apiv2.parksmarter.com`, `https://apiv3.parksmarter.com`
|
||||
- Staging: `https://staging-parksmarter-api.ipsmeters.com`
|
||||
- Testing: `https://testing-parksmarter-api.ipsmeters.com`
|
||||
|
||||
No TLS pinning is configured (okhttp's `CertificatePinner` class is present but no
|
||||
pins are set), and the manifest declares no `networkSecurityConfig` — which is why
|
||||
a stock install can't be MITM'd without repackaging.
|
||||
|
||||
---
|
||||
|
||||
## Finding 1 — Device identifier: the "IMEINumber" that isn't an IMEI
|
||||
|
||||
The app defines a Redux thunk literally named `device/setIMEINumberAsyncThunk`.
|
||||
Despite the name, it **never reads an IMEI**. It reads the **Android SSAID**
|
||||
(`Application.androidId` from `expo-application`), caches it in secure storage as
|
||||
`psUUID`, and later transmits it.
|
||||
|
||||
Evidence (`decomp.js`):
|
||||
|
||||
```js
|
||||
// getAndroidId → expo-application's Application.androidId (the SSAID)
|
||||
r0 = r0.default; r0 = r0.androidId; return r0;
|
||||
// stored under 'psUUID'
|
||||
r2 = 'psUUID'; r2 = setObjectAsync.bind(...)('psUUID', androidId);
|
||||
// later placed on the wire:
|
||||
r2['DeviceID'] = pushNotificationsToken;
|
||||
r2['IMEINumber'] = imeiNumber; // === Application.androidId (SSAID)
|
||||
r2['DeviceType'] = '1';
|
||||
// endpoint: putUpdateDeviceToken → { url: '/api/Device' }
|
||||
```
|
||||
|
||||
Confirmations:
|
||||
|
||||
- **No real IMEI read.** Zero `getImei` / `TelephonyManager.getDeviceId` /
|
||||
`getSubscriberId` / `getSimSerialNumber` in the Java **or** the bundle. Every
|
||||
`getDeviceId()` match in the Java is unrelated — `MotionEvent`/`KeyEvent`
|
||||
device IDs (input routing) or `Context.getDeviceId()` (the API-34 *virtual*
|
||||
device id, an `int`). `react-native-device-info` (which could read IMEI) isn't
|
||||
bundled at all.
|
||||
- **`READ_PHONE_STATE`** comes from `@react-native-community/netinfo`, which uses
|
||||
`TelephonyManager` only for `getNetworkOperatorName()` (carrier name) and
|
||||
network type — not identity.
|
||||
- **Modern Android blocks it anyway**: since Android 10, `getImei()` throws for
|
||||
non-privileged apps regardless of the permission.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant OS as Android OS
|
||||
participant PS as apiv2.parksmarter.com
|
||||
Note over App: cold start / login / token change
|
||||
App->>OS: Application.androidId (SSAID)
|
||||
OS-->>App: e.g. "a1b2c3…"
|
||||
App->>App: cache as psUUID (secure store)
|
||||
App->>PS: PUT /api/Device<br/>{ IMEINumber: SSAID, DeviceID: pushToken, DeviceType: "1" }
|
||||
Note over PS: a stable per-device ID,<br/>tied to the account — just not the IMEI
|
||||
```
|
||||
|
||||
**Net:** the app does transmit a persistent, per-device identifier to its own
|
||||
backend, mislabeled `IMEINumber`. It is the SSAID, not the hardware IMEI, and it
|
||||
is **not** sent to any third party. The earlier "sends your IMEI" claim was wrong
|
||||
in substance but pointed at something real — this field.
|
||||
|
||||
---
|
||||
|
||||
## Finding 2 — GPS location
|
||||
|
||||
Location is acquired via **expo-location** (`getLastKnownPositionAsync` +
|
||||
`watchPositionAsync` at `Accuracy.Balanced`) and sent to the backend **only** on
|
||||
the meter-search endpoints:
|
||||
|
||||
```
|
||||
GET /api/Meter?Lat=<lat>&Long=<long>&localeCode=en-US
|
||||
```
|
||||
|
||||
The query carries **only** `Lat`/`Long` (no radius/limit); "all vs limited meters"
|
||||
is an endpoint choice, not a parameter. Location is **not** attached to login,
|
||||
session load, or payment — confirmed both in code and in the live capture.
|
||||
|
||||
**Auto-send on map open.** The map hub wires `useAutoGoToUserLocation`, a state
|
||||
machine that — when `isAuthenticated && isMapReady && locationPermissionGranted`
|
||||
and the app is foreground — recenters on the user's GPS and fires the meter query
|
||||
**without any tap**. Panning/zooming fires more (debounced).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Map as Map screen
|
||||
participant Loc as expo-location
|
||||
participant PS as apiv2.parksmarter.com
|
||||
User->>Map: open Map (logged in, permission granted)
|
||||
Map->>Loc: watchPositionAsync (Balanced)
|
||||
Loc-->>Map: {lat, long}
|
||||
Map->>PS: GET /api/Meter?Lat=…&Long=… (auto, no tap)
|
||||
User->>Map: pan / zoom
|
||||
Map->>PS: GET /api/Meter?Lat=…&Long=… (debounced)
|
||||
```
|
||||
|
||||
**GrapheneOS caveat (observed).** `react-native-maps` needs Google Play Services
|
||||
for tiles; on GrapheneOS the map doesn't render, so `isMapReady` may never flip
|
||||
and the auto-fetch can be suppressed. In the live capture the single coordinate
|
||||
sent (`Lat=48.274147628&Long=-116.550122619`) exactly matched a *known lot*
|
||||
location rather than a fresh arbitrary GPS fix — consistent with the map being
|
||||
degraded. Location egress is proven; the fully-automatic-on-open behavior is a
|
||||
property of the code that a Play-Services device would exercise more visibly.
|
||||
|
||||
**BigBrainParking, by contrast**, never sends GPS to the API except when you
|
||||
explicitly tap "My location" and search; the map opens on your last lot from
|
||||
history, not your GPS.
|
||||
|
||||
---
|
||||
|
||||
## Finding 3 — Third-party telemetry: Instabug (and only Instabug)
|
||||
|
||||
Contrary to the earlier analysis, **Segment, Amplitude, Sentry, Firebase
|
||||
Analytics, Crashlytics, advertising-ID, and Play install-referrer are all
|
||||
absent** (verified by package-dir and endpoint-host search). Firebase is present
|
||||
only as **Cloud Messaging** (push), not analytics.
|
||||
|
||||
The one real telemetry SDK is **Instabug** (2,328 classes), and its scope is
|
||||
broad:
|
||||
|
||||
- **Session replay** (`library/sessionreplay`)
|
||||
- **Screenshot / screen capture** (`instacapture`, `screenshot`)
|
||||
- **Network-request logging & interception** (`apm/networking`, `networkinterception`)
|
||||
- **Visual user-steps / interaction tracking** (`visualusersteps`, `interactionstracking`)
|
||||
- Crash reports, APM, surveys, user attributes
|
||||
|
||||
All reporting to `api.instabug.com`. Captured live on startup:
|
||||
`POST /api/sdk/v3/sessions/v2`, `GET /api/sdk/v3/features`, `/api/sdk/v3/first_seen`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
D[Your device]
|
||||
D -->|"SSAID as IMEINumber + push token<br/>GPS (Lat/Long) on meter search<br/>account, vehicle, payment, sessions"| PS[apiv2.parksmarter.com]
|
||||
D -->|"session replay, screenshots,<br/>network logs, user-steps, crashes"| IB[api.instabug.com]
|
||||
D -->|push registration| FCM[Firebase Cloud Messaging]
|
||||
style PS fill:#294,color:#fff
|
||||
style IB fill:#922,color:#fff
|
||||
style FCM fill:#247,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permissions (from the manifest)
|
||||
|
||||
Present: `ACCESS_FINE/COARSE_LOCATION`, `READ_PHONE_STATE` (netinfo),
|
||||
`ACCESS_WIFI_STATE`, `CAMERA`, `RECORD_AUDIO`, `BLUETOOTH_SCAN/CONNECT` (meter
|
||||
BLE), `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `FOREGROUND_SERVICE`,
|
||||
`USE_BIOMETRIC`, `SYSTEM_ALERT_WINDOW`, `DETECT_SCREEN_CAPTURE`, install-referrer
|
||||
service binding, plus a long list of launcher badge permissions.
|
||||
|
||||
**Absent (important):** `ACCESS_BACKGROUND_LOCATION`. The app **cannot** track
|
||||
location while closed.
|
||||
|
||||
---
|
||||
|
||||
## Live capture — evidence timeline
|
||||
|
||||
Captured through mitmproxy against a repackaged (user-CA-trusting) universal APK,
|
||||
authenticated session:
|
||||
|
||||
```
|
||||
18:44:07 GET apiv2.parksmarter.com/api/ParkingSession
|
||||
18:44:07 GET apiv2.parksmarter.com/api/Session
|
||||
18:44:08 POST api.instabug.com/api/sdk/v3/sessions/v2 (414B) ← telemetry
|
||||
18:44:08 GET api.instabug.com/api/sdk/v3/first_seen
|
||||
18:44:16 GET apiv2.parksmarter.com/api/Meter?ZoneName=dl
|
||||
18:44:17 GET apiv2.parksmarter.com/api/Meter?Lat=48.274…&Long=-116.550… ← GPS
|
||||
18:44:20 GET apiv2.parksmarter.com/api/ParkingEstimateItems
|
||||
18:44:33 GET apiv2.parksmarter.com/api/ApplicationValidity
|
||||
18:44:39 GET apiv2.parksmarter.com/api/ParkSmarterPrivacyPolicies
|
||||
```
|
||||
|
||||
Header sample on a parksmarter request: `Application_Token: B66EEDDA-…`,
|
||||
`Auth_Token: …` (rolling), `ParkSmarter_SessionId: …`, `User-Agent: okhttp/4.12.0`.
|
||||
|
||||
`PUT /api/Device` (the `IMEINumber` PUT) did **not** fire in this session because
|
||||
the login was cached — it sends on fresh login / token change.
|
||||
|
||||
---
|
||||
|
||||
## Reproducing this
|
||||
|
||||
The repackaged capture-ready APK and step-by-step mitmproxy instructions live in
|
||||
the private source mirror (`ParkSmarterSourceCode`, `MITMPROXY.md`). In short:
|
||||
merge splits → inject a user-CA `network_security_config` → resign → install the
|
||||
CA as a user cert → proxy the phone through mitmproxy (mobile data **off** so the
|
||||
Wi-Fi proxy applies).
|
||||
|
||||
---
|
||||
|
||||
## Appendix — backend endpoint inventory (from the bundle)
|
||||
|
||||
`ApplicationValidity`, `Auth`, `SignUp`, `User`, `Device`, `Session`,
|
||||
`ParkingSession`, `Meter` (by location / scanner code / serial / zone name),
|
||||
`ParkingLots`/`ParkingLogix`, `ParkingEstimateSingle/Multi/Items`,
|
||||
`StartParkingSession`, `ParkingReceipt`, credit-card + vehicle CRUD,
|
||||
`NotificationSettings`, `ParkSmarterPrivacyPolicies` / `Terms` / `About` / `FAQ`.
|
||||
```
|
||||
321
docs/PARKSMARTER_API.md
Normal file
321
docs/PARKSMARTER_API.md
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
# ParkSmarter API — usage guide & endpoint reference
|
||||
|
||||
A commented guide to the ParkSmarter (IPS Group) API as exposed by
|
||||
[`parksmarter-client`](../parksmarter-client). It reverse-engineers the official
|
||||
Android app (`com.ipsgroupinc.parksmarter` 4.4.0) and has been verified against
|
||||
production. For install/quick-start and the auth-header model, see the
|
||||
[client README](../parksmarter-client/README.md); this document focuses on **how to
|
||||
actually use the API** — searching for parking and running a transaction — plus a
|
||||
per-endpoint reference and the behavioral quirks worth knowing.
|
||||
|
||||
> Everything here is for interoperability/research with **your own** account. All
|
||||
> methods below are on a `ParkSmarterClient` instance (`const ps = new ParkSmarterClient(...)`).
|
||||
|
||||
---
|
||||
|
||||
## Mental model
|
||||
|
||||
- **Zone** — a metered area (e.g. Sandpoint `DSB3`, `DL`). Has an id (`ZoneId`), a
|
||||
`CustomerId` (the operator/city, e.g. `217` = "Sandpoint, ID"), and one or more **Spaces**.
|
||||
- **Space** — an individual stall/segment within a zone (`SpaceId`). Estimates and
|
||||
sessions are always for a specific `(zone, space)`.
|
||||
- **Policy** — a zone/space's schedule: a list of time slots, each with a `RateType`
|
||||
(`Free`, `Hour`, `Variable`, `No Parking`, `Prepay`), a `Rate`, a `MaxTime`, and a
|
||||
`CurrentSlot` flag marking the one in effect now. Policies are **descriptive** — they
|
||||
tell you when/what it costs; there is no "check-in", grace-token, or free-session
|
||||
concept in the API.
|
||||
- **Estimate** — a price quote for parking a given `(zone, space, vehicle)` for some
|
||||
duration. Three flavors (multi / single / items) — see below.
|
||||
- **Session** — a paid parking transaction (`POST /api/Session`). Requires a card and a
|
||||
charge; there is **no $0 / free session**. Free parking is simply unmetered time.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bootstrap & authentication
|
||||
|
||||
Auth is **custom-header**, not OAuth. Call `getApplicationValidity()` first (seeds a
|
||||
`ParkSmarter_SessionId`), then log in. The client stores `Auth_Token` + `SessionId`
|
||||
automatically and refreshes the rolling `Auth_Token` from the `Response` envelope.
|
||||
|
||||
```ts
|
||||
const ps = new ParkSmarterClient({ environment: 'prodv2' });
|
||||
|
||||
const validity = await ps.getApplicationValidity();
|
||||
if (validity.Config?.IsInMaintenanceMode) throw new Error('maintenance');
|
||||
|
||||
await ps.loginWithPhone({ phoneNumber: '5551234567', password: '…' });
|
||||
// From here, authenticated endpoints work. Auth_Token is sent as a header.
|
||||
```
|
||||
|
||||
**Gotchas**
|
||||
- A **failed login returns HTTP 200** with `Status: "Error"` + null `Auth_Token`. The
|
||||
client throws `LoginError` for you; if you call `/api/Auth` yourself, check both.
|
||||
- A `POST /api/Auth` returning **201** means the account needs SMS verification — not a
|
||||
normal login. Treat it distinctly (`requestVerifyUser` → `verifyUser`).
|
||||
- **Validating a stored token:** a stale/expired `Auth_Token` does **not** 401 —
|
||||
`GET /api/User` just returns an **empty body** (`getUserDetail()` resolves to
|
||||
`undefined`). To check "am I really signed in?", require real fields:
|
||||
`const u = await ps.getUserDetail(); const ok = !!(u && (u.PersonalPhone || u.PersonalEmailAddress));`
|
||||
- Persist tokens across launches with a `TokenStore` (see README's RN example) so you
|
||||
don't have to log in every time.
|
||||
|
||||
---
|
||||
|
||||
## 2. Finding parking (search)
|
||||
|
||||
There are several ways in, all returning the same `MetersResponse` shape
|
||||
(`{ Zones: Zone[], Response }`). **Meter search requires a valid `Auth_Token`** — these
|
||||
lookups 401 when signed out, despite being "data" reads.
|
||||
|
||||
| You have… | Use | Sends |
|
||||
| --- | --- | --- |
|
||||
| A map location | `getMetersByLocation({ latitude, longitude })` | `Lat`,`Long` |
|
||||
| A map location (lighter list) | `getLimitedMetersByLocation({ latitude, longitude })` | `Lat`,`Long` |
|
||||
| A zone name (e.g. `"DL"`) | `getMetersByZoneName('DL')` | `ZoneName` |
|
||||
| Free-text zone/space | `searchMetersByZoneOrSpace('Main St')` | `Query` |
|
||||
| A meter serial number | `getMetersBySerialNumber('…')` | `TerminalSerNo` |
|
||||
| A scanned QR/barcode | `getMetersByScannerCode('DSB13')` | `ScannerCode` |
|
||||
| Gated lots w/ occupancy | `getParkingLots()` | — |
|
||||
|
||||
```ts
|
||||
// By location (only a single coordinate is ever sent — the point you're searching):
|
||||
const res = await ps.getMetersByLocation({ latitude: 48.28, longitude: -116.55 }); // ~downtown Sandpoint
|
||||
const zones = res.Zones ?? [];
|
||||
|
||||
// Pick a zone + space to act on:
|
||||
const zone = zones[0];
|
||||
const space = zone.Spaces?.[0];
|
||||
const base = {
|
||||
zoneId: zone.ZoneId!,
|
||||
spaceId: space!.SpaceId!,
|
||||
customerId: zone.CustomerId!,
|
||||
};
|
||||
|
||||
// Is it free / paid right now? Read the CurrentSlot policy:
|
||||
const current = space!.Policies?.find((p) => p.CurrentSlot);
|
||||
const free = (current?.RateType ?? '').toLowerCase().includes('free');
|
||||
```
|
||||
|
||||
**Notes**
|
||||
- `Zone.Lat`/`Long` in results is the **meter's** location (for map pins), not yours.
|
||||
- QR codes on kiosks are often a generic `parksmarter.com/home/processQR` URL with **no
|
||||
zone in it** — fall back to prompting for the printed Zone ID and
|
||||
`getMetersByScannerCode`/`getMetersByZoneName`.
|
||||
- Scanner codes map to zones: e.g. `DSB13` → zone `113150`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Getting a price (estimates)
|
||||
|
||||
Before a transaction you quote a price for a `(zone, space, vehicle)`. Three endpoints:
|
||||
|
||||
| Method | Path | Use |
|
||||
| --- | --- | --- |
|
||||
| `getParkingEstimateMulti(base)` | `/api/ParkingEstimateMulti` | A **ladder** of durations → prices (the app's default). |
|
||||
| `getParkingEstimateSingle({…, durationInMinutes, creditCardId})` | `/api/ParkingEstimate` | One duration → one price. |
|
||||
| `getParkingEstimateItems(base)` | `/api/ParkingEstimateItems` | Item/product-style options. |
|
||||
|
||||
```ts
|
||||
const vehicleId = (await ps.getUserDetail()).VehicleDetails![0].VehicleID!;
|
||||
const q = { ...base, vehicleId };
|
||||
|
||||
const multi = await ps.getParkingEstimateMulti(q);
|
||||
const ladder = multi.ParkingDetail ?? []; // [{ Minutes, StartTime, EndTime, ParkingCost, TransactionFee }, …]
|
||||
```
|
||||
|
||||
**This is where most of the quirks live:**
|
||||
|
||||
- **Multi is not universal.** Some zones (e.g. short-term `DL` spaces) reject
|
||||
`ParkingEstimateMulti` with HTTP 200 + `Response.Status: "Error"`
|
||||
("Unable to process your request…") even though **single works**. Detect the error
|
||||
envelope and fall back to `getParkingEstimateSingle` across the zone's `MinTime..MaxTime`.
|
||||
- **Free windows signal via `MaxTime: 0` / `$0.00`.** During a free period the single
|
||||
estimate returns `MinTime: 5, MaxTime: 0, ParkingCost: "0.00"` and multi returns an
|
||||
empty/error ladder. Treat `MaxTime <= 0` (helper: `isFreeEstimate`) or an all-`$0.00`
|
||||
ladder (`ladderAllFree`) as "currently free — just park", **not** a purchasable option.
|
||||
- **Flat-rate zones.** Some zones charge one flat price for any duration up to a cap
|
||||
(e.g. `DL` = **$0.10 flat** until the ~5 PM boundary). Every single-estimate duration
|
||||
returns the same `ParkingCost`; `MaxTime` is *minutes remaining until the paid-window
|
||||
boundary* and shrinks through the day. Offer one option ("park to MaxTime"), not a
|
||||
ladder of identical prices.
|
||||
- **`ParkingDetail.Minutes` may be `0`** on single estimates — the real duration is in
|
||||
`StartTime`/`EndTime`. Trust the duration you requested.
|
||||
- **Estimates need auth** (like meter search).
|
||||
|
||||
---
|
||||
|
||||
## 4. Initiating a transaction (start a session)
|
||||
|
||||
`startParkingSession()` (`POST /api/Session`) is the **only** session-creating call, and
|
||||
it **charges the card**. Build it from a chosen estimate rung:
|
||||
|
||||
```ts
|
||||
const rung = ladder[selectedIndex]; // from a multi/single estimate
|
||||
const res = await ps.startParkingSession({
|
||||
creditCardId: card.CCID!,
|
||||
vehicleId,
|
||||
zoneId: base.zoneId,
|
||||
spaceId: base.spaceId,
|
||||
customerId: base.customerId,
|
||||
meterTypeId: zone.MeterTypeId!,
|
||||
startTime: rung.StartTime!,
|
||||
endTime: rung.EndTime!,
|
||||
minutesToPurchase: rung.Minutes!,
|
||||
parkingCost: Number(rung.ParkingCost ?? 0),
|
||||
transactionFee: Number(rung.TransactionFee ?? 0),
|
||||
minCreditAmount: zone.MinimumAmount, // optional
|
||||
});
|
||||
```
|
||||
|
||||
**Request body** carries `CCID, Amount (cost+fee), SpaceID, StartTime, EndTime,
|
||||
CustomerID, VehicleID, TimePurchased, ParkingCost, TransactionFee, ZoneID,
|
||||
MinCreditAmount?, MeterTypeId` (and `BleEncBytes` for BLE meters). **No device location
|
||||
is sent.**
|
||||
|
||||
**Gotchas**
|
||||
- **A decline still returns HTTP 200**, with `Response.Status: "Error"` and often
|
||||
`OriginalErrorMessage: "DECLINED"`. The client throws on this envelope so you don't get a
|
||||
phantom success — surface `serverMessage` to the user.
|
||||
- **You cannot buy during a free window.** The app blocks purchase
|
||||
("Purchases are currently not allowed. Parking is currently free.") and the API rejects
|
||||
zero-minute / zero-cost sessions (`unableToPurchaseZeroMinutes`, `minTimeNotReached`).
|
||||
- **No free check-in.** There is no way to register a $0 session; free time is just
|
||||
unmetered — you park, enforcement (LPR/chalk) handles the limit.
|
||||
- Schedule your own **local** expiry reminder from `EndTime`; the API has no per-session
|
||||
reminder push.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sessions & receipts
|
||||
|
||||
```ts
|
||||
const active = (await ps.getActiveParkingSessions()).ParkingSession ?? [];
|
||||
const past = (await ps.getPastParkingSessions({ currentPage: 1, pageSize: 20 })).Session ?? [];
|
||||
|
||||
// Full receipt for a past session (auth code, payment, amounts):
|
||||
const tid = past[0]?.TransactionID;
|
||||
const receipt = (await ps.getParkingReceipt(tid!)).ParkingReceipt;
|
||||
await ps.emailParkingReceipt(tid!); // emails it to the account holder
|
||||
```
|
||||
|
||||
- `PastSession` uses `Zone`/`Description` (not `ZoneName`) for the zone label, and echoes
|
||||
`Lat`/`Long` of the meter.
|
||||
- `ParkingReceipt` has `PaymentType`, `PaymentDisplay` ("MASTERCARD"), masked `CC`,
|
||||
`AuthCode`, `Vehicle` (plate), `Amount`/`TransactionFee`/`Total` (display strings) plus
|
||||
`*Value` numerics.
|
||||
|
||||
---
|
||||
|
||||
## 6. Account management
|
||||
|
||||
| Task | Method(s) |
|
||||
| --- | --- |
|
||||
| Vehicles | `addVehicle`, `updateVehicle`, `deleteVehicle` (list via `getUserDetail().VehicleDetails`) |
|
||||
| Cards | `addCard`, `updateCard`, `setCardDefault`, `deleteCard` (list via `getUserDetail().CreditCardDetails`) |
|
||||
| Profile | `updateProfile`, `requestDeleteUser` |
|
||||
| Password | `requestResetPassword`, `updatePassword` |
|
||||
| Notifications | `getNotificationSettings`, `setNotificationSettings` |
|
||||
|
||||
Mutations return a `{ Status, Message }` envelope — treat `Status === 'Success'` as the
|
||||
success signal. Card numbers are only ever **sent** (add/update); responses mask them.
|
||||
|
||||
---
|
||||
|
||||
## Behaviors & gotchas (quick reference)
|
||||
|
||||
| Behavior | Detail |
|
||||
| --- | --- |
|
||||
| 200-on-error | Failed login / declined payment / bad request often return **HTTP 200** with `Response.Status: "Error"`. Always check the envelope, not just the status code. |
|
||||
| Stale token ≠ 401 | An expired `Auth_Token` yields an **empty** `/api/User` body, not a 401. Validate by requiring real user fields. |
|
||||
| Rolling token refresh | Non-empty `Response.Auth_Token` (or top-level) on any response replaces your token — the client stores it. |
|
||||
| Meter/estimate search needs auth | `/api/Meter`, `/api/MeterList`, and estimates 401 when signed out. |
|
||||
| Multi estimate unsupported on some zones | Falls back to single; detect `Response.Status: "Error"`. |
|
||||
| Free window = `MaxTime 0` / `$0.00` | Show "currently free", don't offer a $0 ladder. |
|
||||
| Flat-rate zones | One price for any duration; `MaxTime` = minutes to the paid-window boundary. |
|
||||
| No free check-in | Only paid sessions exist; free parking is unmetered time. |
|
||||
| `localeCode` | Every request carries `localeCode` (default `en`). |
|
||||
| Non-prod is IP-restricted | `dev`/`stage`/`test` return 403 "Web App - Unavailable" from the public internet. |
|
||||
|
||||
---
|
||||
|
||||
## Endpoint reference
|
||||
|
||||
All methods are on `ParkSmarterClient`. Friendly camelCase inputs are mapped to the wire
|
||||
format; responses are the raw server PascalCase JSON (typed in
|
||||
[`src/types.ts`](../parksmarter-client/src/types.ts)).
|
||||
|
||||
### Bootstrap
|
||||
- **`getApplicationValidity()`** → `GET /api/ApplicationValidity` — feature flags,
|
||||
maintenance/upgrade, seeds `SessionId`. Public. Call first.
|
||||
|
||||
### Auth
|
||||
- **`loginWithPhone({ phoneNumber, password })`** → `POST /api/Auth` — persists token.
|
||||
- **`loginWithApple({ emailAddress, appleId, providerAuth, providerIdentity })`** → `POST /api/Auth`.
|
||||
- **`loginWithCachedToken(authToken)`** — set a stored token (then bootstrap).
|
||||
- **`logoutAllDevices()`** → `POST /api/Auth/Logout` — server-side invalidate.
|
||||
- **`logoutLocal()`** — clear local tokens only.
|
||||
|
||||
### Sign-up / password / verification
|
||||
- **`signUp({ emailAddress, mobilePhone, password })`** → `POST /api/User`. Public.
|
||||
- **`requestResetPassword({ reqType, phoneNumber })`** → `POST /api/Password`. Public.
|
||||
- **`updatePassword({ oldPassword, newPassword })`** → `PUT /api/Password`.
|
||||
- **`requestVerifyUser({ phoneNumber })`** → `POST /api/UserVerification` — SMS code.
|
||||
- **`verifyUser({ phoneNumber, code })`** → `GET /api/UserVerification`.
|
||||
- **`isEmailRegistered(email)` / `isPhoneRegistered(phone)`** → `GET /api/User`. Public boolean.
|
||||
|
||||
### Profile / device
|
||||
- **`getUserDetail()`** → `GET /api/User` — profile incl. `VehicleDetails` + `CreditCardDetails`. (Empty body ⇒ token stale.)
|
||||
- **`updateProfile({ emailAddress, phoneNumber })`** → `PUT /api/User`.
|
||||
- **`requestDeleteUser()`** → `DELETE /api/User`.
|
||||
- **`updateDeviceToken({ pushNotificationsToken, imeiNumber, deviceType?, language })`** → `PUT /api/Device`. (BigBrainParking does **not** call this — avoids sending device id/IMEI.)
|
||||
|
||||
### Vehicles
|
||||
- **`addVehicle({ plate, state, vehicleAlias, isDefaultVehicle? })`** → `POST /api/Vehicle`.
|
||||
- **`updateVehicle({ id, plate, state, vehicleAlias, isDefaultVehicle? })`** → `PUT /api/Vehicle`.
|
||||
- **`deleteVehicle(vehicleId)`** → `DELETE /api/Vehicle`.
|
||||
|
||||
### Cards
|
||||
- **`addCard({ cardNumber, alias, expDate, zipCode, isDefaultCard? })`** → `POST /api/Card`.
|
||||
- **`updateCard({ id, … })`** → `POST /api/Card` (full replace).
|
||||
- **`setCardDefault({ id, isDefaultCard })`** → `PUT /api/Card`.
|
||||
- **`deleteCard(cardId)`** → `DELETE /api/Card`.
|
||||
|
||||
### Meters / zones
|
||||
- **`getMetersByLocation({ latitude, longitude })`** → `GET /api/Meter?Lat=&Long=`.
|
||||
- **`getLimitedMetersByLocation({ latitude, longitude })`** → `GET /api/MeterList?Lat=&Long=`.
|
||||
- **`getMetersByZoneName(zoneName)`** → `GET /api/Meter?ZoneName=`.
|
||||
- **`searchMetersByZoneOrSpace(query)`** → `GET /api/Meter?Query=`.
|
||||
- **`getMetersBySerialNumber(serial)`** → `GET /api/Meter?TerminalSerNo=`.
|
||||
- **`getMetersByScannerCode(code)`** → `GET /api/Meter?ScannerCode=`.
|
||||
- **`getParkingLots()`** → `GET /api/ParkingLogix` — gated lots + occupancy.
|
||||
|
||||
### Estimates
|
||||
- **`getParkingEstimateMulti({ zoneId, spaceId, customerId, vehicleId, minCreditAmount?, bleEncBytes? })`** → `GET /api/ParkingEstimateMulti` — duration ladder.
|
||||
- **`getParkingEstimateSingle({ …, durationInMinutes, creditCardId })`** → `GET /api/ParkingEstimate` — one duration.
|
||||
- **`getParkingEstimateItems(base)`** → `GET /api/ParkingEstimateItems`.
|
||||
- Helpers: **`isFreeEstimate(probe)`**, **`ladderAllFree(rungs)`** (pure; exported).
|
||||
|
||||
### Sessions / receipts
|
||||
- **`startParkingSession({ creditCardId, vehicleId, zoneId, spaceId, customerId, meterTypeId, startTime, endTime, minutesToPurchase, parkingCost, transactionFee, minCreditAmount?, bleEncBytes? })`** → `POST /api/Session`. **Charges the card.** Throws on the `Status:Error` decline envelope.
|
||||
- **`getActiveParkingSessions()`** → `GET /api/ParkingSession`.
|
||||
- **`getPastParkingSessions({ currentPage, pageSize })`** → `GET /api/Session`.
|
||||
- **`getParkingReceipt(transactionId)`** → `GET /api/ParkingReceipt`.
|
||||
- **`emailParkingReceipt(id)`** → `POST /api/ParkingReceipt` — email to account holder.
|
||||
|
||||
### Settings / content
|
||||
- **`getNotificationSettings()` / `setNotificationSettings(...)`** → `/api/Setting`.
|
||||
- **`getStates()`** → `GET /api/State`. (Note: seen returning 404 on prodv2 — pass an id form if needed.)
|
||||
- **`getAbout()` / `getFAQ()` / `getPrivacyPolicy()` / `getTerms()`** → `GET /api/ParkSmarter*` — static content.
|
||||
|
||||
---
|
||||
|
||||
## Verification status
|
||||
|
||||
Response models are verified against production (`sweep.mjs` records field-names+types
|
||||
only, no PII). **CONFIRMED live:** login, `UserDetail`, vehicles, cards,
|
||||
`Zone`/`Space`/`SpacePolicy`, parking lots, all three estimates, and — from a real DL-zone
|
||||
session — `PastSession` and `ParkingReceipt`. **Still UNCONFIRMED:** `ActiveSession` (no
|
||||
active session existed on the account at capture time). Re-run `sweep.mjs` (schema diff) or
|
||||
`capture-dl.mjs` (full DL request/response record) to refresh.
|
||||
|
||||
Not affiliated with or endorsed by IPS Group / ParkSmarter.
|
||||
BIN
docs/zone-qr/DL-sandpoint.png
Normal file
BIN
docs/zone-qr/DL-sandpoint.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 431 B |
36
docs/zone-qr/README.md
Normal file
36
docs/zone-qr/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Zone QR codes
|
||||
|
||||
A parking-zone QR code just encodes the zone's **`ScannerCode`** as plain text.
|
||||
BigBrainParking's scanner (and the official ParkSmarter app) resolve that code to
|
||||
the zone. No URL, no wrapper — the raw scanner code is enough.
|
||||
|
||||
## Make one
|
||||
|
||||
```bash
|
||||
qrencode -o my-zone.png -s 14 -m 4 "<ScannerCode>"
|
||||
```
|
||||
|
||||
`<ScannerCode>` is the zone's `ScannerCode` field (visible on the meter detail
|
||||
screen, or in a `/api/Meter` response). For the DL zone in Sandpoint it's `DL`:
|
||||
|
||||
```bash
|
||||
qrencode -o DL-sandpoint.png -s 14 -m 4 "DL"
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Which field to encode
|
||||
|
||||
Use **`ScannerCode`** — it's what physical kiosk QR stickers encode and the only
|
||||
field the official ParkSmarter app accepts when scanning.
|
||||
|
||||
- `ScannerCode` — ✅ works in BigBrainParking **and** the official app.
|
||||
- `ZoneName` (e.g. `dl`) — works as a fallback in BigBrainParking (case-sensitive
|
||||
server-side; the app tries the lowercased form too).
|
||||
- `TerminalSerNo` (e.g. `30004246`) — uniquely identifies the terminal and works
|
||||
in BigBrainParking, but the **official app rejects a bare serial** ("not
|
||||
recognized as a ParkSmarter zone"), so don't use it for cross-app QR codes.
|
||||
|
||||
When BigBrainParking scans a code it tries, in order: `ScannerCode → ZoneName →
|
||||
zonename(lowercased) → TerminalSerNo`, each independently, so any of these
|
||||
resolve.
|
||||
504
package-lock.json
generated
504
package-lock.json
generated
|
|
@ -40,6 +40,7 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "~19.0.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "~5.4.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -1599,6 +1600,448 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/cli": {
|
||||
"version": "0.24.24",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
|
||||
|
|
@ -4502,6 +4945,48 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
|
@ -9151,6 +9636,25 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ Node 18+. It has **zero runtime dependencies** (uses `fetch`). If you specifical
|
|||
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
|
||||
directly onto Retrofit/OkHttp — ask and it can be ported.
|
||||
|
||||
> **Usage guide:** for narrative walk-throughs (searching, quoting a price, running a
|
||||
> transaction) plus per-endpoint commentary and the behavioral quirks, see
|
||||
> **[`docs/PARKSMARTER_API.md`](../docs/PARKSMARTER_API.md)**. The tables below are the
|
||||
> quick reference.
|
||||
|
||||
## Install / build
|
||||
|
||||
```bash
|
||||
|
|
@ -170,19 +175,26 @@ Request shapes were recovered from the app code and are exact. Response models w
|
|||
- **CONFIRMED via live capture:** login (`AuthResponse`), `UserDetail`, `VehicleDetail`,
|
||||
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
|
||||
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
|
||||
states wrapper, the password-reset flow, and the shared `Response` envelope.
|
||||
- **UNCONFIRMED (no session history on the test account):** `ActiveSession`,
|
||||
`PastSession`, `ParkingReceipt`. Field names are from static analysis; capture from an
|
||||
account with at least one past/active session to confirm.
|
||||
states wrapper, the password-reset flow, the shared `Response` envelope, and — from a real
|
||||
DL-zone session — `PastSession` and `ParkingReceipt`.
|
||||
- **UNCONFIRMED:** `ActiveSession` (no *active* session existed on the account at capture
|
||||
time — field names are from static analysis; capture while parked to confirm).
|
||||
- `capture-dl.mjs` records a full DL-zone request/response set; `sweep.mjs` refreshes the
|
||||
PII-free schema skeletons.
|
||||
|
||||
Two behaviors worth knowing (both confirmed live):
|
||||
Behaviors worth knowing (all confirmed live — full list in
|
||||
[`docs/PARKSMARTER_API.md`](../docs/PARKSMARTER_API.md)):
|
||||
|
||||
1. **Meter search requires auth.** `/api/Meter` and `/api/MeterList` return **401** without a
|
||||
valid `Auth_Token`, despite being data lookups.
|
||||
2. **`Response` envelope + token refresh.** Most authenticated responses embed
|
||||
`Response: { Auth_Token, Message, Status }`. When `Response.Auth_Token` (or a top-level
|
||||
`Auth_Token`) is non-empty, it's a rolling refresh of your user token — the client stores
|
||||
it automatically.
|
||||
1. **200-on-error.** Failed login, declined payment, and some bad requests return **HTTP
|
||||
200** with `Response.Status: "Error"`. Check the envelope, not just the status code.
|
||||
2. **Meter/estimate search requires auth.** `/api/Meter`, `/api/MeterList`, and estimates
|
||||
**401** without a valid `Auth_Token`, despite being data lookups.
|
||||
3. **Rolling token refresh.** A non-empty `Response.Auth_Token` (or top-level) replaces your
|
||||
user token — the client stores it automatically.
|
||||
4. **Stale token ≠ 401.** An expired token makes `GET /api/User` return an **empty body**
|
||||
(not a 401); validate by requiring real user fields before trusting the session.
|
||||
5. **Estimate quirks.** Multi is unsupported on some zones (falls back to single);
|
||||
`MaxTime: 0`/`$0.00` means a free window; some zones are flat-rate.
|
||||
|
||||
To capture the remaining UNCONFIRMED models, re-run `sweep.mjs` on an account that has a
|
||||
saved card and session history. There's **no TLS pinning**, so a proxy capture on a rooted
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@ export interface ActiveSession {
|
|||
EndTime?: string;
|
||||
StartTimeDisplay?: string;
|
||||
EndTimeDisplay?: string;
|
||||
/** Seconds left on the session (CONFIRMED via a live session — NOT minutes). */
|
||||
TimeRemaining?: number | string;
|
||||
VehiclePlate?: string;
|
||||
VehicleNumber?: string;
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||
import { ParkSmarterClient, isFreeEstimate } from '../dist/index.js';
|
||||
|
||||
const ENV = process.env.PS_TEST_ENV || 'test';
|
||||
const SANDPOINT = { latitude: 48.27538, longitude: -116.54779 };
|
||||
const credsUrl = new URL('../.creds.json', import.meta.url);
|
||||
const creds = existsSync(credsUrl) ? JSON.parse(readFileSync(credsUrl, 'utf8')) : {};
|
||||
// Search point: your own coordinate from the gitignored .creds.json when present,
|
||||
// else a rounded, non-personal downtown-Sandpoint point (~1 km precision — not a home).
|
||||
const SANDPOINT = { latitude: Number(creds.lat) || 48.28, longitude: Number(creds.lng) || -116.55 };
|
||||
|
||||
// Probe reachability once; if the instance blocks us, skip the whole suite.
|
||||
let reason = false;
|
||||
|
|
@ -38,7 +40,7 @@ test(`[${ENV}] ApplicationValidity bootstraps a session`, { skip: reason }, asyn
|
|||
assert.ok(av.SessionId || av.Config, 'expected a bootstrap payload');
|
||||
});
|
||||
|
||||
test(`[${ENV}] Sandpoint (DSB) zones are returned near 48.27,-116.55`, { skip: reason }, async () => {
|
||||
test(`[${ENV}] Sandpoint (DSB) zones are returned near downtown`, { skip: reason }, async () => {
|
||||
const ps = await client();
|
||||
const m = await ps.getMetersByLocation(SANDPOINT);
|
||||
assert.ok(Array.isArray(m.Zones) && m.Zones.length > 0, 'expected zones near Sandpoint');
|
||||
|
|
|
|||
7
server/.env.example
Normal file
7
server/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Copy to .env and set a long random secret (>= 16 chars). This is the admin
|
||||
# password you paste into the app's Settings → Admin. Generate one with:
|
||||
# openssl rand -base64 32
|
||||
BBP_ADMIN_TOKEN=change-me-to-a-long-random-secret
|
||||
|
||||
# Optional: comma-separated IPs to reject outright.
|
||||
# BBP_BLOCKED_IPS=1.2.3.4,5.6.7.8
|
||||
5
server/.gitignore
vendored
Normal file
5
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
23
server/Dockerfile
Normal file
23
server/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Build stage — compiles TS and builds better-sqlite3's native addon.
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
# Runtime stage — same base (binary-compatible native addon), no build tools.
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package.json ./
|
||||
RUN mkdir -p /data && chown -R node:node /data
|
||||
USER node
|
||||
EXPOSE 8090
|
||||
CMD ["node", "dist/index.js"]
|
||||
59
server/README.md
Normal file
59
server/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# BigBrainParking zone-labels API
|
||||
|
||||
A tiny service that classifies parking zones so the app knows whether a space is
|
||||
**free for a limit** (`free_2h` / `free_3h` / `free_4h`) or a **pay-immediately**
|
||||
lot (`pay_immediate`). Public reads; writes require the admin password.
|
||||
|
||||
Lives in the monorepo but is **not** an npm workspace (keeps its native
|
||||
`better-sqlite3` dep out of the app/CI install). Deploy it independently.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/healthz` | – | liveness |
|
||||
| GET | `/api/labels` | – | all labels `{ labels: [...] }` (app bulk-caches) |
|
||||
| GET | `/api/labels/:zoneId` | – | one label, 404 if none |
|
||||
| PUT | `/api/labels/:zoneId` | admin | upsert `{ kind, zoneName?, customerId?, note? }` |
|
||||
| DELETE | `/api/labels/:zoneId` | admin | remove |
|
||||
| GET | `/api/whoami` | admin | `{ admin: true }` — used by the app's "test password" |
|
||||
|
||||
Auth: `Authorization: Bearer <BBP_ADMIN_TOKEN>` (timing-safe compare). Reads are
|
||||
public but rate-limited (~120/min/IP; writes ~20/min). Repeated bad tokens from an
|
||||
IP auto-block it for a cooldown; `blocked_ips` (DB) + `BBP_BLOCKED_IPS` (env) are a
|
||||
manual denylist. `zoneId` is the ParkSmarter `ZoneId` (e.g. `113165`).
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm test # node --test via tsx
|
||||
BBP_ADMIN_TOKEN=dev-secret-please-change npm run dev
|
||||
```
|
||||
|
||||
## Deploy (Docker + nginx on the host the CNAME points to)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env
|
||||
sed -i "s#change-me-to-a-long-random-secret#$(openssl rand -base64 32)#" .env # set the admin secret
|
||||
docker compose up -d --build
|
||||
|
||||
# nginx + TLS (first time)
|
||||
sudo cp deploy/bigbrainparking.mowden.top.conf /etc/nginx/sites-available/bigbrainparking.mowden.top
|
||||
sudo ln -s ../sites-available/bigbrainparking.mowden.top /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d bigbrainparking.mowden.top
|
||||
|
||||
curl https://bigbrainparking.mowden.top/healthz # {"ok":true}
|
||||
```
|
||||
|
||||
The admin secret (from `.env`) is what you paste into the app under
|
||||
**Settings → Admin**. Rotate by editing `.env` and `docker compose up -d`.
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
git pull && docker compose up -d --build
|
||||
```
|
||||
25
server/deploy/bigbrainparking.mowden.top.conf
Normal file
25
server/deploy/bigbrainparking.mowden.top.conf
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# nginx reverse proxy for the zone-labels API.
|
||||
# Install: sudo cp this file to /etc/nginx/sites-available/bigbrainparking.mowden.top
|
||||
# sudo ln -s ../sites-available/bigbrainparking.mowden.top /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
# TLS: sudo certbot --nginx -d bigbrainparking.mowden.top
|
||||
# (certbot rewrites this file to add the :443 server block + HTTP->HTTPS redirect.)
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name bigbrainparking.mowden.top;
|
||||
|
||||
# Small API; cap request bodies.
|
||||
client_max_body_size 32k;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8097;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
}
|
||||
23
server/docker-compose.yml
Normal file
23
server/docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
bbp-labels:
|
||||
build: .
|
||||
image: bbp-labels:latest
|
||||
container_name: bbp-labels
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- BBP_DB_PATH=/data/labels.db
|
||||
- PORT=8090
|
||||
- HOST=0.0.0.0
|
||||
# Bound to loopback only — nginx terminates TLS and reverse-proxies to it.
|
||||
# Host port 8097 (8090 is used by another container); container stays 8090.
|
||||
ports:
|
||||
- "127.0.0.1:8097:8090"
|
||||
volumes:
|
||||
- bbp-labels-data:/data
|
||||
# Shared host — keep this service small.
|
||||
mem_limit: 256m
|
||||
cpus: 0.5
|
||||
|
||||
volumes:
|
||||
bbp-labels-data:
|
||||
1700
server/package-lock.json
generated
Normal file
1700
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
server/package.json
Normal file
26
server/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "bbp-labels-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "BigBrainParking zone-labels API — classifies parking zones (free 2h/3h/4h street vs pay-immediately lots).",
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "node --import tsx --watch src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --import tsx --test test/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/rate-limit": "^10.2.2",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"fastify": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.13.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
231
server/src/app.ts
Normal file
231
server/src/app.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import {
|
||||
LabelDb,
|
||||
LABEL_KINDS,
|
||||
isLabelKind,
|
||||
isAreaKind,
|
||||
AREA_KINDS,
|
||||
type ZoneLabel,
|
||||
type ParkingArea,
|
||||
} from './db.js';
|
||||
import { AbuseGuard, safeEqual } from './auth.js';
|
||||
|
||||
export interface BuildOptions {
|
||||
adminToken: string;
|
||||
/** File path for the SQLite DB, or ':memory:' (tests). Ignored if `db` is given. */
|
||||
dbPath?: string;
|
||||
db?: LabelDb;
|
||||
trustProxy?: boolean;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindow?: string;
|
||||
writeRateLimitMax?: number;
|
||||
authFailLimit?: number;
|
||||
authFailWindowMs?: number;
|
||||
blockCooldownMs?: number;
|
||||
seedBlockedIps?: string[];
|
||||
}
|
||||
|
||||
interface ZoneParams {
|
||||
zoneId: string;
|
||||
}
|
||||
interface PutBody {
|
||||
kind?: unknown;
|
||||
zoneName?: unknown;
|
||||
customerId?: unknown;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (v == null ? null : String(v));
|
||||
|
||||
export async function buildApp(opts: BuildOptions) {
|
||||
const db = opts.db ?? new LabelDb(opts.dbPath ?? ':memory:');
|
||||
if (opts.seedBlockedIps?.length) db.seedBlocked(opts.seedBlockedIps);
|
||||
|
||||
const guard = new AbuseGuard(
|
||||
opts.authFailLimit ?? 8,
|
||||
opts.authFailWindowMs ?? 15 * 60 * 1000,
|
||||
opts.blockCooldownMs ?? 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
// 1 MB: zone-sync batches carry full Zone objects (policies, logos, …).
|
||||
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 1024 * 1024 });
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: opts.rateLimitMax ?? 120,
|
||||
timeWindow: opts.rateLimitWindow ?? '1 minute',
|
||||
});
|
||||
|
||||
// Reject blocked IPs (manual denylist + auto-block) before anything else.
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (guard.isBlocked(req.ip, Date.now()) || db.isBlocked(req.ip)) {
|
||||
return reply.code(403).send({ error: 'forbidden' });
|
||||
}
|
||||
});
|
||||
|
||||
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const hdr = req.headers.authorization ?? '';
|
||||
const token = /^Bearer\s+(.+)$/i.exec(hdr)?.[1] ?? '';
|
||||
if (!token || !safeEqual(token, opts.adminToken)) {
|
||||
const tripped = guard.recordFail(req.ip, Date.now());
|
||||
return reply.code(401).send({ error: 'unauthorized', blocked: tripped });
|
||||
}
|
||||
guard.recordSuccess(req.ip);
|
||||
};
|
||||
|
||||
const writeLimit = {
|
||||
config: { rateLimit: { max: opts.writeRateLimitMax ?? 20, timeWindow: '1 minute' } },
|
||||
};
|
||||
|
||||
app.get('/healthz', async () => ({ ok: true }));
|
||||
|
||||
app.get('/api/whoami', { preHandler: requireAdmin }, async () => ({ admin: true }));
|
||||
|
||||
app.get('/api/labels', async () => ({ labels: db.all() }));
|
||||
|
||||
app.get('/api/labels/:zoneId', async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => {
|
||||
const label = db.get(req.params.zoneId);
|
||||
if (!label) return reply.code(404).send({ error: 'not_found' });
|
||||
return label;
|
||||
});
|
||||
|
||||
app.put(
|
||||
'/api/labels/:zoneId',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Params: ZoneParams; Body: PutBody }>, reply) => {
|
||||
const body = req.body ?? {};
|
||||
if (!isLabelKind(body.kind)) {
|
||||
return reply.code(400).send({ error: 'bad_kind', allowed: LABEL_KINDS });
|
||||
}
|
||||
const label: ZoneLabel = {
|
||||
zoneId: String(req.params.zoneId),
|
||||
customerId: str(body.customerId),
|
||||
zoneName: str(body.zoneName),
|
||||
kind: body.kind,
|
||||
note: body.note == null ? null : String(body.note).slice(0, 500),
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: 'admin',
|
||||
};
|
||||
db.upsert(label);
|
||||
return label;
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/api/labels/:zoneId',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Params: ZoneParams }>, reply) => {
|
||||
if (!db.delete(req.params.zoneId)) return reply.code(404).send({ error: 'not_found' });
|
||||
return { deleted: true };
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Zone mirror (for anonymous browsing) --------------------------------
|
||||
const coord = (v: unknown): number | null => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n !== 0 ? n : null;
|
||||
};
|
||||
|
||||
app.get('/api/zones', async () => ({ zones: db.allZones(), count: db.zoneCount() }));
|
||||
|
||||
// The app pushes the Zone list it just pulled (authed) from ParkSmarter so
|
||||
// anonymous users can read areas without a ParkSmarter login.
|
||||
app.post(
|
||||
'/api/zones/sync',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: { zones?: unknown[] } }>, reply) => {
|
||||
const zones = Array.isArray(req.body?.zones) ? req.body!.zones : null;
|
||||
if (!zones) return reply.code(400).send({ error: 'zones_required' });
|
||||
const rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }> = [];
|
||||
for (const z of zones as Array<Record<string, unknown>>) {
|
||||
if (z == null || z.ZoneId == null) continue;
|
||||
rows.push({
|
||||
zoneId: String(z.ZoneId),
|
||||
zoneName: z.ZoneName != null ? String(z.ZoneName) : null,
|
||||
lat: coord(z.Lat),
|
||||
long: coord(z.Long),
|
||||
data: JSON.stringify(z),
|
||||
});
|
||||
}
|
||||
return { synced: db.upsertZones(rows), total: db.zoneCount() };
|
||||
},
|
||||
);
|
||||
|
||||
// ---- City parking-map areas ---------------------------------------------
|
||||
// The colour-coded areas from the city's printed Downtown & Waterfront parking
|
||||
// map, georeferenced. Purely local geography: none of this touches ParkSmarter,
|
||||
// which is the point — tracking time on these spots must work with no IPS call.
|
||||
|
||||
const num = (v: unknown, fallback: number): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
app.get('/api/areas', async () => ({
|
||||
areas: db.allAreas(),
|
||||
overlay: db.getOverlay(),
|
||||
count: db.areaCount(),
|
||||
updatedAt: db.areasUpdatedAt(),
|
||||
}));
|
||||
|
||||
// Replace-all, not upsert: the areas come from one source document, so a partial
|
||||
// update would leave a map matching neither the old print nor the new one.
|
||||
app.put(
|
||||
'/api/areas',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => {
|
||||
const input = Array.isArray(req.body?.areas) ? req.body!.areas : null;
|
||||
if (!input) return reply.code(400).send({ error: 'areas_required' });
|
||||
|
||||
const areas: ParkingArea[] = [];
|
||||
for (const raw of input as Array<Record<string, unknown>>) {
|
||||
if (raw == null || raw.id == null) continue;
|
||||
if (!isAreaKind(raw.kind)) {
|
||||
return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS });
|
||||
}
|
||||
const g = raw.geometry as { type?: unknown } | null;
|
||||
if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) {
|
||||
return reply.code(400).send({ error: 'bad_geometry', id: raw.id });
|
||||
}
|
||||
areas.push({
|
||||
id: String(raw.id),
|
||||
kind: raw.kind,
|
||||
name: String(raw.name ?? raw.id),
|
||||
label: String(raw.label ?? raw.kind),
|
||||
legend: String(raw.legend ?? ''),
|
||||
hours: num(raw.hours, 0),
|
||||
color: String(raw.color ?? '#888888'),
|
||||
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
||||
geometry: g,
|
||||
// Absent means "let the client decide by category"; only an explicit
|
||||
// boolean overrides a specific lot.
|
||||
requiresAccount: typeof raw.requiresAccount === 'boolean' ? raw.requiresAccount : null,
|
||||
});
|
||||
}
|
||||
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
|
||||
},
|
||||
);
|
||||
|
||||
// Whole-overlay alignment correction, set from the phone against a live GPS fix.
|
||||
app.put(
|
||||
'/api/areas/overlay',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: Record<string, unknown> }>, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const scale = num(b.scale, 1);
|
||||
if (scale <= 0.5 || scale >= 2) {
|
||||
// A fit that needs more than a ±2x correction is a broken fit, not a nudge.
|
||||
return reply.code(400).send({ error: 'scale_out_of_range' });
|
||||
}
|
||||
return db.setOverlay({
|
||||
dxMeters: num(b.dxMeters, 0),
|
||||
dyMeters: num(b.dyMeters, 0),
|
||||
scale,
|
||||
rotationDeg: num(b.rotationDeg, 0),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.addHook('onClose', async () => db.close());
|
||||
return app;
|
||||
}
|
||||
50
server/src/auth.ts
Normal file
50
server/src/auth.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** Constant-time string compare (guards the admin token check against timing attacks). */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks failed admin-auth attempts per IP and auto-blocks an IP for a cooldown
|
||||
* after too many failures in a rolling window. In-memory (resets on restart) —
|
||||
* complements the persistent manual denylist in the DB.
|
||||
*/
|
||||
export class AbuseGuard {
|
||||
private fails = new Map<string, number[]>();
|
||||
private blockedUntil = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly limit: number,
|
||||
private readonly windowMs: number,
|
||||
private readonly cooldownMs: number,
|
||||
) {}
|
||||
|
||||
isBlocked(ip: string, now: number): boolean {
|
||||
const until = this.blockedUntil.get(ip);
|
||||
if (until == null) return false;
|
||||
if (until > now) return true;
|
||||
this.blockedUntil.delete(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns true if this failure just tripped an auto-block. */
|
||||
recordFail(ip: string, now: number): boolean {
|
||||
const recent = (this.fails.get(ip) ?? []).filter((t) => now - t < this.windowMs);
|
||||
recent.push(now);
|
||||
if (recent.length >= this.limit) {
|
||||
this.blockedUntil.set(ip, now + this.cooldownMs);
|
||||
this.fails.delete(ip);
|
||||
return true;
|
||||
}
|
||||
this.fails.set(ip, recent);
|
||||
return false;
|
||||
}
|
||||
|
||||
recordSuccess(ip: string): void {
|
||||
this.fails.delete(ip);
|
||||
}
|
||||
}
|
||||
326
server/src/db.ts
Normal file
326
server/src/db.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
|
||||
export const LABEL_KINDS: LabelKind[] = ['free_2h', 'free_3h', 'free_4h', 'pay_immediate'];
|
||||
|
||||
export function isLabelKind(v: unknown): v is LabelKind {
|
||||
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
/** The five categories on the city's printed Downtown & Waterfront parking map. */
|
||||
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
|
||||
export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit'];
|
||||
|
||||
export function isAreaKind(v: unknown): v is AreaKind {
|
||||
return typeof v === 'string' && (AREA_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* One coloured area from the city map: an on-street segment (LineString) or a
|
||||
* city lot (Polygon). These are city geography, unrelated to ParkSmarter zones —
|
||||
* nothing here ever reaches the IPS API.
|
||||
*/
|
||||
export interface ParkingArea {
|
||||
id: string;
|
||||
kind: AreaKind;
|
||||
name: string;
|
||||
label: string;
|
||||
legend: string;
|
||||
hours: number;
|
||||
color: string;
|
||||
shape: 'line' | 'polygon';
|
||||
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
||||
geometry: unknown;
|
||||
/**
|
||||
* Overrides the client's by-category default (paid city lots need a
|
||||
* ParkSmarter account, free time-limited streets don't). Null means "use the
|
||||
* default"; set it only to correct a specific lot.
|
||||
*/
|
||||
requiresAccount: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-overlay correction. The map was georeferenced by fitting it to OSM, which
|
||||
* is good to a few metres but not perfect; this lets the alignment be nudged from the
|
||||
* phone against a live GPS fix and persisted, with no app release.
|
||||
*
|
||||
* Offsets are ground metres (east/north); scale and rotation apply about the
|
||||
* overlay's own centroid.
|
||||
*/
|
||||
export interface OverlayAdjust {
|
||||
dxMeters: number;
|
||||
dyMeters: number;
|
||||
scale: number;
|
||||
rotationDeg: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_OVERLAY: OverlayAdjust = {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
export interface ZoneLabel {
|
||||
zoneId: string;
|
||||
customerId: string | null;
|
||||
zoneName: string | null;
|
||||
kind: LabelKind;
|
||||
note: string | null;
|
||||
updatedAt: number;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
zone_id: string;
|
||||
customer_id: string | null;
|
||||
zone_name: string | null;
|
||||
kind: string;
|
||||
note: string | null;
|
||||
updated_at: number;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
const toLabel = (r: Row): ZoneLabel => ({
|
||||
zoneId: r.zone_id,
|
||||
customerId: r.customer_id,
|
||||
zoneName: r.zone_name,
|
||||
kind: r.kind as LabelKind,
|
||||
note: r.note,
|
||||
updatedAt: r.updated_at,
|
||||
updatedBy: r.updated_by,
|
||||
});
|
||||
|
||||
/** SQLite-backed store for zone labels + a manual IP denylist. */
|
||||
export class LabelDb {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(path: string) {
|
||||
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||
this.db = new Database(path);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zone_labels (
|
||||
zone_id TEXT PRIMARY KEY,
|
||||
customer_id TEXT,
|
||||
zone_name TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
note TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS blocked_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
zone_id TEXT PRIMARY KEY,
|
||||
zone_name TEXT,
|
||||
lat REAL,
|
||||
long REAL,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS parking_areas (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
legend TEXT NOT NULL,
|
||||
hours REAL NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
shape TEXT NOT NULL,
|
||||
geometry TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_overlay (
|
||||
id TEXT PRIMARY KEY,
|
||||
dx_meters REAL NOT NULL,
|
||||
dy_meters REAL NOT NULL,
|
||||
scale REAL NOT NULL,
|
||||
rotation REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// CREATE TABLE IF NOT EXISTS won't add a column to a table that already
|
||||
// exists, so added columns need an explicit migration.
|
||||
this.addColumnIfMissing('parking_areas', 'requires_account', 'INTEGER');
|
||||
}
|
||||
|
||||
private addColumnIfMissing(table: string, column: string, type: string): void {
|
||||
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (cols.some((c) => c.name === column)) return;
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ city parking-map areas */
|
||||
|
||||
allAreas(): ParkingArea[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM parking_areas ORDER BY id')
|
||||
.all() as Array<Record<string, any>>;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind as AreaKind,
|
||||
name: r.name,
|
||||
label: r.label,
|
||||
legend: r.legend,
|
||||
hours: r.hours,
|
||||
color: r.color,
|
||||
shape: r.shape as 'line' | 'polygon',
|
||||
geometry: JSON.parse(r.geometry),
|
||||
// SQLite has no boolean; null stays null so the client applies its default.
|
||||
requiresAccount: r.requires_account == null ? null : !!r.requires_account,
|
||||
}));
|
||||
}
|
||||
|
||||
areaCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
/** Newest updated_at across areas — the app uses it to skip redundant refreshes. */
|
||||
areasUpdatedAt(): number {
|
||||
const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as {
|
||||
t: number | null;
|
||||
};
|
||||
return r.t ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole area set in one transaction. The areas come from a single
|
||||
* source document, so a partial update would leave a map that matches neither
|
||||
* the old print nor the new one.
|
||||
*/
|
||||
replaceAreas(areas: ParkingArea[]): number {
|
||||
const now = Date.now();
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO parking_areas
|
||||
(id, kind, name, label, legend, hours, color, shape, geometry, requires_account, updated_at)
|
||||
VALUES
|
||||
(@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @requiresAccount, @updatedAt)`,
|
||||
);
|
||||
this.db.transaction((items: ParkingArea[]) => {
|
||||
this.db.prepare('DELETE FROM parking_areas').run();
|
||||
for (const a of items) {
|
||||
insert.run({
|
||||
...a,
|
||||
geometry: JSON.stringify(a.geometry),
|
||||
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
})(areas);
|
||||
return areas.length;
|
||||
}
|
||||
|
||||
getOverlay(): OverlayAdjust {
|
||||
const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
if (!r) return IDENTITY_OVERLAY;
|
||||
return {
|
||||
dxMeters: r.dx_meters,
|
||||
dyMeters: r.dy_meters,
|
||||
scale: r.scale,
|
||||
rotationDeg: r.rotation,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
setOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): OverlayAdjust {
|
||||
const updatedAt = Date.now();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at)
|
||||
VALUES ('default', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters,
|
||||
scale = excluded.scale, rotation = excluded.rotation,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt);
|
||||
return { ...o, updatedAt };
|
||||
}
|
||||
|
||||
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
|
||||
upsertZones(
|
||||
rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>,
|
||||
): number {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO zones (zone_id, zone_name, lat, long, data, updated_at)
|
||||
VALUES (@zoneId, @zoneName, @lat, @long, @data, @updatedAt)
|
||||
ON CONFLICT(zone_id) DO UPDATE SET
|
||||
zone_name = excluded.zone_name,
|
||||
lat = excluded.lat, long = excluded.long,
|
||||
data = excluded.data, updated_at = excluded.updated_at`,
|
||||
);
|
||||
const now = Date.now();
|
||||
this.db.transaction((items: typeof rows) => {
|
||||
for (const it of items) stmt.run({ ...it, updatedAt: now });
|
||||
})(rows);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/** All mirrored zones as their original Zone objects. */
|
||||
allZones(): unknown[] {
|
||||
const rows = this.db.prepare('SELECT data FROM zones').all() as { data: string }[];
|
||||
return rows.map((r) => JSON.parse(r.data));
|
||||
}
|
||||
|
||||
zoneCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
all(): ZoneLabel[] {
|
||||
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
|
||||
}
|
||||
|
||||
get(zoneId: string): ZoneLabel | undefined {
|
||||
const r = this.db.prepare('SELECT * FROM zone_labels WHERE zone_id = ?').get(zoneId) as Row | undefined;
|
||||
return r ? toLabel(r) : undefined;
|
||||
}
|
||||
|
||||
upsert(label: ZoneLabel): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO zone_labels (zone_id, customer_id, zone_name, kind, note, updated_at, updated_by)
|
||||
VALUES (@zoneId, @customerId, @zoneName, @kind, @note, @updatedAt, @updatedBy)
|
||||
ON CONFLICT(zone_id) DO UPDATE SET
|
||||
customer_id = excluded.customer_id,
|
||||
zone_name = excluded.zone_name,
|
||||
kind = excluded.kind,
|
||||
note = excluded.note,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by`,
|
||||
)
|
||||
.run(label);
|
||||
}
|
||||
|
||||
delete(zoneId: string): boolean {
|
||||
return this.db.prepare('DELETE FROM zone_labels WHERE zone_id = ?').run(zoneId).changes > 0;
|
||||
}
|
||||
|
||||
isBlocked(ip: string): boolean {
|
||||
return !!this.db.prepare('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip);
|
||||
}
|
||||
|
||||
block(ip: string, reason: string): void {
|
||||
this.db
|
||||
.prepare('INSERT OR REPLACE INTO blocked_ips (ip, reason, created_at) VALUES (?, ?, ?)')
|
||||
.run(ip, reason, Date.now());
|
||||
}
|
||||
|
||||
seedBlocked(ips: string[]): void {
|
||||
for (const ip of ips) this.block(ip, 'seed');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
34
server/src/index.ts
Normal file
34
server/src/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { buildApp } from './app.js';
|
||||
|
||||
const adminToken = process.env.BBP_ADMIN_TOKEN ?? '';
|
||||
if (adminToken.length < 16) {
|
||||
console.error('FATAL: BBP_ADMIN_TOKEN is missing or shorter than 16 chars.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = await buildApp({
|
||||
adminToken,
|
||||
dbPath: process.env.BBP_DB_PATH ?? '/data/labels.db',
|
||||
trustProxy: true,
|
||||
seedBlockedIps: (process.env.BBP_BLOCKED_IPS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 8090);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
console.log(`bbp-labels listening on ${host}:${port}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(sig, () => {
|
||||
void app.close().then(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
248
server/test/areas.test.ts
Normal file
248
server/test/areas.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp, type BuildOptions } from '../src/app.ts';
|
||||
|
||||
const TOKEN = 'test-admin-token-0123456789';
|
||||
const auth = { authorization: `Bearer ${TOKEN}` };
|
||||
|
||||
const make = (o: Partial<BuildOptions> = {}) =>
|
||||
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
|
||||
|
||||
const line = (id: string, kind = 'free_2h') => ({
|
||||
id,
|
||||
kind,
|
||||
name: `${id} name`,
|
||||
label: '2-hour free',
|
||||
legend: 'Permits not valid',
|
||||
hours: 2,
|
||||
color: '#d367cc',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-116.5535, 48.2766],
|
||||
[-116.5525, 48.2766],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test('areas are public to read and admin-only to replace', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.deepEqual(r.json().areas, []);
|
||||
assert.equal(r.json().count, 0);
|
||||
|
||||
r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } });
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().replaced, 2);
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
const areas = r.json().areas;
|
||||
assert.equal(areas.length, 2);
|
||||
assert.equal(areas[0].id, 'sp-001');
|
||||
assert.equal(areas[0].kind, 'free_2h');
|
||||
assert.equal(areas[0].shape, 'line');
|
||||
// Geometry survives the round-trip as real GeoJSON, not a string.
|
||||
assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('replace is wholesale — stale areas do not survive', async () => {
|
||||
const app = await make();
|
||||
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-001'), line('sp-002')] },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-003')] },
|
||||
});
|
||||
|
||||
const r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().areas[0].id, 'sp-003');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('polygons are accepted; bad kinds and geometries are rejected', async () => {
|
||||
const app = await make();
|
||||
|
||||
const lot = {
|
||||
id: 'sp-022',
|
||||
kind: 'green_lot',
|
||||
name: 'Lot off Oak St',
|
||||
label: 'City lot',
|
||||
legend: 'Paid hourly or permit',
|
||||
hours: 2,
|
||||
color: '#75b259',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-116.554, 48.2766],
|
||||
[-116.553, 48.2766],
|
||||
[-116.553, 48.2772],
|
||||
[-116.554, 48.2766],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [lot] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().replaced, 1);
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().areas[0].shape, 'polygon');
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_kind');
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_geometry');
|
||||
|
||||
// A rejected batch must not have clobbered the good one.
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().areas[0].id, 'sp-022');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('requiresAccount is null unless explicitly overridden', async () => {
|
||||
const app = await make();
|
||||
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: {
|
||||
areas: [
|
||||
line('sp-001'), // no override -> client decides by category
|
||||
{ ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account
|
||||
{ ...line('sp-003'), requiresAccount: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||
const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount]));
|
||||
assert.equal(by['sp-001'], null, 'no override should stay null, not become false');
|
||||
assert.equal(by['sp-002'], false);
|
||||
assert.equal(by['sp-003'], true);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('a database created before requires_account existed still works', async () => {
|
||||
// Simulate an older deployment: build the table without the column, then let
|
||||
// the migration add it. CREATE TABLE IF NOT EXISTS alone would not.
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
const file = `/tmp/bbp-migrate-${process.pid}.db`;
|
||||
const raw = new Database(file);
|
||||
raw.exec(`CREATE TABLE parking_areas (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL,
|
||||
legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL,
|
||||
geometry TEXT NOT NULL, updated_at INTEGER NOT NULL)`);
|
||||
raw.close();
|
||||
|
||||
const app = await make({ dbPath: file });
|
||||
const r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||
assert.equal(areas[0].requiresAccount, true);
|
||||
|
||||
await app.close();
|
||||
(await import('node:fs')).rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
test('overlay defaults to identity and round-trips', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.deepEqual(r.json().overlay, {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
payload: { dxMeters: 3 },
|
||||
});
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
headers: auth,
|
||||
payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().dxMeters, 3.5);
|
||||
assert.ok(r.json().updatedAt > 0);
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().overlay.dyMeters, -2);
|
||||
assert.equal(r.json().overlay.rotationDeg, 0.4);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('an absurd overlay scale is refused rather than stored', async () => {
|
||||
const app = await make();
|
||||
|
||||
for (const scale of [0, 0.4, 2, 5]) {
|
||||
const r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
headers: auth,
|
||||
payload: { scale },
|
||||
});
|
||||
assert.equal(r.statusCode, 400, `scale ${scale} should be refused`);
|
||||
}
|
||||
|
||||
const r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().overlay.scale, 1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
140
server/test/labels.test.ts
Normal file
140
server/test/labels.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp, type BuildOptions } from '../src/app.ts';
|
||||
|
||||
const TOKEN = 'test-admin-token-0123456789';
|
||||
const auth = { authorization: `Bearer ${TOKEN}` };
|
||||
|
||||
const make = (o: Partial<BuildOptions> = {}) =>
|
||||
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
|
||||
|
||||
test('reads are public; writes require the admin token', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/labels' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.deepEqual(r.json().labels, []);
|
||||
|
||||
r = await app.inject({ method: 'PUT', url: '/api/labels/113165', payload: { kind: 'free_2h' } });
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/labels/113165',
|
||||
headers: auth,
|
||||
payload: { kind: 'free_2h', zoneName: 'DL', customerId: 217 },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().kind, 'free_2h');
|
||||
assert.equal(r.json().zoneName, 'DL');
|
||||
assert.equal(r.json().customerId, '217');
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/labels/113165' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().kind, 'free_2h');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('upsert replaces the kind', async () => {
|
||||
const app = await make();
|
||||
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_2h' } });
|
||||
await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'pay_immediate' } });
|
||||
const r = await app.inject({ method: 'GET', url: '/api/labels/1' });
|
||||
assert.equal(r.json().kind, 'pay_immediate');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('invalid kind is rejected', async () => {
|
||||
const app = await make();
|
||||
const r = await app.inject({ method: 'PUT', url: '/api/labels/1', headers: auth, payload: { kind: 'free_9h' } });
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_kind');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('unknown zone → 404 on GET and DELETE', async () => {
|
||||
const app = await make();
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/nope' })).statusCode, 404);
|
||||
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/nope', headers: auth })).statusCode, 404);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('delete removes the label', async () => {
|
||||
const app = await make();
|
||||
await app.inject({ method: 'PUT', url: '/api/labels/9', headers: auth, payload: { kind: 'free_4h' } });
|
||||
assert.equal((await app.inject({ method: 'DELETE', url: '/api/labels/9', headers: auth })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/api/labels/9' })).statusCode, 404);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('whoami reflects the token', async () => {
|
||||
const app = await make();
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/api/whoami' })).statusCode, 401);
|
||||
const ok = await app.inject({ method: 'GET', url: '/api/whoami', headers: auth });
|
||||
assert.equal(ok.statusCode, 200);
|
||||
assert.equal(ok.json().admin, true);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('repeated auth failures auto-block the IP (403)', async () => {
|
||||
const app = await make({ authFailLimit: 3, blockCooldownMs: 60_000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/labels/1',
|
||||
headers: { authorization: 'Bearer wrong' },
|
||||
payload: { kind: 'free_2h' },
|
||||
});
|
||||
}
|
||||
const r = await app.inject({ method: 'GET', url: '/api/labels' }); // even a public read is now blocked
|
||||
assert.equal(r.statusCode, 403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('rate limit returns 429 past the threshold', async () => {
|
||||
const app = await make({ rateLimitMax: 3, rateLimitWindow: '1 minute' });
|
||||
let last;
|
||||
for (let i = 0; i < 5; i++) last = await app.inject({ method: 'GET', url: '/api/labels' });
|
||||
assert.equal(last!.statusCode, 429);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('zone mirror: admin sync then public read', async () => {
|
||||
const app = await make();
|
||||
// sync requires auth
|
||||
let r = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/zones/sync',
|
||||
payload: { zones: [{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55 }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 401);
|
||||
// authed sync (one zone lacks ZoneId → skipped)
|
||||
r = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/zones/sync',
|
||||
headers: auth,
|
||||
payload: {
|
||||
zones: [
|
||||
{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55, Spaces: [{ SpaceId: 1 }] },
|
||||
{ ZoneName: 'no-id' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().synced, 1);
|
||||
// public read returns full Zone objects
|
||||
r = await app.inject({ method: 'GET', url: '/api/zones' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().zones[0].ZoneName, 'DL');
|
||||
assert.equal(r.json().zones[0].Spaces[0].SpaceId, 1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('seeded IP denylist blocks with 403', async () => {
|
||||
// app.inject uses 127.0.0.1 as the client IP
|
||||
const app = await make({ seedBlockedIps: ['127.0.0.1'] });
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/api/labels' })).statusCode, 403);
|
||||
await app.close();
|
||||
});
|
||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
72
tools/citymap/README.md
Normal file
72
tools/citymap/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Georeferencing the city parking map
|
||||
|
||||
Turns the City of Sandpoint's printed **Downtown & Waterfront Public Parking** PDF into
|
||||
`app/src/features/citymap/parkingAreas.json` — the colour-coded overlay the app draws and
|
||||
hit-tests against.
|
||||
|
||||
Run this again when the city publishes a new edition of the map.
|
||||
|
||||
## Why it needs doing at all
|
||||
|
||||
The PDF carries **no** georeferencing metadata (no `/Measure`, `/GPTS`, `/LPTS`, `/GEO`,
|
||||
`/Viewport`, `/GCS`). It is a north-up Web Mercator screenshot of a slippy map with vector
|
||||
parking stripes drawn on top, so page coordinates relate to the world by a plain affine
|
||||
transform — which has to be recovered by fitting the drawing to something we already know
|
||||
the coordinates of. That something is OpenStreetMap's street centrelines.
|
||||
|
||||
Two structural details cost the most time, so they are worth knowing up front:
|
||||
|
||||
- `pdftocairo` writes each **stroked** street segment with its own `matrix()` transform and
|
||||
*local* coordinates, while **filled** lots are in absolute page coordinates. Both have to
|
||||
be handled or the streets land in a heap near the origin.
|
||||
- The legend swatches are drawn in the same five colours as the real geometry. They are
|
||||
identified by stroke-width 7 inside the legend card's x-band and dropped.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```bash
|
||||
# 0. deps: poppler-utils (pdftocairo, pdftotext, pdfimages), python3, curl
|
||||
pdftocairo -svg downtown_and_waterfront_public_parking_map.pdf map.svg
|
||||
|
||||
# 1. vector geometry -> page coordinates, bucketed by the legend's five colours
|
||||
python3 extract_map.py map.svg map_page_coords.json
|
||||
|
||||
# 2. OSM street centrelines for downtown Sandpoint
|
||||
curl -s --data-binary @roads.overpass https://overpass-api.de/api/interpreter -o osm.json
|
||||
|
||||
# 3. fit page -> Web Mercator against named streets; prints per-street residuals
|
||||
python3 georef.py # writes fit_raw.json
|
||||
|
||||
# 4. apply the fit, name each area from OSM, verify, emit the GeoJSON
|
||||
python3 build_geojson.py # writes parking_areas.geojson
|
||||
|
||||
cp parking_areas.geojson ../../app/src/features/citymap/parkingAreas.json
|
||||
```
|
||||
|
||||
## What "good" looks like
|
||||
|
||||
`georef.py` prints a residual per control street and `build_geojson.py` prints how far each
|
||||
on-street segment sits from the nearest OSM road. The current edition fits to:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| X control residual (avenues) | **RMS 4.1 m** |
|
||||
| Y control residual (streets) | **RMS 3.5 m** |
|
||||
| On-street segments vs nearest OSM road | **mean 4.0 m**, 40 of 42 under 10 m |
|
||||
|
||||
The two segments over 10 m (`sp-039`, `sp-040`) are correct, not errors: they are angled
|
||||
bays along the old rail corridor that sit on no named road at all — the nearest way is a
|
||||
service alley 19 m off. Anything much worse than the table above means a control street was
|
||||
mis-identified; `georef.py`'s per-street residuals will say which.
|
||||
|
||||
Residual error is also correctable after the fact without re-running any of this — the app's
|
||||
**Account → Align city map** screen nudges the whole overlay against a live GPS fix and
|
||||
persists the correction.
|
||||
|
||||
## Control points
|
||||
|
||||
`georef.py` maps page grid lines to OSM street names by hand (`AVENUES` / `STREETS`). Sandpoint's
|
||||
grid **jogs** between its north and south halves — North 2nd Ave and South 2nd Ave are 38 m
|
||||
apart — so each control street is measured only over the span its page segment actually
|
||||
covers, and both halves are used as independent control points. That jog is a useful sanity
|
||||
check: the page shows the same 9.3 pt offset, which at the fitted scale is 38 m.
|
||||
190
tools/citymap/build_geojson.py
Normal file
190
tools/citymap/build_geojson.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the fitted transform and emit the final parking-areas GeoJSON.
|
||||
|
||||
Also verifies the result the only way that matters: every stroked segment should
|
||||
land on an actual road, so measure each one's distance to the nearest OSM road
|
||||
centreline. Lots are skipped in that check — they are off-street by definition.
|
||||
|
||||
Each feature gets a human name from OSM (the street it runs along, plus the two
|
||||
cross streets it lies between) so the app can list areas without the map.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
COS = math.cos(math.radians(48.278))
|
||||
|
||||
fit = json.load(open("fit_raw.json"))
|
||||
SX, TX, SY, TY = fit["sx"], fit["tx"], fit["sy"], fit["ty"]
|
||||
|
||||
|
||||
def to_merc(x, y):
|
||||
return (SX * x + TX, SY * y + TY)
|
||||
|
||||
|
||||
def to_lonlat(x, y):
|
||||
X, Y = to_merc(x, y)
|
||||
return (round(math.degrees(X / R), 7), round(math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), 7))
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def dist_to_seg(p, a, b):
|
||||
px, py = p
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
dx, dy = bx - ax, by - ay
|
||||
L = dx * dx + dy * dy
|
||||
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
|
||||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- OSM roads
|
||||
osm = json.load(open("osm.json"))
|
||||
SKIP = {"footway", "path", "cycleway", "steps", "track", "service"}
|
||||
roads = [] # (a, b, name) in mercator
|
||||
for w in osm["elements"]:
|
||||
t = w.get("tags", {})
|
||||
if t.get("highway") in SKIP or "geometry" not in w:
|
||||
continue
|
||||
g = [merc(p["lat"], p["lon"]) for p in w["geometry"]]
|
||||
nm = t.get("name")
|
||||
for a, b in zip(g, g[1:]):
|
||||
roads.append((a, b, nm))
|
||||
named = [r for r in roads if r[2]]
|
||||
|
||||
SHORT = [
|
||||
("North ", "N "), ("South ", "S "), ("East ", "E "), ("West ", "W "),
|
||||
(" Street", " St"), (" Avenue", " Ave"), (" Boulevard", " Blvd"),
|
||||
(" Road", " Rd"), (" Drive", " Dr"), (" Lane", " Ln"), (" Bridge", " Brg"),
|
||||
]
|
||||
|
||||
|
||||
def short(n):
|
||||
for a, b in SHORT:
|
||||
n = n.replace(a, b)
|
||||
return n
|
||||
|
||||
|
||||
def nearest_name(p, exclude=None, limit=60.0):
|
||||
best, bestd = None, limit
|
||||
for a, b, nm in named:
|
||||
if nm == exclude:
|
||||
continue
|
||||
d = dist_to_seg(p, a, b)
|
||||
if d < bestd:
|
||||
best, bestd = nm, d
|
||||
return best
|
||||
|
||||
|
||||
def describe(pts_merc, is_line):
|
||||
"""'N 3rd Ave · Cedar St to Oak St' for a segment, or the nearest road for a lot."""
|
||||
mid = pts_merc[len(pts_merc) // 2]
|
||||
on = nearest_name(mid) if is_line else None
|
||||
if not is_line:
|
||||
near = nearest_name(mid, limit=200.0)
|
||||
return f"Lot off {short(near)}" if near else "City lot"
|
||||
ends = [pts_merc[0], pts_merc[-1]]
|
||||
cross = []
|
||||
for e in ends:
|
||||
c = nearest_name(e, exclude=on, limit=45.0)
|
||||
if c and short(c) not in cross:
|
||||
cross.append(short(c))
|
||||
if not on:
|
||||
# Bays along the old rail corridor sit on no named road — describe them
|
||||
# by what they are near rather than inventing a street.
|
||||
near = nearest_name(mid, limit=150.0)
|
||||
return f"Off-street bays near {short(near)}" if near else "Off-street bays"
|
||||
base = short(on)
|
||||
if len(cross) == 2:
|
||||
return f"{base} · {cross[0]} to {cross[1]}"
|
||||
if len(cross) == 1:
|
||||
return f"{base} · at {cross[0]}"
|
||||
return base
|
||||
|
||||
|
||||
# --------------------------------------------------------------- build output
|
||||
page = json.load(open("map_page_coords.json"))
|
||||
|
||||
|
||||
def is_legend(f):
|
||||
"""The five legend swatches: stroke-width 7 sitting in the legend card's x-band."""
|
||||
x0, y0, x1, y1 = f["bbox"]
|
||||
return f["strokeWidth"] > 5 and 25 < x0 < 28 and 60 < y0 < 130
|
||||
|
||||
|
||||
# kind -> (short label, legend text, default tracked hours, colour)
|
||||
KINDS = {
|
||||
"green_lot": ("City lot", "Paid hourly or permit", 2, "#75b259"),
|
||||
"free_2h": ("2-hour free", "Permits not valid", 2, "#d367cc"),
|
||||
"limit_3h": ("3-hour", "3-hour or permit", 3, "#ccc542"),
|
||||
"limit_4h": ("4-hour", "4-hour or permit", 4, "#f78b08"),
|
||||
"no_limit": ("No time limit", "No posted time limit", 0, "#c3c4c2"),
|
||||
}
|
||||
|
||||
features = []
|
||||
n_legend = 0
|
||||
for i, f in enumerate(page["features"]):
|
||||
if is_legend(f):
|
||||
n_legend += 1
|
||||
continue
|
||||
is_line = f["geom"] == "line"
|
||||
pm = [to_merc(x, y) for x, y in f["points"]]
|
||||
coords = [to_lonlat(x, y) for x, y in f["points"]]
|
||||
if is_line:
|
||||
geom = {"type": "LineString", "coordinates": coords}
|
||||
else:
|
||||
if coords[0] != coords[-1]:
|
||||
coords.append(coords[0])
|
||||
geom = {"type": "Polygon", "coordinates": [coords]}
|
||||
label, legend, hours, color = KINDS[f["kind"]]
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": f"sp-{i:03d}",
|
||||
"geometry": geom,
|
||||
"properties": {
|
||||
"id": f"sp-{i:03d}",
|
||||
"kind": f["kind"],
|
||||
"label": label,
|
||||
"legend": legend,
|
||||
"hours": hours,
|
||||
"color": color,
|
||||
"shape": "line" if is_line else "polygon",
|
||||
"name": describe(pm, is_line),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fc = {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"metadata": {
|
||||
"source": "City of Sandpoint — Downtown & Waterfront Public Parking map",
|
||||
"generated": "from downtown_and_waterfront_public_parking_map.pdf",
|
||||
"georeference": "affine page->WebMercator fitted to OSM street centrelines",
|
||||
},
|
||||
}
|
||||
json.dump(fc, open("parking_areas.geojson", "w"), indent=1)
|
||||
print(f"{len(features)} features written ({n_legend} legend swatches dropped)\n")
|
||||
for f in features:
|
||||
p = f["properties"]
|
||||
print(f" {p['id']} {p['kind']:10s} {p['shape']:7s} {p['name']}")
|
||||
|
||||
# ---- verification: distance from each on-street segment to the nearest road
|
||||
worst = []
|
||||
for f in features:
|
||||
if f["properties"]["shape"] != "line":
|
||||
continue
|
||||
ds = [min(dist_to_seg(merc(lat, lon), a, b) for a, b, _ in roads) * COS
|
||||
for lon, lat in f["geometry"]["coordinates"]]
|
||||
worst.append((max(ds), sum(ds) / len(ds), f["properties"]["id"], f["properties"]["kind"]))
|
||||
|
||||
worst.sort(reverse=True)
|
||||
print(f"\non-street segments: {len(worst)}, mean offset from nearest road = "
|
||||
f"{sum(m for _, m, _, _ in worst)/len(worst):.1f} m")
|
||||
print(f"segments with mean offset > 10 m: {sum(1 for _, m, _, _ in worst if m > 10)}")
|
||||
for mx, mn, fid, kind in worst[:4]:
|
||||
print(f" worst: {fid} {kind:10s} max={mx:6.1f} m mean={mn:6.1f} m")
|
||||
195
tools/citymap/extract_map.py
Normal file
195
tools/citymap/extract_map.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Extract the color-coded parking geometry from the city's parking-map PDF.
|
||||
|
||||
pdftocairo emits every street segment as a stroked <path> carrying its own
|
||||
matrix() transform with local coordinates, and every lot as an absolute filled
|
||||
<path>. So: parse the path, flatten curves, push through the path's own matrix,
|
||||
and bucket by the exact colour pdftocairo wrote.
|
||||
|
||||
Output is GeoJSON-shaped but still in SVG *page* coordinates (y down); the
|
||||
georeferencing step turns that into lat/lon.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
SVG = sys.argv[1] if len(sys.argv) > 1 else "/tmp/map.svg"
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else "map_page_coords.json"
|
||||
|
||||
# Colours exactly as pdftocairo writes them, mapped to the map legend.
|
||||
COLORS = {
|
||||
"rgb(76.499939%, 76.899719%, 76.098633%)": "no_limit",
|
||||
"rgb(96.899414%, 54.499817%, 3.09906%)": "limit_4h",
|
||||
"rgb(79.998779%, 77.2995%, 25.898743%)": "limit_3h",
|
||||
"rgb(82.699585%, 40.39917%, 79.998779%)": "free_2h",
|
||||
"rgb(45.899963%, 69.799805%, 34.899902%)": "green_lot",
|
||||
}
|
||||
|
||||
NUM = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
||||
|
||||
|
||||
def parse_matrix(s):
|
||||
"""matrix(a,b,c,d,e,f) -> tuple. Identity when absent."""
|
||||
if not s:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
m = re.search(r"matrix\s*\(([^)]*)\)", s)
|
||||
if not m:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
v = [float(x) for x in NUM.findall(m.group(1))]
|
||||
return tuple(v[:6])
|
||||
|
||||
|
||||
def apply(mtx, x, y):
|
||||
a, b, c, d, e, f = mtx
|
||||
return (a * x + c * y + e, b * x + d * y + f)
|
||||
|
||||
|
||||
def bezier(p0, p1, p2, p3, steps=8):
|
||||
"""Flatten a cubic to points. The map's curves are gentle; 8 is plenty."""
|
||||
out = []
|
||||
for i in range(1, steps + 1):
|
||||
t = i / steps
|
||||
u = 1 - t
|
||||
out.append(
|
||||
(
|
||||
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
||||
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_path(d):
|
||||
"""Return a list of subpaths [(points, closed)] in the path's local space."""
|
||||
tokens = re.findall(r"([MmLlHhVvCcSsZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", d)
|
||||
subpaths, pts = [], []
|
||||
cur = (0.0, 0.0)
|
||||
start = (0.0, 0.0)
|
||||
cmd = None
|
||||
i = 0
|
||||
flat = [(c, n) for c, n in tokens]
|
||||
|
||||
def nums(k):
|
||||
nonlocal i
|
||||
vals = []
|
||||
while len(vals) < k and i < len(flat) and flat[i][1]:
|
||||
vals.append(float(flat[i][1]))
|
||||
i += 1
|
||||
return vals
|
||||
|
||||
while i < len(flat):
|
||||
c, n = flat[i]
|
||||
if c:
|
||||
cmd = c
|
||||
i += 1
|
||||
elif cmd is None:
|
||||
i += 1
|
||||
continue
|
||||
if cmd in "Zz":
|
||||
if pts:
|
||||
subpaths.append((pts, True))
|
||||
pts = []
|
||||
cur = start
|
||||
cmd = None
|
||||
continue
|
||||
if cmd in "Mm":
|
||||
v = nums(2)
|
||||
if len(v) < 2:
|
||||
break
|
||||
if pts:
|
||||
subpaths.append((pts, False))
|
||||
pts = []
|
||||
cur = (v[0], v[1]) if cmd == "M" else (cur[0] + v[0], cur[1] + v[1])
|
||||
start = cur
|
||||
pts = [cur]
|
||||
cmd = "L" if cmd == "M" else "l"
|
||||
elif cmd in "Ll":
|
||||
v = nums(2)
|
||||
if len(v) < 2:
|
||||
break
|
||||
cur = (v[0], v[1]) if cmd == "L" else (cur[0] + v[0], cur[1] + v[1])
|
||||
pts.append(cur)
|
||||
elif cmd in "Hh":
|
||||
v = nums(1)
|
||||
if not v:
|
||||
break
|
||||
cur = (v[0], cur[1]) if cmd == "H" else (cur[0] + v[0], cur[1])
|
||||
pts.append(cur)
|
||||
elif cmd in "Vv":
|
||||
v = nums(1)
|
||||
if not v:
|
||||
break
|
||||
cur = (cur[0], v[0]) if cmd == "V" else (cur[0], cur[1] + v[0])
|
||||
pts.append(cur)
|
||||
elif cmd in "Cc":
|
||||
v = nums(6)
|
||||
if len(v) < 6:
|
||||
break
|
||||
if cmd == "C":
|
||||
p1, p2, p3 = (v[0], v[1]), (v[2], v[3]), (v[4], v[5])
|
||||
else:
|
||||
p1 = (cur[0] + v[0], cur[1] + v[1])
|
||||
p2 = (cur[0] + v[2], cur[1] + v[3])
|
||||
p3 = (cur[0] + v[4], cur[1] + v[5])
|
||||
pts.extend(bezier(cur, p1, p2, p3))
|
||||
cur = p3
|
||||
else:
|
||||
i += 1
|
||||
if pts:
|
||||
subpaths.append((pts, False))
|
||||
return subpaths
|
||||
|
||||
|
||||
def main():
|
||||
tree = ET.parse(SVG)
|
||||
root = tree.getroot()
|
||||
ns = "{http://www.w3.org/2000/svg}"
|
||||
|
||||
features = []
|
||||
for el in root.iter(ns + "path"):
|
||||
stroke = (el.get("stroke") or "").strip()
|
||||
fill = (el.get("fill") or "").strip()
|
||||
kind = COLORS.get(stroke) or COLORS.get(fill)
|
||||
if not kind:
|
||||
continue
|
||||
is_stroke = stroke in COLORS
|
||||
mtx = parse_matrix(el.get("transform"))
|
||||
width = float(el.get("stroke-width") or 0)
|
||||
for local, closed in parse_path(el.get("d") or ""):
|
||||
world = [apply(mtx, x, y) for x, y in local]
|
||||
if len(world) < 2:
|
||||
continue
|
||||
xs = [p[0] for p in world]
|
||||
ys = [p[1] for p in world]
|
||||
features.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"geom": "line" if is_stroke else "polygon",
|
||||
"closed": closed,
|
||||
"strokeWidth": width,
|
||||
"bbox": [min(xs), min(ys), max(xs), max(ys)],
|
||||
"points": [[round(x, 3), round(y, 3)] for x, y in world],
|
||||
}
|
||||
)
|
||||
|
||||
with open(OUT, "w") as fh:
|
||||
json.dump({"viewBox": [0, 0, 491.87, 529.043], "features": features}, fh, indent=1)
|
||||
|
||||
from collections import Counter
|
||||
print(f"{len(features)} features -> {OUT}")
|
||||
for (k, g), n in sorted(Counter((f["kind"], f["geom"]) for f in features).items()):
|
||||
print(f" {k:10s} {g:8s} {n}")
|
||||
# Where are they? Legend swatches cluster in one corner; real geometry spreads out.
|
||||
print("\nbbox spread by kind:")
|
||||
for k in COLORS.values():
|
||||
fs = [f for f in features if f["kind"] == k]
|
||||
if not fs:
|
||||
continue
|
||||
print(
|
||||
f" {k:10s} x[{min(f['bbox'][0] for f in fs):7.1f},{max(f['bbox'][2] for f in fs):7.1f}] "
|
||||
f"y[{min(f['bbox'][1] for f in fs):7.1f},{max(f['bbox'][3] for f in fs):7.1f}]"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
114
tools/citymap/georef.py
Normal file
114
tools/citymap/georef.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Georeference the parking-map page coordinates against OSM.
|
||||
|
||||
The base map is a north-up Web Mercator screenshot, so page -> mercator is a
|
||||
uniform scale plus a translation (3 free params, not 6). Control points are
|
||||
street centrelines identified by name: an avenue pins X, a street pins Y.
|
||||
|
||||
Streets in Sandpoint jog between the north and south halves of the grid, so each
|
||||
control street's mercator coordinate is measured only over the span the page
|
||||
segment actually covers, not over the whole way.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def unmerc(X, Y):
|
||||
return (math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), math.degrees(X / R))
|
||||
|
||||
|
||||
osm = json.load(open("osm.json"))
|
||||
ways = {}
|
||||
for w in osm["elements"]:
|
||||
n = w.get("tags", {}).get("name")
|
||||
if not n or "geometry" not in w:
|
||||
continue
|
||||
ways.setdefault(n, []).append([merc(p["lat"], p["lon"]) for p in w["geometry"]])
|
||||
|
||||
|
||||
def centreline(name, axis, lo, hi):
|
||||
"""Mean coordinate on `axis` of `name`, over the other axis' [lo,hi] window."""
|
||||
other = 1 - axis
|
||||
vals = []
|
||||
for g in ways.get(name, []):
|
||||
for (x0, y0), (x1, y1) in zip(g, g[1:]):
|
||||
p0, p1 = (x0, y0), (x1, y1)
|
||||
if not (lo <= p0[other] <= hi or lo <= p1[other] <= hi):
|
||||
continue
|
||||
vals.append((p0[axis] + p1[axis]) / 2)
|
||||
return sum(vals) / len(vals) if vals else None
|
||||
|
||||
|
||||
# Page grid lines read off the rendered map, with the mercator window each spans.
|
||||
# X window for E-W streets / Y window for N-S avenues, in mercator metres.
|
||||
XW = (-12974700, -12974000) # 5th Ave .. 1st Ave
|
||||
YW = (6152200, 6153300) # Lake St .. Poplar St
|
||||
|
||||
AVENUES = [ # page x, OSM name
|
||||
(83.1, "North 5th Avenue"),
|
||||
(120.0, "North 4th Avenue"),
|
||||
(163.8, "North 3rd Avenue"),
|
||||
(207.7, "North 2nd Avenue"),
|
||||
(235.7, "North 1st Avenue"),
|
||||
(164.3, "South 3rd Avenue"),
|
||||
(198.4, "South 2nd Avenue"),
|
||||
]
|
||||
STREETS = [ # page y, OSM name
|
||||
(184.4, "Poplar Street"),
|
||||
(228.3, "Alder Street"),
|
||||
(272.3, "Cedar Street"),
|
||||
(314.9, "Oak Street"),
|
||||
(359.1, "Church Street"),
|
||||
(398.0, "Pine Street"),
|
||||
(438.2, "Lake Street"),
|
||||
(492.1, "Superior Street"),
|
||||
]
|
||||
|
||||
|
||||
def fit(pairs, flip):
|
||||
"""Least-squares v = s*p + t. Returns (s, t, residuals)."""
|
||||
n = len(pairs)
|
||||
sp = sum(p for p, v in pairs)
|
||||
sv = sum(v for p, v in pairs)
|
||||
spp = sum(p * p for p, v in pairs)
|
||||
spv = sum(p * v for p, v in pairs)
|
||||
s = (n * spv - sp * sv) / (n * spp - sp * sp)
|
||||
t = (sv - s * sp) / n
|
||||
return s, t, [(p, v, s * p + t - v) for p, v in pairs]
|
||||
|
||||
|
||||
ax = [(px, centreline(n, 0, *YW)) for px, n in AVENUES]
|
||||
ay = [(py, centreline(n, 1, *XW)) for py, n in STREETS]
|
||||
print("control points (mercator metres):")
|
||||
for (px, n), (_, v) in zip(AVENUES, ax):
|
||||
print(f" x {px:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
for (py, n), (_, v) in zip(STREETS, ay):
|
||||
print(f" y {py:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
|
||||
ax = [(p, v) for p, v in ax if v is not None]
|
||||
ay = [(p, v) for p, v in ay if v is not None]
|
||||
|
||||
COS = math.cos(math.radians(48.278)) # mercator metres -> ground metres here
|
||||
|
||||
|
||||
def report(label, pairs, names):
|
||||
s, t, res = fit(pairs, False)
|
||||
print(f"\n{label}: scale={s:.4f} merc-m/pt ({abs(s)*COS:.4f} ground-m/pt), offset={t:.1f}")
|
||||
for (p, v, r), nm in zip(res, names):
|
||||
print(f" {nm:20s} page={p:7.1f} residual={r*COS:7.1f} ground-m")
|
||||
rms = math.sqrt(sum(r * r for _, _, r in res) / len(res)) * COS
|
||||
print(f" RMS = {rms:.1f} ground-m")
|
||||
return s, t, rms
|
||||
|
||||
|
||||
sx, tx, rx = report("X (avenues)", ax, [n for _, n in AVENUES])
|
||||
sy, ty, ry = report("Y (streets)", ay, [n for _, n in STREETS])
|
||||
print(f"\nscale ratio |sy/sx| = {abs(sy/sx):.4f} (1.0 == truly uniform / north-up)")
|
||||
|
||||
json.dump({"sx": sx, "tx": tx, "sy": sy, "ty": ty}, open("fit_raw.json", "w"), indent=1)
|
||||
3
tools/citymap/roads.overpass
Normal file
3
tools/citymap/roads.overpass
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[out:json][timeout:60];
|
||||
way["highway"](48.2600,-116.5750,48.2900,-116.5300);
|
||||
out geom;
|
||||
Loading…
Add table
Add a link
Reference in a new issue