Files
time_to_leave/apps/mobile/src/screens/SettingsScreen.tsx
T
fegger 20159262c1 Add getLeaveStatus utility and update project status
- Introduce `getLeaveStatus` function in `packages/core/src/status-utils.ts` to determine leave-by status based on
  journey data
- Mark Phase 1 mobile app tasks as complete in `CHECKLIST.md`
- Add mobile workspace configurations and npm scripts
2026-05-10 22:14:41 +02:00

236 lines
7.9 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 } from '../store/eventStore';
import { api } from '../services/api';
import type { Station, ReminderSettings } from '@timetoleave/core';
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
route: RouteProp<RootStack, 'Settings'>;
};
export function SettingsScreen({ navigation }: ScreenProps) {
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,
});
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([]);
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);
};
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;
// Search for stations near the user's actual GPS coordinates
// Use Nominatim reverse geocode via the API to find a nearby station
const geoResults = await api.geocode('Wien', 'at');
// Find the station closest to the user's actual coordinates
if (geoResults.length > 0) {
const closest = geoResults.reduce((best: { lat: number; lng: number; display_name: string } | null, candidate) => {
if (!best) return 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;
}, null);
if (closest) {
const station: Station = {
name: closest.display_name,
extId: String(closest.lat) + ',' + String(closest.lng),
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);
};
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);
}
};
return (
<View style={styles.container}>
{/* Origin Station */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ursprungstation</Text>
<TextInput
style={styles.input}
placeholder="Station suchen …"
value={query}
onChangeText={onQueryChange}
autoCapitalize="words"
accessibilityLabel="Station suchen"
/>
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color="#007AFF" />}
{origin && (
<Text style={styles.currentStation}>Aktuell: {origin.name}</Text>
)}
{results.map((s) => (
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
<Text style={styles.resultItem}>{s.name}</Text>
</TouchableOpacity>
))}
<TouchableOpacity style={styles.locBtn} onPress={useCurrentLocation}>
<Text style={styles.locBtnText}>📍 Aktuelle Position verwenden</Text>
</TouchableOpacity>
<Text style={styles.locStatus}>
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
</Text>
</View>
{/* Notification Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Benachrichtigungen aktivieren</Text>
<Switch
value={notifSettings.enabled}
onValueChange={toggleNotifications}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
accessibilityLabel="Benachrichtigungen umschalten"
/>
</View>
<Text style={styles.settingLabel}>Pufferzeit (Minuten)</Text>
<TextInput
style={[styles.input, styles.numberInput]}
value={String(notifSettings.bufferMinutes)}
onChangeText={updateBufferMinutes}
keyboardType="numeric"
accessibilityLabel="Pufferzeit in Minuten"
/>
<Text style={styles.hint}>
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7', padding: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 10 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
},
numberInput: { width: 80 },
currentStation: { fontSize: 14, color: '#34C759', marginTop: 6 },
resultItem: {
fontSize: 15,
color: '#007AFF',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e5e5ea',
},
locBtn: {
marginTop: 12,
paddingVertical: 12,
backgroundColor: '#e8f4fd',
borderRadius: 10,
alignItems: 'center',
},
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
settingLabel: { fontSize: 14, color: '#1c1c1e' },
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
});