20159262c1
- 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
236 lines
7.9 KiB
TypeScript
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 },
|
|
});
|