5 Commits

Author SHA1 Message Date
e1bfbdbf1e feat(#17,#18): add location filter and custom-location autofill
All checks were successful
Luggage List Build / build-web (push) Successful in 52s
Luggage List Build / build-android (push) Successful in 7m2s
Luggage List Build / release (push) Successful in 17s
2026-04-18 21:59:23 +02:00
063e5090ed feat(#16): support custom placement when selecting other
All checks were successful
Luggage List Build / build-web (push) Successful in 40s
Luggage List Build / build-android (push) Successful in 7m26s
Luggage List Build / release (push) Successful in 15s
2026-04-18 21:41:18 +02:00
354a13e9a9 fix(#14,#15): reduce UI clutter and rename status actions
All checks were successful
Luggage List Build / build-web (push) Successful in 29s
Luggage List Build / build-android (push) Successful in 6m27s
Luggage List Build / release (push) Successful in 18s
2026-04-18 21:32:12 +02:00
4018e97476 feat(#13): open item images in full-screen preview modal
All checks were successful
Luggage List Build / build-web (push) Successful in 29s
Luggage List Build / build-android (push) Successful in 5m42s
Luggage List Build / release (push) Successful in 11s
2026-04-18 19:45:53 +02:00
1e0eb7aee9 fix(#12): remove Select action from trips list
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
2026-04-18 19:45:46 +02:00
7 changed files with 220 additions and 64 deletions

View File

@@ -14,7 +14,7 @@ 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 { emptyData, ITEM_PLACEMENTS, STORAGE_KEY } from './constants';
import { findBestTripId, makeId, parseYMD, todayYMD } from './utils/date';
import { styles } from './styles';
@@ -34,6 +34,7 @@ const emptyItemForm = () => ({
category: '',
status: 'unpacked',
placement: 'suitcase',
placementCustom: '',
lentTo: '',
imageUri: '',
imageQuality: 'balanced',
@@ -43,6 +44,7 @@ const emptyItemForm = () => ({
const emptyCheckupNoForm = () => ({
status: 'unpacked',
placement: 'suitcase',
placementCustom: '',
lentTo: '',
updateMasterList: false,
});
@@ -107,6 +109,35 @@ export default function AppRoot() {
return (data.checkupsByTrip[selectedTripId] || []).slice().sort((a, b) => b.createdAt - a.createdAt);
}, [data.checkupsByTrip, selectedTripId]);
const previousCustomPlacements = useMemo(() => {
const seen = new Set();
function takeFrom(items = [], bucket = []) {
items
.slice()
.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0))
.forEach((item) => {
const location = item.placement?.trim();
if (!location || ITEM_PLACEMENTS.includes(location) || seen.has(location)) return;
seen.add(location);
bucket.push(location);
});
return bucket;
}
const collected = [];
if (selectedTripId) {
takeFrom(data.itemsByTrip[selectedTripId] || [], collected);
}
Object.entries(data.itemsByTrip).forEach(([tripId, items]) => {
if (tripId === selectedTripId) return;
takeFrom(items || [], collected);
});
return collected;
}, [data.itemsByTrip, selectedTripId]);
const templateTrip = useMemo(
() => data.trips.find((trip) => trip.id === data.defaultTemplateTripId) || null,
[data.trips, data.defaultTemplateTripId]
@@ -227,7 +258,18 @@ export default function AppRoot() {
}
function updateItemForm(field, value) {
setItemForm((prev) => ({ ...prev, [field]: value }));
setItemForm((prev) => {
if (field === 'placement' && value === 'other') {
const fallbackLocation = previousCustomPlacements[0] || '';
return {
...prev,
placement: value,
placementCustom: prev.placementCustom?.trim() ? prev.placementCustom : fallbackLocation,
};
}
return { ...prev, [field]: value };
});
}
function openDatePicker(field) {
@@ -430,13 +472,17 @@ export default function AppRoot() {
}
function openEditItemModal(item) {
const existingPlacement = item.placement || 'suitcase';
const hasPresetPlacement = ITEM_PLACEMENTS.includes(existingPlacement);
setItemForm({
id: item.id,
name: item.name || '',
description: item.description || '',
category: item.category || '',
status: item.status || 'unpacked',
placement: item.placement || 'suitcase',
placement: hasPresetPlacement ? existingPlacement : 'other',
placementCustom: hasPresetPlacement || existingPlacement === 'other' ? '' : existingPlacement,
lentTo: item.lentTo || '',
imageUri: item.imageUri || '',
imageQuality: item.imageQuality || 'balanced',
@@ -456,6 +502,12 @@ export default function AppRoot() {
return;
}
const resolvedPlacement = itemForm.placement === 'other' ? itemForm.placementCustom.trim() : itemForm.placement;
if (!resolvedPlacement) {
showAlert('Missing location', 'Please enter a custom location for "other".');
return;
}
const now = Date.now();
setData((prev) => {
@@ -467,7 +519,7 @@ export default function AppRoot() {
description: itemForm.description.trim(),
category: itemForm.category.trim(),
status: itemForm.status,
placement: itemForm.placement,
placement: resolvedPlacement,
lentTo: itemForm.status === 'lent-to' ? itemForm.lentTo.trim() : '',
imageUri: itemForm.imageUri,
imageQuality: itemForm.imageQuality,
@@ -650,9 +702,14 @@ export default function AppRoot() {
function openCurrentCheckupNo() {
const entry = checkupCurrentEntry;
if (!entry) return;
const existingPlacement = entry.current.placement || 'suitcase';
const hasPresetPlacement = ITEM_PLACEMENTS.includes(existingPlacement);
setCheckupNoForm({
status: entry.current.status || 'unpacked',
placement: entry.current.placement || 'suitcase',
placement: hasPresetPlacement ? existingPlacement : 'other',
placementCustom: hasPresetPlacement || existingPlacement === 'other' ? '' : existingPlacement,
lentTo: entry.current.lentTo || '',
updateMasterList: false,
});
@@ -663,9 +720,15 @@ export default function AppRoot() {
const entry = checkupCurrentEntry;
if (!entry) return;
const resolvedPlacement = checkupNoForm.placement === 'other' ? checkupNoForm.placementCustom.trim() : checkupNoForm.placement;
if (!resolvedPlacement) {
showAlert('Missing location', 'Please enter a custom location for "other".');
return;
}
const patch = {
status: checkupNoForm.status,
placement: checkupNoForm.placement,
placement: resolvedPlacement,
lentTo: checkupNoForm.status === 'lent-to' ? checkupNoForm.lentTo.trim() : '',
};
@@ -877,7 +940,6 @@ export default function AppRoot() {
createTrip={createTrip}
trips={data.trips}
selectedTripId={selectedTripId}
chooseTrip={setSelectedTripId}
setTripAsTemplate={setTripAsTemplate}
saveTripEdits={saveTripEdits}
setTripArchived={setTripArchived}
@@ -937,6 +999,7 @@ export default function AppRoot() {
<ItemModal
visible={itemModalVisible}
itemForm={itemForm}
previousCustomPlacements={previousCustomPlacements}
setItemModalVisible={setItemModalVisible}
updateItemForm={updateItemForm}
pickItemImage={(options) => pickImage((uri) => updateItemForm('imageUri', uri), options)}

View File

@@ -8,7 +8,7 @@ function statusAccent(status) {
return STATUS_COLORS[status] || '#64748b';
}
export default function ItemCard({ item, onEdit, onDelete, onQuickPack, onQuickUnpack }) {
export default function ItemCard({ item, onEdit, onDelete, onQuickPack, onQuickUnpack, onOpenImage }) {
const isPacked = item.status === 'packed';
const isUnpacked = item.status === 'unpacked';
@@ -18,7 +18,9 @@ export default function ItemCard({ item, onEdit, onDelete, onQuickPack, onQuickU
<View style={styles.itemThumbWrap}>
{item.imageUri ? (
<Image source={{ uri: item.imageUri }} style={styles.itemThumbSmall} />
<Pressable onPress={() => onOpenImage?.(item.imageUri)}>
<Image source={{ uri: item.imageUri }} style={styles.itemThumbSmall} />
</Pressable>
) : (
<View style={styles.itemThumbPlaceholder}>
<Text style={styles.itemThumbPlaceholderText}>🧳</Text>
@@ -29,11 +31,11 @@ export default function ItemCard({ item, onEdit, onDelete, onQuickPack, onQuickU
<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'} · {formatStatusLabel(item.status, item.lentTo)}</Text>
<Text style={styles.itemTitle} numberOfLines={1}>{item.name}</Text>
<Text style={styles.itemMeta} numberOfLines={1}>{item.category || 'uncategorized'} · {formatStatusLabel(item.status, item.lentTo)}</Text>
<Text style={styles.itemMeta}>Location: {item.placement}</Text>
{item.status === 'lent-to' && !!item.lentTo ? <Text style={styles.itemMeta}>Borrower: {item.lentTo}</Text> : null}
{!!item.description ? <Text style={styles.itemMeta}>{item.description}</Text> : null}
{item.status === 'lent-to' && !!item.lentTo ? <Text style={styles.itemMeta} numberOfLines={1}>Borrower: {item.lentTo}</Text> : null}
{!!item.description ? <Text style={styles.itemMeta} numberOfLines={2}>{item.description}</Text> : null}
</View>
<View style={styles.stackButtons}>
<Pressable style={styles.miniBtn} onPress={() => onEdit(item)}>
@@ -47,10 +49,10 @@ export default function ItemCard({ item, onEdit, onDelete, onQuickPack, onQuickU
<View style={styles.quickStatusRow}>
<Pressable style={[styles.quickStatusBtn, isPacked && styles.quickStatusBtnActive]} onPress={onQuickPack}>
<Text style={[styles.quickStatusBtnText, isPacked && styles.quickStatusBtnTextActive]}>Pack</Text>
<Text style={[styles.quickStatusBtnText, isPacked && styles.quickStatusBtnTextActive]}>Packed</Text>
</Pressable>
<Pressable style={[styles.quickStatusBtn, isUnpacked && styles.quickStatusBtnActive]} onPress={onQuickUnpack}>
<Text style={[styles.quickStatusBtnText, isUnpacked && styles.quickStatusBtnTextActive]}>Unpack</Text>
<Text style={[styles.quickStatusBtnText, isUnpacked && styles.quickStatusBtnTextActive]}>Unpacked</Text>
</Pressable>
</View>
</View>

View File

@@ -103,6 +103,18 @@ export default function CheckupFlowModal({
/>
</Field>
{noForm.placement === 'other' ? (
<Field label="Custom location">
<TextInput
style={styles.input}
value={noForm.placementCustom}
onChangeText={(v) => setNoForm((prev) => ({ ...prev, placementCustom: v }))}
placeholder="bath-kit"
placeholderTextColor="#6b7280"
/>
</Field>
) : null}
{noForm.status === 'lent-to' ? (
<Field label="Lent to">
<TextInput

View File

@@ -14,6 +14,7 @@ function qualityValue(level) {
export default function ItemModal({
visible,
itemForm,
previousCustomPlacements,
setItemModalVisible,
updateItemForm,
pickItemImage,
@@ -81,6 +82,27 @@ export default function ItemModal({
<ChipGroup options={ITEM_PLACEMENTS} value={itemForm.placement} onChange={(v) => updateItemForm('placement', v)} />
</Field>
{itemForm.placement === 'other' ? (
<Field label="Custom location">
<TextInput
style={styles.input}
value={itemForm.placementCustom}
onChangeText={(v) => updateItemForm('placementCustom', v)}
placeholder="bath-kit"
placeholderTextColor="#6b7280"
/>
{previousCustomPlacements?.length ? (
<View style={styles.chipGroup}>
{previousCustomPlacements.slice(0, 6).map((location) => (
<Pressable key={location} style={styles.chip} onPress={() => updateItemForm('placementCustom', location)}>
<Text style={styles.chipText}>{location}</Text>
</Pressable>
))}
</View>
) : null}
</Field>
) : null}
{itemForm.status === 'lent-to' ? (
<Field label="Lent to">
<TextInput

View File

@@ -10,10 +10,10 @@ export const styles = StyleSheet.create({
flex: 1,
},
content: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: TAB_BAR_HEIGHT + 22,
gap: 14,
paddingHorizontal: 14,
paddingTop: 10,
paddingBottom: TAB_BAR_HEIGHT + 20,
gap: 10,
},
statusSpacer: {
height: Platform.OS === 'android' ? 8 : 0,
@@ -86,7 +86,7 @@ export const styles = StyleSheet.create({
},
section: {
gap: 12,
gap: 10,
},
sectionTitle: {
color: '#f1f5f9',
@@ -105,8 +105,8 @@ export const styles = StyleSheet.create({
borderRadius: 16,
borderWidth: 1,
borderColor: '#1f2937',
padding: 12,
gap: 8,
padding: 10,
gap: 6,
},
cardActive: {
borderColor: '#60a5fa',
@@ -120,12 +120,12 @@ export const styles = StyleSheet.create({
borderRadius: 16,
borderWidth: 1,
borderColor: '#1e293b',
padding: 12,
gap: 10,
padding: 10,
gap: 8,
},
cardRow: {
flexDirection: 'row',
gap: 10,
gap: 8,
},
cardTitle: {
color: '#f8fafc',
@@ -134,8 +134,8 @@ export const styles = StyleSheet.create({
},
cardMeta: {
color: '#94a3b8',
marginTop: 3,
fontSize: 13,
marginTop: 2,
fontSize: 12,
},
tripHistoryLabel: {
color: '#93c5fd',
@@ -148,8 +148,8 @@ export const styles = StyleSheet.create({
borderRadius: 18,
borderWidth: 1,
borderColor: '#334155',
padding: 12,
gap: 8,
padding: 10,
gap: 6,
},
tripHeroImage: {
width: '100%',
@@ -160,7 +160,7 @@ export const styles = StyleSheet.create({
tripHeroTitle: {
color: '#f8fafc',
fontWeight: '800',
fontSize: 22,
fontSize: 20,
},
tripListTitle: {
color: '#cbd5e1',
@@ -290,19 +290,19 @@ export const styles = StyleSheet.create({
},
stackButtons: {
gap: 7,
gap: 6,
},
miniBtn: {
backgroundColor: '#1e293b',
borderRadius: 8,
paddingVertical: 7,
paddingHorizontal: 10,
paddingVertical: 6,
paddingHorizontal: 9,
},
miniBtnDanger: {
backgroundColor: '#3b1d22',
borderRadius: 8,
paddingVertical: 7,
paddingHorizontal: 10,
paddingVertical: 6,
paddingHorizontal: 9,
},
miniBtnText: {
color: '#e2e8f0',
@@ -324,18 +324,18 @@ export const styles = StyleSheet.create({
alignSelf: 'stretch',
},
itemThumbWrap: {
paddingTop: 10,
paddingLeft: 10,
paddingTop: 8,
paddingLeft: 8,
},
itemThumbSmall: {
width: 46,
height: 46,
width: 42,
height: 42,
borderRadius: 8,
backgroundColor: '#0b1220',
},
itemThumbPlaceholder: {
width: 46,
height: 46,
width: 42,
height: 42,
borderRadius: 8,
backgroundColor: '#0b1220',
alignItems: 'center',
@@ -344,12 +344,12 @@ export const styles = StyleSheet.create({
borderColor: '#243244',
},
itemThumbPlaceholderText: {
fontSize: 18,
fontSize: 16,
},
itemMain: {
flex: 1,
padding: 10,
gap: 8,
padding: 8,
gap: 6,
},
itemTitle: {
color: '#f8fafc',
@@ -358,17 +358,17 @@ export const styles = StyleSheet.create({
},
itemMeta: {
color: '#94a3b8',
marginTop: 2,
fontSize: 13,
marginTop: 1,
fontSize: 12,
},
quickStatusRow: {
flexDirection: 'row',
gap: 8,
marginTop: 2,
gap: 6,
marginTop: 1,
},
quickStatusBtn: {
paddingVertical: 6,
paddingHorizontal: 12,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 999,
borderWidth: 1,
borderColor: '#334155',
@@ -381,7 +381,7 @@ export const styles = StyleSheet.create({
quickStatusBtnText: {
color: '#cbd5e1',
fontWeight: '700',
fontSize: 12,
fontSize: 11,
},
quickStatusBtnTextActive: {
color: '#dbeafe',
@@ -517,6 +517,35 @@ export const styles = StyleSheet.create({
borderRadius: 10,
backgroundColor: '#111827',
},
imagePreviewBackdrop: {
flex: 1,
backgroundColor: 'rgba(2,6,23,0.88)',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
},
imagePreviewCard: {
width: '100%',
maxWidth: 460,
borderRadius: 14,
borderWidth: 1,
borderColor: '#334155',
backgroundColor: '#0f172a',
padding: 10,
gap: 8,
},
imagePreviewImage: {
width: '100%',
height: 360,
borderRadius: 10,
backgroundColor: '#0b1220',
},
imagePreviewHint: {
color: '#93c5fd',
textAlign: 'center',
fontSize: 12,
fontWeight: '600',
},
tabBarWrap: {
position: 'absolute',
@@ -547,8 +576,8 @@ export const styles = StyleSheet.create({
tabItem: {
alignItems: 'center',
justifyContent: 'center',
gap: 4,
minWidth: 58,
gap: 3,
minWidth: 56,
},
tabAddBtn: {
width: 54,
@@ -566,7 +595,7 @@ export const styles = StyleSheet.create({
},
tabLabel: {
color: '#94a3b8',
fontSize: 12,
fontSize: 11,
fontWeight: '600',
},
tabLabelActive: {
@@ -644,8 +673,8 @@ export const styles = StyleSheet.create({
borderRadius: 20,
borderWidth: 1,
borderColor: '#1e293b',
padding: 16,
gap: 10,
padding: 14,
gap: 8,
},
closeText: {
color: '#93c5fd',

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import { Image, Modal, Pressable, Text, View } from 'react-native';
import ItemCard from '../components/ItemCard';
import { ITEM_STATUSES } from '../constants';
import { styles } from '../styles';
@@ -16,25 +16,35 @@ export default function ItemsTab({
}) {
const [statusFilter, setStatusFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [locationFilter, setLocationFilter] = useState('all');
const [imagePreviewUri, setImagePreviewUri] = useState('');
const categories = useMemo(
() => Array.from(new Set(selectedTripItems.map((item) => item.category?.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b)),
[selectedTripItems]
);
const locations = useMemo(
() => Array.from(new Set(selectedTripItems.map((item) => item.placement?.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b)),
[selectedTripItems]
);
const filteredItems = useMemo(
() =>
selectedTripItems.filter((item) => {
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
const itemCategory = item.category?.trim() || '';
const itemLocation = item.placement?.trim() || '';
const matchCategory = categoryFilter === 'all' || itemCategory === categoryFilter;
return matchStatus && matchCategory;
const matchLocation = locationFilter === 'all' || itemLocation === locationFilter;
return matchStatus && matchCategory && matchLocation;
}),
[selectedTripItems, statusFilter, categoryFilter]
[selectedTripItems, statusFilter, categoryFilter, locationFilter]
);
const filterStatusOptions = ['all', ...ITEM_STATUSES];
const filterCategoryOptions = ['all', ...categories];
const filterLocationOptions = ['all', ...locations];
return (
<View style={styles.section}>
@@ -89,6 +99,18 @@ export default function ItemsTab({
);
})}
</View>
<Text style={styles.cardMeta}>Location</Text>
<View style={styles.chipGroup}>
{filterLocationOptions.map((location) => {
const active = locationFilter === location;
return (
<Pressable key={location} style={[styles.chip, active && styles.chipActive]} onPress={() => setLocationFilter(location)}>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{formatFilterLabel(location)}</Text>
</Pressable>
);
})}
</View>
</View>
) : null}
@@ -100,10 +122,20 @@ export default function ItemsTab({
onDelete={deleteItem}
onQuickPack={() => quickSetItemStatus(item.id, 'packed')}
onQuickUnpack={() => quickSetItemStatus(item.id, 'unpacked')}
onOpenImage={(uri) => setImagePreviewUri(uri)}
/>
))}
{selectedTripItems.length > 0 && filteredItems.length === 0 ? <Text style={styles.muted}>No items match the current filters.</Text> : null}
<Modal visible={!!imagePreviewUri} transparent animationType="fade">
<Pressable style={styles.imagePreviewBackdrop} onPress={() => setImagePreviewUri('')}>
<Pressable style={styles.imagePreviewCard} onPress={() => {}}>
{imagePreviewUri ? <Image source={{ uri: imagePreviewUri }} style={styles.imagePreviewImage} resizeMode="contain" /> : null}
<Text style={styles.imagePreviewHint}>Tap outside to close</Text>
</Pressable>
</Pressable>
</Modal>
</View>
);
}

View File

@@ -28,7 +28,6 @@ export default function TripsTab({
createTrip,
trips,
selectedTripId,
chooseTrip,
setTripAsTemplate,
saveTripEdits,
setTripArchived,
@@ -137,12 +136,9 @@ export default function TripsTab({
<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>
{defaultTemplateTripId === trip.id ? <Text style={styles.cardMeta}>Default template</Text> : null}
</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={() => openView(trip.id)}>
<Text style={styles.miniBtnText}>View</Text>
</Pressable>