1092 lines
32 KiB
JavaScript
1092 lines
32 KiB
JavaScript
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 (
|
|
<View style={styles.chipGroup}>
|
|
{options.map((option) => {
|
|
const active = value === option;
|
|
return (
|
|
<Pressable
|
|
key={option}
|
|
style={[styles.chip, active && styles.chipActive]}
|
|
onPress={() => onChange(option)}
|
|
>
|
|
<Text style={[styles.chipText, active && styles.chipTextActive]}>{option}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }) {
|
|
return (
|
|
<View style={styles.fieldWrap}>
|
|
<Text style={styles.label}>{label}</Text>
|
|
{children}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Card({ title, children, right }) {
|
|
return (
|
|
<View style={styles.card}>
|
|
<View style={styles.cardHeader}>
|
|
<Text style={styles.cardTitle}>{title}</Text>
|
|
{right}
|
|
</View>
|
|
{children}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<SafeAreaView style={styles.safe}>
|
|
<StatusBar style="dark" />
|
|
<View style={styles.loadingWrap}>
|
|
<Text style={styles.loadingText}>Loading local data...</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safe}>
|
|
<StatusBar style="dark" />
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>Luggage List</Text>
|
|
<Text style={styles.subtitle}>Simple local luggage tracking</Text>
|
|
</View>
|
|
|
|
<View style={styles.tabRow}>
|
|
{['trips', 'items', 'checkup', 'history', 'export'].map((name) => (
|
|
<Pressable
|
|
key={name}
|
|
style={[styles.tabBtn, tab === name && styles.tabBtnActive]}
|
|
onPress={() => setTab(name)}
|
|
>
|
|
<Text style={[styles.tabText, tab === name && styles.tabTextActive]}>{name}</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
|
|
<ScrollView contentContainerStyle={styles.content}>
|
|
<Card
|
|
title="Active Trip"
|
|
right={
|
|
<Text style={styles.metaText}>{selectedTrip ? `${selectedTrip.startDate} → ${selectedTrip.endDate}` : 'None'}</Text>
|
|
}
|
|
>
|
|
{selectedTrip ? (
|
|
<View>
|
|
<Text style={styles.activeTripName}>{selectedTrip.name}</Text>
|
|
<Text style={styles.metaText}>{selectedTrip.location || 'No location set'}</Text>
|
|
{!!selectedTrip.imageUri && <Image source={{ uri: selectedTrip.imageUri }} style={styles.previewImage} />}
|
|
</View>
|
|
) : (
|
|
<Text style={styles.metaText}>Create your first trip to start.</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{tab === 'trips' && (
|
|
<>
|
|
<Card title="Create Trip">
|
|
<Field label="Name">
|
|
<TextInput
|
|
value={tripForm.name}
|
|
onChangeText={(v) => updateTripForm('name', v)}
|
|
style={styles.input}
|
|
placeholder="Weekend in Berlin"
|
|
/>
|
|
</Field>
|
|
<Field label="Location">
|
|
<TextInput
|
|
value={tripForm.location}
|
|
onChangeText={(v) => updateTripForm('location', v)}
|
|
style={styles.input}
|
|
placeholder="Berlin"
|
|
/>
|
|
</Field>
|
|
<Field label="Start Date (YYYY-MM-DD)">
|
|
<TextInput
|
|
value={tripForm.startDate}
|
|
onChangeText={(v) => updateTripForm('startDate', v)}
|
|
style={styles.input}
|
|
placeholder="2026-04-18"
|
|
/>
|
|
</Field>
|
|
<Field label="End Date (YYYY-MM-DD)">
|
|
<TextInput
|
|
value={tripForm.endDate}
|
|
onChangeText={(v) => updateTripForm('endDate', v)}
|
|
style={styles.input}
|
|
placeholder="2026-04-21"
|
|
/>
|
|
</Field>
|
|
|
|
<View style={styles.rowGap}>
|
|
<Pressable style={styles.secondaryBtn} onPress={() => pickImage((uri) => updateTripForm('imageUri', uri))}>
|
|
<Text style={styles.secondaryBtnText}>{tripForm.imageUri ? 'Change Trip Image' : 'Add Trip Image'}</Text>
|
|
</Pressable>
|
|
{tripForm.imageUri ? <Image source={{ uri: tripForm.imageUri }} style={styles.previewImage} /> : null}
|
|
</View>
|
|
|
|
{templateTrip ? (
|
|
<Pressable
|
|
style={styles.inlineToggle}
|
|
onPress={() => updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)}
|
|
>
|
|
<Text style={styles.inlineToggleText}>
|
|
{tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template trip: {templateTrip.name}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
|
|
<Pressable
|
|
style={styles.inlineToggle}
|
|
onPress={() => updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)}
|
|
>
|
|
<Text style={styles.inlineToggleText}>
|
|
{tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set this trip as default template
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<Pressable style={styles.primaryBtn} onPress={createTrip}>
|
|
<Text style={styles.primaryBtnText}>Create Trip</Text>
|
|
</Pressable>
|
|
</Card>
|
|
|
|
<Card title="Trips">
|
|
{!data.trips.length ? <Text style={styles.metaText}>No trips yet.</Text> : null}
|
|
{data.trips
|
|
.slice()
|
|
.sort((a, b) => b.startDate.localeCompare(a.startDate))
|
|
.map((trip) => (
|
|
<View key={trip.id} style={styles.listItem}>
|
|
<Pressable onPress={() => setSelectedTripId(trip.id)} style={styles.grow}>
|
|
<Text style={styles.itemTitle}>{trip.name}</Text>
|
|
<Text style={styles.metaText}>{trip.location || 'No location'} • {trip.startDate} → {trip.endDate}</Text>
|
|
<Text style={styles.metaText}>
|
|
{selectedTripId === trip.id ? 'Active' : 'Tap to select'}
|
|
{data.defaultTemplateTripId === trip.id ? ' • Template' : ''}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.smallActionBtn} onPress={() => setTripAsTemplate(trip.id)}>
|
|
<Text style={styles.smallActionBtnText}>Template</Text>
|
|
</Pressable>
|
|
</View>
|
|
))}
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{tab === 'items' && (
|
|
<>
|
|
<Card title={itemForm.id ? 'Edit Item' : 'Add Item'}>
|
|
{!selectedTripId ? <Text style={styles.metaText}>Create/select a trip first.</Text> : null}
|
|
<Field label="Name">
|
|
<TextInput
|
|
value={itemForm.name}
|
|
onChangeText={(v) => updateItemForm('name', v)}
|
|
style={styles.input}
|
|
placeholder="Toothbrush"
|
|
/>
|
|
</Field>
|
|
<Field label="Description">
|
|
<TextInput
|
|
value={itemForm.description}
|
|
onChangeText={(v) => updateItemForm('description', v)}
|
|
style={styles.input}
|
|
placeholder="Electric toothbrush"
|
|
/>
|
|
</Field>
|
|
<Field label="Category">
|
|
<TextInput
|
|
value={itemForm.category}
|
|
onChangeText={(v) => updateItemForm('category', v)}
|
|
style={styles.input}
|
|
placeholder="toiletries"
|
|
/>
|
|
</Field>
|
|
<Field label="Status">
|
|
<ChipGroup options={ITEM_STATUSES} value={itemForm.status} onChange={(v) => updateItemForm('status', v)} />
|
|
</Field>
|
|
<Field label="Placement">
|
|
<ChipGroup options={ITEM_PLACEMENTS} value={itemForm.placement} onChange={(v) => updateItemForm('placement', v)} />
|
|
</Field>
|
|
{itemForm.status === 'lent-to' ? (
|
|
<Field label="Lent to">
|
|
<TextInput
|
|
value={itemForm.lentTo}
|
|
onChangeText={(v) => updateItemForm('lentTo', v)}
|
|
style={styles.input}
|
|
placeholder="Person name"
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
|
|
<View style={styles.rowGap}>
|
|
<Pressable style={styles.secondaryBtn} onPress={() => pickImage((uri) => updateItemForm('imageUri', uri))}>
|
|
<Text style={styles.secondaryBtnText}>{itemForm.imageUri ? 'Change Item Image' : 'Add Item Image'}</Text>
|
|
</Pressable>
|
|
{itemForm.imageUri ? <Image source={{ uri: itemForm.imageUri }} style={styles.previewImage} /> : null}
|
|
</View>
|
|
|
|
<View style={styles.actionRow}>
|
|
<Pressable style={styles.primaryBtn} onPress={saveItem}>
|
|
<Text style={styles.primaryBtnText}>{itemForm.id ? 'Save Item' : 'Add Item'}</Text>
|
|
</Pressable>
|
|
{itemForm.id ? (
|
|
<Pressable
|
|
style={styles.secondaryBtn}
|
|
onPress={() =>
|
|
setItemForm({
|
|
id: null,
|
|
name: '',
|
|
description: '',
|
|
category: '',
|
|
status: 'unpacked',
|
|
placement: 'suitcase',
|
|
lentTo: '',
|
|
imageUri: '',
|
|
})
|
|
}
|
|
>
|
|
<Text style={styles.secondaryBtnText}>Cancel</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
</Card>
|
|
|
|
<Card title="Items">
|
|
{!selectedTripItems.length ? <Text style={styles.metaText}>No items yet for this trip.</Text> : null}
|
|
{selectedTripItems.map((item) => (
|
|
<View key={item.id} style={styles.listItemStack}>
|
|
<View style={styles.listItemTop}>
|
|
<View style={styles.grow}>
|
|
<Text style={styles.itemTitle}>{item.name}</Text>
|
|
<Text style={styles.metaText}>
|
|
{(item.category || 'uncategorized')} • {item.status} • {item.placement}
|
|
</Text>
|
|
{!!item.description && <Text style={styles.metaText}>{item.description}</Text>}
|
|
{item.status === 'lent-to' && !!item.lentTo && <Text style={styles.metaText}>Lent to: {item.lentTo}</Text>}
|
|
</View>
|
|
<View style={styles.itemActionsColumn}>
|
|
<Pressable style={styles.smallActionBtn} onPress={() => editItem(item)}>
|
|
<Text style={styles.smallActionBtnText}>Edit</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.smallActionBtn} onPress={() => deleteItem(item.id)}>
|
|
<Text style={styles.smallActionBtnText}>Delete</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
{!!item.imageUri && <Image source={{ uri: item.imageUri }} style={styles.previewImage} />}
|
|
</View>
|
|
))}
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{tab === 'checkup' && (
|
|
<>
|
|
<Card title="Create Check-Up Snapshot">
|
|
{!selectedTripItems.length ? (
|
|
<Text style={styles.metaText}>Add items first, then do a check-up.</Text>
|
|
) : (
|
|
<>
|
|
{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 (
|
|
<View key={item.id} style={styles.checkupItem}>
|
|
<Text style={styles.itemTitle}>{item.name}</Text>
|
|
<Text style={styles.metaText}>{item.category || 'uncategorized'}</Text>
|
|
<Field label="Status">
|
|
<ChipGroup
|
|
options={ITEM_STATUSES}
|
|
value={statusValue}
|
|
onChange={(v) => updateCheckupItem(item.id, 'status', v)}
|
|
/>
|
|
</Field>
|
|
<Field label="Placement">
|
|
<ChipGroup
|
|
options={ITEM_PLACEMENTS}
|
|
value={placementValue}
|
|
onChange={(v) => updateCheckupItem(item.id, 'placement', v)}
|
|
/>
|
|
</Field>
|
|
{statusValue === 'lent-to' ? (
|
|
<Field label="Lent to">
|
|
<TextInput
|
|
value={lentValue}
|
|
onChangeText={(v) => updateCheckupItem(item.id, 'lentTo', v)}
|
|
style={styles.input}
|
|
placeholder="Person name"
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
</View>
|
|
);
|
|
})}
|
|
|
|
<View style={styles.actionRow}>
|
|
<Pressable style={styles.primaryBtn} onPress={createCheckup}>
|
|
<Text style={styles.primaryBtnText}>Save Check-Up</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.secondaryBtn} onPress={initCheckupDraft}>
|
|
<Text style={styles.secondaryBtnText}>Reset</Text>
|
|
</Pressable>
|
|
</View>
|
|
</>
|
|
)}
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{tab === 'history' && (
|
|
<Card title="Check-Up History">
|
|
{!selectedTripCheckups.length ? <Text style={styles.metaText}>No check-ups saved yet.</Text> : 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 (
|
|
<View key={checkup.id} style={styles.listItemStack}>
|
|
<Pressable onPress={() => setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}>
|
|
<Text style={styles.itemTitle}>{formatDateTime(checkup.createdAt)}</Text>
|
|
<Text style={styles.metaText}>
|
|
{checkup.snapshot.length} items • lost: {lostCount} • left-behind: {leftBehindCount}
|
|
</Text>
|
|
<Text style={styles.metaText}>{selectedCheckupId === checkup.id ? 'Tap to collapse' : 'Tap to view snapshot'}</Text>
|
|
</Pressable>
|
|
|
|
{selectedCheckupId === checkup.id ? (
|
|
<View style={styles.snapshotList}>
|
|
{checkup.snapshot.map((entry) => (
|
|
<View key={entry.itemId} style={styles.snapshotRow}>
|
|
<Text style={styles.snapshotName}>{entry.name}</Text>
|
|
<Text style={styles.metaText}>
|
|
{entry.status} • {entry.placement}
|
|
{entry.status === 'lent-to' && entry.lentTo ? ` • ${entry.lentTo}` : ''}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
})}
|
|
</Card>
|
|
)}
|
|
|
|
{tab === 'export' && (
|
|
<Card title="Export Data">
|
|
<Text style={styles.metaText}>
|
|
Export trips, items, and check-up history as JSON for backup or sharing.
|
|
</Text>
|
|
<Pressable style={styles.primaryBtn} onPress={exportJson}>
|
|
<Text style={styles.primaryBtnText}>Export JSON</Text>
|
|
</Pressable>
|
|
</Card>
|
|
)}
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
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',
|
|
},
|
|
});
|