v0.2.0: never auto-send GPS; "Last lot" uses last session; About page rework
All checks were successful
build-apk / build (push) Successful in 11m47s
All checks were successful
build-apk / build (push) Successful in 11m47s
Location privacy (hard invariant): the device GPS is sent to the API ONLY when the user taps "My location" and searches. - Map opens on your LAST session's parking lot (the meter's coordinate from history), not your GPS. - "Last" -> "Last lot": centers on the last session's lot and searches there (~couple-mile view) — no GPS. - "Search this area" falls back to the tracked viewport center, never the GPS fix. About page: lead with a BigBrainParking open-source blurb + repo link (was showing ParkSmarter's own About text first); privacy wording updated to match; drop the now-unused getAbout fetch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
70047b3964
commit
0a6ca53c04
4 changed files with 80 additions and 41 deletions
|
|
@ -35,7 +35,7 @@ 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 |
|
| 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 + tap for full receipt |
|
| Active / past sessions | ✅ wired | list views + tap for full receipt |
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.1.9",
|
"version": "0.2.0",
|
||||||
"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": 9,
|
"versionCode": 10,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -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,17 @@ 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.section, { color: colors.text }]}>What gets sent to ParkSmarter (IPS)</Text>
|
||||||
<Text style={[styles.body, { color: colors.text }]}>
|
<Text style={[styles.body, { color: colors.text }]}>
|
||||||
|
|
@ -52,16 +56,20 @@ export function AboutScreen() {
|
||||||
{'\n'}• Sign-in (your phone number + password) and your account data — vehicles, cards,
|
{'\n'}• Sign-in (your phone number + password) and your account data — vehicles, cards,
|
||||||
and session history — the same as the official app.
|
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
|
{'\n'}• To find meters, a single map coordinate: the point the map is centered on when you
|
||||||
search. “Near me” just centers the map on your device location first; otherwise it’s
|
search. Your device GPS is sent <Text style={{ fontWeight: '700' }}>only</Text> if you tap
|
||||||
wherever you’ve panned/zoomed to.
|
“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.
|
{'\n'}• To start a session: the meter, your vehicle and card, the times, and the amount.
|
||||||
No location is attached.
|
No location is attached.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.section, { color: colors.text }]}>What it does not do</Text>
|
<Text style={[styles.section, { color: colors.text }]}>What it does not do</Text>
|
||||||
<Text style={[styles.body, { color: colors.text }]}>
|
<Text style={[styles.body, { color: colors.text }]}>
|
||||||
{'\n'}• Your phone’s GPS is used only on-device to position the map, and cached locally so
|
{'\n'}• Your phone’s GPS is <Text style={{ fontWeight: '700' }}>never sent to the API</Text>{' '}
|
||||||
“last location” works — it is never attached to sign-in, sessions, vehicles, or payments.
|
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,
|
{'\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.
|
and no Google services. Session-expiry reminders are scheduled entirely on your device.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -84,6 +92,8 @@ const styles = StyleSheet.create({
|
||||||
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
|
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
|
||||||
section: { fontSize: 17, fontWeight: '700', marginTop: 22, marginBottom: 6 },
|
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,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ 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 type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
|
|
@ -68,11 +68,29 @@ export function MapScreen() {
|
||||||
// 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);
|
const nativeFix = useRef<Coords | 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> => {
|
||||||
|
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;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -109,21 +127,27 @@ export function MapScreen() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Search whatever the map is currently centered on (works with no GPS).
|
// 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. Prefer the
|
||||||
|
|
@ -142,18 +166,23 @@ 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.');
|
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: 500,
|
animationDuration: 600,
|
||||||
});
|
});
|
||||||
await searchAt(last, 'last location');
|
await searchAt(lot, 'your last lot');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
|
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
|
||||||
|
|
@ -259,8 +288,8 @@ export function MapScreen() {
|
||||||
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
||||||
<Text style={styles.pillText}>My location</Text>
|
<Text style={styles.pillText}>My location</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
|
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
|
||||||
<Text style={styles.pillText}>Last</Text>
|
<Text style={styles.pillText}>Last lot</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue