Files
time_to_leave/apps/mobile/src/screens/SettingsScreen.tsx
T

382 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: 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 (
<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 },
});