Compare commits

...

3 Commits

Author SHA1 Message Date
cbf123ddd6 feat: add visual feedback for CalDAV save & sync actions
- Show spinner + loading text while request is in progress
- Display success (green) or error (red) message, auto-clears after 3s
- Save and Sync have independent feedback rows (both visible at once)
- Fix CaldavTextInput theming and add secureTextEntry for password
- Reset CustomTextInput cursor to start when unfocused
2026-02-09 19:53:51 +01:00
3ad4a77951 fix: chat starts scrolled to bottom instead of visibly scrolling down
- Use onContentSizeChange to scroll after FlashList renders content
- Scroll without animation on initial load via needsInitialScroll ref
- Remove unreliable 100ms timeout scrollToEnd from message loading
2026-02-09 19:23:45 +01:00
aabce1a5b0 refactor: use CustomTextInput in login and register screens
- Replace raw TextInput with CustomTextInput in login and register
  for consistent focus border effect across the app
- Add placeholder, secureTextEntry, autoCapitalize, keyboardType
  props to CustomTextInput
- Remove hardcoded default padding (px-3 py-2) and h-11/12 from
  CustomTextInput, callers now set padding via className
- Add explicit px-3 py-2 to existing callers (settings, editEvent)
- Update CLAUDE.md with new CustomTextInput usage and props
2026-02-09 19:15:41 +01:00
7 changed files with 128 additions and 74 deletions

View File

