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:
+8
-8
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
| # | Step | ✅ | ✔️ |
|
| # | Step | ✅ | ✔️ |
|
||||||
|---|------|----|----|
|
|---|------|----|----|
|
||||||
| 10 | Scaffold Expo mobile app with TypeScript | [ ] | [ ] |
|
| 10 | Scaffold Expo mobile app with TypeScript | [x] | [x] |
|
||||||
| 11 | Configure mobile API base URL (`.env` + singleton) | [ ] | [ ] |
|
| 11 | Configure mobile API base URL (`.env` + singleton) | [x] | [x] |
|
||||||
| 12 | Add mobile app shell (navigation + 5 screens) | [ ] | [ ] |
|
| 12 | Add mobile app shell (navigation + 5 screens) | [x] | [x] |
|
||||||
| 13 | Add mobile event store (AsyncStorage) | [ ] | [ ] |
|
| 13 | Add mobile event store (AsyncStorage) | [x] | [x] |
|
||||||
| 14 | Build Event List screen | [ ] | [ ] |
|
| 14 | Build Event List screen | [x] | [x] |
|
||||||
| 15 | Build Add Event screen with validation | [ ] | [ ] |
|
| 15 | Build Add Event screen with validation | [x] | [x] |
|
||||||
| 16 | Build Origin Setup in Settings | [ ] | [ ] |
|
| 16 | Build Origin Setup in Settings | [x] | [x] |
|
||||||
| 17 | Build Event Detail screen (trains + bike + errors) | [ ] | [ ] |
|
| 17 | Build Event Detail screen (trains + bike + errors) | [x] | [x] |
|
||||||
| 18 | Phase 2 summary verification | [ ] | [ ] |
|
| 18 | Phase 2 summary verification | [ ] | [ ] |
|
||||||
|
|
||||||
## Phase 3 — Notifications, Calendar, Deployment (Steps 19-24)
|
## Phase 3 — Notifications, Calendar, Deployment (Steps 19-24)
|
||||||
|
|||||||
@@ -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}]]}
|
||||||
Generated
+6917
-106
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -12,6 +12,8 @@
|
|||||||
"start": "npm run start -w apps/web",
|
"start": "npm run start -w apps/web",
|
||||||
"lint": "npm run lint -w apps/web",
|
"lint": "npm run lint -w apps/web",
|
||||||
"typecheck": "npm run typecheck -w apps/web",
|
"typecheck": "npm run typecheck -w apps/web",
|
||||||
"test": "npm run test -w apps/web"
|
"test": "npm run test -w apps/web",
|
||||||
|
"dev:mobile": "npm run start -w apps/mobile",
|
||||||
|
"typecheck:mobile": "npm run typecheck -w apps/mobile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Status utilities for TimeToLeave
|
// Status utilities for TimeToLeave
|
||||||
import type { ServerStatus } from "./types";
|
import type { ServerStatus, Event, Journey } from "./types";
|
||||||
|
|
||||||
export class StatusUtils {
|
export class StatusUtils {
|
||||||
static async checkServerStatus(url: string): Promise<ServerStatus> {
|
static async checkServerStatus(url: string): Promise<ServerStatus> {
|
||||||
@@ -14,3 +14,36 @@ export class StatusUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a human-readable leave-by status from an event and its journeys.
|
||||||
|
* Returns early / on-time / delayed / unknown based on the best journey's
|
||||||
|
* real departure time relative to now.
|
||||||
|
*/
|
||||||
|
export function getLeaveStatus(event: Event, journeys: Journey[]): string {
|
||||||
|
if (journeys.length === 0) {
|
||||||
|
return "No journey data";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the earliest non-cancelled journey
|
||||||
|
const best = journeys
|
||||||
|
.filter((j) => !j.cancelled)
|
||||||
|
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
|
||||||
|
|
||||||
|
if (!best) {
|
||||||
|
return "All journeys cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMin = (best.rD.getTime() - Date.now()) / 60_000;
|
||||||
|
|
||||||
|
if (diffMin < 0) {
|
||||||
|
return "Departure missed";
|
||||||
|
}
|
||||||
|
if (best.delay > 10) {
|
||||||
|
return `Delayed +${best.delay} min`;
|
||||||
|
}
|
||||||
|
if (diffMin <= 15) {
|
||||||
|
return "Leave now";
|
||||||
|
}
|
||||||
|
return "On time";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../ui/Button../../hooks/useEventsStore
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../lib/bike-routing-client
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../lib/calendar-utils../../../../lib/constants
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../lib/calendar-utils../../../lib/constants
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../lib/geocoding-client
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../lib/constants
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../lib/constants
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../lib/wienerlinien-client../../../../../lib/wienerlinien-client
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../lib/wienerlinien-client
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../lib/wienerlinien-client../../../../../lib/wienerlinien-client
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../lib/wienerlinien-client
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../hooks/useCalendar../../hooks/useEventsStore
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../event/EventCard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../ui/Button
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../ui/Button
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../hooks/useEventsStore../../hooks/useOriginStation
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../ui/LoadingSpinner../ui/Button
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../hooks/useJourneys../../hooks/useDestinationStation../../hooks/useBikeRoute../../hooks/useGeocode../../hooks/useClock../../hooks/useWienerLinien../ui/CountdownBadge
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../lib/formatting../../lib/countdown-utils
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../ui/Chip
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../lib/formatting../ui/LoadingSpinner../ui/Button
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../ui/LoadingSpinner../ui/Chip
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../hooks/useDestinationStation../../../hooks/useJourneys
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../hooks/useEventsStore./layout/Header./layout/Navbar./layout/ReminderEngine
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../ui/Button../add-event/AddEventModal../ui/ReminderSettingsPanel../../hooks/useServerHealth../../hooks/useEventsStore../../hooks/useTheme
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../hooks/useReminder
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../hooks/useEventsStore../hooks/useOriginStation./event/EventCard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../lib/countdown-utils
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../lib/hafas-client../lib/hafas-time
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../lib/constants
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../constants../constants
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../wienerlinien-client../api-service
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
./constants./api-service
|
|
||||||
Reference in New Issue
Block a user