72f9400756
CI / lint-typecheck-test (push) Has been cancelled
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.
379 lines
14 KiB
TypeScript
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' },
|
|
});
|