import { useCallback, useEffect, useMemo, useState } from 'react'; import { FlatList, RefreshControl, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faPenToSquare } from '@fortawesome/free-solid-svg-icons'; import { useFocusEffect } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore'; import { calculateCountdown, formatTime } from '@timetoleave/core'; import type { Event as CalendarEvent, Journey, Station } from '@timetoleave/core'; import type { RootStack } from '../types/navigation'; import { useColors } from '../hooks/useColors'; import { useDepartureTime } from '../hooks/useDepartureTime'; import { useDestinationStation } from '../hooks/useDestinationStation'; import { useGeocode } from '../hooks/useGeocode'; import { useOriginStationWalk } from '../hooks/useOriginStationWalk'; import { useWalkRoute } from '../hooks/useWalkRoute'; import { api } from '../services/api'; type ScreenProps = { navigation: NativeStackNavigationProp; route: RouteProp; }; /** * Home screen showing a scrollable list of upcoming events with leave-time * countdowns. Pull-to-refresh reloads events from storage. Reloads * automatically when the screen gains focus so edits are reflected. */ export function EventListScreen({ navigation }: ScreenProps) { const colors = useColors(); const [events, setEvents] = useState([]); const [origin, setOrigin] = useState(null); const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5); const [showWalkingOption, setShowWalkingOption] = useState(true); const [journeys, setJourneys] = useState([]); const [journeysLoading, setJourneysLoading] = useState(false); const [journeysError, setJourneysError] = useState(null); const [refreshing, setRefreshing] = useState(false); // Force countdown recalculation periodically const [, setTick] = useState(0); const reload = useCallback(async () => { const [list, originStation, settings] = await Promise.all([ loadEvents(), loadOriginStation(), loadNotificationSettings(), ]); setEvents(list); setOrigin(originStation); setArrivalBufferMinutes(settings.arrivalBufferMinutes); setShowWalkingOption(settings.showWalkingOption); }, []); useEffect(() => { reload(); }, [reload]); // Recalculate countdowns every 30 seconds useEffect(() => { const interval = setInterval(() => setTick(t => t + 1), 30_000); return () => clearInterval(interval); }, []); useFocusEffect( useCallback(() => { reload(); }, [reload]), ); const onRefresh = async () => { setRefreshing(true); await reload(); setRefreshing(false); }; const upcomingEvent = useMemo(() => { const now = Date.now(); return events .filter((event) => event.eventTime.getTime() >= now) .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime())[0] ?? null; }, [events]); const destStation = useDestinationStation(upcomingEvent?.destination); const destCoords = useGeocode(upcomingEvent?.destination); const walkHook = useWalkRoute( destStation.station?.lat, destStation.station?.lng, destCoords.coords?.lat, destCoords.coords?.lng, ); const destinationStationExtId = destStation.station?.extId; const destinationStationLat = destStation.station?.lat; const destinationStationLng = destStation.station?.lng; const destinationLat = destCoords.coords?.lat; const destinationLng = destCoords.coords?.lng; const originExtId = origin?.extId; const originWalk = useOriginStationWalk(origin); const originWalkDurationSeconds = originWalk.walkRoute?.duration ?? 0; const walkDurationSeconds = showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0; const departureInfo = useDepartureTime( upcomingEvent?.eventTime ?? new Date(), journeys.length > 0 ? journeys : null, null, journeys.length > 0 ? 'train' : null, arrivalBufferMinutes, walkDurationSeconds, originWalkDurationSeconds, ); const selectedJourney = useMemo(() => { if (!departureInfo.departureTime) return null; const trainDepartureTime = departureInfo.departureTime.getTime() + originWalkDurationSeconds * 1000; return journeys.find((journey) => journey.rD.getTime() === trainDepartureTime) ?? null; }, [departureInfo.departureTime, journeys, originWalkDurationSeconds]); useEffect(() => { let isMounted = true; const fetchJourneys = async () => { if (!upcomingEvent || !originExtId || !destinationStationExtId) return; const finalWalkPending = showWalkingOption && destinationLat != null && destinationLng != null && destinationStationLat != null && destinationStationLng != null && walkHook.loading && !walkHook.walkRoute && !walkHook.error; if (finalWalkPending) return; setJourneys((current) => (current.length > 0 ? [] : current)); setJourneysError((current) => (current === null ? current : null)); setJourneysLoading(true); try { const target = new Date( upcomingEvent.eventTime.getTime() - arrivalBufferMinutes * 60_000 - walkDurationSeconds * 1000, ); const results = await api.searchJourneys(originExtId, destinationStationExtId, target, { arriveBy: true }); if (!isMounted) return; setJourneys(results); } catch (err) { if (!isMounted) return; setJourneysError(err instanceof Error ? err.message : 'Connections could not be loaded'); } finally { if (isMounted) setJourneysLoading(false); } }; fetchJourneys(); return () => { isMounted = false; }; }, [ upcomingEvent, originExtId, destinationStationExtId, destinationStationLat, destinationStationLng, destinationLat, destinationLng, walkHook.loading, walkHook.walkRoute, walkHook.error, showWalkingOption, arrivalBufferMinutes, walkDurationSeconds, ]); const renderItem = ({ item }: { item: CalendarEvent }) => { const leaveBy = departureInfo.departureTime; const leaveCountdown = leaveBy ? calculateCountdown(leaveBy) : null; const leaveCountdownLabel = leaveCountdown?.label; const leaveByLabel = leaveBy ? formatTime(leaveBy) : null; const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Searching for train connection'; return ( navigation.navigate('EventDetail', { eventId: item.id })} activeOpacity={0.6} style={{ flex: 1 }} > {item.title} {item.destination} Time To Leave {leaveByLabel ?? '--:--'} Leave in {leaveCountdownLabel ? leaveCountdownLabel : journeysLoading || destStation.loading ? '--' : 'No connection'} {journeysError ? 'Train connection unavailable' : trainLabel} {selectedJourney ? ( Departure {formatTime(selectedJourney.rD)} ) : ( {journeysError ?? (origin ? 'Best connection for the next event' : 'Set origin station')} )} navigation.navigate('AddEvent', { editEventId: item.id })} style={styles.editIconBtn} > ); }; if (!upcomingEvent) { return ( No upcoming events navigation.navigate('AddEvent')} > + Add event ); } return ( item.id} renderItem={renderItem} contentContainerStyle={styles.list} refreshControl={ } /> navigation.navigate('AddEvent')} > + ); } const styles = StyleSheet.create({ container: { flex: 1 }, list: { padding: 12 }, cardWrapper: { marginBottom: 12 }, card: { borderRadius: 12, padding: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 4, elevation: 2, }, dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 }, dot: { width: 10, height: 10, borderRadius: 5 }, title: { fontSize: 18, fontWeight: '600', flex: 1 }, badge: { fontSize: 12, fontWeight: '600' }, subtitle: { fontSize: 14, marginBottom: 4 }, time: { fontSize: 13 }, leaveLabel: { fontSize: 12, fontWeight: '600', marginTop: 18, textTransform: 'uppercase' }, leaveTime: { fontSize: 52, lineHeight: 58, fontWeight: '800', marginTop: 2, marginBottom: 12 }, trainBox: { borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 12 }, trainTitle: { fontSize: 16, fontWeight: '700' }, trainMeta: { fontSize: 13, marginTop: 6, lineHeight: 18 }, status: { fontSize: 13, marginTop: 2, fontWeight: '500' }, editIconBtn: { alignSelf: 'flex-end', padding: 4 }, center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, empty: { fontSize: 20, marginBottom: 16 }, addBtn: { backgroundColor: '#8B5CF6', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 }, addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' }, fab: { position: 'absolute', right: 20, bottom: 20, width: 56, height: 56, borderRadius: 28, backgroundColor: '#8B5CF6', justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4, }, fabText: { color: '#fff', fontSize: 32, fontWeight: '300', marginTop: -4 }, });