diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index c66b7db..6bdec30 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -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 ;
diff --git a/apps/mobile/src/hooks/useDepartureTime.ts b/apps/mobile/src/hooks/useDepartureTime.ts
new file mode 100644
index 0000000..bf3a092
--- /dev/null
+++ b/apps/mobile/src/hooks/useDepartureTime.ts
@@ -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]);
+}
diff --git a/apps/mobile/src/hooks/useDestinationStation.ts b/apps/mobile/src/hooks/useDestinationStation.ts
new file mode 100644
index 0000000..c05ee06
--- /dev/null
+++ b/apps/mobile/src/hooks/useDestinationStation.ts
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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(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 };
+}
diff --git a/apps/mobile/src/hooks/useGeocode.ts b/apps/mobile/src/hooks/useGeocode.ts
new file mode 100644
index 0000000..db9e726
--- /dev/null
+++ b/apps/mobile/src/hooks/useGeocode.ts
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 };
+}
diff --git a/apps/mobile/src/hooks/useTheme.ts b/apps/mobile/src/hooks/useTheme.ts
new file mode 100644
index 0000000..bfd96c1
--- /dev/null
+++ b/apps/mobile/src/hooks/useTheme.ts
@@ -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 };
+}
diff --git a/apps/mobile/src/hooks/useWalkRoute.ts b/apps/mobile/src/hooks/useWalkRoute.ts
new file mode 100644
index 0000000..9143444
--- /dev/null
+++ b/apps/mobile/src/hooks/useWalkRoute.ts
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 };
+}
diff --git a/apps/mobile/src/hooks/useWienerLinien.ts b/apps/mobile/src/hooks/useWienerLinien.ts
new file mode 100644
index 0000000..0d812b5
--- /dev/null
+++ b/apps/mobile/src/hooks/useWienerLinien.ts
@@ -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([]);
+ const [departures, setDepartures] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const stopIdsRef = useRef([]);
+ const abortRef = useRef(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 };
+}
diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx
index 1b5ecf9..d30aa82 100644
--- a/apps/mobile/src/navigation/AppNavigator.tsx
+++ b/apps/mobile/src/navigation/AppNavigator.tsx
@@ -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();
export default function AppNavigator() {
return (
@@ -24,16 +19,16 @@ export default function AppNavigator() {
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/mobile/src/screens/AddEventScreen.tsx b/apps/mobile/src/screens/AddEventScreen.tsx
index f1a4587..19e366c 100644
--- a/apps/mobile/src/screens/AddEventScreen.tsx
+++ b/apps/mobile/src/screens/AddEventScreen.tsx
@@ -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;
route: RouteProp;
};
-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 (
-
+
- Titel
+ Titel
- Ziel
+ Ziel
- Datum
+ Datum
- Zeit
+ Zeit
- {error ? {error} : null}
+ {error ? {error} : null}
- Speichern
+ {route.params?.editEventId ? 'Aktualisieren' : 'Speichern'}
navigation.goBack()}
>
- Abbrechen
+ Abbrechen
@@ -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' },
});
diff --git a/apps/mobile/src/screens/CalendarImportScreen.tsx b/apps/mobile/src/screens/CalendarImportScreen.tsx
index 05d0ff1..bbcd427 100644
--- a/apps/mobile/src/screens/CalendarImportScreen.tsx
+++ b/apps/mobile/src/screens/CalendarImportScreen.tsx
@@ -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;
@@ -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(null);
const [count, setCount] = useState(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 (
-
+
- Kalender-Import
-
+ Kalender-Import
+
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
- ICS-URL Import
+ ICS-URL Import
- Geräte-Kalender Sync
-
- Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
+ Geräte-Kalender Sync
+
+ Hole Termine der nächsten 30 Tage aus den Kalendern auf deinem Gerät.
@@ -145,13 +164,13 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
{error && (
-
+
{error}
)}
{count !== null && (
-
+
✓ {count} Termin(e) erfolgreich importiert!
@@ -162,34 +181,32 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
style={styles.backBtn}
onPress={() => navigation.goBack()}
>
- ← Zurück
+ ← Zurück
-
+
);
}
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 },
});
diff --git a/apps/mobile/src/screens/EventDetailScreen.tsx b/apps/mobile/src/screens/EventDetailScreen.tsx
index 5fe8d5b..6cca58f 100644
--- a/apps/mobile/src/screens/EventDetailScreen.tsx
+++ b/apps/mobile/src/screens/EventDetailScreen.tsx
@@ -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;
route: RouteProp;
};
+type TransportMode = 'train' | 'bike';
+
export function EventDetailScreen({ navigation, route }: ScreenProps) {
const { eventId } = route.params;
+ const { dark } = useTheme();
const [event, setEvent] = useState(null);
const [journeys, setJourneys] = useState([]);
const [bikeRoute, setBikeRoute] = useState(null);
+ const [walkRoute, setWalkRoute] = useState(null);
const [origin, setOrigin] = useState(null);
const [loading, setLoading] = useState(true);
const [loadingBike, setLoadingBike] = useState(false);
+ const [loadingWalk, setLoadingWalk] = useState(false);
+ const [activeMode, setActiveMode] = useState('train');
const [error, setError] = useState(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 (
-
-
- Termine werden geladen…
+
+
+
+ {destStation.loading && !event ? 'Ziel-Station wird aufgelöst…' : 'Termine werden geladen…'}
+
);
}
+ // Disable bike mode if setting is off
+ const bikeDisabled = !showBikeOption;
+ const requestedMode: TransportMode = activeMode;
+ const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
+
return (
-
+
{/* Event header */}
{event && (
-
- {event.title}
- {event.destination}
-
+
+ {event.title}
+ {event.destination}
+
{event.eventTime.toLocaleString('de-AT', {
weekday: 'long',
day: '2-digit',
@@ -119,20 +202,42 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
minute: '2-digit',
})}
- Quelle: {event.source}
+ Quelle: {event.source}
+
+ {/* Leave by / Arrive by / Buffer info */}
+
+
+ Losgehen um
+
+ {leaveByTime ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) : '—'}
+
+
+
+ Ankommen um
+
+ {new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ Puffer
+
+ {arrivalBufferMinutes} min
+
+
+
)}
{/* Error */}
{error && (
-
+
⚠ {error}
)}
{/* Origin status */}
{!origin && !error && (
-
+
Keine Ursprungstation festgelegt.
{' '}
@@ -143,135 +248,235 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
)}
- {/* Journeys list */}
-
- Zugverbindungen
- {journeys.length === 0 ? (
-
- {origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
-
- ) : (
- journeys.map((j) => (
-
-
-
- {j.trains.length > 0 ? j.trains.join(', ') : '—'}
-
- {j.delay > 0 && +{j.delay} min}
- {j.cancelled && Storniert}
-
-
- Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
- {' '}
- (Plattform {j.platform || '—'})
-
-
- Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
- {' '}
- ({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
-
+ {/* Transport mode selector */}
+ {origin && (
+
+ setActiveMode('train')}
+ >
+
+ 🚆 Zug
+ {effectiveMode === 'train' && (
+
+ Aktiv
+
+ )}
- ))
- )}
-
+
+ {showWalkingOption ? 'Bahn + finaler Fußweg' : 'Nur Bahn'}
+
+
- {/* Bike route section */}
-
- Radroute
- {loadingBike ? (
-
-
- Radroute wird geladen…
-
- ) : bikeRoute ? (
-
-
- ⏱ Dauer
- {formatDuration(bikeRoute.duration)}
+ !bikeDisabled && setActiveMode('bike')}
+ disabled={bikeDisabled}
+ >
+
+ 🚲 Rad
+ {effectiveMode === 'bike' && (
+
+ Aktiv
+
+ )}
-
- 📏 Distanz
- {formatDistance(bikeRoute.distance)}
+
+ {bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'}
+
+
+
+ )}
+
+ {/* Journeys list (Train mode) */}
+ {effectiveMode === 'train' && (
+
+ Zugverbindungen
+ {destStation.loading && (
+
+
+
+ Ziel-Station wird aufgelöst…
+
-
- 🗺 Karte (post-MVP)
+ )}
+ {journeys.length === 0 && !destStation.loading ? (
+
+ {origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
+
+ ) : (
+ journeys.map((j) => (
+
+
+
+ {j.trains.length > 0 ? j.trains.join(', ') : '—'}
+
+ {j.delay > 0 && (
+ +{j.delay} min
+ )}
+ {j.cancelled && (
+ Storniert
+ )}
+
+
+ Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
+ {' '}
+ (Plattform {j.platform || '—'})
+
+
+ Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
+ {' '}
+ ({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
+
+
+ ))
+ )}
+
+ {/* Walk route section (when walking option enabled) */}
+ {showWalkingOption && walkRoute && (
+
+ 🚶 Finaler Fußweg
+
+ ⏱ Dauer
+ {formatDuration(walkRoute.duration)}
+
+
+ 📏 Distanz
+ {formatDistance(walkRoute.distance)}
+
-
- ) : (
-
- {origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
-
- )}
-
+ )}
+ {showWalkingOption && loadingWalk && (
+
+
+
+ Fußweg wird geladen…
+
+
+ )}
+
+ )}
+
+ {/* Bike route section (Bike mode) */}
+ {effectiveMode === 'bike' && (
+
+ Radroute
+ {loadingBike ? (
+
+
+ Radroute wird geladen…
+
+ ) : bikeRoute ? (
+
+
+ ⏱ Dauer
+ {formatDuration(bikeRoute.duration)}
+
+
+ 📏 Distanz
+ {formatDistance(bikeRoute.distance)}
+
+
+ 🗺 Karte (post-MVP)
+
+
+ ) : (
+
+ {origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
+
+ )}
+
+ )}
+
+ {/* WienerLinien nearby stops */}
+ {wienerLinien.stops.length > 0 && (
+
+ 🚏 ÖPNV in der Nähe des Ziels
+ {wienerLinien.loading ? (
+
+
+ Haltestellen werden geladen…
+
+ ) : (
+ wienerLinien.stops.slice(0, 5).map((stop) => (
+
+ {stop.name}
+
+ ))
+ )}
+ {wienerLinien.error && (
+ {wienerLinien.error}
+ )}
+
+ )}
{/* Refresh */}
-
- 🔄 Neu laden
+
+ 🔄 Neu laden
-
+
+ {/* Bottom padding for scroll */}
+
+
);
}
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' },
});
diff --git a/apps/mobile/src/screens/EventListScreen.tsx b/apps/mobile/src/screens/EventListScreen.tsx
index ae5c203..395720a 100644
--- a/apps/mobile/src/screens/EventListScreen.tsx
+++ b/apps/mobile/src/screens/EventListScreen.tsx
@@ -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;
@@ -34,8 +22,27 @@ type ScreenProps = {
};
export function EventListScreen({ navigation }: ScreenProps) {
+ const { dark } = useTheme();
const [events, setEvents] = useState([]);
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 (
- navigation.navigate('EventDetail', { eventId: item.id })}
- activeOpacity={0.6}
- >
-
-
-
- {item.title}
-
- {countdown.label}
+
+ navigation.navigate('EventDetail', { eventId: item.id })}
+ activeOpacity={0.6}
+ style={{ flex: 1 }}
+ >
+
+
+
+ {item.title}
+
+ {countdown.label}
+
+
+ {item.destination}
+
+ {item.eventTime.toLocaleString('de-AT', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+ {status}
- {item.destination}
-
- {item.eventTime.toLocaleString('de-AT', {
- day: '2-digit',
- month: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })}
-
- {status}
- removeEvent(item.id, reload)} style={styles.deleteBtn}>
- Entfernen
-
-
-
+
+
+ {/* Edit button */}
+ navigation.navigate('AddEvent', { editEventId: item.id })}
+ style={styles.editBtn}
+ >
+ ✏️ Bearbeiten
+
+
+ {/* Delete button */}
+ removeEvent(item.id, reload)} style={styles.deleteBtn}>
+ Entfernen
+
+
);
};
if (events.length === 0) {
return (
-
- Keine Termine
+
+ Keine Termine
navigation.navigate('AddEvent')}
@@ -106,19 +134,19 @@ export function EventListScreen({ navigation }: ScreenProps) {
}
return (
-
+
navigation.navigate('CalendarImport')}
style={styles.topBtn}
>
- 📅 Kalender
+ 📅 Kalender
navigation.navigate('Settings')}
style={styles.topBtn}
>
- ⚙️ Einstellungen
+ ⚙️ Einstellungen
+
}
/>
;
@@ -30,6 +24,7 @@ type ScreenProps = {
};
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
+ const { dark, toggle: toggleTheme } = useTheme();
const [origin, setOrigin] = useState(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
@@ -45,6 +40,24 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
const searchTimerRef = useRef | 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 (
-
+
+ {/* Appearance */}
+
+ Erscheinungsbild
+
+ Dunkelmodus
+
+
+
+
{/* Origin Station */}
- Ursprungstation
+ Ursprungstation
- {searching && }
+ {searching && }
{origin && (
- Aktuell: {origin.name}
+ Aktuell: {origin.name}
)}
{results.map((s) => (
selectStation(s)}>
- {s.name}
+ {s.name}
))}
-
- 📍 Aktuelle Position verwenden
+
+ 📍 Aktuelle Position verwenden
-
+
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
{/* Notification Settings */}
- Benachrichtigungen
+ Benachrichtigungen
- Benachrichtigungen aktivieren
+ Benachrichtigungen aktivieren
- Pufferzeit (Minuten)
+ Pufferzeit (Minuten)
-
+
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
-
-
+
+
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
{showAdvanced && (
-
- Ankunfts-Puffer (Minuten)
+
+ Ankunfts-Puffer (Minuten)
- Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest
+ Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest
- Zu Fuß-Option anzeigen
+ Zu Fuß-Option anzeigen
- Fahrrad-Option anzeigen
+ Fahrrad-Option anzeigen
@@ -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 },
});
diff --git a/apps/mobile/src/services/expoNotifications.ts b/apps/mobile/src/services/expoNotifications.ts
index 08d99c0..00efbe5 100644
--- a/apps/mobile/src/services/expoNotifications.ts
+++ b/apps/mobile/src/services/expoNotifications.ts
@@ -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';
diff --git a/apps/mobile/src/services/notifications.ts b/apps/mobile/src/services/notifications.ts
deleted file mode 100644
index 5862954..0000000
--- a/apps/mobile/src/services/notifications.ts
+++ /dev/null
@@ -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 {
- // 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, // eventId -> journeys
- settings: ReminderSettings
-): Promise {
- // 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 {
- 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,
- }),
- });
-}
diff --git a/apps/mobile/src/store/eventStore.ts b/apps/mobile/src/store/eventStore.ts
index b2162f1..06d0a42 100644
--- a/apps/mobile/src/store/eventStore.ts
+++ b/apps/mobile/src/store/eventStore.ts
@@ -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 {
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
}
-// ────────────────────────────────────────────────────────────
+// ───────────────────────────────────────────────────────────────
// Notification scheduling utilities
-// ────────────────────────────────────────────────────────────
+// ───────────────────────────────────────────────────────────────
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise {
// Calculate target arrival time (event time minus arrival buffer)
@@ -78,6 +77,9 @@ async function scheduleEventNotification(event: Event): Promise {
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 {
: `${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 {
const json = await AsyncStorage.getItem(EVENTS_KEY);
@@ -113,6 +115,17 @@ export async function addEvent(event: Event): Promise {
await scheduleEventNotification(event);
}
+export async function updateEvent(id: string, updates: Partial): Promise {
+ 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 {
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 {
const json = await AsyncStorage.getItem(ORIGIN_KEY);
@@ -141,8 +155,9 @@ export async function saveOriginStation(station: Station): Promise {
await AsyncStorage.setItem(ORIGIN_KEY, json);
}
-// ────────────────────────────────────────────────────────────
-// Notification Settings ──────────────────────────────
+// ───────────────────────────────────────────────────────────────
+// Notification Settings
+// ───────────────────────────────────────────────────────────────
export async function loadNotificationSettings(): Promise {
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 {
const events = await loadEvents();
@@ -188,6 +203,9 @@ export async function rescheduleAllNotifications(): Promise {
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 {
: `${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,
});
}
}
diff --git a/apps/mobile/src/types/navigation.ts b/apps/mobile/src/types/navigation.ts
new file mode 100644
index 0000000..f95fc6c
--- /dev/null
+++ b/apps/mobile/src/types/navigation.ts
@@ -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;
+};