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

1345
App.js

File diff suppressed because it is too large Load Diff

View File

@@ -27,3 +27,5 @@ Improving & Fixing Bugs (V2)
- [x] V1 prototype complete and shipped - [x] V1 prototype complete and shipped
- [x] CI adjusted (no `eas init` in workflows) - [x] CI adjusted (no `eas init` in workflows)
- [x] V2 redesign + behavior fixes implemented - [x] V2 redesign + behavior fixes implemented
- [x] Removed legacy template src folder
- [x] Rebuilt app into modular `src/` structure (tabs/components/modals/styles/utils)

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

View File

@@ -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 (
<View style={styles.tabBarWrap}>
<View style={styles.tabBar}>
{tabs.map((tab) => {
const active = current === tab.key;
return (
<Pressable key={tab.key} onPress={() => onChange(tab.key)} style={styles.tabItem}>
<View style={[styles.tabDot, active && styles.tabDotActive]} />
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
</View>
);
}

View File

@@ -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 (
<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>
);
}

12
src/components/Field.js Normal file
View File

@@ -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 (
<View style={styles.fieldWrap}>
<Text style={styles.label}>{label}</Text>
{children}
</View>
);
}

View File

@@ -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 (
<View style={styles.itemCard}>
<View style={[styles.itemAccent, { backgroundColor: statusAccent(item.status) }]} />
<View style={styles.itemMain}>
<View style={styles.cardRow}>
<View style={styles.flex}>
<Text style={styles.itemTitle}>{item.name}</Text>
<Text style={styles.itemMeta}>{item.category || 'uncategorized'} · {item.status}</Text>
<Text style={styles.itemMeta}>Location: {item.placement}</Text>
{item.status === 'lent-to' && !!item.lentTo ? <Text style={styles.itemMeta}>Lent to: {item.lentTo}</Text> : null}
{!!item.description ? <Text style={styles.itemMeta}>{item.description}</Text> : null}
</View>
<View style={styles.stackButtons}>
<Pressable style={styles.miniBtn} onPress={() => onEdit(item)}>
<Text style={styles.miniBtnText}>Edit</Text>
</Pressable>
<Pressable style={styles.miniBtnDanger} onPress={() => onDelete(item.id)}>
<Text style={styles.miniBtnText}>Delete</Text>
</Pressable>
</View>
</View>
{item.imageUri ? <Image source={{ uri: item.imageUri }} style={styles.previewImageSmall} /> : null}
</View>
</View>
);
}

View File

@@ -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 (
<View style={styles.tripPickerWrap}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tripChipScroll}>
{trips.length ? (
trips
.slice()
.sort((a, b) => b.startDate.localeCompare(a.startDate))
.map((trip) => {
const active = selectedTripId === trip.id;
return (
<Pressable key={trip.id} style={[styles.tripChip, active && styles.tripChipActive]} onPress={() => onChooseTrip(trip.id)}>
<Text style={[styles.tripChipText, active && styles.tripChipTextActive]}>{trip.name}</Text>
<Text style={[styles.tripChipSub, active && styles.tripChipSubActive]}>{trip.startDate}</Text>
</Pressable>
);
})
) : (
<Text style={styles.muted}>Create your first trip to start.</Text>
)}
</ScrollView>
</View>
);
}

20
src/constants.js Normal file
View File

@@ -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,
};

View File

@@ -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 (
<Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<View style={styles.sectionRow}>
<Text style={styles.sectionTitle}>Update for this Check-Up</Text>
<Pressable onPress={() => setCheckupFixModalVisible(false)}>
<Text style={styles.closeText}>Close</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<Field label="Status">
<ChipGroup
options={ITEM_STATUSES}
value={checkupFixForm.status}
onChange={(v) => setCheckupFixForm((prev) => ({ ...prev, status: v }))}
/>
</Field>
<Field label="Placement">
<ChipGroup
options={ITEM_PLACEMENTS}
value={checkupFixForm.placement}
onChange={(v) => setCheckupFixForm((prev) => ({ ...prev, placement: v }))}
/>
</Field>
{checkupFixForm.status === 'lent-to' ? (
<Field label="Lent to">
<TextInput
style={styles.input}
value={checkupFixForm.lentTo}
onChangeText={(v) => setCheckupFixForm((prev) => ({ ...prev, lentTo: v }))}
placeholder="Person name"
placeholderTextColor="#6b7280"
/>
</Field>
) : null}
<Pressable
style={styles.inlineToggle}
onPress={() => setCheckupFixForm((prev) => ({ ...prev, updateMasterList: !prev.updateMasterList }))}
>
<Text style={styles.inlineToggleText}>
{checkupFixForm.updateMasterList ? '☑' : '☐'} Also update item in trip list
</Text>
</Pressable>
<Pressable style={styles.primaryBtn} onPress={saveFixModal}>
<Text style={styles.primaryBtnText}>Save</Text>
</Pressable>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}

