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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue