feat: add EditEventScreen with calendar and chat mode support

Add a unified event editor that works in two modes:
- Calendar mode: Create/edit events directly via EventService API
- Chat mode: Edit AI-proposed events before confirming them

The chat mode allows users to modify proposed events (title, time,
recurrence) and persists changes both locally and to the server.

New components: DateTimePicker, ScrollableDropdown, useDropdownPosition
New API: PUT /api/chat/messages/:messageId/proposal
This commit is contained in:
2026-01-31 18:46:31 +01:00
parent 617543a603
commit 6f0d172bf2
33 changed files with 1394 additions and 289 deletions

View File

@@ -8,6 +8,7 @@ type CardBaseProps = {
title: string;
subtitle?: string;
children: ReactNode;
attachment?: ReactNode; // renders between children and footer
footer?: {
label: string;
onPress: () => void;
@@ -27,6 +28,7 @@ export const CardBase = ({
title,
subtitle,
children,
attachment,
footer,
className = "",
scrollable = false,
@@ -94,6 +96,8 @@ export const CardBase = ({
contentElement
)}
{attachment}
{/* Footer (optional) */}
{footer && (
<Pressable

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import { Platform, Modal, Pressable, Text, View } from "react-native";
import DateTimePicker, {
DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { useThemeStore } from "../stores/ThemeStore";
import { THEMES } from "../Themes";
type DateTimePickerButtonProps = {
mode: "date" | "time";
className?: string;
label?: string;
value: Date;
onChange: (date: Date) => void;
};
const DateTimePickerButton = ({
mode,
label,
value,
onChange,
className,
}: DateTimePickerButtonProps) => {
const { theme } = useThemeStore();
const [showPicker, setShowPicker] = useState(false);
const isDark = theme === THEMES.defaultDark;
const handleChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
if (Platform.OS === "android") {
setShowPicker(false);
}
if (event.type === "set" && selectedDate) {
onChange(selectedDate);
}
};
const formattedValue =
mode === "date"
? value.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: value.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
return (
<View className={className}>
{label && (
<Text style={{ color: theme.textSecondary }} className="text-sm mb-1">
{label}
</Text>
)}
<Pressable
onPress={() => setShowPicker(true)}
className="w-full rounded-lg px-3 py-2 border"
style={{
backgroundColor: theme.messageBorderBg,
borderColor: theme.borderPrimary,
}}
>
<Text style={{ color: theme.textPrimary }} className="text-base">
{formattedValue}
</Text>
</Pressable>
{Platform.OS === "ios" ? (
<Modal
visible={showPicker}
transparent
animationType="fade"
onRequestClose={() => setShowPicker(false)}
>
<Pressable
className="flex-1 justify-end"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={() => setShowPicker(false)}
>
<View
style={{ backgroundColor: theme.secondaryBg }}
className="rounded-t-2xl"
>
<View className="flex-row justify-end p-2">
<Pressable onPress={() => setShowPicker(false)} className="p-2">
<Text
style={{ color: theme.chatBot }}
className="text-lg font-semibold"
>
Fertig
</Text>
</Pressable>
</View>
<DateTimePicker
value={value}
mode={mode}
display="spinner"
onChange={handleChange}
locale="de-DE"
is24Hour={mode === "time"}
accentColor={theme.chatBot}
textColor={theme.textPrimary}
themeVariant={isDark ? "dark" : "light"}
/>
</View>
</Pressable>
</Modal>
) : (
showPicker && (
<DateTimePicker
value={value}
mode={mode}
display="default"
onChange={handleChange}
is24Hour={mode === "time"}
accentColor={theme.chatBot}
textColor={theme.textPrimary}
themeVariant={isDark ? "dark" : "light"}
/>
)
)}
</View>
);
};
// Convenience wrappers for simpler usage
export const DatePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode">
) => <DateTimePickerButton {...props} mode="date" />;
export const TimePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode">
) => <DateTimePickerButton {...props} mode="time" />;
export default DateTimePickerButton;

View File

@@ -3,6 +3,12 @@ import { Feather } from "@expo/vector-icons";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase";
import {
isMultiDayEvent,
formatDateWithWeekday,
formatDateWithWeekdayShort,
formatTime,
} from "@calchat/shared";
type EventCardBaseProps = {
className?: string;
@@ -14,24 +20,6 @@ type EventCardBaseProps = {
children?: ReactNode;
};
function formatDate(date: Date): string {
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatTime(date: Date): string {
const d = new Date(date);
return d.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
}
function formatDuration(start: Date, end: Date): string {
const startDate = new Date(start);
const endDate = new Date(end);
@@ -62,6 +50,7 @@ export const EventCardBase = ({
children,
}: EventCardBaseProps) => {
const { theme } = useThemeStore();
const multiDay = isMultiDayEvent(startTime, endTime);
return (
<CardBase title={title} className={className} borderWidth={2}>
@@ -73,9 +62,16 @@ export const EventCardBase = ({
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatDate(startTime)}
</Text>
{multiDay ? (
<Text style={{ color: theme.textPrimary }}>
{formatDateWithWeekdayShort(startTime)} {" "}
{formatDateWithWeekday(endTime)}
</Text>
) : (
<Text style={{ color: theme.textPrimary }}>
{formatDateWithWeekday(startTime)}
</Text>
)}
</View>
{/* Time with duration */}
@@ -86,10 +82,16 @@ export const EventCardBase = ({
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)})
</Text>
{multiDay ? (
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} {formatTime(endTime)}
</Text>
) : (
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)})
</Text>
)}
</View>
{/* Recurring indicator */}

View File

@@ -1,6 +1,7 @@
import { View } from "react-native";
import { View, Text, Pressable } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react";
import { ComponentProps, ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
type HeaderProps = {
children?: ReactNode;
@@ -37,4 +38,54 @@ const Header = (props: HeaderProps) => {
);
};
type HeaderButton = {
className?: string;
iconName: ComponentProps<typeof Ionicons>["name"];
iconSize: number;
onPress?: () => void;
};
export const HeaderButton = (props: HeaderButton) => {
const { theme } = useThemeStore();
return (
<Pressable
onPress={props.onPress}
className={
"w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid absolute left-6 " +
props.className
}
style={{
backgroundColor: theme.chatBot,
borderColor: theme.primeFg,
// iOS shadow
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
// Android shadow
elevation: 6,
}}
>
<Ionicons
name={props.iconName}
size={props.iconSize}
color={theme.buttonText}
/>
</Pressable>
);
};
type SimpleHeaderProps = {
text: string;
};
export const SimpleHeader = ({ text }: SimpleHeaderProps) => (
<Header>
<View className="h-full flex justify-center">
<Text className="text-center text-3xl font-bold">{text}</Text>
</View>
</Header>
);
export default Header;

View File

@@ -9,6 +9,7 @@ type ModalBaseProps = {
title: string;
subtitle?: string;
children: ReactNode;
attachment?: ReactNode;
footer?: {
label: string;
onPress: () => void;
@@ -23,6 +24,7 @@ export const ModalBase = ({
title,
subtitle,
children,
attachment,
footer,
scrollable,
maxContentHeight,
@@ -55,6 +57,7 @@ export const ModalBase = ({
<CardBase
title={title}
subtitle={subtitle}
attachment={attachment}
footer={footer}
scrollable={scrollable}
maxContentHeight={maxContentHeight}

View File

@@ -1,25 +1,31 @@
import { View, Text, Pressable } from "react-native";
import { Feather } from "@expo/vector-icons";
import { ProposedEventChange, parseRRule, formatDate } from "@calchat/shared";
import { ProposedEventChange, formatDate } from "@calchat/shared";
import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = {
proposedChange: ProposedEventChange;
onConfirm: () => void;
onConfirm: (proposal: ProposedEventChange) => void;
onReject: () => void;
onEdit?: (proposal: ProposedEventChange) => void;
};
const ConfirmRejectButtons = ({
const ActionButtons = ({
isDisabled,
respondedAction,
showEdit,
onConfirm,
onReject,
onEdit,
}: {
isDisabled: boolean;
respondedAction?: "confirm" | "reject";
showEdit: boolean;
onConfirm: () => void;
onReject: () => void;
onEdit?: () => void;
}) => {
const { theme } = useThemeStore();
return (
@@ -56,6 +62,19 @@ const ConfirmRejectButtons = ({
Ablehnen
</Text>
</Pressable>
{showEdit && onEdit && (
<Pressable
onPress={onEdit}
className="py-2 px-3 rounded-lg items-center"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Feather name="edit-2" size={18} color={theme.textPrimary} />
</Pressable>
)}
</View>
);
};
@@ -64,6 +83,7 @@ export const ProposedEventCard = ({
proposedChange,
onConfirm,
onReject,
onEdit,
}: ProposedEventCardProps) => {
const { theme } = useThemeStore();
const event = proposedChange.event;
@@ -79,7 +99,7 @@ export const ProposedEventCard = ({
const newUntilDate =
proposedChange.action === "update" &&
event?.recurrenceRule &&
parseRRule(event.recurrenceRule)?.until;
rrulestr(event.recurrenceRule).options.until;
if (!event) {
return null;
@@ -93,7 +113,7 @@ export const ProposedEventCard = ({
startTime={event.startTime}
endTime={event.endTime}
description={event.description}
isRecurring={event.isRecurring}
isRecurring={!!event.recurrenceRule}
>
{/* Show new exception date for delete/single actions */}
{newExceptionDate && (
@@ -123,11 +143,13 @@ export const ProposedEventCard = ({
</Text>
</View>
)}
<ConfirmRejectButtons
<ActionButtons
isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm}
showEdit={proposedChange.action !== "delete" && !isDisabled}
onConfirm={() => onConfirm(proposedChange)}
onReject={onReject}
onEdit={onEdit ? () => onEdit(proposedChange) : undefined}
/>
</EventCardBase>
</View>

View File

@@ -0,0 +1,107 @@
import { useRef, useEffect } from "react";
import { Modal, Pressable, Animated, useWindowDimensions } from "react-native";
import { FlashList } from "@shopify/flash-list";
import { useThemeStore } from "../stores/ThemeStore";
import { Theme } from "../Themes";
export type ScrollableDropdownProps<T> = {
visible: boolean;
onClose: () => void;
position: {
top?: number;
bottom?: number;
left: number;
width: number;
};
data: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T, theme: Theme) => React.ReactNode;
onSelect: (item: T) => void;
height?: number;
heightRatio?: number; // Alternative: fraction of screen height (0-1)
initialScrollIndex?: number;
// Infinite scroll (optional)
onEndReached?: () => void;
onStartReached?: () => void;
};
export const ScrollableDropdown = <T,>({
visible,
onClose,
position,
data,
keyExtractor,
renderItem,
onSelect,
height = 200,
heightRatio,
initialScrollIndex = 0,
onEndReached,
onStartReached,
}: ScrollableDropdownProps<T>) => {
const { theme } = useThemeStore();
const { height: screenHeight } = useWindowDimensions();
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
// Calculate actual height: use heightRatio if provided, otherwise fall back to height
const actualHeight = heightRatio ? screenHeight * heightRatio : height;
// Calculate top position: use top if provided, otherwise calculate from bottom
const topValue =
position.top ?? screenHeight - actualHeight - (position.bottom ?? 0);
useEffect(() => {
if (visible) {
Animated.timing(heightAnim, {
toValue: actualHeight,
duration: 200,
useNativeDriver: false,
}).start();
} else {
heightAnim.setValue(0);
}
}, [visible, heightAnim, actualHeight]);
return (
<Modal
visible={visible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
style={{
top: topValue,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: theme.primeBg,
borderWidth: 2,
borderColor: theme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={keyExtractor}
data={data}
initialScrollIndex={initialScrollIndex}
onEndReachedThreshold={0.5}
onEndReached={onEndReached}
onStartReachedThreshold={0.5}
onStartReached={onStartReached}
renderItem={({ item }) => (
<Pressable onPress={() => onSelect(item)}>
{renderItem(item, theme)}
</Pressable>
)}
/>
</Animated.View>
</Pressable>
</Modal>
);
};