import React, { useEffect, useMemo, useRef, useState } from 'react'; import { KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, StatusBar as RNStatusBar, Text, View } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import { StatusBar } from 'expo-status-bar'; import BottomTab from './components/BottomTab'; import TripPicker from './components/TripPicker'; import DatePickerModal from './components/DatePickerModal'; import AppDialogModal from './components/AppDialogModal'; import ItemModal from './modals/ItemModal'; import CheckupFlowModal from './modals/CheckupFlowModal'; import BackupModal from './modals/BackupModal'; import TripsTab from './tabs/TripsTab'; import ItemsTab from './tabs/ItemsTab'; import CheckupTab from './tabs/CheckupTab'; import HistoryTab from './tabs/HistoryTab'; import { emptyData, STORAGE_KEY } from './constants'; import { findBestTripId, makeId, parseYMD, todayYMD } from './utils/date'; import { styles } from './styles'; const emptyTripForm = () => ({ name: '', location: '', startDate: todayYMD(), endDate: todayYMD(), copyDefaultTemplate: true, setAsDefaultTemplate: false, }); const emptyItemForm = () => ({ id: null, name: '', description: '', category: '', status: 'unpacked', placement: 'suitcase', lentTo: '', imageUri: '', imageQuality: 'balanced', imageAllowCrop: false, }); const emptyCheckupNoForm = () => ({ status: 'unpacked', placement: 'suitcase', lentTo: '', updateMasterList: false, }); function buildCheckupSession(items) { return items.map((item) => ({ itemId: item.id, name: item.name, category: item.category, current: { status: item.status, placement: item.placement, lentTo: item.lentTo || '', }, confirmed: false, result: 'pending', })); } export default function AppRoot() { const scrollRef = useRef(null); const [loaded, setLoaded] = useState(false); const [fakeLoadDone, setFakeLoadDone] = useState(false); const [fakeLoadProgress, setFakeLoadProgress] = useState(0); const [tab, setTab] = useState('trips'); const [data, setData] = useState(emptyData); const [selectedTripId, setSelectedTripId] = useState(null); const [tripForm, setTripForm] = useState(emptyTripForm()); const [datePicker, setDatePicker] = useState({ visible: false, field: 'startDate' }); const [itemModalVisible, setItemModalVisible] = useState(false); const [itemForm, setItemForm] = useState(emptyItemForm()); const [checkupSession, setCheckupSession] = useState([]); const [checkupFlowVisible, setCheckupFlowVisible] = useState(false); const [checkupFlowIndex, setCheckupFlowIndex] = useState(0); const [checkupFlowMode, setCheckupFlowMode] = useState('question'); const [checkupNoForm, setCheckupNoForm] = useState(emptyCheckupNoForm()); const [selectedCheckupId, setSelectedCheckupId] = useState(null); const [dialogState, setDialogState] = useState({ visible: false, title: '', message: '', buttons: [] }); const [backupModalVisible, setBackupModalVisible] = useState(false); const [backupImportText, setBackupImportText] = useState(''); const topInset = Platform.OS === 'android' ? (RNStatusBar.currentHeight || 0) + 10 : 0; const fakeLoadTotalMs = useMemo(() => 1200 + Math.floor(Math.random() * 2801), []); const appReady = loaded && fakeLoadDone; const visibleTrips = useMemo(() => data.trips.filter((trip) => !trip.archived), [data.trips]); const selectedTrip = useMemo(() => visibleTrips.find((trip) => trip.id === selectedTripId) || null, [visibleTrips, 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] ); 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]); const checkupCurrentEntry = useMemo(() => { if (!checkupFlowVisible) return null; if (checkupFlowIndex >= checkupSession.length) return null; return checkupSession[checkupFlowIndex] || null; }, [checkupFlowVisible, checkupFlowIndex, checkupSession]); function closeDialog() { setDialogState((prev) => ({ ...prev, visible: false })); } function showAlert(title, message) { setDialogState({ visible: true, title, message, buttons: [{ text: 'OK', tone: 'primary', onPress: closeDialog }], }); } function showConfirm({ title, message, confirmText = 'Confirm', onConfirm, tone = 'danger' }) { setDialogState({ visible: true, title, message, buttons: [ { text: 'Cancel', tone: 'neutral', onPress: closeDialog }, { text: confirmText, tone, onPress: () => { closeDialog(); if (typeof onConfirm === 'function') onConfirm(); }, }, ], }); } useEffect(() => { (async () => { try { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); setData({ ...emptyData, ...parsed }); } } catch { showAlert('Error', 'Could not load local data.'); } finally { setLoaded(true); } })(); }, []); useEffect(() => { const startedAt = Date.now(); const interval = setInterval(() => { const elapsed = Date.now() - startedAt; setFakeLoadProgress(Math.min(1, elapsed / fakeLoadTotalMs)); }, 60); const timeout = setTimeout(() => { setFakeLoadProgress(1); setFakeLoadDone(true); clearInterval(interval); }, fakeLoadTotalMs); return () => { clearInterval(interval); clearTimeout(timeout); }; }, [fakeLoadTotalMs]); useEffect(() => { if (!loaded) return; AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data)).catch(() => { showAlert('Error', 'Could not save local data.'); }); }, [data, loaded]); useEffect(() => { if (!loaded) return; if (!visibleTrips.length) { setSelectedTripId(null); return; } if (selectedTripId && visibleTrips.some((trip) => trip.id === selectedTripId)) { return; } const bestTripId = findBestTripId(visibleTrips); setSelectedTripId(bestTripId || visibleTrips[0].id); }, [visibleTrips, selectedTripId, loaded]); useEffect(() => { if (tab !== 'checkup') return; createFreshCheckupSession(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedTripId, selectedTripItems.length]); function updateTripForm(field, value) { setTripForm((prev) => ({ ...prev, [field]: value })); } function updateItemForm(field, value) { setItemForm((prev) => ({ ...prev, [field]: value })); } function openDatePicker(field) { setDatePicker({ visible: true, field }); } function onSelectDate(ymd) { updateTripForm(datePicker.field, ymd); setDatePicker((prev) => ({ ...prev, visible: false })); } async function pickImage(onPicked, options = {}) { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { showAlert('Permission needed', 'Allow gallery access to select images.'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: !!options.allowCrop, quality: typeof options.quality === 'number' ? options.quality : 0.85, }); if (!result.canceled && result.assets?.[0]?.uri) { onPicked(result.assets[0].uri); } } async function takeImage(onPicked, options = {}) { const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { showAlert('Permission needed', 'Allow camera access to take photos.'); return; } const result = await ImagePicker.launchCameraAsync({ allowsEditing: !!options.allowCrop, quality: typeof options.quality === 'number' ? options.quality : 0.85, }); if (!result.canceled && result.assets?.[0]?.uri) { onPicked(result.assets[0].uri); } } function createTrip() { if (!tripForm.name.trim()) { showAlert('Missing name', 'Trip name is required.'); return false; } const start = parseYMD(tripForm.startDate); const end = parseYMD(tripForm.endDate); if (!start || !end) { showAlert('Invalid dates', 'Please select valid trip dates.'); return false; } if (start > end) { showAlert('Invalid dates', 'Start date cannot be after end date.'); return false; } const now = Date.now(); const tripId = makeId('trip'); setData((prev) => { const next = { ...prev, trips: [ ...prev.trips, { id: tripId, name: tripForm.name.trim(), location: tripForm.location.trim(), startDate: tripForm.startDate, endDate: tripForm.endDate, imageUri: '', archived: false, createdAt: now, updatedAt: now, }, ], itemsByTrip: { ...prev.itemsByTrip, [tripId]: [] }, checkupsByTrip: { ...prev.checkupsByTrip, [tripId]: [] }, }; if (tripForm.copyDefaultTemplate && prev.defaultTemplateTripId) { const templateItems = prev.itemsByTrip[prev.defaultTemplateTripId] || []; next.itemsByTrip[tripId] = templateItems.map((item) => ({ ...item, id: makeId('item'), createdAt: now, updatedAt: now, })); } if (tripForm.setAsDefaultTemplate) { next.defaultTemplateTripId = tripId; } return next; }); setSelectedTripId(tripId); setTripForm(emptyTripForm()); return true; } function setTripAsTemplate(tripId) { setData((prev) => ({ ...prev, defaultTemplateTripId: tripId })); } function saveTripEdits(tripId, patch) { if (!patch.name.trim()) { showAlert('Missing name', 'Trip name is required.'); return false; } const start = parseYMD(patch.startDate); const end = parseYMD(patch.endDate); if (!start || !end) { showAlert('Invalid dates', 'Please select valid trip dates.'); return false; } if (start > end) { showAlert('Invalid dates', 'Start date cannot be after end date.'); return false; } setData((prev) => ({ ...prev, trips: prev.trips.map((trip) => trip.id === tripId ? { ...trip, name: patch.name.trim(), location: patch.location.trim(), startDate: patch.startDate, endDate: patch.endDate, updatedAt: Date.now(), } : trip ), })); return true; } function setTripArchived(tripId, archived) { setData((prev) => ({ ...prev, trips: prev.trips.map((trip) => trip.id === tripId ? { ...trip, archived, archivedAt: archived ? Date.now() : null, updatedAt: Date.now(), } : trip ), defaultTemplateTripId: archived && prev.defaultTemplateTripId === tripId ? null : prev.defaultTemplateTripId, })); } function deleteTrip(tripId) { showConfirm({ title: 'Delete trip?', message: 'Trip items and check-up history will also be deleted.', confirmText: 'Delete', tone: 'danger', onConfirm: () => { setData((prev) => { const trips = prev.trips.filter((trip) => trip.id !== tripId); const itemsByTrip = { ...prev.itemsByTrip }; const checkupsByTrip = { ...prev.checkupsByTrip }; delete itemsByTrip[tripId]; delete checkupsByTrip[tripId]; return { ...prev, trips, itemsByTrip, checkupsByTrip, defaultTemplateTripId: prev.defaultTemplateTripId === tripId ? null : prev.defaultTemplateTripId, }; }); }, }); } function openAddItemModal() { setItemForm(emptyItemForm()); setItemModalVisible(true); } function openEditItemModal(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 || '', imageQuality: item.imageQuality || 'balanced', imageAllowCrop: !!item.imageAllowCrop, }); setItemModalVisible(true); } function saveItemFromModal() { if (!selectedTripId) { showAlert('No trip selected', 'Please select or create a trip first.'); return; } if (!itemForm.name.trim()) { showAlert('Missing name', 'Item name is required.'); return; } const now = Date.now(); setData((prev) => { const items = prev.itemsByTrip[selectedTripId] || []; const existingCreatedAt = items.find((item) => item.id === itemForm.id)?.createdAt || now; const nextItem = { 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, imageQuality: itemForm.imageQuality, imageAllowCrop: itemForm.imageAllowCrop, createdAt: existingCreatedAt, updatedAt: now, }; const nextItems = itemForm.id ? items.map((item) => (item.id === itemForm.id ? nextItem : item)) : [...items, nextItem]; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: nextItems, }, }; }); setItemModalVisible(false); setItemForm(emptyItemForm()); } function deleteItem(itemId) { setData((prev) => { const items = prev.itemsByTrip[selectedTripId] || []; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: items.filter((item) => item.id !== itemId), }, }; }); } function quickSetItemStatus(itemId, status) { if (!selectedTripId) return; setData((prev) => { const items = prev.itemsByTrip[selectedTripId] || []; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: items.map((item) => item.id === itemId ? { ...item, status, lentTo: status === 'lent-to' ? item.lentTo : '', updatedAt: Date.now(), } : item ), }, }; }); } function bulkSetItemStatus(itemIds, status) { if (!selectedTripId || !itemIds.length) return; const idSet = new Set(itemIds); setData((prev) => { const items = prev.itemsByTrip[selectedTripId] || []; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: items.map((item) => idSet.has(item.id) ? { ...item, status, lentTo: status === 'lent-to' ? item.lentTo : '', updatedAt: Date.now(), } : item ), }, }; }); } function deleteCheckup(checkupId) { if (!selectedTripId) return; showConfirm({ title: 'Delete check-up?', message: 'This snapshot will be removed from history.', confirmText: 'Delete', tone: 'danger', onConfirm: () => { setData((prev) => { const existing = prev.checkupsByTrip[selectedTripId] || []; return { ...prev, checkupsByTrip: { ...prev.checkupsByTrip, [selectedTripId]: existing.filter((checkup) => checkup.id !== checkupId), }, }; }); setSelectedCheckupId((prev) => (prev === checkupId ? null : prev)); }, }); } function createFreshCheckupSession() { if (!selectedTripItems.length) { setCheckupSession([]); return; } setCheckupSession(buildCheckupSession(selectedTripItems)); } function startCheckupFlow() { if (!selectedTripId) { showAlert('No trip selected', 'Please select a trip first.'); return; } if (!selectedTripItems.length) { showAlert('No items', 'Add items before starting a check-up.'); return; } const fresh = buildCheckupSession(selectedTripItems); setCheckupSession(fresh); setCheckupFlowIndex(0); setCheckupFlowMode('question'); setCheckupNoForm(emptyCheckupNoForm()); setCheckupFlowVisible(true); } function closeCheckupFlow() { setCheckupFlowVisible(false); setCheckupFlowMode('question'); setCheckupNoForm(emptyCheckupNoForm()); } function goNextInCheckup() { setCheckupFlowIndex((prev) => prev + 1); setCheckupFlowMode('question'); setCheckupNoForm(emptyCheckupNoForm()); } function goBackInCheckup() { setCheckupFlowIndex((prev) => Math.max(0, prev - 1)); setCheckupFlowMode('question'); setCheckupNoForm(emptyCheckupNoForm()); } function skipCurrentCheckupItem() { if (!checkupCurrentEntry) return; setCheckupSession((prev) => prev.map((x) => x.itemId === checkupCurrentEntry.itemId ? { ...x, confirmed: false, result: 'pending', } : x ) ); goNextInCheckup(); } function answerCurrentCheckupYes() { const entry = checkupCurrentEntry; if (!entry) return; setCheckupSession((prev) => prev.map((x) => (x.itemId === entry.itemId ? { ...x, confirmed: true, result: 'correct' } : x)) ); goNextInCheckup(); } function openCurrentCheckupNo() { const entry = checkupCurrentEntry; if (!entry) return; setCheckupNoForm({ status: entry.current.status || 'unpacked', placement: entry.current.placement || 'suitcase', lentTo: entry.current.lentTo || '', updateMasterList: false, }); setCheckupFlowMode('edit'); } function saveCurrentCheckupNo() { const entry = checkupCurrentEntry; if (!entry) return; const patch = { status: checkupNoForm.status, placement: checkupNoForm.placement, lentTo: checkupNoForm.status === 'lent-to' ? checkupNoForm.lentTo.trim() : '', }; setCheckupSession((prev) => prev.map((x) => x.itemId === entry.itemId ? { ...x, current: patch, confirmed: true, result: 'bad', } : x ) ); if (checkupNoForm.updateMasterList && selectedTripId) { setData((prev) => { const items = prev.itemsByTrip[selectedTripId] || []; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, [selectedTripId]: items.map((item) => item.id === entry.itemId ? { ...item, status: patch.status, placement: patch.placement, lentTo: patch.lentTo, updatedAt: Date.now(), } : item ), }, }; }); } goNextInCheckup(); } function saveCheckupSnapshot(sessionToSave) { if (!selectedTripId) { showAlert('No trip selected', 'Please select a trip first.'); return false; } if (!sessionToSave.length) { showAlert('No items', 'Add items before creating a check-up.'); return false; } const pending = sessionToSave.filter((entry) => !entry.confirmed).length; if (pending > 0) { showAlert('Incomplete', `Please confirm all items first (${pending} remaining).`); return false; } const snapshot = sessionToSave.map((entry) => ({ itemId: entry.itemId, name: entry.name, category: entry.category, status: entry.current.status, placement: entry.current.placement, lentTo: entry.current.status === 'lent-to' ? entry.current.lentTo : '', result: entry.result || 'pending', })); setData((prev) => { const existing = prev.checkupsByTrip[selectedTripId] || []; return { ...prev, checkupsByTrip: { ...prev.checkupsByTrip, [selectedTripId]: [ ...existing, { id: makeId('checkup'), createdAt: Date.now(), snapshot, stats: { correct: snapshot.filter((entry) => entry.result === 'correct').length, bad: snapshot.filter((entry) => entry.result === 'bad').length, }, }, ], }, }; }); return true; } function finishCheckupFlow() { const ok = saveCheckupSnapshot(checkupSession); if (!ok) return; showAlert('Saved', 'Check-up snapshot saved.'); closeCheckupFlow(); createFreshCheckupSession(); } function onInputFocus(event) { const target = event?.target; if (!target) return; setTimeout(() => { const scrollFn = scrollRef.current?.scrollResponderScrollNativeHandleToKeyboard; if (typeof scrollFn === 'function') { scrollFn(target, 90, true); } }, 80); } function openQuickAddItemFromNav() { if (!selectedTripId) { showAlert('No trip selected', 'Please select or create a trip first.'); return; } openAddItemModal(); } function buildBackupJson() { return JSON.stringify( { version: 2, exportedAt: new Date().toISOString(), data, }, null, 2 ); } function openBackupModal() { setBackupImportText(''); setBackupModalVisible(true); } function applyBackupImport() { if (!backupImportText.trim()) { showAlert('Missing backup', 'Paste backup JSON first.'); return; } let parsed; try { parsed = JSON.parse(backupImportText); } catch { showAlert('Invalid JSON', 'Backup JSON could not be parsed.'); return; } const payload = parsed?.data && typeof parsed.data === 'object' ? parsed.data : parsed; if (!payload || typeof payload !== 'object' || !Array.isArray(payload.trips) || !payload.itemsByTrip || !payload.checkupsByTrip) { showAlert('Invalid backup', 'Backup format is not supported.'); return; } showConfirm({ title: 'Import backup?', message: 'This will replace all current local data.', confirmText: 'Import', tone: 'danger', onConfirm: () => { setData({ ...emptyData, ...payload }); setBackupModalVisible(false); setBackupImportText(''); showAlert('Imported', 'Backup data was restored.'); }, }); } if (!appReady) { return ( 🧳 Packing your list... {Math.round(fakeLoadProgress * 100)}% ); } return ( {tab === 'trips' && ( )} {tab === 'items' && ( )} {tab === 'checkup' && ( )} {tab === 'history' && ( )} setDatePicker((prev) => ({ ...prev, visible: false }))} onSelect={onSelectDate} /> pickImage((uri) => updateItemForm('imageUri', uri), options)} takeItemImage={(options) => takeImage((uri) => updateItemForm('imageUri', uri), options)} saveItemFromModal={saveItemFromModal} /> setBackupModalVisible(false)} exportJson={buildBackupJson()} importJson={backupImportText} setImportJson={setBackupImportText} applyImport={applyBackupImport} /> ); }