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