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; route: RouteProp; }; /** * 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 = { 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; }) { const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other; return ( onToggle(calendar.id)} accessibilityRole="button" accessibilityLabel={`${calendar.name} – ${calendar.sourceInfo.label}`} > {selected && } {calendar.sourceInfo.emoji} {calendar.name} {calendar.sourceInfo.badge} {calendar.sourceInfo.label} ); } /** * 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(null); const [count, setCount] = useState(null); // Calendar selection state const [availableCalendars, setAvailableCalendars] = useState([]); const [selectedCalendarIds, setSelectedCalendarIds] = useState>(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 ( Kalender-Import Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender. {/* ── ICS URL Import ── */} ICS-URL Import {loading ? ( ) : ( ICS Importieren )} {/* ── Calendar Selection ── */} Kalender auswählen Alle · Keine 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. {!calendarsLoaded ? ( Kalender werden geladen… ) : availableCalendars.length === 0 ? ( Keine Kalender auf diesem Gerät gefunden. ) : ( <> {/* 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 ( {group[0].sourceInfo.emoji} {group[0].sourceInfo.label} ({group.length}) {group.map((cal) => ( ))} ); })} {hasSelection && ( {selectedCalendarIds.size} von {availableCalendars.length} Kalendern ausgewählt )} )} {/* ── Device Calendar Sync ── */} Geräte-Kalender Sync Hole Termine der nächsten 30 Tage aus den Kalendern auf deinem Gerät. 📅 Kalender Sync {error && ( {error} )} {count !== null && ( ✓ {count} Termin(e) erfolgreich importiert! )} navigation.goBack()} > ← Zurück ); } 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', }, });