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:
@@ -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
|
||||
|
||||
136
apps/client/src/components/DateTimePicker.tsx
Normal file
136
apps/client/src/components/DateTimePicker.tsx
Normal 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;
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
apps/client/src/components/ScrollableDropdown.tsx
Normal file
107
apps/client/src/components/ScrollableDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user