- AI fetches events on-demand via callbacks for better efficiency - Add conflict detection with warning display when proposing overlapping events - Improve event search and display in chat interface - Load full chat history for display while limiting AI context
589 lines
16 KiB
TypeScript
589 lines
16 KiB
TypeScript
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
} from "react-native";
|
|
import { Frequency, rrulestr } from "rrule";
|
|
import BaseBackground from "../components/BaseBackground";
|
|
import { useThemeStore } from "../stores/ThemeStore";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { router, useLocalSearchParams } from "expo-router";
|
|
import Header, { HeaderButton } from "../components/Header";
|
|
import {
|
|
DatePickerButton,
|
|
TimePickerButton,
|
|
} from "../components/DateTimePicker";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { ScrollableDropdown } from "../components/ScrollableDropdown";
|
|
import { useDropdownPosition } from "../hooks/useDropdownPosition";
|
|
import { EventService, ChatService } from "../services";
|
|
import { buildRRule, CreateEventDTO } from "@calchat/shared";
|
|
import { useChatStore } from "../stores";
|
|
|
|
type EditEventTextFieldProps = {
|
|
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}
|
|
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)}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type PickerRowProps = {
|
|
title: string;
|
|
showLabels?: boolean;
|
|
dateValue: Date;
|
|
onDateChange: (date: Date) => void;
|
|
onTimeChange: (date: Date) => void;
|
|
};
|
|
|
|
const PickerRow = ({
|
|
showLabels,
|
|
dateValue,
|
|
title,
|
|
onDateChange,
|
|
onTimeChange,
|
|
}: PickerRowProps) => {
|
|
const { theme } = useThemeStore();
|
|
return (
|
|
<View className="flex flex-row w-11/12 mt-4 items-end justify-between gap-x-2">
|
|
<Text className="text-xl pb-2" style={{ color: theme.textPrimary }}>
|
|
{title}
|
|
</Text>
|
|
<View className="flex flex-row w-10/12 gap-x-2">
|
|
<DatePickerButton
|
|
className="flex-1"
|
|
label={showLabels ? "Datum" : undefined}
|
|
value={dateValue}
|
|
onChange={onDateChange}
|
|
/>
|
|
<TimePickerButton
|
|
className="flex-1"
|
|
label={showLabels ? "Uhrzeit" : undefined}
|
|
value={dateValue}
|
|
onChange={onTimeChange}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
|
|
|
|
const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
|
|
Tag: "Tage",
|
|
Woche: "Wochen",
|
|
Monat: "Monate",
|
|
Jahr: "Jahre",
|
|
};
|
|
|
|
type RepeatPressableProps = {
|
|
focused: boolean;
|
|
repeatType: RepeatType;
|
|
setRepeatType: (repeatType: RepeatType) => void;
|
|
};
|
|
|
|
const RepeatPressable = ({
|
|
focused,
|
|
repeatType,
|
|
setRepeatType,
|
|
}: RepeatPressableProps) => {
|
|
const { theme } = useThemeStore();
|
|
return (
|
|
<Pressable
|
|
className="px-4 py-2 rounded-lg border"
|
|
style={{
|
|
backgroundColor: focused ? theme.chatBot : theme.secondaryBg,
|
|
borderColor: theme.borderPrimary,
|
|
}}
|
|
onPress={() => setRepeatType(repeatType)}
|
|
>
|
|
<Text style={{ color: focused ? theme.buttonText : theme.textPrimary }}>
|
|
{repeatType}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
type RepeatSelectorProps = {
|
|
repeatCount: number;
|
|
onRepeatCountChange: (count: number) => void;
|
|
repeatType: RepeatType;
|
|
onRepeatTypeChange: (type: RepeatType) => void;
|
|
};
|
|
|
|
// Static data for repeat count dropdown (1-120)
|
|
const REPEAT_COUNT_DATA = Array.from({ length: 120 }, (_, i) => i + 1);
|
|
|
|
const RepeatSelector = ({
|
|
repeatCount,
|
|
onRepeatCountChange,
|
|
repeatType,
|
|
onRepeatTypeChange,
|
|
}: RepeatSelectorProps) => {
|
|
const { theme } = useThemeStore();
|
|
const dropdown = useDropdownPosition(2);
|
|
|
|
const handleSelectCount = useCallback(
|
|
(count: number) => {
|
|
onRepeatCountChange(count);
|
|
dropdown.close();
|
|
},
|
|
[onRepeatCountChange, dropdown],
|
|
);
|
|
|
|
const typeLabel = REPEAT_TYPE_LABELS[repeatType];
|
|
|
|
return (
|
|
<View className="mt-4">
|
|
{/* Repeat Type Selection */}
|
|
<View className="flex flex-row gap-2 mb-3">
|
|
<RepeatPressable
|
|
repeatType="Tag"
|
|
setRepeatType={onRepeatTypeChange}
|
|
focused={repeatType === "Tag"}
|
|
/>
|
|
<RepeatPressable
|
|
repeatType="Woche"
|
|
setRepeatType={onRepeatTypeChange}
|
|
focused={repeatType === "Woche"}
|
|
/>
|
|
<RepeatPressable
|
|
repeatType="Monat"
|
|
setRepeatType={onRepeatTypeChange}
|
|
focused={repeatType === "Monat"}
|
|
/>
|
|
<RepeatPressable
|
|
repeatType="Jahr"
|
|
setRepeatType={onRepeatTypeChange}
|
|
focused={repeatType === "Jahr"}
|
|
/>
|
|
</View>
|
|
|
|
{/* Repeat Count Selection */}
|
|
<View className="flex flex-row items-center">
|
|
<Text className="text-lg" style={{ color: theme.textPrimary }}>
|
|
Alle{" "}
|
|
</Text>
|
|
<Pressable
|
|
ref={dropdown.ref}
|
|
className="px-4 py-2 rounded-lg border"
|
|
style={{
|
|
backgroundColor: theme.secondaryBg,
|
|
borderColor: theme.borderPrimary,
|
|
}}
|
|
onPress={dropdown.open}
|
|
>
|
|
<Text className="text-lg" style={{ color: theme.textPrimary }}>
|
|
{repeatCount}
|
|
</Text>
|
|
</Pressable>
|
|
<Text className="text-lg" style={{ color: theme.textPrimary }}>
|
|
{" "}
|
|
{typeLabel}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Count Dropdown */}
|
|
<ScrollableDropdown
|
|
visible={dropdown.visible}
|
|
onClose={dropdown.close}
|
|
position={{
|
|
bottom: 12,
|
|
left: 10,
|
|
width: 100,
|
|
}}
|
|
data={REPEAT_COUNT_DATA}
|
|
keyExtractor={(n) => String(n)}
|
|
renderItem={(n, theme) => (
|
|
<View
|
|
className="w-full flex justify-center items-center py-2"
|
|
style={{
|
|
backgroundColor: n % 2 === 0 ? theme.primeBg : theme.secondaryBg,
|
|
}}
|
|
>
|
|
<Text className="text-xl" style={{ color: theme.textPrimary }}>
|
|
{n}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
onSelect={handleSelectCount}
|
|
heightRatio={0.4}
|
|
initialScrollIndex={repeatCount - 1}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type EditEventHeaderProps = {
|
|
id?: string;
|
|
mode?: "calendar" | "chat";
|
|
};
|
|
|
|
const EditEventHeader = ({ id, mode }: EditEventHeaderProps) => {
|
|
const getTitle = () => {
|
|
if (mode === "chat") return "Edit Proposal";
|
|
return id ? "Edit Meeting" : "New Meeting";
|
|
};
|
|
|
|
return (
|
|
<Header className="flex flex-row justify-center items-center">
|
|
<HeaderButton
|
|
className="absolute left-6"
|
|
iconName="arrow-back-outline"
|
|
iconSize={36}
|
|
onPress={router.back}
|
|
/>
|
|
<View className="h-full flex justify-center ml-4">
|
|
<Text className="text-center text-3xl font-bold">{getTitle()}</Text>
|
|
</View>
|
|
</Header>
|
|
);
|
|
};
|
|
|
|
type EditEventParams = {
|
|
id?: string;
|
|
date?: string;
|
|
mode?: "calendar" | "chat";
|
|
eventData?: string;
|
|
proposalContext?: string;
|
|
};
|
|
|
|
type ProposalContext = {
|
|
messageId: string;
|
|
proposalId: string;
|
|
conversationId: string;
|
|
};
|
|
|
|
const EditEventScreen = () => {
|
|
const { id, date, mode, eventData, proposalContext } =
|
|
useLocalSearchParams<EditEventParams>();
|
|
const { theme } = useThemeStore();
|
|
const updateMessage = useChatStore((state) => state.updateMessage);
|
|
|
|
// Only show loading if we need to fetch from API (calendar mode with id)
|
|
const [isLoading, setIsLoading] = useState(
|
|
mode !== "chat" && !!id && !eventData,
|
|
);
|
|
|
|
// Initialize dates from URL parameter or use current time
|
|
const initialDate = date ? new Date(date) : new Date();
|
|
const initialEndDate = new Date(initialDate.getTime() + 60 * 60 * 1000);
|
|
|
|
const [repeatVisible, setRepeatVisible] = useState(false);
|
|
const [repeatCount, setRepeatCount] = useState(1);
|
|
const [repeatType, setRepeatType] = useState<RepeatType>("Tag");
|
|
const [startDate, setStartDate] = useState(initialDate);
|
|
const [endDate, setEndDate] = useState(initialEndDate);
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
|
|
// Helper to populate form from event data
|
|
const populateFormFromEvent = useCallback((event: CreateEventDTO) => {
|
|
setStartDate(new Date(event.startTime));
|
|
setEndDate(new Date(event.endTime));
|
|
setTitle(event.title);
|
|
if (event.description) {
|
|
setDescription(event.description);
|
|
}
|
|
|
|
if (event.recurrenceRule) {
|
|
setRepeatVisible(true);
|
|
|
|
const rrule = rrulestr(event.recurrenceRule);
|
|
if (rrule.options.interval) {
|
|
setRepeatCount(rrule.options.interval);
|
|
}
|
|
switch (rrule.options.freq) {
|
|
case Frequency.DAILY:
|
|
setRepeatType("Tag");
|
|
break;
|
|
case Frequency.WEEKLY:
|
|
setRepeatType("Woche");
|
|
break;
|
|
case Frequency.MONTHLY:
|
|
setRepeatType("Monat");
|
|
break;
|
|
case Frequency.YEARLY:
|
|
setRepeatType("Jahr");
|
|
break;
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Load event data based on mode
|
|
useEffect(() => {
|
|
// Chat mode: load from eventData JSON parameter
|
|
if (mode === "chat" && eventData) {
|
|
try {
|
|
const event = JSON.parse(eventData) as CreateEventDTO;
|
|
populateFormFromEvent(event);
|
|
} catch (error) {
|
|
console.error("Failed to parse eventData:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Calendar mode with id: fetch from API
|
|
if (id && !eventData) {
|
|
const fetchEvent = async () => {
|
|
try {
|
|
const event = await EventService.getById(id);
|
|
populateFormFromEvent({
|
|
title: event.title,
|
|
description: event.description,
|
|
startTime: event.startTime,
|
|
endTime: event.endTime,
|
|
recurrenceRule: event.recurrenceRule,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to load event: ", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchEvent();
|
|
}
|
|
}, [id, mode, eventData, populateFormFromEvent]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<BaseBackground>
|
|
<EditEventHeader id={id} mode={mode} />
|
|
<View className="flex-1 justify-center items-center">
|
|
<ActivityIndicator size="large" color={theme.chatBot} />
|
|
</View>
|
|
</BaseBackground>
|
|
);
|
|
}
|
|
|
|
const handleStartDateChange = (date: Date) => {
|
|
// Keep the time from startDate, update the date part
|
|
const newStart = new Date(startDate);
|
|
newStart.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
|
|
setStartDate(newStart);
|
|
|
|
// If end date is before new start date, adjust it
|
|
if (endDate < newStart) {
|
|
const newEnd = new Date(newStart);
|
|
newEnd.setHours(newStart.getHours() + 1);
|
|
setEndDate(newEnd);
|
|
}
|
|
};
|
|
|
|
const handleStartTimeChange = (date: Date) => {
|
|
// Keep the date from startDate, update the time part
|
|
const newStart = new Date(startDate);
|
|
newStart.setHours(date.getHours(), date.getMinutes(), 0, 0);
|
|
setStartDate(newStart);
|
|
|
|
// If end time is before new start time on the same day, adjust it
|
|
if (endDate <= newStart) {
|
|
const newEnd = new Date(newStart);
|
|
newEnd.setHours(newStart.getHours() + 1);
|
|
setEndDate(newEnd);
|
|
}
|
|
};
|
|
|
|
const handleEndDateChange = (date: Date) => {
|
|
// Keep the time from endDate, update the date part
|
|
const newEnd = new Date(endDate);
|
|
newEnd.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
|
|
setEndDate(newEnd);
|
|
};
|
|
|
|
const handleEndTimeChange = (date: Date) => {
|
|
// Keep the date from endDate, update the time part
|
|
const newEnd = new Date(endDate);
|
|
newEnd.setHours(date.getHours(), date.getMinutes(), 0, 0);
|
|
setEndDate(newEnd);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const eventObject: CreateEventDTO = {
|
|
title,
|
|
description: description === "" ? undefined : description,
|
|
startTime: startDate,
|
|
endTime: endDate,
|
|
recurrenceRule: repeatVisible
|
|
? buildRRule(repeatType, repeatCount)
|
|
: undefined,
|
|
};
|
|
|
|
// Chat mode: update proposal on server and sync response to local store
|
|
if (mode === "chat" && proposalContext) {
|
|
try {
|
|
const context = JSON.parse(proposalContext) as ProposalContext;
|
|
|
|
// Persist to server - returns updated message with recalculated conflictingEvents
|
|
const updatedMessage = await ChatService.updateProposalEvent(
|
|
context.messageId,
|
|
context.proposalId,
|
|
eventObject,
|
|
);
|
|
|
|
// Update local ChatStore with server response (includes updated conflicts)
|
|
if (updatedMessage?.proposedChanges) {
|
|
updateMessage(context.messageId, {
|
|
proposedChanges: updatedMessage.proposedChanges,
|
|
});
|
|
}
|
|
|
|
router.back();
|
|
} catch (error) {
|
|
console.error("Failed to update proposal:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Calendar mode: call API
|
|
try {
|
|
if (id) {
|
|
await EventService.update(id, eventObject);
|
|
} else {
|
|
await EventService.create(eventObject);
|
|
}
|
|
router.back();
|
|
} catch (error) {
|
|
console.error("Creating/Updating event failed!", error);
|
|
}
|
|
};
|
|
|
|
const getButtonText = () => {
|
|
if (mode === "chat") {
|
|
return "Fertig";
|
|
}
|
|
return id ? "Aktualisiere Termin" : "Erstelle neuen Termin";
|
|
};
|
|
|
|
return (
|
|
<BaseBackground>
|
|
<EditEventHeader id={id} mode={mode} />
|
|
<View className="h-full flex items-center">
|
|
{/* Date and Time */}
|
|
<View className="w-11/12">
|
|
<EditEventTextField
|
|
className="h-16 mt-2"
|
|
titel="Titel"
|
|
text={title}
|
|
onValueChange={setTitle}
|
|
/>
|
|
<PickerRow
|
|
title="Von"
|
|
dateValue={startDate}
|
|
onDateChange={handleStartDateChange}
|
|
onTimeChange={handleStartTimeChange}
|
|
showLabels
|
|
/>
|
|
<PickerRow
|
|
title="Bis"
|
|
dateValue={endDate}
|
|
onDateChange={handleEndDateChange}
|
|
onTimeChange={handleEndTimeChange}
|
|
/>
|
|
|
|
{/* TODO: Reminder */}
|
|
|
|
{/* Notes */}
|
|
<EditEventTextField
|
|
className="h-64 mt-6"
|
|
titel="Notizen"
|
|
text={description}
|
|
onValueChange={setDescription}
|
|
multiline
|
|
/>
|
|
|
|
{/* Repeat Toggle Button */}
|
|
<Pressable
|
|
className="flex flex-row w-1/3 h-10 mt-4 rounded-lg items-center justify-evenly"
|
|
style={{
|
|
backgroundColor: repeatVisible
|
|
? theme.chatBot
|
|
: theme.secondaryBg,
|
|
borderWidth: 1,
|
|
borderColor: theme.borderPrimary,
|
|
}}
|
|
onPress={() => setRepeatVisible(!repeatVisible)}
|
|
>
|
|
<Ionicons
|
|
name="repeat"
|
|
size={24}
|
|
color={repeatVisible ? theme.buttonText : theme.textPrimary}
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: repeatVisible ? theme.buttonText : theme.textPrimary,
|
|
}}
|
|
>
|
|
Wiederholen
|
|
</Text>
|
|
</Pressable>
|
|
|
|
{/* Repeat Selector (shown when toggle is active) */}
|
|
{repeatVisible && (
|
|
<RepeatSelector
|
|
repeatCount={repeatCount}
|
|
onRepeatCountChange={setRepeatCount}
|
|
repeatType={repeatType}
|
|
onRepeatTypeChange={setRepeatType}
|
|
/>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Send new or updated Event */}
|
|
<View className="absolute bottom-16 w-full h-16">
|
|
<Pressable
|
|
className="flex flex-row justify-center items-center py-3"
|
|
onPress={handleSave}
|
|
style={{
|
|
backgroundColor: theme.confirmButton,
|
|
}}
|
|
>
|
|
{mode !== "chat" && (
|
|
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
|
|
)}
|
|
<Text
|
|
style={{ color: theme.buttonText }}
|
|
className="font-semibold ml-1"
|
|
>
|
|
{getButtonText()}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</BaseBackground>
|
|
);
|
|
};
|
|
|
|
export default EditEventScreen;
|