Add request logging mode, dark mode, Account hub, profile, vehicle CRUD, cards view

- Client: logRequests option + redacted request/response logging (setLogRequests to
  toggle at runtime); enabled via app extra.debugHttp for on-device 401 debugging
- Dark mode (persisted) via ThemeProvider + React Navigation theme
- New "Account" tab: Profile (view), Vehicles (full CRUD), Payment methods (view-only),
  About, dark-mode + request-logging toggles, sign out

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 10:14:24 -07:00
parent 65e9118806
commit 2f9f0f604a
11 changed files with 707 additions and 8 deletions

View file

@ -2,15 +2,18 @@ import React from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from '@/auth/AuthContext'; import { AuthProvider } from '@/auth/AuthContext';
import { ThemeProvider } from '@/theme/ThemeContext';
import { RootNavigator } from '@/navigation/RootNavigator'; import { RootNavigator } from '@/navigation/RootNavigator';
export default function App() { export default function App() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<RootNavigator /> <RootNavigator />
<StatusBar style="auto" /> <StatusBar style="auto" />
</AuthProvider> </AuthProvider>
</ThemeProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }

View file

@ -44,7 +44,8 @@
"extra": { "extra": {
"psEnvironment": "prodv2", "psEnvironment": "prodv2",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty", "mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"unifiedPushDefaultDistributor": "io.heckel.ntfy" "unifiedPushDefaultDistributor": "io.heckel.ntfy",
"debugHttp": true
} }
} }
} }

View file

@ -17,4 +17,7 @@ export const ps = new ParkSmarterClient({
tokens: secureTokenStore, tokens: secureTokenStore,
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(), localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
onUnauthorized: () => authBus.onUnauthorized?.(), onUnauthorized: () => authBus.onUnauthorized?.(),
// Redacted request/response logging -> logcat (grep "PS →" / "PS ←").
// Toggle at runtime with ps.setLogRequests(false), or flip extra.debugHttp.
logRequests: Boolean(Constants.expoConfig?.extra?.debugHttp),
}); });

View file

