From fb54db0619386814343c668f72ae25968ddbc741 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 18 Apr 2026 15:20:25 +0200 Subject: [PATCH] feat: replace native alerts with custom dialog modals --- src/AppRoot.js | 152 ++++++++++++++++++++----------- src/components/AppDialogModal.js | 40 ++++++++ src/styles.js | 53 +++++++++++ src/tabs/HistoryTab.js | 23 +---- 4 files changed, 197 insertions(+), 71 deletions(-) create mode 100644 src/components/AppDialogModal.js diff --git a/src/AppRoot.js b/src/AppRoot.js index 1d92a67..17da674 100644 --- a/src/AppRoot.js +++ b/src/AppRoot.js @@ -1,11 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, StatusBar as RNStatusBar, Text, View } from 'react-native'; +import { KeyboardAvoidingView, Platform, SafeAreaView, ScrollView, StatusBar as RNStatusBar, 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 DatePickerModal from './components/DatePickerModal'; +import AppDialogModal from './components/AppDialogModal'; import ItemModal from './modals/ItemModal'; import CheckupFlowModal from './modals/CheckupFlowModal'; import TripsTab from './tabs/TripsTab'; @@ -80,6 +81,7 @@ export default function AppRoot() { const [checkupNoForm, setCheckupNoForm] = useState(emptyCheckupNoForm()); const [selectedCheckupId, setSelectedCheckupId] = useState(null); + const [dialogState, setDialogState] = useState({ visible: false, title: '', message: '', buttons: [] }); const topInset = Platform.OS === 'android' ? (RNStatusBar.currentHeight || 0) + 10 : 0; @@ -116,6 +118,38 @@ export default function AppRoot() { return checkupSession[checkupFlowIndex] || null; }, [checkupFlowVisible, checkupFlowIndex, checkupSession]); + function closeDialog() { + setDialogState((prev) => ({ ...prev, visible: false })); + } + + function showAlert(title, message) { + setDialogState({ + visible: true, + title, + message, + buttons: [{ text: 'OK', tone: 'primary', onPress: closeDialog }], + }); + } + + function showConfirm({ title, message, confirmText = 'Confirm', onConfirm, tone = 'danger' }) { + setDialogState({ + visible: true, + title, + message, + buttons: [ + { text: 'Cancel', tone: 'neutral', onPress: closeDialog }, + { + text: confirmText, + tone, + onPress: () => { + closeDialog(); + if (typeof onConfirm === 'function') onConfirm(); + }, + }, + ], + }); + } + useEffect(() => { (async () => { try { @@ -125,7 +159,7 @@ export default function AppRoot() { setData({ ...emptyData, ...parsed }); } } catch { - Alert.alert('Error', 'Could not load local data.'); + showAlert('Error', 'Could not load local data.'); } finally { setLoaded(true); } @@ -135,7 +169,7 @@ export default function AppRoot() { useEffect(() => { if (!loaded) return; AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data)).catch(() => { - Alert.alert('Error', 'Could not save local data.'); + showAlert('Error', 'Could not save local data.'); }); }, [data, loaded]); @@ -180,7 +214,7 @@ export default function AppRoot() { async function pickImage(onPicked) { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { - Alert.alert('Permission needed', 'Allow gallery access to select images.'); + showAlert('Permission needed', 'Allow gallery access to select images.'); return; } @@ -198,7 +232,7 @@ export default function AppRoot() { async function takeImage(onPicked) { const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { - Alert.alert('Permission needed', 'Allow camera access to take photos.'); + showAlert('Permission needed', 'Allow camera access to take photos.'); return; } @@ -214,7 +248,7 @@ export default function AppRoot() { function createTrip() { if (!tripForm.name.trim()) { - Alert.alert('Missing name', 'Trip name is required.'); + showAlert('Missing name', 'Trip name is required.'); return false; } @@ -222,12 +256,12 @@ export default function AppRoot() { const end = parseYMD(tripForm.endDate); if (!start || !end) { - Alert.alert('Invalid dates', 'Please select valid trip dates.'); + showAlert('Invalid dates', 'Please select valid trip dates.'); return false; } if (start > end) { - Alert.alert('Invalid dates', 'Start date cannot be after end date.'); + showAlert('Invalid dates', 'Start date cannot be after end date.'); return false; } @@ -283,7 +317,7 @@ export default function AppRoot() { function saveTripEdits(tripId, patch) { if (!patch.name.trim()) { - Alert.alert('Missing name', 'Trip name is required.'); + showAlert('Missing name', 'Trip name is required.'); return false; } @@ -291,12 +325,12 @@ export default function AppRoot() { const end = parseYMD(patch.endDate); if (!start || !end) { - Alert.alert('Invalid dates', 'Please select valid trip dates.'); + showAlert('Invalid dates', 'Please select valid trip dates.'); return false; } if (start > end) { - Alert.alert('Invalid dates', 'Start date cannot be after end date.'); + showAlert('Invalid dates', 'Start date cannot be after end date.'); return false; } @@ -338,30 +372,29 @@ export default function AppRoot() { } 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]; + showConfirm({ + title: 'Delete trip?', + message: 'Trip items and check-up history will also be deleted.', + confirmText: 'Delete', + tone: 'danger', + onConfirm: () => { + 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, - }; - }); - }, + return { + ...prev, + trips, + itemsByTrip, + checkupsByTrip, + defaultTemplateTripId: prev.defaultTemplateTripId === tripId ? null : prev.defaultTemplateTripId, + }; + }); }, - ]); + }); } function openAddItemModal() { @@ -385,12 +418,12 @@ export default function AppRoot() { function saveItemFromModal() { if (!selectedTripId) { - Alert.alert('No trip selected', 'Please select or create a trip first.'); + showAlert('No trip selected', 'Please select or create a trip first.'); return; } if (!itemForm.name.trim()) { - Alert.alert('Missing name', 'Item name is required.'); + showAlert('Missing name', 'Item name is required.'); return; } @@ -491,17 +524,26 @@ export default function AppRoot() { function deleteCheckup(checkupId) { if (!selectedTripId) return; - setData((prev) => { - const existing = prev.checkupsByTrip[selectedTripId] || []; - return { - ...prev, - checkupsByTrip: { - ...prev.checkupsByTrip, - [selectedTripId]: existing.filter((checkup) => checkup.id !== checkupId), - }, - }; + + showConfirm({ + title: 'Delete check-up?', + message: 'This snapshot will be removed from history.', + confirmText: 'Delete', + tone: 'danger', + onConfirm: () => { + setData((prev) => { + const existing = prev.checkupsByTrip[selectedTripId] || []; + return { + ...prev, + checkupsByTrip: { + ...prev.checkupsByTrip, + [selectedTripId]: existing.filter((checkup) => checkup.id !== checkupId), + }, + }; + }); + setSelectedCheckupId((prev) => (prev === checkupId ? null : prev)); + }, }); - setSelectedCheckupId((prev) => (prev === checkupId ? null : prev)); } function createFreshCheckupSession() { @@ -514,11 +556,11 @@ export default function AppRoot() { function startCheckupFlow() { if (!selectedTripId) { - Alert.alert('No trip selected', 'Please select a trip first.'); + showAlert('No trip selected', 'Please select a trip first.'); return; } if (!selectedTripItems.length) { - Alert.alert('No items', 'Add items before starting a check-up.'); + showAlert('No items', 'Add items before starting a check-up.'); return; } @@ -615,18 +657,18 @@ export default function AppRoot() { function saveCheckupSnapshot(sessionToSave) { if (!selectedTripId) { - Alert.alert('No trip selected', 'Please select a trip first.'); + showAlert('No trip selected', 'Please select a trip first.'); return false; } if (!sessionToSave.length) { - Alert.alert('No items', 'Add items before creating a check-up.'); + showAlert('No items', 'Add items before creating a check-up.'); return false; } const pending = sessionToSave.filter((entry) => !entry.confirmed).length; if (pending > 0) { - Alert.alert('Incomplete', `Please confirm all items first (${pending} remaining).`); + showAlert('Incomplete', `Please confirm all items first (${pending} remaining).`); return false; } @@ -669,7 +711,7 @@ export default function AppRoot() { const ok = saveCheckupSnapshot(checkupSession); if (!ok) return; - Alert.alert('Saved', 'Check-up snapshot saved.'); + showAlert('Saved', 'Check-up snapshot saved.'); closeCheckupFlow(); createFreshCheckupSession(); } @@ -809,6 +851,14 @@ export default function AppRoot() { onSaveNo={saveCurrentCheckupNo} onFinish={finishCheckupFlow} /> + + ); } diff --git a/src/components/AppDialogModal.js b/src/components/AppDialogModal.js new file mode 100644 index 0000000..1546945 --- /dev/null +++ b/src/components/AppDialogModal.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { Modal, Pressable, Text, View } from 'react-native'; +import { styles } from '../styles'; + +export default function AppDialogModal({ visible, title, message, buttons = [], onClose }) { + if (!visible) return null; + + const safeButtons = buttons.length ? buttons : [{ text: 'OK' }]; + + return ( + + + + {title || 'Notice'} + {!!message ? {message} : null} + + + {safeButtons.map((button, idx) => { + const tone = button.tone || (button.style === 'destructive' ? 'danger' : button.style === 'cancel' ? 'neutral' : 'primary'); + return ( + + {button.text || 'OK'} + + ); + })} + + + + + ); +} diff --git a/src/styles.js b/src/styles.js index fd89d7d..d962a72 100644 --- a/src/styles.js +++ b/src/styles.js @@ -484,6 +484,59 @@ export const styles = StyleSheet.create({ backgroundColor: 'rgba(2,6,23,0.72)', paddingHorizontal: 12, }, + dialogBackdrop: { + flex: 1, + backgroundColor: 'rgba(2,6,23,0.72)', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 18, + }, + dialogCard: { + width: '100%', + backgroundColor: '#0f172a', + borderRadius: 16, + borderWidth: 1, + borderColor: '#1e293b', + padding: 14, + gap: 12, + }, + dialogTitle: { + color: '#f8fafc', + fontWeight: '700', + fontSize: 17, + }, + dialogMessage: { + color: '#cbd5e1', + lineHeight: 20, + }, + dialogButtonsRow: { + flexDirection: 'row', + gap: 8, + justifyContent: 'flex-end', + flexWrap: 'wrap', + }, + dialogBtn: { + borderRadius: 10, + paddingVertical: 9, + paddingHorizontal: 14, + borderWidth: 1, + }, + dialogBtnPrimary: { + backgroundColor: '#2563eb', + borderColor: '#2563eb', + }, + dialogBtnDanger: { + backgroundColor: '#7f1d1d', + borderColor: '#991b1b', + }, + dialogBtnNeutral: { + backgroundColor: '#1e293b', + borderColor: '#334155', + }, + dialogBtnText: { + color: '#f8fafc', + fontWeight: '700', + }, modalKeyboardWrap: { flex: 1, width: '100%', diff --git a/src/tabs/HistoryTab.js b/src/tabs/HistoryTab.js index d6043f8..3b63bbb 100644 --- a/src/tabs/HistoryTab.js +++ b/src/tabs/HistoryTab.js @@ -1,25 +1,8 @@ import React from 'react'; -import { Alert, Pressable, Text, View } from 'react-native'; +import { Pressable, Text, View } from 'react-native'; import { styles } from '../styles'; -export default function HistoryTab({ - selectedTrip, - selectedTripCheckups, - selectedCheckupId, - setSelectedCheckupId, - onDeleteCheckup, -}) { - function askDelete(checkup) { - Alert.alert('Delete check-up?', 'This snapshot will be removed from history.', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => onDeleteCheckup(checkup.id), - }, - ]); - } - +export default function HistoryTab({ selectedTrip, selectedTripCheckups, selectedCheckupId, setSelectedCheckupId, onDeleteCheckup }) { return ( History @@ -32,7 +15,7 @@ export default function HistoryTab({ setSelectedCheckupId((prev) => (prev === checkup.id ? null : checkup.id))} - onLongPress={() => askDelete(checkup)} + onLongPress={() => onDeleteCheckup(checkup.id)} delayLongPress={280} > {new Date(checkup.createdAt).toLocaleString()}