@@ -77,7 +77,7 @@ src/
│ │ ├── _layout.tsx # Tab bar configuration (themed) │ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview │ │ ├── calendar.tsx # Calendar overview
│ │ └── settings.tsx # Settings screen (theme switcher, logout) │ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat) │ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/ │ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route) │ │ └── [id].tsx # Event detail screen (dynamic route)
@@ -98,7 +98,7 @@ src/
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton) │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons) │ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons)
│ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase) │ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase)
│ ├── CustomTextInput.tsx # Themed text input with focus border (used in CaldavSettings) │ ├── CustomTextInput.tsx # Themed text input with focus border (used in login, register, CaldavSettings, editEvent)
│ ├── DateTimePicker.tsx # Date and time picker components │ ├── DateTimePicker.tsx # Date and time picker components
│ └── ScrollableDropdown.tsx # Scrollable dropdown component │ └── ScrollableDropdown.tsx # Scrollable dropdown component
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type ├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
@@ -602,8 +602,8 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `AuthService`: login(), register(), logout() - calls backend API - `AuthService`: login(), register(), logout() - calls backend API
- `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204) - `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204)
- `AuthGuard`: Reusable component that wraps protected routes - loads user, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`) - `AuthGuard`: Reusable component that wraps protected routes - loads user, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`)
- Login screen: Supports email OR userName login, preloads app data + triggers CalDAV sync after successful login - Login screen: Supports email OR userName login, uses CustomTextInput with focus border, preloads app data + triggers CalDAV sync after successful login
- Register screen: Email validation, checks for existing email/userName - Register screen: Email validation, checks for existing email/userName, uses CustomTextInput with focus border
- `AuthButton`: Reusable button component with themed shadow - `AuthButton`: Reusable button component with themed shadow
- `Header`: Themed header component (logout moved to Settings) - `Header`: Themed header component (logout moved to Settings)
- `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access - `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access
@@ -612,7 +612,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `ThemeStore`: Zustand store with theme state and setTheme() - `ThemeStore`: Zustand store with theme state and setTheme()
- `Themes.tsx`: THEMES object with defaultLight/defaultDark variants - `Themes.tsx`: THEMES object with defaultLight/defaultDark variants
- All components use `useThemeStore()` for reactive theme colors - All components use `useThemeStore()` for reactive theme colors
- Settings screen with theme switcher (light/dark) and CalDAV configuration (url, username, password with save/sync buttons, loads existing config on mount) - Settings screen with theme switcher (light/dark) and CalDAV configuration (url, username, password with save/sync buttons, loads existing config on mount). Save/Sync buttons show independent feedback via `FeedbackRow` component: spinner + loading text during request, then success (green) or error (red) message that auto-clears after 3s. Both feedbacks can be visible simultaneously.
- `BaseButton`: Reusable themed button component - `BaseButton`: Reusable themed button component
- Tab navigation (Chat, Calendar, Settings) implemented with themed UI - Tab navigation (Chat, Calendar, Settings) implemented with themed UI
- Calendar screen fully functional: - Calendar screen fully functional:
@@ -634,12 +634,12 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Tracks conversationId for message continuity across sessions - Tracks conversationId for message continuity across sessions
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show - Auto-scroll to end on new messages and keyboard show; initial load uses `onContentSizeChange` with `animated: false` to start at bottom without visible scrolling
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing - `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing
- `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger - `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger
- `CustomTextInput`: Themed text input component with focus border highlight, supports controlled value via `text` prop - `CustomTextInput`: Themed text input with focus border highlight. Props: `text`, `onValueChange`, `placeholder`, `placeholderTextColor`, `secureTextEntry`, `autoCapitalize`, `keyboardType`, `className`, `multiline`. No default padding — callers must set padding via `className` (e.g., `px-3 py-2` or `p-4`). When not focused, cursor is reset to start (`selection={{ start: 0 }}`) to avoid text appearing scrolled to the end.
- `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android - `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android - `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()` - `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()`

View File

@@ -64,11 +64,11 @@ const Chat = () => {
string | undefined string | undefined
>(); >();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false); const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
const needsInitialScroll = useRef(false);
useEffect(() => { useEffect(() => {
const keyboardDidShow = Keyboard.addListener( const keyboardDidShow = Keyboard.addListener("keyboardDidShow", () =>
"keyboardDidShow", scrollToEnd(),
scrollToEnd,
); );
return () => keyboardDidShow.remove(); return () => keyboardDidShow.remove();
}, []); }, []);
@@ -90,7 +90,7 @@ const Chat = () => {
await ChatService.getConversation(conversationId); await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData); const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages); addMessages(clientMessages);
scrollToEnd(); needsInitialScroll.current = true;
} }
} catch (error) { } catch (error) {
console.error("Failed to load messages:", error); console.error("Failed to load messages:", error);
@@ -102,9 +102,9 @@ const Chat = () => {
}, [isAuthLoading, isAuthenticated, hasLoadedMessages]), }, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
); );
const scrollToEnd = () => { const scrollToEnd = (animated = true) => {
setTimeout(() => { setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true }); listRef.current?.scrollToEnd({ animated });
}, 100); }, 100);
}; };
@@ -277,6 +277,12 @@ const Chat = () => {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
onContentSizeChange={() => {
if (needsInitialScroll.current) {
needsInitialScroll.current = false;
listRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={ ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null isWaitingForResponse ? <TypingIndicator /> : null
} }

View File

@@ -1,4 +1,4 @@
import { Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import BaseButton, { BaseButtonProps } from "../../components/BaseButton"; import BaseButton, { BaseButtonProps } from "../../components/BaseButton";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
@@ -8,7 +8,7 @@ import { Ionicons } from "@expo/vector-icons";
import { SimpleHeader } from "../../components/Header"; import { SimpleHeader } from "../../components/Header";
import { THEMES } from "../../Themes"; import { THEMES } from "../../Themes";
import CustomTextInput from "../../components/CustomTextInput"; import CustomTextInput from "../../components/CustomTextInput";
import { useState } from "react"; import { useCallback, useRef, useState } from "react";
import { CaldavConfigService } from "../../services/CaldavConfigService"; import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores"; import { useCaldavConfigStore } from "../../stores";
@@ -33,25 +33,54 @@ type CaldavTextInputProps = {
title: string; title: string;
value: string; value: string;
onValueChange: (text: string) => void; onValueChange: (text: string) => void;
secureTextEntry?: boolean;
}; };
const CaldavTextInput = ({ const CaldavTextInput = ({
title, title,
value, value,
onValueChange, onValueChange,
secureTextEntry,
}: CaldavTextInputProps) => { }: CaldavTextInputProps) => {
const { theme } = useThemeStore();
return ( return (
<View className="flex flex-row items-center py-1"> <View className="flex flex-row items-center py-1">
<Text className="ml-4 w-24">{title}:</Text> <Text className="ml-4 w-24" style={{ color: theme.textPrimary }}>{title}:</Text>
<CustomTextInput <CustomTextInput
className="flex-1 mr-4" className="flex-1 mr-4 px-3 py-2"
text={value} text={value}
onValueChange={onValueChange} onValueChange={onValueChange}
secureTextEntry={secureTextEntry}
/> />
</View> </View>
); );
}; };
type Feedback = { text: string; isError: boolean; loading: boolean };
const FeedbackRow = ({ feedback }: { feedback: Feedback | null }) => {
const { theme } = useThemeStore();
if (!feedback) return null;
return (
<View className="flex flex-row items-center justify-center mt-2 mx-4 gap-2">
{feedback.loading && (
<ActivityIndicator size="small" color={theme.textMuted} />
)}
<Text
style={{
color: feedback.loading
? theme.textMuted
: feedback.isError
? theme.rejectButton
: theme.confirmButton,
}}
>
{feedback.text}
</Text>
</View>
);
};
const CaldavSettings = () => { const CaldavSettings = () => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const { config, setConfig } = useCaldavConfigStore(); const { config, setConfig } = useCaldavConfigStore();
@@ -59,18 +88,51 @@ const CaldavSettings = () => {
const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? ""); const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? "");
const [username, setUsername] = useState(config?.username ?? ""); const [username, setUsername] = useState(config?.username ?? "");
const [password, setPassword] = useState(config?.password ?? ""); const [password, setPassword] = useState(config?.password ?? "");
const [saveFeedback, setSaveFeedback] = useState<Feedback | null>(null);
const [syncFeedback, setSyncFeedback] = useState<Feedback | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showFeedback = useCallback(
(
setter: typeof setSaveFeedback,
timer: typeof saveTimer,
text: string,
isError: boolean,
loading = false,
) => {
if (timer.current) clearTimeout(timer.current);
setter({ text, isError, loading });
if (!loading) {
timer.current = setTimeout(() => setter(null), 3000);
}
},
[],
);
const saveConfig = async () => { const saveConfig = async () => {
showFeedback(setSaveFeedback, saveTimer, "Speichere Konfiguration...", false, true);
try {
const saved = await CaldavConfigService.saveConfig( const saved = await CaldavConfigService.saveConfig(
serverUrl, serverUrl,
username, username,
password, password,
); );
setConfig(saved); setConfig(saved);
showFeedback(setSaveFeedback, saveTimer, "Konfiguration wurde gespeichert", false);
} catch {
showFeedback(setSaveFeedback, saveTimer, "Fehler beim Speichern der Konfiguration", true);
}
}; };
const sync = async () => { const sync = async () => {
showFeedback(setSyncFeedback, syncTimer, "Synchronisiere...", false, true);
try {
await CaldavConfigService.sync(); await CaldavConfigService.sync();
showFeedback(setSyncFeedback, syncTimer, "Synchronisierung erfolgreich", false);
} catch {
showFeedback(setSyncFeedback, syncTimer, "Fehler beim Synchronisieren", true);
}
}; };
return ( return (
@@ -99,6 +161,7 @@ const CaldavSettings = () => {
title="password" title="password"
value={password} value={password}
onValueChange={setPassword} onValueChange={setPassword}
secureTextEntry
/> />
</View> </View>
<View className="flex flex-row"> <View className="flex flex-row">
@@ -109,6 +172,8 @@ const CaldavSettings = () => {
Sync Sync
</BaseButton> </BaseButton>
</View> </View>
<FeedbackRow feedback={saveFeedback} />
<FeedbackRow feedback={syncFeedback} />
</View> </View>
</> </>
); );

View File

@@ -43,7 +43,7 @@ const EditEventTextField = (props: EditEventTextFieldProps) => {
{props.titel} {props.titel}
</Text> </Text>
<CustomTextInput <CustomTextInput
className="flex-1" className="flex-1 px-3 py-2"
text={props.text} text={props.text}
multiline={props.multiline} multiline={props.multiline}
onValueChange={props.onValueChange} onValueChange={props.onValueChange}

View File

@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService"; import { CaldavConfigService } from "../services/CaldavConfigService";
import { preloadAppData } from "../components/AuthGuard"; import { preloadAppData } from "../components/AuthGuard";
@@ -59,34 +60,22 @@ const LoginScreen = () => {
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail oder Benutzername" placeholder="E-Mail oder Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={identifier} text={identifier}
onChangeText={setIdentifier} onValueChange={setIdentifier}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<AuthButton <AuthButton

View File

@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
@@ -59,50 +60,32 @@ const RegisterScreen = () => {
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail" placeholder="E-Mail"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={email} text={email}
onChangeText={setEmail} onValueChange={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Benutzername" placeholder="Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={userName} text={userName}
onChangeText={setUserName} onValueChange={setUserName}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<AuthButton <AuthButton

View File

@@ -1,4 +1,4 @@
import { TextInput } from "react-native"; import { TextInput, TextInputProps } from "react-native";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { useState } from "react"; import { useState } from "react";
@@ -8,6 +8,11 @@ export type CustomTextInputProps = {
className?: string; className?: string;
multiline?: boolean; multiline?: boolean;
onValueChange?: (text: string) => void; onValueChange?: (text: string) => void;
placeholder?: string;
placeholderTextColor?: string;
secureTextEntry?: boolean;
autoCapitalize?: TextInputProps["autoCapitalize"];
keyboardType?: TextInputProps["keyboardType"];
}; };
const CustomTextInput = (props: CustomTextInputProps) => { const CustomTextInput = (props: CustomTextInputProps) => {
@@ -16,10 +21,16 @@ const CustomTextInput = (props: CustomTextInputProps) => {
return ( return (
<TextInput <TextInput
className={`border border-solid rounded-2xl px-3 py-2 h-11/12 ${props.className}`} className={`border border-solid rounded-2xl ${props.className}`}
onChangeText={props.onValueChange} onChangeText={props.onValueChange}
value={props.text} value={props.text}
multiline={props.multiline} multiline={props.multiline}
placeholder={props.placeholder}
placeholderTextColor={props.placeholderTextColor}
secureTextEntry={props.secureTextEntry}
autoCapitalize={props.autoCapitalize}
keyboardType={props.keyboardType}
selection={!focused ? { start: 0 } : undefined}
style={{ style={{
backgroundColor: theme.messageBorderBg, backgroundColor: theme.messageBorderBg,
color: theme.textPrimary, color: theme.textPrimary,