feat: polish UI, fix top inset, and add check-up correctness stats
This commit is contained in:
@@ -5,7 +5,7 @@ Minimal local-first luggage management app built with Expo.
|
|||||||
## Current Features (V2)
|
## Current Features (V2)
|
||||||
|
|
||||||
- No auth, no server, local storage only (AsyncStorage)
|
- No auth, no server, local storage only (AsyncStorage)
|
||||||
- Trips with name, location, dates, optional image from gallery
|
- Trips with name, location, calendar date picker, optional image from gallery
|
||||||
- Active trip auto-select on first load, with manual trip switching anytime via global trip picker
|
- Active trip auto-select on first load, with manual trip switching anytime via global trip picker
|
||||||
- Default trip template (copied into new trip, not linked)
|
- Default trip template (copied into new trip, not linked)
|
||||||
- Luggage items with:
|
- Luggage items with:
|
||||||
@@ -14,10 +14,10 @@ Minimal local-first luggage management app built with Expo.
|
|||||||
- placement: suitcase, backpack, with-user, other
|
- placement: suitcase, backpack, with-user, other
|
||||||
- optional image from gallery
|
- optional image from gallery
|
||||||
- Item create/edit via modal
|
- Item create/edit via modal
|
||||||
- Check-up flow as yes/no checklist:
|
- Check-up flow as yes/no checklist with live stats (correct/bad/pending):
|
||||||
- “No” opens update modal
|
- “No” opens update modal
|
||||||
- fixes can be check-up-only or optionally synced to trip item list
|
- fixes can be check-up-only or optionally synced to trip item list
|
||||||
- Check-up history per trip with saved snapshots
|
- Check-up history per selected trip with saved snapshots + stats
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
3
TODO.md
3
TODO.md
@@ -34,3 +34,6 @@ Improving & Fixing Bugs (V3)
|
|||||||
- [x] Improved keyboard focus scrolling to focused input (not scroll-to-end)
|
- [x] Improved keyboard focus scrolling to focused input (not scroll-to-end)
|
||||||
- [x] Reworked bottom nav to real icons + labels
|
- [x] Reworked bottom nav to real icons + labels
|
||||||
- [x] Clarified history as selected-trip check-up history
|
- [x] Clarified history as selected-trip check-up history
|
||||||
|
- [x] Increased safe top inset to avoid status-bar overlap
|
||||||
|
- [x] Added check-up stats (correct/bad/pending) and persisted per snapshot
|
||||||
|
- [x] Extra UI polish pass (spacing, cards, hierarchy)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Alert, KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, Text, View } from 'react-native';
|
import { Alert, KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, StatusBar as RNStatusBar, Text, View } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
@@ -63,6 +63,8 @@ export default function AppRoot() {
|
|||||||
|
|
||||||
const [selectedCheckupId, setSelectedCheckupId] = useState(null);
|
const [selectedCheckupId, setSelectedCheckupId] = useState(null);
|
||||||
|
|
||||||
|
const topInset = Platform.OS === 'android' ? (RNStatusBar.currentHeight || 0) + 10 : 0;
|
||||||
|
|
||||||
const selectedTrip = useMemo(() => data.trips.find((trip) => trip.id === selectedTripId) || null, [data.trips, selectedTripId]);
|
const selectedTrip = useMemo(() => data.trips.find((trip) => trip.id === selectedTripId) || null, [data.trips, selectedTripId]);
|
||||||
|
|
||||||
const selectedTripItems = useMemo(() => {
|
const selectedTripItems = useMemo(() => {
|
||||||
@@ -80,6 +82,14 @@ export default function AppRoot() {
|
|||||||
[data.trips, data.defaultTemplateTripId]
|
[data.trips, data.defaultTemplateTripId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const checkupStats = useMemo(() => {
|
||||||
|
const total = checkupSession.length;
|
||||||
|
const correct = checkupSession.filter((entry) => entry.result === 'correct').length;
|
||||||
|
const bad = checkupSession.filter((entry) => entry.result === 'bad').length;
|
||||||
|
const pending = total - correct - bad;
|
||||||
|
return { total, correct, bad, pending };
|
||||||
|
}, [checkupSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -347,13 +357,16 @@ export default function AppRoot() {
|
|||||||
lentTo: item.lentTo || '',
|
lentTo: item.lentTo || '',
|
||||||
},
|
},
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
|
result: 'pending',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setCheckupSession(fresh);
|
setCheckupSession(fresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
function answerCheckupYes(itemId) {
|
function answerCheckupYes(itemId) {
|
||||||
setCheckupSession((prev) => prev.map((entry) => (entry.itemId === itemId ? { ...entry, confirmed: true } : entry)));
|
setCheckupSession((prev) =>
|
||||||
|
prev.map((entry) => (entry.itemId === itemId ? { ...entry, confirmed: true, result: 'correct' } : entry))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFixModal(itemId) {
|
function openFixModal(itemId) {
|
||||||
@@ -387,6 +400,7 @@ export default function AppRoot() {
|
|||||||
...entry,
|
...entry,
|
||||||
current: patch,
|
current: patch,
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
|
result: 'bad',
|
||||||
}
|
}
|
||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
@@ -443,6 +457,7 @@ export default function AppRoot() {
|
|||||||
status: entry.current.status,
|
status: entry.current.status,
|
||||||
placement: entry.current.placement,
|
placement: entry.current.placement,
|
||||||
lentTo: entry.current.status === 'lent-to' ? entry.current.lentTo : '',
|
lentTo: entry.current.status === 'lent-to' ? entry.current.lentTo : '',
|
||||||
|
result: entry.result || 'pending',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
@@ -457,6 +472,10 @@ export default function AppRoot() {
|
|||||||
id: makeId('checkup'),
|
id: makeId('checkup'),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
snapshot,
|
snapshot,
|
||||||
|
stats: {
|
||||||
|
correct: snapshot.filter((entry) => entry.result === 'correct').length,
|
||||||
|
bad: snapshot.filter((entry) => entry.result === 'bad').length,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -480,7 +499,7 @@ export default function AppRoot() {
|
|||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={[styles.safe, { paddingTop: topInset }]}>
|
||||||
<StatusBar style="light" translucent={false} />
|
<StatusBar style="light" translucent={false} />
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<Text style={styles.muted}>Loading local data...</Text>
|
<Text style={styles.muted}>Loading local data...</Text>
|
||||||
@@ -490,7 +509,7 @@ export default function AppRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={[styles.safe, { paddingTop: topInset }]}>
|
||||||
<StatusBar style="light" translucent={false} />
|
<StatusBar style="light" translucent={false} />
|
||||||
|
|
||||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
@@ -500,7 +519,6 @@ export default function AppRoot() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={styles.statusSpacer} />
|
|
||||||
<TripPicker trips={data.trips} selectedTripId={selectedTripId} onChooseTrip={setSelectedTripId} />
|
<TripPicker trips={data.trips} selectedTripId={selectedTripId} onChooseTrip={setSelectedTripId} />
|
||||||
|
|
||||||
{tab === 'trips' && (
|
{tab === 'trips' && (
|
||||||
@@ -534,6 +552,7 @@ export default function AppRoot() {
|
|||||||
{tab === 'checkup' && (
|
{tab === 'checkup' && (
|
||||||
<CheckupTab
|
<CheckupTab
|
||||||
checkupSession={checkupSession}
|
checkupSession={checkupSession}
|
||||||
|
checkupStats={checkupStats}
|
||||||
answerCheckupYes={answerCheckupYes}
|
answerCheckupYes={answerCheckupYes}
|
||||||
openFixModal={openFixModal}
|
openFixModal={openFixModal}
|
||||||
createFreshCheckupSession={createFreshCheckupSession}
|
createFreshCheckupSession={createFreshCheckupSession}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 10,
|
paddingTop: 12,
|
||||||
paddingBottom: TAB_BAR_HEIGHT + 20,
|
paddingBottom: TAB_BAR_HEIGHT + 22,
|
||||||
gap: 12,
|
gap: 14,
|
||||||
},
|
},
|
||||||
statusSpacer: {
|
statusSpacer: {
|
||||||
height: Platform.OS === 'android' ? 8 : 0,
|
height: Platform.OS === 'android' ? 8 : 0,
|
||||||
@@ -64,7 +64,7 @@ export const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
section: {
|
section: {
|
||||||
gap: 10,
|
gap: 12,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
color: '#f1f5f9',
|
color: '#f1f5f9',
|
||||||
@@ -80,7 +80,7 @@ export const styles = StyleSheet.create({
|
|||||||
|
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#111827',
|
backgroundColor: '#111827',
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#1f2937',
|
borderColor: '#1f2937',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
@@ -91,7 +91,7 @@ export const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
cardSoft: {
|
cardSoft: {
|
||||||
backgroundColor: '#0f172a',
|
backgroundColor: '#0f172a',
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#1e293b',
|
borderColor: '#1e293b',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
@@ -241,7 +241,7 @@ export const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
itemCard: {
|
itemCard: {
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
backgroundColor: '#111827',
|
backgroundColor: '#111827',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#1f2937',
|
borderColor: '#1f2937',
|
||||||
@@ -302,6 +302,38 @@ export const styles = StyleSheet.create({
|
|||||||
answerStateDotOn: {
|
answerStateDotOn: {
|
||||||
backgroundColor: '#22c55e',
|
backgroundColor: '#22c55e',
|
||||||
},
|
},
|
||||||
|
answerStateDotBad: {
|
||||||
|
backgroundColor: '#ef4444',
|
||||||
|
},
|
||||||
|
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
statPill: {
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingVertical: 7,
|
||||||
|
paddingHorizontal: 11,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
statPillCorrect: {
|
||||||
|
backgroundColor: '#163223',
|
||||||
|
borderColor: '#1f7a4e',
|
||||||
|
},
|
||||||
|
statPillBad: {
|
||||||
|
backgroundColor: '#3b1d22',
|
||||||
|
borderColor: '#7f1d1d',
|
||||||
|
},
|
||||||
|
statPillPending: {
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
borderColor: '#334155',
|
||||||
|
},
|
||||||
|
statPillText: {
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
|
||||||
snapshotWrap: {
|
snapshotWrap: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import React from 'react';
|
|||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { styles } from '../styles';
|
import { styles } from '../styles';
|
||||||
|
|
||||||
export default function CheckupTab({ checkupSession, answerCheckupYes, openFixModal, createFreshCheckupSession, saveCheckup }) {
|
export default function CheckupTab({
|
||||||
|
checkupSession,
|
||||||
|
checkupStats,
|
||||||
|
answerCheckupYes,
|
||||||
|
openFixModal,
|
||||||
|
createFreshCheckupSession,
|
||||||
|
saveCheckup,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionRow}>
|
<View style={styles.sectionRow}>
|
||||||
@@ -12,6 +19,20 @@ export default function CheckupTab({ checkupSession, answerCheckupYes, openFixMo
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{!!checkupSession.length && (
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={[styles.statPill, styles.statPillCorrect]}>
|
||||||
|
<Text style={styles.statPillText}>Correct: {checkupStats.correct}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statPill, styles.statPillBad]}>
|
||||||
|
<Text style={styles.statPillText}>Bad: {checkupStats.bad}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statPill, styles.statPillPending]}>
|
||||||
|
<Text style={styles.statPillText}>Pending: {checkupStats.pending}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{checkupSession.length === 0 ? <Text style={styles.muted}>No items for this trip yet.</Text> : null}
|
{checkupSession.length === 0 ? <Text style={styles.muted}>No items for this trip yet.</Text> : null}
|
||||||
|
|
||||||
{checkupSession.map((entry) => (
|
{checkupSession.map((entry) => (
|
||||||
@@ -30,7 +51,13 @@ export default function CheckupTab({ checkupSession, answerCheckupYes, openFixMo
|
|||||||
<Pressable style={styles.answerNo} onPress={() => openFixModal(entry.itemId)}>
|
<Pressable style={styles.answerNo} onPress={() => openFixModal(entry.itemId)}>
|
||||||
<Text style={styles.answerText}>No</Text>
|
<Text style={styles.answerText}>No</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={[styles.answerStateDot, entry.confirmed ? styles.answerStateDotOn : null]} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.answerStateDot,
|
||||||
|
entry.result === 'correct' ? styles.answerStateDotOn : null,
|
||||||
|
entry.result === 'bad' ? styles.answerStateDotBad : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export default function HistoryTab({ selectedTrip, selectedTripCheckups, selecte
|
|||||||
<View key={checkup.id} style={styles.cardSoft}>
|
<View key={checkup.id} style={styles.cardSoft}>
|
||||||
<Pressable onPress={() => setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}>
|
<Pressable onPress={() => setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}>
|
||||||
<Text style={styles.cardTitle}>{new Date(checkup.createdAt).toLocaleString()}</Text>
|
<Text style={styles.cardTitle}>{new Date(checkup.createdAt).toLocaleString()}</Text>
|
||||||
<Text style={styles.cardMeta}>{checkup.snapshot.length} items</Text>
|
<Text style={styles.cardMeta}>
|
||||||
|
{checkup.snapshot.length} items · correct: {checkup.stats?.correct ?? checkup.snapshot.filter((x) => x.result === 'correct').length} · bad:{' '}
|
||||||
|
{checkup.stats?.bad ?? checkup.snapshot.filter((x) => x.result === 'bad').length}
|
||||||
|
</Text>
|
||||||
<Text style={styles.cardMeta}>{selectedCheckupId === checkup.id ? 'Tap to collapse' : 'Tap to open'}</Text>
|
<Text style={styles.cardMeta}>{selectedCheckupId === checkup.id ? 'Tap to collapse' : 'Tap to open'}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user