3 Commits

Author SHA1 Message Date
bd500674a0 fix(ui): stabilize modal keyboard behavior
All checks were successful
Luggage List Build / build-web (push) Successful in 55s
Luggage List Build / build-android (push) Successful in 6m28s
Luggage List Build / release (push) Successful in 10s
2026-04-18 14:12:34 +02:00
ef7e0ba7a1 style: center and enlarge modals to overlay navbar
All checks were successful
Luggage List Build / build-web (push) Successful in 30s
Luggage List Build / build-android (push) Successful in 6m13s
Luggage List Build / release (push) Successful in 15s
2026-04-18 13:34:42 +02:00
f34ffe39c0 feat: polish UI, fix top inset, and add check-up correctness stats
All checks were successful
Luggage List Build / build-web (push) Successful in 38s
Luggage List Build / build-android (push) Successful in 6m16s
Luggage List Build / release (push) Successful in 14s
2026-04-18 13:23:19 +02:00
8 changed files with 128 additions and 29 deletions

View File

@@ -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

View File

@@ -34,3 +34,8 @@ 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)
- [x] Centered and enlarged edit/check-up modals to fully overlay nav
- [x] Fixed modal keyboard glitching (stable centered keyboard-aware layout)

View File

@@ -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}

View File

@@ -15,7 +15,7 @@ export default function CheckupFixModal({
return ( return (
<Modal visible={visible} animationType="slide" transparent> <Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalBackdrop}> <View style={styles.modalBackdrop}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalKeyboardWrap}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}> <View style={styles.modalCard}>
<View style={styles.sectionRow}> <View style={styles.sectionRow}>
<Text style={styles.sectionTitle}>Update for this Check-Up</Text> <Text style={styles.sectionTitle}>Update for this Check-Up</Text>
@@ -24,7 +24,12 @@ export default function CheckupFixModal({
</Pressable> </Pressable>
</View> </View>
<ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}> <ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
contentContainerStyle={{ paddingBottom: 12 }}
showsVerticalScrollIndicator={false}
>
<Field label="Status"> <Field label="Status">
<ChipGroup <ChipGroup
options={ITEM_STATUSES} options={ITEM_STATUSES}

View File

@@ -16,7 +16,7 @@ export default function ItemModal({
return ( return (
<Modal visible={visible} animationType="slide" transparent> <Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalBackdrop}> <View style={styles.modalBackdrop}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalKeyboardWrap}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}> <View style={styles.modalCard}>
<View style={styles.sectionRow}> <View style={styles.sectionRow}>
<Text style={styles.sectionTitle}>{itemForm.id ? 'Update Item' : 'Add Item'}</Text> <Text style={styles.sectionTitle}>{itemForm.id ? 'Update Item' : 'Add Item'}</Text>
@@ -25,7 +25,12 @@ export default function ItemModal({
</Pressable> </Pressable>
</View> </View>
<ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}> <ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
contentContainerStyle={{ paddingBottom: 12 }}
showsVerticalScrollIndicator={false}
>
<Field label="Name"> <Field label="Name">
<TextInput <TextInput
style={styles.input} style={styles.input}

View File

@@ -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,
@@ -368,20 +400,23 @@ export const styles = StyleSheet.create({
modalBackdrop: { modalBackdrop: {
flex: 1, flex: 1,
backgroundColor: 'rgba(2,6,23,0.72)', backgroundColor: 'rgba(2,6,23,0.72)',
justifyContent: 'flex-end', paddingHorizontal: 12,
}, },
modalKeyboardWrap: { modalKeyboardWrap: {
flex: 1,
width: '100%', width: '100%',
alignItems: 'center',
justifyContent: 'center',
}, },
modalCard: { modalCard: {
maxHeight: '87%', width: '96%',
maxHeight: '90%',
backgroundColor: '#0f172a', backgroundColor: '#0f172a',
borderTopLeftRadius: 18, borderRadius: 20,
borderTopRightRadius: 18,
borderWidth: 1, borderWidth: 1,
borderColor: '#1e293b', borderColor: '#1e293b',
padding: 14, padding: 16,
gap: 8, gap: 10,
}, },
closeText: { closeText: {
color: '#93c5fd', color: '#93c5fd',

View File

@@ -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>
))} ))}

View File

@@ -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>