93
src/modals/ItemModal.js Normal file
View File

@@ -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 (
<Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<View style={styles.sectionRow}>
<Text style={styles.sectionTitle}>{itemForm.id ? 'Update Item' : 'Add Item'}</Text>
<Pressable onPress={() => setItemModalVisible(false)}>
<Text style={styles.closeText}>Close</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<Field label="Name">
<TextInput
style={styles.input}
value={itemForm.name}
onChangeText={(v) => updateItemForm('name', v)}
placeholder="Toothbrush"
placeholderTextColor="#6b7280"
/>
</Field>
<Field label="Description">
<TextInput
style={styles.input}
value={itemForm.description}
onChangeText={(v) => updateItemForm('description', v)}
placeholder="Optional"
placeholderTextColor="#6b7280"
/>
</Field>
<Field label="Category">
<TextInput
style={styles.input}
value={itemForm.category}
onChangeText={(v) => updateItemForm('category', v)}
placeholder="toiletries"
placeholderTextColor="#6b7280"
/>
</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
style={styles.input}
value={itemForm.lentTo}
onChangeText={(v) => updateItemForm('lentTo', v)}
placeholder="Person name"
placeholderTextColor="#6b7280"
/>
</Field>
) : null}
<Pressable style={styles.secondaryBtn} onPress={pickItemImage}>
<Text style={styles.secondaryBtnText}>{itemForm.imageUri ? 'Change image' : 'Add image'}</Text>
</Pressable>
{!!itemForm.imageUri && <Image source={{ uri: itemForm.imageUri }} style={styles.previewImageSmall} />}
<Pressable style={styles.primaryBtn} onPress={saveItemFromModal}>
<Text style={styles.primaryBtnText}>{itemForm.id ? 'Save Changes' : 'Add Item'}</Text>
</Pressable>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}

376
src/styles.js Normal file
View File

@@ -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',
},
});

45
src/tabs/CheckupTab.js Normal file
View File

@@ -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 (
<View style={styles.section}>
<View style={styles.sectionRow}>
<Text style={styles.sectionTitle}>Check-Up</Text>
<Pressable style={styles.secondaryBtnTight} onPress={createFreshCheckupSession}>
<Text style={styles.secondaryBtnText}>Restart</Text>
</Pressable>
</View>
{checkupSession.length === 0 ? <Text style={styles.muted}>No items for this trip yet.</Text> : null}
{checkupSession.map((entry) => (
<View key={entry.itemId} style={styles.cardSoft}>
<Text style={styles.cardTitle}>{entry.name}</Text>
<Text style={styles.cardMeta}>{entry.category || 'uncategorized'}</Text>
<Text style={styles.cardMeta}>
{entry.current.status} · {entry.current.placement}
{entry.current.status === 'lent-to' && entry.current.lentTo ? ` · ${entry.current.lentTo}` : ''}
</Text>
<View style={styles.answerRow}>
<Pressable style={styles.answerYes} onPress={() => answerCheckupYes(entry.itemId)}>
<Text style={styles.answerText}>Yes</Text>
</Pressable>
<Pressable style={styles.answerNo} onPress={() => openFixModal(entry.itemId)}>
<Text style={styles.answerText}>No</Text>
</Pressable>
<View style={[styles.answerStateDot, entry.confirmed ? styles.answerStateDotOn : null]} />
</View>
</View>
))}
{!!checkupSession.length && (
<Pressable style={styles.primaryBtn} onPress={saveCheckup}>
<Text style={styles.primaryBtnText}>Save Check-Up Snapshot</Text>
</Pressable>
)}
</View>
);
}

36
src/tabs/HistoryTab.js Normal file
View File

@@ -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 (
<View style={styles.section}>
<Text style={styles.sectionTitle}>History</Text>
{selectedTripCheckups.length === 0 ? <Text style={styles.muted}>No check-ups saved yet.</Text> : null}
{selectedTripCheckups.map((checkup) => (
<View key={checkup.id} style={styles.cardSoft}>
<Pressable onPress={() => setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))}>
<Text style={styles.cardTitle}>{new Date(checkup.createdAt).toLocaleString()}</Text>
<Text style={styles.cardMeta}>{checkup.snapshot.length} items</Text>
<Text style={styles.cardMeta}>{selectedCheckupId === checkup.id ? 'Tap to collapse' : 'Tap to open'}</Text>
</Pressable>
{selectedCheckupId === checkup.id ? (
<View style={styles.snapshotWrap}>
{checkup.snapshot.map((entry) => (
<View key={entry.itemId} style={styles.snapshotRow}>
<Text style={styles.snapshotTitle}>{entry.name}</Text>
<Text style={styles.cardMeta}>
{entry.status} · {entry.placement}
{entry.status === 'lent-to' && entry.lentTo ? ` · ${entry.lentTo}` : ''}
</Text>
</View>
))}
</View>
) : null}
</View>
))}
</View>
);
}

