diff --git a/App.js b/App.js
index dc27c69..5d83b63 100644
--- a/App.js
+++ b/App.js
@@ -1,1344 +1,3 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
-import {
- Alert,
- KeyboardAvoidingView,
- Modal,
- Platform,
- 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 { StatusBar } from 'expo-status-bar';
+import AppRoot from './src/AppRoot';
-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: {},
- checkupsByTrip: {},
- 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();
- return `${now.getFullYear()}-${`${now.getMonth() + 1}`.padStart(2, '0')}-${`${now.getDate()}`.padStart(2, '0')}`;
-}
-
-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 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);
- return !!start && !!end && today >= start && today <= end;
- });
- return active?.id || null;
-}
-
-function Field({ label, children }) {
- return (
-
- {label}
- {children}
-
- );
-}
-
-function ChipGroup({ options, value, onChange }) {
- return (
-
- {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}
-
- );
- })}
-
-
- );
-}
-
-export default function App() {
- 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 [itemModalVisible, setItemModalVisible] = useState(false);
- const [itemForm, setItemForm] = useState(emptyItemForm());
-
- const [checkupSession, setCheckupSession] = useState([]);
- const [checkupFixModalVisible, setCheckupFixModalVisible] = useState(false);
- const [checkupFixTargetId, setCheckupFixTargetId] = useState(null);
- const [checkupFixForm, setCheckupFixForm] = useState({
- status: 'unpacked',
- placement: 'suitcase',
- lentTo: '',
- updateMasterList: false,
- });
-
- const [selectedCheckupId, setSelectedCheckupId] = useState(null);
-
- 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]
- );
-
- 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;
- }
-
- 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) {
- setTripForm((prev) => ({ ...prev, [field]: value }));
- }
-
- function updateItemForm(field, value) {
- 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 start = parseYMD(tripForm.startDate);
- const end = parseYMD(tripForm.endDate);
-
- if (!start || !end) {
- Alert.alert('Invalid dates', 'Use YYYY-MM-DD format.');
- return;
- }
-
- if (start > end) {
- Alert.alert('Invalid dates', 'Start date cannot be after end date.');
- return;
- }
-
- 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: tripForm.imageUri,
- 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());
- }
-
- function setTripAsTemplate(tripId) {
- setData((prev) => ({ ...prev, defaultTemplateTripId: tripId }));
- }
-
- function deleteTrip(tripId) {
- 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 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 saveItemFromModal() {
- if (!selectedTripId) {
- Alert.alert('No trip selected', 'Please select or create a trip first.');
- return;
- }
-
- if (!itemForm.name.trim()) {
- Alert.alert('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,
- 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 createFreshCheckupSession() {
- if (!selectedTripItems.length) {
- setCheckupSession([]);
- return;
- }
-
- 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);
- }
-
- function saveFixModal() {
- if (!checkupFixTargetId) return;
-
- 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 {
- ...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,
- checkupsByTrip: {
- ...prev.checkupsByTrip,
- [selectedTripId]: [
- ...existing,
- {
- id: makeId('checkup'),
- createdAt: Date.now(),
- snapshot,
- },
- ],
- },
- };
- });
-
- Alert.alert('Saved', 'Check-up snapshot saved.');
- createFreshCheckupSession();
- }
-
- function statusAccent(status) {
- return STATUS_COLORS[status] || '#64748b';
- }
-
- function focusToEnd() {
- setTimeout(() => {
- scrollRef.current?.scrollToEnd?.({ animated: true });
- }, 80);
- }
-
- if (!loaded) {
- return (
-
-
-
- Loading local data...
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {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' && (
-
- 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 ? : null}
-
- {templateTrip ? (
- updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)}>
-
- {tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template ({templateTrip.name})
-
-
- ) : null}
-
- updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)}>
-
- {tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set as default template
-
-
-
-
- Create Trip
-
-
-
- {data.trips
- .slice()
- .sort((a, b) => b.startDate.localeCompare(a.startDate))
- .map((trip) => (
-
-
-
- {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' && (
-
-
- Luggage Items
-
- + Add
-
-
-
- {!selectedTrip ? (
- Select a trip first.
- ) : null}
-
- {selectedTripItems.length === 0 && selectedTrip ? No items yet. : null}
-
- {selectedTripItems.map((item) => (
-
-
-
-
-
- {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}
-
-
- ))}
-
- )}
-
- {tab === 'checkup' && (
-
-
- Check-Up
-
- Restart
-
-
-
- {checkupSession.length === 0 ? No items for this trip yet. : null}
-
- {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
-
- openFixModal(entry.itemId)}>
- No
-
-
-
-
- ))}
-
- {!!checkupSession.length && (
-
- Save Check-Up Snapshot
-
- )}
-
- )}
-
- {tab === 'history' && (
-
- History
- {selectedTripCheckups.length === 0 ? No check-ups saved yet. : null}
-
- {selectedTripCheckups.map((checkup) => (
-
- setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}>
- {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}` : ''}
-
-
- ))}
-
- ) : null}
-
- ))}
-
- )}
-
-
-
-
-
-
-
-
-
-
- {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
-
-
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- safe: {
- flex: 1,
- backgroundColor: '#090d12',
- },
- flex: {
- flex: 1,
- },
- content: {
- paddingHorizontal: 14,
- paddingTop: 12,
- paddingBottom: TAB_BAR_HEIGHT + 18,
- gap: 12,
- },
- center: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- },
- 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',
- gap: 10,
- },
-
- card: {
- backgroundColor: '#111827',
- borderRadius: 14,
- borderWidth: 1,
- borderColor: '#1f2937',
- padding: 12,
- gap: 8,
- },
- 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: {
- gap: 6,
- },
- label: {
- color: '#cbd5e1',
- fontWeight: '600',
- },
- input: {
- borderWidth: 1,
- borderColor: '#243244',
- borderRadius: 10,
- backgroundColor: '#0b1220',
- color: '#e5e7eb',
- paddingHorizontal: 10,
- paddingVertical: 11,
- },
-
- chipGroup: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 7,
- },
- chip: {
- borderRadius: 999,
- paddingHorizontal: 11,
- paddingVertical: 7,
- borderWidth: 1,
- borderColor: '#273449',
- backgroundColor: '#0b1220',
- },
- chipActive: {
- borderColor: '#60a5fa',
- backgroundColor: '#172338',
- },
- chipText: {
- color: '#cbd5e1',
- fontWeight: '600',
- fontSize: 12,
- },
- chipTextActive: {
- color: '#bfdbfe',
- },
-
- primaryBtn: {
- marginTop: 4,
- borderRadius: 10,
- 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: 4,
- borderRadius: 10,
- backgroundColor: '#1f2937',
- alignItems: 'center',
- paddingVertical: 11,
- paddingHorizontal: 12,
- },
- secondaryBtnTight: {
- borderRadius: 10,
- backgroundColor: '#1f2937',
- alignItems: 'center',
- paddingVertical: 8,
- paddingHorizontal: 12,
- },
- secondaryBtnText: {
- color: '#dbeafe',
- fontWeight: '700',
- },
-
- inlineToggle: {
- marginTop: 2,
- },
- inlineToggleText: {
- color: '#cbd5e1',
- },
-
- stackButtons: {
- gap: 7,
- },
- miniBtn: {
- backgroundColor: '#1e293b',
- borderRadius: 8,
- paddingVertical: 7,
- paddingHorizontal: 10,
- },
- miniBtnDanger: {
- backgroundColor: '#3b1d22',
- borderRadius: 8,
- paddingVertical: 7,
- paddingHorizontal: 10,
- },
- miniBtnText: {
- color: '#e2e8f0',
- fontWeight: '700',
- fontSize: 12,
- },
-
- itemCard: {
- borderRadius: 14,
- backgroundColor: '#111827',
- borderWidth: 1,
- borderColor: '#1f2937',
- overflow: 'hidden',
- flexDirection: 'row',
- },
- itemAccent: {
- width: 5,
- },
- itemMain: {
- flex: 1,
- padding: 12,
- gap: 8,
- },
- 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: '#1e293b',
- paddingTop: 8,
- gap: 7,
- },
- snapshotRow: {
- paddingVertical: 3,
- },
- snapshotTitle: {
- color: '#e2e8f0',
- fontWeight: '600',
- },
-
- previewImage: {
- width: '100%',
- height: 150,
- borderRadius: 10,
- backgroundColor: '#111827',
- },
- 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',
- },
-});
+export default AppRoot;
diff --git a/TODO.md b/TODO.md
index f1bcb95..8f72628 100644
--- a/TODO.md
+++ b/TODO.md
@@ -27,3 +27,5 @@ Improving & Fixing Bugs (V2)
- [x] V1 prototype complete and shipped
- [x] CI adjusted (no `eas init` in workflows)
- [x] V2 redesign + behavior fixes implemented
+- [x] Removed legacy template src folder
+- [x] Rebuilt app into modular `src/` structure (tabs/components/modals/styles/utils)
diff --git a/src/AppRoot.js b/src/AppRoot.js
new file mode 100644
index 0000000..a0ab718
--- /dev/null
+++ b/src/AppRoot.js
@@ -0,0 +1,556 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Alert, KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, 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 ItemModal from './modals/ItemModal';
+import CheckupFixModal from './modals/CheckupFixModal';
+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 { findActiveTripId, makeId, parseYMD, todayYMD } from './utils/date';
+import { styles } from './styles';
+
+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: '',
+});
+
+export default function AppRoot() {
+ 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 [itemModalVisible, setItemModalVisible] = useState(false);
+ const [itemForm, setItemForm] = useState(emptyItemForm());
+
+ const [checkupSession, setCheckupSession] = useState([]);
+ const [checkupFixModalVisible, setCheckupFixModalVisible] = useState(false);
+ const [checkupFixTargetId, setCheckupFixTargetId] = useState(null);
+ const [checkupFixForm, setCheckupFixForm] = useState({
+ status: 'unpacked',
+ placement: 'suitcase',
+ lentTo: '',
+ updateMasterList: false,
+ });
+
+ const [selectedCheckupId, setSelectedCheckupId] = useState(null);
+
+ 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]
+ );
+
+ 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;
+ }
+
+ 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 updateTripForm(field, value) {
+ setTripForm((prev) => ({ ...prev, [field]: value }));
+ }
+
+ function updateItemForm(field, value) {
+ setItemForm((prev) => ({ ...prev, [field]: value }));
+ }
+
+ async function pickImage(onPicked) {
+ 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) {
+ onPicked(result.assets[0].uri);
+ }
+ }
+
+ function createTrip() {
+ if (!tripForm.name.trim()) {
+ Alert.alert('Missing name', 'Trip name is required.');
+ return;
+ }
+
+ const start = parseYMD(tripForm.startDate);
+ const end = parseYMD(tripForm.endDate);
+
+ if (!start || !end) {
+ Alert.alert('Invalid dates', 'Use YYYY-MM-DD format.');
+ return;
+ }
+
+ if (start > end) {
+ Alert.alert('Invalid dates', 'Start date cannot be after end date.');
+ return;
+ }
+
+ 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: tripForm.imageUri,
+ 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());
+ }
+
+ function setTripAsTemplate(tripId) {
+ setData((prev) => ({ ...prev, defaultTemplateTripId: tripId }));
+ }
+
+ function deleteTrip(tripId) {
+ 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 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 || '',
+ });
+ setItemModalVisible(true);
+ }
+
+ function saveItemFromModal() {
+ if (!selectedTripId) {
+ Alert.alert('No trip selected', 'Please select or create a trip first.');
+ return;
+ }
+
+ if (!itemForm.name.trim()) {
+ Alert.alert('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,
+ 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 createFreshCheckupSession() {
+ if (!selectedTripItems.length) {
+ setCheckupSession([]);
+ return;
+ }
+
+ 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);
+ }
+
+ function saveFixModal() {
+ if (!checkupFixTargetId) return;
+
+ 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 {
+ ...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,
+ checkupsByTrip: {
+ ...prev.checkupsByTrip,
+ [selectedTripId]: [
+ ...existing,
+ {
+ id: makeId('checkup'),
+ createdAt: Date.now(),
+ snapshot,
+ },
+ ],
+ },
+ };
+ });
+
+ Alert.alert('Saved', 'Check-up snapshot saved.');
+ createFreshCheckupSession();
+ }
+
+ function focusToEnd() {
+ setTimeout(() => {
+ scrollRef.current?.scrollToEnd?.({ animated: true });
+ }, 80);
+ }
+
+ if (!loaded) {
+ return (
+
+
+
+ Loading local data...
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {tab === 'trips' && (
+ pickImage((uri) => updateTripForm('imageUri', uri))}
+ templateTrip={templateTrip}
+ createTrip={createTrip}
+ trips={data.trips}
+ selectedTripId={selectedTripId}
+ chooseTrip={setSelectedTripId}
+ setTripAsTemplate={setTripAsTemplate}
+ deleteTrip={deleteTrip}
+ focusToEnd={focusToEnd}
+ defaultTemplateTripId={data.defaultTemplateTripId}
+ />
+ )}
+
+ {tab === 'items' && (
+
+ )}
+
+ {tab === 'checkup' && (
+
+ )}
+
+ {tab === 'history' && (
+
+ )}
+
+
+
+
+
+ pickImage((uri) => updateItemForm('imageUri', uri))}
+ saveItemFromModal={saveItemFromModal}
+ />
+
+
+
+ );
+}
diff --git a/src/components/BottomTab.js b/src/components/BottomTab.js
new file mode 100644
index 0000000..affed1e
--- /dev/null
+++ b/src/components/BottomTab.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { styles } from '../styles';
+
+export default 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}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/ChipGroup.js b/src/components/ChipGroup.js
new file mode 100644
index 0000000..328686c
--- /dev/null
+++ b/src/components/ChipGroup.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { styles } from '../styles';
+
+export default function ChipGroup({ options, value, onChange }) {
+ return (
+
+ {options.map((option) => {
+ const active = value === option;
+ return (
+ onChange(option)}>
+ {option}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/Field.js b/src/components/Field.js
new file mode 100644
index 0000000..7f27813
--- /dev/null
+++ b/src/components/Field.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Text, View } from 'react-native';
+import { styles } from '../styles';
+
+export default function Field({ label, children }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
diff --git a/src/components/ItemCard.js b/src/components/ItemCard.js
new file mode 100644
index 0000000..4b43003
--- /dev/null
+++ b/src/components/ItemCard.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Image, Pressable, Text, View } from 'react-native';
+import { STATUS_COLORS } from '../constants';
+import { styles } from '../styles';
+
+function statusAccent(status) {
+ return STATUS_COLORS[status] || '#64748b';
+}
+
+export default function ItemCard({ item, onEdit, onDelete }) {
+ return (
+
+
+
+
+
+ {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}
+
+
+ onEdit(item)}>
+ Edit
+
+ onDelete(item.id)}>
+ Delete
+
+
+
+ {item.imageUri ? : null}
+
+
+ );
+}
diff --git a/src/components/TripPicker.js b/src/components/TripPicker.js
new file mode 100644
index 0000000..19ec7bd
--- /dev/null
+++ b/src/components/TripPicker.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+import { styles } from '../styles';
+
+export default function TripPicker({ trips, selectedTripId, onChooseTrip }) {
+ return (
+
+
+ {trips.length ? (
+ trips
+ .slice()
+ .sort((a, b) => b.startDate.localeCompare(a.startDate))
+ .map((trip) => {
+ const active = selectedTripId === trip.id;
+ return (
+ onChooseTrip(trip.id)}>
+ {trip.name}
+ {trip.startDate}
+
+ );
+ })
+ ) : (
+ Create your first trip to start.
+ )}
+
+
+ );
+}
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000..74486f4
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,20 @@
+export const STORAGE_KEY = 'luggage-list:v2';
+export const TAB_BAR_HEIGHT = 72;
+
+export const ITEM_STATUSES = ['packed', 'unpacked', 'lost', 'left-behind', 'lent-to'];
+export const ITEM_PLACEMENTS = ['suitcase', 'backpack', 'with-user', 'other'];
+
+export const STATUS_COLORS = {
+ packed: '#22c55e',
+ unpacked: '#64748b',
+ lost: '#ef4444',
+ 'left-behind': '#f59e0b',
+ 'lent-to': '#8b5cf6',
+};
+
+export const emptyData = {
+ trips: [],
+ itemsByTrip: {},
+ checkupsByTrip: {},
+ defaultTemplateTripId: null,
+};
diff --git a/src/modals/CheckupFixModal.js b/src/modals/CheckupFixModal.js
new file mode 100644
index 0000000..fbe7895
--- /dev/null
+++ b/src/modals/CheckupFixModal.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import { KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
+import { ITEM_PLACEMENTS, ITEM_STATUSES } from '../constants';
+import ChipGroup from '../components/ChipGroup';
+import Field from '../components/Field';
+import { styles } from '../styles';
+
+export default function CheckupFixModal({
+ visible,
+ checkupFixForm,
+ setCheckupFixForm,
+ setCheckupFixModalVisible,
+ saveFixModal,
+}) {
+ return (
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ );
+}
diff --git a/src/modals/ItemModal.js b/src/modals/ItemModal.js
new file mode 100644
index 0000000..baa449c
--- /dev/null
+++ b/src/modals/ItemModal.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { Image, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
+import { ITEM_PLACEMENTS, ITEM_STATUSES } from '../constants';
+import ChipGroup from '../components/ChipGroup';
+import Field from '../components/Field';
+import { styles } from '../styles';
+
+export default function ItemModal({
+ visible,
+ itemForm,
+ setItemModalVisible,
+ updateItemForm,
+ pickItemImage,
+ saveItemFromModal,
+}) {
+ return (
+
+
+
+
+
+ {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}
+
+
+ {itemForm.imageUri ? 'Change image' : 'Add image'}
+
+ {!!itemForm.imageUri && }
+
+
+ {itemForm.id ? 'Save Changes' : 'Add Item'}
+
+
+
+
+
+
+ );
+}
diff --git a/src/styles.js b/src/styles.js
new file mode 100644
index 0000000..59530e8
--- /dev/null
+++ b/src/styles.js
@@ -0,0 +1,376 @@
+import { Platform, StyleSheet } from 'react-native';
+import { TAB_BAR_HEIGHT } from './constants';
+
+export const styles = StyleSheet.create({
+ safe: {
+ flex: 1,
+ backgroundColor: '#090d12',
+ },
+ flex: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 14,
+ paddingTop: 12,
+ paddingBottom: TAB_BAR_HEIGHT + 18,
+ gap: 12,
+ },
+ center: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ 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',
+ gap: 10,
+ },
+
+ card: {
+ backgroundColor: '#111827',
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: '#1f2937',
+ padding: 12,
+ gap: 8,
+ },
+ 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: {
+ gap: 6,
+ },
+ label: {
+ color: '#cbd5e1',
+ fontWeight: '600',
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#243244',
+ borderRadius: 10,
+ backgroundColor: '#0b1220',
+ color: '#e5e7eb',
+ paddingHorizontal: 10,
+ paddingVertical: 11,
+ },
+
+ chipGroup: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 7,
+ },
+ chip: {
+ borderRadius: 999,
+ paddingHorizontal: 11,
+ paddingVertical: 7,
+ borderWidth: 1,
+ borderColor: '#273449',
+ backgroundColor: '#0b1220',
+ },
+ chipActive: {
+ borderColor: '#60a5fa',
+ backgroundColor: '#172338',
+ },
+ chipText: {
+ color: '#cbd5e1',
+ fontWeight: '600',
+ fontSize: 12,
+ },
+ chipTextActive: {
+ color: '#bfdbfe',
+ },
+
+ primaryBtn: {
+ marginTop: 4,
+ borderRadius: 10,
+ 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: 4,
+ borderRadius: 10,
+ backgroundColor: '#1f2937',
+ alignItems: 'center',
+ paddingVertical: 11,
+ paddingHorizontal: 12,
+ },
+ secondaryBtnTight: {
+ borderRadius: 10,
+ backgroundColor: '#1f2937',
+ alignItems: 'center',
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ },
+ secondaryBtnText: {
+ color: '#dbeafe',
+ fontWeight: '700',
+ },
+
+ inlineToggle: {
+ marginTop: 2,
+ },
+ inlineToggleText: {
+ color: '#cbd5e1',
+ },
+
+ stackButtons: {
+ gap: 7,
+ },
+ miniBtn: {
+ backgroundColor: '#1e293b',
+ borderRadius: 8,
+ paddingVertical: 7,
+ paddingHorizontal: 10,
+ },
+ miniBtnDanger: {
+ backgroundColor: '#3b1d22',
+ borderRadius: 8,
+ paddingVertical: 7,
+ paddingHorizontal: 10,
+ },
+ miniBtnText: {
+ color: '#e2e8f0',
+ fontWeight: '700',
+ fontSize: 12,
+ },
+
+ itemCard: {
+ borderRadius: 14,
+ backgroundColor: '#111827',
+ borderWidth: 1,
+ borderColor: '#1f2937',
+ overflow: 'hidden',
+ flexDirection: 'row',
+ },
+ itemAccent: {
+ width: 5,
+ },
+ itemMain: {
+ flex: 1,
+ padding: 12,
+ gap: 8,
+ },
+ 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: '#1e293b',
+ paddingTop: 8,
+ gap: 7,
+ },
+ snapshotRow: {
+ paddingVertical: 3,
+ },
+ snapshotTitle: {
+ color: '#e2e8f0',
+ fontWeight: '600',
+ },
+
+ previewImage: {
+ width: '100%',
+ height: 150,
+ borderRadius: 10,
+ backgroundColor: '#111827',
+ },
+ 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/src/tabs/CheckupTab.js b/src/tabs/CheckupTab.js
new file mode 100644
index 0000000..5c600dc
--- /dev/null
+++ b/src/tabs/CheckupTab.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { styles } from '../styles';
+
+export default function CheckupTab({ checkupSession, answerCheckupYes, openFixModal, createFreshCheckupSession, saveCheckup }) {
+ return (
+
+
+ Check-Up
+
+ Restart
+
+
+
+ {checkupSession.length === 0 ? No items for this trip yet. : null}
+
+ {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
+
+ openFixModal(entry.itemId)}>
+ No
+
+
+
+
+ ))}
+
+ {!!checkupSession.length && (
+
+ Save Check-Up Snapshot
+
+ )}
+
+ );
+}
diff --git a/src/tabs/HistoryTab.js b/src/tabs/HistoryTab.js
new file mode 100644
index 0000000..4d77871
--- /dev/null
+++ b/src/tabs/HistoryTab.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { styles } from '../styles';
+
+export default function HistoryTab({ selectedTripCheckups, selectedCheckupId, setSelectedCheckupId }) {
+ return (
+
+ History
+ {selectedTripCheckups.length === 0 ? No check-ups saved yet. : null}
+
+ {selectedTripCheckups.map((checkup) => (
+
+ setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}>
+ {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}` : ''}
+
+
+ ))}
+
+ ) : null}
+
+ ))}
+
+ );
+}
diff --git a/src/tabs/ItemsTab.js b/src/tabs/ItemsTab.js
new file mode 100644
index 0000000..21da4f3
--- /dev/null
+++ b/src/tabs/ItemsTab.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Pressable, Text, View } from 'react-native';
+import ItemCard from '../components/ItemCard';
+import { styles } from '../styles';
+
+export default function ItemsTab({ selectedTrip, selectedTripItems, openAddItemModal, openEditItemModal, deleteItem }) {
+ return (
+
+
+ Luggage Items
+
+ + Add
+
+
+
+ {!selectedTrip ? Select a trip first. : null}
+ {selectedTripItems.length === 0 && selectedTrip ? No items yet. : null}
+
+ {selectedTripItems.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/tabs/TripsTab.js b/src/tabs/TripsTab.js
new file mode 100644
index 0000000..b288137
--- /dev/null
+++ b/src/tabs/TripsTab.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { Image, Pressable, Text, TextInput, View } from 'react-native';
+import Field from '../components/Field';
+import { styles } from '../styles';
+
+export default function TripsTab({
+ tripForm,
+ updateTripForm,
+ pickTripImage,
+ templateTrip,
+ createTrip,
+ trips,
+ selectedTripId,
+ chooseTrip,
+ setTripAsTemplate,
+ deleteTrip,
+ focusToEnd,
+ defaultTemplateTripId,
+}) {
+ return (
+
+ 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}
+ />
+
+
+
+ {tripForm.imageUri ? 'Change trip image' : 'Add trip image'}
+
+
+ {tripForm.imageUri ? : null}
+
+ {templateTrip ? (
+ updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)}>
+
+ {tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template ({templateTrip.name})
+
+
+ ) : null}
+
+ updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)}>
+ {tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set as default template
+
+
+
+ Create Trip
+
+
+
+ {trips
+ .slice()
+ .sort((a, b) => b.startDate.localeCompare(a.startDate))
+ .map((trip) => (
+
+
+
+ {trip.name}
+ {trip.location || 'No location'} · {trip.startDate} → {trip.endDate}
+ {defaultTemplateTripId === trip.id ? 'Default template' : ' '}
+
+
+ chooseTrip(trip.id)}>
+ Select
+
+ setTripAsTemplate(trip.id)}>
+ Template
+
+ deleteTrip(trip.id)}>
+ Delete
+
+
+
+ {trip.imageUri ? : null}
+
+ ))}
+
+ );
+}
diff --git a/src/utils/date.js b/src/utils/date.js
new file mode 100644
index 0000000..448f50f
--- /dev/null
+++ b/src/utils/date.js
@@ -0,0 +1,25 @@
+export function makeId(prefix) {
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export function todayYMD() {
+ const now = new Date();
+ return `${now.getFullYear()}-${`${now.getMonth() + 1}`.padStart(2, '0')}-${`${now.getDate()}`.padStart(2, '0')}`;
+}
+
+export 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;
+}
+
+export 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);
+ return !!start && !!end && today >= start && today <= end;
+ });
+ return active?.id || null;
+}