Files
time_to_leave/apps/mobile/src/screens/EventDetailScreen.tsx
T
fegger ce1fa4972c Account for origin walk in departure time
Add useOriginStationWalk and integrate it into EventDetail/EventList to
resolve walking time from origin to station. Introduce calculateLeaveByTime
for notification scheduling and use exported Expo trigger types. Support
HAFAS 'crd' coordinates in client and destination hooks, update tests,
jest mappings, and Expo run scripts.
2026-05-18 11:56:49 +02:00

329 lines
12 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
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);
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('Termin nicht gefunden'); 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);
const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true });
setJourneys(results);
} else if (destStation.error) {
setError(`Ziel-Station nicht auflösbar: ${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);
}
} catch {
setBikeRoute(null);
} finally {
setLoadingBike(false);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden');
} finally {
setLoading(false);
}
}, [eventId, destStation.station, destStation.error, destCoords.coords, walkHook.walkRoute, walkHook.error]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => {
setWalkRoute(walkHook.walkRoute);
setLoadingWalk(walkHook.loading);
}, [walkHook.walkRoute, walkHook.loading]);
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 ? 'Ziel-Station wird aufgelöst…' : 'Termine werden geladen…'}
</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 }]}>
<Text style={styles.bannerText}> {error}</Text>
</View>
)}
{!origin && !error && (
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
<Text style={styles.bannerText}>
Keine Ursprungstation festgelegt.{' '}
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
Einstellungen öffnen
</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}>
<Text style={[styles.modeLabel, { color: colors.text }]}>🚆 Zug</Text>
{effectiveMode === 'train' && (
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.activeBadgeText}>Aktiv</Text>
</View>
)}
</View>
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
{showWalkingOption ? 'Bahn + finaler Fußweg' : 'Nur Bahn'}
</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}>
<Text style={[styles.modeLabel, { color: colors.text }]}>🚲 Rad</Text>
{effectiveMode === 'bike' && (
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.activeBadgeText}>Aktiv</Text>
</View>
)}
</View>
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
{bikeDisabled
? 'In Einstellungen deaktiviert'
: loadingBike
? 'Route wird berechnet...'
: 'Direktweg'}
</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}
>
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
</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 },
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' },
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 },
refreshBtnText: { fontSize: 15, fontWeight: '600' },
});