Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 718c72dde1 | |||
| 4217e88338 | |||
| 6af3dad762 |
12 changed files with 591 additions and 95 deletions
23
README.md
23
README.md
|
|
@ -56,11 +56,19 @@ The **City map** layer on the Map tab is the City of Sandpoint's printed *Downto
|
||||||
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
|
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.
|
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
|
||||||
|
|
||||||
**None of it touches ParkSmarter.** The areas live in the local database (bundled with the
|
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
|
||||||
app, refreshed from the zone-labels server, cached on-device), the countdown is the phone's
|
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
|
||||||
own clock, and the notification is the same foreground service every other session uses. So
|
phone's own clock, and the notification is the same foreground service every other session
|
||||||
tracking your time on a city spot works with no account, no signal, no payment, and in
|
uses. So tracking your time on a free city spot works with no account, no signal, no
|
||||||
Anonymous Mode. Two ways to start:
|
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
|
- **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
|
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
|
||||||
|
|
@ -73,6 +81,11 @@ offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing no
|
||||||
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
||||||
edits the local timer and says so.
|
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
|
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).
|
(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
|
Because a few metres is the difference between two sides of a street, **Account → Align city
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.6.1",
|
"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": 22,
|
"versionCode": 25,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ export interface ParkingArea {
|
||||||
color: string;
|
color: string;
|
||||||
shape: 'line' | 'polygon';
|
shape: 'line' | 'polygon';
|
||||||
geometry: AreaGeometry;
|
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 {
|
export interface AreaData {
|
||||||
|
|
@ -76,6 +81,19 @@ export function areaIsFree(kind: AreaKind): boolean {
|
||||||
return kind !== 'green_lot';
|
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.
|
* Durations offered when starting tracking, the posted limit first.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from '@/features/notifications/reminderPrefs';
|
} from '@/features/notifications/reminderPrefs';
|
||||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
import type { ParkingArea } from '@/api/parkingAreas';
|
import type { ParkingArea } from '@/api/parkingAreas';
|
||||||
|
import { recordLocalSession } from './localHistory';
|
||||||
import {
|
import {
|
||||||
clearActiveParking,
|
clearActiveParking,
|
||||||
getActiveParking,
|
getActiveParking,
|
||||||
|
|
@ -255,6 +256,10 @@ export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void>
|
||||||
* keeps running at the meter whether or not the app is showing it.
|
* keeps running at the meter whether or not the app is showing it.
|
||||||
*/
|
*/
|
||||||
export async function endActiveParking(): Promise<void> {
|
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 clearActiveParking();
|
||||||
await clearNotification();
|
await clearNotification();
|
||||||
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/nativ
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
|
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import {
|
import {
|
||||||
endActiveParking,
|
endActiveParking,
|
||||||
extendAreaParking,
|
extendAreaParking,
|
||||||
|
|
@ -43,6 +44,7 @@ export function CityAreaScreen() {
|
||||||
const { area, spot } = useRoute<AreaRoute>().params;
|
const { area, spot } = useRoute<AreaRoute>().params;
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
const { isAnonymous, requireLogin } = useAuth();
|
||||||
const [active, setActive] = useState<ActiveParking | null>(null);
|
const [active, setActive] = useState<ActiveParking | null>(null);
|
||||||
|
|
||||||
const options = areaDurationOptions(area);
|
const options = areaDurationOptions(area);
|
||||||
|
|
@ -121,7 +123,18 @@ export function CityAreaScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{parkedHere && active ? (
|
{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}>
|
<View style={s.card}>
|
||||||
<Text style={s.sectionTitle}>Tracking now</Text>
|
<Text style={s.sectionTitle}>Tracking now</Text>
|
||||||
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ActivityIndicator, Alert, 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,
|
||||||
|
|
@ -18,7 +26,12 @@ import { ps } from '@/api/client';
|
||||||
import { useLocation, type Coords } from '@/features/location/useLocation';
|
import { useLocation, type Coords } from '@/features/location/useLocation';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
||||||
import { getAdjustedAreas, refreshAreas, type ParkingArea } from '@/api/parkingAreas';
|
import {
|
||||||
|
areaRequiresAccount,
|
||||||
|
getAdjustedAreas,
|
||||||
|
refreshAreas,
|
||||||
|
type ParkingArea,
|
||||||
|
} from '@/api/parkingAreas';
|
||||||
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
|
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
|
||||||
import {
|
import {
|
||||||
getParkedPin,
|
getParkedPin,
|
||||||
|
|
@ -40,6 +53,20 @@ const MAP_STYLE_DARK =
|
||||||
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||||
const DEFAULT_ZOOM = 14;
|
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>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,7 +107,19 @@ 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 { 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”.');
|
||||||
|
|
@ -108,7 +147,9 @@ 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);
|
||||||
|
|
||||||
// Look up the LAST session's parking-lot coordinate (the meter's own location
|
// 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".
|
// from history — never the user's GPS). Used to open the map and by "Last lot".
|
||||||
|
|
@ -211,13 +252,32 @@ export function MapScreen() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Re-read the pin on every focus: the session may have ended on another screen
|
// Re-read the pin on every focus: the session may have ended on another screen
|
||||||
// (or from the notification), which clears it.
|
// (or from the notification), which clears it. Focus also gates the GPS poll.
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
void getParkedPin().then(setSpot);
|
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
|
// 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
|
// 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.)
|
// location, tap "My location" to center there first, then Search this area.)
|
||||||
|
|
@ -241,10 +301,9 @@ export function MapScreen() {
|
||||||
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
|
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;
|
||||||
|
|
@ -282,6 +341,19 @@ export function MapScreen() {
|
||||||
|
|
||||||
/* -------------------------------------------------- parking-map interaction */
|
/* -------------------------------------------------- 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. */
|
/** Open an area, carrying the pin along if we have one. */
|
||||||
const openArea = useCallback(
|
const openArea = useCallback(
|
||||||
(area: ParkingArea, at?: ParkedSpot) => {
|
(area: ParkingArea, at?: ParkedSpot) => {
|
||||||
|
|
@ -298,8 +370,12 @@ export function MapScreen() {
|
||||||
// Persist immediately — the pin is worth keeping even if you never start a
|
// 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.
|
// timer, and even if you back out of the screen we're about to open.
|
||||||
void pinParkedSpot(at);
|
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);
|
const found = areaAt([c.longitude, c.latitude], areas);
|
||||||
if (found) {
|
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}`);
|
setStatus(`Parked at ${found.name}`);
|
||||||
openArea(found, at);
|
openArea(found, at);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -307,7 +383,7 @@ export function MapScreen() {
|
||||||
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
|
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[areas, openArea],
|
[areas, hidden, openArea],
|
||||||
);
|
);
|
||||||
|
|
||||||
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
|
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
|
||||||
|
|
@ -319,7 +395,7 @@ export function MapScreen() {
|
||||||
setStatus('Pin cancelled.');
|
setStatus('Pin cancelled.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
const c = await bestFix();
|
||||||
if (!c) {
|
if (!c) {
|
||||||
setPinning(true);
|
setPinning(true);
|
||||||
setStatus('No GPS fix — tap the map where you parked.');
|
setStatus('No GPS fix — tap the map where you parked.');
|
||||||
|
|
@ -362,21 +438,21 @@ export function MapScreen() {
|
||||||
/** A tap on a coloured segment: the other start flow, no pin involved. */
|
/** A tap on a coloured segment: the other start flow, no pin involved. */
|
||||||
const onAreaPress = (e: any) => {
|
const onAreaPress = (e: any) => {
|
||||||
const id = e?.features?.[0]?.properties?.id;
|
const id = e?.features?.[0]?.properties?.id;
|
||||||
const found = areas.find((a) => a.id === id);
|
const found = visibleAreas.find((a) => a.id === id);
|
||||||
if (found) openArea(found, spot ?? undefined);
|
if (found) openArea(found, spot ?? undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const areaFeatures = useMemo(
|
const areaFeatures = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: areas.map((a) => ({
|
features: visibleAreas.map((a) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
id: a.id,
|
id: a.id,
|
||||||
geometry: a.geometry,
|
geometry: a.geometry,
|
||||||
properties: { id: a.id, color: a.color, kind: a.kind },
|
properties: { id: a.id, color: a.color, kind: a.kind },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[areas],
|
[visibleAreas],
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotFeature = useMemo(
|
const spotFeature = useMemo(
|
||||||
|
|
@ -436,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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,72 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { useFocusEffect, 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 { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
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 { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
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 navigation = useNavigation<Nav>();
|
||||||
const { isAnonymous, requireLogin } = useAuth();
|
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. */
|
||||||
|
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;
|
if (isAnonymous) return;
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
try {
|
||||||
const [a, p] = await Promise.all([
|
const [a, p] = await Promise.all([
|
||||||
ps.getActiveParkingSessions(),
|
ps.getActiveParkingSessions(),
|
||||||
|
|
@ -28,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);
|
||||||
}
|
}
|
||||||
}, [isAnonymous]);
|
}, [loadLocal, loadRemote]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|
@ -39,24 +97,14 @@ export function SessionsScreen() {
|
||||||
}, [load]),
|
}, [load]),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAnonymous) {
|
// Keep "time left" honest while the screen sits open.
|
||||||
return (
|
useEffect(() => {
|
||||||
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
if (!local) return;
|
||||||
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
|
const id = setInterval(() => void loadLocal(), 30_000);
|
||||||
Sign in to see your sessions
|
return () => clearInterval(id);
|
||||||
</Text>
|
}, [local, loadLocal]);
|
||||||
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
|
|
||||||
Your active and past parking sessions live in your ParkSmarter account.
|
const s = styles;
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
|
|
||||||
onPress={requireLogin}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|
@ -64,52 +112,166 @@ export function SessionsScreen() {
|
||||||
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}>
|
||||||
|
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
|
||||||
|
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.big, { color: colors.text }]}>
|
||||||
|
{fmtRemaining(local.endMs - Date.now())} left
|
||||||
|
</Text>
|
||||||
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
|
{local.area?.legend ? `${local.area.legend} · ` : ''}
|
||||||
|
{local.kind === 'free' ? 'Free' : 'Paid'} until {fmtClock(local.endMs)}
|
||||||
|
{local.spot ? (local.spot.manual ? ' · pin placed by hand' : ' · pinned from GPS') : ''}
|
||||||
|
</Text>
|
||||||
|
<View style={s.row}>
|
||||||
|
{/* Same split as the notification's second button: a local timer can be
|
||||||
|
nudged for free, but a bought session can only be extended by buying
|
||||||
|
more, so that one goes to the purchase screen instead of lying. */}
|
||||||
|
{isLocalOnly(local) ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, { borderColor: colors.border }]}
|
||||||
|
onPress={async () => {
|
||||||
|
await extendAreaParking();
|
||||||
|
await loadLocal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '700' }}>+1 hour</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : local.zone ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, { borderColor: colors.border }]}
|
||||||
|
onPress={() => navigation.navigate('StartSession', { zone: local.zone! })}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '700' }}>Extend</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btnFilled, { backgroundColor: colors.danger }]}
|
||||||
|
onPress={async () => {
|
||||||
|
await endActiveParking();
|
||||||
|
await loadLocal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>End</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.hint, { color: colors.subtext }]}>
|
||||||
|
{isLocalOnly(local)
|
||||||
|
? 'Works offline — this timer lives on your phone, not on a server.'
|
||||||
|
: 'Ending stops the countdown here. Time you bought keeps running at the meter.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
active.map((s, i) => (
|
<Text style={[s.empty, { color: colors.subtext }]}>
|
||||||
<TouchableOpacity
|
Nothing being tracked. Start one from the map — “Park here”, or tap a coloured block.
|
||||||
key={i}
|
</Text>
|
||||||
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
|
|
||||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
|
|
||||||
>
|
|
||||||
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
|
|
||||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
|
||||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} › tap for details
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={[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) => (
|
||||||
past.map((s, i) => (
|
<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
|
<TouchableOpacity
|
||||||
key={i}
|
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
|
||||||
style={[styles.card, { backgroundColor: colors.card }]}
|
onPress={requireLogin}
|
||||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
|
|
||||||
>
|
>
|
||||||
<Text style={[styles.zone, { color: colors.text }]}>
|
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||||
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
|
||||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} › tap for receipt
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
|
||||||
|
{remoteError ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
|
||||||
|
) : active.length === 0 ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||||
|
) : (
|
||||||
|
active.map((sess, i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i}
|
||||||
|
style={[s.card, { backgroundColor: colors.primary + '22' }]}
|
||||||
|
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'active' })}
|
||||||
|
>
|
||||||
|
<Text style={[s.zone, { color: colors.text }]}>{sess.ZoneName ?? 'Session'}</Text>
|
||||||
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
|
{sess.SpaceName ?? sess.Space ?? ''} · ends{' '}
|
||||||
|
{sess.EndTimeDisplay ?? sess.EndTime ?? ''} › tap for details
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>History (ParkSmarter)</Text>
|
||||||
|
{remoteError ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>Unavailable offline.</Text>
|
||||||
|
) : past.length === 0 ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||||
|
) : (
|
||||||
|
past.map((sess, i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i}
|
||||||
|
style={[s.card, { backgroundColor: colors.card }]}
|
||||||
|
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
|
||||||
|
>
|
||||||
|
<Text style={[s.zone, { color: colors.text }]}>
|
||||||
|
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
|
{sess.StartTime ?? ''} · {sess.Amount != null ? `$${sess.Amount}` : ''} › tap for
|
||||||
|
receipt
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
|
|
||||||
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' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,9 @@ export async function buildApp(opts: BuildOptions) {
|
||||||
color: String(raw.color ?? '#888888'),
|
color: String(raw.color ?? '#888888'),
|
||||||
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
||||||
geometry: g,
|
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() };
|
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ export interface ParkingArea {
|
||||||
shape: 'line' | 'polygon';
|
shape: 'line' | 'polygon';
|
||||||
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
||||||
geometry: unknown;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,6 +147,16 @@ export class LabelDb {
|
||||||
updated_at INTEGER 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 */
|
/* ------------------------------------------------ city parking-map areas */
|
||||||
|
|
@ -159,6 +175,8 @@ export class LabelDb {
|
||||||
color: r.color,
|
color: r.color,
|
||||||
shape: r.shape as 'line' | 'polygon',
|
shape: r.shape as 'line' | 'polygon',
|
||||||
geometry: JSON.parse(r.geometry),
|
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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,13 +200,20 @@ export class LabelDb {
|
||||||
replaceAreas(areas: ParkingArea[]): number {
|
replaceAreas(areas: ParkingArea[]): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const insert = this.db.prepare(
|
const insert = this.db.prepare(
|
||||||
`INSERT INTO parking_areas (id, kind, name, label, legend, hours, color, shape, geometry, updated_at)
|
`INSERT INTO parking_areas
|
||||||
VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`,
|
(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.transaction((items: ParkingArea[]) => {
|
||||||
this.db.prepare('DELETE FROM parking_areas').run();
|
this.db.prepare('DELETE FROM parking_areas').run();
|
||||||
for (const a of items) {
|
for (const a of items) {
|
||||||
insert.run({ ...a, geometry: JSON.stringify(a.geometry), updatedAt: now });
|
insert.run({
|
||||||
|
...a,
|
||||||
|
geometry: JSON.stringify(a.geometry),
|
||||||
|
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})(areas);
|
})(areas);
|
||||||
return areas.length;
|
return areas.length;
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,58 @@ test('polygons are accepted; bad kinds and geometries are rejected', async () =>
|
||||||
await app.close();
|
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 () => {
|
test('overlay defaults to identity and round-trips', async () => {
|
||||||
const app = await make();
|
const app = await make();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue