ce1fa4972c
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.
329 lines
12 KiB
TypeScript
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' },
|
|
});
|