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

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:
Hank 2026-07-13 19:29:13 -07:00
parent 70047b3964
commit 0a6ca53c04
4 changed files with 80 additions and 41 deletions

View file

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useRef, useState } from 'react';
import {
Image,
Linking,
Modal,
Pressable,
ScrollView,
@ -9,24 +10,17 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
const TAPS_TO_UNLOCK = 7;
const REPO_URL = 'https://git.mowden.top/hank/BigBrainParking';
export function AboutScreen() {
const { colors } = useTheme();
const [about, setAbout] = useState<string | null>(null);
const [showJoel, setShowJoel] = useState(false);
const taps = useRef(0);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
ps.getAbout()
.then((r) => setAbout(r.Value ?? ''))
.catch(() => setAbout(''));
}, []);
// Tap the title 7 times (within a rolling window) to summon Joel.
const onTitleTap = () => {
taps.current += 1;
@ -44,7 +38,17 @@ export function AboutScreen() {
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
</Pressable>
<Text style={[styles.body, { color: colors.text }]}>{about ?? 'Loading…'}</Text>
<Text style={[styles.body, { color: colors.text }]}>
BigBrainParking is a free, <Text style={{ fontWeight: '700' }}>open-source</Text>,
de-Googled client for the ParkSmarter parking system, built to run on GrapheneOS
without any Google services. Its an independent project not affiliated with,
endorsed by, or supported by IPS Group / ParkSmarter.
</Text>
<TouchableOpacity onPress={() => Linking.openURL(REPO_URL)} style={{ marginTop: 12 }}>
<Text style={[styles.link, { color: colors.primary }]}>Source code & issues </Text>
<Text style={[styles.linkUrl, { color: colors.subtext }]}>{REPO_URL}</Text>
</TouchableOpacity>
<Text style={[styles.section, { color: colors.text }]}>What gets sent to ParkSmarter (IPS)</Text>
<Text style={[styles.body, { color: colors.text }]}>
@ -52,16 +56,20 @@ export function AboutScreen() {
{'\n'} Sign-in (your phone number + password) and your account data vehicles, cards,
and session history the same as the official app.
{'\n'} To find meters, a single map coordinate: the point the map is centered on when you
search. Near me just centers the map on your device location first; otherwise its
wherever youve panned/zoomed to.
search. Your device GPS is sent <Text style={{ fontWeight: '700' }}>only</Text> if you tap
My location to center the map on yourself and then search; otherwise its wherever youve
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 phones GPS is used only on-device to position the map, and cached locally so
last location works it is never attached to sign-in, sessions, vehicles, or payments.
{'\n'} Your phones 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 meters location, not your GPS), and Last lot does the same. GPS
otherwise only draws your dot on the map and is never attached to sign-in, sessions,
vehicles, or payments.
{'\n'} No background location, no tracking, no device ID / IMEI / advertising identifiers,
and no Google services. Session-expiry reminders are scheduled entirely on your device.
</Text>
@ -84,6 +92,8 @@ const styles = StyleSheet.create({
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
section: { fontSize: 17, fontWeight: '700', marginTop: 22, marginBottom: 6 },
body: { fontSize: 15, lineHeight: 22, color: '#333' },
link: { fontSize: 15, fontWeight: '700' },
linkUrl: { fontSize: 13, marginTop: 2 },
meta: { fontSize: 12, color: '#999', marginTop: 24 },
joelBackdrop: {
flex: 1,

View file

@ -13,7 +13,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client';
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
import { useLocation, type Coords } from '@/features/location/useLocation';
import type { RootStackParamList } from '@/navigation/RootNavigator';
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).
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(() => {
(async () => {
const last = await getLastKnownSavedLocation();
setInitialCenter(last ?? coords ?? DEFAULT_CENTER);
const lot = await lastSessionLot();
setInitialCenter(lot ?? DEFAULT_CENTER);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -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 () => {
let center: [number, number] | null = null;
try {
const center = await mapRef.current?.getCenter?.(); // [lng, lat]
if (center && center.length === 2) {
// Commit the current view as the camera's stop so the results re-render
// doesn't revert to the last programmatic (e.g. "My location") target.
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
return;
}
const c = await mapRef.current?.getCenter?.(); // [lng, lat]
if (Array.isArray(c) && c.length === 2) center = [c[0], c[1]];
} catch {
/* fall through */
}
if (coords) await searchAt(coords, 'this area');
// Fallback is the tracked viewport center — deliberately NOT the GPS fix.
if (!center && viewRef.current) center = viewRef.current.center;
if (!center) {
setStatus('Move the map, then tap “Search this area”.');
return;
}
// Commit the current view as the camera's stop so the results re-render
// doesn't revert to the last programmatic (e.g. "My location") target.
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
};
// Recenter on the live GPS fix (if available) and search there. Prefer the
@ -142,18 +166,23 @@ export function MapScreen() {
await searchAt(c, 'you');
};
const searchLastKnown = async () => {
const last = (await getLastKnownSavedLocation()) ?? coords;
if (!last) {
setStatus('No saved location yet — enable location once to cache it.');
// Center on the LAST SESSION's parking lot (the meter's own coordinate from
// history — never your GPS) and search around it with a ~couple-mile view.
const searchLastSessionLot = async () => {
setLoading(true);
setStatus('Finding your last parking lot…');
const lot = await lastSessionLot();
if (!lot) {
setStatus('No past parking session with a location yet.');
setLoading(false);
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [last.longitude, last.latitude],
zoomLevel: 15,
animationDuration: 500,
centerCoordinate: [lot.longitude, lot.latitude],
zoomLevel: 12.5, // ~a couple miles across
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
@ -259,8 +288,8 @@ export function MapScreen() {
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<Text style={styles.pillText}>My location</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
<Text style={styles.pillText}>Last</Text>
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
<Text style={styles.pillText}>Last lot</Text>
</TouchableOpacity>
</View>
</View>