24
src/tabs/ItemsTab.js Normal file
View File

@@ -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 (
<View style={styles.section}>
<View style={styles.sectionRow}>
<Text style={styles.sectionTitle}>Luggage Items</Text>
<Pressable style={styles.primaryBtnTight} onPress={openAddItemModal}>
<Text style={styles.primaryBtnText}>+ Add</Text>
</Pressable>
</View>
{!selectedTrip ? <Text style={styles.muted}>Select a trip first.</Text> : null}
{selectedTripItems.length === 0 && selectedTrip ? <Text style={styles.muted}>No items yet.</Text> : null}
{selectedTripItems.map((item) => (
<ItemCard key={item.id} item={item} onEdit={openEditItemModal} onDelete={deleteItem} />
))}
</View>
);
}

118
src/tabs/TripsTab.js Normal file
View File

@@ -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 (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Trips</Text>
<View style={styles.cardSoft}>
<Field label="Name">
<TextInput
style={styles.input}
value={tripForm.name}
onChangeText={(v) => updateTripForm('name', v)}
placeholder="Summer Weekend"
placeholderTextColor="#6b7280"
onFocus={focusToEnd}
/>
</Field>
<Field label="Location">
<TextInput
style={styles.input}
value={tripForm.location}
onChangeText={(v) => updateTripForm('location', v)}
placeholder="Berlin"
placeholderTextColor="#6b7280"
onFocus={focusToEnd}
/>
</Field>
<Field label="Start Date (YYYY-MM-DD)">
<TextInput
style={styles.input}
value={tripForm.startDate}
onChangeText={(v) => updateTripForm('startDate', v)}
placeholderTextColor="#6b7280"
onFocus={focusToEnd}
/>
</Field>
<Field label="End Date (YYYY-MM-DD)">
<TextInput
style={styles.input}
value={tripForm.endDate}
onChangeText={(v) => updateTripForm('endDate', v)}
placeholderTextColor="#6b7280"
onFocus={focusToEnd}
/>
</Field>
<Pressable style={styles.secondaryBtn} onPress={pickTripImage}>
<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}
{templateTrip ? (
<Pressable style={styles.inlineToggle} onPress={() => updateTripForm('copyDefaultTemplate', !tripForm.copyDefaultTemplate)}>
<Text style={styles.inlineToggleText}>
{tripForm.copyDefaultTemplate ? '☑' : '☐'} Copy items from template ({templateTrip.name})
</Text>
</Pressable>
) : null}
<Pressable style={styles.inlineToggle} onPress={() => updateTripForm('setAsDefaultTemplate', !tripForm.setAsDefaultTemplate)}>
<Text style={styles.inlineToggleText}>{tripForm.setAsDefaultTemplate ? '☑' : '☐'} Set as default template</Text>
</Pressable>
<Pressable style={styles.primaryBtn} onPress={createTrip}>
<Text style={styles.primaryBtnText}>Create Trip</Text>
</Pressable>
</View>
{trips
.slice()
.sort((a, b) => b.startDate.localeCompare(a.startDate))
.map((trip) => (
<View key={trip.id} style={[styles.card, selectedTripId === trip.id && styles.cardActive]}>
<View style={styles.cardRow}>
<View style={styles.flex}>
<Text style={styles.cardTitle}>{trip.name}</Text>
<Text style={styles.cardMeta}>{trip.location || 'No location'} · {trip.startDate} {trip.endDate}</Text>
<Text style={styles.cardMeta}>{defaultTemplateTripId === trip.id ? 'Default template' : ' '}</Text>
</View>
<View style={styles.stackButtons}>
<Pressable style={styles.miniBtn} onPress={() => chooseTrip(trip.id)}>
<Text style={styles.miniBtnText}>Select</Text>
</Pressable>
<Pressable style={styles.miniBtn} onPress={() => setTripAsTemplate(trip.id)}>
<Text style={styles.miniBtnText}>Template</Text>
</Pressable>
<Pressable style={styles.miniBtnDanger} onPress={() => deleteTrip(trip.id)}>
<Text style={styles.miniBtnText}>Delete</Text>
</Pressable>
</View>
</View>
{trip.imageUri ? <Image source={{ uri: trip.imageUri }} style={styles.previewImageSmall} /> : null}
</View>
))}
</View>
);
}

25
src/utils/date.js Normal file
View File

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