refactor: split app into modular src architecture
Some checks failed
Luggage List Build / release (push) Has been cancelled
Luggage List Build / build-android (push) Has been cancelled
Luggage List Build / build-web (push) Has been cancelled

This commit is contained in:
2026-04-18 12:56:12 +02:00
parent 3323d09dda
commit bdea52b7c6
17 changed files with 1493 additions and 1343 deletions

556
src/AppRoot.js Normal file
View File

@@ -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 (
<SafeAreaView style={styles.safe}>
<StatusBar style="light" />
<View style={styles.center}>
<Text style={styles.muted}>Loading local data...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safe}>
<StatusBar style="light" />
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView
ref={scrollRef}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<TripPicker trips={data.trips} selectedTripId={selectedTripId} onChooseTrip={setSelectedTripId} />
{tab === 'trips' && (
<TripsTab
tripForm={tripForm}
updateTripForm={updateTripForm}
pickTripImage={() => 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' && (
<ItemsTab
selectedTrip={selectedTrip}
selectedTripItems={selectedTripItems}
openAddItemModal={openAddItemModal}
openEditItemModal={openEditItemModal}
deleteItem={deleteItem}
/>
)}
{tab === 'checkup' && (
<CheckupTab
checkupSession={checkupSession}
answerCheckupYes={answerCheckupYes}
openFixModal={openFixModal}
createFreshCheckupSession={createFreshCheckupSession}
saveCheckup={saveCheckup}
/>
)}
{tab === 'history' && (
<HistoryTab
selectedTripCheckups={selectedTripCheckups}
selectedCheckupId={selectedCheckupId}
setSelectedCheckupId={setSelectedCheckupId}
/>
)}
</ScrollView>
</KeyboardAvoidingView>
<BottomTab current={tab} onChange={setTab} />
<ItemModal
visible={itemModalVisible}
itemForm={itemForm}
setItemModalVisible={setItemModalVisible}
updateItemForm={updateItemForm}
pickItemImage={() => pickImage((uri) => updateItemForm('imageUri', uri))}
saveItemFromModal={saveItemFromModal}
/>
<CheckupFixModal
visible={checkupFixModalVisible}
checkupFixForm={checkupFixForm}
setCheckupFixForm={setCheckupFixForm}
setCheckupFixModalVisible={setCheckupFixModalVisible}
saveFixModal={saveFixModal}
/>
</SafeAreaView>
);
}