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; +}