Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 103 additions & 14 deletions src/app/entry/new.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useConvexAuth, useMutation } from "convex/react";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { type ReactNode, useCallback, useRef, useState } from "react";
import { type LayoutChangeEvent, Platform, View } from "react-native";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Keyboard, type LayoutChangeEvent, Platform, View } from "react-native";
import {
type KeyboardAwareScrollViewRef,
KeyboardStickyView,
Expand Down Expand Up @@ -92,6 +98,8 @@ const EXAM_TYPE_OPTIONS = [
"Präsentation",
];

const KEYBOARD_DISMISS_FALLBACK_MS = 280;

const subjectIconByOption = {
Mathematik: Calculator,
Deutsch: Pencil,
Expand Down Expand Up @@ -244,6 +252,15 @@ export default function NewEntryScreen() {
const [selectTarget, setSelectTarget] = useState<SelectTarget | null>(null);
const scrollViewRef = useRef<KeyboardAwareScrollViewRef | null>(null);
const noteInputOffsetY = useRef(0);
const keyboardHideSubscriptionRef = useRef<ReturnType<
typeof Keyboard.addListener
> | null>(null);
const keyboardDismissFallbackRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
const keyboardDismissFrameRef = useRef<ReturnType<
typeof requestAnimationFrame
> | null>(null);

const trimmedSubject = subject.trim();
const trimmedExamType = examTypeLabel.trim();
Expand All @@ -265,8 +282,80 @@ export default function NewEntryScreen() {
: "Plane jetzt, wann du die Hausaufgabe erledigst."
: "Trage Datum, Uhrzeit, Fach und Prüfungsart ein.";

const closePicker = () => setPickerTarget(null);
const closeSelect = () => setSelectTarget(null);
const clearPendingModalOpen = useCallback(() => {
keyboardHideSubscriptionRef.current?.remove();
keyboardHideSubscriptionRef.current = null;

if (keyboardDismissFallbackRef.current) {
clearTimeout(keyboardDismissFallbackRef.current);
keyboardDismissFallbackRef.current = null;
}

if (keyboardDismissFrameRef.current) {
cancelAnimationFrame(keyboardDismissFrameRef.current);
keyboardDismissFrameRef.current = null;
}
}, []);

useEffect(() => clearPendingModalOpen, [clearPendingModalOpen]);

const openAfterKeyboardDismiss = useCallback(
(open: () => void) => {
clearPendingModalOpen();
const isKeyboardVisible = Keyboard.isVisible();

if (!isKeyboardVisible) {
Keyboard.dismiss();
open();
return;
}

let didOpen = false;
const finishOpen = () => {
if (didOpen) return;
didOpen = true;
clearPendingModalOpen();
keyboardDismissFrameRef.current = requestAnimationFrame(() => {
keyboardDismissFrameRef.current = null;
open();
});
};

keyboardHideSubscriptionRef.current = Keyboard.addListener(
"keyboardDidHide",
finishOpen,
);
keyboardDismissFallbackRef.current = setTimeout(
finishOpen,
KEYBOARD_DISMISS_FALLBACK_MS,
);
Keyboard.dismiss();
},
Comment on lines +300 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cancel queued opens before changing steps.

Line 302 queues the modal open outside React state, but step transitions still call setStep(...) without clearing that queue. If the keyboard is hiding and the user taps Back/Weiter before keyboardDidHide or the fallback fires, the stale open() can set pickerTarget/selectTarget after the UI has moved to another step.

🐛 Proposed fix
 const handleBack = useCallback(() => {
+	clearPendingModalOpen();
+
 	if (selectTarget) {
 		setSelectTarget(null);
 		return true;
 	}
@@
 	goBackOrReplace(router, "/home");
 	return true;
-}, [pickerTarget, router, selectTarget, step]);
+}, [clearPendingModalOpen, pickerTarget, router, selectTarget, step]);

Also clear before other step-changing paths:

 if (isHomework) {
+	clearPendingModalOpen();
 	setStep("success");
 	return;
 }
@@
 if (step === "basics") {
+	clearPendingModalOpen();
 	setStep("planning");
 	return;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/entry/new.tsx` around lines 300 - 333, The openAfterKeyboardDismiss
function queues modal opens that may execute after the user navigates to a
different step, causing stale callbacks to modify the wrong step's state. You
need to call clearPendingModalOpen() before all step-changing operations
(anywhere setStep is called in this component) to ensure pending modal opens are
cancelled when the user navigates away. This prevents the queued open callback
from executing after the step context has changed and setting pickerTarget or
selectTarget in the wrong step.

[clearPendingModalOpen],
);

const openPicker = useCallback(
(target: PickerTarget) => {
openAfterKeyboardDismiss(() => setPickerTarget(target));
},
[openAfterKeyboardDismiss],
);

const openSelect = useCallback(
(target: SelectTarget) => {
openAfterKeyboardDismiss(() => setSelectTarget(target));
},
[openAfterKeyboardDismiss],
);

const closePicker = () => {
clearPendingModalOpen();
setPickerTarget(null);
};
const closeSelect = () => {
clearPendingModalOpen();
setSelectTarget(null);
};

const handlePickerChange = (
event: DateTimePickerEvent,
Expand Down Expand Up @@ -545,14 +634,14 @@ export default function NewEntryScreen() {
icon={
<CalendarDays size={20} color="#9EA1A8" strokeWidth={2.1} />
}
onPress={() => setPickerTarget("dueDate")}
onPress={() => openPicker("dueDate")}
/>

<Field>
<FieldLabel>Schulfach</FieldLabel>
<FieldTrigger
activeOpacity={0.86}
onPress={() => setSelectTarget("subject")}
onPress={() => openSelect("subject")}
className="min-h-[64px] rounded-[28px] px-5"
style={{
boxShadow: "0 6px 13px rgba(0, 0, 0, 0.08)",
Expand Down Expand Up @@ -616,7 +705,7 @@ export default function NewEntryScreen() {
icon={
<CalendarDays size={20} color="#9EA1A8" strokeWidth={2.1} />
}
onPress={() => setPickerTarget("plannedDate")}
onPress={() => openPicker("plannedDate")}
/>

<View className="mb-5 flex-row" style={{ columnGap: 12 }}>
Expand All @@ -627,7 +716,7 @@ export default function NewEntryScreen() {
icon={
<Clock3 size={19} color="#9EA1A8" strokeWidth={2.1} />
}
onPress={() => setPickerTarget("plannedTime")}
onPress={() => openPicker("plannedTime")}
className="min-h-[64px] px-5"
/>
</View>
Expand All @@ -638,7 +727,7 @@ export default function NewEntryScreen() {
icon={
<Clock3 size={19} color="#9EA1A8" strokeWidth={2.1} />
}
onPress={() => setPickerTarget("plannedEndTime")}
onPress={() => openPicker("plannedEndTime")}
className="min-h-[64px] px-5"
/>
</View>
Expand Down Expand Up @@ -687,15 +776,15 @@ export default function NewEntryScreen() {
icon={
<CalendarDays size={20} color="#9EA1A8" strokeWidth={2.1} />
}
onPress={() => setPickerTarget("plannedDate")}
onPress={() => openPicker("plannedDate")}
/>
<View className="mb-5 flex-row" style={{ columnGap: 12 }}>
<View className="flex-1">
<HomeworkPillField
value={formatTime(plannedTime)}
placeholder="Von"
icon={<Clock3 size={19} color="#9EA1A8" strokeWidth={2.1} />}
onPress={() => setPickerTarget("plannedTime")}
onPress={() => openPicker("plannedTime")}
className="min-h-[64px] px-5"
/>
</View>
Expand All @@ -704,7 +793,7 @@ export default function NewEntryScreen() {
value={formatTime(plannedEndTime)}
placeholder="Bis"
icon={<Clock3 size={19} color="#9EA1A8" strokeWidth={2.1} />}
onPress={() => setPickerTarget("plannedEndTime")}
onPress={() => openPicker("plannedEndTime")}
className="min-h-[64px] px-5"
/>
</View>
Expand All @@ -714,7 +803,7 @@ export default function NewEntryScreen() {
<FieldLabel>Schulfach</FieldLabel>
<FieldTrigger
activeOpacity={0.86}
onPress={() => setSelectTarget("subject")}
onPress={() => openSelect("subject")}
className="min-h-[64px] rounded-[28px] px-5"
style={{
boxShadow: "0 6px 13px rgba(0, 0, 0, 0.08)",
Expand All @@ -740,7 +829,7 @@ export default function NewEntryScreen() {
<FieldLabel>Prüfungsart</FieldLabel>
<FieldTrigger
activeOpacity={0.86}
onPress={() => setSelectTarget("examType")}
onPress={() => openSelect("examType")}
className="min-h-[64px] rounded-[28px] px-5"
style={{
boxShadow: "0 6px 13px rgba(0, 0, 0, 0.08)",
Expand Down