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:
@@ -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]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, ActivityIndicator } from "react-native";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../stores";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { CaldavConfigService } from "../services/CaldavConfigService";
|
||||
|
||||
type AuthGuardProps = {
|
||||
children: ReactNode;
|
||||
@@ -20,7 +21,16 @@ export const AuthGuard = ({ children }: AuthGuardProps) => {
|
||||
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadStoredUser();
|
||||
const init = async () => {
|
||||
await loadStoredUser();
|
||||
if (!useAuthStore.getState().isAuthenticated) return;
|
||||
try {
|
||||
await CaldavConfigService.sync();
|
||||
} catch {
|
||||
// No CalDAV config or sync failed — not critical
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [loadStoredUser]);
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -2,17 +2,18 @@ import { Pressable, Text } from "react-native";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type BaseButtonProps = {
|
||||
export type BaseButtonProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onPress: () => void;
|
||||
solid?: boolean;
|
||||
};
|
||||
|
||||
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => {
|
||||
const BaseButton = ({ className, children, onPress, solid = false }: BaseButtonProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<Pressable
|
||||
className="w-11/12 rounded-lg p-4 mb-4 border-4"
|
||||
className={`rounded-lg p-4 mb-4 border-4 ${className}`}
|
||||
onPress={onPress}
|
||||
style={{
|
||||
borderColor: theme.borderPrimary,
|
||||
|
||||
35
apps/client/src/components/CustomTextInput.tsx
Normal file
35
apps/client/src/components/CustomTextInput.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TextInput } from "react-native";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { useState } from "react";
|
||||
|
||||
export type CustomTextInputProps = {
|
||||
text?: string;
|
||||
focused?: boolean;
|
||||
className?: string;
|
||||
multiline?: boolean;
|
||||
onValueChange?: (text: string) => void;
|
||||
};
|
||||
|
||||
const CustomTextInput = (props: CustomTextInputProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
const [focused, setFocused] = useState(props.focused ?? false);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
className={`border border-solid rounded-2xl px-3 py-2 h-11/12 ${props.className}`}
|
||||
onChangeText={props.onValueChange}
|
||||
value={props.text}
|
||||
multiline={props.multiline}
|
||||
style={{
|
||||
backgroundColor: theme.messageBorderBg,
|
||||
color: theme.textPrimary,
|
||||
textAlignVertical: "top",
|
||||
borderColor: focused ? theme.chatBot : theme.borderPrimary,
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTextInput;
|
||||
32
apps/client/src/services/CaldavConfigService.ts
Normal file
32
apps/client/src/services/CaldavConfigService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CalendarEvent, CaldavConfig } from "@calchat/shared";
|
||||
import { ApiClient } from "./ApiClient";
|
||||
|
||||
export const CaldavConfigService = {
|
||||
saveConfig: async (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<CaldavConfig> => {
|
||||
return ApiClient.put<CaldavConfig>("/caldav/config", {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
},
|
||||
getConfig: async (): Promise<CaldavConfig> => {
|
||||
return ApiClient.get<CaldavConfig>("/caldav/config");
|
||||
},
|
||||
deleteConfig: async (): Promise<void> => {
|
||||
return ApiClient.delete<void>("/caldav/config");
|
||||
},
|
||||
pull: async (): Promise<CalendarEvent[]> => {
|
||||
return ApiClient.post<CalendarEvent[]>("/caldav/pull");
|
||||
},
|
||||
pushAll: async (): Promise<void> => {
|
||||
return ApiClient.post<void>("/caldav/pushAll");
|
||||
},
|
||||
sync: async (): Promise<void> => {
|
||||
await ApiClient.post<void>("/caldav/pushAll");
|
||||
await ApiClient.post<CalendarEvent[]>("/caldav/pull");
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user