diff --git a/src/AppRoot.js b/src/AppRoot.js index b81c9bf..e37da2f 100644 --- a/src/AppRoot.js +++ b/src/AppRoot.js @@ -9,6 +9,7 @@ import DatePickerModal from './components/DatePickerModal'; import AppDialogModal from './components/AppDialogModal'; import ItemModal from './modals/ItemModal'; import CheckupFlowModal from './modals/CheckupFlowModal'; +import BackupModal from './modals/BackupModal'; import TripsTab from './tabs/TripsTab'; import ItemsTab from './tabs/ItemsTab'; import CheckupTab from './tabs/CheckupTab'; @@ -85,6 +86,8 @@ export default function AppRoot() { const [selectedCheckupId, setSelectedCheckupId] = useState(null); const [dialogState, setDialogState] = useState({ visible: false, title: '', message: '', buttons: [] }); + const [backupModalVisible, setBackupModalVisible] = useState(false); + const [backupImportText, setBackupImportText] = useState(''); const topInset = Platform.OS === 'android' ? (RNStatusBar.currentHeight || 0) + 10 : 0; const fakeLoadTotalMs = useMemo(() => 1200 + Math.floor(Math.random() * 2801), []); @@ -785,6 +788,58 @@ export default function AppRoot() { openAddItemModal(); } + function buildBackupJson() { + return JSON.stringify( + { + version: 2, + exportedAt: new Date().toISOString(), + data, + }, + null, + 2 + ); + } + + function openBackupModal() { + setBackupImportText(''); + setBackupModalVisible(true); + } + + function applyBackupImport() { + if (!backupImportText.trim()) { + showAlert('Missing backup', 'Paste backup JSON first.'); + return; + } + + let parsed; + try { + parsed = JSON.parse(backupImportText); + } catch { + showAlert('Invalid JSON', 'Backup JSON could not be parsed.'); + return; + } + + const payload = parsed?.data && typeof parsed.data === 'object' ? parsed.data : parsed; + + if (!payload || typeof payload !== 'object' || !Array.isArray(payload.trips) || !payload.itemsByTrip || !payload.checkupsByTrip) { + showAlert('Invalid backup', 'Backup format is not supported.'); + return; + } + + showConfirm({ + title: 'Import backup?', + message: 'This will replace all current local data.', + confirmText: 'Import', + tone: 'danger', + onConfirm: () => { + setData({ ...emptyData, ...payload }); + setBackupModalVisible(false); + setBackupImportText(''); + showAlert('Imported', 'Backup data was restored.'); + }, + }); + } + if (!appReady) { return ( @@ -832,6 +887,7 @@ export default function AppRoot() { openDatePicker={openDatePicker} activeTripItemCount={selectedTripItems.length} activeTripCheckupCount={selectedTripCheckups.length} + openBackupModal={openBackupModal} /> )} @@ -905,6 +961,15 @@ export default function AppRoot() { onFinish={finishCheckupFlow} /> + setBackupModalVisible(false)} + exportJson={buildBackupJson()} + importJson={backupImportText} + setImportJson={setBackupImportText} + applyImport={applyBackupImport} + /> + {item.name} - {item.category || 'uncategorized'} · {item.status} + {item.category || 'uncategorized'} · {formatStatusLabel(item.status, item.lentTo)} Location: {item.placement} - {item.status === 'lent-to' && !!item.lentTo ? Lent to: {item.lentTo} : null} + {item.status === 'lent-to' && !!item.lentTo ? Borrower: {item.lentTo} : null} {!!item.description ? {item.description} : null} diff --git a/src/modals/BackupModal.js b/src/modals/BackupModal.js new file mode 100644 index 0000000..62cdfe2 --- /dev/null +++ b/src/modals/BackupModal.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import { styles } from '../styles'; + +export default function BackupModal({ + visible, + onClose, + exportJson, + importJson, + setImportJson, + applyImport, +}) { + return ( + + + + + + Backup & Restore + + Close + + + + + Export JSON + Copy this JSON and store it safely. + + Import JSON + Paste a previous backup. This will replace current data. + + + Import & Replace + + + + + + + ); +} diff --git a/src/modals/CheckupFlowModal.js b/src/modals/CheckupFlowModal.js index 9770043..2466dc9 100644 --- a/src/modals/CheckupFlowModal.js +++ b/src/modals/CheckupFlowModal.js @@ -4,6 +4,7 @@ import { ITEM_PLACEMENTS, ITEM_STATUSES } from '../constants'; import ChipGroup from '../components/ChipGroup'; import Field from '../components/Field'; import { styles } from '../styles'; +import { formatStatusLabel } from '../utils/labels'; export default function CheckupFlowModal({ visible, @@ -64,8 +65,7 @@ export default function CheckupFlowModal({ {entry.name} {entry.category || 'uncategorized'} - Current: {entry.current.status} · {entry.current.placement} - {entry.current.status === 'lent-to' && entry.current.lentTo ? ` · ${entry.current.lentTo}` : ''} + Current: {formatStatusLabel(entry.current.status, entry.current.lentTo)} · {entry.current.placement} {mode === 'question' ? ( diff --git a/src/styles.js b/src/styles.js index 10ea627..afbc513 100644 --- a/src/styles.js +++ b/src/styles.js @@ -186,6 +186,19 @@ export const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 11, }, + inputMultiline: { + borderWidth: 1, + borderColor: '#243244', + borderRadius: 10, + backgroundColor: '#0b1220', + color: '#e5e7eb', + paddingHorizontal: 10, + paddingVertical: 10, + minHeight: 150, + textAlignVertical: 'top', + fontSize: 12, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, dateInput: { borderWidth: 1, borderColor: '#29415e', diff --git a/src/tabs/HistoryTab.js b/src/tabs/HistoryTab.js index 3b63bbb..fd2fb46 100644 --- a/src/tabs/HistoryTab.js +++ b/src/tabs/HistoryTab.js @@ -1,6 +1,7 @@ import React from 'react'; import { Pressable, Text, View } from 'react-native'; import { styles } from '../styles'; +import { formatStatusLabel } from '../utils/labels'; export default function HistoryTab({ selectedTrip, selectedTripCheckups, selectedCheckupId, setSelectedCheckupId, onDeleteCheckup }) { return ( @@ -32,8 +33,7 @@ export default function HistoryTab({ selectedTrip, selectedTripCheckups, selecte {entry.name} - {entry.status} · {entry.placement} - {entry.status === 'lent-to' && entry.lentTo ? ` · ${entry.lentTo}` : ''} + {formatStatusLabel(entry.status, entry.lentTo)} · {entry.placement} ))} diff --git a/src/tabs/ItemsTab.js b/src/tabs/ItemsTab.js index b197a4d..b6827a0 100644 --- a/src/tabs/ItemsTab.js +++ b/src/tabs/ItemsTab.js @@ -3,6 +3,7 @@ import { Pressable, Text, View } from 'react-native'; import ItemCard from '../components/ItemCard'; import { ITEM_STATUSES } from '../constants'; import { styles } from '../styles'; +import { formatFilterLabel, formatStatusLabel } from '../utils/labels'; export default function ItemsTab({ selectedTrip, @@ -52,10 +53,10 @@ export default function ItemsTab({ Quick actions bulkSetItemStatus(filteredItems.map((x) => x.id), 'packed')}> - Pack shown ({filteredItems.length}) + Pack All ({filteredItems.length}) bulkSetItemStatus(filteredItems.map((x) => x.id), 'unpacked')}> - Unpack shown ({filteredItems.length}) + Unpack All ({filteredItems.length}) @@ -71,7 +72,7 @@ export default function ItemsTab({ const active = statusFilter === status; return ( setStatusFilter(status)}> - {status} + {status === 'all' ? formatFilterLabel(status) : formatStatusLabel(status)} ); })} @@ -83,7 +84,7 @@ export default function ItemsTab({ const active = categoryFilter === category; return ( setCategoryFilter(category)}> - {category} + {formatFilterLabel(category)} ); })} diff --git a/src/tabs/TripsTab.js b/src/tabs/TripsTab.js index 56e2308..ac80b2c 100644 --- a/src/tabs/TripsTab.js +++ b/src/tabs/TripsTab.js @@ -38,6 +38,7 @@ export default function TripsTab({ openDatePicker, activeTripItemCount, activeTripCheckupCount, + openBackupModal, }) { const [createModalVisible, setCreateModalVisible] = useState(false); const [viewTripId, setViewTripId] = useState(null); @@ -105,9 +106,14 @@ export default function TripsTab({ Trips - setCreateModalVisible(true)}> - + New Trip - + + + Backup + + setCreateModalVisible(true)}> + + New Trip + + {activeTrip ? ( diff --git a/src/utils/labels.js b/src/utils/labels.js new file mode 100644 index 0000000..114923a --- /dev/null +++ b/src/utils/labels.js @@ -0,0 +1,23 @@ +export function toTitleWords(value) { + if (!value) return ''; + return value + .toString() + .split('-') + .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join(' '); +} + +export function formatStatusLabel(status, lentTo = '') { + if (!status) return ''; + if (status === 'lent-to') { + const name = lentTo?.trim(); + return name ? `Lent To ${name}` : 'Lent To'; + } + return toTitleWords(status); +} + +export function formatFilterLabel(value) { + if (!value) return ''; + if (value === 'all') return 'All'; + return toTitleWords(value); +}