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
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
@@ -0,0 +1,5 @@
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
return <AppNavigator />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Time To Leave",
|
||||
"slug": "timetoleave",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#007AFF"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "at.timetoleave.mobile"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#007AFF"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "at.timetoleave.mobile"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-location",
|
||||
"expo-notifications"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@timetoleave/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo 'no lint yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"@react-navigation/native-stack": "^7.14.14",
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
"expo": "~54.0.33",
|
||||
"expo-location": "^55.1.9",
|
||||
"expo-notifications": "^55.0.22",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
import { EventDetailScreen } from '../screens/EventDetailScreen';
|
||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
|
||||
|
||||
// ── Root Stack ────────────────────────────────────────
|
||||
|
||||
const RootStack = createNativeStackNavigator<{
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
}>();
|
||||
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
|
||||
<StatusBar style="auto" />
|
||||
<NavigationContainer>
|
||||
<RootStack.Navigator
|
||||
initialRouteName="EventList"
|
||||
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
|
||||
>
|
||||
<RootStack.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||
<RootStack.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||
<RootStack.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||
<RootStack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||
<RootStack.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||
</RootStack.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { addEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||
route: RouteProp<RootStack, 'AddEvent'>;
|
||||
};
|
||||
|
||||
export function AddEventScreen({ navigation }: ScreenProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
const [timeStr, setTimeStr] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const validate = (): boolean => {
|
||||
if (!title.trim()) { setError('Titel erforderlich'); return false; }
|
||||
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
|
||||
if (!dateStr || !timeStr) { setError('Datum und Zeit erforderlich'); return false; }
|
||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||
if (isNaN(eventTime.getTime())) { setError('Ungültiges Datum'); return false; }
|
||||
if (eventTime <= new Date()) { setError('Datum muss in der Zukunft liegen'); return false; }
|
||||
setError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||
const event: CalendarEvent = {
|
||||
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: title.trim(),
|
||||
destination: destination.trim(),
|
||||
eventTime,
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
await addEvent(event);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.label}>Titel</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z.B. Team Meeting"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Ziel</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z.B. Wien, Donau-City"
|
||||
value={destination}
|
||||
onChangeText={setDestination}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Datum</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="JJJJ-MM-TT"
|
||||
value={dateStr}
|
||||
onChangeText={setDateStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Zeit</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="SS:MM"
|
||||
value={timeStr}
|
||||
onChangeText={setTimeStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveBtnText}>Speichern</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, styles.cancelBtn]}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={[styles.saveBtnText, styles.cancelText]}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
form: { padding: 20 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#1c1c1e', marginBottom: 6 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
errorText: { color: '#FF3B30', fontSize: 14, marginBottom: 8 },
|
||||
saveBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
cancelBtn: { marginTop: 12, backgroundColor: '#e5e5ea' },
|
||||
cancelText: { color: '#1c1c1e' },
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { api } from '../services/api';
|
||||
import { addEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
||||
route: RouteProp<RootStack, 'CalendarImport'>;
|
||||
};
|
||||
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte ICS-URL eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
const events = await api.fetchCalendar(url.trim());
|
||||
// Add imported events to local store
|
||||
for (const evt of events) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
}
|
||||
setCount(events.length);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.description}>
|
||||
Gib eine ICS-Kalender-URL ein, um Termine automatisch zu importieren.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{count !== null && (
|
||||
<View style={styles.successBanner}>
|
||||
<Text style={styles.successText}>
|
||||
✓ {count} Termin(e) erfolgreich importiert!
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.importBtn, loading && styles.importBtnDisabled]}
|
||||
onPress={handleImport}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.importBtnText}>Importieren</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backBtn}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={styles.backBtnText}>← Zurück</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
content: { padding: 20 },
|
||||
description: { fontSize: 14, color: '#8e8e93', marginBottom: 16, lineHeight: 20 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
errorText: { color: '#fff', fontSize: 14 },
|
||||
successBanner: { backgroundColor: '#34C759', borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
successText: { color: '#fff', fontSize: 14 },
|
||||
importBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
importBtnDisabled: { opacity: 0.6 },
|
||||
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
backBtn: {
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
backBtnText: { color: '#007AFF', fontSize: 15 },
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, loadOriginStation } from '../store/eventStore';
|
||||
import { api } from '../services/api';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, BikeRoute, Station, Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
|
||||
route: RouteProp<RootStack, 'EventDetail'>;
|
||||
};
|
||||
|
||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const { eventId } = route.params;
|
||||
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingBike, setLoadingBike] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [events, originStation] = await Promise.all([loadEvents(), loadOriginStation()]);
|
||||
setOrigin(originStation);
|
||||
const found = events.find((e) => e.id === eventId);
|
||||
if (!found) {
|
||||
setError('Termin nicht gefunden');
|
||||
return;
|
||||
}
|
||||
setEvent(found);
|
||||
|
||||
if (originStation) {
|
||||
const results = await api.searchJourneys(
|
||||
originStation.extId,
|
||||
found.destination,
|
||||
found.eventTime,
|
||||
);
|
||||
setJourneys(results);
|
||||
|
||||
// Fetch bike route if we have a destination station
|
||||
// We need destination coordinates; for MVP we geocode the destination name
|
||||
try {
|
||||
setLoadingBike(true);
|
||||
const geo = await api.geocode(found.destination);
|
||||
if (geo.length > 0 && originStation.lat && originStation.lng) {
|
||||
const bike = await api.getBikeRoute(
|
||||
originStation.lat,
|
||||
originStation.lng,
|
||||
geo[0].lat,
|
||||
geo[0].lng,
|
||||
);
|
||||
setBikeRoute(bike);
|
||||
}
|
||||
} catch {
|
||||
// Bike route is optional — don't fail the whole screen
|
||||
setBikeRoute(null);
|
||||
} finally {
|
||||
setLoadingBike(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setBikeRoute(null);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Termine werden geladen…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Event header */}
|
||||
{event && (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||
<Text style={styles.eventDest}>{event.destination}</Text>
|
||||
<Text style={styles.eventTime}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.source}>Quelle: {event.source}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Origin status */}
|
||||
{!origin && !error && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Text style={styles.warningBannerText}>
|
||||
Keine Ursprungstation festgelegt.
|
||||
{' '}
|
||||
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Einstellungen öffnen
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Journeys list */}
|
||||
<View style={styles.journeys}>
|
||||
<Text style={styles.sectionTitle}>Zugverbindungen</Text>
|
||||
{journeys.length === 0 ? (
|
||||
<Text style={styles.emptyText}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={styles.journeyCard}>
|
||||
<View style={styles.journeyRow}>
|
||||
<Text style={styles.lineText}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{j.delay > 0 && <Text style={styles.delayBadge}>+{j.delay} min</Text>}
|
||||
{j.cancelled && <Text style={styles.cancelBadge}>Storniert</Text>}
|
||||
</View>
|
||||
<Text style={styles.departure}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={styles.arrival}>
|
||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bike route section */}
|
||||
<View style={styles.journeys}>
|
||||
<Text style={styles.sectionTitle}>Radroute</Text>
|
||||
{loadingBike ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={styles.bikeCard}>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={styles.bikeLabel}>⏱ Dauer</Text>
|
||||
<Text style={styles.bikeValue}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={styles.bikeLabel}>📏 Distanz</Text>
|
||||
<Text style={styles.bikeValue}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Refresh */}
|
||||
<TouchableOpacity style={styles.refreshBtn} onPress={handleRefresh}>
|
||||
<Text style={styles.refreshBtnText}>🔄 Neu laden</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
||||
loadingText: { color: '#8e8e93', marginTop: 12, fontSize: 15 },
|
||||
header: { padding: 20, backgroundColor: '#fff', marginBottom: 12 },
|
||||
eventTitle: { fontSize: 22, fontWeight: '700', color: '#1c1c1e' },
|
||||
eventDest: { fontSize: 16, color: '#8e8e93', marginTop: 4 },
|
||||
eventTime: { fontSize: 14, color: '#007AFF', marginTop: 8 },
|
||||
source: { fontSize: 12, color: '#8e8e93', marginTop: 4 },
|
||||
errorBanner: { backgroundColor: '#FF3B30', padding: 12, marginBottom: 12 },
|
||||
errorBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningBanner: { backgroundColor: '#FF9500', padding: 12, marginBottom: 12 },
|
||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
journeys: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 12 },
|
||||
emptyText: { color: '#8e8e93', fontSize: 14 },
|
||||
journeyCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
lineText: { fontSize: 16, fontWeight: '600', color: '#1c1c1e' },
|
||||
delayBadge: { backgroundColor: '#FF3B30', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { backgroundColor: '#000', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
departure: { fontSize: 13, color: '#1c1c1e', marginTop: 6 },
|
||||
arrival: { fontSize: 13, color: '#8e8e93', marginTop: 2 },
|
||||
centerBike: { alignItems: 'center', gap: 8 },
|
||||
bikeCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
bikeLabel: { fontSize: 15, color: '#1c1c1e', fontWeight: '500' },
|
||||
bikeValue: { fontSize: 15, color: '#007AFF', fontWeight: '600' },
|
||||
mapPlaceholder: {
|
||||
marginTop: 10,
|
||||
height: 100,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f2f2f7',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#c7c7cc',
|
||||
},
|
||||
mapPlaceholderText: { fontSize: 14, color: '#8e8e93' },
|
||||
refreshBtn: {
|
||||
alignSelf: 'center',
|
||||
marginTop: 20,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
backgroundColor: '#e5e5ea',
|
||||
borderRadius: 12,
|
||||
},
|
||||
refreshBtnText: { fontSize: 15, color: '#1c1c1e', fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
// Color map for countdown urgency
|
||||
const urgencyColor = (urgent: boolean): string => {
|
||||
if (urgent) return '#FF3B30';
|
||||
return '#34C759';
|
||||
};
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||
route: RouteProp<RootStack, 'EventList'>;
|
||||
};
|
||||
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await loadEvents();
|
||||
setEvents(list);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
useFocusEffect(
|
||||
useCallback(() => { reload(); }, [reload]),
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await reload();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||
const countdown = calculateCountdown(item.eventTime);
|
||||
|
||||
// Derive a simple status — journeys aren't loaded on the list screen for MVP
|
||||
// so we show countdown-based status instead
|
||||
const status = countdown.urgent ? 'Bald!' : countdown.label;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.dotRow}>
|
||||
<View style={[styles.dot, { backgroundColor: urgencyColor(countdown.urgent) }]} />
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
<Text style={[styles.badge, { color: countdown.color === 'red' || countdown.color === 'orange' ? '#FF3B30' : '#007AFF' }]}>
|
||||
{countdown.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{item.destination}</Text>
|
||||
<Text style={styles.time}>
|
||||
{item.eventTime.toLocaleString('de-AT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.status}>{status}</Text>
|
||||
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
|
||||
<Text style={styles.deleteText}>Entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.empty}>Keine Termine</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addBtn}
|
||||
onPress={() => navigation.navigate('AddEvent')}
|
||||
>
|
||||
<Text style={styles.addBtnText}>+ Termin hinzufügen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('CalendarImport')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={styles.topBtnText}>📅 Kalender</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={styles.topBtnText}>⚙️ Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={events}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#007AFF" />
|
||||
}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.fab}
|
||||
onPress={() => navigation.navigate('AddEvent')}
|
||||
>
|
||||
<Text style={styles.fabText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
||||
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
||||
topBtnText: { color: '#007AFF', fontSize: 15 },
|
||||
list: { padding: 12 },
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
|
||||
dot: { width: 10, height: 10, borderRadius: 5 },
|
||||
title: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', flex: 1 },
|
||||
badge: { fontSize: 12, fontWeight: '600' },
|
||||
subtitle: { fontSize: 14, color: '#8e8e93', marginBottom: 4 },
|
||||
time: { fontSize: 13, color: '#007AFF' },
|
||||
status: { fontSize: 13, color: '#34C759', marginTop: 2, fontWeight: '500' },
|
||||
deleteBtn: { alignSelf: 'flex-start', marginTop: 8, paddingVertical: 4, paddingHorizontal: 8 },
|
||||
deleteText: { color: '#FF3B30', fontSize: 13 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
||||
empty: { fontSize: 20, color: '#8e8e93', marginBottom: 16 },
|
||||
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#007AFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
fabText: { color: '#fff', fontSize: 32, fontWeight: '300', marginTop: -4 },
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ApiClient } from '@timetoleave/api-client';
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
|
||||
export const api = new ApiClient(baseUrl);
|
||||
@@ -0,0 +1,77 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||
|
||||
// ── Keys ────────────────────────────────────────────
|
||||
|
||||
const EVENTS_KEY = '@timetoleave_events';
|
||||
const ORIGIN_KEY = '@timetoleave_origin';
|
||||
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
|
||||
|
||||
// ── Default notification settings ─────────────────────
|
||||
|
||||
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────
|
||||
|
||||
function reviveDates(json: string): Event[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
|
||||
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────
|
||||
|
||||
export async function loadEvents(): Promise<Event[]> {
|
||||
const json = await AsyncStorage.getItem(EVENTS_KEY);
|
||||
return json ? reviveDates(json) : [];
|
||||
}
|
||||
|
||||
export async function saveEvents(events: Event[]): Promise<void> {
|
||||
const json = JSON.stringify(events);
|
||||
await AsyncStorage.setItem(EVENTS_KEY, json);
|
||||
}
|
||||
|
||||
export async function addEvent(event: Event): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
events.push(event);
|
||||
await saveEvents(events);
|
||||
}
|
||||
|
||||
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const filtered = events.filter((e) => e.id !== id);
|
||||
await saveEvents(filtered);
|
||||
onDone?.();
|
||||
}
|
||||
|
||||
// ── Origin Station ────────────────────────────────────
|
||||
|
||||
export async function loadOriginStation(): Promise<Station | null> {
|
||||
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
||||
return json ? JSON.parse(json) : null;
|
||||
}
|
||||
|
||||
export async function saveOriginStation(station: Station): Promise<void> {
|
||||
const json = JSON.stringify(station);
|
||||
await AsyncStorage.setItem(ORIGIN_KEY, json);
|
||||
}
|
||||
|
||||
// ── Notification Settings ─────────────────────────────
|
||||
|
||||
export async function loadNotificationSettings(): Promise<ReminderSettings> {
|
||||
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
||||
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
||||
}
|
||||
|
||||
export async function saveNotificationSettings(
|
||||
settings: ReminderSettings,
|
||||
): Promise<void> {
|
||||
const json = JSON.stringify(settings);
|
||||
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":"4.1.5","results":[[":src/hooks/__tests__/useReminder.test.tsx",{"duration":56.437834000000066,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":95.17231700000002,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":29.41875300000015,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":70.20783200000005,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":37.28677700000003,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":10.81236899999999,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":130.41898500000025,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":47.99793599999998,"failed":false}],[":src/lib/__tests__/hafas-client.test.ts",{"duration":3859.471915,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2205.214679,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":74.15890599999989,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":239.26886700000023,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":156.40212100000008,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.596728999999982,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":25.32538199999999,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":23.511126999999988,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":70.20564699999977,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":202.69056899999987,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":6.0024530000000595,"failed":false}]]}
|
||||
{"version":"4.1.5","results":[[":src/hooks/__tests__/useReminder.test.tsx",{"duration":67.8894879999998,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":48.40527399999996,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":12.656690000000026,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":34.53456600000004,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":27.283642999999984,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":8.794370999999956,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":98.56559299999981,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":60.67743799999994,"failed":false}],[":src/lib/__tests__/hafas-client.test.ts",{"duration":4293.082399,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2121.7380169999997,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":65.84604800000011,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":243.63465800000017,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":146.3176759999999,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.2089169999999285,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":19.50615700000003,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":19.419233000000077,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":73.80535599999985,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":210.3314519999999,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":5.045508000000041,"failed":false}]]}
|
||||
Reference in New Issue
Block a user