mobile: let users dismiss the keyboard from a task card

Focusing a task field opened the keyboard with no way out — it covered the
card and the stream's tap-to-advance zones. Now:

- A "Done" pill appears at the card's top-right while editing (reusing the
  existing `editing` flag) and calls Keyboard.dismiss(); the title row
  reserves space so the pill never overlaps a long title.
- The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag
  on Android) so dragging the card also dismisses the keyboard.

Dismissing blurs the active field, which flips `editing` off and resumes the
dwell timer and tap navigation automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
Claude
2026-06-21 16:35:33 +00:00
parent c161d96534
commit 4a73ccad0b
@@ -1,5 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import {
Keyboard,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { deleteField } from 'firebase/firestore';
import { Check, Plus, X } from 'lucide-react-native';
import type { ChecklistItem, Particle } from '@/api/types';
@@ -158,119 +166,146 @@ export function TaskParticleView({
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<ScrollView
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5 gap-5"
showsVerticalScrollIndicator
indicatorStyle="white"
keyboardShouldPersistTaps="handled"
>
{/* Title + done */}
<View className="flex-row items-start gap-3">
<Pressable
onPress={handleToggleDone}
hitSlop={8}
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
className={cn(
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
)}
<View className="w-full max-w-xl" style={{ maxHeight: '100%' }}>
<ScrollView
className="max-h-full rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5 gap-5"
showsVerticalScrollIndicator
indicatorStyle="white"
keyboardShouldPersistTaps="handled"
// Dragging the card dismisses the keyboard (interactive follow on
// iOS; on-drag on Android, which lacks the interactive variant).
keyboardDismissMode={
Platform.OS === 'ios' ? 'interactive' : 'on-drag'
}
>
{/* Title + done */}
<View
className={cn('flex-row items-start gap-3', editing && 'pr-12')}
>
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
</Pressable>
<Pressable
onPress={handleToggleDone}
hitSlop={8}
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
className={cn(
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
)}
>
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={title}
key={`title-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== title) {
void updateParticleProperties<'task'>(docPath, {
title: value,
});
}
}}
placeholder="Task title"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className={cn(
'flex-1 text-white text-2xl font-semibold',
done && 'text-white/50 line-through',
)}
/>
</View>
{/* Notes */}
<TextInput
defaultValue={title}
key={`title-${particle.id}`}
defaultValue={notes ?? ''}
key={`notes-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== title) {
if (value !== (notes ?? '')) {
void updateParticleProperties<'task'>(docPath, {
title: value,
notes: value,
});
}
}}
placeholder="Task title"
placeholder="Add notes…"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className={cn(
'flex-1 text-white text-2xl font-semibold',
done && 'text-white/50 line-through',
)}
className="text-white/80 text-base"
/>
</View>
{/* Notes */}
<TextInput
defaultValue={notes ?? ''}
key={`notes-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== (notes ?? '')) {
void updateParticleProperties<'task'>(docPath, { notes: value });
}
}}
placeholder="Add notes…"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className="text-white/80 text-base"
/>
{/* Checklist */}
<View className="gap-2">
{checklist.length > 0 ? (
<Text className="text-white/40 text-xs">
{doneCount} / {checklist.length} done
</Text>
) : null}
{checklist.map((item, index) => (
<ChecklistItemRow
key={`${particle.id}-item-${index}`}
item={item}
onToggle={() => handleToggleItem(index)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
{/* Checklist */}
<View className="gap-2">
{checklist.length > 0 ? (
<Text className="text-white/40 text-xs">
{doneCount} / {checklist.length} done
</Text>
) : null}
{checklist.map((item, index) => (
<ChecklistItemRow
key={`${particle.id}-item-${index}`}
item={item}
onToggle={() => handleToggleItem(index)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
onFocusChange={setEditing}
/>
))}
<AddChecklistItemRow
onAdd={handleAddItem}
onFocusChange={setEditing}
/>
))}
<AddChecklistItemRow
onAdd={handleAddItem}
onFocusChange={setEditing}
/>
</View>
</View>
{/* Assignee */}
<View className="gap-2">
<Text className="text-white/40 text-xs">Assignee</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2"
>
<AssigneeChip
label="Unassigned"
selected={!assigned_to}
onPress={() => handleAssign(null)}
/>
{network?.humans?.map((human) => {
const display = resolveHumanDisplay(human.id, network?.humans);
return (
<AssigneeChip
key={human.id}
label={display.displayName}
selected={assigned_to === human.id}
onPress={() => handleAssign(human.id)}
avatarHumanId={human.id}
humans={network?.humans}
/>
);
})}
</ScrollView>
</View>
</ScrollView>
{/* Assignee */}
<View className="gap-2">
<Text className="text-white/40 text-xs">Assignee</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2"
>
<AssigneeChip
label="Unassigned"
selected={!assigned_to}
onPress={() => handleAssign(null)}
/>
{network?.humans?.map((human) => {
const display = resolveHumanDisplay(human.id, network?.humans);
return (
<AssigneeChip
key={human.id}
label={display.displayName}
selected={assigned_to === human.id}
onPress={() => handleAssign(human.id)}
avatarHumanId={human.id}
humans={network?.humans}
/>
);
})}
</ScrollView>
</View>
</ScrollView>
{/* While a field is focused the keyboard hides the stream's tap-zones,
so offer an explicit way out. Dismissing blurs the active field,
which flips `editing` off and resumes the dwell + tap navigation. */}
{editing ? (
<View className="absolute right-2 top-2">
<Pressable
onPress={() => Keyboard.dismiss()}
hitSlop={8}
accessibilityLabel="Done editing"
className="rounded-full bg-white/15 px-3 py-1.5"
>
<Text className="text-white text-sm font-medium">Done</Text>
</Pressable>
</View>
) : null}
</View>
</View>
);
}