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:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useThemeStore } from "../../stores/ThemeStore";
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useFocusEffect, router } from "expo-router";
|
||||
import Header from "../../components/Header";
|
||||
import BaseBackground from "../../components/BaseBackground";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
@@ -38,6 +38,7 @@ type ChatMessageProps = {
|
||||
proposedChanges?: ProposedEventChange[];
|
||||
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
|
||||
onReject?: (proposalId: string) => void;
|
||||
onEdit?: (proposalId: string, proposal: ProposedEventChange) => void;
|
||||
};
|
||||
|
||||
type ChatInputProps = {
|
||||
@@ -62,6 +63,7 @@ const Chat = () => {
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardDidShow = Keyboard.addListener(
|
||||
@@ -71,10 +73,11 @@ const Chat = () => {
|
||||
return () => keyboardDidShow.remove();
|
||||
}, []);
|
||||
|
||||
// Load existing messages from database once authenticated and screen is focused
|
||||
// Load existing messages from database only once (on initial mount)
|
||||
// Skip on subsequent focus events to preserve local edits (e.g., edited proposals)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (isAuthLoading || !isAuthenticated) return;
|
||||
if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return;
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
@@ -91,10 +94,12 @@ const Chat = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error);
|
||||
} finally {
|
||||
setHasLoadedMessages(true);
|
||||
}
|
||||
};
|
||||
fetchMessages();
|
||||
}, [isAuthLoading, isAuthenticated]),
|
||||
}, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
|
||||
);
|
||||
|
||||
const scrollToEnd = () => {
|
||||
@@ -159,6 +164,22 @@ const Chat = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProposal = (
|
||||
messageId: string,
|
||||
conversationId: string,
|
||||
proposalId: string,
|
||||
proposal: ProposedEventChange,
|
||||
) => {
|
||||
router.push({
|
||||
pathname: "/editEvent",
|
||||
params: {
|
||||
mode: "chat",
|
||||
eventData: JSON.stringify(proposal.event),
|
||||
proposalContext: JSON.stringify({ messageId, proposalId, conversationId }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
// Show user message immediately
|
||||
const userMessage: MessageData = {
|
||||
@@ -238,6 +259,14 @@ const Chat = () => {
|
||||
proposalId,
|
||||
)
|
||||
}
|
||||
onEdit={(proposalId, proposal) =>
|
||||
handleEditProposal(
|
||||
item.id,
|
||||
item.conversationId!,
|
||||
proposalId,
|
||||
proposal,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
@@ -334,6 +363,7 @@ const ChatMessage = ({
|
||||
proposedChanges,
|
||||
onConfirm,
|
||||
onReject,
|
||||
onEdit,
|
||||
}: ChatMessageProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
@@ -361,7 +391,7 @@ const ChatMessage = ({
|
||||
{content}
|
||||
</Text>
|
||||
|
||||
{hasProposals && currentProposal && onConfirm && onReject && (
|
||||
{hasProposals && currentProposal && onConfirm && onReject && onEdit && (
|
||||
<View>
|
||||
{/* Event card with optional navigation arrows */}
|
||||
<View className="flex-row items-center">
|
||||
@@ -381,8 +411,9 @@ const ChatMessage = ({
|
||||
<View className="flex-1">
|
||||
<ProposedEventCard
|
||||
proposedChange={currentProposal}
|
||||
onConfirm={() => onConfirm(currentProposal.id, currentProposal)}
|
||||
onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
|
||||
onReject={() => onReject(currentProposal.id)}
|
||||
onEdit={(proposal) => onEdit(proposal.id, proposal)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useThemeStore } from "../../stores/ThemeStore";
|
||||
import { AuthService } from "../../services/AuthService";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Header from "../../components/Header";
|
||||
import { SimpleHeader } from "../../components/Header";
|
||||
import { THEMES } from "../../Themes";
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -18,11 +18,7 @@ const Settings = () => {
|
||||
|
||||
return (
|
||||
<BaseBackground>
|
||||
<Header>
|
||||
<View className="h-full flex justify-center">
|
||||
<Text className="text-center text-3xl font-bold">Settings</Text>
|
||||
</View>
|
||||
</Header>
|
||||
<SimpleHeader text="Settings" />
|
||||
<View className="flex items-center mt-4">
|
||||
<BaseButton onPress={handleLogout} solid={true}>
|
||||
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
|
||||
|
||||
@@ -7,8 +7,9 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
<Stack.Screen name="event/[id]" />
|
||||
<Stack.Screen name="note/[id]" />
|
||||
<Stack.Screen name="editEvent" />
|
||||
{/* <Stack.Screen name="event/[id]" /> */}
|
||||
{/* <Stack.Screen name="note/[id]" /> */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
597
apps/client/src/app/editEvent.tsx
Normal file
597
apps/client/src/app/editEvent.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
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";
|
||||
|
||||
// Direct store access for getting current state in callbacks
|
||||
const getChatStoreState = () => useChatStore.getState();
|
||||
|
||||
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 locally and on server
|
||||
if (mode === "chat" && proposalContext) {
|
||||
try {
|
||||
const context = JSON.parse(proposalContext) as ProposalContext;
|
||||
|
||||
// Update locally in ChatStore
|
||||
const currentMessages = getChatStoreState().messages;
|
||||
const message = currentMessages.find((m) => m.id === context.messageId);
|
||||
|
||||
if (message?.proposedChanges) {
|
||||
const updatedProposals = message.proposedChanges.map((p) =>
|
||||
p.id === context.proposalId ? { ...p, event: eventObject } : p,
|
||||
);
|
||||
updateMessage(context.messageId, {
|
||||
proposedChanges: updatedProposals,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist to server
|
||||
await ChatService.updateProposalEvent(
|
||||
context.messageId,
|
||||
context.proposalId,
|
||||
eventObject,
|
||||
);
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user