9d6efdd43b
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.
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
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',
|
||
},
|
||
});
|