Files
time_to_leave/apps/mobile/src/screens/SettingsScreen.tsx
T
fegger ce1fa4972c Account for origin walk in departure time
Add useOriginStationWalk and integrate it into EventDetail/EventList to
resolve walking time from origin to station. Introduce calculateLeaveByTime
for notification scheduling and use exported Expo trigger types. Support
HAFAS 'crd' coordinates in client and destination hooks, update tests,
jest mappings, and Expo run scripts.
2026-05-18 11:56:49 +02:00

386 lines
14 KiB
TypeScript

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<RootStack, 'Settings'>;
route: RouteProp<RootStack, 'Settings'>;
};
/**
* 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<Station | null>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof api.findNearbyStops>> | 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: userLat,
lng: userLng,
};
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,
lat: userLat,
lng: userLng,
});
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 (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.contentContainer}
keyboardShouldPersistTaps="handled"
>
{/* 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, { color: colors.text }]}>Ursprungstation</Text>
<TextInput
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={colors.accent} />}
{searchError && (
<Text style={[styles.errorText, { color: '#ff453a' }]}>{searchError}</Text>
)}
{origin && (
<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, { color: colors.accent, borderBottomColor: colors.border }]}>{s.name}</Text>
</TouchableOpacity>
))}
<TouchableOpacity style={[styles.locBtn, { backgroundColor: colors.highlight }]} onPress={useCurrentLocation}>
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
</TouchableOpacity>
<Text style={[styles.locStatus, { color: colors.subtext }]}>
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
</Text>
</View>
{/* Notification Settings */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Benachrichtigungen</Text>
<View style={styles.settingRow}>
<Text style={[styles.settingLabel, { color: colors.text }]}>Benachrichtigungen aktivieren</Text>
<Switch
value={notifSettings.enabled}
onValueChange={toggleNotifications}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Benachrichtigungen umschalten"
/>
</View>
<Text style={[styles.settingLabel, { color: colors.text }]}>Pufferzeit (Minuten)</Text>
<TextInput
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, { color: colors.subtext }]}>
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
</Text>
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: colors.highlight }]} onPress={toggleAdvanced}>
<Text style={[styles.locBtnText, { color: colors.accent }]}>
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
</Text>
</TouchableOpacity>
{showAdvanced && (
<View style={[styles.advancedSection, { borderTopColor: colors.border }]}>
<Text style={[styles.settingLabel, { color: colors.text }]}>Ankunfts-Puffer (Minuten)</Text>
<TextInput
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, { 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, { color: colors.text }]}>Zu Fuß-Option anzeigen</Text>
<Switch
value={notifSettings.showWalkingOption}
onValueChange={toggleWalking}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Zu Fuß-Option umschalten"
/>
</View>
<View style={styles.settingRow}>
<Text style={[styles.settingLabel, { color: colors.text }]}>Fahrrad-Option anzeigen</Text>
<Switch
value={notifSettings.showBikeOption}
onValueChange={toggleBike}
trackColor={{ true: colors.accent, false: colors.border }}
accessibilityLabel="Fahrrad-Option umschalten"
/>
</View>
</View>
)}
</View>
</ScrollView>
);
}
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 },
});