feat: add calendar date picker, nav icons, and input-focused keyboard scrolling
This commit is contained in:
7
TODO.md
7
TODO.md
@@ -1,7 +1,7 @@
|
||||
# TODO - Luggage List
|
||||
|
||||
## Stage
|
||||
Improving & Fixing Bugs (V2)
|
||||
Improving & Fixing Bugs (V3)
|
||||
|
||||
## V2 Changes Requested
|
||||
- [x] Trip can be selected from everywhere (global trip picker)
|
||||
@@ -29,3 +29,8 @@ Improving & Fixing Bugs (V2)
|
||||
- [x] V2 redesign + behavior fixes implemented
|
||||
- [x] Removed legacy template src folder
|
||||
- [x] Rebuilt app into modular `src/` structure (tabs/components/modals/styles/utils)
|
||||
- [x] Fixed status-bar overlap with top spacing
|
||||
- [x] Replaced trip date text inputs with calendar modal picker
|
||||
- [x] Improved keyboard focus scrolling to focused input (not scroll-to-end)
|
||||
- [x] Reworked bottom nav to real icons + labels
|
||||
- [x] Clarified history as selected-trip check-up history
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 ItemModal from './modals/ItemModal';
|
||||
import CheckupFixModal from './modals/CheckupFixModal';
|
||||
import TripsTab from './tabs/TripsTab';
|
||||
@@ -45,6 +46,7 @@ export default function AppRoot() {
|
||||
|
||||
const [selectedTripId, setSelectedTripId] = useState(null);
|
||||
const [tripForm, setTripForm] = useState(emptyTripForm());
|
||||
const [datePicker, setDatePicker] = useState({ visible: false, field: 'startDate' });
|
||||
|
||||
const [itemModalVisible, setItemModalVisible] = useState(false);
|
||||
const [itemForm, setItemForm] = useState(emptyItemForm());
|
||||
@@ -130,6 +132,15 @@ export default function AppRoot() {
|
||||
setItemForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function openDatePicker(field) {
|
||||
setDatePicker({ visible: true, field });
|
||||
}
|
||||
|
||||
function onSelectDate(ymd) {
|
||||
updateTripForm(datePicker.field, ymd);
|
||||
setDatePicker((prev) => ({ ...prev, visible: false }));
|
||||
}
|
||||
|
||||
async function pickImage(onPicked) {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
@@ -158,7 +169,7 @@ export default function AppRoot() {
|
||||
const end = parseYMD(tripForm.endDate);
|
||||
|
||||
if (!start || !end) {
|
||||
Alert.alert('Invalid dates', 'Use YYYY-MM-DD format.');
|
||||
Alert.alert('Invalid dates', 'Please select valid trip dates.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -456,16 +467,21 @@ export default function AppRoot() {
|
||||
createFreshCheckupSession();
|
||||
}
|
||||
|
||||
function focusToEnd() {
|
||||
function onInputFocus(event) {
|
||||
const target = event?.target;
|
||||
if (!target) return;
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollToEnd?.({ animated: true });
|
||||
const scrollFn = scrollRef.current?.scrollResponderScrollNativeHandleToKeyboard;
|
||||
if (typeof scrollFn === 'function') {
|
||||
scrollFn(target, 90, true);
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style="light" translucent={false} />
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.muted}>Loading local data...</Text>
|
||||
</View>
|
||||
@@ -475,7 +491,7 @@ export default function AppRoot() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style="light" translucent={false} />
|
||||
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<ScrollView
|
||||
@@ -484,6 +500,7 @@ export default function AppRoot() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.statusSpacer} />
|
||||
<TripPicker trips={data.trips} selectedTripId={selectedTripId} onChooseTrip={setSelectedTripId} />
|
||||
|
||||
{tab === 'trips' && (
|
||||
@@ -498,8 +515,9 @@ export default function AppRoot() {
|
||||
chooseTrip={setSelectedTripId}
|
||||
setTripAsTemplate={setTripAsTemplate}
|
||||
deleteTrip={deleteTrip}
|
||||
focusToEnd={focusToEnd}
|
||||
onInputFocus={onInputFocus}
|
||||
defaultTemplateTripId={data.defaultTemplateTripId}
|
||||
openDatePicker={openDatePicker}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -525,6 +543,7 @@ export default function AppRoot() {
|
||||
|
||||
{tab === 'history' && (
|
||||
<HistoryTab
|
||||
selectedTrip={selectedTrip}
|
||||
selectedTripCheckups={selectedTripCheckups}
|
||||
selectedCheckupId={selectedCheckupId}
|
||||
setSelectedCheckupId={setSelectedCheckupId}
|
||||
@@ -535,6 +554,14 @@ export default function AppRoot() {
|
||||
|
||||
<BottomTab current={tab} onChange={setTab} />
|
||||
|
||||
<DatePickerModal
|
||||
visible={datePicker.visible}
|
||||
title={datePicker.field === 'startDate' ? 'Pick start date' : 'Pick end date'}
|
||||
value={tripForm[datePicker.field]}
|
||||
onClose={() => setDatePicker((prev) => ({ ...prev, visible: false }))}
|
||||
onSelect={onSelectDate}
|
||||
/>
|
||||
|
||||
<ItemModal
|
||||
visible={itemModalVisible}
|
||||
itemForm={itemForm}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { styles } from '../styles';
|
||||
|
||||
export default function BottomTab({ current, onChange }) {
|
||||
const tabs = [
|
||||
{ key: 'trips', label: 'Trips' },
|
||||
{ key: 'items', label: 'Items' },
|
||||
{ key: 'checkup', label: 'Check-Up' },
|
||||
{ key: 'history', label: 'History' },
|
||||
{ key: 'trips', label: 'Trips', icon: 'airplane-outline', iconActive: 'airplane' },
|
||||
{ key: 'items', label: 'Items', icon: 'briefcase-outline', iconActive: 'briefcase' },
|
||||
{ key: 'checkup', label: 'Check-Up', icon: 'checkmark-circle-outline', iconActive: 'checkmark-circle' },
|
||||
{ key: 'history', label: 'History', icon: 'time-outline', iconActive: 'time' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -17,7 +18,11 @@ export default function BottomTab({ current, onChange }) {
|
||||
const active = current === tab.key;
|
||||
return (
|
||||
<Pressable key={tab.key} onPress={() => onChange(tab.key)} style={styles.tabItem}>
|
||||
<View style={[styles.tabDot, active && styles.tabDotActive]} />
|
||||
<Ionicons
|
||||
name={active ? tab.iconActive : tab.icon}
|
||||
size={18}
|
||||
color={active ? '#dbeafe' : '#94a3b8'}
|
||||
/>
|
||||
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>{tab.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
93
src/components/DatePickerModal.js
Normal file
93
src/components/DatePickerModal.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, Text, View } from 'react-native';
|
||||
import { styles } from '../styles';
|
||||
import { todayYMD } from '../utils/date';
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
|
||||
function toYMD(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const d = `${date.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function parseFromYMD(value) {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return new Date();
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? new Date() : date;
|
||||
}
|
||||
|
||||
function monthLabel(date) {
|
||||
return date.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function buildMonthGrid(viewDate) {
|
||||
const first = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
|
||||
const startWeekday = (first.getDay() + 6) % 7;
|
||||
const daysInMonth = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 0).getDate();
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < startWeekday; i += 1) cells.push(null);
|
||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||
cells.push(new Date(viewDate.getFullYear(), viewDate.getMonth(), day));
|
||||
}
|
||||
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
return cells;
|
||||
}
|
||||
|
||||
export default function DatePickerModal({ visible, value, onClose, onSelect, title = 'Pick date' }) {
|
||||
const [viewDate, setViewDate] = useState(parseFromYMD(value || todayYMD()));
|
||||
|
||||
const grid = useMemo(() => buildMonthGrid(viewDate), [viewDate]);
|
||||
const selected = value || todayYMD();
|
||||
|
||||
const goMonth = (diff) => {
|
||||
setViewDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + diff, 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View style={styles.dateModalBackdrop}>
|
||||
<View style={styles.dateModalCard}>
|
||||
<View style={styles.sectionRow}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text style={styles.closeText}>Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.calendarHeader}>
|
||||
<Pressable style={styles.calendarNavBtn} onPress={() => goMonth(-1)}>
|
||||
<Text style={styles.calendarNavText}>‹</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.calendarMonthText}>{monthLabel(viewDate)}</Text>
|
||||
<Pressable style={styles.calendarNavBtn} onPress={() => goMonth(1)}>
|
||||
<Text style={styles.calendarNavText}>›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.calendarWeekRow}>
|
||||
{WEEKDAYS.map((w) => (
|
||||
<Text key={w} style={styles.calendarWeekday}>{w}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.calendarGrid}>
|
||||
{grid.map((cell, idx) => {
|
||||
if (!cell) return <View key={`empty-${idx}`} style={styles.calendarCell} />;
|
||||
const ymd = toYMD(cell);
|
||||
const isSelected = ymd === selected;
|
||||
return (
|
||||
<Pressable key={ymd} style={[styles.calendarCell, isSelected && styles.calendarCellActive]} onPress={() => onSelect(ymd)}>
|
||||
<Text style={[styles.calendarCellText, isSelected && styles.calendarCellTextActive]}>{cell.getDate()}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
108
src/styles.js
108
src/styles.js
@@ -11,10 +11,13 @@ export const styles = StyleSheet.create({
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 12,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 18,
|
||||
paddingTop: 10,
|
||||
paddingBottom: TAB_BAR_HEIGHT + 20,
|
||||
gap: 12,
|
||||
},
|
||||
statusSpacer: {
|
||||
height: Platform.OS === 'android' ? 8 : 0,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -108,6 +111,12 @@ export const styles = StyleSheet.create({
|
||||
marginTop: 3,
|
||||
fontSize: 13,
|
||||
},
|
||||
tripHistoryLabel: {
|
||||
color: '#93c5fd',
|
||||
fontSize: 13,
|
||||
marginTop: -2,
|
||||
marginBottom: 2,
|
||||
},
|
||||
|
||||
fieldWrap: {
|
||||
gap: 6,
|
||||
@@ -125,6 +134,18 @@ export const styles = StyleSheet.create({
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 11,
|
||||
},
|
||||
dateInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#29415e',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#0b1220',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
dateInputText: {
|
||||
color: '#dbeafe',
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
chipGroup: {
|
||||
flexDirection: 'row',
|
||||
@@ -331,16 +352,9 @@ export const styles = StyleSheet.create({
|
||||
},
|
||||
tabItem: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
tabDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 99,
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
tabDotActive: {
|
||||
backgroundColor: '#60a5fa',
|
||||
minWidth: 62,
|
||||
},
|
||||
tabLabel: {
|
||||
color: '#94a3b8',
|
||||
@@ -373,4 +387,76 @@ export const styles = StyleSheet.create({
|
||||
color: '#93c5fd',
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
dateModalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(2,6,23,0.75)',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
dateModalCard: {
|
||||
backgroundColor: '#0f172a',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1e293b',
|
||||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
calendarHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
calendarNavBtn: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#1e293b',
|
||||
},
|
||||
calendarNavText: {
|
||||
color: '#dbeafe',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
lineHeight: 22,
|
||||
},
|
||||
calendarMonthText: {
|
||||
color: '#f8fafc',
|
||||
fontWeight: '700',
|
||||
fontSize: 15,
|
||||
},
|
||||
calendarWeekRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
calendarWeekday: {
|
||||
color: '#94a3b8',
|
||||
width: '14.2%',
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
},
|
||||
calendarGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
calendarCell: {
|
||||
width: '14.2%',
|
||||
height: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 10,
|
||||
marginVertical: 2,
|
||||
},
|
||||
calendarCellActive: {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
calendarCellText: {
|
||||
color: '#cbd5e1',
|
||||
fontWeight: '600',
|
||||
},
|
||||
calendarCellTextActive: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import React from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { styles } from '../styles';
|
||||
|
||||
export default function HistoryTab({ selectedTripCheckups, selectedCheckupId, setSelectedCheckupId }) {
|
||||
export default function HistoryTab({ selectedTrip, selectedTripCheckups, selectedCheckupId, setSelectedCheckupId }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>History</Text>
|
||||
{selectedTripCheckups.length === 0 ? <Text style={styles.muted}>No check-ups saved yet.</Text> : null}
|
||||
|
||||
{!selectedTrip ? <Text style={styles.muted}>Select a trip first.</Text> : null}
|
||||
{selectedTrip ? <Text style={styles.tripHistoryLabel}>Check-ups for: {selectedTrip.name}</Text> : null}
|
||||
{selectedTrip && selectedTripCheckups.length === 0 ? <Text style={styles.muted}>No check-ups saved yet.</Text> : null}
|
||||
|
||||
{selectedTripCheckups.map((checkup) => (
|
||||
<View key={checkup.id} style={styles.cardSoft}>
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Image, Pressable, Text, TextInput, View } from 'react-native';
|
||||
import Field from '../components/Field';
|
||||
import { styles } from '../styles';
|
||||
|
||||
function DateField({ label, value, onPress }) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Pressable style={styles.dateInput} onPress={onPress}>
|
||||
<Text style={styles.dateInputText}>{value}</Text>
|
||||
</Pressable>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TripsTab({
|
||||
tripForm,
|
||||
updateTripForm,
|
||||
@@ -14,8 +24,9 @@ export default function TripsTab({
|
||||
chooseTrip,
|
||||
setTripAsTemplate,
|
||||
deleteTrip,
|
||||
focusToEnd,
|
||||
onInputFocus,
|
||||
defaultTemplateTripId,
|
||||
openDatePicker,
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
@@ -29,7 +40,7 @@ export default function TripsTab({
|
||||
onChangeText={(v) => updateTripForm('name', v)}
|
||||
placeholder="Summer Weekend"
|
||||
placeholderTextColor="#6b7280"
|
||||
onFocus={focusToEnd}
|
||||
onFocus={onInputFocus}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -40,29 +51,12 @@ export default function TripsTab({
|
||||
onChangeText={(v) => updateTripForm('location', v)}
|
||||
placeholder="Berlin"
|
||||
placeholderTextColor="#6b7280"
|
||||
onFocus={focusToEnd}
|
||||
onFocus={onInputFocus}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Start Date (YYYY-MM-DD)">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={tripForm.startDate}
|
||||
onChangeText={(v) => updateTripForm('startDate', v)}
|
||||
placeholderTextColor="#6b7280"
|
||||
onFocus={focusToEnd}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="End Date (YYYY-MM-DD)">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={tripForm.endDate}
|
||||
onChangeText={(v) => updateTripForm('endDate', v)}
|
||||
placeholderTextColor="#6b7280"
|
||||
onFocus={focusToEnd}
|
||||
/>
|
||||
</Field>
|
||||
<DateField label="Start Date" value={tripForm.startDate} onPress={() => openDatePicker('startDate')} />
|
||||
<DateField label="End Date" value={tripForm.endDate} onPress={() => openDatePicker('endDate')} />
|
||||
|
||||
<Pressable style={styles.secondaryBtn} onPress={pickTripImage}>
|
||||
<Text style={styles.secondaryBtnText}>{tripForm.imageUri ? 'Change trip image' : 'Add trip image'}</Text>
|
||||
|
||||
Reference in New Issue
Block a user