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:
parent
65e9118806
commit
2f9f0f604a
11 changed files with 707 additions and 8 deletions
11
app/App.tsx
11
app/App.tsx
|
|
@ -2,15 +2,18 @@ import React from 'react';
|
|||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { AuthProvider } from '@/auth/AuthContext';
|
||||
import { ThemeProvider } from '@/theme/ThemeContext';
|
||||
import { RootNavigator } from '@/navigation/RootNavigator';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<RootNavigator />
|
||||
<StatusBar style="auto" />
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<RootNavigator />
|
||||
<StatusBar style="auto" />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@
|
|||
"extra": {
|
||||
"psEnvironment": "prodv2",
|
||||
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
|
||||
"unifiedPushDefaultDistributor": "io.heckel.ntfy"
|
||||
"unifiedPushDefaultDistributor": "io.heckel.ntfy",
|
||||
"debugHttp": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,7 @@ export const ps = new ParkSmarterClient({
|
|||
tokens: secureTokenStore,
|
||||
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
|
||||
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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,12 +15,20 @@ import { FavoritesScreen } from '@/screens/FavoritesScreen';
|
|||
import { SessionsScreen } from '@/screens/SessionsScreen';
|
||||
import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
|
||||
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';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Tabs: undefined;
|
||||
MeterDetail: { zone: Zone };
|
||||
About: undefined;
|
||||
Profile: undefined;
|
||||
Vehicles: undefined;
|
||||
PaymentMethods: undefined;
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
|
|
@ -28,6 +36,7 @@ export type TabParamList = {
|
|||
Scan: undefined;
|
||||
Favorites: undefined;
|
||||
Sessions: undefined;
|
||||
Account: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
|
@ -47,6 +56,7 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
|||
Scan: 'qr-code',
|
||||
Favorites: 'star',
|
||||
Sessions: 'time',
|
||||
Account: 'person',
|
||||
};
|
||||
|
||||
function Tabs() {
|
||||
|
|
@ -65,23 +75,32 @@ function Tabs() {
|
|||
<Tab.Screen name="Scan" component={ScanScreen} />
|
||||
<Tab.Screen name="Favorites" component={FavoritesScreen} />
|
||||
<Tab.Screen name="Sessions" component={SessionsScreen} />
|
||||
<Tab.Screen name="Account" component={AccountScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export function RootNavigator() {
|
||||
const { status } = useAuth();
|
||||
const { navTheme } = useTheme();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator size="large" />
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: navTheme.colors.background,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={navTheme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<NavigationContainer theme={navTheme}>
|
||||
{status === 'signedIn' ? (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
||||
|
|
@ -91,6 +110,13 @@ export function RootNavigator() {
|
|||
options={{ title: 'Meter' }}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<LoginScreen />
|
||||
|
|
|
|||
102
app/src/screens/AccountScreen.tsx
Normal file
102
app/src/screens/AccountScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
77
app/src/screens/PaymentMethodsScreen.tsx
Normal file
77
app/src/screens/PaymentMethodsScreen.tsx
Normal 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 },
|
||||
});
|
||||
68
app/src/screens/ProfileScreen.tsx
Normal file
68
app/src/screens/ProfileScreen.tsx
Normal 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' },
|
||||
});
|
||||
239
app/src/screens/VehiclesScreen.tsx
Normal file
239
app/src/screens/VehiclesScreen.tsx
Normal 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 },
|
||||
});
|
||||
102
app/src/theme/ThemeContext.tsx
Normal file
102
app/src/theme/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -45,6 +45,10 @@ export interface ParkSmarterClientOptions {
|
|||
uuid?: () => string;
|
||||
/** Called when any authenticated request returns 401 (token cleared automatically). */
|
||||
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(
|
||||
|
|
@ -69,6 +73,8 @@ export class ParkSmarterClient {
|
|||
fetchImpl: options.fetchImpl,
|
||||
uuid: options.uuid,
|
||||
onUnauthorized: options.onUnauthorized,
|
||||
logRequests: options.logRequests,
|
||||
logSink: options.logSink,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +83,11 @@ export class ParkSmarterClient {
|
|||
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> {
|
||||
return p.then((r) => r.data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,49 @@ export interface HttpClientConfig {
|
|||
* stored token and route the user back to sign-in. Fired before the error throws.
|
||||
*/
|
||||
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 {
|
||||
|
|
@ -141,6 +184,8 @@ export class HttpClient {
|
|||
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
|
||||
uuid: config.uuid ?? defaultUuid,
|
||||
onUnauthorized: config.onUnauthorized ?? (() => {}),
|
||||
logRequests: config.logRequests ?? false,
|
||||
logSink: config.logSink ?? ((line: string) => console.log(line)),
|
||||
};
|
||||
if (!this.cfg.fetchImpl) {
|
||||
throw new Error(
|
||||
|
|
@ -161,6 +206,11 @@ export class HttpClient {
|
|||
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>> {
|
||||
const { method } = opts;
|
||||
const requestId = this.cfg.uuid();
|
||||
|
|
@ -202,6 +252,15 @@ export class HttpClient {
|
|||
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;
|
||||
try {
|
||||
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.status === 401) {
|
||||
// Token invalid/rotated/expired — let the app clear it and re-auth.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue