diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6a8272f..d63597e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,12 @@ "Bash(gh run *)", "PowerShell(gh run list --workflow=android-release.yml --limit 3 2>&1)", "Bash(xxd)", - "Read(//d//**)" + "Read(//d//**)", + "Bash(xargs grep *)", + "Bash(echo \"tsc exit code: $?\")", + "Bash(powershell -Command ' *)", + "Bash(grep -rn \"#[0-9a-fA-F]\\\\{3,6\\\\}\" d:/Dev/pit-stop/src/app/ d:/Dev/pit-stop/src/components/fab.tsx d:/Dev/pit-stop/src/components/vehicle-card.tsx d:/Dev/pit-stop/src/components/part-status-row.tsx d:/Dev/pit-stop/src/components/add-sheet.tsx d:/Dev/pit-stop/src/components/app-tabs.tsx d:/Dev/pit-stop/src/components/status-pill.tsx)", + "Bash(grep -rn \"#[0-9a-fA-F]\\\\{3,6\\\\}\" \"d:/Dev/pit-stop/src/components/themed-text.tsx\" \"d:/Dev/pit-stop/src/components/themed-view.tsx\" \"d:/Dev/pit-stop/src/components/modal-sheet.tsx\")" ] } } diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..b60055d --- /dev/null +++ b/app.config.js @@ -0,0 +1,24 @@ +const baseConfig = require("./app.json"); + +const variant = process.env.APP_VARIANT; +const isDev = variant === "development" || variant === "dev"; + +const expo = { + ...baseConfig.expo, + name: isDev ? "Pit Stop Dev" : baseConfig.expo.name, + slug: isDev ? "pit-stop-dev" : baseConfig.expo.slug, + scheme: isDev ? "pitstop-dev" : baseConfig.expo.scheme, + icon: isDev ? "./assets/appIcons/appstore-dev.png" : baseConfig.expo.icon, + ios: { + ...baseConfig.expo.ios, + bundleIdentifier: isDev ? "com.edzy.pitstop.dev" : "com.edzy.pitstop", + icon: isDev ? "./assets/appIcons/appstore-dev.png" : baseConfig.expo.ios?.icon, + }, + android: { + ...baseConfig.expo.android, + package: isDev ? "com.edzy.pitstop.dev" : baseConfig.expo.android.package, + icon: isDev ? "./assets/appIcons/playstore-dev.png" : baseConfig.expo.android.icon, + }, +}; + +module.exports = { expo }; diff --git a/assets/appIcons/appstore-dev.png b/assets/appIcons/appstore-dev.png new file mode 100644 index 0000000..a7803d4 Binary files /dev/null and b/assets/appIcons/appstore-dev.png differ diff --git a/assets/appIcons/playstore-dev.png b/assets/appIcons/playstore-dev.png new file mode 100644 index 0000000..c195bca Binary files /dev/null and b/assets/appIcons/playstore-dev.png differ diff --git a/bun.lock b/bun.lock index 98cb932..78c4506 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "pit-stop", "dependencies": { + "@expo/vector-icons": "^15.1.1", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", "@react-navigation/native": "^7.1.33", diff --git a/package.json b/package.json index 7154a34..cf8f77c 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,16 @@ "version": "1.0.0", "scripts": { "start": "expo start", + "start:dev": "node ./scripts/with-app-variant.js development expo start", + "prebuild": "expo prebuild", + "prebuild:clean": "expo prebuild --clean", + "prebuild:dev": "node ./scripts/with-app-variant.js development expo prebuild", + "prebuild:dev:clean": "node ./scripts/with-app-variant.js development expo prebuild --clean", "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", + "android:dev": "node ./scripts/with-app-variant.js development expo run:android", "ios": "expo run:ios", + "ios:dev": "node ./scripts/with-app-variant.js development expo run:ios", "web": "expo start --web", "lint": "expo lint", "test": "jest" @@ -18,6 +25,7 @@ } }, "dependencies": { + "@expo/vector-icons": "^15.1.1", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", "@react-navigation/native": "^7.1.33", @@ -30,6 +38,7 @@ "expo-linking": "~55.0.14", "expo-router": "~55.0.13", "expo-splash-screen": "~55.0.19", + "expo-sqlite": "~55.0.15", "expo-status-bar": "~55.0.5", "expo-symbols": "~55.0.7", "expo-system-ui": "~55.0.16", @@ -38,13 +47,12 @@ "react-dom": "19.2.0", "react-native": "0.83.6", "react-native-gesture-handler": "~2.30.0", - "react-native-worklets": "0.7.4", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", + "react-native-svg": "15.15.3", "react-native-web": "~0.21.0", - "expo-sqlite": "~55.0.15", - "react-native-svg": "15.15.3" + "react-native-worklets": "0.7.4" }, "devDependencies": { "@types/jest": "29.5.14", diff --git a/scripts/with-app-variant.js b/scripts/with-app-variant.js new file mode 100644 index 0000000..fe03ee3 --- /dev/null +++ b/scripts/with-app-variant.js @@ -0,0 +1,16 @@ +const { spawnSync } = require("node:child_process"); + +const [, , variant, command, ...args] = process.argv; + +if (!variant || !command) { + console.error("Usage: node scripts/with-app-variant.js [...args]"); + process.exit(1); +} + +const result = spawnSync(command, args, { + env: { ...process.env, APP_VARIANT: variant }, + shell: true, + stdio: "inherit", +}); + +process.exit(result.status ?? 1); diff --git a/src/app/index.tsx b/src/app/index.tsx index fc74b60..3645ca6 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -3,6 +3,7 @@ import { ScrollView, StyleSheet, View } from 'react-native'; import { useSQLiteContext } from 'expo-sqlite'; import { useFocusEffect } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialIcons } from '@expo/vector-icons'; import { Colors, BottomTabInset, Spacing } from '@/constants/theme'; import { Part, Vehicle } from '@/types'; @@ -76,7 +77,11 @@ export default function HomeScreen() { {!hasFlags && data.length > 0 && ( - + All parts are up to date )} @@ -143,8 +148,4 @@ const styles = StyleSheet.create({ gap: Spacing.two, paddingVertical: Spacing.four, }, - allGoodIcon: { - color: Colors.dark.success, - fontSize: 20, - }, }); diff --git a/src/app/parts/[id].tsx b/src/app/parts/[id].tsx new file mode 100644 index 0000000..d7df821 --- /dev/null +++ b/src/app/parts/[id].tsx @@ -0,0 +1,443 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; +import { useSQLiteContext } from "expo-sqlite"; +import React, { useCallback, useMemo, useState } from "react"; +import { + Alert, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { AddPartModal } from "@/components/modals/add-part-modal"; +import { LogReplacementModal } from "@/components/modals/log-replacement-modal"; +import { PartStatusRow } from "@/components/part-status-row"; +import { ThemedText } from "@/components/themed-text"; +import { BottomTabInset, Colors, Spacing } from "@/constants/theme"; +import { + deletePart, + deleteReplacementLog, + getPartById, + getReplacementLogsByPart, +} from "@/db/parts"; +import { getVehicleById } from "@/db/vehicles"; +import { Part, PartReplacementLog, Vehicle } from "@/types"; + +const INITIAL_LOG_LIMIT = 3; + +export default function PartDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const db = useSQLiteContext(); + const router = useRouter(); + const partId = parseInt(id, 10); + + const [part, setPart] = useState(null); + const [vehicle, setVehicle] = useState(null); + const [logs, setLogs] = useState([]); + const [showAllLogs, setShowAllLogs] = useState(false); + const [editPartOpen, setEditPartOpen] = useState(false); + const [logOpen, setLogOpen] = useState(false); + const [editingLog, setEditingLog] = useState(null); + const [notFound, setNotFound] = useState(false); + + const loadData = useCallback(async () => { + if (isNaN(partId)) { + setNotFound(true); + return; + } + + const loadedPart = await getPartById(db, partId); + if (!loadedPart) { + setNotFound(true); + return; + } + + const [loadedVehicle, loadedLogs] = await Promise.all([ + getVehicleById(db, loadedPart.vehicle_id), + getReplacementLogsByPart(db, loadedPart.id), + ]); + + setNotFound(!loadedVehicle); + setPart(loadedPart); + setVehicle(loadedVehicle); + setLogs(loadedLogs); + }, [db, partId]); + + useFocusEffect( + useCallback(() => { + loadData(); + }, [loadData]), + ); + + const visibleLogs = useMemo( + () => (showAllLogs ? logs : logs.slice(0, INITIAL_LOG_LIMIT)), + [logs, showAllLogs], + ); + const latestLog = logs[0] ?? null; + + if (notFound) { + return ( + + + Part not found + + This part no longer exists. + + router.back()} + style={styles.backButton} + > + + Back + + + + ); + } + + if (!part || !vehicle) return null; + + function confirmDeletePart() { + if (!part) return; + Alert.alert("Delete Part", `Delete "${part.name}" and its history?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + await deletePart(db, part.id); + router.back(); + }, + }, + ]); + } + + function confirmDeleteLatestLog() { + if (!part || !latestLog) return; + Alert.alert( + "Delete Latest Log", + "Remove the most recent replacement log?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + await deleteReplacementLog(db, latestLog.id, part.id); + loadData(); + }, + }, + ], + ); + } + + return ( + + + + router.back()} + style={styles.backButton} + > + + Back + + + + + {part.name} + + + {vehicle.name} + + + + setEditPartOpen(true)} + > + + + + + + + + + + setLogOpen(true)} + /> + + + setLogOpen(true)} + > + + Add Log + + + + + + + Change History + + + {logs.length} total + + + + {logs.length === 0 && ( + + No replacement logs yet. + + )} + + {visibleLogs.map((log, index) => { + const isLatest = log.id === latestLog?.id; + return ( + + + + {log.replaced_at_km.toLocaleString()} km + + + {log.logged_at + ? new Date(log.logged_at).toLocaleDateString() + : "Imported from part"} + + + {isLatest ? ( + + setEditingLog(log)} + style={styles.iconButton} + > + + + + + + + ) : ( + + #{index + 1} + + )} + + ); + })} + + {logs.length > INITIAL_LOG_LIMIT && ( + setShowAllLogs((value) => !value)} + style={styles.showMoreButton} + > + + {showAllLogs + ? "Show Less" + : `Show ${logs.length - INITIAL_LOG_LIMIT} More`} + + + )} + + + + setEditPartOpen(false)} + onSaved={() => { + loadData(); + setEditPartOpen(false); + }} + vehicleId={vehicle.id} + currentKm={vehicle.current_km} + existing={part} + /> + + setLogOpen(false)} + onSaved={loadData} + part={part} + vehicleId={vehicle.id} + currentKm={vehicle.current_km} + /> + + setEditingLog(null)} + onSaved={() => { + loadData(); + setEditingLog(null); + }} + part={part} + vehicleId={vehicle.id} + currentKm={vehicle.current_km} + existingLog={editingLog ?? undefined} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: Colors.dark.background, + }, + content: { + paddingHorizontal: Spacing.four, + gap: Spacing.four, + }, + notFound: { + paddingHorizontal: Spacing.four, + paddingTop: Spacing.six, + gap: Spacing.two, + }, + backButton: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingTop: Spacing.four, + paddingBottom: Spacing.two, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: Spacing.three, + }, + titleCopy: { + flex: 1, + gap: Spacing.half, + }, + editIconRow: { + flexDirection: "row", + gap: Spacing.one, + }, + actions: { + flexDirection: "row", + gap: Spacing.two, + }, + primaryButton: { + flex: 1, + minHeight: 48, + borderRadius: Spacing.two, + backgroundColor: Colors.dark.primary, + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: Spacing.one, + }, + primaryButtonText: { + color: Colors.dark.primaryText, + fontWeight: "700", + }, + secondaryButton: { + flex: 1, + minHeight: 48, + borderRadius: Spacing.two, + borderWidth: 1, + borderColor: Colors.dark.backgroundSelected, + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: Spacing.one, + }, + section: { + gap: Spacing.two, + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + sectionTitle: { + fontWeight: "700", + fontSize: 18, + }, + logRow: { + minHeight: 68, + borderRadius: Spacing.two, + backgroundColor: Colors.dark.backgroundElement, + padding: Spacing.three, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: Spacing.two, + }, + logInfo: { + flex: 1, + gap: Spacing.half, + }, + logKm: { + fontWeight: "700", + }, + logActions: { + flexDirection: "row", + gap: Spacing.one, + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + backgroundColor: Colors.dark.backgroundSelected, + }, + showMoreButton: { + minHeight: 44, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/src/app/vehicles.tsx b/src/app/vehicles.tsx index 372bfaa..b26ec2f 100644 --- a/src/app/vehicles.tsx +++ b/src/app/vehicles.tsx @@ -11,6 +11,8 @@ import { useSQLiteContext } from 'expo-sqlite'; import { useFocusEffect, useRouter } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialIcons } from '@expo/vector-icons'; + import { Colors, BottomTabInset, Spacing } from '@/constants/theme'; import { Vehicle } from '@/types'; import { getVehicles, deleteVehicle } from '@/db/vehicles'; @@ -75,7 +77,8 @@ export default function VehiclesScreen() { setAddModalOpen(true)}> - + Add + + Add @@ -94,12 +97,19 @@ export default function VehiclesScreen() { style={({ pressed }) => [styles.card, pressed && styles.pressed]} onPress={() => router.push(`/vehicles/${vehicle.id}`)} onLongPress={() => handleLongPress(vehicle)}> - - {vehicle.name} - - - {vehicle.current_km.toLocaleString()} km - + + + {vehicle.name} + + + {vehicle.current_km.toLocaleString()} km + + + ))} @@ -144,14 +154,18 @@ const styles = StyleSheet.create({ paddingBottom: Spacing.two, }, addButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, backgroundColor: Colors.dark.primary, paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, - borderRadius: Spacing.two, + borderRadius: 999, }, addButtonText: { - color: '#fff', + color: Colors.dark.primaryText, fontWeight: '600', + fontSize: 13, }, emptyState: { paddingVertical: Spacing.six, @@ -162,17 +176,21 @@ const styles = StyleSheet.create({ lineHeight: 24, }, card: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.three, backgroundColor: Colors.dark.backgroundElement, borderRadius: Spacing.three, - borderWidth: 1, - borderColor: Colors.dark.backgroundSelected, padding: Spacing.three, + }, + cardContent: { + flex: 1, gap: Spacing.half, }, pressed: { opacity: 0.7, }, vehicleName: { - fontWeight: '600', + fontWeight: '700', }, }); diff --git a/src/app/vehicles/[id].tsx b/src/app/vehicles/[id].tsx index 50ec298..db34663 100644 --- a/src/app/vehicles/[id].tsx +++ b/src/app/vehicles/[id].tsx @@ -1,3 +1,4 @@ +import { MaterialIcons } from "@expo/vector-icons"; import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; import { useSQLiteContext } from "expo-sqlite"; import React, { useCallback, useState } from "react"; @@ -11,9 +12,9 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { StatusPill } from "@/components/status-pill"; import { AddPartModal } from "@/components/modals/add-part-modal"; import { LogFuelModal } from "@/components/modals/log-fuel-modal"; -import { LogReplacementModal } from "@/components/modals/log-replacement-modal"; import { PartStatusRow } from "@/components/part-status-row"; import { ThemedText } from "@/components/themed-text"; import { BottomTabInset, Colors, Spacing } from "@/constants/theme"; @@ -43,7 +44,6 @@ export default function VehicleDetailScreen() { const [addPartOpen, setAddPartOpen] = useState(false); const [editPart, setEditPart] = useState(null); - const [replacePart, setReplacePart] = useState(null); const [logFuelOpen, setLogFuelOpen] = useState(false); const [notFound, setNotFound] = useState(false); @@ -189,10 +189,7 @@ export default function VehicleDetailScreen() { const mileageBadge = () => { if (!mileage) return null; if (mileage.status === "precise") { - return { label: "✓ Precise", color: Colors.dark.success }; - } - if (mileage.status === "estimated") { - return { label: "~ Estimated", color: Colors.dark.warning }; + return { label: "Precise", icon: "check-circle" as const, color: Colors.dark.success }; } return null; }; @@ -210,7 +207,8 @@ export default function VehicleDetailScreen() { onPress={() => router.back()} style={styles.backButton} > - ← Back + + Back {vehicle.name} @@ -223,8 +221,11 @@ export default function VehicleDetailScreen() { Parts - setAddPartOpen(true)}> - + Add + setAddPartOpen(true)} + style={styles.iconLink}> + + Add @@ -242,7 +243,7 @@ export default function VehicleDetailScreen() { setReplacePart(p)} + onPress={(p) => router.push(`/parts/${p.id}` as never)} /> ))} @@ -276,9 +277,12 @@ export default function VehicleDetailScreen() { ) : ( - - {vehicle.current_km.toLocaleString()} km - + + + + {vehicle.current_km.toLocaleString()} km + + setEditingOdometer(true)}> Update @@ -286,13 +290,14 @@ export default function VehicleDetailScreen() { )} - + Fuel log - setLogFuelOpen(true)}> - - + Add fill-up - + setLogFuelOpen(true)} + style={styles.iconLink}> + + Add fill-up @@ -307,15 +312,17 @@ export default function VehicleDetailScreen() { onLongPress={() => handleFuelLogLongPress(log)} style={styles.fuelRow} > - - {log.odometer_km.toLocaleString()} km - - + + + {log.odometer_km.toLocaleString()} km + + + {new Date(log.logged_at).toLocaleDateString()} + + + {log.fuel_litres} L{log.is_full_tank ? "" : " (partial)"} - - {new Date(log.logged_at).toLocaleDateString()} - ))} @@ -327,9 +334,11 @@ export default function VehicleDetailScreen() { Mileage {mileageBadge() && ( - - {mileageBadge()!.label} - + )} @@ -388,18 +397,6 @@ export default function VehicleDetailScreen() { /> )} - setReplacePart(null)} - onSaved={() => { - loadData(); - setReplacePart(null); - }} - part={replacePart} - vehicleId={vehicle.id} - currentKm={vehicle.current_km} - /> - setLogFuelOpen(false)} @@ -421,6 +418,9 @@ const styles = StyleSheet.create({ gap: Spacing.four, }, backButton: { + flexDirection: "row", + alignItems: "center", + gap: 4, paddingTop: Spacing.four, paddingBottom: Spacing.two, }, @@ -432,6 +432,11 @@ const styles = StyleSheet.create({ vehicleName: { marginBottom: Spacing.two, }, + iconLink: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, section: { gap: Spacing.two, }, @@ -451,8 +456,14 @@ const styles = StyleSheet.create({ backgroundColor: Colors.dark.backgroundElement, borderRadius: Spacing.two, padding: Spacing.three, - borderWidth: 1, - borderColor: Colors.dark.backgroundSelected, + }, + odometerLeft: { + flexDirection: "row", + alignItems: "center", + gap: Spacing.two, + }, + odometerValue: { + fontWeight: "700", }, odometerEdit: { flexDirection: "row", @@ -474,24 +485,31 @@ const styles = StyleSheet.create({ paddingVertical: Spacing.two, }, saveButtonText: { - color: "#fff", + color: Colors.dark.primaryText, fontWeight: "600", }, fuelHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - marginTop: Spacing.two, - }, - smallLink: { - fontSize: 13, }, fuelRow: { flexDirection: "row", justifyContent: "space-between", - paddingVertical: Spacing.two, - borderBottomWidth: 1, - borderBottomColor: Colors.dark.backgroundSelected, + alignItems: "center", + backgroundColor: Colors.dark.backgroundElement, + borderRadius: Spacing.two, + padding: Spacing.three, + }, + fuelRowLeft: { + gap: Spacing.half, + }, + fuelKm: { + fontWeight: "700", + }, + fuelLitres: { + color: Colors.dark.primary, + fontWeight: "700", }, mileageHeader: { flexDirection: "row", diff --git a/src/components/add-sheet.tsx b/src/components/add-sheet.tsx index 5f38584..c1e6d6f 100644 --- a/src/components/add-sheet.tsx +++ b/src/components/add-sheet.tsx @@ -1,6 +1,8 @@ +import { MaterialIcons } from '@expo/vector-icons'; import React, { useEffect, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useSQLiteContext } from 'expo-sqlite'; + import { Colors, Spacing } from '@/constants/theme'; import { Vehicle, Part } from '@/types'; import { getVehicles } from '@/db/vehicles'; @@ -56,25 +58,30 @@ export function AddSheet({ visible, onClose, onSaved }: AddSheetProps) { if (!selectedVehicle) { return ( - + Select Vehicle {vehicles.length === 0 && ( - + No vehicles yet. Add one from the Vehicles tab. )} - {vehicles.map(v => ( - handleVehicleSelect(v)}> - {v.name} - - {v.current_km.toLocaleString()} km - - - ))} + + {vehicles.map(v => ( + handleVehicleSelect(v)}> + + {v.name} + + {v.current_km.toLocaleString()} km + + + + + ))} + ); } @@ -82,45 +89,69 @@ export function AddSheet({ visible, onClose, onSaved }: AddSheetProps) { return ( <> - + {selectedVehicle.name} - handleActionSelect('replacement')}> - Log Part Replacement - - Mark a part as replaced - - - handleActionSelect('fuel')}> - Log Fuel Fill-up - - Record odometer and litres - + + CHOOSE ACTION + + + handleActionSelect('replacement')}> + + + + + Log Part Replacement + + Reset maintenance interval + + + + + + handleActionSelect('fuel')}> + + + + + Log Fuel Fill-up + + Track mileage and cost + + + + + + + Cancel {action === 'replacement' && ( setAction(null)}> - + Select Part {parts.length === 0 && ( - + No parts for this vehicle yet. )} - {parts.map(p => ( - setSelectedPart(p)}> - {p.name} - - ))} + + {parts.map(p => ( + setSelectedPart(p)}> + {p.name} + + + ))} + setSelectedPart(null)} @@ -144,19 +175,69 @@ export function AddSheet({ visible, onClose, onSaved }: AddSheetProps) { } const styles = StyleSheet.create({ - title: { + titleCenter: { + textAlign: 'center', + marginBottom: Spacing.one, + }, + stepCaption: { + textAlign: 'center', + fontSize: 12, + letterSpacing: 1.5, + textTransform: 'uppercase', marginBottom: Spacing.four, }, - option: { - paddingVertical: Spacing.three, - borderBottomWidth: 1, - borderBottomColor: Colors.dark.backgroundSelected, + empty: { + textAlign: 'center', + paddingVertical: Spacing.four, + }, + rowList: { + gap: Spacing.two, + }, + simpleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: Spacing.three, + borderRadius: Spacing.three, + backgroundColor: Colors.dark.backgroundElevated, + }, + simpleRowContent: { + flex: 1, gap: Spacing.half, }, - actionButton: { - paddingVertical: Spacing.three, - borderBottomWidth: 1, - borderBottomColor: Colors.dark.backgroundSelected, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.three, + padding: Spacing.three, + borderRadius: Spacing.three, + backgroundColor: Colors.dark.backgroundElevated, + }, + actionRowContent: { + flex: 1, gap: Spacing.half, }, + iconTile: { + width: 48, + height: 48, + borderRadius: Spacing.two, + backgroundColor: Colors.dark.backgroundSelected, + justifyContent: 'center', + alignItems: 'center', + }, + rowTitle: { + fontWeight: '700', + }, + cancelButton: { + backgroundColor: Colors.dark.primary, + borderRadius: Spacing.three, + paddingVertical: Spacing.three, + alignItems: 'center', + marginTop: Spacing.four, + }, + cancelText: { + color: Colors.dark.primaryText, + fontWeight: '700', + fontSize: 16, + }, }); diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx index 9e00694..6c5fe5d 100644 --- a/src/components/app-tabs.tsx +++ b/src/components/app-tabs.tsx @@ -1,5 +1,5 @@ import { Tabs } from "expo-router"; -import { Image } from "react-native"; +import { MaterialIcons } from "@expo/vector-icons"; import MotorbikeFill from "@/assets/icons/bike"; import { Colors } from "@/constants/theme"; @@ -18,6 +18,12 @@ export default function AppTabs() { backgroundColor: colors.backgroundElement, borderTopColor: colors.backgroundSelected, }, + tabBarLabelStyle: { + fontSize: 12, + fontWeight: '500', + letterSpacing: 1.5, + textTransform: 'uppercase', + }, }} > ( - + ), }} /> @@ -48,6 +51,14 @@ export default function AppTabs() { headerShown: false, }} /> + + ); } diff --git a/src/components/fab.tsx b/src/components/fab.tsx index fbc7743..bf94403 100644 --- a/src/components/fab.tsx +++ b/src/components/fab.tsx @@ -1,7 +1,8 @@ -import { BottomTabInset, Colors, Spacing } from "@/constants/theme"; import React from "react"; import { Platform, Pressable, StyleSheet } from "react-native"; -import { ThemedText } from "./themed-text"; +import { MaterialIcons } from "@expo/vector-icons"; + +import { BottomTabInset, Colors, Spacing } from "@/constants/theme"; interface FABProps { onPress: () => void; @@ -12,17 +13,11 @@ export function FAB({ onPress }: FABProps) { [styles.fab, pressed && styles.pressed]} onPress={onPress} - accessible={true} + accessible accessibilityRole="button" accessibilityLabel="Add" - accessibilityState={{ disabled: false }} > - - + - + ); } @@ -53,10 +48,4 @@ const styles = StyleSheet.create({ opacity: 0.8, transform: [{ scale: 0.96 }], }, - icon: { - fontSize: 28, - color: "#fff", - lineHeight: 32, - fontWeight: "300", - }, }); diff --git a/src/components/modals/log-replacement-modal.tsx b/src/components/modals/log-replacement-modal.tsx index 8007b3c..700173b 100644 --- a/src/components/modals/log-replacement-modal.tsx +++ b/src/components/modals/log-replacement-modal.tsx @@ -1,9 +1,9 @@ import { ModalSheet } from "@/components/modal-sheet"; import { ThemedText } from "@/components/themed-text"; import { Colors, Spacing } from "@/constants/theme"; -import { logReplacement } from "@/db/parts"; +import { logReplacement, updateReplacementLog } from "@/db/parts"; import { bumpOdometer } from "@/db/vehicles"; -import { Part } from "@/types"; +import { Part, PartReplacementLog } from "@/types"; import { useSQLiteContext } from "expo-sqlite"; import React, { useEffect, useState } from "react"; import { @@ -24,6 +24,7 @@ interface LogReplacementModalProps { part: Part | null; vehicleId: number; currentKm: number; + existingLog?: PartReplacementLog; } export function LogReplacementModal({ @@ -33,15 +34,16 @@ export function LogReplacementModal({ part, vehicleId, currentKm, + existingLog, }: LogReplacementModalProps) { const db = useSQLiteContext(); const [kmStr, setKmStr] = useState(""); // Snapshot currentKm at open time so parent refetches don't wipe user input. useEffect(() => { - if (visible) setKmStr(String(currentKm)); + if (visible) setKmStr(String(existingLog?.replaced_at_km ?? currentKm)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible]); + }, [visible, existingLog?.id]); if (!part) return null; @@ -51,7 +53,11 @@ export function LogReplacementModal({ if (!part) return; const replacedAtKm = Math.round(rawKm); try { - await logReplacement(db, part.id, replacedAtKm); + if (existingLog) { + await updateReplacementLog(db, existingLog.id, part.id, replacedAtKm); + } else { + await logReplacement(db, part.id, replacedAtKm); + } await bumpOdometer(db, vehicleId, replacedAtKm); onSaved(); onClose(); @@ -86,7 +92,7 @@ export function LogReplacementModal({ return ( - Log Replacement + {existingLog ? "Edit Replacement" : "Log Replacement"} - Log Replacement + {existingLog ? "Save Changes" : "Log Replacement"} diff --git a/src/components/part-status-row.tsx b/src/components/part-status-row.tsx index 456ccf7..a185d03 100644 --- a/src/components/part-status-row.tsx +++ b/src/components/part-status-row.tsx @@ -54,8 +54,6 @@ const styles = StyleSheet.create({ backgroundColor: Colors.dark.backgroundElement, borderRadius: Spacing.three, overflow: 'hidden', - borderWidth: 1, - borderColor: Colors.dark.backgroundSelected, }, pressed: { opacity: 0.7, diff --git a/src/components/status-pill.tsx b/src/components/status-pill.tsx new file mode 100644 index 0000000..6c5c9f4 --- /dev/null +++ b/src/components/status-pill.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; + +import { ThemedText } from './themed-text'; + +interface StatusPillProps { + label: string; + color: string; + icon?: React.ComponentProps['name']; +} + +export function StatusPill({ label, color, icon }: StatusPillProps) { + return ( + + {icon && } + {label} + + ); +} + +const styles = StyleSheet.create({ + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 12, + paddingVertical: 2, + borderRadius: 999, + alignSelf: 'flex-start', + }, + label: { + fontSize: 11, + fontWeight: '500', + letterSpacing: 1.5, + textTransform: 'uppercase', + }, +}); diff --git a/src/components/vehicle-card.tsx b/src/components/vehicle-card.tsx index ce736af..f7c1f35 100644 --- a/src/components/vehicle-card.tsx +++ b/src/components/vehicle-card.tsx @@ -27,7 +27,12 @@ export function VehicleCard({ styles.badge, flaggedParts.length > 0 ? styles.badgeAlert : styles.badgeOk, ]}> - + 0 ? Colors.dark.danger : Colors.dark.success }, + ]}> {flaggedParts.length > 0 ? `${flaggedParts.length} part${flaggedParts.length > 1 ? 's' : ''} due` : 'All good'} @@ -57,8 +62,6 @@ const styles = StyleSheet.create({ card: { backgroundColor: Colors.dark.backgroundElement, borderRadius: Spacing.three, - borderWidth: 1, - borderColor: Colors.dark.backgroundSelected, padding: Spacing.three, gap: Spacing.two, }, @@ -72,9 +75,9 @@ const styles = StyleSheet.create({ flex: 1, }, badge: { - paddingHorizontal: Spacing.two, + paddingHorizontal: Spacing.three, paddingVertical: Spacing.half, - borderRadius: Spacing.three, + borderRadius: 999, }, badgeAlert: { backgroundColor: Colors.dark.danger + '33', @@ -84,6 +87,7 @@ const styles = StyleSheet.create({ }, badgeText: { fontSize: 12, + fontWeight: '500', }, parts: { gap: Spacing.two, diff --git a/src/constants/theme.ts b/src/constants/theme.ts index 742a199..98bbca7 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -5,9 +5,12 @@ const dark = { background: "#0A0A0A", backgroundElement: "#141414", backgroundSelected: "#1E1E1E", + backgroundElevated: "#251E19", primary: "#F3A261", - text: "#FFFFFF", + primaryText: "#0A0A0A", + text: "#EEE0D8", textSecondary: "#6B7280", + textMuted: "#A08D80", danger: "#EF4444", warning: "#F59E0B", success: "#22C55E", diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1eb7058..6391ef9 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -120,4 +120,28 @@ export async function migrateDb(db: SQLiteDatabase): Promise { await db.runAsync('INSERT INTO migrations (version) VALUES (?)', 3); }); } + + if (!appliedVersions.has(4)) { + await db.withTransactionAsync(async () => { + await db.execAsync(` + CREATE TABLE IF NOT EXISTS part_replacement_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE, + replaced_at_km INTEGER NOT NULL, + logged_at INTEGER NOT NULL + ); + + INSERT INTO part_replacement_logs (part_id, replaced_at_km, logged_at) + SELECT id, replaced_at_km, 0 + FROM parts + WHERE NOT EXISTS ( + SELECT 1 FROM part_replacement_logs WHERE part_replacement_logs.part_id = parts.id + ); + + CREATE INDEX IF NOT EXISTS idx_part_replacement_logs_part + ON part_replacement_logs(part_id, replaced_at_km DESC, id DESC); + `); + await db.runAsync('INSERT INTO migrations (version) VALUES (?)', 4); + }); + } } diff --git a/src/db/parts.ts b/src/db/parts.ts index 45c133a..0fabc17 100644 --- a/src/db/parts.ts +++ b/src/db/parts.ts @@ -1,5 +1,5 @@ import { SQLiteDatabase } from 'expo-sqlite'; -import { Part } from '@/types'; +import { Part, PartReplacementLog } from '@/types'; export async function getPartsByVehicle( db: SQLiteDatabase, @@ -11,6 +11,25 @@ export async function getPartsByVehicle( ); } +export async function getPartById( + db: SQLiteDatabase, + id: number +): Promise { + return db.getFirstAsync('SELECT * FROM parts WHERE id = ?', id); +} + +export async function getReplacementLogsByPart( + db: SQLiteDatabase, + partId: number +): Promise { + return db.getAllAsync( + `SELECT * FROM part_replacement_logs + WHERE part_id = ? + ORDER BY replaced_at_km DESC, id DESC`, + partId + ); +} + export async function addPart( db: SQLiteDatabase, vehicleId: number, @@ -18,14 +37,24 @@ export async function addPart( replacedAtKm: number, intervalKm: number | null ): Promise { - const result = await db.runAsync( - 'INSERT INTO parts (vehicle_id, name, replaced_at_km, interval_km) VALUES (?, ?, ?, ?)', - vehicleId, - name, - replacedAtKm, - intervalKm - ); - return result.lastInsertRowId; + let partId = 0; + await db.withTransactionAsync(async () => { + const result = await db.runAsync( + 'INSERT INTO parts (vehicle_id, name, replaced_at_km, interval_km) VALUES (?, ?, ?, ?)', + vehicleId, + name, + replacedAtKm, + intervalKm + ); + partId = result.lastInsertRowId; + await db.runAsync( + 'INSERT INTO part_replacement_logs (part_id, replaced_at_km, logged_at) VALUES (?, ?, ?)', + partId, + replacedAtKm, + Date.now() + ); + }); + return partId; } export async function updatePart( @@ -47,9 +76,63 @@ export async function logReplacement( partId: number, replacedAtKm: number ): Promise { + await db.withTransactionAsync(async () => { + await db.runAsync( + 'INSERT INTO part_replacement_logs (part_id, replaced_at_km, logged_at) VALUES (?, ?, ?)', + partId, + replacedAtKm, + Date.now() + ); + await syncPartLatestReplacement(db, partId); + }); +} + +export async function updateReplacementLog( + db: SQLiteDatabase, + logId: number, + partId: number, + replacedAtKm: number +): Promise { + await db.withTransactionAsync(async () => { + await db.runAsync( + 'UPDATE part_replacement_logs SET replaced_at_km = ? WHERE id = ? AND part_id = ?', + replacedAtKm, + logId, + partId + ); + await syncPartLatestReplacement(db, partId); + }); +} + +export async function deleteReplacementLog( + db: SQLiteDatabase, + logId: number, + partId: number +): Promise { + await db.withTransactionAsync(async () => { + await db.runAsync( + 'DELETE FROM part_replacement_logs WHERE id = ? AND part_id = ?', + logId, + partId + ); + await syncPartLatestReplacement(db, partId); + }); +} + +async function syncPartLatestReplacement( + db: SQLiteDatabase, + partId: number +): Promise { + const latest = await db.getFirstAsync<{ replaced_at_km: number }>( + `SELECT replaced_at_km FROM part_replacement_logs + WHERE part_id = ? + ORDER BY replaced_at_km DESC, id DESC + LIMIT 1`, + partId + ); await db.runAsync( 'UPDATE parts SET replaced_at_km = ? WHERE id = ?', - replacedAtKm, + latest?.replaced_at_km ?? 0, partId ); } diff --git a/src/types/index.ts b/src/types/index.ts index d7b94db..ee0c2fe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,13 @@ export interface Part { interval_km: number | null; } +export interface PartReplacementLog { + id: number; + part_id: number; + replaced_at_km: number; + logged_at: number; +} + export interface FuelLog { id: number; vehicle_id: number;