672d053ece
- Add new hook files for departure time, destination station, geocode, theme, walk route, and WienerLinien - Add navigation types for centralized route definitions - Update App.tsx to use ref for initialization logic - Update notification service to use stable exports from expo-notifications - Remove legacy notifications.ts and rename to expoNotifications.ts - Add ScrollView to several screens for better layout - Replace duplicated type definitions with imports from shared navigation types - Add useTheme to relevant screens - Remove notification handler duplication in App.tsx
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Alert,
|
|
ActivityIndicator,
|
|
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 { useTheme } from '../hooks/useTheme';
|
|
|
|
type ScreenProps = {
|
|
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
|
|
route: RouteProp<RootStack, 'Settings'>;
|
|
};
|
|
|
|
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
|
const { dark, toggle: toggleTheme } = useTheme();
|
|
const [origin, setOrigin] = useState<Station | null>(null);
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<Station[]>([]);
|
|
const [searching, setSearching] = useState(false);
|
|
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);
|
|
|
|
const colors = dark ? {
|
|
background: '#1c1c1e',
|
|
card: '#2c2c2e',
|
|
text: '#f2f2f2',
|
|
subtext: '#aeaeb2',
|
|
accent: '#0a84ff',
|
|
border: '#38383a',
|
|
success: '#30d158',
|
|
} : {
|
|
background: '#f2f2f7',
|
|
card: '#ffffff',
|
|
text: '#1c1c1e',
|
|
subtext: '#8e8e93',
|
|
accent: '#007AFF',
|
|
border: '#e5e5ea',
|
|
success: '#34C759',
|
|
};
|
|
|
|
// 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([]);
|
|
return;
|
|
}
|
|
setSearching(true);
|
|
try {
|
|
const stations = await api.searchStation(q.trim());
|
|
setResults(stations);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
const onQueryChange = (text: string) => {
|
|
setQuery(text);
|
|
// Proper debounce using useRef — no `any`
|
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
|
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
|
|
};
|
|
|
|
const selectStation = (station: Station) => {
|
|
setOrigin(station);
|
|
setQuery(station.name);
|
|
setResults([]);
|
|
saveOriginStation(station);
|
|
rescheduleAllNotifications(); // Recalculate when origin changes
|
|
};
|
|
|
|
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;
|
|
|
|
// Find real public-transport stops near the user's GPS coordinates
|
|
// via the WienerLinien nearby-stops proxy.
|
|
const stops = await api.findNearbyStops(userLat, userLng, 2000);
|
|
if (stops.length === 0) {
|
|
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
|
return;
|
|
}
|
|
|
|
// Pick the closest stop to the user's actual position
|
|
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]);
|
|
|
|
// Build a Station with the real stop id as extId — HAFAS can look this up.
|
|
const station: Station = {
|
|
name: closest.name,
|
|
extId: closest.id,
|
|
lat: closest.lat,
|
|
lng: closest.lng,
|
|
};
|
|
selectStation(station);
|
|
} catch (_err) {
|
|
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
|
{/* 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} />}
|
|
{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: dark ? '#1a3a5c' : '#e8f4fd' }]} 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: dark ? '#1a3a5c' : '#e8f4fd' }]} 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>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 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 },
|
|
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 },
|
|
});
|