Files
luggage-list/App.js
Luna 7de77d2878
Some checks failed
Build App / release (push) Has been cancelled
Build App / build-web (push) Has been cancelled
Build App / build-android (push) Has been cancelled
feat: scaffold luggage list expo app with core local MVP
2026-04-18 11:51:58 +02:00

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