feat: add CalDAV synchronization with automatic sync

- Add CaldavService with tsdav/ical.js for CalDAV server communication
- Add CaldavController, CaldavRepository, and caldav routes
- Add client-side CaldavConfigService with sync(), config CRUD
- Add CalDAV settings UI with config load/save in settings screen
- Sync on login, auto-login (AuthGuard), periodic timer (calendar), and sync button
- Push single events to CalDAV on server-side create/update/delete
- Push all events to CalDAV after chat event confirmation
- Refactor ChatService to use EventService instead of direct EventRepository
- Rename CalDav/calDav to Caldav/caldav for consistent naming
- Add Radicale Docker setup for local CalDAV testing
- Update PlantUML diagrams and CLAUDE.md with CalDAV architecture
This commit is contained in:
2026-02-08 19:24:59 +01:00
parent 81221d8b70
commit 325246826a
44 changed files with 7074 additions and 126 deletions

View File

@@ -22,6 +22,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground";
import { EventService } from "../../services";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
@@ -84,9 +85,15 @@ const Calendar = () => {
const { events, setEvents, deleteEvent } = useEventsStore();
// Function to load events for current view
// Sync CalDAV then load events for current view
const loadEvents = useCallback(async () => {
try {
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
// Calculate first visible day (up to 6 days before month start)
const firstOfMonth = new Date(currentYear, monthIndex, 1);
const dayOfWeek = firstOfMonth.getDay();
@@ -122,6 +129,9 @@ const Calendar = () => {
if (selectedDate) {
setOverlayVisible(true);
}
const interval = setInterval(loadEvents, 10_000);
return () => clearInterval(interval);
}, [loadEvents, selectedDate]),
);

View File

@@ -1,18 +1,106 @@
import { Text, View } from "react-native";
import BaseBackground from "../../components/BaseBackground";
import BaseButton from "../../components/BaseButton";
import BaseButton, { BaseButtonProps } from "../../components/BaseButton";
import { useThemeStore } from "../../stores/ThemeStore";
import { AuthService } from "../../services/AuthService";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { SimpleHeader } from "../../components/Header";
import { THEMES } from "../../Themes";
import CustomTextInput from "../../components/CustomTextInput";
import { useEffect, useState } from "react";
import { CaldavConfigService } from "../../services/CaldavConfigService";
const handleLogout = async () => {
await AuthService.logout();
router.replace("/login");
};
const SettingsButton = (props: BaseButtonProps) => {
return (
<BaseButton
onPress={props.onPress}
solid={props.solid}
className={"w-11/12"}
>
{props.children}
</BaseButton>
);
};
type CaldavTextInputProps = {
title: string;
value: string;
onValueChange: (text: string) => void;
};
const CaldavTextInput = ({ title, value, onValueChange }: CaldavTextInputProps) => {
return (
<View className="flex flex-row items-center py-1">
<Text className="ml-4 w-24">{title}:</Text>
<CustomTextInput className="flex-1 mr-4" text={value} onValueChange={onValueChange} />
</View>
);
};
const CaldavSettings = () => {
const { theme } = useThemeStore();
const [serverUrl, setServerUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
const loadConfig = async () => {
try {
const config = await CaldavConfigService.getConfig();
setServerUrl(config.serverUrl);
setUsername(config.username);
setPassword(config.password);
} catch {
// No config saved yet
}
};
loadConfig();
}, []);
const saveConfig = async () => {
await CaldavConfigService.saveConfig(serverUrl, username, password);
};
const sync = async () => {
await CaldavConfigService.sync();
};
return (
<>
<View>
<Text
className="text-center text-2xl"
style={{ color: theme.textPrimary }}
>
Caldav Config
</Text>
</View>
<View>
<View className="pb-1">
<CaldavTextInput title="url" value={serverUrl} onValueChange={setServerUrl} />
<CaldavTextInput title="username" value={username} onValueChange={setUsername} />
<CaldavTextInput title="password" value={password} onValueChange={setPassword} />
</View>
<View className="flex flex-row">
<BaseButton className="mx-4 w-1/5" solid={true} onPress={saveConfig}>
Save
</BaseButton>
<BaseButton className="w-1/5" solid={true} onPress={sync}>
Sync
</BaseButton>
</View>
</View>
</>
);
};
const Settings = () => {
const { theme, setTheme } = useThemeStore();
@@ -20,10 +108,10 @@ const Settings = () => {
<BaseBackground>
<SimpleHeader text="Settings" />
<View className="flex items-center mt-4">
<BaseButton onPress={handleLogout} solid={true}>
<SettingsButton onPress={handleLogout} solid={true}>
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
Logout
</BaseButton>
</SettingsButton>
<View>
<Text
className="text-center text-2xl"
@@ -32,23 +120,24 @@ const Settings = () => {
Select Theme
</Text>
</View>
<BaseButton
<SettingsButton
solid={theme == THEMES.defaultLight}
onPress={() => {
setTheme("defaultLight");
}}
>
Default Light
</BaseButton>
<BaseButton
</SettingsButton>
<SettingsButton
solid={theme == THEMES.defaultDark}
onPress={() => {
setTheme("defaultDark");
}}
>
Default Dark
</BaseButton>
</SettingsButton>
</View>
<CaldavSettings />
</BaseBackground>
);
};

View File

@@ -21,38 +21,27 @@ import { useDropdownPosition } from "../hooks/useDropdownPosition";
import { EventService, ChatService } from "../services";
import { buildRRule, CreateEventDTO } from "@calchat/shared";
import { useChatStore } from "../stores";
import CustomTextInput, {
CustomTextInputProps,
} from "../components/CustomTextInput";
type EditEventTextFieldProps = {
type EditEventTextFieldProps = CustomTextInputProps & {
titel: string;
text?: string;
focused?: boolean;
className?: string;
multiline?: boolean;
onValueChange?: (text: string) => void;
};
const EditEventTextField = (props: EditEventTextFieldProps) => {
const { theme } = useThemeStore();
const [focused, setFocused] = useState(props.focused ?? false);
return (
<View className={props.className}>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{props.titel}
</Text>
<TextInput
onChangeText={props.onValueChange}
value={props.text}
<CustomTextInput
className="flex-1"
text={props.text}
multiline={props.multiline}
className="flex-1 border border-solid rounded-2xl px-3 py-2 w-full h-11/12"
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onValueChange={props.onValueChange}
/>
</View>
);

View File

@@ -4,6 +4,7 @@ import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton";
import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => {
@@ -24,6 +25,11 @@ const LoginScreen = () => {
setIsLoading(true);
try {
await AuthService.login({ identifier, password });
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
router.replace("/(tabs)/chat");
} catch {
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");