fix: prevent double-tap save, add subtasks at creation, fix keyboard overlap (#7, #8, #6)

- Add saving guard to prevent duplicate task creation on rapid taps
- Add pending subtasks UI to new task screen with local add/remove
- Wrap both task screens in KeyboardAvoidingView with scrollToEnd on subtask focus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-28 16:27:03 -05:00
parent e6ac92e745
commit 72eafbd9d9
2 changed files with 97 additions and 19 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import {
View,
Text,
@ -8,6 +8,7 @@ import {
useColorScheme,
Alert,
Platform,
KeyboardAvoidingView,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
@ -79,6 +80,8 @@ export default function TaskDetailScreen() {
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]);
const [selectedListId, setSelectedListId] = useState<string>('');
const [saving, setSaving] = useState(false);
const scrollRef = useRef<ScrollView>(null);
useEffect(() => {
if (!isValidUUID(id)) {
@ -112,17 +115,23 @@ export default function TaskDetailScreen() {
};
const handleSave = async () => {
if (saving) return;
if (!task || !title.trim()) return;
await updateTask(task.id, {
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate,
recurrence,
listId: selectedListId,
});
await setTagsForTask(task.id, selectedTagIds);
router.back();
setSaving(true);
try {
await updateTask(task.id, {
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate,
recurrence,
listId: selectedListId,
});
await setTagsForTask(task.id, selectedTagIds);
router.back();
} catch {
setSaving(false);
}
};
const handleDelete = () => {
@ -196,13 +205,14 @@ export default function TaskDetailScreen() {
<Pressable onPress={handleDelete} className="mr-3 p-1">
<Trash2 size={20} color={colors.terracotta.DEFAULT} />
</Pressable>
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
<Pressable onPress={handleSave} disabled={saving} className={`rounded-lg bg-bleu px-4 py-1.5 ${saving ? 'opacity-50' : ''}`}>
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>{t('common.save')}</Text>
</Pressable>
</View>
</View>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
<KeyboardAvoidingView className="flex-1" behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView ref={scrollRef} className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Title */}
<TextInput
value={title}
@ -391,6 +401,7 @@ export default function TaskDetailScreen() {
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
onFocus={() => setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300)}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
@ -398,8 +409,9 @@ export default function TaskDetailScreen() {
/>
</View>
<View className="h-24" />
<View className="h-32" />
</ScrollView>
</KeyboardAvoidingView>
</View>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import {
View,
Text,
@ -7,10 +7,11 @@ import {
ScrollView,
useColorScheme,
Platform,
KeyboardAvoidingView,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
X, Calendar, Repeat,
X, Calendar, Repeat, Plus,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -55,6 +56,10 @@ export default function NewTaskScreen() {
const [recurrence, setRecurrence] = useState<string | null>(null);
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [pendingSubtasks, setPendingSubtasks] = useState<string[]>([]);
const [newSubtask, setNewSubtask] = useState('');
const scrollRef = useRef<ScrollView>(null);
useEffect(() => {
getAllLists().then(setLists);
@ -62,7 +67,9 @@ export default function NewTaskScreen() {
}, []);
const handleSave = async () => {
if (saving) return;
if (!title.trim()) return;
setSaving(true);
try {
const taskId = await createTask({
title: title.trim(),
@ -75,9 +82,13 @@ export default function NewTaskScreen() {
if (selectedTagIds.length > 0) {
await setTagsForTask(taskId, selectedTagIds);
}
for (const sub of pendingSubtasks) {
await createTask({ title: sub, listId: selectedListId, parentId: taskId });
}
router.back();
} catch {
// FK constraint or other DB error — fallback to inbox
setSaving(false);
setSelectedListId(getInboxId());
}
};
@ -87,6 +98,16 @@ export default function NewTaskScreen() {
if (date) setDueDate(date);
};
const handleAddPendingSubtask = () => {
if (!newSubtask.trim()) return;
setPendingSubtasks((prev) => [...prev, newSubtask.trim()]);
setNewSubtask('');
};
const handleRemovePendingSubtask = (index: number) => {
setPendingSubtasks((prev) => prev.filter((_, i) => i !== index));
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
@ -110,14 +131,15 @@ export default function NewTaskScreen() {
>
{t('task.newTask')}
</Text>
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
<Pressable onPress={handleSave} disabled={saving} className={`rounded-lg bg-bleu px-4 py-1.5 ${saving ? 'opacity-50' : ''}`}>
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('common.save')}
</Text>
</Pressable>
</View>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
<KeyboardAvoidingView className="flex-1" behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView ref={scrollRef} className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Title */}
<TextInput
autoFocus
@ -314,8 +336,52 @@ export default function NewTaskScreen() {
</>
)}
<View className="h-24" />
{/* Subtasks */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.subtasks')}
</Text>
{pendingSubtasks.map((sub, index) => (
<View
key={index}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{ borderColor: colors.priority.none, backgroundColor: 'transparent' }}
/>
<Text
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub}
</Text>
<Pressable onPress={() => handleRemovePendingSubtask(index)} className="p-1">
<X size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
</View>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddPendingSubtask}
onFocus={() => setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300)}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
<View className="h-32" />
</ScrollView>
</KeyboardAvoidingView>
</View>
);
}