382 lines
14 KiB
TypeScript
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 },
|
|
});
|