diff --git a/TODO.md b/TODO.md index 4dd7986..b12e180 100644 --- a/TODO.md +++ b/TODO.md @@ -39,11 +39,14 @@ Improving & Fixing Bugs (V3) - [x] Extra UI polish pass (spacing, cards, hierarchy) - [x] Centered and enlarged edit/check-up modals to fully overlay nav - [x] Fixed modal keyboard glitching (stable centered keyboard-aware layout) +- [x] Added trip view editing in modal (name/location/date/image) +- [x] Added trip archive/unarchive flow with archived section +- [x] Added item filters + bulk pack/unpack actions ## Next Improvements (Requested) -- [ ] Edit trip directly in the trip view modal (name/location/dates/image) -- [ ] Add archive flow for trips (hide from active list without deleting history) +- [x] Edit trip directly in the trip view modal (name/location/dates/image) +- [x] Add archive flow for trips (hide from active list without deleting history) - [ ] Enhance check-up modal UX (progress bar + back/skip controls) -- [ ] Add bulk item actions and filters (pack all/unpack all + status/category chips) +- [x] Add bulk item actions and filters (pack all/unpack all + status/category chips) - [ ] Add image optimization controls before save (compress/crop) - [ ] Add JSON export/import backup + restore flow diff --git a/src/AppRoot.js b/src/AppRoot.js index e1c4cd7..1d92a67 100644 --- a/src/AppRoot.js +++ b/src/AppRoot.js @@ -83,7 +83,9 @@ export default function AppRoot() { const topInset = Platform.OS === 'android' ? (RNStatusBar.currentHeight || 0) + 10 : 0; - const selectedTrip = useMemo(() => data.trips.find((trip) => trip.id === selectedTripId) || null, [data.trips, selectedTripId]); + const visibleTrips = useMemo(() => data.trips.filter((trip) => !trip.archived), [data.trips]); + + const selectedTrip = useMemo(() => visibleTrips.find((trip) => trip.id === selectedTripId) || null, [visibleTrips, selectedTripId]); const selectedTripItems = useMemo(() => { if (!selectedTripId) return []; @@ -139,18 +141,18 @@ export default function AppRoot() { useEffect(() => { if (!loaded) return; - if (!data.trips.length) { + if (!visibleTrips.length) { setSelectedTripId(null); return; } - if (selectedTripId && data.trips.some((trip) => trip.id === selectedTripId)) { + if (selectedTripId && visibleTrips.some((trip) => trip.id === selectedTripId)) { return; } - const bestTripId = findBestTripId(data.trips); - setSelectedTripId(bestTripId || data.trips[0].id); - }, [data.trips, selectedTripId, loaded]); + const bestTripId = findBestTripId(visibleTrips); + setSelectedTripId(bestTripId || visibleTrips[0].id); + }, [visibleTrips, selectedTripId, loaded]); useEffect(() => { if (tab !== 'checkup') return; @@ -244,6 +246,7 @@ export default function AppRoot() { startDate: tripForm.startDate, endDate: tripForm.endDate, imageUri: tripForm.imageUri, + archived: false, createdAt: now, updatedAt: now, }, @@ -278,6 +281,62 @@ export default function AppRoot() { setData((prev) => ({ ...prev, defaultTemplateTripId: tripId })); } + function saveTripEdits(tripId, patch) { + if (!patch.name.trim()) { + Alert.alert('Missing name', 'Trip name is required.'); + return false; + } + + const start = parseYMD(patch.startDate); + const end = parseYMD(patch.endDate); + + if (!start || !end) { + Alert.alert('Invalid dates', 'Please select valid trip dates.'); + return false; + } + + if (start > end) { + Alert.alert('Invalid dates', 'Start date cannot be after end date.'); + return false; + } + + setData((prev) => ({ + ...prev, + trips: prev.trips.map((trip) => + trip.id === tripId + ? { + ...trip, + name: patch.name.trim(), + location: patch.location.trim(), + startDate: patch.startDate, + endDate: patch.endDate, + imageUri: patch.imageUri, + updatedAt: Date.now(), + } + : trip + ), + })); + + return true; + } + + function setTripArchived(tripId, archived) { + setData((prev) => ({ + ...prev, + trips: prev.trips.map((trip) => + trip.id === tripId + ? { + ...trip, + archived, + archivedAt: archived ? Date.now() : null, + updatedAt: Date.now(), + } + : trip + ), + defaultTemplateTripId: archived && prev.defaultTemplateTripId === tripId ? null : prev.defaultTemplateTripId, + })); + } + function deleteTrip(tripId) { Alert.alert('Delete trip?', 'Trip items and check-up history will also be deleted.', [ { text: 'Cancel', style: 'cancel' }, @@ -406,6 +465,30 @@ export default function AppRoot() { }); } + function bulkSetItemStatus(itemIds, status) { + if (!selectedTripId || !itemIds.length) return; + const idSet = new Set(itemIds); + setData((prev) => { + const items = prev.itemsByTrip[selectedTripId] || []; + return { + ...prev, + itemsByTrip: { + ...prev.itemsByTrip, + [selectedTripId]: items.map((item) => + idSet.has(item.id) + ? { + ...item, + status, + lentTo: status === 'lent-to' ? item.lentTo : '', + updatedAt: Date.now(), + } + : item + ), + }, + }; + }); + } + function deleteCheckup(checkupId) { if (!selectedTripId) return; setData((prev) => { @@ -624,20 +707,32 @@ export default function AppRoot() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - + {tab === 'trips' && ( pickImage((uri) => updateTripForm('imageUri', uri))} - takeTripImage={() => takeImage((uri) => updateTripForm('imageUri', uri))} + pickTripImage={(onPicked) => + pickImage((uri) => { + if (typeof onPicked === 'function') onPicked(uri); + else updateTripForm('imageUri', uri); + }) + } + takeTripImage={(onPicked) => + takeImage((uri) => { + if (typeof onPicked === 'function') onPicked(uri); + else updateTripForm('imageUri', uri); + }) + } templateTrip={templateTrip} createTrip={createTrip} trips={data.trips} selectedTripId={selectedTripId} chooseTrip={setSelectedTripId} setTripAsTemplate={setTripAsTemplate} + saveTripEdits={saveTripEdits} + setTripArchived={setTripArchived} deleteTrip={deleteTrip} onInputFocus={onInputFocus} defaultTemplateTripId={data.defaultTemplateTripId} @@ -655,6 +750,7 @@ export default function AppRoot() { openEditItemModal={openEditItemModal} deleteItem={deleteItem} quickSetItemStatus={quickSetItemStatus} + bulkSetItemStatus={bulkSetItemStatus} /> )} diff --git a/src/styles.js b/src/styles.js index 32ccb9d..fd89d7d 100644 --- a/src/styles.js +++ b/src/styles.js @@ -89,6 +89,10 @@ export const styles = StyleSheet.create({ cardActive: { borderColor: '#60a5fa', }, + cardArchived: { + opacity: 0.72, + borderColor: '#374151', + }, cardSoft: { backgroundColor: '#0f172a', borderRadius: 16, diff --git a/src/tabs/ItemsTab.js b/src/tabs/ItemsTab.js index 7c2abc4..b197a4d 100644 --- a/src/tabs/ItemsTab.js +++ b/src/tabs/ItemsTab.js @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import ItemCard from '../components/ItemCard'; +import { ITEM_STATUSES } from '../constants'; import { styles } from '../styles'; export default function ItemsTab({ @@ -10,7 +11,30 @@ export default function ItemsTab({ openEditItemModal, deleteItem, quickSetItemStatus, + bulkSetItemStatus, }) { + const [statusFilter, setStatusFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('all'); + + const categories = useMemo( + () => Array.from(new Set(selectedTripItems.map((item) => item.category?.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 matchCategory = categoryFilter === 'all' || itemCategory === categoryFilter; + return matchStatus && matchCategory; + }), + [selectedTripItems, statusFilter, categoryFilter] + ); + + const filterStatusOptions = ['all', ...ITEM_STATUSES]; + const filterCategoryOptions = ['all', ...categories]; + return ( @@ -23,7 +47,51 @@ export default function ItemsTab({ {!selectedTrip ? Select a trip first. : null} {selectedTripItems.length === 0 && selectedTrip ? No items yet. : null} - {selectedTripItems.map((item) => ( + {selectedTrip ? ( + + Quick actions + + bulkSetItemStatus(filteredItems.map((x) => x.id), 'packed')}> + Pack shown ({filteredItems.length}) + + bulkSetItemStatus(filteredItems.map((x) => x.id), 'unpacked')}> + Unpack shown ({filteredItems.length}) + + + + ) : null} + + {selectedTripItems.length > 0 ? ( + + Filters + + Status + + {filterStatusOptions.map((status) => { + const active = statusFilter === status; + return ( + setStatusFilter(status)}> + {status} + + ); + })} + + + Category + + {filterCategoryOptions.map((category) => { + const active = categoryFilter === category; + return ( + setCategoryFilter(category)}> + {category} + + ); + })} + + + ) : null} + + {filteredItems.map((item) => ( quickSetItemStatus(item.id, 'unpacked')} /> ))} + + {selectedTripItems.length > 0 && filteredItems.length === 0 ? No items match the current filters. : null} ); } diff --git a/src/tabs/TripsTab.js b/src/tabs/TripsTab.js index 5ececf8..a276762 100644 --- a/src/tabs/TripsTab.js +++ b/src/tabs/TripsTab.js @@ -1,5 +1,6 @@ import React, { useMemo, useState } from 'react'; import { Image, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import DatePickerModal from '../components/DatePickerModal'; import Field from '../components/Field'; import { styles } from '../styles'; @@ -13,6 +14,14 @@ function DateField({ label, value, onPress }) { ); } +const emptyEditForm = { + name: '', + location: '', + startDate: '', + endDate: '', + imageUri: '', +}; + export default function TripsTab({ tripForm, updateTripForm, @@ -24,6 +33,8 @@ export default function TripsTab({ selectedTripId, chooseTrip, setTripAsTemplate, + saveTripEdits, + setTripArchived, deleteTrip, onInputFocus, defaultTemplateTripId, @@ -33,24 +44,72 @@ export default function TripsTab({ }) { const [createModalVisible, setCreateModalVisible] = useState(false); const [viewTripId, setViewTripId] = useState(null); + const [editMode, setEditMode] = useState(false); + const [editForm, setEditForm] = useState(emptyEditForm); + const [viewDatePicker, setViewDatePicker] = useState({ visible: false, field: 'startDate' }); const activeTrip = useMemo(() => trips.find((trip) => trip.id === selectedTripId) || null, [trips, selectedTripId]); const viewingTrip = useMemo(() => trips.find((trip) => trip.id === viewTripId) || null, [trips, viewTripId]); + const activeTrips = useMemo(() => trips.filter((trip) => !trip.archived), [trips]); + const archivedTrips = useMemo(() => trips.filter((trip) => trip.archived), [trips]); function submitCreateTrip() { const ok = createTrip(); if (ok) setCreateModalVisible(false); } + function openView(tripId) { + const trip = trips.find((x) => x.id === tripId); + if (!trip) return; + setViewTripId(tripId); + setEditMode(false); + setEditForm({ + name: trip.name || '', + location: trip.location || '', + startDate: trip.startDate || '', + endDate: trip.endDate || '', + imageUri: trip.imageUri || '', + }); + } + + function updateEditForm(field, value) { + setEditForm((prev) => ({ ...prev, [field]: value })); + } + + function saveEditFromView() { + if (!viewingTrip) return; + const ok = saveTripEdits(viewingTrip.id, editForm); + if (!ok) return; + setEditMode(false); + } + + function pickViewTripImage() { + pickTripImage((uri) => updateEditForm('imageUri', uri)); + } + + function takeViewTripImage() { + takeTripImage((uri) => updateEditForm('imageUri', uri)); + } + function applyTemplateFromView() { if (!viewingTrip) return; setTripAsTemplate(viewingTrip.id); } + function toggleArchiveFromView() { + if (!viewingTrip) return; + setTripArchived(viewingTrip.id, !viewingTrip.archived); + if (!viewingTrip.archived) { + setViewTripId(null); + setEditMode(false); + } + } + function deleteFromView() { if (!viewingTrip) return; const tripId = viewingTrip.id; setViewTripId(null); + setEditMode(false); deleteTrip(tripId); } @@ -71,12 +130,12 @@ export default function TripsTab({ {activeTripItemCount} items · {activeTripCheckupCount} check-ups ) : ( - Create your first trip to get started. + No active trips. Unarchive or create one. )} - All Trips + Active Trips - {trips + {activeTrips .slice() .sort((a, b) => b.startDate.localeCompare(a.startDate)) .map((trip) => ( @@ -91,7 +150,7 @@ export default function TripsTab({ chooseTrip(trip.id)}> Select - setViewTripId(trip.id)}> + openView(trip.id)}> View @@ -100,6 +159,31 @@ export default function TripsTab({ ))} + {archivedTrips.length > 0 ? ( + <> + Archived Trips + {archivedTrips + .slice() + .sort((a, b) => b.startDate.localeCompare(a.startDate)) + .map((trip) => ( + + + + {trip.name} + {trip.location || 'No location'} · {trip.startDate} → {trip.endDate} + Archived + + + openView(trip.id)}> + View + + + + + ))} + + ) : null} + @@ -180,7 +264,12 @@ export default function TripsTab({ Trip View - setViewTripId(null)}> + { + setViewTripId(null); + setEditMode(false); + }} + > Close @@ -192,25 +281,94 @@ export default function TripsTab({ contentContainerStyle={{ paddingBottom: 12 }} showsVerticalScrollIndicator={false} > - {viewingTrip.imageUri ? : null} - {viewingTrip.name} - {viewingTrip.location || 'No location'} - {viewingTrip.startDate} → {viewingTrip.endDate} - {defaultTemplateTripId === viewingTrip.id ? 'Default template trip' : 'Not default template'} + {!editMode ? ( + <> + {viewingTrip.imageUri ? : null} + {viewingTrip.name} + {viewingTrip.location || 'No location'} + {viewingTrip.startDate} → {viewingTrip.endDate} + {defaultTemplateTripId === viewingTrip.id ? 'Default template trip' : 'Not default template'} + {viewingTrip.archived ? 'Archived' : 'Active'} - - Set as Template - + setEditMode(true)}> + Edit Trip + - - Delete Trip - + + Set as Template + + + + {viewingTrip.archived ? 'Unarchive Trip' : 'Archive Trip'} + + + + Delete Trip + + + ) : ( + <> + + updateEditForm('name', v)} + placeholder="Trip name" + placeholderTextColor="#6b7280" + onFocus={onInputFocus} + /> + + + + updateEditForm('location', v)} + placeholder="Location" + placeholderTextColor="#6b7280" + onFocus={onInputFocus} + /> + + + setViewDatePicker({ visible: true, field: 'startDate' })} /> + setViewDatePicker({ visible: true, field: 'endDate' })} /> + + + + Take photo + + + {editForm.imageUri ? 'From gallery (change)' : 'From gallery'} + + + + {editForm.imageUri ? : null} + + + Save Trip + + setEditMode(false)}> + Cancel Edit + + + )} ) : null} + + setViewDatePicker((prev) => ({ ...prev, visible: false }))} + onSelect={(ymd) => { + updateEditForm(viewDatePicker.field, ymd); + setViewDatePicker((prev) => ({ ...prev, visible: false })); + }} + /> ); }