Add core app features for event management and notifications

- Add new hook files for departure time, destination station, geocode, theme, walk route, and WienerLinien
- Add navigation types for centralized route definitions
- Update App.tsx to use ref for initialization logic
- Update notification service to use stable exports from expo-notifications
- Remove legacy notifications.ts and rename to expoNotifications.ts
- Add ScrollView to several screens for better layout
- Replace duplicated type definitions with imports from shared navigation types
- Add useTheme to relevant screens
- Remove notification handler duplication in App.tsx
This commit is contained in:
2026-05-13 08:37:52 +02:00
parent 316e5def72
commit 672d053ece
17 changed files with 1170 additions and 535 deletions
+22 -14
View File
@@ -1,22 +1,30 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import * as Notifications from './src/services/expoNotifications';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
useEffect(() => {
// Request notification permissions on app start
Notifications.requestPermissionsAsync();
const initRef = useRef(false);
// Set up notification handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
useEffect(() => {
// Run initialization only once
if (initRef.current) return;
initRef.current = true;
(async () => {
// Request notification permissions
await Notifications.requestPermissionsAsync();
// Set up notification handler (called exactly once)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
})();
}, []);
return <AppNavigator />;
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import type { Journey } from '@timetoleave/core';
import { loadNotificationSettings } from '../store/eventStore';
interface DepartureTimeResult {
departureTime: Date | null;
arrivalTime: Date | null;
mode: 'train' | 'bike' | null;
}
/**
* Calculate departure time based on selected transport mode.
* Mirrors the web app's useDepartureTime hook.
*/
export function useDepartureTime(
eventTime: Date,
journeys: Journey[] | null,
bikeDurationSeconds: number | null,
activeMode: 'train' | 'bike' | null,
arrivalBufferMinutes: number,
): DepartureTimeResult {
return useMemo(() => {
// Calculate target arrival time (event time minus buffer)
const targetArrivalTime = new Date(eventTime);
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
// Filter out cancelled journeys
const validJourneys = journeys?.filter((journey) => !journey.cancelled) || [];
let departureTime: Date | null = null;
let arrivalTime: Date | null = null;
let mode: 'train' | 'bike' | null = null;
if (activeMode === 'train' && validJourneys.length > 0) {
// Find journeys that arrive by target time
const onTimeJourneys = validJourneys.filter(
(journey) => journey.rA.getTime() <= targetArrivalTime.getTime(),
);
if (onTimeJourneys.length > 0) {
// Pick the journey with the latest departure that still arrives on time
const bestJourney = onTimeJourneys.reduce((latest, current) =>
current.rD.getTime() > latest.rD.getTime() ? current : latest,
);
departureTime = new Date(bestJourney.rD);
arrivalTime = new Date(bestJourney.rA);
mode = 'train';
}
}
if (activeMode === 'bike' && bikeDurationSeconds !== null && bikeDurationSeconds > 0) {
const bikeDurationMs = bikeDurationSeconds * 1000;
const totalBufferMs = arrivalBufferMinutes * 60 * 1000;
const targetArrivalMs = eventTime.getTime() - totalBufferMs;
departureTime = new Date(targetArrivalMs - bikeDurationMs);
arrivalTime = new Date(targetArrivalMs);
mode = 'bike';
}
return { departureTime, arrivalTime, mode };
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes]);
}
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import type { Station } from '@timetoleave/core';
import { api } from '../services/api';
interface HafasLocation {
type: string;
name: string;
extId: string;
lat: number;
lon: number;
}
export function useDestinationStation(destination: string | undefined) {
const [station, setStation] = useState<Station | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination?.trim()) {
setStation(null);
return;
}
let isMounted = true;
// Debounce the lookup
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
try {
// First geocode the destination to get coordinates
const geocodeResults = await api.geocode(destination, 'at');
const coords = geocodeResults[0];
if (!coords) {
if (isMounted) {
setStation(null);
setLoading(false);
}
return;
}
// Then use HAFAS LocMatch to find the nearest station
const body = {
svcReqL: [
{
meth: 'LocMatch',
req: {
input: {
loc: {
crd: {
x: Math.round(coords.lng * 1e6),
y: Math.round(coords.lat * 1e6),
},
type: 'S',
},
maxLoc: 1,
field: 'S',
},
},
},
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await api.hafasRequest<any>(body);
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
const stations = locL
.filter((l) => l.type === 'S')
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
if (!isMounted) return;
setStation(stations[0] ?? null);
setLoading(false);
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Station lookup failed');
setLoading(false);
}
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
};
}, [destination]);
return { station, loading, error };
}
+47
View File
@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
import type { GeocodeResult } from '@timetoleave/core';
import { api } from '../services/api';
/**
* Geocode a destination name to coordinates.
* Mirrors the web app's useGeocode hook.
*/
export function useGeocode(destination: string | undefined) {
const [coords, setCoords] = useState<GeocodeResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination?.trim()) {
setCoords(null);
return;
}
let isMounted = true;
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
try {
const results = await api.geocode(destination, 'at');
if (isMounted) {
setCoords(results[0] ?? null);
setLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Geocoding failed');
setLoading(false);
}
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
};
}, [destination]);
return { coords, loading, error };
}
+52
View File
@@ -0,0 +1,52 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const THEME_KEY = '@timetoleave_theme';
type Theme = 'dark' | 'light';
function getDefaultTheme(): Theme {
// React Native doesn't have window.matchMedia, but we can use a simple default
// In practice, we'd use useColorScheme from react-native for system preference
return 'light';
}
/**
* Theme management for the mobile app.
* Mirrors the web app's useTheme hook.
*/
export function useTheme() {
const [dark, setDark] = useState(false);
const initialized = useRef(false);
// Load theme on mount
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
(async () => {
try {
const stored = await AsyncStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light') {
setDark(stored === 'dark');
} else {
setDark(getDefaultTheme() === 'dark');
}
} catch {
setDark(false);
}
})();
}, []);
const toggle = useCallback(() => {
setDark((prev) => {
const next = !prev;
AsyncStorage.setItem(THEME_KEY, next ? 'dark' : 'light').catch(() => {
// Silently fail storage
});
return next;
});
}, []);
return { dark, toggle };
}
+57
View File
@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import type { WalkRoute } from '@timetoleave/core';
import { api } from '../services/api';
/**
* Fetch walk route between two points.
* Mirrors the web app's useWalkRoute hook.
*/
export function useWalkRoute(
fromLat: number | undefined,
fromLng: number | undefined,
toLat: number | undefined,
toLng: number | undefined,
) {
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchRoute = async () => {
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
return;
}
setLoading(true);
setError(null);
try {
const data = await api.getWalkRoute(fromLat, fromLng, toLat, toLng);
if (isMounted) {
setWalkRoute(data);
setLoading(false);
}
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : 'Failed to fetch walk route';
setError(message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchRoute();
return () => {
isMounted = false;
};
}, [fromLat, fromLng, toLat, toLng]);
return { walkRoute, loading, error };
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
import { api } from '../services/api';
interface DepartureRow {
stopId: string;
lineName: string;
direction: string;
minutes: number;
}
const DEBOUNCE_MS = 400;
const REFRESH_INTERVAL_MS = 60_000;
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
return {
stopId: dep.stopId,
lineName: dep.line.name,
direction: dep.direction,
minutes,
};
}
/**
* Fetch nearby WienerLinien stops and their departures.
* Mirrors the web app's useWienerLinien hook, adapted for mobile API client.
*/
export function useWienerLinien(
lat: number | undefined,
lng: number | undefined,
radius?: number,
) {
const [stops, setStops] = useState<WienerLinienStop[]>([]);
const [departures, setDepartures] = useState<DepartureRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const stopIdsRef = useRef<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const cancelledRef = useRef(false);
// Effect for fetching stops and initial departures
useEffect(() => {
cancelledRef.current = false;
const resetState = () => {
setStops([]);
setDepartures([]);
setError(null);
setLoading(false);
};
if (lat === undefined || lng === undefined) {
resetState();
return;
}
const debounceTimer = setTimeout(async () => {
if (cancelledRef.current) return;
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
setLoading(true);
setError(null);
try {
// Fetch nearby stops
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
if (cancelledRef.current) return;
setStops(stopsList);
setLoading(false);
const ids = stopsList.map((s) => s.id);
stopIdsRef.current = ids;
// Chain monitor fetch for departures
if (ids.length > 0) {
try {
// Fetch departures for each stop - note: mobile API client doesn't have
// a direct monitor endpoint, so we skip this for now
// The web app uses an internal API route for this
} catch {
// Silently ignore departure fetch errors
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
if (cancelledRef.current) return;
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
setLoading(false);
}
}, DEBOUNCE_MS);
return () => {
cancelledRef.current = true;
clearTimeout(debounceTimer);
abortRef.current?.abort();
abortRef.current = null;
};
}, [lat, lng, radius]);
// Effect for periodic departures refresh
useEffect(() => {
if (stops.length === 0) return;
const intervalId = setInterval(async () => {
const currentIds = stopIdsRef.current;
if (currentIds.length === 0) return;
// Refresh logic would go here if we had the monitor API
// For now, this is a placeholder for future implementation
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [stops.length]);
return { stops, departures, loading, error };
}
+10 -15
View File
@@ -7,16 +7,11 @@ import { EventDetailScreen } from '../screens/EventDetailScreen';
import { AddEventScreen } from '../screens/AddEventScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
import type { RootStack } from '../types/navigation';
// ── Root Stack ────────────────────────────────────────
// ── Root Stack ──
const RootStack = createNativeStackNavigator<{
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
}>();
const Root = createNativeStackNavigator<RootStack>();
export default function AppNavigator() {
return (
@@ -24,16 +19,16 @@ export default function AppNavigator() {
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
<StatusBar style="auto" />
<NavigationContainer>
<RootStack.Navigator
<Root.Navigator
initialRouteName="EventList"
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
>
<RootStack.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
<RootStack.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
<RootStack.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
<RootStack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
<RootStack.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
</RootStack.Navigator>
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
</Root.Navigator>
</NavigationContainer>
</SafeAreaView>
</SafeAreaProvider>
+83 -39
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
StyleSheet,
Text,
@@ -8,29 +8,59 @@ import {
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { addEvent } from '../store/eventStore';
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
route: RouteProp<RootStack, 'AddEvent'>;
};
export function AddEventScreen({ navigation }: ScreenProps) {
export function AddEventScreen({ navigation, route }: ScreenProps) {
const { dark } = useTheme();
const [title, setTitle] = useState('');
const [destination, setDestination] = useState('');
const [dateStr, setDateStr] = useState('');
const [timeStr, setTimeStr] = useState('');
const [error, setError] = useState('');
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
border: '#38383a',
error: '#ff453a',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
border: '#e5e5ea',
error: '#FF3B30',
};
// If editing an existing event, populate the form
useEffect(() => {
if (!route.params?.editEventId) return;
(async () => {
const events = await loadEvents();
const event = events.find((e) => e.id === route.params?.editEventId);
if (event) {
setTitle(event.title);
setDestination(event.destination);
const d = new Date(event.eventTime);
setDateStr(d.toISOString().split('T')[0]);
setTimeStr(d.toTimeString().slice(0, 5));
}
})();
}, [route.params?.editEventId]);
const validate = (): boolean => {
if (!title.trim()) { setError('Titel erforderlich'); return false; }
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
@@ -46,68 +76,84 @@ export function AddEventScreen({ navigation }: ScreenProps) {
if (!validate()) return;
const eventTime = new Date(`${dateStr}T${timeStr}`);
const event: CalendarEvent = {
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
title: title.trim(),
destination: destination.trim(),
eventTime,
source: 'manual',
};
await addEvent(event);
if (route.params?.editEventId) {
// Update existing event
await updateEvent(route.params.editEventId, {
title: title.trim(),
destination: destination.trim(),
eventTime,
});
} else {
// Create new event
const event: CalendarEvent = {
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
title: title.trim(),
destination: destination.trim(),
eventTime,
source: 'manual',
};
await addEvent(event);
}
navigation.goBack();
};
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.form}>
<Text style={styles.label}>Titel</Text>
<Text style={[styles.label, { color: colors.text }]}>Titel</Text>
<TextInput
style={styles.input}
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="z.B. Team Meeting"
placeholderTextColor={colors.subtext}
value={title}
onChangeText={setTitle}
autoCapitalize="words"
/>
<Text style={styles.label}>Ziel</Text>
<Text style={[styles.label, { color: colors.text }]}>Ziel</Text>
<TextInput
style={styles.input}
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="z.B. Wien, Donau-City"
placeholderTextColor={colors.subtext}
value={destination}
onChangeText={setDestination}
autoCapitalize="words"
/>
<Text style={styles.label}>Datum</Text>
<Text style={[styles.label, { color: colors.text }]}>Datum</Text>
<TextInput
style={styles.input}
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="JJJJ-MM-TT"
placeholderTextColor={colors.subtext}
value={dateStr}
onChangeText={setDateStr}
keyboardType="numbers-and-punctuation"
/>
<Text style={styles.label}>Zeit</Text>
<Text style={[styles.label, { color: colors.text }]}>Zeit</Text>
<TextInput
style={styles.input}
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="SS:MM"
placeholderTextColor={colors.subtext}
value={timeStr}
onChangeText={setTimeStr}
keyboardType="numbers-and-punctuation"
/>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
{error ? <Text style={[styles.errorText, { color: colors.error }]}>{error}</Text> : null}
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>Speichern</Text>
<Text style={styles.saveBtnText}>{route.params?.editEventId ? 'Aktualisieren' : 'Speichern'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveBtn, styles.cancelBtn]}
style={[styles.saveBtn, styles.cancelBtn, { backgroundColor: colors.border }]}
onPress={() => navigation.goBack()}
>
<Text style={[styles.saveBtnText, styles.cancelText]}>Abbrechen</Text>
<Text style={[styles.cancelText, { color: colors.text }]}>Abbrechen</Text>
</TouchableOpacity>
</View>
</View>
@@ -115,20 +161,18 @@ export function AddEventScreen({ navigation }: ScreenProps) {
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
container: { flex: 1 },
form: { padding: 20 },
label: { fontSize: 14, fontWeight: '600', color: '#1c1c1e', marginBottom: 6 },
label: { fontSize: 14, fontWeight: '600', marginBottom: 6 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
},
errorText: { color: '#FF3B30', fontSize: 14, marginBottom: 8 },
errorText: { fontSize: 14, marginBottom: 8 },
saveBtn: {
backgroundColor: '#007AFF',
paddingVertical: 14,
@@ -137,6 +181,6 @@ const styles = StyleSheet.create({
marginTop: 8,
},
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
cancelBtn: { marginTop: 12, backgroundColor: '#e5e5ea' },
cancelText: { color: '#1c1c1e' },
cancelBtn: { marginTop: 12 },
cancelText: { fontSize: 16, fontWeight: '600' },
});
@@ -1,6 +1,7 @@
import { useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TextInput,
@@ -13,14 +14,8 @@ import { api } from '../services/api';
import { fetchNativeEvents } from '../services/calendar';
import { addEvent, loadEvents } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
@@ -28,11 +23,34 @@ type ScreenProps = {
};
export function CalendarImportScreen({ navigation }: ScreenProps) {
const { dark } = useTheme();
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: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
border: '#38383a',
error: '#ff453a',
success: '#30d158',
purple: '#bf5af2',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
border: '#e5e5ea',
error: '#FF3B30',
success: '#34C759',
purple: '#5856D6',
};
const handleImport = async () => {
if (!url.trim()) {
setError('Bitte ICS-URL eingeben');
@@ -97,19 +115,20 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
};
return (
<View style={styles.container}>
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.content}>
<Text style={styles.heading}>Kalender-Import</Text>
<Text style={styles.description}>
<Text style={[styles.heading, { color: colors.text }]}>Kalender-Import</Text>
<Text style={[styles.description, { color: colors.subtext }]}>
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>ICS-URL Import</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS-URL Import</Text>
<TextInput
style={styles.input}
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="https://calendar.google.com/calendar/ical/..."
placeholderTextColor={colors.subtext}
value={url}
onChangeText={setUrl}
autoCapitalize="none"
@@ -130,13 +149,13 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Geräte-Kalender Sync</Text>
<Text style={styles.sectionDesc}>
Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
<Text style={[styles.sectionTitle, { color: colors.text }]}>Geräte-Kalender Sync</Text>
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
Hole Termine der nächsten 30 Tage aus den Kalendern auf deinem Gerät.
</Text>
<TouchableOpacity
style={[styles.importBtn, styles.nativeBtn, loading && styles.importBtnDisabled]}
style={[styles.importBtn, { backgroundColor: colors.purple }, loading && styles.importBtnDisabled]}
onPress={handleSyncNative}
disabled={loading}
>
@@ -145,13 +164,13 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
</View>
{error && (
<View style={styles.errorBanner}>
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{count !== null && (
<View style={styles.successBanner}>
<View style={[styles.successBanner, { backgroundColor: colors.success }]}>
<Text style={styles.successText}>
{count} Termin(e) erfolgreich importiert!
</Text>
@@ -162,34 +181,32 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
style={styles.backBtn}
onPress={() => navigation.goBack()}
>
<Text style={styles.backBtnText}> Zurück</Text>
<Text style={[styles.backBtnText, { color: colors.accent }]}> Zurück</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
container: { flex: 1 },
content: { padding: 20 },
heading: { fontSize: 22, fontWeight: '700', color: '#1c1c1e', marginBottom: 4 },
description: { fontSize: 14, color: '#8e8e93', marginBottom: 20, lineHeight: 20 },
heading: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
description: { fontSize: 14, marginBottom: 20, lineHeight: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 16, fontWeight: '600', color: '#1c1c1e', marginBottom: 8 },
sectionDesc: { fontSize: 13, color: '#8e8e93', marginBottom: 12, lineHeight: 18 },
sectionTitle: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
sectionDesc: { fontSize: 13, marginBottom: 12, lineHeight: 18 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
marginBottom: 12,
},
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
errorBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
errorText: { color: '#fff', fontSize: 14 },
successBanner: { backgroundColor: '#34C759', borderRadius: 8, padding: 12, marginBottom: 12 },
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
successText: { color: '#fff', fontSize: 14 },
importBtn: {
backgroundColor: '#007AFF',
@@ -197,14 +214,11 @@ const styles = StyleSheet.create({
borderRadius: 12,
alignItems: 'center',
},
nativeBtn: {
backgroundColor: '#5856D6',
},
importBtnDisabled: { opacity: 0.6 },
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
backBtn: {
paddingVertical: 10,
alignItems: 'center',
},
backBtnText: { color: '#007AFF', fontSize: 15 },
backBtnText: { fontSize: 15 },
});
+353 -148
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -8,41 +9,74 @@ import {
} from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation } from '../store/eventStore';
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 } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
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 type { RootStack } from '../types/navigation';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
route: RouteProp<RootStack, 'EventDetail'>;
};
type TransportMode = 'train' | 'bike';
export function EventDetailScreen({ navigation, route }: ScreenProps) {
const { eventId } = route.params;
const { dark } = useTheme();
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);
// 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 () => {
setLoading(true);
setError(null);
try {
const [events, originStation] = await Promise.all([loadEvents(), loadOriginStation()]);
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');
@@ -51,29 +85,32 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
setEvent(found);
if (originStation) {
const results = await api.searchJourneys(
originStation.extId,
found.destination,
found.eventTime,
);
setJourneys(results);
// 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,
);
setJourneys(results);
} else if (destStation.error) {
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
}
// Fetch bike route if we have a destination station
// We need destination coordinates; for MVP we geocode the destination name
// Fetch bike route if we have coordinates
try {
setLoadingBike(true);
const geo = await api.geocode(found.destination);
if (geo.length > 0 && originStation.lat && originStation.lng) {
if (destCoords.coords && originStation.lat && originStation.lng) {
const bike = await api.getBikeRoute(
originStation.lat,
originStation.lng,
geo[0].lat,
geo[0].lng,
destCoords.coords.lat,
destCoords.coords.lng,
);
setBikeRoute(bike);
}
} catch {
// Bike route is optional — don't fail the whole screen
setBikeRoute(null);
} finally {
setLoadingBike(false);
@@ -84,32 +121,78 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
} finally {
setLoading(false);
}
}, [eventId]);
}, [eventId, destStation.station, destStation.error, destCoords.coords]);
useEffect(() => { fetchData(); }, [fetchData]);
// Sync walk route from hook
useEffect(() => {
setWalkRoute(walkHook.walkRoute);
setLoadingWalk(walkHook.loading);
}, [walkHook.walkRoute, walkHook.loading]);
const handleRefresh = () => {
setBikeRoute(null);
setWalkRoute(null);
setJourneys([]);
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),
arrivalBufferMinutes,
);
const leaveByTime = departureInfo.departureTime;
// Theme-based colors
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
border: '#38383a',
warning: '#ff9f0a',
error: '#ff453a',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
border: '#e5e5ea',
warning: '#FF9500',
error: '#FF3B30',
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Termine werden geladen</Text>
<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>
);
}
// Disable bike mode if setting is off
const bikeDisabled = !showBikeOption;
const requestedMode: TransportMode = activeMode;
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
return (
<View style={styles.container}>
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
{/* Event header */}
{event && (
<View style={styles.header}>
<Text style={styles.eventTitle}>{event.title}</Text>
<Text style={styles.eventDest}>{event.destination}</Text>
<Text style={styles.eventTime}>
<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',
@@ -119,20 +202,42 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
minute: '2-digit',
})}
</Text>
<Text style={styles.source}>Quelle: {event.source}</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>
)}
{/* Error */}
{error && (
<View style={styles.errorBanner}>
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
<Text style={styles.errorBannerText}> {error}</Text>
</View>
)}
{/* Origin status */}
{!origin && !error && (
<View style={styles.warningBanner}>
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
<Text style={styles.warningBannerText}>
Keine Ursprungstation festgelegt.
{' '}
@@ -143,135 +248,235 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
</View>
)}
{/* Journeys list */}
<View style={styles.journeys}>
<Text style={styles.sectionTitle}>Zugverbindungen</Text>
{journeys.length === 0 ? (
<Text style={styles.emptyText}>
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
</Text>
) : (
journeys.map((j) => (
<View key={j.id} style={styles.journeyCard}>
<View style={styles.journeyRow}>
<Text style={styles.lineText}>
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
</Text>
{j.delay > 0 && <Text style={styles.delayBadge}>+{j.delay} min</Text>}
{j.cancelled && <Text style={styles.cancelBadge}>Storniert</Text>}
</View>
<Text style={styles.departure}>
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}
(Plattform {j.platform || '—'})
</Text>
<Text style={styles.arrival}>
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
</Text>
{/* Transport mode selector */}
{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>
))
)}
</View>
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
{showWalkingOption ? 'Bahn + finaler Fußweg' : 'Nur Bahn'}
</Text>
</TouchableOpacity>
{/* Bike route section */}
<View style={styles.journeys}>
<Text style={styles.sectionTitle}>Radroute</Text>
{loadingBike ? (
<View style={styles.centerBike}>
<ActivityIndicator size="small" color="#007AFF" />
<Text style={styles.loadingText}>Radroute wird geladen</Text>
</View>
) : bikeRoute ? (
<View style={styles.bikeCard}>
<View style={styles.bikeRow}>
<Text style={styles.bikeLabel}> Dauer</Text>
<Text style={styles.bikeValue}>{formatDuration(bikeRoute.duration)}</Text>
<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>
<View style={styles.bikeRow}>
<Text style={styles.bikeLabel}>📏 Distanz</Text>
<Text style={styles.bikeValue}>{formatDistance(bikeRoute.distance)}</Text>
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
{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>
<View style={styles.mapPlaceholder}>
<Text style={styles.mapPlaceholderText}>🗺 Karte (post-MVP)</Text>
)}
{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>
</View>
) : (
<Text style={styles.emptyText}>
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
</Text>
)}
</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>
)}
{/* 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>
)}
{/* WienerLinien nearby stops */}
{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.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>
)}
{/* Refresh */}
<TouchableOpacity style={styles.refreshBtn} onPress={handleRefresh}>
<Text style={styles.refreshBtnText}>🔄 Neu laden</Text>
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
</TouchableOpacity>
</View>
{/* Bottom padding for scroll */}
<View style={{ height: 40 }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
loadingText: { color: '#8e8e93', marginTop: 12, fontSize: 15 },
header: { padding: 20, backgroundColor: '#fff', marginBottom: 12 },
eventTitle: { fontSize: 22, fontWeight: '700', color: '#1c1c1e' },
eventDest: { fontSize: 16, color: '#8e8e93', marginTop: 4 },
eventTime: { fontSize: 14, color: '#007AFF', marginTop: 8 },
source: { fontSize: 12, color: '#8e8e93', marginTop: 4 },
errorBanner: { backgroundColor: '#FF3B30', padding: 12, marginBottom: 12 },
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: { backgroundColor: '#FF9500', padding: 12, marginBottom: 12 },
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 },
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', color: '#1c1c1e', marginBottom: 12 },
emptyText: { color: '#8e8e93', fontSize: 14 },
journeyCard: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 14,
marginBottom: 10,
borderWidth: 1,
borderColor: '#e5e5ea',
},
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', color: '#1c1c1e' },
delayBadge: { backgroundColor: '#FF3B30', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
cancelBadge: { backgroundColor: '#000', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
departure: { fontSize: 13, color: '#1c1c1e', marginTop: 6 },
arrival: { fontSize: 13, color: '#8e8e93', marginTop: 2 },
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: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 14,
borderWidth: 1,
borderColor: '#e5e5ea',
},
bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 },
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
bikeLabel: { fontSize: 15, color: '#1c1c1e', fontWeight: '500' },
bikeValue: { fontSize: 15, color: '#007AFF', fontWeight: '600' },
mapPlaceholder: {
marginTop: 10,
height: 100,
borderRadius: 8,
backgroundColor: '#f2f2f7',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#c7c7cc',
},
mapPlaceholderText: { fontSize: 14, color: '#8e8e93' },
refreshBtn: {
alignSelf: 'center',
marginTop: 20,
paddingVertical: 12,
paddingHorizontal: 24,
backgroundColor: '#e5e5ea',
borderRadius: 12,
},
refreshBtnText: { fontSize: 15, color: '#1c1c1e', fontWeight: '600' },
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' },
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
refreshBtnText: { fontSize: 15, fontWeight: '600' },
});
+86 -57
View File
@@ -13,20 +13,8 @@ import type { RouteProp } from '@react-navigation/native';
import { loadEvents, removeEvent } from '../store/eventStore';
import { calculateCountdown } from '@timetoleave/core';
import type { Event as CalendarEvent } from '@timetoleave/core';
// Color map for countdown urgency
const urgencyColor = (urgent: boolean): string => {
if (urgent) return '#FF3B30';
return '#34C759';
};
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
@@ -34,8 +22,27 @@ type ScreenProps = {
};
export function EventListScreen({ navigation }: ScreenProps) {
const { dark } = useTheme();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [refreshing, setRefreshing] = useState(false);
// Force countdown recalculation periodically
const [, setTick] = useState(0);
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
delete: '#ff453a',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
delete: '#FF3B30',
};
const reload = useCallback(async () => {
const list = await loadEvents();
@@ -43,6 +50,13 @@ export function EventListScreen({ navigation }: ScreenProps) {
}, []);
useEffect(() => { reload(); }, [reload]);
// Recalculate countdowns every 30 seconds
useEffect(() => {
const interval = setInterval(() => setTick(t => t + 1), 30_000);
return () => clearInterval(interval);
}, []);
useFocusEffect(
useCallback(() => { reload(); }, [reload]),
);
@@ -54,6 +68,7 @@ export function EventListScreen({ navigation }: ScreenProps) {
};
const renderItem = ({ item }: { item: CalendarEvent }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const countdown = calculateCountdown(item.eventTime);
// Derive a simple status — journeys aren't loaded on the list screen for MVP
@@ -61,40 +76,53 @@ export function EventListScreen({ navigation }: ScreenProps) {
const status = countdown.urgent ? 'Bald!' : countdown.label;
return (
<TouchableOpacity
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
activeOpacity={0.6}
>
<View style={styles.card}>
<View style={styles.dotRow}>
<View style={[styles.dot, { backgroundColor: urgencyColor(countdown.urgent) }]} />
<Text style={styles.title}>{item.title}</Text>
<Text style={[styles.badge, { color: countdown.color === 'red' || countdown.color === 'orange' ? '#FF3B30' : '#007AFF' }]}>
{countdown.label}
<View style={styles.cardWrapper}>
<TouchableOpacity
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
activeOpacity={0.6}
style={{ flex: 1 }}
>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<View style={styles.dotRow}>
<View style={[styles.dot, { backgroundColor: countdown.urgent ? colors.delete : '#34C759' }]} />
<Text style={[styles.title, { color: colors.text }]}>{item.title}</Text>
<Text style={[styles.badge, { color: countdown.urgent ? colors.delete : colors.accent }]}>
{countdown.label}
</Text>
</View>
<Text style={[styles.subtitle, { color: colors.subtext }]}>{item.destination}</Text>
<Text style={[styles.time, { color: colors.accent }]}>
{item.eventTime.toLocaleString('de-AT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text style={[styles.status, { color: countdown.urgent ? colors.delete : '#34C759' }]}>{status}</Text>
</View>
<Text style={styles.subtitle}>{item.destination}</Text>
<Text style={styles.time}>
{item.eventTime.toLocaleString('de-AT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text style={styles.status}>{status}</Text>
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
<Text style={styles.deleteText}>Entfernen</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
{/* Edit button */}
<TouchableOpacity
onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })}
style={styles.editBtn}
>
<Text style={[styles.editText, { color: colors.accent }]}> Bearbeiten</Text>
</TouchableOpacity>
{/* Delete button */}
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
<Text style={[styles.deleteText, { color: colors.delete }]}>Entfernen</Text>
</TouchableOpacity>
</View>
);
};
if (events.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.empty}>Keine Termine</Text>
<View style={[styles.center, { backgroundColor: colors.background }]}>
<Text style={[styles.empty, { color: colors.subtext }]}>Keine Termine</Text>
<TouchableOpacity
style={styles.addBtn}
onPress={() => navigation.navigate('AddEvent')}
@@ -106,19 +134,19 @@ export function EventListScreen({ navigation }: ScreenProps) {
}
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.topBar}>
<TouchableOpacity
onPress={() => navigation.navigate('CalendarImport')}
style={styles.topBtn}
>
<Text style={styles.topBtnText}>📅 Kalender</Text>
<Text style={[styles.topBtnText, { color: colors.accent }]}>📅 Kalender</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate('Settings')}
style={styles.topBtn}
>
<Text style={styles.topBtnText}> Einstellungen</Text>
<Text style={[styles.topBtnText, { color: colors.accent }]}> Einstellungen</Text>
</TouchableOpacity>
</View>
<FlatList
@@ -127,7 +155,7 @@ export function EventListScreen({ navigation }: ScreenProps) {
renderItem={renderItem}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#007AFF" />
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
}
/>
<TouchableOpacity
@@ -141,16 +169,15 @@ export function EventListScreen({ navigation }: ScreenProps) {
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
container: { flex: 1 },
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
topBtnText: { color: '#007AFF', fontSize: 15 },
topBtnText: { fontSize: 15 },
list: { padding: 12 },
cardWrapper: { marginBottom: 12 },
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
@@ -159,15 +186,17 @@ const styles = StyleSheet.create({
},
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
dot: { width: 10, height: 10, borderRadius: 5 },
title: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', flex: 1 },
title: { fontSize: 18, fontWeight: '600', flex: 1 },
badge: { fontSize: 12, fontWeight: '600' },
subtitle: { fontSize: 14, color: '#8e8e93', marginBottom: 4 },
time: { fontSize: 13, color: '#007AFF' },
status: { fontSize: 13, color: '#34C759', marginTop: 2, fontWeight: '500' },
deleteBtn: { alignSelf: 'flex-start', marginTop: 8, paddingVertical: 4, paddingHorizontal: 8 },
deleteText: { color: '#FF3B30', fontSize: 13 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
empty: { fontSize: 20, color: '#8e8e93', marginBottom: 16 },
subtitle: { fontSize: 14, marginBottom: 4 },
time: { fontSize: 13 },
status: { fontSize: 13, marginTop: 2, fontWeight: '500' },
editBtn: { alignSelf: 'flex-start', marginTop: 4 },
editText: { fontSize: 13, fontWeight: '500' },
deleteBtn: { alignSelf: 'flex-start', marginTop: 2, marginBottom: 4 },
deleteText: { fontSize: 13 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
empty: { fontSize: 20, marginBottom: 16 },
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
fab: {
+68 -46
View File
@@ -15,14 +15,8 @@ import * as Location from 'expo-location';
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings, rescheduleAllNotifications } from '../store/eventStore';
import { api } from '../services/api';
import type { Station, ReminderSettings } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
import type { RootStack } from '../types/navigation';
import { useTheme } from '../hooks/useTheme';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
@@ -30,6 +24,7 @@ type ScreenProps = {
};
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const { dark, toggle: toggleTheme } = useTheme();
const [origin, setOrigin] = useState<Station | null>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]);
@@ -45,6 +40,24 @@ 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: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
border: '#38383a',
success: '#30d158',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
border: '#e5e5ea',
success: '#34C759',
};
// Load persisted data on mount
useEffect(() => {
loadOriginStation().then(setOrigin);
@@ -175,95 +188,110 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
};
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: colors.background }]}>
{/* Appearance */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
<View style={styles.settingRow}>
<Text style={[styles.settingLabel, { color: colors.text }]}>Dunkelmodus</Text>
<Switch
value={dark}
onValueChange={toggleTheme}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Dunkelmodus umschalten"
/>
</View>
</View>
{/* Origin Station */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ursprungstation</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Ursprungstation</Text>
<TextInput
style={styles.input}
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="Station suchen …"
placeholderTextColor={colors.subtext}
value={query}
onChangeText={onQueryChange}
autoCapitalize="words"
accessibilityLabel="Station suchen"
/>
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color="#007AFF" />}
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />}
{origin && (
<Text style={styles.currentStation}>Aktuell: {origin.name}</Text>
<Text style={[styles.currentStation, { color: colors.success }]}>Aktuell: {origin.name}</Text>
)}
{results.map((s) => (
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
<Text style={styles.resultItem}>{s.name}</Text>
<Text style={[styles.resultItem, { color: colors.accent, borderBottomColor: colors.border }]}>{s.name}</Text>
</TouchableOpacity>
))}
<TouchableOpacity style={styles.locBtn} onPress={useCurrentLocation}>
<Text style={styles.locBtnText}>📍 Aktuelle Position verwenden</Text>
<TouchableOpacity style={[styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={useCurrentLocation}>
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
</TouchableOpacity>
<Text style={styles.locStatus}>
<Text style={[styles.locStatus, { color: colors.subtext }]}>
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
</Text>
</View>
{/* Notification Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Benachrichtigungen</Text>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Benachrichtigungen aktivieren</Text>
<Text style={[styles.settingLabel, { color: colors.text }]}>Benachrichtigungen aktivieren</Text>
<Switch
value={notifSettings.enabled}
onValueChange={toggleNotifications}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Benachrichtigungen umschalten"
/>
</View>
<Text style={styles.settingLabel}>Pufferzeit (Minuten)</Text>
<Text style={[styles.settingLabel, { color: colors.text }]}>Pufferzeit (Minuten)</Text>
<TextInput
style={[styles.input, styles.numberInput]}
style={[styles.input, styles.numberInput, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
value={String(notifSettings.bufferMinutes)}
onChangeText={updateBufferMinutes}
keyboardType="numeric"
accessibilityLabel="Pufferzeit in Minuten"
/>
<Text style={styles.hint}>
<Text style={[styles.hint, { color: colors.subtext }]}>
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
</Text>
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn]} onPress={toggleAdvanced}>
<Text style={styles.locBtnText}>
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={toggleAdvanced}>
<Text style={[styles.locBtnText, { color: colors.accent }]}>
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
</Text>
</TouchableOpacity>
{showAdvanced && (
<View style={styles.advancedSection}>
<Text style={styles.settingLabel}>Ankunfts-Puffer (Minuten)</Text>
<View style={[styles.advancedSection, { borderTopColor: colors.border }]}>
<Text style={[styles.settingLabel, { color: colors.text }]}>Ankunfts-Puffer (Minuten)</Text>
<TextInput
style={[styles.input, styles.numberInput]}
style={[styles.input, styles.numberInput, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
value={String(notifSettings.arrivalBufferMinutes)}
onChangeText={updateArrivalBuffer}
keyboardType="numeric"
accessibilityLabel="Ankunfts-Puffer in Minuten"
/>
<Text style={styles.hint}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
<Text style={[styles.hint, { color: colors.subtext }]}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Zu Fuß-Option anzeigen</Text>
<Text style={[styles.settingLabel, { color: colors.text }]}>Zu Fuß-Option anzeigen</Text>
<Switch
value={notifSettings.showWalkingOption}
onValueChange={toggleWalking}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Zu Fuß-Option umschalten"
/>
</View>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Fahrrad-Option anzeigen</Text>
<Text style={[styles.settingLabel, { color: colors.text }]}>Fahrrad-Option anzeigen</Text>
<Switch
value={notifSettings.showBikeOption}
onValueChange={toggleBike}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Fahrrad-Option umschalten"
/>
</View>
@@ -275,36 +303,31 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7', padding: 20 },
container: { flex: 1, padding: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 10 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
},
numberInput: { width: 80 },
currentStation: { fontSize: 14, color: '#34C759', marginTop: 6 },
currentStation: { fontSize: 14, marginTop: 6 },
resultItem: {
fontSize: 15,
color: '#007AFF',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e5e5ea',
},
locBtn: {
marginTop: 12,
paddingVertical: 12,
backgroundColor: '#e8f4fd',
borderRadius: 10,
alignItems: 'center',
},
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
locBtnText: { fontSize: 15, fontWeight: '500' },
locStatus: { fontSize: 12, marginTop: 6 },
advancedToggle: {
marginTop: 12,
marginBottom: 12,
@@ -313,9 +336,8 @@ const styles = StyleSheet.create({
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#e5e5ea',
},
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
settingLabel: { fontSize: 14, color: '#1c1c1e' },
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
settingLabel: { fontSize: 14 },
hint: { fontSize: 12, marginTop: 6 },
});
+18 -8
View File
@@ -1,12 +1,22 @@
export { requestPermissionsAsync } from 'expo-notifications/build/NotificationPermissions.js';
export { setNotificationHandler } from 'expo-notifications/build/NotificationsHandler.js';
export { default as getAllScheduledNotificationsAsync } from 'expo-notifications/build/getAllScheduledNotificationsAsync.js';
export { default as cancelScheduledNotificationAsync } from 'expo-notifications/build/cancelScheduledNotificationAsync.js';
export { default as cancelAllScheduledNotificationsAsync } from 'expo-notifications/build/cancelAllScheduledNotificationsAsync.js';
export { default as scheduleNotificationAsync } from 'expo-notifications/build/scheduleNotificationAsync.js';
export { SchedulableTriggerInputTypes } from 'expo-notifications/build/Notifications.types.js';
// Public API re-exports from expo-notifications
// Using stable public exports instead of internal /build/ paths
import * as Notifications from 'expo-notifications';
export default Notifications;
export {
requestPermissionsAsync,
setNotificationHandler,
getAllScheduledNotificationsAsync,
cancelScheduledNotificationAsync,
cancelAllScheduledNotificationsAsync,
scheduleNotificationAsync,
} from 'expo-notifications';
// Re-export types from the public package
export type {
NotificationBehavior,
NotificationRequest,
NotificationRequestInput,
} from 'expo-notifications/build/Notifications.types.js';
SchedulableTriggerInput,
} from 'expo-notifications';
-156
View File
@@ -1,156 +0,0 @@
import * as Notifications from './expoNotifications';
import { SchedulableTriggerInputTypes } from './expoNotifications';
import type { Event, Journey, ReminderSettings } from '@timetoleave/core';
// Register for push notification permissions
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
/**
* Calculate leave-by time from event time and journey data.
* Uses earliest real departure time if journeys exist, otherwise event time minus buffer.
*
* @param event - The event to calculate leave-by time for
* @param journeys - Journey data for this event
* @param arrivalBufferMinutes - How many minutes before the event to arrive
* @param bufferMinutes - How many minutes before leaving to be reminded
*/
export function calculateLeaveByTime(
event: Event,
journeys: Journey[],
arrivalBufferMinutes: number,
bufferMinutes: number
): Date {
// Calculate target arrival time (event time minus arrival buffer)
const targetArrivalTimeMs = event.eventTime.getTime() - arrivalBufferMinutes * 60 * 1000;
// If we have journeys, use the earliest non-cancelled real departure minus reminder buffer
if (journeys.length > 0) {
const best = journeys
.filter((j) => !j.cancelled)
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
if (best) {
// Leave by time = earliest real departure time - reminder buffer
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
}
}
// Fallback: event time minus arrival buffer minus reminder (no journey data)
return new Date(targetArrivalTimeMs - bufferMinutes * 60 * 1000);
}
/**
* Schedule notifications for an event
*
* @param event - The event to schedule notifications for
* @param journeys - Journey data for this event (optional)
* @param settings - Notification settings
*/
export async function scheduleNotificationsForEvent(
event: Event,
journeys: Journey[] = [],
settings: ReminderSettings
): Promise<void> {
// Don't schedule if notifications are disabled
if (!settings.enabled) {
return;
}
// Calculate leave-by time (when user should actually leave)
const leaveByTime = calculateLeaveByTime(event, journeys, settings.arrivalBufferMinutes, settings.bufferMinutes);
// Cancel existing notifications for this event - cancel one by one
const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
if (toCancel.length > 0) {
for (const notif of toCancel) {
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
}
}
// Default reminders: 30min, 10min, and at leave-by time
// But respect the buffer time - we want reminders relative to when they should leave
const defaultReminders = [30, 10, 0];
// Schedule notifications
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 Date trigger with time property
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
body: minutesBefore === 0
? 'Zeit zu gehen!'
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
});
}
}
/**
* Reschedule notifications for all events
* Use this when origin station changes or notification settings are updated
*/
export async function rescheduleAllNotifications(
events: Event[],
journeysMap: Record<string, Journey[]>, // eventId -> journeys
settings: ReminderSettings
): Promise<void> {
// Cancel ALL existing notifications first
await Notifications.cancelAllScheduledNotificationsAsync();
// Schedule new notifications for each event
for (const event of events) {
const eventJourneys = journeysMap[event.id] || [];
await scheduleNotificationsForEvent(event, eventJourneys, settings);
}
}
// Request permissions if not already granted
let permissionsRequested = false;
export async function requestNotificationPermissions(): Promise<boolean> {
if (permissionsRequested) {
return true;
}
permissionsRequested = true;
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
// Request permissions automatically when app starts (for Android)
// This is called in App.tsx
export function setupNotifications() {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}
+37 -18
View File
@@ -1,15 +1,14 @@
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 '../services/expoNotifications';
// ── Keys ───────────────────────────────
// ── Keys ───────────────────────────────────────────────────────
const EVENTS_KEY = '@timetoleave_events';
const ORIGIN_KEY = '@timetoleave_origin';
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
// ── Default notification settings ─────────────────────
// ── Default notification settings ─────────────────────────────
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
bufferMinutes: 30,
@@ -19,7 +18,7 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
showBikeOption: true,
};
// ── Helpers ───────────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────────
function reviveDates(json: string): Event[] {
try {
@@ -35,9 +34,9 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
}
// ────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────
// Notification scheduling utilities
// ────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
// Calculate target arrival time (event time minus arrival buffer)
@@ -78,6 +77,9 @@ async function scheduleEventNotification(event: Event): Promise<void> {
continue;
}
// Use timestamp (seconds) as trigger — more reliable than Date object across versions
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
@@ -86,15 +88,15 @@ async function scheduleEventNotification(event: Event): Promise<void> {
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger: timestampSeconds as any,
});
}
}
// ────────────────────────────────────────────────────────────
// Events ────────────────────────────────────────────
// ── Events ────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────
// Events
// ───────────────────────────────────────────────────────────────
export async function loadEvents(): Promise<Event[]> {
const json = await AsyncStorage.getItem(EVENTS_KEY);
@@ -113,6 +115,17 @@ export async function addEvent(event: Event): Promise<void> {
await scheduleEventNotification(event);
}
export async function updateEvent(id: string, updates: Partial<Event>): Promise<void> {
const events = await loadEvents();
const updated = events.map((e) => (e.id === id ? { ...e, ...updates } : e));
await saveEvents(updated);
// Reschedule notification for updated event
const updatedEvent = updated.find((e) => e.id === id);
if (updatedEvent) {
await scheduleEventNotification(updatedEvent);
}
}
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
const events = await loadEvents();
const filtered = events.filter((e) => e.id !== id);
@@ -128,8 +141,9 @@ export async function removeEvent(id: string, onDone?: () => void): Promise<void
onDone?.();
}
// ────────────────────────────────────────────────────────────
// Origin Station ────────────────────────────────────
// ───────────────────────────────────────────────────────────────
// Origin Station
// ───────────────────────────────────────────────────────────────
export async function loadOriginStation(): Promise<Station | null> {
const json = await AsyncStorage.getItem(ORIGIN_KEY);
@@ -141,8 +155,9 @@ export async function saveOriginStation(station: Station): Promise<void> {
await AsyncStorage.setItem(ORIGIN_KEY, json);
}
// ────────────────────────────────────────────────────────────
// Notification Settings ──────────────────────────────
// ───────────────────────────────────────────────────────────────
// Notification Settings
// ───────────────────────────────────────────────────────────────
export async function loadNotificationSettings(): Promise<ReminderSettings> {
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
@@ -156,9 +171,9 @@ export async function saveNotificationSettings(
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
}
// ────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────
// Reschedule all notifications (for origin/setting changes)
// ────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────
export async function rescheduleAllNotifications(): Promise<void> {
const events = await loadEvents();
@@ -188,6 +203,9 @@ export async function rescheduleAllNotifications(): Promise<void> {
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}`,
@@ -196,7 +214,8 @@ export async function rescheduleAllNotifications(): Promise<void> {
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger: timestampSeconds as any,
});
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Shared navigation types for the entire app.
* Import from here instead of duplicating in each screen.
*/
export type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: { editEventId?: string };
Settings: undefined;
CalendarImport: undefined;
};