Files
time_to_leave/apps/mobile/src/screens/EventListScreen.tsx
T
fegger 44aaa32296 Add edit icon button to event list item
Remove emoji icons from journey display and replace with FontAwesome icons
Add FontAwesome icon imports and styles for new icon usage
Update calendar service to use FontAwesome icons instead of emojis
Remove redundant emoji field from CalendarSourceInfo type
Adjust journey scoring weights to prioritize fewer changes
Remove emoji icons and simplify event metadata display
2026-05-19 00:06:53 +02:00

323 lines
12 KiB
TypeScript

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<RootStack, 'EventList'>;
route: RouteProp<RootStack, 'EventList'>;
};
/**
* 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<CalendarEvent[]>([]);
const [origin, setOrigin] = useState<Station | null>(null);
const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5);
const [showWalkingOption, setShowWalkingOption] = useState(true);
const [journeys, setJourneys] = useState<Journey[]>([]);
const [journeysLoading, setJourneysLoading] = useState(false);
const [journeysError, setJourneysError] = useState<string | null>(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 (
<View style={styles.cardWrapper}>
<TouchableOpacity
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
activeOpacity={0.6}
style={{ flex: 1 }}
>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<View style={styles.dotRow}>
<View style={[styles.dot, { backgroundColor: leaveCountdown?.urgent ? colors.delete : '#34C759' }]} />
<Text style={[styles.title, { color: colors.text }]}>{item.title}</Text>
</View>
<Text style={[styles.subtitle, { color: colors.subtext }]}>{item.destination}</Text>
<Text style={[styles.leaveLabel, { color: colors.subtext }]}>Time To Leave</Text>
<Text style={[styles.leaveTime, { color: leaveByLabel ? colors.accent : colors.subtext }]}>
{leaveByLabel ?? '--:--'}
</Text>
<Text style={[styles.leaveLabel, { color: colors.subtext }]}>Leave in</Text>
<Text
style={[
styles.leaveTime,
{ color: leaveCountdownLabel ? colors.accent : colors.subtext },
]}
>
{leaveCountdownLabel
? leaveCountdownLabel
: journeysLoading || destStation.loading
? '--'
: 'No connection'}
</Text>
<View style={[styles.trainBox, { borderColor: colors.border }]}>
<Text style={[styles.trainTitle, { color: colors.text }]} numberOfLines={2}>
{journeysError ? 'Train connection unavailable' : trainLabel}
</Text>
{selectedJourney ? (
<Text style={[styles.trainMeta, { color: colors.subtext }]}>
Departure {formatTime(selectedJourney.rD)}
</Text>
) : (
<Text style={[styles.trainMeta, { color: journeysError ? colors.delete : colors.subtext }]}>
{journeysError ?? (origin ? 'Best connection for the next event' : 'Set origin station')}
</Text>
)}
</View>
<TouchableOpacity
onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })}
style={styles.editIconBtn}
>
<FontAwesomeIcon icon={faPenToSquare} size={16} color={colors.subtext} />
</TouchableOpacity>
</View>
</TouchableOpacity>
</View>
);
};
if (!upcomingEvent) {
return (
<View style={[styles.center, { backgroundColor: colors.background }]}>
<Text style={[styles.empty, { color: colors.subtext }]}>No upcoming events</Text>
<TouchableOpacity
style={styles.addBtn}
onPress={() => navigation.navigate('AddEvent')}
>
<Text style={styles.addBtnText}>+ Add event</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={[upcomingEvent]}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
}
/>
<TouchableOpacity
style={styles.fab}
onPress={() => navigation.navigate('AddEvent')}
>
<Text style={styles.fabText}>+</Text>
</TouchableOpacity>
</View>
);
}
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 },
});