Refactor mobile UI and centralize HAFAS parsing
Introduce useColors hook to replace direct theme usage in mobile screens. Extract EventHeader, JourneyList, BikeSection, and NearbyStops components to reduce complexity in EventDetailScreen. Move parseHafasJourneys to packages/core for shared usage between web and mobile clients. Update web API route with stricter HAFAS validation and consistent client instantiation. Add comprehensive codebase function guide documenting data flow, shared packages, and service integrations.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { BikeRoute, Station } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
bikeRoute: BikeRoute | null;
|
||||
loading: boolean;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.value, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||
<Text style={[styles.mapText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
label: { fontSize: 15, fontWeight: '500' },
|
||||
value: { fontSize: 15, fontWeight: '600' },
|
||||
mapPlaceholder: {
|
||||
marginTop: 10,
|
||||
height: 100,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
mapText: { fontSize: 14 },
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { Event } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
event: Event;
|
||||
leaveByTime: Date | null;
|
||||
arrivalBufferMinutes: number;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
|
||||
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{event.title}</Text>
|
||||
<Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text>
|
||||
<Text style={[styles.time, { color: colors.accent }]}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
|
||||
|
||||
<View style={[styles.infoGrid, { borderTopColor: colors.border }]}>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{leaveByTime
|
||||
? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{arriveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>{arrivalBufferMinutes} min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20, marginBottom: 12 },
|
||||
title: { fontSize: 22, fontWeight: '700' },
|
||||
destination: { fontSize: 16, marginTop: 4 },
|
||||
time: { fontSize: 14, marginTop: 8 },
|
||||
source: { fontSize: 12, marginTop: 4 },
|
||||
infoGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
infoBox: { alignItems: 'center' },
|
||||
infoLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
journeys: Journey[];
|
||||
destStationLoading: boolean;
|
||||
walkRoute: WalkRoute | null;
|
||||
loadingWalk: boolean;
|
||||
showWalkingOption: boolean;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function JourneyList({
|
||||
journeys,
|
||||
destStationLoading,
|
||||
walkRoute,
|
||||
loadingWalk,
|
||||
showWalkingOption,
|
||||
origin,
|
||||
colors,
|
||||
}: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||
|
||||
{destStationLoading && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Ziel-Station wird aufgelöst…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{journeys.length === 0 && !destStationLoading ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.line, { color: colors.text }]}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{j.delay > 0 && (
|
||||
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
|
||||
)}
|
||||
{j.cancelled && (
|
||||
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Storniert</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.detail, { color: colors.text }]}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={[styles.detail, { color: colors.subtext }]}>
|
||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{showWalkingOption && walkRoute && (
|
||||
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border, marginTop: 10 }]}>
|
||||
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showWalkingOption && loadingWalk && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Fußweg wird geladen…</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
line: { fontSize: 16, fontWeight: '600' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
detail: { fontSize: 13, marginTop: 6 },
|
||||
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
|
||||
walkLabel: { fontSize: 14, fontWeight: '500' },
|
||||
walkValue: { fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import type { WienerLinienStop } from '@timetoleave/core';
|
||||
import type { DepartureRow } from '../hooks/useWienerLinien';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
stops: WienerLinienStop[];
|
||||
departures: DepartureRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
|
||||
if (!loading && stops.length === 0 && !error) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Haltestellen werden geladen…</Text>
|
||||
</View>
|
||||
) : departures.length > 0 ? (
|
||||
departures.slice(0, 8).map((dep, i) => (
|
||||
<View
|
||||
key={`${dep.stopId}-${dep.lineName}-${i}`}
|
||||
style={[styles.depCard, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.depRow}>
|
||||
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
|
||||
</View>
|
||||
<Text style={[styles.direction, { color: colors.text }]} numberOfLines={1}>
|
||||
{dep.direction}
|
||||
</Text>
|
||||
<Text style={[styles.minutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
|
||||
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
stops.slice(0, 5).map((stop) => (
|
||||
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && <Text style={[styles.hint, { color: colors.subtext }]}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 14 },
|
||||
depCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
|
||||
depRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' },
|
||||
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
direction: { flex: 1, fontSize: 13 },
|
||||
minutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' },
|
||||
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||
stopName: { fontSize: 14, fontWeight: '500' },
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
const DARK = {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
success: '#30d158',
|
||||
warning: '#ff9f0a',
|
||||
purple: '#B23CFF',
|
||||
delete: '#FF3B30',
|
||||
overlay: 'rgba(28,28,30,0.95)',
|
||||
highlight: '#1a3a5c',
|
||||
} as const;
|
||||
|
||||
const LIGHT = {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
warning: '#FF9500',
|
||||
purple: '#8B5CF6',
|
||||
delete: '#FF3B30',
|
||||
overlay: 'rgba(255,255,255,0.95)',
|
||||
highlight: '#e8f4fd',
|
||||
} as const;
|
||||
|
||||
export type AppColors = Record<keyof typeof DARK, string>;
|
||||
|
||||
export function useColors(): AppColors {
|
||||
const { dark } = useTheme();
|
||||
return dark ? DARK : LIGHT;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface DepartureRow {
|
||||
export interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
direction: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||
@@ -19,7 +19,7 @@ type ScreenProps = {
|
||||
};
|
||||
|
||||
export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [title, setTitle] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
@@ -27,24 +27,6 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
|
||||
// If editing an existing event, populate the form
|
||||
useEffect(() => {
|
||||
if (!route.params?.editEventId) return;
|
||||
@@ -162,7 +144,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
</View>
|
||||
|
||||
{success && (
|
||||
<View style={[styles.successOverlay, { backgroundColor: dark ? 'rgba(28,28,30,0.95)' : 'rgba(255,255,255,0.95)' }]}>
|
||||
<View style={[styles.successOverlay, { backgroundColor: colors.overlay }]}>
|
||||
<View style={styles.successContent}>
|
||||
<View style={styles.successCircle}>
|
||||
<Text style={styles.checkmark}>✓</Text>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fetchNativeEvents } from '../services/calendar';
|
||||
import { addEvent, loadEvents } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
||||
@@ -23,34 +23,12 @@ type ScreenProps = {
|
||||
};
|
||||
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
success: '#30d158',
|
||||
purple: '#B23CFF',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
purple: '#8B5CF6',
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte ICS-URL eingeben');
|
||||
@@ -62,19 +40,26 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
const events = await api.fetchCalendar(url.trim());
|
||||
// Add imported events to local store
|
||||
const [events, existing] = await Promise.all([
|
||||
api.fetchCalendar(url.trim()),
|
||||
loadEvents(),
|
||||
]);
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
let added = 0;
|
||||
for (const evt of events) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
if (!existingIds.has(evt.id)) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
setCount(events.length);
|
||||
setCount(added);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
|
||||
} finally {
|
||||
|
||||
@@ -11,14 +11,17 @@ 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 { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
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 { useWalkRoute } from '../hooks/useWalkRoute';
|
||||
import { useWienerLinien } from '../hooks/useWienerLinien';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
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 = {
|
||||
@@ -30,7 +33,7 @@ type TransportMode = 'train' | 'bike';
|
||||
|
||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const { eventId } = route.params;
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
@@ -46,21 +49,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const [showBikeOption, setShowBikeOption] = useState(true);
|
||||
const [showWalkingOption, setShowWalkingOption] = useState(true);
|
||||
|
||||
// Resolve destination text to HAFAS station ID (CRITICAL FIX)
|
||||
const destStation = useDestinationStation(event?.destination);
|
||||
|
||||
// Geocode destination for bike/walk routes
|
||||
const destCoords = useGeocode(event?.destination);
|
||||
|
||||
// Fetch walk route from destination station to final address
|
||||
const walkHook = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
|
||||
// Fetch nearby WienerLinien stops
|
||||
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -78,35 +74,24 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
setShowWalkingOption(settings.showWalkingOption);
|
||||
|
||||
const found = events.find((e) => e.id === eventId);
|
||||
if (!found) {
|
||||
setError('Termin nicht gefunden');
|
||||
return;
|
||||
}
|
||||
if (!found) { setError('Termin nicht gefunden'); return; }
|
||||
setEvent(found);
|
||||
|
||||
if (originStation) {
|
||||
// Use resolved destination station extId instead of raw text (CRITICAL FIX)
|
||||
const destExtId = destStation.station?.extId;
|
||||
if (destExtId) {
|
||||
const results = await api.searchJourneys(
|
||||
originStation.extId,
|
||||
destExtId,
|
||||
found.eventTime,
|
||||
);
|
||||
const results = await api.searchJourneys(originStation.extId, destExtId, found.eventTime);
|
||||
setJourneys(results);
|
||||
} else if (destStation.error) {
|
||||
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
||||
}
|
||||
|
||||
// Fetch bike route if we have coordinates
|
||||
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,
|
||||
originStation.lat, originStation.lng,
|
||||
destCoords.coords.lat, destCoords.coords.lng,
|
||||
);
|
||||
setBikeRoute(bike);
|
||||
}
|
||||
@@ -125,7 +110,6 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Sync walk route from hook
|
||||
useEffect(() => {
|
||||
setWalkRoute(walkHook.walkRoute);
|
||||
setLoadingWalk(walkHook.loading);
|
||||
@@ -138,36 +122,17 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Use the shared departure time hook instead of inline calculation
|
||||
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),
|
||||
activeMode === 'train' && journeys.length > 0
|
||||
? 'train'
|
||||
: activeMode === 'bike' && bikeRoute
|
||||
? 'bike'
|
||||
: null,
|
||||
arrivalBufferMinutes,
|
||||
);
|
||||
const leaveByTime = departureInfo.departureTime;
|
||||
|
||||
// Theme-based colors
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
warning: '#ff9f0a',
|
||||
error: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
warning: '#FF9500',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -180,75 +145,38 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Disable bike mode if setting is off
|
||||
const bikeDisabled = !showBikeOption;
|
||||
const requestedMode: TransportMode = activeMode;
|
||||
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
|
||||
const effectiveMode: TransportMode =
|
||||
bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
{/* Event header */}
|
||||
{event && (
|
||||
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.eventTitle, { color: colors.text }]}>{event.title}</Text>
|
||||
<Text style={[styles.eventDest, { color: colors.subtext }]}>{event.destination}</Text>
|
||||
<Text style={[styles.eventTime, { color: colors.accent }]}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
|
||||
|
||||
{/* Leave by / Arrive by / Buffer info */}
|
||||
<View style={styles.infoGrid}>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{leaveByTime ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{arrivalBufferMinutes} min
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<EventHeader
|
||||
event={event}
|
||||
leaveByTime={departureInfo.departureTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||
<Text style={styles.bannerText}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Origin status */}
|
||||
{!origin && !error && (
|
||||
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
|
||||
<Text style={styles.warningBannerText}>
|
||||
Keine Ursprungstation festgelegt.
|
||||
{' '}
|
||||
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
|
||||
<Text style={styles.bannerText}>
|
||||
Keine Ursprungstation festgelegt.{' '}
|
||||
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Einstellungen öffnen
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Transport mode selector */}
|
||||
{origin && (
|
||||
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
@@ -289,159 +217,52 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||
{bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'}
|
||||
{bikeDisabled
|
||||
? 'In Einstellungen deaktiviert'
|
||||
: loadingBike
|
||||
? 'Route wird berechnet...'
|
||||
: 'Direktweg'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Journeys list (Train mode) */}
|
||||
{effectiveMode === 'train' && (
|
||||
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||
{destStation.loading && (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
Ziel-Station wird aufgelöst…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{journeys.length === 0 && !destStation.loading ? (
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={[styles.journeyCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.journeyRow}>
|
||||
<Text style={[styles.lineText, { color: colors.text }]}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{j.delay > 0 && (
|
||||
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
|
||||
)}
|
||||
{j.cancelled && (
|
||||
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Storniert</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.departure, { color: colors.text }]}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={[styles.arrival, { color: colors.subtext }]}>
|
||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Walk route section (when walking option enabled) */}
|
||||
{showWalkingOption && walkRoute && (
|
||||
<View style={[styles.walkCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
|
||||
<View style={styles.walkRow}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.walkRow}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{showWalkingOption && loadingWalk && (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
Fußweg wird geladen…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<JourneyList
|
||||
journeys={journeys}
|
||||
destStationLoading={destStation.loading}
|
||||
walkRoute={walkRoute}
|
||||
loadingWalk={loadingWalk}
|
||||
showWalkingOption={showWalkingOption}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bike route section (Bike mode) */}
|
||||
{effectiveMode === 'bike' && (
|
||||
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
|
||||
{loadingBike ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={[styles.bikeCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={[styles.bikeLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={[styles.bikeLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||
<Text style={[styles.mapPlaceholderText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<BikeSection
|
||||
bikeRoute={bikeRoute}
|
||||
loading={loadingBike}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* WienerLinien nearby stops + departures */}
|
||||
{(wienerLinien.loading || wienerLinien.stops.length > 0) && (
|
||||
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
|
||||
{wienerLinien.loading ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen…</Text>
|
||||
</View>
|
||||
) : wienerLinien.departures.length > 0 ? (
|
||||
wienerLinien.departures.slice(0, 8).map((dep, i) => (
|
||||
<View
|
||||
key={`${dep.stopId}-${dep.lineName}-${i}`}
|
||||
style={[styles.departureCard, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.departureRow}>
|
||||
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
|
||||
</View>
|
||||
<Text style={[styles.departureDirection, { color: colors.text }]} numberOfLines={1}>
|
||||
{dep.direction}
|
||||
</Text>
|
||||
<Text style={[styles.departureMinutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
|
||||
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
wienerLinien.stops.slice(0, 5).map((stop) => (
|
||||
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
{wienerLinien.error && (
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>{wienerLinien.error}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<NearbyStops
|
||||
stops={wienerLinien.stops}
|
||||
departures={wienerLinien.departures}
|
||||
loading={wienerLinien.loading}
|
||||
error={wienerLinien.error}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
{/* Refresh */}
|
||||
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
|
||||
<TouchableOpacity
|
||||
style={[styles.refreshBtn, { backgroundColor: colors.border }]}
|
||||
onPress={handleRefresh}
|
||||
>
|
||||
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom padding for scroll */}
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -451,57 +272,24 @@ const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loadingText: { marginTop: 12, fontSize: 15 },
|
||||
header: { padding: 20, marginBottom: 12 },
|
||||
eventTitle: { fontSize: 22, fontWeight: '700' },
|
||||
eventDest: { fontSize: 16, marginTop: 4 },
|
||||
eventTime: { fontSize: 14, marginTop: 8 },
|
||||
source: { fontSize: 12, marginTop: 4 },
|
||||
infoGrid: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#e5e5ea' },
|
||||
infoBox: { alignItems: 'center' },
|
||||
infoLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' as const, letterSpacing: 1 },
|
||||
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
|
||||
errorBanner: { padding: 12, marginBottom: 12 },
|
||||
errorBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningBanner: { padding: 12, marginBottom: 12 },
|
||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 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' },
|
||||
journeys: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
emptyText: { fontSize: 14 },
|
||||
journeyCard: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
lineText: { fontSize: 16, fontWeight: '600' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
departure: { fontSize: 13, marginTop: 6 },
|
||||
arrival: { fontSize: 13, marginTop: 2 },
|
||||
walkCard: { borderRadius: 10, padding: 14, marginTop: 10, borderWidth: 1 },
|
||||
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
|
||||
walkRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||
walkLabel: { fontSize: 14, fontWeight: '500' },
|
||||
walkValue: { fontSize: 14, fontWeight: '600' },
|
||||
centerBike: { alignItems: 'center', gap: 8 },
|
||||
bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
bikeLabel: { fontSize: 15, fontWeight: '500' },
|
||||
bikeValue: { fontSize: 15, fontWeight: '600' },
|
||||
mapPlaceholder: { marginTop: 10, height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', borderWidth: 1 },
|
||||
mapPlaceholderText: { fontSize: 14 },
|
||||
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||
stopName: { fontSize: 14, fontWeight: '500' },
|
||||
departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
|
||||
departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 },
|
||||
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const },
|
||||
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
departureDirection: { flex: 1, fontSize: 13 },
|
||||
departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const },
|
||||
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
refreshBtnText: { fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { loadEvents, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||
@@ -22,28 +22,12 @@ type ScreenProps = {
|
||||
};
|
||||
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
// Force countdown recalculation periodically
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
delete: '#FF3B30',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
delete: '#FF3B30',
|
||||
};
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await loadEvents();
|
||||
setEvents(list);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNot
|
||||
import { api } from '../services/api';
|
||||
import type { Station, ReminderSettings } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
type ScreenProps = {
|
||||
@@ -25,6 +26,7 @@ type ScreenProps = {
|
||||
|
||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const { dark, toggle: toggleTheme } = useTheme();
|
||||
const colors = useColors();
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Station[]>([]);
|
||||
@@ -41,24 +43,6 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
success: '#30d158',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
success: '#34C759',
|
||||
};
|
||||
|
||||
// Load persisted data on mount
|
||||
useEffect(() => {
|
||||
loadOriginStation().then(setOrigin);
|
||||
@@ -266,7 +250,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity style={[styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={useCurrentLocation}>
|
||||
<TouchableOpacity style={[styles.locBtn, { backgroundColor: colors.highlight }]} onPress={useCurrentLocation}>
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>
|
||||
@@ -298,7 +282,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={toggleAdvanced}>
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: colors.highlight }]} onPress={toggleAdvanced}>
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>
|
||||
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
|
||||
// ── Keys ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,38 +48,14 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
|
||||
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
|
||||
const REMINDERS_MIN = [30, 10, 0];
|
||||
const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
// Schedule notifications
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
for (const minutesBefore of REMINDERS_MIN) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use timestamp (seconds) as trigger — more reliable than Date object across versions
|
||||
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||
if (triggerTime <= now || triggerTime < twoHoursBefore) continue;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
@@ -88,12 +65,25 @@ async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
trigger: timestampSeconds as any,
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
for (const notif of existing.filter(n => n.content.data?.eventId === event.id)) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
await fireNotificationsForEvent(event, leaveByTime);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Events
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
@@ -176,48 +166,12 @@ export async function saveNotificationSettings(
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function rescheduleAllNotifications(): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const settings = await loadNotificationSettings();
|
||||
|
||||
// Cancel ALL existing notifications first
|
||||
const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]);
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
// Schedule new notifications for each event
|
||||
for (const event of events) {
|
||||
if (settings.enabled) {
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use timestamp (seconds) as trigger — more reliable than Date object
|
||||
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
trigger: timestampSeconds as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
await fireNotificationsForEvent(event, leaveByTime);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user