Files
time_to_leave/apps/mobile/src/screens/EventDetailScreen.tsx
T
fegger 72f9400756
CI / lint-typecheck-test (push) Has been cancelled
Add CI pipeline, pre-commit hooks, and offline caching
Configure GitHub Actions for linting, typechecking, and testing.
Add Husky and lint-staged for pre-commit checks. Implement API
response caching in AsyncStorage for offline support. Move core
tests to packages/core and add vitest configuration.
2026-05-19 14:00:38 +02:00

379 lines
14 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faArrowsRotate, faBicycle, faTrain, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
import {
getCachedJourneys,
getCachedBikeRoute,
getCachedWalkRoute,
setCachedJourneys,
setCachedBikeRoute,
setCachedWalkRoute,
} from '../store/apiCache';
import { api } from '../services/api';
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
import { useDestinationStation } from '../hooks/useDestinationStation';
import { useDepartureTime } from '../hooks/useDepartureTime';
import { useGeocode } from '../hooks/useGeocode';
import { useOriginStationWalk } from '../hooks/useOriginStationWalk';
import { useWalkRoute } from '../hooks/useWalkRoute';
import { useWienerLinien } from '../hooks/useWienerLinien';
import { useColors } from '../hooks/useColors';
import { EventHeader } from '../components/EventHeader';
import { JourneyList } from '../components/JourneyList';
import { BikeSection } from '../components/BikeSection';
import { NearbyStops } from '../components/NearbyStops';
import type { RootStack } from '../types/navigation';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
route: RouteProp<RootStack, 'EventDetail'>;
};
type TransportMode = 'train' | 'bike';
/**
* Compute the target arrival time at the destination station.
* Subtracts both the arrival buffer (time before event) and the walking
* duration from station to event location.
*/
function stationArrivalTarget(
eventTime: Date,
arrivalBufferMinutes: number,
walkDurationSeconds: number,
) {
return new Date(eventTime.getTime() - arrivalBufferMinutes * 60_000 - walkDurationSeconds * 1000);
}
export function EventDetailScreen({ navigation, route }: ScreenProps) {
const { eventId } = route.params;
const colors = useColors();
const [event, setEvent] = useState<CalendarEvent | null>(null);
const [journeys, setJourneys] = useState<Journey[]>([]);
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
const [origin, setOrigin] = useState<Station | null>(null);
const [loading, setLoading] = useState(true);
const [loadingBike, setLoadingBike] = useState(false);
const [loadingWalk, setLoadingWalk] = useState(false);
const [activeMode, setActiveMode] = useState<TransportMode>('train');
const [error, setError] = useState<string | null>(null);
const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5);
const [showBikeOption, setShowBikeOption] = useState(true);
const [showWalkingOption, setShowWalkingOption] = useState(true);
const destStation = useDestinationStation(event?.destination);
const destCoords = useGeocode(event?.destination);
const walkHook = useWalkRoute(
destStation.station?.lat,
destStation.station?.lng,
destCoords.coords?.lat,
destCoords.coords?.lng,
);
const originWalk = useOriginStationWalk(origin);
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
// Preload cached data immediately so the UI isn't empty
const cachedJourneys = await getCachedJourneys(eventId);
const cachedBike = await getCachedBikeRoute(eventId);
const cachedWalk = await getCachedWalkRoute(eventId);
if (cachedJourneys) setJourneys(cachedJourneys);
if (cachedBike) setBikeRoute(cachedBike);
if (cachedWalk) setWalkRoute(cachedWalk);
try {
const [events, originStation, settings] = await Promise.all([
loadEvents(),
loadOriginStation(),
loadNotificationSettings(),
]);
setOrigin(originStation);
setArrivalBufferMinutes(settings.arrivalBufferMinutes);
setShowBikeOption(settings.showBikeOption);
setShowWalkingOption(settings.showWalkingOption);
const found = events.find((e) => e.id === eventId);
if (!found) { setError('Event not found'); return; }
setEvent(found);
if (originStation) {
const destExtId = destStation.station?.extId;
if (destExtId) {
const finalWalkLookupPending =
settings.showWalkingOption &&
destCoords.coords !== null &&
destStation.station?.lat != null &&
destStation.station?.lng != null &&
!walkHook.walkRoute &&
!walkHook.error;
if (finalWalkLookupPending) {
setJourneys([]);
return;
}
const walkDurationSeconds = settings.showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0;
const target = stationArrivalTarget(found.eventTime, settings.arrivalBufferMinutes, walkDurationSeconds);
try {
const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true });
setJourneys(results);
await setCachedJourneys(eventId, results);
} catch {
if (!cachedJourneys) throw new Error('Failed to load journeys');
// Keep stale data, mark as offline
}
} else if (destStation.error) {
setError(`Destination station not resolvable: ${destStation.error}`);
}
try {
setLoadingBike(true);
if (destCoords.coords && originStation.lat && originStation.lng) {
const bike = await api.getBikeRoute(
originStation.lat, originStation.lng,
destCoords.coords.lat, destCoords.coords.lng,
);
setBikeRoute(bike);
await setCachedBikeRoute(eventId, bike);
}
} catch {
if (!cachedBike) setBikeRoute(null);
} finally {
setLoadingBike(false);
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Error loading';
// If we have any cached data, show a soft offline warning instead of a hard error
if (cachedJourneys || cachedBike || cachedWalk) {
setError(`${msg} (showing cached data)`);
} else {
setError(msg);
}
} finally {
setLoading(false);
}
}, [eventId, destStation.station, destStation.error, destCoords.coords, walkHook.walkRoute, walkHook.error]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => {
setWalkRoute(walkHook.walkRoute);
setLoadingWalk(walkHook.loading);
if (walkHook.walkRoute) {
setCachedWalkRoute(eventId, walkHook.walkRoute).catch(() => {});
}
}, [walkHook.walkRoute, walkHook.loading, eventId]);
const handleRefresh = () => {
setBikeRoute(null);
setWalkRoute(null);
setJourneys([]);
fetchData();
};
const departureInfo = useDepartureTime(
event?.eventTime ?? new Date(),
journeys.length > 0 ? journeys : null,
bikeRoute?.duration ?? null,
activeMode === 'train' && journeys.length > 0
? 'train'
: activeMode === 'bike' && bikeRoute
? 'bike'
: null,
arrivalBufferMinutes,
showWalkingOption ? (walkRoute?.duration ?? 0) : 0,
originWalk.walkRoute?.duration ?? 0,
);
if (loading) {
return (
<View style={[styles.center, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>
{destStation.loading && !event ? 'Resolving destination station…' : 'Loading events…'}
</Text>
</View>
);
}
const bikeDisabled = !showBikeOption;
const effectiveMode: TransportMode =
bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
{event && (
<EventHeader
event={event}
leaveByTime={departureInfo.departureTime}
arrivalBufferMinutes={arrivalBufferMinutes}
colors={colors}
/>
)}
{error && (
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
<View style={styles.bannerContent}>
<FontAwesomeIcon icon={faTriangleExclamation} size={14} color="#fff" />
<Text style={styles.bannerText}>{error}</Text>
</View>
</View>
)}
{!origin && !error && (
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
<Text style={styles.bannerText}>
No origin station set.{' '}
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
Open settings
</Text>
</Text>
</View>
)}
{origin && (
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
<TouchableOpacity
style={[
styles.modeButton,
effectiveMode === 'train' && { backgroundColor: colors.accent + '22', borderColor: colors.accent },
]}
onPress={() => setActiveMode('train')}
>
<View style={styles.modeHeader}>
<View style={styles.modeLabelRow}>
<FontAwesomeIcon icon={faTrain} size={14} color={colors.text} />
<Text style={[styles.modeLabel, { color: colors.text }]}>Train</Text>
</View>
{effectiveMode === 'train' && (
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.activeBadgeText}>Active</Text>
</View>
)}
</View>
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
{showWalkingOption ? 'Train + final walk' : 'Train only'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.modeButton,
effectiveMode === 'bike' && { backgroundColor: colors.accent + '22', borderColor: colors.accent },
bikeDisabled && { opacity: 0.45 },
]}
onPress={() => !bikeDisabled && setActiveMode('bike')}
disabled={bikeDisabled}
>
<View style={styles.modeHeader}>
<View style={styles.modeLabelRow}>
<FontAwesomeIcon icon={faBicycle} size={14} color={colors.text} />
<Text style={[styles.modeLabel, { color: colors.text }]}>Bike</Text>
</View>
{effectiveMode === 'bike' && (
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.activeBadgeText}>Active</Text>
</View>
)}
</View>
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
{bikeDisabled
? 'Disabled in settings'
: loadingBike
? 'Calculating route...'
: 'Direct route'}
</Text>
</TouchableOpacity>
</View>
)}
{event && effectiveMode === 'train' && (
<JourneyList
journeys={journeys}
destStationLoading={destStation.loading}
walkRoute={walkRoute}
loadingWalk={loadingWalk}
showWalkingOption={showWalkingOption}
eventTime={event.eventTime}
arrivalBufferMinutes={arrivalBufferMinutes}
origin={origin}
colors={colors}
/>
)}
{effectiveMode === 'bike' && (
<BikeSection
bikeRoute={bikeRoute}
loading={loadingBike}
origin={origin}
colors={colors}
/>
)}
<NearbyStops
stops={wienerLinien.stops}
departures={wienerLinien.departures}
loading={wienerLinien.loading}
error={wienerLinien.error}
colors={colors}
/>
<TouchableOpacity
style={[styles.refreshBtn, { backgroundColor: colors.border }]}
onPress={handleRefresh}
>
<View style={styles.refreshBtnContent}>
<FontAwesomeIcon icon={faArrowsRotate} size={14} color={colors.text} />
<Text style={[styles.refreshBtnText, { color: colors.text }]}>Refresh</Text>
</View>
</TouchableOpacity>
<View style={{ height: 40 }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loadingText: { marginTop: 12, fontSize: 15 },
errorBanner: { padding: 12, marginBottom: 12 },
warningBanner: { padding: 12, marginBottom: 12 },
bannerContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
bannerText: { color: '#fff', fontSize: 14 },
bannerLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
modeSelector: {
flexDirection: 'row',
padding: 12,
gap: 12,
marginBottom: 12,
borderWidth: 1,
borderRadius: 12,
},
modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' },
modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
modeLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
modeLabel: { fontSize: 15, fontWeight: '600' },
modeMeta: { fontSize: 11, marginTop: 4 },
activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
activeBadgeText: { color: '#fff', fontSize: 10, fontWeight: '700' },
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
refreshBtnContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
refreshBtnText: { fontSize: 15, fontWeight: '600' },
});