Compare commits
29 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 | |||
| 8a2a87a1a9 | |||
| 26b6a3ea82 | |||
| 4cb5478737 | |||
| 42a96b0cff | |||
| 65fbd86644 |
79 changed files with 9104 additions and 314 deletions
|
|
@ -53,11 +53,21 @@ jobs:
|
||||||
- name: Run client tests
|
- name: Run client tests
|
||||||
run: npm test --workspace parksmarter-client
|
run: npm test --workspace parksmarter-client
|
||||||
|
|
||||||
# RN 0.79 wants a specific NDK; install it if the image doesn't ship it (best-effort).
|
# Pre-install the NDK + CMake the RN native modules need. Do it here (not via
|
||||||
- name: Ensure NDK
|
# Gradle's mid-build auto-install) and retry: the SDK mirror sometimes serves
|
||||||
|
# a truncated archive ("Archive is not a ZIP archive"), which self-heals on a
|
||||||
|
# clean retry. Clearing the partial dir between tries avoids reusing the bad file.
|
||||||
|
- name: Ensure NDK + CMake
|
||||||
run: |
|
run: |
|
||||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
sdkmanager "ndk;27.1.12297006" >/dev/null 2>&1 || true
|
ok=0
|
||||||
|
for i in 1 2 3; do
|
||||||
|
if sdkmanager "ndk;27.1.12297006" "cmake;3.22.1"; then ok=1; break; fi
|
||||||
|
echo "sdkmanager attempt $i failed; clearing partial CMake and retrying"
|
||||||
|
rm -rf "${ANDROID_HOME:-/opt/android}/cmake/3.22.1" "${ANDROID_SDK_ROOT:-/opt/android}/cmake/3.22.1" 2>/dev/null || true
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "$ok" = 1 ] || echo "WARNING: NDK/CMake pre-install did not confirm; the build may retry it."
|
||||||
|
|
||||||
- name: Restore signing keystore
|
- name: Restore signing keystore
|
||||||
run: echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "${{ github.workspace }}/app/release.keystore"
|
run: echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "${{ github.workspace }}/app/release.keystore"
|
||||||
|
|
|
||||||
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
|
# BigBrainParking
|
||||||
|
|
||||||
An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system,
|
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 |
|
| Phone + password login | ✅ wired | tokens in OS keystore |
|
||||||
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
|
| 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 |
|
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
|
||||||
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
||||||
| Active / past sessions | ✅ wired | list views |
|
| Active / past sessions | ✅ wired | list views + tap for full receipt |
|
||||||
| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) |
|
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
|
||||||
|
| Ongoing parking countdown | ✅ wired | foreground service; ticks down, **End** / **Extend** buttons, survives reboot |
|
||||||
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
|
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
|
||||||
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
|
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
|
||||||
|
|
||||||
|
## 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)
|
## Build & run (dev)
|
||||||
|
|
||||||
Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device)
|
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
|
## Notifications on GrapheneOS
|
||||||
|
|
||||||
Session-expiry reminders are scheduled **entirely on-device** from each session's end time
|
Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services —
|
||||||
(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play
|
so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for
|
||||||
Services. They work fully offline. Configure the lead time (default 15 min) in
|
any *future* server-initiated messages; nothing time-based needs it.
|
||||||
**Account → Notifications**, where a **"Send a test reminder"** button lets you confirm it
|
|
||||||
fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any
|
**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or
|
||||||
*future* server-initiated messages; nothing time-based needs it.
|
a free check-in — a persistent notification shows the time left and ticks down, with
|
||||||
|
**End** and **Extend** buttons. It is held up by a real **foreground service**
|
||||||
|
(`modules/bbp-notify`, type `specialUse`), which is what makes it stick on GrapheneOS the
|
||||||
|
way ntfy's does: it survives the app being killed, can't be swiped away, and a
|
||||||
|
`BOOT_COMPLETED` receiver brings it back after a reboot. The service's life is exactly the
|
||||||
|
session's life — it stops itself, removing the notification, on **End**, when the meter
|
||||||
|
runs out, or whenever there's no session to show.
|
||||||
|
|
||||||
|
The countdown itself costs no battery: the end time is handed to Android as a
|
||||||
|
[chronometer](https://developer.android.com/reference/android/app/Notification.Builder#setChronometerCountDown(boolean)),
|
||||||
|
and the system redraws the ticking text with the app closed and no timer of its own.
|
||||||
|
|
||||||
|
- **End** stops tracking and clears the notification. On a *free check-in* that genuinely
|
||||||
|
ends it. On a *paid* session it only stops the display — ParkSmarter has no stop-session
|
||||||
|
endpoint, so time you already bought keeps running at the meter either way.
|
||||||
|
- **Extend** (labeled **Pay** on a free check-in) opens the purchase screen for that exact
|
||||||
|
zone. The active session is stored locally *with its zone*, so this works offline, in
|
||||||
|
Anonymous Mode, and after a reboot. Extending doesn't end anything until the purchase
|
||||||
|
actually goes through.
|
||||||
|
|
||||||
|
**Expiry reminders** fire a configurable lead time (default 15 min) before the end, via
|
||||||
|
`AlarmManager`. Configure them in **Account → Notifications**, where a **"Send a test
|
||||||
|
reminder"** button lets you confirm they fire on your phone; the same screen has a toggle
|
||||||
|
for the ongoing countdown.
|
||||||
|
|
||||||
## Distribution via Obtainium (self-hosted)
|
## Distribution via Obtainium (self-hosted)
|
||||||
|
|
||||||
|
|
|
||||||
3
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",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.1.5",
|
"version": "0.6.4",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"android": {
|
"android": {
|
||||||
"package": "top.mowden.bigbrainparking",
|
"package": "top.mowden.bigbrainparking",
|
||||||
"versionCode": 5,
|
"versionCode": 25,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#0F2A33"
|
"backgroundColor": "#FFFFFF"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"ACCESS_COARSE_LOCATION",
|
"ACCESS_COARSE_LOCATION",
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"psEnvironment": "prodv2",
|
"psEnvironment": "prodv2",
|
||||||
|
"zoneLabelsApiUrl": "https://bigbrainparking.mowden.top",
|
||||||
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
|
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
|
||||||
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
||||||
"unifiedPushDefaultDistributor": "io.heckel.ntfy",
|
"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",
|
"android": "expo run:android",
|
||||||
"prebuild": "expo prebuild --platform android --clean",
|
"prebuild": "expo prebuild --platform android --clean",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test test/*.test.ts",
|
||||||
"ios": "expo run:ios"
|
"ios": "expo run:ios"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -37,6 +38,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.0.0",
|
"@types/react": "~19.0.0",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
"typescript": "~5.4.0"
|
"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 { authBus } from '@/auth/authBus';
|
||||||
import type { ApplicationValidityResponse } from 'parksmarter-client';
|
import type { ApplicationValidityResponse } from 'parksmarter-client';
|
||||||
|
|
||||||
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
|
type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'anonymous';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
validity: ApplicationValidityResponse | null;
|
validity: ApplicationValidityResponse | null;
|
||||||
login: (phoneNumber: string, password: string) => Promise<void>;
|
login: (phoneNumber: string, password: string) => Promise<void>;
|
||||||
logout: () => 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;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,9 +31,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
|
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
|
||||||
const [error, setError] = useState<string | 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.
|
// Any 401 from the API (expired/rotated token) bounces us back to sign-in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authBus.onUnauthorized = () => {
|
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.');
|
setError('Your session expired — please sign in again.');
|
||||||
setStatus('signedOut');
|
setStatus('signedOut');
|
||||||
};
|
};
|
||||||
|
|
@ -37,18 +54,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// On launch: bootstrap (seeds SessionId + feature flags) and probe for an
|
// On launch: bootstrap (seeds SessionId + feature flags), then verify any
|
||||||
// existing token by attempting an authenticated read.
|
// stored token BEFORE showing the app. A stale/expired token doesn't 401 —
|
||||||
|
// /api/User just returns an empty body, so getUserDetail() resolves to
|
||||||
|
// undefined without throwing. Require real user data to count as signed in,
|
||||||
|
// and clear the dead token otherwise, so an expired session lands on the
|
||||||
|
// login screen at open instead of failing later on the map.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const v = await ps.getApplicationValidity();
|
const v = await ps.getApplicationValidity();
|
||||||
setValidity(v);
|
setValidity(v);
|
||||||
const existing = await ps.tokens.getAuthToken();
|
const existing = await ps.tokens.getAuthToken();
|
||||||
if (existing) {
|
if (!existing) {
|
||||||
await ps.getUserDetail(); // 401 throws -> treated as signed out
|
setStatus('signedOut');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await ps.getUserDetail().catch(() => null);
|
||||||
|
const valid = !!(user && (user.PersonalPhone || user.PersonalEmailAddress));
|
||||||
|
if (valid) {
|
||||||
setStatus('signedIn');
|
setStatus('signedIn');
|
||||||
} else {
|
} else {
|
||||||
|
await ps.logoutLocal(); // drop the dead token so the login screen is clean
|
||||||
setStatus('signedOut');
|
setStatus('signedOut');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -75,8 +102,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
setStatus('signedOut');
|
setStatus('signedOut');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enterAnonymous = () => {
|
||||||
|
setError(null);
|
||||||
|
setStatus('anonymous');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireLogin = () => {
|
||||||
|
setError(null);
|
||||||
|
setStatus('signedOut');
|
||||||
|
};
|
||||||
|
|
||||||
const value = useMemo<AuthState>(
|
const value = useMemo<AuthState>(
|
||||||
() => ({ status, validity, login, logout, error }),
|
() => ({
|
||||||
|
status,
|
||||||
|
validity,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
enterAnonymous,
|
||||||
|
requireLogin,
|
||||||
|
isAnonymous: status === 'anonymous',
|
||||||
|
error,
|
||||||
|
}),
|
||||||
[status, validity, error],
|
[status, validity, error],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,13 @@
|
||||||
* Tiny bridge so the API client (created at module load) can notify the React
|
* 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
|
* auth layer when a 401 happens, without a circular import. AuthProvider
|
||||||
* registers a handler; the client calls it via app/src/api/client.ts.
|
* 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 * as Location from 'expo-location';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
|
@ -7,6 +7,20 @@ export interface Coords {
|
||||||
longitude: number;
|
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';
|
const LAST_LOC_KEY = 'ps_last_location';
|
||||||
|
|
||||||
/** Persist the most recent fix so the "near my last location" button works cold. */
|
/** 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
|
* Foreground location. On GrapheneOS this uses the OS location provider directly
|
||||||
* (no Google Play Services). We prefer a fast last-known fix, then refine.
|
* (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 [coords, setCoords] = useState<Coords | null>(null);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState(0);
|
||||||
const [granted, setGranted] = useState<boolean | null>(null);
|
const [granted, setGranted] = useState<boolean | null>(null);
|
||||||
const [error, setError] = useState<string | 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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
if (!permitted.current) {
|
||||||
const ok = status === 'granted';
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
setGranted(ok);
|
const ok = status === 'granted';
|
||||||
if (!ok) {
|
permitted.current = ok;
|
||||||
setError('Location permission denied.');
|
setGranted(ok);
|
||||||
return null;
|
if (!ok) {
|
||||||
|
setError('Location permission denied.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const last = await Location.getLastKnownPositionAsync();
|
// Only worth it before we have anything to show: on a later poll the
|
||||||
if (last) {
|
// last-known fix is usually older than the one we already hold, and
|
||||||
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
|
// publishing it would make the dot jump backwards.
|
||||||
setCoords(c);
|
if (!haveFix.current) {
|
||||||
void saveLastLocation(c);
|
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({
|
const cur = await Location.getCurrentPositionAsync({
|
||||||
accuracy: Location.Accuracy.Balanced,
|
accuracy: Location.Accuracy.Balanced,
|
||||||
});
|
});
|
||||||
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
||||||
|
haveFix.current = true;
|
||||||
setCoords(c);
|
setCoords(c);
|
||||||
|
setUpdatedAt(Date.now());
|
||||||
|
setError(null);
|
||||||
void saveLastLocation(c);
|
void saveLastLocation(c);
|
||||||
return c;
|
return c;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -56,8 +92,19 @@ export function useLocation() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
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> {
|
export async function setRemindersEnabled(on: boolean): Promise<void> {
|
||||||
await AsyncStorage.setItem(ENABLED_KEY, String(on));
|
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);
|
||||||
|
}
|
||||||
|
|
@ -21,20 +21,32 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
||||||
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
||||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||||
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
||||||
|
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
||||||
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
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 { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import { useActiveParkingSync } from '@/features/session/activeParking';
|
||||||
|
import type { ParkingArea } from '@/api/parkingAreas';
|
||||||
|
import type { ParkedSpot } from '@/features/session/activeParkingStore';
|
||||||
|
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Tabs: undefined;
|
Tabs: undefined;
|
||||||
MeterDetail: { zone: Zone };
|
MeterDetail: { zone: Zone };
|
||||||
StartSession: { 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;
|
About: undefined;
|
||||||
Profile: undefined;
|
Profile: undefined;
|
||||||
Vehicles: undefined;
|
Vehicles: undefined;
|
||||||
PaymentMethods: undefined;
|
PaymentMethods: undefined;
|
||||||
Notifications: undefined;
|
Notifications: undefined;
|
||||||
Diagnostics: undefined;
|
Diagnostics: undefined;
|
||||||
|
Admin: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabParamList = {
|
export type TabParamList = {
|
||||||
|
|
@ -66,6 +78,9 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function Tabs() {
|
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 (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
|
|
@ -107,7 +122,7 @@ export function RootNavigator() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer theme={navTheme}>
|
<NavigationContainer theme={navTheme}>
|
||||||
{status === 'signedIn' ? (
|
{status === 'signedIn' || status === 'anonymous' ? (
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|
@ -120,6 +135,11 @@ export function RootNavigator() {
|
||||||
component={StartSessionScreen}
|
component={StartSessionScreen}
|
||||||
options={{ title: 'Start session' }}
|
options={{ title: 'Start session' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="SessionDetail"
|
||||||
|
component={SessionDetailScreen}
|
||||||
|
options={{ title: 'Session' }}
|
||||||
|
/>
|
||||||
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
|
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
|
||||||
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
|
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
|
||||||
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
|
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
|
||||||
|
|
@ -138,6 +158,17 @@ export function RootNavigator() {
|
||||||
component={DiagnosticsScreen}
|
component={DiagnosticsScreen}
|
||||||
options={{ title: 'Diagnostics' }}
|
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>
|
</Stack.Navigator>
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen />
|
<LoginScreen />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import {
|
|
||||||
getReminderLeadMinutes,
|
|
||||||
getRemindersEnabled,
|
|
||||||
} from '@/features/notifications/reminderPrefs';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
|
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
|
||||||
|
|
@ -11,7 +7,9 @@ import {
|
||||||
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
|
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CHANNEL_ID = 'session-reminders';
|
/** Exported so the active-parking module can schedule onto the same channel. */
|
||||||
|
export const REMINDER_CHANNEL_ID = 'session-reminders';
|
||||||
|
const CHANNEL_ID = REMINDER_CHANNEL_ID;
|
||||||
|
|
||||||
/** Android 8+ needs a notification channel; ensure it exists once. */
|
/** Android 8+ needs a notification channel; ensure it exists once. */
|
||||||
async function ensureChannel(): Promise<void> {
|
async function ensureChannel(): Promise<void> {
|
||||||
|
|
@ -33,58 +31,21 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async (notification) => {
|
||||||
// shouldShowAlert is the legacy field; Banner/List are the newer split.
|
// The ongoing "time left" status refreshes on every foreground — keep it
|
||||||
shouldShowAlert: true,
|
// quiet (no banner/sound), just present in the shade.
|
||||||
shouldShowBanner: true,
|
const quiet = (notification.request.content.data as any)?.kind === 'session-status';
|
||||||
shouldShowList: true,
|
return {
|
||||||
shouldPlaySound: true,
|
// shouldShowAlert is the legacy field; Banner/List are the newer split.
|
||||||
shouldSetBadge: false,
|
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. */
|
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
|
||||||
export async function sendTestReminder(seconds = 10): Promise<boolean> {
|
export async function sendTestReminder(seconds = 10): Promise<boolean> {
|
||||||
if (!(await ensureNotificationPermission())) return false;
|
if (!(await ensureNotificationPermission())) return false;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
|
Linking,
|
||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
|
@ -9,24 +10,17 @@ import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { ps } from '@/api/client';
|
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
|
||||||
const TAPS_TO_UNLOCK = 7;
|
const TAPS_TO_UNLOCK = 7;
|
||||||
|
const REPO_URL = 'https://git.mowden.top/hank/BigBrainParking';
|
||||||
|
|
||||||
export function AboutScreen() {
|
export function AboutScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [about, setAbout] = useState<string | null>(null);
|
|
||||||
const [showJoel, setShowJoel] = useState(false);
|
const [showJoel, setShowJoel] = useState(false);
|
||||||
const taps = useRef(0);
|
const taps = useRef(0);
|
||||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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.
|
// Tap the title 7 times (within a rolling window) to summon Joel.
|
||||||
const onTitleTap = () => {
|
const onTitleTap = () => {
|
||||||
taps.current += 1;
|
taps.current += 1;
|
||||||
|
|
@ -44,7 +38,41 @@ export function AboutScreen() {
|
||||||
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
|
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
|
||||||
</Pressable>
|
</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 }]}>
|
||||||
|
This app talks only to ParkSmarter’s own API, and only sends what a request needs:
|
||||||
|
{'\n'}• Sign-in (your phone number + password) and your account data — vehicles, cards,
|
||||||
|
and session history — the same as the official app.
|
||||||
|
{'\n'}• To find meters, a single map coordinate: the point the map is centered on when you
|
||||||
|
search. Your device GPS is sent <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 <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>
|
||||||
|
|
||||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||||
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
|
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
|
||||||
|
|
@ -62,7 +90,10 @@ export function AboutScreen() {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { padding: 20 },
|
container: { padding: 20 },
|
||||||
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
|
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
|
||||||
|
section: { fontSize: 17, fontWeight: '700', marginTop: 22, marginBottom: 6 },
|
||||||
body: { fontSize: 15, lineHeight: 22, color: '#333' },
|
body: { fontSize: 15, lineHeight: 22, color: '#333' },
|
||||||
|
link: { fontSize: 15, fontWeight: '700' },
|
||||||
|
linkUrl: { fontSize: 13, marginTop: 2 },
|
||||||
meta: { fontSize: 12, color: '#999', marginTop: 24 },
|
meta: { fontSize: 12, color: '#999', marginTop: 24 },
|
||||||
joelBackdrop: {
|
joelBackdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function AccountScreen() {
|
export function AccountScreen() {
|
||||||
const { colors, mode, toggle } = useTheme();
|
const { colors, mode, toggle } = useTheme();
|
||||||
const { logout } = useAuth();
|
const { logout, isAnonymous, requireLogin } = useAuth();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
|
||||||
const Item = ({
|
const Item = ({
|
||||||
|
|
@ -35,21 +35,33 @@ export function AccountScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
{isAnonymous ? (
|
||||||
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
|
<Item icon="log-in" label="Sign in to pay & sync" onPress={requireLogin} />
|
||||||
<Item
|
<Item
|
||||||
icon="card"
|
icon="notifications"
|
||||||
label="Payment methods"
|
label="Notifications"
|
||||||
onPress={() => navigation.navigate('PaymentMethods')}
|
onPress={() => navigation.navigate('Notifications')}
|
||||||
/>
|
/>
|
||||||
<Item
|
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||||
icon="notifications"
|
</View>
|
||||||
label="Notifications"
|
) : (
|
||||||
onPress={() => navigation.navigate('Notifications')}
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
/>
|
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
|
||||||
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
|
||||||
</View>
|
<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>
|
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
|
||||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
|
|
@ -59,14 +71,23 @@ export function AccountScreen() {
|
||||||
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
||||||
</View>
|
</View>
|
||||||
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
|
<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>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
{isAnonymous ? null : (
|
||||||
style={[styles.logout, { borderColor: colors.danger }]}
|
<TouchableOpacity style={[styles.logout, { borderColor: colors.danger }]} onPress={logout}>
|
||||||
onPress={logout}
|
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
|
||||||
>
|
</TouchableOpacity>
|
||||||
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
</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 React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
|
@ -13,7 +14,7 @@ import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
const { login, error } = useAuth();
|
const { login, error, enterAnonymous } = useAuth();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [password, setPassword] = 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 (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.container, { backgroundColor: colors.bg }]}
|
style={[styles.container, { backgroundColor: colors.bg }]}
|
||||||
|
|
@ -77,6 +97,10 @@ export function LoginScreen() {
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</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 }]}>
|
<Text style={[styles.hint, { color: colors.subtext }]}>
|
||||||
Forgot your password? Reset it via the official app — this build reuses the same
|
Forgot your password? Reset it via the official app — this build reuses the same
|
||||||
account.
|
account.
|
||||||
|
|
@ -101,5 +125,7 @@ const styles = StyleSheet.create({
|
||||||
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||||
buttonDisabled: { opacity: 0.6 },
|
buttonDisabled: { opacity: 0.6 },
|
||||||
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
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 },
|
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 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 Constants from 'expo-constants';
|
||||||
import {
|
import {
|
||||||
MapView,
|
MapView,
|
||||||
Camera,
|
Camera,
|
||||||
ShapeSource,
|
ShapeSource,
|
||||||
CircleLayer,
|
CircleLayer,
|
||||||
|
FillLayer,
|
||||||
|
LineLayer,
|
||||||
UserLocation,
|
UserLocation,
|
||||||
} from '@maplibre/maplibre-react-native';
|
} 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 type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { ps } from '@/api/client';
|
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 { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
|
|
@ -24,12 +49,47 @@ const MAP_STYLE_DARK =
|
||||||
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
||||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||||
|
|
||||||
// Fallback view when we have no GPS and no cached location (continental US).
|
// Default view when we have no last-session lot: all of downtown Sandpoint, ID.
|
||||||
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
|
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||||
const DEFAULT_ZOOM = 4;
|
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>;
|
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. */
|
/** Coerce the server's zone color (hex string, color name, or numeric) into a usable color. */
|
||||||
function normalizeColor(v: unknown): string | null {
|
function normalizeColor(v: unknown): string | null {
|
||||||
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
|
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
|
||||||
|
|
@ -47,12 +107,33 @@ export function MapScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { mode } = useTheme();
|
const { mode } = useTheme();
|
||||||
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
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 [zones, setZones] = useState<Zone[]>([]);
|
||||||
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
|
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 mapRef = useRef<any>(null);
|
||||||
const cameraRef = useRef<any>(null);
|
const cameraRef = useRef<any>(null);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
@ -66,13 +147,36 @@ export function MapScreen() {
|
||||||
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
||||||
// The native UserLocation dot has its own GPS feed — capture it so "My
|
// 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).
|
// 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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const last = await getLastKnownSavedLocation();
|
const lot = await lastSessionLot();
|
||||||
setInitialCenter(last ?? coords ?? DEFAULT_CENTER);
|
setInitialCenter(lot ?? DEFAULT_CENTER);
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -89,47 +193,117 @@ export function MapScreen() {
|
||||||
}
|
}
|
||||||
}, [mapReady, initialCenter]);
|
}, [mapReady, initialCenter]);
|
||||||
|
|
||||||
const searchAt = useCallback(async (c: Coords, label: string) => {
|
const searchAt = useCallback(
|
||||||
setLoading(true);
|
async (c: Coords, label: string) => {
|
||||||
setStatus(`Searching ${label}…`);
|
setLoading(true);
|
||||||
try {
|
setStatus(`Searching ${label}…`);
|
||||||
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
try {
|
||||||
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
if (isAnonymous) {
|
||||||
setZones(found);
|
// No ParkSmarter login — read areas from our mirror (all of them; the
|
||||||
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
|
// covered area is small). The device GPS is never sent.
|
||||||
} catch (e: any) {
|
const all = await getMirrorZones();
|
||||||
setZones([]);
|
const found = all.filter((z) => z.Lat != null && z.Long != null);
|
||||||
setStatus(
|
setZones(found);
|
||||||
e?.status === 401
|
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
|
||||||
? 'Session expired — sign in again.'
|
return;
|
||||||
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
|
}
|
||||||
);
|
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
||||||
} finally {
|
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
||||||
setLoading(false);
|
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 () => {
|
const searchThisArea = async () => {
|
||||||
|
let center: [number, number] | null = null;
|
||||||
try {
|
try {
|
||||||
const center = await mapRef.current?.getCenter?.(); // [lng, lat]
|
const c = await mapRef.current?.getCenter?.(); // [lng, lat]
|
||||||
if (center && center.length === 2) {
|
if (Array.isArray(c) && c.length === 2) center = [c[0], c[1]];
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* 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
|
// Recenter on the live GPS fix (if available) and search there.
|
||||||
// native map fix (the blue dot), then expo-location, then a forced refresh.
|
|
||||||
const goToMyLocation = async () => {
|
const goToMyLocation = async () => {
|
||||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
const c = await bestFix();
|
||||||
if (!c) {
|
if (!c) {
|
||||||
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
||||||
return;
|
return;
|
||||||
|
|
@ -142,20 +316,162 @@ export function MapScreen() {
|
||||||
await searchAt(c, 'you');
|
await searchAt(c, 'you');
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchLastKnown = async () => {
|
// Center on the LAST SESSION's parking lot (the meter's own coordinate from
|
||||||
const last = (await getLastKnownSavedLocation()) ?? coords;
|
// history — never your GPS) and search around it with a ~couple-mile view.
|
||||||
if (!last) {
|
const searchLastSessionLot = async () => {
|
||||||
setStatus('No saved location yet — enable location once to cache it.');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
cameraRef.current?.setCamera?.({
|
cameraRef.current?.setCamera?.({
|
||||||
centerCoordinate: [last.longitude, last.latitude],
|
centerCoordinate: [lot.longitude, lot.latitude],
|
||||||
zoomLevel: 15,
|
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,
|
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
|
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
|
||||||
// than React MarkerViews, which floated and thrashed the camera.
|
// than React MarkerViews, which floated and thrashed the camera.
|
||||||
const meterFeatures = useMemo(
|
const meterFeatures = useMemo(
|
||||||
|
|
@ -196,6 +512,7 @@ export function MapScreen() {
|
||||||
nativeFix.current = {
|
nativeFix.current = {
|
||||||
latitude: loc.coords.latitude,
|
latitude: loc.coords.latitude,
|
||||||
longitude: loc.coords.longitude,
|
longitude: loc.coords.longitude,
|
||||||
|
at: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -212,6 +529,7 @@ export function MapScreen() {
|
||||||
mapStyle={mapStyle}
|
mapStyle={mapStyle}
|
||||||
rotateEnabled={false}
|
rotateEnabled={false}
|
||||||
onDidFinishLoadingMap={() => setMapReady(true)}
|
onDidFinishLoadingMap={() => setMapReady(true)}
|
||||||
|
onPress={onMapPress}
|
||||||
onRegionDidChange={(f: any) => {
|
onRegionDidChange={(f: any) => {
|
||||||
const c = f?.geometry?.coordinates;
|
const c = f?.geometry?.coordinates;
|
||||||
const z = f?.properties?.zoomLevel;
|
const z = f?.properties?.zoomLevel;
|
||||||
|
|
@ -223,6 +541,57 @@ export function MapScreen() {
|
||||||
{cameraEl}
|
{cameraEl}
|
||||||
{userLocationEl}
|
{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}>
|
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
|
||||||
<CircleLayer
|
<CircleLayer
|
||||||
id="meter-circles"
|
id="meter-circles"
|
||||||
|
|
@ -253,15 +622,31 @@ export function MapScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
|
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
|
||||||
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.pillText}>Search this area</Text>
|
<TouchableOpacity
|
||||||
</TouchableOpacity>
|
style={pinning ? styles.pillActive : styles.pillPrimary}
|
||||||
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
onPress={parkHere}
|
||||||
<Text style={styles.pillText}>My location</Text>
|
>
|
||||||
</TouchableOpacity>
|
<Text style={styles.pillText}>{pinning ? 'Tap the map…' : 'Park here'}</Text>
|
||||||
<TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
|
</TouchableOpacity>
|
||||||
<Text style={styles.pillText}>Last</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>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -287,9 +672,10 @@ const styles = StyleSheet.create({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
flexDirection: 'row',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
|
row: { flexDirection: 'row', gap: 8 },
|
||||||
pill: {
|
pill: {
|
||||||
backgroundColor: '#444',
|
backgroundColor: '#444',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
|
|
@ -302,5 +688,18 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderRadius: 22,
|
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' },
|
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 { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import type { RouteProp } from '@react-navigation/native';
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
import { useNavigation, useRoute } 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 { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { SpacePolicy } from 'parksmarter-client';
|
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'>;
|
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
|
||||||
|
|
||||||
|
|
@ -66,9 +88,48 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
export function MeterDetailScreen() {
|
export function MeterDetailScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { isAnonymous, requireLogin } = useAuth();
|
||||||
const { params } = useRoute<DetailRoute>();
|
const { params } = useRoute<DetailRoute>();
|
||||||
const z = params.zone;
|
const z = params.zone;
|
||||||
const [saved, setSaved] = useState(false);
|
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 () => {
|
const onSave = async () => {
|
||||||
await saveKiosk(toSavedKiosk(z));
|
await saveKiosk(toSavedKiosk(z));
|
||||||
|
|
@ -76,6 +137,42 @@ export function MeterDetailScreen() {
|
||||||
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
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 firstSpace = z.Spaces?.[0];
|
||||||
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
|
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>
|
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
|
||||||
) : null}
|
) : 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}>
|
<View style={styles.row}>
|
||||||
<Field label="Scanner code" value={z.ScannerCode} />
|
<Field label="Scanner code" value={z.ScannerCode} />
|
||||||
<Field label="Serial" value={z.TerminalSerNo} />
|
<Field label="Serial" value={z.TerminalSerNo} />
|
||||||
|
|
@ -136,6 +244,36 @@ export function MeterDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
|
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
|
||||||
onPress={onSave}
|
onPress={onSave}
|
||||||
|
|
@ -146,11 +284,20 @@ export function MeterDetailScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
{label?.kind === 'pay_immediate' ? (
|
||||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately — no free window here.</Text>
|
||||||
onPress={() => navigation.navigate('StartSession', { zone: z })}
|
) : (
|
||||||
>
|
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
|
||||||
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
|
<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>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
@ -171,4 +318,9 @@ const styles = StyleSheet.create({
|
||||||
policyRate: { fontSize: 13, fontWeight: '600' },
|
policyRate: { fontSize: 13, fontWeight: '600' },
|
||||||
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
|
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||||
buttonText: { fontWeight: '600', fontSize: 16 },
|
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 { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { sendTestReminder } from '@/notifications/localReminders';
|
import { sendTestReminder } from '@/notifications/localReminders';
|
||||||
|
import { refreshParkingNotification } from '@/features/session/activeParking';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LEAD_MINUTES,
|
DEFAULT_LEAD_MINUTES,
|
||||||
LEAD_STEP,
|
LEAD_STEP,
|
||||||
|
|
@ -10,19 +11,23 @@ import {
|
||||||
MIN_LEAD_MINUTES,
|
MIN_LEAD_MINUTES,
|
||||||
getReminderLeadMinutes,
|
getReminderLeadMinutes,
|
||||||
getRemindersEnabled,
|
getRemindersEnabled,
|
||||||
|
getCountdownEnabled,
|
||||||
setReminderLeadMinutes,
|
setReminderLeadMinutes,
|
||||||
setRemindersEnabled,
|
setRemindersEnabled,
|
||||||
|
setCountdownEnabled,
|
||||||
} from '@/features/notifications/reminderPrefs';
|
} from '@/features/notifications/reminderPrefs';
|
||||||
|
|
||||||
export function NotificationsScreen() {
|
export function NotificationsScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
|
const [lead, setLead] = useState(DEFAULT_LEAD_MINUTES);
|
||||||
|
const [countdown, setCountdown] = useState(true);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
void getRemindersEnabled().then(setEnabled);
|
void getRemindersEnabled().then(setEnabled);
|
||||||
void getReminderLeadMinutes().then(setLead);
|
void getReminderLeadMinutes().then(setLead);
|
||||||
|
void getCountdownEnabled().then(setCountdown);
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -31,6 +36,11 @@ export function NotificationsScreen() {
|
||||||
void setRemindersEnabled(v);
|
void setRemindersEnabled(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleCountdown = (v: boolean) => {
|
||||||
|
setCountdown(v);
|
||||||
|
void setCountdownEnabled(v).then(refreshParkingNotification);
|
||||||
|
};
|
||||||
|
|
||||||
const bump = (delta: number) => {
|
const bump = (delta: number) => {
|
||||||
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
|
const next = Math.min(MAX_LEAD_MINUTES, Math.max(MIN_LEAD_MINUTES, lead + delta));
|
||||||
setLead(next);
|
setLead(next);
|
||||||
|
|
@ -53,6 +63,21 @@ export function NotificationsScreen() {
|
||||||
</View>
|
</View>
|
||||||
</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>
|
<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.card, { backgroundColor: colors.card, opacity: enabled ? 1 : 0.4 }]}>
|
||||||
<View style={styles.stepper}>
|
<View style={styles.stepper}>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
|
import { getMirrorZones } from '@/api/zoneMirror';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
@ -52,6 +54,7 @@ export function parseScannedCode(raw: string): { code?: string; isAppLink?: bool
|
||||||
|
|
||||||
export function ScanScreen() {
|
export function ScanScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { isAnonymous } = useAuth();
|
||||||
const { hasPermission, requestPermission } = useCameraPermission();
|
const { hasPermission, requestPermission } = useCameraPermission();
|
||||||
const device = useCameraDevice('back');
|
const device = useCameraDevice('back');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
@ -66,26 +69,67 @@ export function ScanScreen() {
|
||||||
const lookupCode = useCallback(
|
const lookupCode = useCallback(
|
||||||
async (code: string) => {
|
async (code: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
// Anonymous: no ParkSmarter API — match against the mirrored areas.
|
||||||
let res = await ps.getMetersByScannerCode(code);
|
if (isAnonymous) {
|
||||||
let zone = res.Zones?.[0];
|
try {
|
||||||
if (!zone) {
|
const lc = code.toLowerCase();
|
||||||
res = await ps.getMetersByZoneName(code);
|
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];
|
zone = res.Zones?.[0];
|
||||||
|
if (zone) break;
|
||||||
|
} catch {
|
||||||
|
// strategy didn't apply (e.g. error envelope) — try the next one
|
||||||
}
|
}
|
||||||
if (zone) {
|
}
|
||||||
setManualOpen(false);
|
setBusy(false);
|
||||||
navigation.navigate('MeterDetail', { zone });
|
if (zone) {
|
||||||
} else {
|
setManualOpen(false);
|
||||||
Alert.alert('Not found', `No meter matches "${code}".`);
|
navigation.navigate('MeterDetail', { zone });
|
||||||
}
|
} else if (anyResponded) {
|
||||||
} catch (e: any) {
|
Alert.alert('Not found', `No meter matches "${code}".`);
|
||||||
Alert.alert('Lookup failed', e?.serverMessage ?? e?.message ?? 'error');
|
} else {
|
||||||
} finally {
|
Alert.alert('Lookup failed', 'Could not reach ParkSmarter. Check your connection.');
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigation],
|
[navigation, isAnonymous],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onScanned = useCallback(
|
const onScanned = useCallback(
|
||||||
|
|
|
||||||
169
app/src/screens/SessionDetailScreen.tsx
Normal file
169
app/src/screens/SessionDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
|
import { useRoute } from '@react-navigation/native';
|
||||||
|
import { ps } from '@/api/client';
|
||||||
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
import type { ParkingReceipt } from 'parksmarter-client';
|
||||||
|
|
||||||
|
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;
|
||||||
|
const s = session as Record<string, any>;
|
||||||
|
const tid = s.TransactionID;
|
||||||
|
|
||||||
|
const [receipt, setReceipt] = useState<ParkingReceipt | null>(null);
|
||||||
|
const [loading, setLoading] = useState(kind === 'past' && tid != null);
|
||||||
|
const [emailing, setEmailing] = useState(false);
|
||||||
|
|
||||||
|
// Past sessions have a full receipt (auth code, payment, amounts) — fetch it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== 'past' || tid == null) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await ps.getParkingReceipt(tid);
|
||||||
|
if (r?.ParkingReceipt && (r as any).Response?.Status !== 'Error') {
|
||||||
|
setReceipt(r.ParkingReceipt);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logLine(`[RECEIPT] fetch failed tid=${tid}: ${e?.serverMessage ?? e?.message ?? e}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [kind, tid]);
|
||||||
|
|
||||||
|
const emailReceipt = async () => {
|
||||||
|
if (tid == null) return;
|
||||||
|
setEmailing(true);
|
||||||
|
try {
|
||||||
|
await ps.emailParkingReceipt(tid);
|
||||||
|
Alert.alert('Receipt sent', 'Emailed to your account address.');
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Couldn’t email receipt', e?.serverMessage ?? e?.message ?? 'Please try again.');
|
||||||
|
} finally {
|
||||||
|
setEmailing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = receipt;
|
||||||
|
const rows: Array<[string, string | undefined]> =
|
||||||
|
kind === 'past'
|
||||||
|
? [
|
||||||
|
['Vehicle', r?.Vehicle ?? s.VehicleNumber],
|
||||||
|
['Zone', s.Description ?? s.Zone ?? r?.MeterNumber],
|
||||||
|
['Space', r?.Space ?? s.Space],
|
||||||
|
['Started', r?.StartTime ?? s.StartTime],
|
||||||
|
['Ended', r?.EndTime ?? s.EndTime],
|
||||||
|
['Time purchased', s.TimePurchased ? `${s.TimePurchased} min` : undefined],
|
||||||
|
['Parking', r?.Amount ?? dollars(s.Amount)],
|
||||||
|
['Fee', r?.TransactionFee ?? dollars(s.TransactionFee)],
|
||||||
|
['Total', r?.Total],
|
||||||
|
[
|
||||||
|
'Payment',
|
||||||
|
[r?.PaymentDisplay ?? s.PaymentDisplay, r?.CC ?? (s.CardLastFour ? `••${s.CardLastFour}` : '')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined,
|
||||||
|
],
|
||||||
|
['Auth code', r?.AuthCode],
|
||||||
|
['City', s.City ?? r?.CustomerName],
|
||||||
|
['Transaction', tid != null ? String(tid) : undefined],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
['Vehicle', s.VehiclePlate ?? s.VehicleNumber],
|
||||||
|
['Zone', s.ZoneName ?? s.Zone],
|
||||||
|
['Space', s.SpaceName ?? s.Space],
|
||||||
|
['Started', s.StartTimeDisplay ?? s.StartTime],
|
||||||
|
['Ends', s.EndTimeDisplay ?? s.EndTime],
|
||||||
|
['Time remaining', fmtRemaining(s.TimeRemaining)],
|
||||||
|
['Amount', dollars(s.Amount)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
||||||
|
<ActivityIndicator color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = s.Description ?? s.ZoneName ?? s.Zone ?? r?.MeterNumber ?? 'Session';
|
||||||
|
const visible = rows.filter(([, v]) => v != null && v !== '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
|
||||||
|
{kind === 'active' ? (
|
||||||
|
<View style={[styles.badge, { backgroundColor: colors.primary + '22' }]}>
|
||||||
|
<Text style={{ color: colors.primary, fontWeight: '700' }}>Active session</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
|
{visible.map(([label, value], i) => (
|
||||||
|
<View
|
||||||
|
key={label}
|
||||||
|
style={[styles.row, i === visible.length - 1 && { borderBottomWidth: 0 }]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.subtext }}>{label}</Text>
|
||||||
|
<Text style={[styles.value, { color: colors.text }]}>{show(value)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{kind === 'past' && tid != null ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, { borderColor: colors.primary, opacity: emailing ? 0.6 : 1 }]}
|
||||||
|
disabled={emailing}
|
||||||
|
onPress={emailReceipt}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.primary, fontWeight: '700' }}>
|
||||||
|
{emailing ? 'Sending…' : 'Email me this receipt'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
title: { fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||||
|
badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginBottom: 12 },
|
||||||
|
card: { borderRadius: 12, padding: 4, paddingHorizontal: 14 },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#8883',
|
||||||
|
},
|
||||||
|
value: { fontWeight: '600', flexShrink: 1, textAlign: 'right', marginLeft: 12 },
|
||||||
|
btn: { marginTop: 16, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
|
||||||
|
});
|
||||||
|
|
@ -1,18 +1,72 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
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';
|
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() {
|
export function SessionsScreen() {
|
||||||
const { colors } = useTheme();
|
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 [active, setActive] = useState<ActiveSession[]>([]);
|
||||||
const [past, setPast] = useState<PastSession[]>([]);
|
const [past, setPast] = useState<PastSession[]>([]);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
/** On-device only. Never awaits the network, so it works offline and signed out. */
|
||||||
setRefreshing(true);
|
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 {
|
try {
|
||||||
const [a, p] = await Promise.all([
|
const [a, p] = await Promise.all([
|
||||||
ps.getActiveParkingSessions(),
|
ps.getActiveParkingSessions(),
|
||||||
|
|
@ -20,10 +74,22 @@ export function SessionsScreen() {
|
||||||
]);
|
]);
|
||||||
setActive(a.ParkingSession ?? []);
|
setActive(a.ParkingSession ?? []);
|
||||||
setPast(p.Session ?? []);
|
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 {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [loadLocal, loadRemote]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|
@ -31,38 +97,159 @@ export function SessionsScreen() {
|
||||||
}, [load]),
|
}, [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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ backgroundColor: colors.bg }}
|
style={{ backgroundColor: colors.bg }}
|
||||||
contentContainerStyle={{ padding: 16 }}
|
contentContainerStyle={{ padding: 16 }}
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||||
>
|
>
|
||||||
<Text style={[styles.header, { color: colors.text }]}>Active</Text>
|
<Text style={[s.header, { color: colors.text }]}>Tracking on this phone</Text>
|
||||||
{active.length === 0 ? (
|
{local ? (
|
||||||
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
<View style={[s.card, { backgroundColor: colors.primary + '22' }]}>
|
||||||
) : (
|
<View style={s.chipRow}>
|
||||||
active.map((s, i) => (
|
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
|
||||||
<View key={i} style={[styles.card, { backgroundColor: colors.primary + '22' }]}>
|
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
|
||||||
<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 ?? ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</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>
|
||||||
|
) : (
|
||||||
|
<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>
|
{history.length > 0 ? (
|
||||||
{past.length === 0 ? (
|
<>
|
||||||
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
<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
|
||||||
|
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
|
||||||
|
onPress={requireLogin}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
past.map((s, i) => (
|
<>
|
||||||
<View key={i} style={[styles.card, { backgroundColor: colors.card }]}>
|
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
|
||||||
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
|
{remoteError ? (
|
||||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
|
||||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
|
) : active.length === 0 ? (
|
||||||
</Text>
|
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||||
</View>
|
) : (
|
||||||
))
|
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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
@ -71,7 +258,20 @@ export function SessionsScreen() {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||||
empty: { 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' },
|
zone: { fontSize: 16, fontWeight: '600' },
|
||||||
|
big: { fontSize: 26, fontWeight: '700' },
|
||||||
meta: { fontSize: 13, marginTop: 2 },
|
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 type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
import { parseApiTime } from '@/api/parseTime';
|
||||||
|
import { startPaidSession } from '@/features/session/activeParking';
|
||||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import {
|
import {
|
||||||
|
|
@ -45,9 +46,27 @@ const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
|
||||||
export interface SingleLadderResult {
|
export interface SingleLadderResult {
|
||||||
/** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */
|
/** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */
|
||||||
free: boolean;
|
free: boolean;
|
||||||
|
/** Flat-rate zone: one price for any duration, so we offer a single max option. */
|
||||||
|
flat: boolean;
|
||||||
ladder: ParkingDetail[];
|
ladder: ParkingDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Price a single duration; returns a rung (Minutes overridden to the request) or null. */
|
||||||
|
async function singleRung(
|
||||||
|
base: { zoneId: number; spaceId: number; customerId: number; vehicleId: number },
|
||||||
|
minutes: number,
|
||||||
|
): Promise<ParkingDetail | null> {
|
||||||
|
try {
|
||||||
|
const r = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: minutes, creditCardId: 0 });
|
||||||
|
const p = r.ParkingDetail;
|
||||||
|
if (!p || (r as any)?.Response?.Status === 'Error') return null;
|
||||||
|
// The single endpoint often echoes Minutes:0; trust the requested duration.
|
||||||
|
return { ...p, Minutes: minutes } as ParkingDetail;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildSingleLadder(base: {
|
export async function buildSingleLadder(base: {
|
||||||
zoneId: number;
|
zoneId: number;
|
||||||
spaceId: number;
|
spaceId: number;
|
||||||
|
|
@ -60,44 +79,31 @@ export async function buildSingleLadder(base: {
|
||||||
}
|
}
|
||||||
// MaxTime 0 means no paid time is available right now — this is a free window,
|
// MaxTime 0 means no paid time is available right now — this is a free window,
|
||||||
// not a purchasable zone. Don't build a bogus $0.00 ladder.
|
// not a purchasable zone. Don't build a bogus $0.00 ladder.
|
||||||
if (isFreeEstimate(probe)) return { free: true, ladder: [] };
|
if (isFreeEstimate(probe)) return { free: true, flat: false, ladder: [] };
|
||||||
|
|
||||||
const min = Math.max(5, Number(probe.MinTime) || 5);
|
const min = Math.max(5, Number(probe.MinTime) || 5);
|
||||||
const max = Math.max(min, Number(probe.MaxTime) || min);
|
const max = Math.max(min, Number(probe.MaxTime) || min);
|
||||||
|
|
||||||
|
// Price both ends first. If they match, it's a flat-rate zone — one price no
|
||||||
|
// matter how long you stay — so there's no reason to pick a shorter duration;
|
||||||
|
// offer a single option for the whole window (park to MaxTime).
|
||||||
|
const [minRung, maxRung] = await Promise.all([singleRung(base, min), singleRung(base, max)]);
|
||||||
|
if (maxRung && ladderAllFree([maxRung])) return { free: true, flat: false, ladder: [] };
|
||||||
|
if (minRung && maxRung && Number(minRung.ParkingCost) === Number(maxRung.ParkingCost)) {
|
||||||
|
return { free: false, flat: true, ladder: [maxRung] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graded rate: fill in intermediate durations between the two ends.
|
||||||
const span = max - min;
|
const span = max - min;
|
||||||
const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5;
|
const step = span > 180 ? 30 : span > 60 ? 15 : span > 20 ? 10 : 5;
|
||||||
const durs = new Set<number>();
|
const mids = new Set<number>();
|
||||||
for (let d = min; d < max; d += step) durs.add(d);
|
for (let d = min + step; d < max; d += step) mids.add(d);
|
||||||
durs.add(max);
|
const midRungs = await Promise.all([...mids].map((d) => singleRung(base, d)));
|
||||||
const rungs = await Promise.all(
|
const ladder = [minRung, ...midRungs, maxRung]
|
||||||
[...durs].map(async (d) => {
|
.filter((r): r is ParkingDetail => r != null)
|
||||||
try {
|
.sort((a, b) => Number(a.Minutes) - Number(b.Minutes));
|
||||||
const r = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: d, creditCardId: 0 });
|
if (ladderAllFree(ladder)) return { free: true, flat: false, ladder: [] };
|
||||||
const p = r.ParkingDetail;
|
return { free: false, flat: false, ladder };
|
||||||
if (!p || (r as any)?.Response?.Status === 'Error') return null;
|
|
||||||
// The single endpoint often echoes Minutes:0; trust the requested duration.
|
|
||||||
return { ...p, Minutes: d } as ParkingDetail;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const ladder = rungs.filter((r): r is ParkingDetail => r != null);
|
|
||||||
// If every rung came back $0.00, it's effectively a free window too.
|
|
||||||
if (ladderAllFree(ladder)) return { free: true, ladder: [] };
|
|
||||||
return { free: false, ladder };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
|
|
||||||
function parseApiTime(s?: string): Date | null {
|
|
||||||
if (!s) return null;
|
|
||||||
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
|
||||||
if (!m) return null;
|
|
||||||
let hr = parseInt(m[4], 10);
|
|
||||||
const pm = /pm/i.test(m[6]);
|
|
||||||
if (pm && hr !== 12) hr += 12;
|
|
||||||
if (!pm && hr === 12) hr = 0;
|
|
||||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartSessionScreen() {
|
export function StartSessionScreen() {
|
||||||
|
|
@ -124,6 +130,8 @@ export function StartSessionScreen() {
|
||||||
const [ladderError, setLadderError] = useState<string | null>(null);
|
const [ladderError, setLadderError] = useState<string | null>(null);
|
||||||
// Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy.
|
// Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy.
|
||||||
const [estimatedFree, setEstimatedFree] = useState(false);
|
const [estimatedFree, setEstimatedFree] = useState(false);
|
||||||
|
// Flat-rate zone: one price for the whole window (a single option, not a ladder).
|
||||||
|
const [flatRate, setFlatRate] = useState(false);
|
||||||
|
|
||||||
// Load the account's vehicles + cards and pick the defaults.
|
// Load the account's vehicles + cards and pick the defaults.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -147,6 +155,7 @@ export function StartSessionScreen() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLadderError(null);
|
setLadderError(null);
|
||||||
setEstimatedFree(false);
|
setEstimatedFree(false);
|
||||||
|
setFlatRate(false);
|
||||||
const base = {
|
const base = {
|
||||||
zoneId: zone.ZoneId!,
|
zoneId: zone.ZoneId!,
|
||||||
spaceId: space.SpaceId!,
|
spaceId: space.SpaceId!,
|
||||||
|
|
@ -175,6 +184,7 @@ export function StartSessionScreen() {
|
||||||
setEstimatedFree(true);
|
setEstimatedFree(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setFlatRate(fb.flat);
|
||||||
details = fb.ladder;
|
details = fb.ladder;
|
||||||
}
|
}
|
||||||
if (!details.length) {
|
if (!details.length) {
|
||||||
|
|
@ -252,14 +262,18 @@ export function StartSessionScreen() {
|
||||||
minCreditAmount: zone.MinimumAmount,
|
minCreditAmount: zone.MinimumAmount,
|
||||||
meterTypeId: zone.MeterTypeId!,
|
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);
|
const end = parseApiTime(selected.EndTime);
|
||||||
if (end) {
|
if (end) {
|
||||||
await scheduleExpiryReminder({
|
await startPaidSession({
|
||||||
transactionId: (res as any)?.TransactionID ?? Date.now(),
|
zone,
|
||||||
zoneName: zone.ZoneName ?? 'Parking',
|
|
||||||
endTime: end,
|
endTime: end,
|
||||||
|
transactionId: (res as any)?.TransactionID,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logLine(`[SESSION] no countdown: unparseable EndTime "${selected.EndTime}"`);
|
||||||
}
|
}
|
||||||
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
||||||
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||||
|
|
@ -382,20 +396,29 @@ export function StartSessionScreen() {
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={[styles.label, { color: colors.subtext }]}>Duration</Text>
|
<Text style={[styles.label, { color: colors.subtext }]}>
|
||||||
<FlatList
|
{flatRate ? 'Flat rate' : 'Duration'}
|
||||||
horizontal
|
</Text>
|
||||||
data={ladder}
|
{flatRate ? (
|
||||||
keyExtractor={(d, i) => String(d.Minutes ?? i)}
|
<Text style={{ color: colors.text, marginBottom: 4 }}>
|
||||||
showsHorizontalScrollIndicator={false}
|
{money(selected?.ParkingCost)} flat — one price for the whole window
|
||||||
renderItem={({ item, index }) => (
|
{selected?.EndTime ? `, parked until ${selected.EndTime}` : ''}.
|
||||||
<Chip
|
</Text>
|
||||||
active={index === selIdx}
|
) : (
|
||||||
label={`${fmtDuration(item.Minutes)} · ${money(item.ParkingCost)}`}
|
<FlatList
|
||||||
onPress={() => setSelIdx(index)}
|
horizontal
|
||||||
/>
|
data={ladder}
|
||||||
)}
|
keyExtractor={(d, i) => String(d.Minutes ?? i)}
|
||||||
/>
|
showsHorizontalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<Chip
|
||||||
|
active={index === selIdx}
|
||||||
|
label={`${fmtDuration(item.Minutes)} · ${money(item.ParkingCost)}`}
|
||||||
|
onPress={() => setSelIdx(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text style={[styles.label, { color: colors.subtext }]}>Card</Text>
|
<Text style={[styles.label, { color: colors.subtext }]}>Card</Text>
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow}>
|
||||||
|
|
|
||||||
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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.0.0",
|
"@types/react": "~19.0.0",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
"typescript": "~5.4.0"
|
"typescript": "~5.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1599,6 +1600,448 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@expo/cli": {
|
||||||
"version": "0.24.24",
|
"version": "0.24.24",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
|
||||||
|
|
@ -4502,6 +4945,48 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -9151,6 +9636,25 @@
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-detect": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
"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
|
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
|
||||||
directly onto Retrofit/OkHttp — ask and it can be ported.
|
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
|
## Install / build
|
||||||
|
|
||||||
```bash
|
```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`,
|
- **CONFIRMED via live capture:** login (`AuthResponse`), `UserDetail`, `VehicleDetail`,
|
||||||
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
|
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
|
||||||
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
|
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
|
||||||
states wrapper, the password-reset flow, and the shared `Response` envelope.
|
states wrapper, the password-reset flow, the shared `Response` envelope, and — from a real
|
||||||
- **UNCONFIRMED (no session history on the test account):** `ActiveSession`,
|
DL-zone session — `PastSession` and `ParkingReceipt`.
|
||||||
`PastSession`, `ParkingReceipt`. Field names are from static analysis; capture from an
|
- **UNCONFIRMED:** `ActiveSession` (no *active* session existed on the account at capture
|
||||||
account with at least one past/active session to confirm.
|
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
|
1. **200-on-error.** Failed login, declined payment, and some bad requests return **HTTP
|
||||||
valid `Auth_Token`, despite being data lookups.
|
200** with `Response.Status: "Error"`. Check the envelope, not just the status code.
|
||||||
2. **`Response` envelope + token refresh.** Most authenticated responses embed
|
2. **Meter/estimate search requires auth.** `/api/Meter`, `/api/MeterList`, and estimates
|
||||||
`Response: { Auth_Token, Message, Status }`. When `Response.Auth_Token` (or a top-level
|
**401** without a valid `Auth_Token`, despite being data lookups.
|
||||||
`Auth_Token`) is non-empty, it's a rolling refresh of your user token — the client stores
|
3. **Rolling token refresh.** A non-empty `Response.Auth_Token` (or top-level) replaces your
|
||||||
it automatically.
|
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
|
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
|
saved card and session history. There's **no TLS pinning**, so a proxy capture on a rooted
|
||||||
|
|
|
||||||
65
parksmarter-client/capture-dl.mjs
Normal file
65
parksmarter-client/capture-dl.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* capture-dl.mjs — record the live requests + responses for the Sandpoint "DL"
|
||||||
|
* zone (ZoneID 113165 / SpaceID 329522 / CustomerID 217) so we have a durable
|
||||||
|
* record of its odd flat-rate behavior.
|
||||||
|
*
|
||||||
|
* Writes one JSON file per call (request line with tokens redacted by the client
|
||||||
|
* + the full parsed response) to ~/Downloads/dl-capture/ plus a _summary.json.
|
||||||
|
*
|
||||||
|
* Usage: node capture-dl.mjs (needs ./.creds.json)
|
||||||
|
*/
|
||||||
|
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ParkSmarterClient } from './dist/index.js';
|
||||||
|
|
||||||
|
const c = JSON.parse(readFileSync('./.creds.json', 'utf8'));
|
||||||
|
const OUT = join(homedir(), 'Downloads', 'dl-capture');
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const reqLog = [];
|
||||||
|
const ps = new ParkSmarterClient({
|
||||||
|
environment: 'prodv2',
|
||||||
|
timeoutMs: 20000,
|
||||||
|
logRequests: true, // the client redacts Auth_Token/Application_Token/SessionId
|
||||||
|
logSink: (line) => reqLog.push(line),
|
||||||
|
});
|
||||||
|
|
||||||
|
await ps.loginWithPhone({ phoneNumber: c.phoneNumber, password: c.password });
|
||||||
|
const me = await ps.getUserDetail();
|
||||||
|
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID;
|
||||||
|
const base = { zoneId: 113165, spaceId: 329522, customerId: 217, vehicleId };
|
||||||
|
|
||||||
|
const save = (name, obj) => writeFileSync(join(OUT, `${name}.json`), JSON.stringify(obj, null, 2) + '\n');
|
||||||
|
const summary = { zone: base, note: 'Sandpoint DL zone — flat $0.10, MaxTime = minutes until the paid-window boundary; ParkingEstimateMulti errors here.', calls: [] };
|
||||||
|
|
||||||
|
async function cap(name, fn) {
|
||||||
|
const from = reqLog.length;
|
||||||
|
let response = null, error = null;
|
||||||
|
try { response = await fn(); } catch (e) { error = `${e.name} ${e.status ?? ''} ${e.message}`; }
|
||||||
|
const entry = { name, capturedField: 'request+response', request: reqLog.slice(from), response, error };
|
||||||
|
save(name, entry);
|
||||||
|
summary.calls.push({ name, ok: !error, error });
|
||||||
|
console.log(` ${name}: ${error ? 'ERR ' + error : 'ok'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Capturing DL zone -> ${OUT}`);
|
||||||
|
// The zone/space/policy record (search for DL by name).
|
||||||
|
await cap('00-zone-DL', async () => {
|
||||||
|
const byName = await ps.getMetersByZoneName('DL');
|
||||||
|
const z = (byName?.Zones || []).find((z) => (z.ZoneId ?? z.ZoneID) == 113165) ?? byName?.Zones?.[0];
|
||||||
|
return z ?? { note: 'DL zone not found by name lookup' };
|
||||||
|
});
|
||||||
|
// Single-estimate at a spread of durations (shows the flat $0.10 + moving MaxTime).
|
||||||
|
for (const d of [5, 15, 30, 60, 120, 240, 480]) {
|
||||||
|
await cap(`10-estimate-single-${String(d).padStart(3, '0')}min`, () =>
|
||||||
|
ps.getParkingEstimateSingle({ ...base, durationInMinutes: d, creditCardId: 0 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The multi ladder (errors for this zone) and the item-based estimate.
|
||||||
|
await cap('20-estimate-multi', () => ps.getParkingEstimateMulti(base));
|
||||||
|
await cap('21-estimate-items', () => ps.getParkingEstimateItems(base));
|
||||||
|
|
||||||
|
summary.capturedAtLocalNote = 'timestamps are inside each request/response line';
|
||||||
|
save('_summary', summary);
|
||||||
|
console.log(`Done. ${summary.calls.length + 1} files in ${OUT}`);
|
||||||
|
|
@ -488,6 +488,7 @@ export interface ActiveSession {
|
||||||
EndTime?: string;
|
EndTime?: string;
|
||||||
StartTimeDisplay?: string;
|
StartTimeDisplay?: string;
|
||||||
EndTimeDisplay?: string;
|
EndTimeDisplay?: string;
|
||||||
|
/** Seconds left on the session (CONFIRMED via a live session — NOT minutes). */
|
||||||
TimeRemaining?: number | string;
|
TimeRemaining?: number | string;
|
||||||
VehiclePlate?: string;
|
VehiclePlate?: string;
|
||||||
VehicleNumber?: string;
|
VehicleNumber?: string;
|
||||||
|
|
@ -504,17 +505,34 @@ export interface ActiveSessionsResponse {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UNCONFIRMED element shape (no past sessions on the test account). */
|
/** CONFIRMED via a live DL-zone session (fields observed on GET /api/Session). */
|
||||||
export interface PastSession {
|
export interface PastSession {
|
||||||
TransactionID?: number | string;
|
TransactionID?: number | string;
|
||||||
ZoneName?: string;
|
Zone?: string;
|
||||||
|
ZoneID?: number | string;
|
||||||
|
Space?: string;
|
||||||
|
SpaceID?: number | string;
|
||||||
StartTime?: string;
|
StartTime?: string;
|
||||||
EndTime?: string;
|
EndTime?: string;
|
||||||
|
TimePurchased?: number | string;
|
||||||
|
VehicleNumber?: string;
|
||||||
Amount?: number | string;
|
Amount?: number | string;
|
||||||
PaymentType?: string;
|
TransactionFee?: number | string;
|
||||||
|
CardFirstSix?: string;
|
||||||
|
CardLastFour?: string;
|
||||||
PaymentDisplay?: string;
|
PaymentDisplay?: string;
|
||||||
VehiclePlate?: string;
|
CustomerID?: number | string;
|
||||||
IsPaid?: boolean;
|
CustomerName?: string;
|
||||||
|
City?: string;
|
||||||
|
Description?: string;
|
||||||
|
MeterTypeId?: number;
|
||||||
|
Lat?: number | string;
|
||||||
|
Long?: number | string;
|
||||||
|
IsFavorite?: boolean;
|
||||||
|
FavoriteID?: number | string;
|
||||||
|
Logo?: string;
|
||||||
|
/** Legacy/alt field some views used; prefer `Zone`. */
|
||||||
|
ZoneName?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -534,21 +552,41 @@ export interface PastSessionsParams {
|
||||||
/* Receipts */
|
/* Receipts */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** CONFIRMED via a live DL-zone receipt (GET /api/ParkingReceipt). */
|
||||||
export interface ParkingReceipt {
|
export interface ParkingReceipt {
|
||||||
TransactionID?: number | string;
|
TransactionID?: number | string;
|
||||||
ZoneName?: string;
|
CustomerID?: number | string;
|
||||||
|
CustomerName?: string;
|
||||||
|
Space?: string;
|
||||||
|
SpaceID?: number | string;
|
||||||
|
MeterNumber?: string;
|
||||||
|
MeterTypeId?: number;
|
||||||
StartTime?: string;
|
StartTime?: string;
|
||||||
EndTime?: string;
|
EndTime?: string;
|
||||||
Amount?: number | string;
|
|
||||||
Total?: number | string;
|
|
||||||
TotalCost?: number | string;
|
|
||||||
TransactionFee?: number | string;
|
|
||||||
PaymentType?: string;
|
PaymentType?: string;
|
||||||
VehiclePlate?: string;
|
PaymentDisplay?: string;
|
||||||
|
CardType?: string | null;
|
||||||
|
/** Masked card, e.g. "****1234". */
|
||||||
|
CC?: string;
|
||||||
|
AuthCode?: string;
|
||||||
|
VehicleID?: number | string;
|
||||||
|
Vehicle?: string;
|
||||||
|
/** Display strings, e.g. "$0.10". */
|
||||||
|
Amount?: string;
|
||||||
|
AmountCharged?: string;
|
||||||
|
TransactionFee?: string;
|
||||||
|
Total?: string;
|
||||||
|
/** Numeric equivalents. */
|
||||||
|
AmountValue?: number;
|
||||||
|
TransactionFeeValue?: number;
|
||||||
|
TotalValue?: number;
|
||||||
|
Logo?: string;
|
||||||
|
BackgroundColor?: string;
|
||||||
|
ForegroundColor?: string;
|
||||||
|
ZoneName?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UNCONFIRMED (no receipts on the test account) — field names from static analysis. */
|
|
||||||
export interface ReceiptResponse {
|
export interface ReceiptResponse {
|
||||||
ParkingReceipt?: ParkingReceipt;
|
ParkingReceipt?: ParkingReceipt;
|
||||||
Response?: ResponseEnvelope;
|
Response?: ResponseEnvelope;
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||||
import { ParkSmarterClient, isFreeEstimate } from '../dist/index.js';
|
import { ParkSmarterClient, isFreeEstimate } from '../dist/index.js';
|
||||||
|
|
||||||
const ENV = process.env.PS_TEST_ENV || 'test';
|
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 credsUrl = new URL('../.creds.json', import.meta.url);
|
||||||
const creds = existsSync(credsUrl) ? JSON.parse(readFileSync(credsUrl, 'utf8')) : {};
|
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.
|
// Probe reachability once; if the instance blocks us, skip the whole suite.
|
||||||
let reason = false;
|
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');
|
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 ps = await client();
|
||||||
const m = await ps.getMetersByLocation(SANDPOINT);
|
const m = await ps.getMetersByLocation(SANDPOINT);
|
||||||
assert.ok(Array.isArray(m.Zones) && m.Zones.length > 0, 'expected zones near 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