Files
time_to_leave/apps/mobile/src/screens/CalendarImportScreen.tsx
T
fegger 9d6efdd43b Configure Expo mobile project and update Android/iOS bundles
Initialize iOS and Android native projects for the Time to Leave app.
Rename Android package to com.floegger.timetoleave and add iOS
project files. Add FontAwesome icons, calendar types, and
dependencies.
2026-05-15 18:26:51 +02:00

527 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
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 { fetchNativeEvents, getSelectableCalendars, groupCalendarsByType } from '../services/calendar';
import { addEvent, getSelectedCalendarIds, hasCalendarSelection, loadEvents, saveSelectedCalendarIds } from '../store/eventStore';
import type { Event as CalendarEvent, CalendarAccountType, SelectableCalendar } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useColors } from '../hooks/useColors';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
route: RouteProp<RootStack, 'CalendarImport'>;
};
/**
* Display order for calendar source type groups.
*/
const GROUP_ORDER: CalendarAccountType[] = [
'caldav',
'mobileme',
'google',
'exchange',
'subscriptions',
'local',
'carddav',
'activesync',
'other',
'none',
];
/** Badge color for each account type. */
const BADGE_COLORS: Record<string, string> = {
caldav: '#34C759',
mobileme: '#FF9500',
google: '#4285F4',
exchange: '#0078D4',
subscriptions: '#AF52DE',
local: '#8E8E93',
carddav: '#5AC8FA',
activesync: '#FF2D55',
other: '#8E8E93',
none: '#8E8E93',
};
/** Calendar checkbox row component. */
function CalendarCheckbox({
calendar,
selected,
onToggle,
colors,
}: {
calendar: SelectableCalendar;
selected: boolean;
onToggle: (id: string) => void;
colors: ReturnType<typeof useColors>;
}) {
const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other;
return (
<TouchableOpacity
style={[styles.calendarRow, { borderColor: selected ? colors.accent : colors.border }]}
onPress={() => onToggle(calendar.id)}
accessibilityRole="button"
accessibilityLabel={`${calendar.name} ${calendar.sourceInfo.label}`}
>
<View style={[styles.checkBox, selected && styles.checkBoxSelected]}>
{selected && <Text style={styles.checkMark}></Text>}
</View>
<View style={styles.calendarInfo}>
<Text style={[styles.calendarName, { color: colors.text }]}>
{calendar.sourceInfo.emoji} {calendar.name}
</Text>
<View style={styles.badgeRow}>
<View style={[styles.badge, { backgroundColor: badgeColor }]}>
<Text style={styles.badgeText}>{calendar.sourceInfo.badge}</Text>
</View>
<Text style={[styles.accountTypeText, { color: colors.subtext }]}>
{calendar.sourceInfo.label}
</Text>
</View>
</View>
</TouchableOpacity>
);
}
/**
* Screen for importing events from either an ICS calendar URL or the device's
* native calendar. Includes calendar selection so the user can choose which
* calendars to sync. Deduplicates against already-imported events by ID.
*/
export function CalendarImportScreen({ navigation }: ScreenProps) {
const colors = useColors();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [count, setCount] = useState<number | null>(null);
// Calendar selection state
const [availableCalendars, setAvailableCalendars] = useState<SelectableCalendar[]>([]);
const [selectedCalendarIds, setSelectedCalendarIds] = useState<Set<string>>(new Set());
const [calendarsLoaded, setCalendarsLoaded] = useState(false);
const [hasSelection, setHasSelection] = useState(false);
const initialLoadRef = useRef(false);
// Load available calendars and persisted selection on mount
useEffect(() => {
if (initialLoadRef.current) return;
initialLoadRef.current = true;
(async () => {
const [calendars, persistedIds, hasSel] = await Promise.all([
getSelectableCalendars(),
getSelectedCalendarIds(),
hasCalendarSelection(),
]);
setAvailableCalendars(calendars);
setSelectedCalendarIds(new Set(persistedIds));
setHasSelection(hasSel);
setCalendarsLoaded(true);
})();
}, []);
const toggleCalendar = useCallback((id: string) => {
setSelectedCalendarIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedCalendarIds(new Set(availableCalendars.map((c) => c.id)));
}, [availableCalendars]);
const deselectAll = useCallback(() => {
setSelectedCalendarIds(new Set());
}, []);
const saveSelection = useCallback(async () => {
const ids = Array.from(selectedCalendarIds);
await saveSelectedCalendarIds(ids);
setHasSelection(ids.length > 0);
}, [selectedCalendarIds]);
const handleImport = async () => {
if (!url.trim()) {
setError('Bitte ICS-URL eingeben');
return;
}
setLoading(true);
setError(null);
setCount(null);
try {
const [events, existing] = await Promise.all([
api.fetchCalendar(url.trim()),
loadEvents(),
]);
const existingIds = new Set(existing.map((e) => e.id));
let added = 0;
for (const evt of events) {
if (!existingIds.has(evt.id)) {
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);
added++;
}
}
setCount(added);
} catch (err) {
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
} finally {
setLoading(false);
}
};
const handleSyncNative = async () => {
setLoading(true);
setError(null);
setCount(null);
try {
// Save the current selection before syncing
await saveSelection();
// Fetch events from the next 30 days
const now = new Date();
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
// If a selection was made, sync only selected calendars
const calendarIds = hasSelection
? Array.from(selectedCalendarIds)
: undefined;
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater, calendarIds);
// Load existing events to avoid duplicates
const existing = await loadEvents();
const existingIds = new Set(existing.map((e) => e.id));
let added = 0;
for (const evt of nativeEvents) {
if (!existingIds.has(evt.id)) {
await addEvent(evt);
added++;
}
}
setCount(added);
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync fehlgeschlagen');
} finally {
setLoading(false);
}
};
// Group calendars by account type for display
const grouped = groupCalendarsByType(availableCalendars);
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.content}>
<Text style={[styles.heading, { color: colors.text }]}>Kalender-Import</Text>
<Text style={[styles.description, { color: colors.subtext }]}>
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
</Text>
{/* ── ICS URL Import ── */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS-URL Import</Text>
<TextInput
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="https://calendar.google.com/calendar/ical/..."
placeholderTextColor={colors.subtext}
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
/>
<TouchableOpacity
style={[styles.importBtn, loading && styles.importBtnDisabled]}
onPress={handleImport}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.importBtnText}>ICS Importieren</Text>
)}
</TouchableOpacity>
</View>
{/* ── Calendar Selection ── */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Kalender auswählen</Text>
<View style={styles.selectionActions}>
<TouchableOpacity onPress={selectAll} accessibilityLabel="Alle auswählen">
<Text style={[styles.linkText, { color: colors.accent }]}>Alle</Text>
</TouchableOpacity>
<Text style={[styles.dividerText, { color: colors.subtext }]}> · </Text>
<TouchableOpacity onPress={deselectAll} accessibilityLabel="Alle abwählen">
<Text style={[styles.linkText, { color: colors.accent }]}>Keine</Text>
</TouchableOpacity>
</View>
</View>
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
Wähle die Kalender aus, die gesynct werden sollen. Ohne Auswahl werden alle Kalender verwendet.
{'\n'}
CalDAV-Quellen (DAVx5, Apple Calendar, etc.) werden automatisch erkannt.
</Text>
{!calendarsLoaded ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>Kalender werden geladen</Text>
</View>
) : availableCalendars.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.subtext }]}>
Keine Kalender auf diesem Gerät gefunden.
</Text>
</View>
) : (
<>
{/* Render groups in defined order, then any remaining types */}
{GROUP_ORDER.map((type) => {
const group = grouped.get(type);
if (!group || group.length === 0) return null;
return (
<View key={type} style={styles.group}>
<Text style={[styles.groupLabel, { color: colors.subtext }]}>
{group[0].sourceInfo.emoji} {group[0].sourceInfo.label} ({group.length})
</Text>
{group.map((cal) => (
<CalendarCheckbox
key={cal.id}
calendar={cal}
selected={selectedCalendarIds.has(cal.id)}
onToggle={toggleCalendar}
colors={colors}
/>
))}
</View>
);
})}
{hasSelection && (
<View style={[styles.selectionInfo, { backgroundColor: colors.highlight }]}>
<Text style={[styles.selectionInfoText, { color: colors.text }]}>
{selectedCalendarIds.size} von {availableCalendars.length} Kalendern ausgewählt
</Text>
</View>
)}
</>
)}
</View>
{/* ── Device Calendar Sync ── */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Geräte-Kalender Sync</Text>
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
Hole Termine der nächsten 30 Tage aus den Kalendern auf deinem Gerät.
</Text>
<TouchableOpacity
style={[styles.importBtn, { backgroundColor: colors.purple }, loading && styles.importBtnDisabled]}
onPress={handleSyncNative}
disabled={loading}
>
<Text style={styles.importBtnText}>📅 Kalender Sync</Text>
</TouchableOpacity>
</View>
{error && (
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{count !== null && (
<View style={[styles.successBanner, { backgroundColor: colors.success }]}>
<Text style={styles.successText}>
{count} Termin(e) erfolgreich importiert!
</Text>
</View>
)}
<TouchableOpacity
style={styles.backBtn}
onPress={() => navigation.goBack()}
>
<Text style={[styles.backBtnText, { color: colors.accent }]}> Zurück</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
content: { padding: 20 },
heading: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
description: { fontSize: 14, marginBottom: 20, lineHeight: 20 },
section: { marginBottom: 24 },
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
sectionTitle: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
sectionDesc: { fontSize: 13, marginBottom: 12, lineHeight: 18 },
input: {
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
marginBottom: 12,
},
errorBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
errorText: { color: '#fff', fontSize: 14 },
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
successText: { color: '#fff', fontSize: 14 },
importBtn: {
backgroundColor: '#8B5CF6',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
importBtnDisabled: { opacity: 0.6 },
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
backBtn: {
paddingVertical: 10,
alignItems: 'center',
},
backBtnText: { fontSize: 15 },
// Calendar selection
selectionActions: {
flexDirection: 'row',
alignItems: 'center',
},
linkText: {
fontSize: 13,
fontWeight: '600',
},
dividerText: {
fontSize: 13,
marginHorizontal: 4,
},
group: {
marginBottom: 12,
},
groupLabel: {
fontSize: 12,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
marginTop: 4,
},
calendarRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1.5,
marginBottom: 6,
},
checkBox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: '#8E8E93',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
checkBoxSelected: {
backgroundColor: '#8B5CF6',
borderColor: '#8B5CF6',
},
checkMark: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
},
calendarInfo: {
flex: 1,
},
calendarName: {
fontSize: 15,
fontWeight: '500',
marginBottom: 2,
},
badgeRow: {
flexDirection: 'row',
alignItems: 'center',
},
badge: {
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
marginRight: 6,
},
badgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '700',
},
accountTypeText: {
fontSize: 11,
},
loadingContainer: {
padding: 20,
alignItems: 'center',
},
loadingText: {
marginTop: 8,
fontSize: 13,
},
emptyContainer: {
padding: 20,
alignItems: 'center',
},
emptyText: {
fontSize: 13,
textAlign: 'center',
},
selectionInfo: {
padding: 10,
borderRadius: 8,
marginTop: 4,
},
selectionInfoText: {
fontSize: 13,
textAlign: 'center',
},
});