44aaa32296
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
323 lines
12 KiB
TypeScript
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 },
|
|
});
|