feat: improve chat keyboard handling and MonthSelector memory efficiency

- Add KeyboardAvoidingView with platform-specific behavior to chat screen
- Implement auto-scroll to end on new messages and keyboard show
- Configure keyboardDismissMode and keyboardShouldPersistTaps for better UX
- Lazy-load MonthSelector data only when modal opens, clear on close
- Add .env to gitignore
This commit is contained in:
2026-01-07 18:40:00 +01:00
parent 613bafa5f5
commit d86b18173f
4 changed files with 62 additions and 38 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules
*.tsbuildinfo *.tsbuildinfo
docs/praesi_2_context.md docs/praesi_2_context.md
docs/*.png docs/*.png
.env

View File

@@ -296,7 +296,7 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- Tab navigation (Chat, Calendar) implemented with basic UI - Tab navigation (Chat, Calendar) implemented with basic UI
- Calendar screen fully functional: - Calendar screen fully functional:
- Month navigation with grid display and Ionicons (chevron-back/forward) - Month navigation with grid display and Ionicons (chevron-back/forward)
- MonthSelector dropdown with infinite scroll (dynamically loads months) - MonthSelector dropdown with infinite scroll (dynamically loads months, lazy-loaded when modal opens, cleared on close for memory efficiency)
- Events loaded from API via EventService.getByDateRange() - Events loaded from API via EventService.getByDateRange()
- Orange dot indicator for days with events - Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day - Tap-to-open modal overlay showing EventCards for selected day
@@ -304,6 +304,9 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- Uses `useFocusEffect` for automatic reload on tab focus - Uses `useFocusEffect` for automatic reload on tab focus
- Chat screen functional with FlashList, message sending, and event confirm/reject - Chat screen functional with FlashList, message sending, and event confirm/reject
- Messages persisted via ChatStore (survives tab switches) - Messages persisted via ChatStore (survives tab switches)
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `ApiClient`: get(), post(), put(), delete() implemented - `ApiClient`: get(), post(), put(), delete() implemented
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions - `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions

View File

@@ -323,16 +323,7 @@ const MonthSelector = ({
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null); const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
const INITIAL_RANGE = 12; // 12 months before and after current const INITIAL_RANGE = 12; // 12 months before and after current
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>(() => const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
);
// Reset data when current month changes
useEffect(() => {
setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
);
}, [currentYear, currentMonthIndex]);
const appendMonths = (direction: "start" | "end", count: number) => { const appendMonths = (direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => { setMonthSelectorData((prevData) => {
@@ -378,6 +369,10 @@ const MonthSelector = ({
useEffect(() => { useEffect(() => {
if (modalVisible) { if (modalVisible) {
// Generate fresh data centered on current month
setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
);
Animated.timing(heightAnim, { Animated.timing(heightAnim, {
toValue: 200, toValue: 200,
duration: 200, duration: 200,
@@ -385,8 +380,10 @@ const MonthSelector = ({
}).start(); }).start();
} else { } else {
heightAnim.setValue(0); heightAnim.setValue(0);
// Clear data when closing
setMonthSelectorData([]);
} }
}, [modalVisible, heightAnim]); }, [modalVisible, heightAnim, currentYear, currentMonthIndex]);
const renderItem = ({ item }: { item: MonthItem }) => ( const renderItem = ({ item }: { item: MonthItem }) => (
<Pressable <Pressable

View File

@@ -1,6 +1,6 @@
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, Keyboard } from "react-native";
import currentTheme from "../../Themes"; import currentTheme from "../../Themes";
import { useState } from "react"; import { useState, useRef, useEffect } from "react";
import Header from "../../components/Header"; import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
@@ -31,6 +31,18 @@ type ChatInputProps = {
const Chat = () => { const Chat = () => {
const { messages, addMessage, updateMessage } = useChatStore(); const { messages, addMessage, updateMessage } = useChatStore();
const listRef = useRef<FlashList<MessageData>>(null);
useEffect(() => {
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", scrollToEnd);
return () => keyboardDidShow.remove();
}, []);
const scrollToEnd = () => {
setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true });
}, 100);
};
const handleEventResponse = async ( const handleEventResponse = async (
action: "confirm" | "reject", action: "confirm" | "reject",
@@ -61,6 +73,7 @@ const Chat = () => {
conversationId: response.conversationId, conversationId: response.conversationId,
}; };
addMessage(botMessage); addMessage(botMessage);
scrollToEnd();
} catch (error) { } catch (error) {
console.error(`Failed to ${action} event:`, error); console.error(`Failed to ${action} event:`, error);
// Revert on error // Revert on error
@@ -76,6 +89,7 @@ const Chat = () => {
content: text, content: text,
}; };
addMessage(userMessage); addMessage(userMessage);
scrollToEnd();
try { try {
// Fetch server response // Fetch server response
@@ -90,6 +104,7 @@ const Chat = () => {
conversationId: response.conversationId, conversationId: response.conversationId,
}; };
addMessage(botMessage); addMessage(botMessage);
scrollToEnd();
} catch (error) { } catch (error) {
console.error("Failed to send message:", error); console.error("Failed to send message:", error);
} }
@@ -98,30 +113,38 @@ const Chat = () => {
return ( return (
<BaseBackground> <BaseBackground>
<ChatHeader /> <ChatHeader />
<FlashList <KeyboardAvoidingView
data={messages} behavior={Platform.OS === "ios" ? "padding" : "height"}
renderItem={({ item }) => ( style={{ flex: 1 }}
<ChatMessage >
side={item.side} <FlashList
content={item.content} ref={listRef}
proposedChange={item.proposedChange} data={messages}
respondedAction={item.respondedAction} renderItem={({ item }) => (
onConfirm={() => <ChatMessage
handleEventResponse( side={item.side}
"confirm", content={item.content}
item.id, proposedChange={item.proposedChange}
item.conversationId!, respondedAction={item.respondedAction}
item.proposedChange, onConfirm={() =>
) handleEventResponse(
} "confirm",
onReject={() => item.id,
handleEventResponse("reject", item.id, item.conversationId!) item.conversationId!,
} item.proposedChange,
/> )
)} }
keyExtractor={(item) => item.id} onReject={() =>
/> handleEventResponse("reject", item.id, item.conversationId!)
<ChatInput onSend={handleSend} /> }
/>
)}
keyExtractor={(item) => item.id}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
/>
<ChatInput onSend={handleSend} />
</KeyboardAvoidingView>
</BaseBackground> </BaseBackground>
); );
}; };