Switch mobile app to dark theme and fix calendar filtering

Switch mobile app to dark theme and fix calendar filtering

- Default to dark theme in mobile app, matching web app style
- Filter native calendar events by location to exclude empty ones
- Add batch edit panel for destinations on web calendar
- Add edit support to AddEventModal
- Update notification trigger input type export
This commit is contained in:
2026-05-13 09:45:55 +02:00
parent de9a8606ab
commit 9e461aab1a
22 changed files with 632 additions and 168 deletions
+12 -5
View File
@@ -5,12 +5,12 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#007AFF"
"backgroundColor": "#090816"
},
"ios": {
"supportsTablet": true,
@@ -23,7 +23,7 @@
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#007AFF"
"backgroundColor": "#090816"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
@@ -31,7 +31,8 @@
"permissions": [
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.POST_NOTIFICATIONS",
"android.permission.INTERNET"
"android.permission.INTERNET",
"android.permission.ACCESS_COARSE_LOCATION"
]
},
"web": {
@@ -41,6 +42,12 @@
"expo-location",
"expo-notifications"
],
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy"
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy",
"extra": {
"eas": {
"projectId": "2467d09e-f838-404b-b5a9-14d48ac76bec"
}
},
"owner": "floegger"
}
}
+1
View File
@@ -20,6 +20,7 @@
"@timetoleave/core": "*",
"expo": "~54.0.33",
"expo-calendar": "~15.0.8",
"expo-dev-client": "~6.0.21",
"expo-location": "~19.0.8",
"expo-notifications": "~0.32.17",
"expo-status-bar": "~3.0.9",
+31 -11
View File
@@ -91,7 +91,8 @@ describe('calendar service', () => {
const result = await fetchNativeEvents(startDate, endDate);
expect(result).toHaveLength(2);
// Only the event with a location is returned; events without a location are filtered out
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: 'evt1',
title: 'Team Meeting',
@@ -99,13 +100,6 @@ describe('calendar service', () => {
eventTime: new Date('2025-01-15T10:00:00'),
source: 'native:cal1',
});
expect(result[1]).toEqual({
id: 'evt2',
title: 'Dentist',
destination: '',
eventTime: new Date('2025-01-20T14:00:00'),
source: 'native:cal2',
});
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
['cal1', 'cal2'],
@@ -114,7 +108,33 @@ describe('calendar service', () => {
);
});
it('handles events with missing title or startDate', async () => {
it('filters out events with no location', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
mockCalendar.getEventsAsync.mockResolvedValue([
{
id: 'evt1',
calendarId: 'cal1',
title: 'No Location Event',
location: null,
startDate: new Date('2025-01-15T10:00:00'),
},
{
id: 'evt2',
calendarId: 'cal1',
title: 'Empty Location Event',
location: ' ',
startDate: new Date('2025-01-16T10:00:00'),
},
] as Calendar.Event[]);
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toHaveLength(0);
});
it('handles events with missing title', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
@@ -123,7 +143,7 @@ describe('calendar service', () => {
id: 'evt1',
calendarId: 'cal1',
title: null as unknown as string,
location: null,
location: 'Wien Hbf',
startDate: null as unknown as string | Date,
},
] as Calendar.Event[]);
@@ -132,7 +152,7 @@ describe('calendar service', () => {
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Untitled Event');
expect(result[0].destination).toBe('');
expect(result[0].destination).toBe('Wien Hbf');
});
});
});
@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import type { Journey } from '@timetoleave/core';
import { loadNotificationSettings } from '../store/eventStore';
interface DepartureTimeResult {
departureTime: Date | null;
+2 -2
View File
@@ -7,8 +7,8 @@ type Theme = 'dark' | 'light';
function getDefaultTheme(): Theme {
// React Native doesn't have window.matchMedia, but we can use a simple default
// In practice, we'd use useColorScheme from react-native for system preference
return 'light';
// The web app uses a dark-first theme, so we match that default
return 'dark';
}
/**
+23 -39
View File
@@ -22,10 +22,6 @@ function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
};
}
/**
* Fetch nearby WienerLinien stops and their departures.
* Mirrors the web app's useWienerLinien hook, adapted for mobile API client.
*/
export function useWienerLinien(
lat: number | undefined,
lng: number | undefined,
@@ -37,37 +33,38 @@ export function useWienerLinien(
const [error, setError] = useState<string | null>(null);
const stopIdsRef = useRef<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const cancelledRef = useRef(false);
// Effect for fetching stops and initial departures
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
if (stopIds.length === 0 || cancelledRef.current) return;
try {
const rawDepartures = await api.monitorStops(stopIds);
if (!cancelledRef.current) {
setDepartures(rawDepartures.map(transformDeparture));
}
} catch {
// Silently ignore monitor errors — stops are still shown
}
}, []);
useEffect(() => {
cancelledRef.current = false;
const resetState = () => {
if (lat === undefined || lng === undefined) {
setStops([]);
setDepartures([]);
setError(null);
setLoading(false);
};
if (lat === undefined || lng === undefined) {
resetState();
return;
}
const debounceTimer = setTimeout(async () => {
if (cancelledRef.current) return;
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
setLoading(true);
setError(null);
try {
// Fetch nearby stops
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
if (cancelledRef.current) return;
@@ -78,20 +75,10 @@ export function useWienerLinien(
const ids = stopsList.map((s) => s.id);
stopIdsRef.current = ids;
// Chain monitor fetch for departures
if (ids.length > 0) {
try {
// Fetch departures for each stop - note: mobile API client doesn't have
// a direct monitor endpoint, so we skip this for now
// The web app uses an internal API route for this
} catch {
// Silently ignore departure fetch errors
}
}
await fetchMonitor(ids);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
if (cancelledRef.current) return;
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
setError(err instanceof Error ? err.message : 'Haltestellen konnten nicht geladen werden');
setLoading(false);
}
}, DEBOUNCE_MS);
@@ -99,25 +86,22 @@ export function useWienerLinien(
return () => {
cancelledRef.current = true;
clearTimeout(debounceTimer);
abortRef.current?.abort();
abortRef.current = null;
};
}, [lat, lng, radius]);
}, [lat, lng, radius, fetchMonitor]);
// Effect for periodic departures refresh
// Periodic departures refresh
useEffect(() => {
if (stops.length === 0) return;
const intervalId = setInterval(async () => {
const currentIds = stopIdsRef.current;
if (currentIds.length === 0) return;
// Refresh logic would go here if we had the monitor API
// For now, this is a placeholder for future implementation
const intervalId = setInterval(() => {
const ids = stopIdsRef.current;
if (ids.length > 0) {
fetchMonitor(ids);
}
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [stops.length]);
}, [stops.length, fetchMonitor]);
return { stops, departures, loading, error };
}
+3 -3
View File
@@ -16,12 +16,12 @@ const Root = createNativeStackNavigator<RootStack>();
export default function AppNavigator() {
return (
<SafeAreaProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
<StatusBar style="auto" />
<SafeAreaView style={{ flex: 1, backgroundColor: '#090816' }}>
<StatusBar style="light" />
<NavigationContainer>
<Root.Navigator
initialRouteName="EventList"
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
screenOptions={{ headerStyle: { backgroundColor: '#17112A' }, headerTintColor: '#F4F1EA' }}
>
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
+50 -9
View File
@@ -25,13 +25,14 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
const [dateStr, setDateStr] = useState('');
const [timeStr, setTimeStr] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
error: '#ff453a',
} : {
@@ -39,7 +40,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
accent: '#B23CFF',
border: '#e5e5ea',
error: '#FF3B30',
};
@@ -97,11 +98,14 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
await addEvent(event);
}
navigation.goBack();
setSuccess(true);
setTimeout(() => {
navigation.goBack();
}, 1500);
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.container, { backgroundColor: colors.background, position: 'relative' }]}>
<View style={styles.form}>
<Text style={[styles.label, { color: colors.text }]}>Titel</Text>
<TextInput
@@ -156,6 +160,20 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
<Text style={[styles.cancelText, { color: colors.text }]}>Abbrechen</Text>
</TouchableOpacity>
</View>
{success && (
<View style={[styles.successOverlay, { backgroundColor: dark ? 'rgba(28,28,30,0.95)' : 'rgba(255,255,255,0.95)' }]}>
<View style={styles.successContent}>
<View style={styles.successCircle}>
<Text style={styles.checkmark}></Text>
</View>
<Text style={[styles.successTitle, { color: colors.text }]}>Erfolg!</Text>
<Text style={[styles.successSubtitle, { color: colors.subtext }]}>
{route.params?.editEventId ? 'Termin aktualisiert' : 'Termin hinzugefügt'}
</Text>
</View>
</View>
)}
</View>
);
}
@@ -174,7 +192,7 @@ const styles = StyleSheet.create({
},
errorText: { fontSize: 14, marginBottom: 8 },
saveBtn: {
backgroundColor: '#007AFF',
backgroundColor: '#8B5CF6',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
@@ -183,4 +201,27 @@ const styles = StyleSheet.create({
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
cancelBtn: { marginTop: 12 },
cancelText: { fontSize: 16, fontWeight: '600' },
successOverlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 20,
paddingBottom: 40,
borderTopWidth: 1,
borderTopColor: '#34C759',
alignItems: 'center',
},
successContent: { alignItems: 'center', gap: 8 },
successCircle: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#34C759',
justifyContent: 'center',
alignItems: 'center',
},
checkmark: { color: '#fff', fontSize: 24, fontWeight: 'bold' },
successTitle: { fontSize: 18, fontWeight: '600' },
successSubtitle: { fontSize: 14 },
});
@@ -30,25 +30,25 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
const [count, setCount] = useState<number | null>(null);
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
error: '#ff453a',
success: '#30d158',
purple: '#bf5af2',
purple: '#B23CFF',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
accent: '#B23CFF',
border: '#e5e5ea',
error: '#FF3B30',
success: '#34C759',
purple: '#5856D6',
purple: '#8B5CF6',
};
const handleImport = async () => {
@@ -209,7 +209,7 @@ const styles = StyleSheet.create({
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
successText: { color: '#fff', fontSize: 14 },
importBtn: {
backgroundColor: '#007AFF',
backgroundColor: '#8B5CF6',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
+33 -8
View File
@@ -150,11 +150,11 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
// Theme-based colors
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
warning: '#ff9f0a',
error: '#ff453a',
@@ -163,7 +163,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
accent: '#B23CFF',
border: '#e5e5ea',
warning: '#FF9500',
error: '#FF3B30',
@@ -395,8 +395,8 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
</View>
)}
{/* WienerLinien nearby stops */}
{wienerLinien.stops.length > 0 && (
{/* WienerLinien nearby stops + departures */}
{(wienerLinien.loading || wienerLinien.stops.length > 0) && (
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
{wienerLinien.loading ? (
@@ -404,6 +404,25 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen</Text>
</View>
) : wienerLinien.departures.length > 0 ? (
wienerLinien.departures.slice(0, 8).map((dep, i) => (
<View
key={`${dep.stopId}-${dep.lineName}-${i}`}
style={[styles.departureCard, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<View style={styles.departureRow}>
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
</View>
<Text style={[styles.departureDirection, { color: colors.text }]} numberOfLines={1}>
{dep.direction}
</Text>
<Text style={[styles.departureMinutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
{dep.minutes === 0 ? 'jetzt' : `${dep.minutes} min`}
</Text>
</View>
</View>
))
) : (
wienerLinien.stops.slice(0, 5).map((stop) => (
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
@@ -477,6 +496,12 @@ const styles = StyleSheet.create({
mapPlaceholderText: { fontSize: 14 },
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
stopName: { fontSize: 14, fontWeight: '500' },
departureCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
departureRow: { flexDirection: 'row' as const, alignItems: 'center', gap: 8 },
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' as const },
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
departureDirection: { flex: 1, fontSize: 13 },
departureMinutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' as const },
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
refreshBtnText: { fontSize: 15, fontWeight: '600' },
});
+9 -9
View File
@@ -29,18 +29,18 @@ export function EventListScreen({ navigation }: ScreenProps) {
const [, setTick] = useState(0);
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
delete: '#ff453a',
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
delete: '#FF3B30',
} : {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
accent: '#B23CFF',
delete: '#FF3B30',
};
@@ -197,7 +197,7 @@ const styles = StyleSheet.create({
deleteText: { fontSize: 13 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
empty: { fontSize: 20, marginBottom: 16 },
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
addBtn: { backgroundColor: '#8B5CF6', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
fab: {
position: 'absolute',
@@ -206,7 +206,7 @@ const styles = StyleSheet.create({
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#007AFF',
backgroundColor: '#8B5CF6',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
+36 -11
View File
@@ -29,6 +29,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
bufferMinutes: 30,
enabled: true,
@@ -41,11 +42,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const colors = dark ? {
background: '#1c1c1e',
card: '#2c2c2e',
text: '#f2f2f2',
subtext: '#aeaeb2',
accent: '#0a84ff',
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
success: '#30d158',
} : {
@@ -53,7 +54,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#007AFF',
accent: '#B23CFF',
border: '#e5e5ea',
success: '#34C759',
};
@@ -72,14 +73,18 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const searchStation = useCallback(async (q: string) => {
if (q.trim().length < 2) {
setResults([]);
setSearchError(null);
return;
}
setSearching(true);
setSearchError(null);
try {
const stations = await api.searchStation(q.trim());
setResults(stations);
if (stations.length === 0) setSearchError('Keine Stationen gefunden.');
} catch {
setResults([]);
setSearchError('API nicht erreichbar. Läuft der Server auf deinem Gerät? Überprüfe EXPO_PUBLIC_API_BASE_URL in .env.');
} finally {
setSearching(false);
}
@@ -87,6 +92,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const onQueryChange = (text: string) => {
setQuery(text);
setSearchError(null);
// Proper debounce using useRef — no `any`
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
@@ -121,8 +127,10 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
// Try the WienerLinien nearby-stops proxy first
let stops: Awaited<ReturnType<typeof api.findNearbyStops>> | null = null;
let apiReachable = false;
try {
stops = await api.findNearbyStops(userLat, userLng, 2000);
apiReachable = true;
} catch {
// API unavailable, will fall back to HAFAS LocMatch below
}
@@ -146,15 +154,28 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
}
// Fallback: use HAFAS LocMatch directly (same pattern as the web app)
const nearestStation = await api.findNearestStationByCoords(userLat, userLng);
if (!nearestStation) {
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
try {
const nearestStation = await api.findNearestStationByCoords(userLat, userLng);
if (!nearestStation) {
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
return;
}
await selectStation(nearestStation);
return;
} catch {
// API not reachable
}
await selectStation(nearestStation);
if (!apiReachable) {
Alert.alert(
'API nicht erreichbar',
'Der Server konnte nicht erreicht werden. Stelle sicher, dass EXPO_PUBLIC_API_BASE_URL in der .env-Datei auf die LAN-IP deines Entwicklungsrechners zeigt (z. B. http://192.168.1.x:3000) und nicht auf localhost.'
);
} else {
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
}
} catch (_err) {
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
Alert.alert('Fehler', 'Standortermittlung fehlgeschlagen. Bitte überprüfe die Berechtigungen in den Systemeinstellungen.');
}
};
@@ -232,6 +253,9 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
accessibilityLabel="Station suchen"
/>
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />}
{searchError && (
<Text style={[styles.errorText, { color: '#ff453a' }]}>{searchError}</Text>
)}
{origin && (
<Text style={[styles.currentStation, { color: colors.success }]}>Aktuell: {origin.name}</Text>
)}
@@ -344,6 +368,7 @@ const styles = StyleSheet.create({
},
locBtnText: { fontSize: 15, fontWeight: '500' },
locStatus: { fontSize: 12, marginTop: 6 },
errorText: { fontSize: 13, marginTop: 6 },
advancedToggle: {
marginTop: 12,
marginBottom: 12,
+9 -7
View File
@@ -31,11 +31,13 @@ export async function fetchNativeEvents(
const calendarIds = calendars.map((c) => c.id);
const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate);
return events.map((evt) => ({
id: evt.id,
title: evt.title ?? 'Untitled Event',
destination: evt.location ?? '',
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
source: `native:${evt.calendarId}`,
}));
return events
.filter((evt) => evt.location && evt.location.trim().length > 0)
.map((evt) => ({
id: evt.id,
title: evt.title ?? 'Untitled Event',
destination: evt.location!.trim(),
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
source: `native:${evt.calendarId}`,
}));
}
@@ -18,5 +18,5 @@ export type {
NotificationBehavior,
NotificationRequest,
NotificationRequestInput,
SchedulableTriggerInput,
SchedulableTriggerInputTypes as SchedulableTriggerInput,
} from 'expo-notifications';
+1 -1
View File
@@ -5,7 +5,7 @@
export type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: { editEventId?: string };
AddEvent: undefined | { editEventId?: string };
Settings: undefined;
CalendarImport: undefined;
};