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