diff --git a/App.js b/App.js index 41ba7a2..dc27c69 100644 --- a/App.js +++ b/App.js @@ -1,6 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, + KeyboardAvoidingView, + Modal, + Platform, Pressable, SafeAreaView, ScrollView, @@ -12,15 +15,22 @@ import { } 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 STORAGE_KEY = 'luggage-list:v2'; +const TAB_BAR_HEIGHT = 72; const ITEM_STATUSES = ['packed', 'unpacked', 'lost', 'left-behind', 'lent-to']; const ITEM_PLACEMENTS = ['suitcase', 'backpack', 'with-user', 'other']; +const STATUS_COLORS = { + packed: '#22c55e', + unpacked: '#64748b', + lost: '#ef4444', + 'left-behind': '#f59e0b', + 'lent-to': '#8b5cf6', +}; + const emptyData = { trips: [], itemsByTrip: {}, @@ -28,16 +38,34 @@ const emptyData = { defaultTemplateTripId: null, }; +const emptyTripForm = () => ({ + name: '', + location: '', + startDate: todayYMD(), + endDate: todayYMD(), + imageUri: '', + copyDefaultTemplate: true, + setAsDefaultTemplate: false, +}); + +const emptyItemForm = () => ({ + id: null, + name: '', + description: '', + category: '', + status: 'unpacked', + placement: 'suitcase', + lentTo: '', + imageUri: '', +}); + 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}`; + return `${now.getFullYear()}-${`${now.getMonth() + 1}`.padStart(2, '0')}-${`${now.getDate()}`.padStart(2, '0')}`; } function parseYMD(value) { @@ -46,37 +74,15 @@ function parseYMD(value) { return Number.isNaN(d.getTime()) ? null : d; } -function findAutoActiveTrip(trips) { +function findActiveTripId(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 !!start && !!end && today >= start && today <= end; }); - - return active || null; -} - -function ChipGroup({ options, value, onChange }) { - return ( - - {options.map((option) => { - const active = value === option; - return ( - onChange(option)} - > - {option} - - ); - })} - - ); + return active?.id || null; } function Field({ label, children }) { @@ -88,94 +94,72 @@ function Field({ label, children }) { ); } -function Card({ title, children, right }) { +function ChipGroup({ options, value, onChange }) { return ( - - - {title} - {right} + + {options.map((option) => { + const active = value === option; + return ( + onChange(option)}> + {option} + + ); + })} + + ); +} + +function BottomTab({ current, onChange }) { + const tabs = [ + { key: 'trips', label: 'Trips' }, + { key: 'items', label: 'Items' }, + { key: 'checkup', label: 'Check-Up' }, + { key: 'history', label: 'History' }, + ]; + + return ( + + + {tabs.map((tab) => { + const active = current === tab.key; + return ( + onChange(tab.key)} style={styles.tabItem}> + + {tab.label} + + ); + })} - {children} ); } export default function App() { - const [data, setData] = useState(emptyData); + const scrollRef = useRef(null); + const [loaded, setLoaded] = useState(false); const [tab, setTab] = useState('trips'); + const [data, setData] = useState(emptyData); + const [selectedTripId, setSelectedTripId] = useState(null); + const [tripForm, setTripForm] = useState(emptyTripForm()); - const [tripForm, setTripForm] = useState({ - name: '', - location: '', - startDate: todayYMD(), - endDate: todayYMD(), - imageUri: '', - copyDefaultTemplate: true, - setAsDefaultTemplate: false, - }); + const [itemModalVisible, setItemModalVisible] = useState(false); + const [itemForm, setItemForm] = useState(emptyItemForm()); - const [itemForm, setItemForm] = useState({ - id: null, - name: '', - description: '', - category: '', + const [checkupSession, setCheckupSession] = useState([]); + const [checkupFixModalVisible, setCheckupFixModalVisible] = useState(false); + const [checkupFixTargetId, setCheckupFixTargetId] = useState(null); + const [checkupFixForm, setCheckupFixForm] = useState({ status: 'unpacked', placement: 'suitcase', lentTo: '', - imageUri: '', + updateMasterList: false, }); - 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 selectedTrip = useMemo(() => data.trips.find((trip) => trip.id === selectedTripId) || null, [data.trips, selectedTripId]); const selectedTripItems = useMemo(() => { if (!selectedTripId) return []; @@ -192,22 +176,52 @@ export default function App() { [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.'); + useEffect(() => { + (async () => { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + setData({ ...emptyData, ...parsed }); + } + } catch { + Alert.alert('Error', 'Could not load local data.'); + } finally { + setLoaded(true); + } + })(); + }, []); + + useEffect(() => { + if (!loaded) return; + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data)).catch(() => { + Alert.alert('Error', 'Could not save local data.'); + }); + }, [data, loaded]); + + useEffect(() => { + if (!loaded) return; + if (!data.trips.length) { + setSelectedTripId(null); 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); + if (selectedTripId && data.trips.some((trip) => trip.id === selectedTripId)) { + return; } + + const activeTripId = findActiveTripId(data.trips); + setSelectedTripId(activeTripId || data.trips[0].id); + }, [data.trips, selectedTripId, loaded]); + + useEffect(() => { + if (tab !== 'checkup') return; + createFreshCheckupSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTripId, selectedTripItems.length]); + + function chooseTrip(tripId) { + setSelectedTripId(tripId); } function updateTripForm(field, value) { @@ -218,50 +232,88 @@ export default function App() { setItemForm((prev) => ({ ...prev, [field]: value })); } + 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 || '', + }); + setItemModalVisible(true); + } + + async function pickImage(setter) { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert('Permission needed', 'Allow gallery access to select images.'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 0.85, + }); + + if (!result.canceled && result.assets?.[0]?.uri) { + setter(result.assets[0].uri); + } + } + 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); + const start = parseYMD(tripForm.startDate); + const end = parseYMD(tripForm.endDate); - if (!startDate || !endDate) { - Alert.alert('Date format', 'Please use YYYY-MM-DD for dates.'); + if (!start || !end) { + Alert.alert('Invalid dates', 'Use YYYY-MM-DD format.'); return; } - if (startDate > endDate) { - Alert.alert('Dates invalid', 'Start date cannot be after end date.'); + if (start > end) { + Alert.alert('Invalid dates', '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, - }; + const tripId = makeId('trip'); setData((prev) => { const next = { ...prev, - trips: [...prev.trips, newTrip], - itemsByTrip: { ...prev.itemsByTrip, [newTripId]: [] }, - checkupsByTrip: { ...prev.checkupsByTrip, [newTripId]: [] }, + trips: [ + ...prev.trips, + { + id: tripId, + name: tripForm.name.trim(), + location: tripForm.location.trim(), + startDate: tripForm.startDate, + endDate: tripForm.endDate, + imageUri: tripForm.imageUri, + createdAt: now, + updatedAt: now, + }, + ], + itemsByTrip: { ...prev.itemsByTrip, [tripId]: [] }, + checkupsByTrip: { ...prev.checkupsByTrip, [tripId]: [] }, }; - if (tripForm.copyDefaultTemplate && prev.defaultTemplateTripId && prev.defaultTemplateTripId !== newTripId) { - const source = prev.itemsByTrip[prev.defaultTemplateTripId] || []; - next.itemsByTrip[newTripId] = source.map((item) => ({ + if (tripForm.copyDefaultTemplate && prev.defaultTemplateTripId) { + const templateItems = prev.itemsByTrip[prev.defaultTemplateTripId] || []; + next.itemsByTrip[tripId] = templateItems.map((item) => ({ ...item, id: makeId('item'), createdAt: now, @@ -270,22 +322,14 @@ export default function App() { } if (tripForm.setAsDefaultTemplate) { - next.defaultTemplateTripId = newTripId; + next.defaultTemplateTripId = tripId; } return next; }); - setSelectedTripId(newTripId); - setTripForm({ - name: '', - location: '', - startDate: todayYMD(), - endDate: todayYMD(), - imageUri: '', - copyDefaultTemplate: true, - setAsDefaultTemplate: false, - }); + setSelectedTripId(tripId); + setTripForm(emptyTripForm()); } function setTripAsTemplate(tripId) { @@ -293,24 +337,24 @@ export default function App() { } function deleteTrip(tripId) { - Alert.alert('Delete trip?', 'This removes the trip, its items, and its check-up history.', [ + Alert.alert('Delete trip?', 'Trip items and check-up history will also be deleted.', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: () => { setData((prev) => { - const nextTrips = prev.trips.filter((trip) => trip.id !== tripId); - const nextItemsByTrip = { ...prev.itemsByTrip }; - const nextCheckupsByTrip = { ...prev.checkupsByTrip }; - delete nextItemsByTrip[tripId]; - delete nextCheckupsByTrip[tripId]; + 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: nextTrips, - itemsByTrip: nextItemsByTrip, - checkupsByTrip: nextCheckupsByTrip, + trips, + itemsByTrip, + checkupsByTrip, defaultTemplateTripId: prev.defaultTemplateTripId === tripId ? null : prev.defaultTemplateTripId, }; }); @@ -319,9 +363,9 @@ export default function App() { ]); } - function saveItem() { + function saveItemFromModal() { if (!selectedTripId) { - Alert.alert('No trip', 'Create or select a trip first.'); + Alert.alert('No trip selected', 'Please select or create a trip first.'); return; } @@ -333,8 +377,9 @@ export default function App() { const now = Date.now(); setData((prev) => { - const currentItems = prev.itemsByTrip[selectedTripId] || []; - const normalized = { + 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(), @@ -343,13 +388,13 @@ export default function App() { 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, + createdAt: existingCreatedAt, updatedAt: now, }; const nextItems = itemForm.id - ? currentItems.map((item) => (item.id === itemForm.id ? normalized : item)) - : [...currentItems, normalized]; + ? items.map((item) => (item.id === itemForm.id ? nextItem : item)) + : [...items, nextItem]; return { ...prev, @@ -360,127 +405,148 @@ export default function App() { }; }); - 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'); + setItemModalVisible(false); + setItemForm(emptyItemForm()); } function deleteItem(itemId) { setData((prev) => { - const currentItems = prev.itemsByTrip[selectedTripId] || []; + const items = prev.itemsByTrip[selectedTripId] || []; return { ...prev, itemsByTrip: { ...prev.itemsByTrip, - [selectedTripId]: currentItems.filter((item) => item.id !== itemId), + [selectedTripId]: items.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; - } - + function createFreshCheckupSession() { if (!selectedTripItems.length) { - Alert.alert('No items', 'Add at least one luggage item first.'); + setCheckupSession([]); 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 || '') : '', - }; + const fresh = selectedTripItems.map((item) => ({ + itemId: item.id, + name: item.name, + category: item.category, + current: { + status: item.status, + placement: item.placement, + lentTo: item.lentTo || '', + }, + confirmed: false, + })); + + setCheckupSession(fresh); + } + + function answerCheckupYes(itemId) { + setCheckupSession((prev) => prev.map((entry) => (entry.itemId === itemId ? { ...entry, confirmed: true } : entry))); + } + + function openFixModal(itemId) { + const entry = checkupSession.find((x) => x.itemId === itemId); + if (!entry) return; + + setCheckupFixTargetId(itemId); + setCheckupFixForm({ + status: entry.current.status || 'unpacked', + placement: entry.current.placement || 'suitcase', + lentTo: entry.current.lentTo || '', + updateMasterList: false, }); + setCheckupFixModalVisible(true); + } - setData((prev) => { - const currentCheckups = prev.checkupsByTrip[selectedTripId] || []; - const currentItems = prev.itemsByTrip[selectedTripId] || []; + function saveFixModal() { + if (!checkupFixTargetId) return; - const nextItems = currentItems.map((item) => { - const snap = snapshot.find((x) => x.itemId === item.id); - if (!snap) return item; + const targetId = checkupFixTargetId; + const patch = { + status: checkupFixForm.status, + placement: checkupFixForm.placement, + lentTo: checkupFixForm.status === 'lent-to' ? checkupFixForm.lentTo.trim() : '', + }; + + setCheckupSession((prev) => + prev.map((entry) => + entry.itemId === targetId + ? { + ...entry, + current: patch, + confirmed: true, + } + : entry + ) + ); + + if (checkupFixForm.updateMasterList && selectedTripId) { + setData((prev) => { + const items = prev.itemsByTrip[selectedTripId] || []; return { - ...item, - status: snap.status, - placement: snap.placement, - lentTo: snap.lentTo, - updatedAt: now, + ...prev, + itemsByTrip: { + ...prev.itemsByTrip, + [selectedTripId]: items.map((item) => + item.id === targetId + ? { + ...item, + status: patch.status, + placement: patch.placement, + lentTo: patch.lentTo, + updatedAt: Date.now(), + } + : item + ), + }, }; }); + } + setCheckupFixModalVisible(false); + setCheckupFixTargetId(null); + } + + function saveCheckup() { + if (!selectedTripId) { + Alert.alert('No trip selected', 'Please select a trip first.'); + return; + } + + if (!checkupSession.length) { + Alert.alert('No items', 'Add items before creating a check-up.'); + return; + } + + const pending = checkupSession.filter((entry) => !entry.confirmed).length; + if (pending > 0) { + Alert.alert('Incomplete', `Please confirm all items first (${pending} remaining).`); + return; + } + + const snapshot = checkupSession.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 : '', + })); + + setData((prev) => { + const existing = prev.checkupsByTrip[selectedTripId] || []; return { ...prev, - itemsByTrip: { - ...prev.itemsByTrip, - [selectedTripId]: nextItems, - }, checkupsByTrip: { ...prev.checkupsByTrip, [selectedTripId]: [ - ...currentCheckups, + ...existing, { id: makeId('checkup'), - createdAt: now, + createdAt: Date.now(), snapshot, }, ], @@ -488,48 +554,26 @@ export default function App() { }; }); - Alert.alert('Saved', 'Check-up snapshot stored.'); + Alert.alert('Saved', 'Check-up snapshot saved.'); + createFreshCheckupSession(); } - 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 statusAccent(status) { + return STATUS_COLORS[status] || '#64748b'; } - function formatDateTime(ts) { - return new Date(ts).toLocaleString(); + function focusToEnd() { + setTimeout(() => { + scrollRef.current?.scrollToEnd?.({ animated: true }); + }, 80); } if (!loaded) { return ( - - - Loading local data... + + + Loading local data... ); @@ -537,352 +581,390 @@ export default function App() { 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. - )} - + + + {data.trips.length ? ( + data.trips + .slice() + .sort((a, b) => b.startDate.localeCompare(a.startDate)) + .map((trip) => { + const active = selectedTripId === trip.id; + return ( + chooseTrip(trip.id)}> + {trip.name} + {trip.startDate} + + ); + }) + ) : ( + 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" - /> - + {tab === 'trips' && ( + + Trips + + + + updateTripForm('name', v)} + placeholder="Summer Weekend" + placeholderTextColor="#6b7280" + onFocus={focusToEnd} + /> + + + + updateTripForm('location', v)} + placeholder="Berlin" + placeholderTextColor="#6b7280" + onFocus={focusToEnd} + /> + + + + updateTripForm('startDate', v)} + placeholderTextColor="#6b7280" + onFocus={focusToEnd} + /> + + + + updateTripForm('endDate', v)} + placeholderTextColor="#6b7280" + onFocus={focusToEnd} + /> + - pickImage((uri) => updateTripForm('imageUri', uri))}> - {tripForm.imageUri ? 'Change Trip Image' : 'Add Trip Image'} + {tripForm.imageUri ? 'Change trip image' : 'Add trip image'} - {tripForm.imageUri ? : null} - - {templateTrip ? ( - updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)} - > + {tripForm.imageUri ? : null} + + {templateTrip ? ( + updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)}> + + {tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template ({templateTrip.name}) + + + ) : null} + + updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)}> - {tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template trip: {templateTrip.name} + {tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set as default template - ) : null} - updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)} - > - - {tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set this trip as default template - - + + Create Trip + + - - 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 - - deleteTrip(trip.id)}> - Delete - + + + + {trip.name} + {trip.location || 'No location'} · {trip.startDate} → {trip.endDate} + + {data.defaultTemplateTripId === trip.id ? 'Default template' : ' '} + + + + chooseTrip(trip.id)}> + Select + + setTripAsTemplate(trip.id)}> + Template + + deleteTrip(trip.id)}> + Delete + + + {trip.imageUri ? : null} ))} - - - )} + + )} - {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" - /> - + {tab === 'items' && ( + + + Luggage Items + + + Add + + + + {!selectedTrip ? ( + Select a trip first. ) : null} - - pickImage((uri) => updateItemForm('imageUri', uri))}> - {itemForm.imageUri ? 'Change Item Image' : 'Add Item Image'} - - {itemForm.imageUri ? : null} - + {selectedTripItems.length === 0 && selectedTrip ? No items yet. : 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.name} + {item.category || 'uncategorized'} · {item.status} + Location: {item.placement} + {item.status === 'lent-to' && !!item.lentTo ? Lent to: {item.lentTo} : null} + {!!item.description ? {item.description} : null} + + + openEditItemModal(item)}> + Edit + + deleteItem(item.id)}> + Delete + + + {item.imageUri ? : null} - {!!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 || ''; + {tab === 'checkup' && ( + + + Check-Up + + Restart + + - 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} - - ); - })} + {checkupSession.length === 0 ? No items for this trip yet. : null} - - - Save Check-Up + {checkupSession.map((entry) => ( + + {entry.name} + {entry.category || 'uncategorized'} + + {entry.current.status} · {entry.current.placement} + {entry.current.status === 'lent-to' && entry.current.lentTo ? ` · ${entry.current.lentTo}` : ''} + + + + answerCheckupYes(entry.itemId)}> + Yes - - Reset + openFixModal(entry.itemId)}> + No + - + + ))} + + {!!checkupSession.length && ( + + Save Check-Up Snapshot + )} - - - )} + + )} - {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; + {tab === 'history' && ( + + History + {selectedTripCheckups.length === 0 ? No check-ups saved yet. : null} - return ( - + {selectedTripCheckups.map((checkup) => ( + 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'} + {new Date(checkup.createdAt).toLocaleString()} + {checkup.snapshot.length} items + {selectedCheckupId === checkup.id ? 'Tap to collapse' : 'Tap to open'} {selectedCheckupId === checkup.id ? ( - + {checkup.snapshot.map((entry) => ( - {entry.name} - - {entry.status} • {entry.placement} - {entry.status === 'lent-to' && entry.lentTo ? ` • ${entry.lentTo}` : ''} + {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 - - - )} - + + + + + + + + {itemForm.id ? 'Update Item' : 'Add Item'} + setItemModalVisible(false)}> + Close + + + + + + updateItemForm('name', v)} + placeholder="Toothbrush" + placeholderTextColor="#6b7280" + /> + + + + updateItemForm('description', v)} + placeholder="Optional" + placeholderTextColor="#6b7280" + /> + + + + updateItemForm('category', v)} + placeholder="toiletries" + placeholderTextColor="#6b7280" + /> + + + + updateItemForm('status', v)} /> + + + + updateItemForm('placement', v)} /> + + + {itemForm.status === 'lent-to' ? ( + + updateItemForm('lentTo', v)} + placeholder="Person name" + placeholderTextColor="#6b7280" + /> + + ) : null} + + pickImage((uri) => updateItemForm('imageUri', uri))}> + {itemForm.imageUri ? 'Change image' : 'Add image'} + + {!!itemForm.imageUri && } + + + {itemForm.id ? 'Save Changes' : 'Add Item'} + + + + + + + + + + + + + Update for this Check-Up + setCheckupFixModalVisible(false)}> + Close + + + + + + setCheckupFixForm((prev) => ({ ...prev, status: v }))} + /> + + + + setCheckupFixForm((prev) => ({ ...prev, placement: v }))} + /> + + + {checkupFixForm.status === 'lent-to' ? ( + + setCheckupFixForm((prev) => ({ ...prev, lentTo: v }))} + placeholder="Person name" + placeholderTextColor="#6b7280" + /> + + ) : null} + + setCheckupFixForm((prev) => ({ ...prev, updateMasterList: !prev.updateMasterList }))} + > + + {checkupFixForm.updateMasterList ? '☑' : '☐'} Also update item in trip list + + + + + Save + + + + + + ); } @@ -890,234 +972,373 @@ export default function App() { const styles = StyleSheet.create({ safe: { flex: 1, - backgroundColor: '#f5f5f7', + backgroundColor: '#090d12', }, - 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', + flex: { + flex: 1, }, content: { - padding: 12, - paddingBottom: 24, + paddingHorizontal: 14, + paddingTop: 12, + paddingBottom: TAB_BAR_HEIGHT + 18, gap: 12, }, - card: { - backgroundColor: '#fff', - borderRadius: 12, - padding: 12, - borderWidth: 1, - borderColor: '#e2e8f0', + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', }, - cardHeader: { + muted: { + color: '#8793a5', + }, + + tripPickerWrap: { + marginBottom: 6, + }, + tripChipScroll: { + gap: 8, + paddingRight: 12, + }, + tripChip: { + paddingHorizontal: 12, + paddingVertical: 9, + borderRadius: 12, + backgroundColor: '#121923', + borderWidth: 1, + borderColor: '#1f2937', + minWidth: 120, + }, + tripChipActive: { + backgroundColor: '#1d2a3a', + borderColor: '#60a5fa', + }, + tripChipText: { + color: '#e2e8f0', + fontWeight: '700', + }, + tripChipTextActive: { + color: '#bfdbfe', + }, + tripChipSub: { + color: '#64748b', + fontSize: 12, + marginTop: 2, + }, + tripChipSubActive: { + color: '#93c5fd', + }, + + section: { + gap: 10, + }, + sectionTitle: { + color: '#f1f5f9', + fontSize: 18, + fontWeight: '700', + }, + sectionRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 8, + gap: 10, + }, + + card: { + backgroundColor: '#111827', + borderRadius: 14, + borderWidth: 1, + borderColor: '#1f2937', + padding: 12, gap: 8, }, - cardTitle: { - fontSize: 17, - fontWeight: '700', - color: '#0f172a', + cardActive: { + borderColor: '#60a5fa', }, + cardSoft: { + backgroundColor: '#0f172a', + borderRadius: 14, + borderWidth: 1, + borderColor: '#1e293b', + padding: 12, + gap: 10, + }, + cardRow: { + flexDirection: 'row', + gap: 10, + }, + cardTitle: { + color: '#f8fafc', + fontWeight: '700', + fontSize: 15, + }, + cardMeta: { + color: '#94a3b8', + marginTop: 3, + fontSize: 13, + }, + fieldWrap: { - marginTop: 8, + gap: 6, }, label: { - marginBottom: 6, - color: '#334155', + color: '#cbd5e1', fontWeight: '600', }, input: { borderWidth: 1, - borderColor: '#cbd5e1', - backgroundColor: '#fff', - paddingHorizontal: 10, - paddingVertical: 10, + borderColor: '#243244', borderRadius: 10, + backgroundColor: '#0b1220', + color: '#e5e7eb', + paddingHorizontal: 10, + paddingVertical: 11, }, + chipGroup: { flexDirection: 'row', flexWrap: 'wrap', - gap: 6, + gap: 7, }, chip: { - borderWidth: 1, - borderColor: '#cbd5e1', borderRadius: 999, - paddingHorizontal: 10, - paddingVertical: 6, - backgroundColor: '#fff', + paddingHorizontal: 11, + paddingVertical: 7, + borderWidth: 1, + borderColor: '#273449', + backgroundColor: '#0b1220', }, chipActive: { - backgroundColor: '#0f172a', - borderColor: '#0f172a', + borderColor: '#60a5fa', + backgroundColor: '#172338', }, chipText: { - color: '#334155', - fontSize: 12, + color: '#cbd5e1', fontWeight: '600', + fontSize: 12, }, chipTextActive: { - color: '#fff', + color: '#bfdbfe', }, + primaryBtn: { - marginTop: 12, - backgroundColor: '#0f172a', + marginTop: 4, borderRadius: 10, - paddingVertical: 10, - paddingHorizontal: 12, + backgroundColor: '#2563eb', alignItems: 'center', + paddingVertical: 11, + paddingHorizontal: 12, + }, + primaryBtnTight: { + borderRadius: 10, + backgroundColor: '#2563eb', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, }, primaryBtnText: { color: '#fff', fontWeight: '700', }, secondaryBtn: { - marginTop: 12, - backgroundColor: '#e2e8f0', + marginTop: 4, borderRadius: 10, - paddingVertical: 10, - paddingHorizontal: 12, + backgroundColor: '#1f2937', alignItems: 'center', + paddingVertical: 11, + paddingHorizontal: 12, + }, + secondaryBtnTight: { + borderRadius: 10, + backgroundColor: '#1f2937', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, }, secondaryBtnText: { - color: '#1e293b', + color: '#dbeafe', fontWeight: '700', }, - actionRow: { - flexDirection: 'row', - gap: 8, - flexWrap: 'wrap', + + inlineToggle: { + marginTop: 2, }, - listItem: { - marginTop: 8, - borderWidth: 1, - borderColor: '#e2e8f0', - borderRadius: 10, - padding: 10, - flexDirection: 'row', - gap: 8, - alignItems: 'center', + inlineToggleText: { + color: '#cbd5e1', }, - listItemStack: { - marginTop: 8, - borderWidth: 1, - borderColor: '#e2e8f0', - borderRadius: 10, - padding: 10, - gap: 8, + + stackButtons: { + gap: 7, }, - 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', + miniBtn: { + backgroundColor: '#1e293b', borderRadius: 8, - paddingVertical: 6, + paddingVertical: 7, paddingHorizontal: 10, }, - smallActionBtnText: { - color: '#1e293b', + miniBtnDanger: { + backgroundColor: '#3b1d22', + borderRadius: 8, + paddingVertical: 7, + paddingHorizontal: 10, + }, + miniBtnText: { + color: '#e2e8f0', fontWeight: '700', fontSize: 12, }, - itemActionsColumn: { - gap: 6, + + itemCard: { + borderRadius: 14, + backgroundColor: '#111827', + borderWidth: 1, + borderColor: '#1f2937', + overflow: 'hidden', + flexDirection: 'row', }, - inlineToggle: { - marginTop: 10, + itemAccent: { + width: 5, }, - inlineToggleText: { - color: '#1e293b', + itemMain: { + flex: 1, + padding: 12, + gap: 8, }, - checkupItem: { - marginTop: 12, + itemTitle: { + color: '#f8fafc', + fontWeight: '700', + fontSize: 15, + }, + itemMeta: { + color: '#94a3b8', + marginTop: 2, + fontSize: 13, + }, + + answerRow: { + marginTop: 8, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + answerYes: { + backgroundColor: '#163223', + borderWidth: 1, + borderColor: '#1f7a4e', + borderRadius: 9, + paddingHorizontal: 16, + paddingVertical: 8, + }, + answerNo: { + backgroundColor: '#3b1d22', + borderWidth: 1, + borderColor: '#7f1d1d', + borderRadius: 9, + paddingHorizontal: 16, + paddingVertical: 8, + }, + answerText: { + color: '#f8fafc', + fontWeight: '700', + }, + answerStateDot: { + width: 10, + height: 10, + borderRadius: 99, + backgroundColor: '#475569', + }, + answerStateDotOn: { + backgroundColor: '#22c55e', + }, + + snapshotWrap: { + marginTop: 8, borderTopWidth: 1, - borderTopColor: '#e2e8f0', - paddingTop: 10, - }, - snapshotList: { - borderTopWidth: 1, - borderTopColor: '#e2e8f0', + borderTopColor: '#1e293b', paddingTop: 8, - gap: 6, + gap: 7, }, snapshotRow: { - paddingVertical: 4, + paddingVertical: 3, }, - snapshotName: { - color: '#0f172a', + snapshotTitle: { + color: '#e2e8f0', fontWeight: '600', }, - loadingWrap: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', + + previewImage: { + width: '100%', + height: 150, + borderRadius: 10, + backgroundColor: '#111827', }, - loadingText: { - color: '#334155', + previewImageSmall: { + width: '100%', + height: 120, + borderRadius: 10, + backgroundColor: '#111827', + }, + + tabBarWrap: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 10, + paddingBottom: Platform.OS === 'ios' ? 14 : 8, + backgroundColor: 'transparent', + }, + tabBar: { + height: TAB_BAR_HEIGHT, + borderRadius: 16, + backgroundColor: '#0b1220', + borderWidth: 1, + borderColor: '#1f2937', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + }, + tabItem: { + alignItems: 'center', + gap: 4, + }, + tabDot: { + width: 6, + height: 6, + borderRadius: 99, + backgroundColor: '#334155', + }, + tabDotActive: { + backgroundColor: '#60a5fa', + }, + tabLabel: { + color: '#94a3b8', + fontSize: 12, + fontWeight: '600', + }, + tabLabelActive: { + color: '#dbeafe', + }, + + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(2,6,23,0.72)', + justifyContent: 'flex-end', + }, + modalKeyboardWrap: { + width: '100%', + }, + modalCard: { + maxHeight: '87%', + backgroundColor: '#0f172a', + borderTopLeftRadius: 18, + borderTopRightRadius: 18, + borderWidth: 1, + borderColor: '#1e293b', + padding: 14, + gap: 8, + }, + closeText: { + color: '#93c5fd', + fontWeight: '700', }, }); diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png index 0db9e07..decb7d7 100644 Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ diff --git a/assets/icon.png b/assets/icon.png index b6a2553..decb7d7 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png index ba5f039..decb7d7 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/package-lock.json b/package-lock.json index ecbcb25..9029412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,7 @@ "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", "expo": "~54.0.33", - "expo-file-system": "~19.0.17", "expo-image-picker": "~17.0.8", - "expo-sharing": "~14.0.7", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-dom": "19.1.0", @@ -4441,15 +4439,6 @@ "node": ">=20.16.0" } }, - "node_modules/expo-sharing": { - "version": "14.0.8", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", - "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", diff --git a/package.json b/package.json index d08675d..6d14160 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,7 @@ "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", "expo": "~54.0.33", - "expo-file-system": "~19.0.17", "expo-image-picker": "~17.0.8", - "expo-sharing": "~14.0.7", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-dom": "19.1.0",