import React, { useEffect, useMemo, useState } from 'react'; import { Alert, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, Image, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system'; import * as Sharing from 'expo-sharing'; import { StatusBar } from 'expo-status-bar'; const STORAGE_KEY = 'luggage-list:v1'; const ITEM_STATUSES = ['packed', 'unpacked', 'lost', 'left-behind', 'lent-to']; const ITEM_PLACEMENTS = ['suitcase', 'backpack', 'with-user', 'other']; const emptyData = { trips: [], itemsByTrip: {}, checkupsByTrip: {}, defaultTemplateTripId: null, }; function makeId(prefix) { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } function todayYMD() { const now = new Date(); const y = now.getFullYear(); const m = `${now.getMonth() + 1}`.padStart(2, '0'); const d = `${now.getDate()}`.padStart(2, '0'); return `${y}-${m}-${d}`; } function parseYMD(value) { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; const d = new Date(`${value}T00:00:00`); return Number.isNaN(d.getTime()) ? null : d; } function findAutoActiveTrip(trips) { const today = parseYMD(todayYMD()); if (!today) return null; const active = trips.find((trip) => { const start = parseYMD(trip.startDate); const end = parseYMD(trip.endDate); if (!start || !end) return false; return today >= start && today <= end; }); return active || null; } function ChipGroup({ options, value, onChange }) { return ( {options.map((option) => { const active = value === option; return ( onChange(option)} > {option} ); })} ); } function Field({ label, children }) { return ( {label} {children} ); } function Card({ title, children, right }) { return ( {title} {right} {children} ); } export default function App() { const [data, setData] = useState(emptyData); const [loaded, setLoaded] = useState(false); const [tab, setTab] = useState('trips'); const [selectedTripId, setSelectedTripId] = useState(null); const [tripForm, setTripForm] = useState({ name: '', location: '', startDate: todayYMD(), endDate: todayYMD(), imageUri: '', copyDefaultTemplate: true, setAsDefaultTemplate: false, }); const [itemForm, setItemForm] = useState({ id: null, name: '', description: '', category: '', status: 'unpacked', placement: 'suitcase', lentTo: '', imageUri: '', }); const [checkupDraft, setCheckupDraft] = useState({}); const [selectedCheckupId, setSelectedCheckupId] = useState(null); useEffect(() => { (async () => { try { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); setData({ ...emptyData, ...parsed }); } } catch (error) { Alert.alert('Load error', 'Could not load local data.'); } finally { setLoaded(true); } })(); }, []); useEffect(() => { if (!loaded) return; AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data)).catch(() => { Alert.alert('Save error', 'Could not save local data.'); }); }, [data, loaded]); useEffect(() => { if (!loaded) return; const autoTrip = findAutoActiveTrip(data.trips); if (autoTrip?.id && selectedTripId !== autoTrip.id) { setSelectedTripId(autoTrip.id); return; } if (!selectedTripId && data.trips[0]?.id) { setSelectedTripId(data.trips[0].id); return; } if (selectedTripId && !data.trips.some((trip) => trip.id === selectedTripId)) { setSelectedTripId(data.trips[0]?.id || null); } }, [data.trips, selectedTripId, loaded]); const selectedTrip = useMemo( () => data.trips.find((trip) => trip.id === selectedTripId) || null, [data.trips, selectedTripId] ); const selectedTripItems = useMemo(() => { if (!selectedTripId) return []; return data.itemsByTrip[selectedTripId] || []; }, [data.itemsByTrip, selectedTripId]); const selectedTripCheckups = useMemo(() => { if (!selectedTripId) return []; return (data.checkupsByTrip[selectedTripId] || []).slice().sort((a, b) => b.createdAt - a.createdAt); }, [data.checkupsByTrip, selectedTripId]); const templateTrip = useMemo( () => data.trips.find((trip) => trip.id === data.defaultTemplateTripId) || null, [data.trips, data.defaultTemplateTripId] ); async function pickImage(onPick) { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) { Alert.alert('Permission needed', 'Please allow gallery access to pick an image.'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 0.8, }); if (!result.canceled && result.assets?.[0]?.uri) { onPick(result.assets[0].uri); } } function updateTripForm(field, value) { setTripForm((prev) => ({ ...prev, [field]: value })); } function updateItemForm(field, value) { setItemForm((prev) => ({ ...prev, [field]: value })); } function createTrip() { if (!tripForm.name.trim()) { Alert.alert('Missing name', 'Trip name is required.'); return; } const startDate = parseYMD(tripForm.startDate); const endDate = parseYMD(tripForm.endDate); if (!startDate || !endDate) { Alert.alert('Date format', 'Please use YYYY-MM-DD for dates.'); return; } if (startDate > endDate) { Alert.alert('Dates invalid', 'Start date cannot be after end date.'); return; } const now = Date.now(); const newTripId = makeId('trip'); const newTrip = { id: newTripId, name: tripForm.name.trim(), location: tripForm.location.trim(), startDate: tripForm.startDate, endDate: tripForm.endDate, imageUri: tripForm.imageUri, createdAt: now, updatedAt: now, }; setData((prev) => { const next = { ...prev, trips: [...prev.trips, newTrip], itemsByTrip: { ...prev.itemsByTrip, [newTripId]: [] }, checkupsByTrip: { ...prev.checkupsByTrip, [newTripId]: [] }, }; if (tripForm.copyDefaultTemplate && prev.defaultTemplateTripId && prev.defaultTemplateTripId !== newTripId) { const source = prev.itemsByTrip[prev.defaultTemplateTripId] || []; next.itemsByTrip[newTripId] = source.map((item) => ({ ...item, id: makeId('item'), createdAt: now, updatedAt: now, })); } if (tripForm.setAsDefaultTemplate) { next.defaultTemplateTripId = newTripId; } return next; }); setSelectedTripId(newTripId); setTripForm({ name: '', location: '', startDate: todayYMD(), endDate: todayYMD(), imageUri: '', copyDefaultTemplate: true, setAsDefaultTemplate: false, }); } function setTripAsTemplate(tripId) { setData((prev) => ({ ...prev, defaultTemplateTripId: tripId })); } function saveItem() { if (!selectedTripId) { Alert.alert('No trip', 'Create or select a trip first.'); return; } if (!itemForm.name.trim()) { Alert.alert('Missing name', 'Item name is required.'); return; } const now = Date.now(); setData((prev) => { const currentItems = prev.itemsByTrip[selectedTripId] || []; const normalized = { id: itemForm.id || makeId('item'), name: itemForm.name.trim(), description: itemForm.description.trim(), category: itemForm.category.trim(), status: itemForm.status, placement: itemForm.placement, lentTo: itemForm.status === 'lent-to' ? itemForm.lentTo.trim() : '', imageUri: itemForm.imageUri, createdAt: itemForm.id ? (currentItems.find((x) => x.id === itemForm.id)?.createdAt || now) : now, updatedAt: now, }; const nextItems = itemForm.id ? currentItems.map((item) => (item.id === itemForm.id ? normalized : item)) : [...currentItems, normalized]; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: nextItems, }, }; }); setItemForm({ id: null, name: '', description: '', category: '', status: 'unpacked', placement: 'suitcase', lentTo: '', imageUri: '', }); } function editItem(item) { setItemForm({ id: item.id, name: item.name || '', description: item.description || '', category: item.category || '', status: item.status || 'unpacked', placement: item.placement || 'suitcase', lentTo: item.lentTo || '', imageUri: item.imageUri || '', }); setTab('items'); } function deleteItem(itemId) { setData((prev) => { const currentItems = prev.itemsByTrip[selectedTripId] || []; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: currentItems.filter((item) => item.id !== itemId), }, }; }); } function initCheckupDraft() { const draft = {}; selectedTripItems.forEach((item) => { draft[item.id] = { status: item.status || 'unpacked', placement: item.placement || 'suitcase', lentTo: item.lentTo || '', }; }); setCheckupDraft(draft); } useEffect(() => { initCheckupDraft(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedTripId, selectedTripItems.length]); function updateCheckupItem(itemId, field, value) { setCheckupDraft((prev) => ({ ...prev, [itemId]: { status: prev[itemId]?.status || 'unpacked', placement: prev[itemId]?.placement || 'suitcase', lentTo: prev[itemId]?.lentTo || '', [field]: value, }, })); } function createCheckup() { if (!selectedTripId) { Alert.alert('No trip', 'Create or select a trip first.'); return; } if (!selectedTripItems.length) { Alert.alert('No items', 'Add at least one luggage item first.'); return; } const now = Date.now(); const snapshot = selectedTripItems.map((item) => { const draft = checkupDraft[item.id] || {}; return { itemId: item.id, name: item.name, category: item.category, status: draft.status || item.status || 'unpacked', placement: draft.placement || item.placement || 'suitcase', lentTo: (draft.status || item.status) === 'lent-to' ? (draft.lentTo || item.lentTo || '') : '', }; }); setData((prev) => { const currentCheckups = prev.checkupsByTrip[selectedTripId] || []; const currentItems = prev.itemsByTrip[selectedTripId] || []; const nextItems = currentItems.map((item) => { const snap = snapshot.find((x) => x.itemId === item.id); if (!snap) return item; return { ...item, status: snap.status, placement: snap.placement, lentTo: snap.lentTo, updatedAt: now, }; }); return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: nextItems, }, checkupsByTrip: { ...prev.checkupsByTrip, [selectedTripId]: [ ...currentCheckups, { id: makeId('checkup'), createdAt: now, snapshot, }, ], }, }; }); Alert.alert('Saved', 'Check-up snapshot stored.'); } async function exportJson() { try { const payload = { exportedAt: new Date().toISOString(), app: 'Luggage List', ...data, }; const fileName = `luggage-list-export-${Date.now()}.json`; const path = `${FileSystem.documentDirectory}${fileName}`; await FileSystem.writeAsStringAsync(path, JSON.stringify(payload, null, 2), { encoding: FileSystem.EncodingType.UTF8, }); const canShare = await Sharing.isAvailableAsync(); if (canShare) { await Sharing.shareAsync(path, { mimeType: 'application/json', dialogTitle: 'Export luggage data', UTI: 'public.json', }); } else { Alert.alert('Exported', `Saved to: ${path}`); } } catch (error) { Alert.alert('Export failed', 'Could not export JSON file.'); } } function formatDateTime(ts) { return new Date(ts).toLocaleString(); } if (!loaded) { return ( Loading local data... ); } return ( Luggage List Simple local luggage tracking {['trips', 'items', 'checkup', 'history', 'export'].map((name) => ( setTab(name)} > {name} ))} {selectedTrip ? `${selectedTrip.startDate} → ${selectedTrip.endDate}` : 'None'} } > {selectedTrip ? ( {selectedTrip.name} {selectedTrip.location || 'No location set'} {!!selectedTrip.imageUri && } ) : ( Create your first trip to start. )} {tab === 'trips' && ( <> updateTripForm('name', v)} style={styles.input} placeholder="Weekend in Berlin" /> updateTripForm('location', v)} style={styles.input} placeholder="Berlin" /> updateTripForm('startDate', v)} style={styles.input} placeholder="2026-04-18" /> updateTripForm('endDate', v)} style={styles.input} placeholder="2026-04-21" /> pickImage((uri) => updateTripForm('imageUri', uri))}> {tripForm.imageUri ? 'Change Trip Image' : 'Add Trip Image'} {tripForm.imageUri ? : null} {templateTrip ? ( updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)} > {tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template trip: {templateTrip.name} ) : null} updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)} > {tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set this trip as default template Create Trip {!data.trips.length ? No trips yet. : null} {data.trips .slice() .sort((a, b) => b.startDate.localeCompare(a.startDate)) .map((trip) => ( setSelectedTripId(trip.id)} style={styles.grow}> {trip.name} {trip.location || 'No location'} • {trip.startDate} → {trip.endDate} {selectedTripId === trip.id ? 'Active' : 'Tap to select'} {data.defaultTemplateTripId === trip.id ? ' • Template' : ''} setTripAsTemplate(trip.id)}> Template ))} )} {tab === 'items' && ( <> {!selectedTripId ? Create/select a trip first. : null} updateItemForm('name', v)} style={styles.input} placeholder="Toothbrush" /> updateItemForm('description', v)} style={styles.input} placeholder="Electric toothbrush" /> updateItemForm('category', v)} style={styles.input} placeholder="toiletries" /> updateItemForm('status', v)} /> updateItemForm('placement', v)} /> {itemForm.status === 'lent-to' ? ( updateItemForm('lentTo', v)} style={styles.input} placeholder="Person name" /> ) : null} pickImage((uri) => updateItemForm('imageUri', uri))}> {itemForm.imageUri ? 'Change Item Image' : 'Add Item Image'} {itemForm.imageUri ? : null} {itemForm.id ? 'Save Item' : 'Add Item'} {itemForm.id ? ( setItemForm({ id: null, name: '', description: '', category: '', status: 'unpacked', placement: 'suitcase', lentTo: '', imageUri: '', }) } > Cancel ) : null} {!selectedTripItems.length ? No items yet for this trip. : null} {selectedTripItems.map((item) => ( {item.name} {(item.category || 'uncategorized')} • {item.status} • {item.placement} {!!item.description && {item.description}} {item.status === 'lent-to' && !!item.lentTo && Lent to: {item.lentTo}} editItem(item)}> Edit deleteItem(item.id)}> Delete {!!item.imageUri && } ))} )} {tab === 'checkup' && ( <> {!selectedTripItems.length ? ( Add items first, then do a check-up. ) : ( <> {selectedTripItems.map((item) => { const draft = checkupDraft[item.id] || {}; const statusValue = draft.status || item.status || 'unpacked'; const placementValue = draft.placement || item.placement || 'suitcase'; const lentValue = draft.lentTo || ''; return ( {item.name} {item.category || 'uncategorized'} updateCheckupItem(item.id, 'status', v)} /> updateCheckupItem(item.id, 'placement', v)} /> {statusValue === 'lent-to' ? ( updateCheckupItem(item.id, 'lentTo', v)} style={styles.input} placeholder="Person name" /> ) : null} ); })} Save Check-Up Reset )} )} {tab === 'history' && ( {!selectedTripCheckups.length ? No check-ups saved yet. : null} {selectedTripCheckups.map((checkup) => { const lostCount = checkup.snapshot.filter((x) => x.status === 'lost').length; const leftBehindCount = checkup.snapshot.filter((x) => x.status === 'left-behind').length; return ( setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}> {formatDateTime(checkup.createdAt)} {checkup.snapshot.length} items • lost: {lostCount} • left-behind: {leftBehindCount} {selectedCheckupId === checkup.id ? 'Tap to collapse' : 'Tap to view snapshot'} {selectedCheckupId === checkup.id ? ( {checkup.snapshot.map((entry) => ( {entry.name} {entry.status} • {entry.placement} {entry.status === 'lent-to' && entry.lentTo ? ` • ${entry.lentTo}` : ''} ))} ) : null} ); })} )} {tab === 'export' && ( Export trips, items, and check-up history as JSON for backup or sharing. Export JSON )} ); } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: '#f5f5f7', }, header: { paddingHorizontal: 16, paddingTop: 14, paddingBottom: 8, }, title: { fontSize: 26, fontWeight: '700', color: '#0f172a', }, subtitle: { marginTop: 2, color: '#475569', }, tabRow: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 10, gap: 6, }, tabBtn: { backgroundColor: '#e2e8f0', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, }, tabBtnActive: { backgroundColor: '#0f172a', }, tabText: { color: '#1e293b', textTransform: 'capitalize', fontWeight: '600', }, tabTextActive: { color: '#fff', }, content: { padding: 12, paddingBottom: 24, gap: 12, }, card: { backgroundColor: '#fff', borderRadius: 12, padding: 12, borderWidth: 1, borderColor: '#e2e8f0', }, cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, gap: 8, }, cardTitle: { fontSize: 17, fontWeight: '700', color: '#0f172a', }, fieldWrap: { marginTop: 8, }, label: { marginBottom: 6, color: '#334155', fontWeight: '600', }, input: { borderWidth: 1, borderColor: '#cbd5e1', backgroundColor: '#fff', paddingHorizontal: 10, paddingVertical: 10, borderRadius: 10, }, chipGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, }, chip: { borderWidth: 1, borderColor: '#cbd5e1', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, backgroundColor: '#fff', }, chipActive: { backgroundColor: '#0f172a', borderColor: '#0f172a', }, chipText: { color: '#334155', fontSize: 12, fontWeight: '600', }, chipTextActive: { color: '#fff', }, primaryBtn: { marginTop: 12, backgroundColor: '#0f172a', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12, alignItems: 'center', }, primaryBtnText: { color: '#fff', fontWeight: '700', }, secondaryBtn: { marginTop: 12, backgroundColor: '#e2e8f0', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12, alignItems: 'center', }, secondaryBtnText: { color: '#1e293b', fontWeight: '700', }, actionRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap', }, listItem: { marginTop: 8, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8, alignItems: 'center', }, listItemStack: { marginTop: 8, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 10, padding: 10, gap: 8, }, listItemTop: { flexDirection: 'row', gap: 8, }, itemTitle: { fontWeight: '700', color: '#0f172a', fontSize: 15, }, activeTripName: { fontSize: 16, fontWeight: '700', color: '#0f172a', }, metaText: { color: '#64748b', fontSize: 13, }, grow: { flex: 1, }, rowGap: { gap: 8, marginTop: 6, }, previewImage: { width: '100%', height: 140, borderRadius: 10, marginTop: 8, backgroundColor: '#e2e8f0', }, smallActionBtn: { backgroundColor: '#e2e8f0', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 10, }, smallActionBtnText: { color: '#1e293b', fontWeight: '700', fontSize: 12, }, itemActionsColumn: { gap: 6, }, inlineToggle: { marginTop: 10, }, inlineToggleText: { color: '#1e293b', }, checkupItem: { marginTop: 12, borderTopWidth: 1, borderTopColor: '#e2e8f0', paddingTop: 10, }, snapshotList: { borderTopWidth: 1, borderTopColor: '#e2e8f0', paddingTop: 8, gap: 6, }, snapshotRow: { paddingVertical: 4, }, snapshotName: { color: '#0f172a', fontWeight: '600', }, loadingWrap: { flex: 1, justifyContent: 'center', alignItems: 'center', }, loadingText: { color: '#334155', }, });