@ -15,12 +15,20 @@ import { FavoritesScreen } from '@/screens/FavoritesScreen';
import { SessionsScreen } from '@/screens/SessionsScreen'; import { SessionsScreen } from '@/screens/SessionsScreen';
import { MeterDetailScreen } from '@/screens/MeterDetailScreen'; import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
import { AboutScreen } from '@/screens/AboutScreen'; import { AboutScreen } from '@/screens/AboutScreen';
import { AccountScreen } from '@/screens/AccountScreen';
import { ProfileScreen } from '@/screens/ProfileScreen';
import { VehiclesScreen } from '@/screens/VehiclesScreen';
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
import { useTheme } from '@/theme/ThemeContext';
import type { Zone } from 'parksmarter-client'; import type { Zone } from 'parksmarter-client';
export type RootStackParamList = { export type RootStackParamList = {
Tabs: undefined; Tabs: undefined;
MeterDetail: { zone: Zone }; MeterDetail: { zone: Zone };
About: undefined; About: undefined;
Profile: undefined;
Vehicles: undefined;
PaymentMethods: undefined;
}; };
export type TabParamList = { export type TabParamList = {
@ -28,6 +36,7 @@ export type TabParamList = {
Scan: undefined; Scan: undefined;
Favorites: undefined; Favorites: undefined;
Sessions: undefined; Sessions: undefined;
Account: undefined;
}; };
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
@ -47,6 +56,7 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
Scan: 'qr-code', Scan: 'qr-code',
Favorites: 'star', Favorites: 'star',
Sessions: 'time', Sessions: 'time',
Account: 'person',
}; };
function Tabs() { function Tabs() {
@ -65,23 +75,32 @@ function Tabs() {
<Tab.Screen name="Scan" component={ScanScreen} /> <Tab.Screen name="Scan" component={ScanScreen} />
<Tab.Screen name="Favorites" component={FavoritesScreen} /> <Tab.Screen name="Favorites" component={FavoritesScreen} />
<Tab.Screen name="Sessions" component={SessionsScreen} /> <Tab.Screen name="Sessions" component={SessionsScreen} />
<Tab.Screen name="Account" component={AccountScreen} />
</Tab.Navigator> </Tab.Navigator>
); );
} }
export function RootNavigator() { export function RootNavigator() {
const { status } = useAuth(); const { status } = useAuth();
const { navTheme } = useTheme();
if (status === 'loading') { if (status === 'loading') {
return ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> <View
<ActivityIndicator size="large" /> style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: navTheme.colors.background,
}}
>
<ActivityIndicator size="large" color={navTheme.colors.primary} />
</View> </View>
); );
} }
return ( return (
<NavigationContainer> <NavigationContainer theme={navTheme}>
{status === 'signedIn' ? ( {status === 'signedIn' ? (
<Stack.Navigator> <Stack.Navigator>
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} /> <Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
@ -91,6 +110,13 @@ export function RootNavigator() {
options={{ title: 'Meter' }} options={{ title: 'Meter' }}
/> />
<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="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
<Stack.Screen
name="PaymentMethods"
component={PaymentMethodsScreen}
options={{ title: 'Payment methods' }}
/>
</Stack.Navigator> </Stack.Navigator>
) : ( ) : (
<LoginScreen /> <LoginScreen />

View file

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants';
import { Ionicons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/theme/ThemeContext';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function AccountScreen() {
const { colors, mode, toggle } = useTheme();
const { logout } = useAuth();
const navigation = useNavigation<Nav>();
const [logging, setLogging] = useState<boolean>(
Boolean(Constants.expoConfig?.extra?.debugHttp),
);
const Item = ({
icon,
label,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
style={[styles.item, { borderBottomColor: colors.border }]}
onPress={onPress}
>
<Ionicons name={icon} size={20} color={colors.primary} />
<Text style={[styles.itemText, { color: colors.text }]}>{label}</Text>
<Ionicons name="chevron-forward" size={18} color={colors.subtext} />
</TouchableOpacity>
);
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
<Item
icon="card"
label="Payment methods"
onPress={() => navigation.navigate('PaymentMethods')}
/>
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
</View>
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<View style={[styles.item, { borderBottomColor: colors.border }]}>
<Ionicons name="moon" size={20} color={colors.primary} />
<Text style={[styles.itemText, { color: colors.text }]}>Dark mode</Text>
<Switch value={mode === 'dark'} onValueChange={toggle} />
</View>
<View style={[styles.item, { borderBottomColor: colors.border }]}>
<Ionicons name="bug" size={20} color={colors.primary} />
<Text style={[styles.itemText, { color: colors.text }]}>Log requests (debug)</Text>
<Switch
value={logging}
onValueChange={(v) => {
setLogging(v);
ps.setLogRequests(v);
}}
/>
</View>
</View>
<TouchableOpacity
style={[styles.logout, { borderColor: colors.danger }]}
onPress={logout}
>
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
</TouchableOpacity>
</ScrollView>
);
}
const styles = StyleSheet.create({
card: { borderRadius: 12, paddingHorizontal: 16 },
section: { marginTop: 24, marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' },
item: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
},
itemText: { flex: 1, fontSize: 16 },
logout: {
marginTop: 28,
borderWidth: 1.5,
borderRadius: 12,
padding: 14,
alignItems: 'center',
},
});

View file

@ -0,0 +1,77 @@
import React, { useCallback, useState } from 'react';
import { ActivityIndicator, FlatList, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import type { CreditCardDetail } from 'parksmarter-client';
export function PaymentMethodsScreen() {
const { colors } = useTheme();
const [cards, setCards] = useState<CreditCardDetail[]>([]);
const [loading, setLoading] = useState(true);
useFocusEffect(
useCallback(() => {
setLoading(true);
ps.getUserDetail()
.then((u) => setCards(u.CreditCardDetails ?? []))
.catch(() => setCards([]))
.finally(() => setLoading(false));
}, []),
);
if (loading) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
return (
<FlatList
style={{ backgroundColor: colors.bg }}
contentContainerStyle={{ padding: 16 }}
data={cards}
keyExtractor={(c, i) => String(c.CCID ?? i)}
ListHeaderComponent={
<Text style={[styles.note, { color: colors.subtext }]}>
View only add/edit coming later.
</Text>
}
ListEmptyComponent={
<Text style={[styles.note, { color: colors.subtext }]}>No saved cards.</Text>
}
renderItem={({ item }) => (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<View style={styles.rowBetween}>
<Text style={[styles.brand, { color: colors.text }]}>
{item.CCAlias || 'Card'}
</Text>
{String(item.CCDefault) === 'true' ? (
<Text style={[styles.badge, { color: colors.primary }]}>Default</Text>
) : null}
</View>
<Text style={[styles.num, { color: colors.text }]}>
{item.CCLastFour ?? '••••'}
</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
Exp {item.CCExpDate ?? '--/--'}
{item.CCZip ? ` · ZIP ${item.CCZip}` : ''}
</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
note: { fontSize: 13, marginBottom: 12 },
card: { borderRadius: 12, padding: 16, marginBottom: 12 },
rowBetween: { flexDirection: 'row', justifyContent: 'space-between' },
brand: { fontSize: 16, fontWeight: '700' },
badge: { fontSize: 12, fontWeight: '700' },
num: { fontSize: 18, letterSpacing: 2, marginTop: 10, fontVariant: ['tabular-nums'] },
meta: { fontSize: 13, marginTop: 6 },
});

View file

@ -0,0 +1,68 @@
import React, { useCallback, useState } from 'react';
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import type { UserDetail } from 'parksmarter-client';
export function ProfileScreen() {
const { colors } = useTheme();
const [user, setUser] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true);
useFocusEffect(
useCallback(() => {
setLoading(true);
ps.getUserDetail()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []),
);
if (loading) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
const Row = ({ label, value }: { label: string; value?: string | number }) => (
<View style={[styles.row, { borderBottomColor: colors.border }]}>
<Text style={[styles.label, { color: colors.subtext }]}>{label}</Text>
<Text style={[styles.value, { color: colors.text }]}>{value ?? '—'}</Text>
</View>
);
return (
<ScrollView
style={{ backgroundColor: colors.bg }}
contentContainerStyle={{ padding: 16 }}
>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Row label="Email" value={user?.PersonalEmailAddress} />
<Row label="Phone" value={user?.PersonalPhone} />
<Row label="Vehicles" value={user?.VehicleDetails?.length ?? 0} />
<Row label="Cards" value={user?.CreditCardDetails?.length ?? 0} />
<Row
label="Marketing offers"
value={user?.OffersOptIn ? 'On' : 'Off'}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
card: { borderRadius: 12, paddingHorizontal: 16 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
label: { fontSize: 14 },
value: { fontSize: 15, fontWeight: '600', maxWidth: '60%', textAlign: 'right' },
});

View file

@ -0,0 +1,239 @@
import React, { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import type { VehicleDetail } from 'parksmarter-client';
interface FormState {
id?: number;
plate: string;
state: string;
vehicleAlias: string;
isDefaultVehicle: boolean;
}
const EMPTY: FormState = { plate: '', state: '', vehicleAlias: '', isDefaultVehicle: false };
export function VehiclesScreen() {
const { colors } = useTheme();
const [vehicles, setVehicles] = useState<VehicleDetail[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const u = await ps.getUserDetail();
setVehicles(u.VehicleDetails ?? []);
} catch {
setVehicles([]);
} finally {
setLoading(false);
}
}, []);
useFocusEffect(
useCallback(() => {
void load();
}, [load]),
);
const openAdd = () => setEditing({ ...EMPTY });
const openEdit = (v: VehicleDetail) =>
setEditing({
id: typeof v.VehicleID === 'number' ? v.VehicleID : Number(v.VehicleID),
plate: v.VehiclePlate ?? '',
state: v.VehicleState ?? '',
vehicleAlias: v.VehicleAlias ?? '',
isDefaultVehicle: String(v.IsDefault) === 'true',
});
const save = async () => {
if (!editing) return;
if (!editing.plate.trim() || !editing.state.trim()) {
Alert.alert('Missing info', 'Plate and state are required.');
return;
}
setSaving(true);
try {
if (editing.id != null) {
await ps.updateVehicle({
id: editing.id,
plate: editing.plate.trim(),
state: editing.state.trim().toUpperCase(),
vehicleAlias: editing.vehicleAlias.trim(),
isDefaultVehicle: editing.isDefaultVehicle,
});
} else {
await ps.addVehicle({
plate: editing.plate.trim(),
state: editing.state.trim().toUpperCase(),
vehicleAlias: editing.vehicleAlias.trim(),
isDefaultVehicle: editing.isDefaultVehicle,
});
}
setEditing(null);
await load();
} catch (e: any) {
Alert.alert('Save failed', e?.serverMessage ?? e?.message ?? 'error');
} finally {
setSaving(false);
}
};
const remove = (v: VehicleDetail) => {
Alert.alert('Delete vehicle', `Remove ${v.VehiclePlate}?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await ps.deleteVehicle(v.VehicleID as number);
await load();
} catch (e: any) {
Alert.alert('Delete failed', e?.serverMessage ?? e?.message ?? 'error');
}
},
},
]);
};
if (loading) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<FlatList
contentContainerStyle={{ padding: 16 }}
data={vehicles}
keyExtractor={(v, i) => String(v.VehicleID ?? i)}
ListEmptyComponent={
<Text style={[styles.note, { color: colors.subtext }]}>
No vehicles yet. Tap Add vehicle.
</Text>
}
renderItem={({ item }) => (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<View style={{ flex: 1 }}>
<View style={styles.rowInline}>
<Text style={[styles.plate, { color: colors.text }]}>
{item.VehiclePlate} · {item.VehicleState}
</Text>
{String(item.IsDefault) === 'true' ? (
<Text style={[styles.badge, { color: colors.primary }]}>Default</Text>
) : null}
</View>
{item.VehicleAlias ? (
<Text style={[styles.alias, { color: colors.subtext }]}>{item.VehicleAlias}</Text>
) : null}
</View>
<TouchableOpacity onPress={() => openEdit(item)} style={styles.action}>
<Text style={{ color: colors.primary, fontWeight: '600' }}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => remove(item)} style={styles.action}>
<Text style={{ color: colors.danger, fontWeight: '600' }}>Delete</Text>
</TouchableOpacity>
</View>
)}
/>
<TouchableOpacity
style={[styles.fab, { backgroundColor: colors.primary }]}
onPress={openAdd}
>
<Text style={styles.fabText}>+ Add vehicle</Text>
</TouchableOpacity>
<Modal visible={editing != null} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalCard, { backgroundColor: colors.bg }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{editing?.id != null ? 'Edit vehicle' : 'Add vehicle'}
</Text>
{(
[
['License plate', 'plate'],
['State (e.g. WA)', 'state'],
['Nickname (optional)', 'vehicleAlias'],
] as const
).map(([ph, key]) => (
<TextInput
key={key}
placeholder={ph}
placeholderTextColor={colors.subtext}
value={editing?.[key] as string}
autoCapitalize="characters"
onChangeText={(t) => setEditing((s) => (s ? { ...s, [key]: t } : s))}
style={[styles.input, { color: colors.text, borderColor: colors.border }]}
/>
))}
<View style={styles.rowInline}>
<Text style={{ color: colors.text }}>Default vehicle</Text>
<Switch
value={!!editing?.isDefaultVehicle}
onValueChange={(v) => setEditing((s) => (s ? { ...s, isDefaultVehicle: v } : s))}
/>
</View>
<View style={styles.modalActions}>
<TouchableOpacity onPress={() => setEditing(null)} style={styles.action}>
<Text style={{ color: colors.subtext, fontWeight: '600' }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={save}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: colors.primary }]}
>
<Text style={styles.fabText}>{saving ? 'Saving…' : 'Save'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
note: { fontSize: 14, textAlign: 'center', marginTop: 40 },
card: { flexDirection: 'row', alignItems: 'center', borderRadius: 12, padding: 14, marginBottom: 10 },
rowInline: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
plate: { fontSize: 16, fontWeight: '700' },
alias: { fontSize: 13, marginTop: 2 },
badge: { fontSize: 12, fontWeight: '700' },
action: { paddingHorizontal: 8, paddingVertical: 6 },
fab: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 26,
},
fabText: { color: '#fff', fontWeight: '700' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalCard: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, gap: 12 },
modalTitle: { fontSize: 20, fontWeight: '700', marginBottom: 4 },
input: { borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 16, marginTop: 8 },
saveBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 10 },
});

View file

@ -0,0 +1,102 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
DarkTheme as NavDark,
DefaultTheme as NavLight,
type Theme as NavTheme,
} from '@react-navigation/native';
export type ThemeMode = 'light' | 'dark';
export interface Colors {
bg: string;
card: string;
text: string;
subtext: string;
border: string;
primary: string;
danger: string;
}
const light: Colors = {
bg: '#ffffff',
card: '#f4f4f4',
text: '#111111',
subtext: '#666666',
border: '#e0e0e0',
primary: '#1e6f5c',
danger: '#c0392b',
};
const dark: Colors = {
bg: '#0e1513',
card: '#1b2422',
text: '#f2f2f2',
subtext: '#9aa5a1',
border: '#2c3a36',
primary: '#3fbf9c',
danger: '#ef6a5c',
};
interface ThemeState {
mode: ThemeMode;
colors: Colors;
navTheme: NavTheme;
setMode: (m: ThemeMode) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeState | null>(null);
const STORAGE_KEY = 'ps_theme_mode';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>('light');
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY).then((v) => {
if (v === 'light' || v === 'dark') setModeState(v);
});
}, []);
const setMode = (m: ThemeMode) => {
setModeState(m);
void AsyncStorage.setItem(STORAGE_KEY, m);
};
const value = useMemo<ThemeState>(() => {
const colors = mode === 'dark' ? dark : light;
const base = mode === 'dark' ? NavDark : NavLight;
const navTheme: NavTheme = {
...base,
colors: {
...base.colors,
background: colors.bg,
card: colors.bg,
text: colors.text,
border: colors.border,
primary: colors.primary,
},
};
return {
mode,
colors,
navTheme,
setMode,
toggle: () => setMode(mode === 'dark' ? 'light' : 'dark'),
};
}, [mode]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeState {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

View file

@ -45,6 +45,10 @@ export interface ParkSmarterClientOptions {
uuid?: () => string; uuid?: () => string;
/** Called when any authenticated request returns 401 (token cleared automatically). */ /** Called when any authenticated request returns 401 (token cleared automatically). */
onUnauthorized?: () => void; onUnauthorized?: () => void;
/** Log every request/response (redacted) — useful for on-device debugging via logcat. */
logRequests?: boolean;
/** Where log lines go (default console.log). */
logSink?: (line: string) => void;
} }
function resolveEnvironment( function resolveEnvironment(
@ -69,6 +73,8 @@ export class ParkSmarterClient {
fetchImpl: options.fetchImpl, fetchImpl: options.fetchImpl,
uuid: options.uuid, uuid: options.uuid,
onUnauthorized: options.onUnauthorized, onUnauthorized: options.onUnauthorized,
logRequests: options.logRequests,
logSink: options.logSink,
}); });
} }
@ -77,6 +83,11 @@ export class ParkSmarterClient {
this.http.setLocaleCode(localeCode); this.http.setLocaleCode(localeCode);
} }
/** Toggle redacted request/response logging at runtime. */
setLogRequests(on: boolean): void {
this.http.setLogRequests(on);
}
private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> { private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
return p.then((r) => r.data); return p.then((r) => r.data);
} }

View file

@ -106,6 +106,49 @@ export interface HttpClientConfig {
* stored token and route the user back to sign-in. Fired before the error throws. * stored token and route the user back to sign-in. Fired before the error throws.
*/ */
onUnauthorized?: () => void; onUnauthorized?: () => void;
/**
* When true, logs every request/response (method, URL, headers, body, status)
* via `logSink` (default: console.log). Sensitive header/body values are
* redacted. Handy for capturing traffic in logcat while debugging; turn off in
* production.
*/
logRequests?: boolean;
/** Where log lines go when logRequests is on. Defaults to console.log. */
logSink?: (line: string) => void;
}
/** Header/body keys whose VALUES must never be logged in the clear. */
const SENSITIVE_KEYS = new Set(
[
'Auth_Token',
'Application_Token',
'ParkSmarter_SessionId',
'Password',
'OldPassword',
'NewPassword',
'CCNumber',
'CCExpDate',
'CCZip',
'CCFirstSix',
'CCLastFour',
'ProviderAuth',
'ProviderIdentity',
].map((k) => k.toLowerCase()),
);
function redact(obj: unknown): unknown {
if (!obj || typeof obj !== 'object') return obj;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (SENSITIVE_KEYS.has(k.toLowerCase())) {
out[k] = typeof v === 'string' ? `<redacted:${v.length}>` : '<redacted>';
} else if (v && typeof v === 'object') {
out[k] = redact(v);
} else {
out[k] = v;
}
}
return out;
} }
function defaultUuid(): string { function defaultUuid(): string {
@ -141,6 +184,8 @@ export class HttpClient {
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis), fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
uuid: config.uuid ?? defaultUuid, uuid: config.uuid ?? defaultUuid,
onUnauthorized: config.onUnauthorized ?? (() => {}), onUnauthorized: config.onUnauthorized ?? (() => {}),
logRequests: config.logRequests ?? false,
logSink: config.logSink ?? ((line: string) => console.log(line)),
}; };
if (!this.cfg.fetchImpl) { if (!this.cfg.fetchImpl) {
throw new Error( throw new Error(
@ -161,6 +206,11 @@ export class HttpClient {
this.cfg.localeCode = localeCode; this.cfg.localeCode = localeCode;
} }
/** Toggle request/response logging at runtime (e.g. from a Settings switch). */
setLogRequests(on: boolean) {
this.cfg.logRequests = on;
}
async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> { async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
const { method } = opts; const { method } = opts;
const requestId = this.cfg.uuid(); const requestId = this.cfg.uuid();
@ -202,6 +252,15 @@ export class HttpClient {
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true }); else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
} }
if (this.cfg.logRequests) {
this.cfg.logSink(
`[PS →] ${method} ${url}\n headers: ${JSON.stringify(redact(headers))}` +
(opts.body !== undefined && opts.body !== null
? `\n body: ${JSON.stringify(redact(opts.body))}`
: ''),
);
}
let res: Response; let res: Response;
try { try {
res = await this.cfg.fetchImpl(url, { res = await this.cfg.fetchImpl(url, {
@ -249,6 +308,14 @@ export class HttpClient {
} }
} }
if (this.cfg.logRequests) {
const preview =
data && typeof data === 'object'
? JSON.stringify(redact(data)).slice(0, 500)
: String(text).slice(0, 300);
this.cfg.logSink(`[PS ←] ${res.status} ${method} ${opts.path} ${preview}`);
}
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
// Token invalid/rotated/expired — let the app clear it and re-auth. // Token invalid/rotated/expired — let the app clear it and re-auth.