import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; 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'; import type { RootStack } from '../types/navigation'; import { useColors } from '../hooks/useColors'; import { useTheme } from '../hooks/useTheme'; type ScreenProps = { navigation: NativeStackNavigationProp; route: RouteProp; }; /** * Settings screen for managing the origin station, notification preferences, * appearance (dark/light mode), and advanced options (walk/bike toggles). * * Station search is debounced by 400 ms. When the origin changes, all * scheduled push notifications are recalculated to use the new departure station. */ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const { dark, toggle: toggleTheme } = useTheme(); const colors = useColors(); const [origin, setOrigin] = useState(null); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [notifSettings, setNotifSettings] = useState({ bufferMinutes: 30, enabled: true, arrivalBufferMinutes: 5, showWalkingOption: true, showBikeOption: true, }); const [showAdvanced, setShowAdvanced] = useState(false); const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt'); const searchTimerRef = useRef | null>(null); // Load persisted data on mount useEffect(() => { loadOriginStation().then(setOrigin); loadNotificationSettings().then(setNotifSettings); return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); }; }, []); // Debounced station search const searchStation = useCallback(async (q: string) => { if (q.trim().length < 2) { setResults([]); setSearchError(null); return; } setSearching(true); setSearchError(null); try { const stations = await api.searchStation(q.trim()); setResults(stations); if (stations.length === 0) setSearchError('Keine Stationen gefunden.'); } catch { setResults([]); setSearchError('API nicht erreichbar. Läuft der Server auf deinem Gerät? Überprüfe EXPO_PUBLIC_API_BASE_URL in .env.'); } finally { setSearching(false); } }, []); const onQueryChange = (text: string) => { setQuery(text); setSearchError(null); // Proper debounce using useRef — no `any` if (searchTimerRef.current) clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => searchStation(text), 400); }; const selectStation = async (station: Station) => { setOrigin(station); setQuery(station.name); setResults([]); try { await saveOriginStation(station); await rescheduleAllNotifications(); Alert.alert('Station gespeichert', station.name); } catch { Alert.alert('Fehler', 'Station konnte nicht gespeichert werden.'); } }; const useCurrentLocation = async () => { try { const { status } = await Location.requestForegroundPermissionsAsync(); setLocPermission(status === 'granted' ? 'granted' : 'denied'); if (status !== 'granted') { Alert.alert('Berechtigung erforderlich', 'Standortzugriff ist nötig für die automatische Stationssuche.'); return; } const loc = await Location.getCurrentPositionAsync({}); const userLat = loc.coords.latitude; const userLng = loc.coords.longitude; // Try the WienerLinien nearby-stops proxy first let stops: Awaited> | null = null; let apiReachable = false; try { stops = await api.findNearbyStops(userLat, userLng, 2000); apiReachable = true; } catch { // API unavailable, will fall back to HAFAS LocMatch below } // If nearby-stops returned results, pick the closest one if (stops && stops.length > 0) { const closest = stops.reduce((best, candidate) => { const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng); const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng); return candDist < bestDist ? candidate : best; }, stops[0]); const station: Station = { name: closest.name, extId: closest.id, lat: closest.lat, lng: closest.lng, }; await selectStation(station); return; } // Fallback: use HAFAS LocMatch directly (same pattern as the web app) try { const nearestStation = await api.findNearestStationByCoords(userLat, userLng); if (!nearestStation) { Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); return; } await selectStation(nearestStation); return; } catch { // API not reachable } if (!apiReachable) { Alert.alert( 'API nicht erreichbar', 'Der Server konnte nicht erreicht werden. Stelle sicher, dass EXPO_PUBLIC_API_BASE_URL in der .env-Datei auf die LAN-IP deines Entwicklungsrechners zeigt (z. B. http://192.168.1.x:3000) und nicht auf localhost.' ); } else { Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); } } catch (_err) { Alert.alert('Fehler', 'Standortermittlung fehlgeschlagen. Bitte überprüfe die Berechtigungen in den Systemeinstellungen.'); } }; const toggleNotifications = async (value: boolean) => { const updated = { ...notifSettings, enabled: value }; setNotifSettings(updated); await saveNotificationSettings(updated); await rescheduleAllNotifications(); }; const updateBufferMinutes = async (value: string) => { const minutes = parseInt(value, 10); if (!isNaN(minutes) && minutes >= 0) { const updated = { ...notifSettings, bufferMinutes: minutes }; setNotifSettings(updated); await saveNotificationSettings(updated); await rescheduleAllNotifications(); } }; const updateArrivalBuffer = async (value: string) => { const minutes = parseInt(value, 10); if (!isNaN(minutes) && minutes >= 0) { const updated = { ...notifSettings, arrivalBufferMinutes: minutes }; setNotifSettings(updated); await saveNotificationSettings(updated); await rescheduleAllNotifications(); } }; const toggleWalking = async (value: boolean) => { const updated = { ...notifSettings, showWalkingOption: value }; setNotifSettings(updated); await saveNotificationSettings(updated); await rescheduleAllNotifications(); }; const toggleBike = async (value: boolean) => { const updated = { ...notifSettings, showBikeOption: value }; setNotifSettings(updated); await saveNotificationSettings(updated); await rescheduleAllNotifications(); }; const toggleAdvanced = () => { setShowAdvanced(!showAdvanced); }; return ( {/* Appearance */} Erscheinungsbild Dunkelmodus {/* Origin Station */} Ursprungstation {searching && } {searchError && ( {searchError} )} {origin && ( Aktuell: {origin.name} )} {results.map((s) => ( selectStation(s)}> {s.name} ))} 📍 Aktuelle Position verwenden Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'} {/* Notification Settings */} Benachrichtigungen Benachrichtigungen aktivieren Pufferzeit (Minuten) Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert. {showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'} {showAdvanced && ( Ankunfts-Puffer (Minuten) Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest Zu Fuß-Option anzeigen Fahrrad-Option anzeigen )} ); } const styles = StyleSheet.create({ container: { flex: 1 }, contentContainer: { flexGrow: 1, padding: 20 }, section: { marginBottom: 24 }, sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 }, input: { borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 16, borderWidth: 1, }, numberInput: { width: 80 }, currentStation: { fontSize: 14, marginTop: 6 }, resultItem: { fontSize: 15, paddingVertical: 8, borderBottomWidth: 1, }, locBtn: { marginTop: 12, paddingVertical: 12, borderRadius: 10, alignItems: 'center', }, locBtnText: { fontSize: 15, fontWeight: '500' }, locStatus: { fontSize: 12, marginTop: 6 }, errorText: { fontSize: 13, marginTop: 6 }, advancedToggle: { marginTop: 12, marginBottom: 12, }, advancedSection: { marginTop: 16, paddingTop: 16, borderTopWidth: 1, }, settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, settingLabel: { fontSize: 14 }, hint: { fontSize: 12, marginTop: 6 }, });