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

@@ -1,4 +1,4 @@
import { Animated, Modal, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import {
DAYS,
MONTHS,
@@ -10,20 +10,20 @@ import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal";
import { ModalBase } from "../../components/ModalBase";
import { ScrollableDropdown } from "../../components/ScrollableDropdown";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useFocusEffect } from "expo-router";
import { router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
import { EventService } from "../../services";
import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
// MonthSelector types and helpers
type MonthItem = {
@@ -74,6 +74,7 @@ const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [overlayVisible, setOverlayVisible] = useState(false);
// State for delete modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
@@ -114,20 +115,33 @@ const Calendar = () => {
// Load events when tab gains focus or month/year changes
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
// and useFocusEffect expects a sync function (optionally returning cleanup)
// Also re-open overlay if selectedDate exists (for back navigation from editEvent)
useFocusEffect(
useCallback(() => {
loadEvents();
}, [loadEvents]),
if (selectedDate) {
setOverlayVisible(true);
}
}, [loadEvents, selectedDate]),
);
// Group events by date (YYYY-MM-DD format)
// Multi-day events are added to all days they span
const eventsByDate = useMemo(() => {
const map = new Map<string, ExpandedEvent[]>();
events.forEach((e) => {
const date = new Date(e.occurrenceStart);
const key = getDateKey(date);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e);
const start = new Date(e.occurrenceStart);
const end = new Date(e.occurrenceEnd);
// Iterate through each day the event spans
const current = new Date(start);
current.setHours(0, 0, 0, 0);
while (current <= end) {
const key = getDateKey(current);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e);
current.setDate(current.getDate() + 1);
}
});
return map;
}, [events]);
@@ -147,19 +161,32 @@ const Calendar = () => {
});
};
const handleDayPress = (date: Date, hasEvents: boolean) => {
if (hasEvents) {
setSelectedDate(date);
}
const handleDayPress = (date: Date) => {
setSelectedDate(date);
setOverlayVisible(true);
};
const handleCloseOverlay = () => {
setSelectedDate(null);
setOverlayVisible(false);
};
const handleEditEvent = (event: ExpandedEvent) => {
console.log("Edit event:", event.id);
// TODO: Navigate to event edit screen
const handleCreateEvent = () => {
setOverlayVisible(false);
router.push({
pathname: "/editEvent",
params: { date: selectedDate?.toISOString() },
});
};
const handleEditEvent = (event?: ExpandedEvent) => {
router.push({
pathname: "/editEvent",
params: {
mode: "calendar",
id: event?.id,
},
});
};
const handleDeleteEvent = (event: ExpandedEvent) => {
@@ -222,12 +249,13 @@ const Calendar = () => {
onDayPress={handleDayPress}
/>
<EventOverlay
visible={selectedDate !== null && !deleteModalVisible}
visible={overlayVisible && !deleteModalVisible}
date={selectedDate}
events={selectedDateEvents}
onClose={handleCloseOverlay}
onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent}
onCreateEvent={handleCreateEvent}
/>
<DeleteEventModal
visible={deleteModalVisible}
@@ -245,8 +273,9 @@ type EventOverlayProps = {
date: Date | null;
events: ExpandedEvent[];
onClose: () => void;
onEditEvent: (event: ExpandedEvent) => void;
onEditEvent: (event?: ExpandedEvent) => void;
onDeleteEvent: (event: ExpandedEvent) => void;
onCreateEvent: () => void;
};
const EventOverlay = ({
@@ -256,7 +285,10 @@ const EventOverlay = ({
onClose,
onEditEvent,
onDeleteEvent,
onCreateEvent,
}: EventOverlayProps) => {
const { theme } = useThemeStore();
if (!date) return null;
const dateString = date.toLocaleDateString("de-DE", {
@@ -268,12 +300,26 @@ const EventOverlay = ({
const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
const addEventAttachment = (
<Pressable
className="flex flex-row justify-center items-center py-3"
style={{ backgroundColor: theme.confirmButton }}
onPress={onCreateEvent}
>
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
<Text style={{ color: theme.buttonText }} className="font-semibold ml-1">
Neuen Termin erstellen
</Text>
</Pressable>
);
return (
<ModalBase
visible={visible}
onClose={onClose}
title={dateString}
subtitle={subtitle}
attachment={addEventAttachment}
footer={{ label: "Schliessen", onPress: onClose }}
scrollable={true}
maxContentHeight={400}
@@ -299,6 +345,8 @@ type MonthSelectorProps = {
onSelectMonth: (year: number, monthIndex: number) => void;
};
const INITIAL_RANGE = 12; // 12 months before and after current
const MonthSelector = ({
modalVisible,
onClose,
@@ -307,131 +355,98 @@ const MonthSelector = ({
currentMonthIndex,
onSelectMonth,
}: MonthSelectorProps) => {
const { theme } = useThemeStore();
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
const INITIAL_RANGE = 12; // 12 months before and after current
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
const appendMonths = (direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => {
if (prevData.length === 0) return prevData;
const appendMonths = useCallback(
(direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => {
if (prevData.length === 0) return prevData;
const newMonths: MonthItem[] = [];
const referenceMonth =
direction === "start" ? prevData[0] : prevData[prevData.length - 1];
const newMonths: MonthItem[] = [];
const referenceMonth =
direction === "start" ? prevData[0] : prevData[prevData.length - 1];
for (let i = 1; i <= count; i++) {
const offset = direction === "start" ? -i : i;
let year = referenceMonth.year;
let month = referenceMonth.monthIndex + offset;
for (let i = 1; i <= count; i++) {
const offset = direction === "start" ? -i : i;
let year = referenceMonth.year;
let month = referenceMonth.monthIndex + offset;
while (month < 0) {
month += 12;
year--;
}
while (month > 11) {
month -= 12;
year++;
while (month < 0) {
month += 12;
year--;
}
while (month > 11) {
month -= 12;
year++;
}
const newMonth: MonthItem = {
id: `${year}-${String(month + 1).padStart(2, "0")}`,
year,
monthIndex: month,
label: `${MONTHS[month]} ${year}`,
};
if (direction === "start") {
newMonths.unshift(newMonth);
} else {
newMonths.push(newMonth);
}
}
const newMonth: MonthItem = {
id: `${year}-${String(month + 1).padStart(2, "0")}`,
year,
monthIndex: month,
label: `${MONTHS[month]} ${year}`,
};
if (direction === "start") {
newMonths.unshift(newMonth);
} else {
newMonths.push(newMonth);
}
}
return direction === "start"
? [...newMonths, ...prevData]
: [...prevData, ...newMonths];
});
};
return direction === "start"
? [...newMonths, ...prevData]
: [...prevData, ...newMonths];
});
},
[],
);
// Generate fresh data when modal opens, clear when closes
useEffect(() => {
if (modalVisible) {
// Generate fresh data centered on current month
setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
);
Animated.timing(heightAnim, {
toValue: 200,
duration: 200,
useNativeDriver: false,
}).start();
} else {
heightAnim.setValue(0);
// Clear data when closing
setMonthSelectorData([]);
}
}, [modalVisible, heightAnim, currentYear, currentMonthIndex]);
}, [modalVisible, currentYear, currentMonthIndex]);
const renderItem = ({ item }: { item: MonthItem }) => (
<Pressable
onPress={() => {
onSelectMonth(item.year, item.monthIndex);
onClose();
}}
>
<View
className="w-full flex justify-center items-center py-2"
style={{
backgroundColor:
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
}}
>
<Text className="text-xl" style={{ color: theme.primeFg }}>
{item.label}
</Text>
</View>
</Pressable>
const handleSelect = useCallback(
(item: MonthItem) => {
onSelectMonth(item.year, item.monthIndex);
onClose();
},
[onSelectMonth, onClose],
);
return (
<Modal
<ScrollableDropdown
visible={modalVisible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
onClose={onClose}
position={position}
data={monthSelectorData}
keyExtractor={(item) => item.id}
renderItem={(item, theme) => (
<View
className="w-full flex justify-center items-center py-2"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: theme.primeBg,
borderWidth: 2,
borderColor: theme.borderPrimary,
borderRadius: 8,
backgroundColor:
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={(item) => item.id}
data={monthSelectorData}
initialScrollIndex={INITIAL_RANGE}
onEndReachedThreshold={0.5}
onEndReached={() => appendMonths("end", 12)}
onStartReachedThreshold={0.5}
onStartReached={() => appendMonths("start", 12)}
renderItem={renderItem}
/>
</Animated.View>
</Pressable>
</Modal>
<Text className="text-xl" style={{ color: theme.primeFg }}>
{item.label}
</Text>
</View>
)}
onSelect={handleSelect}
height={200}
initialScrollIndex={INITIAL_RANGE}
onEndReached={() => appendMonths("end", 12)}
onStartReached={() => appendMonths("start", 12)}
/>
);
};
@@ -445,29 +460,16 @@ type CalendarHeaderProps = {
const CalendarHeader = (props: CalendarHeaderProps) => {
const { theme } = useThemeStore();
const [modalVisible, setModalVisible] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<View>(null);
const dropdown = useDropdownPosition();
const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => props.changeMonth(1);
const measureAndOpen = () => {
containerRef.current?.measureInWindow((x, y, width, height) => {
setDropdownPosition({ top: y + height, left: x, width });
setModalVisible(true);
});
};
return (
<Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View
ref={containerRef}
ref={dropdown.ref}
className="relative flex flex-row items-center justify-around"
>
<Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
@@ -486,15 +488,15 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
// Android shadow
elevation: 6,
}}
onPress={measureAndOpen}
onPress={dropdown.open}
>
<Ionicons name="chevron-down" size={28} color={theme.primeFg} />
</Pressable>
</View>
<MonthSelector
modalVisible={modalVisible}
onClose={() => setModalVisible(false)}
position={dropdownPosition}
modalVisible={dropdown.visible}
onClose={dropdown.close}
position={dropdown.position}
currentYear={props.currentYear}
currentMonthIndex={props.monthIndex}
onSelectMonth={(year, month) => {
@@ -561,7 +563,7 @@ type CalendarGridProps = {
month: Month;
year: number;
eventsByDate: Map<string, ExpandedEvent[]>;
onDayPress: (date: Date, hasEvents: boolean) => void;
onDayPress: (date: Date) => void;
};
const CalendarGrid = (props: CalendarGridProps) => {
@@ -602,7 +604,7 @@ const CalendarGrid = (props: CalendarGridProps) => {
date={date}
month={props.month}
hasEvents={hasEvents}
onPress={() => props.onDayPress(date, hasEvents)}
onPress={() => props.onDayPress(date)}
/>
);
})}