mobile: task particles — view, edit, and compose (parity phase 3)
Bring the task particle to parity with desktop's richer model: - Replace the thin `quest` schema with desktop's `task` model (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done) in the discriminated union, the Firestore converter, and consumers (StreamCard, FallbackParticleView). - New TaskParticleView renders an editable card (round done checkbox, title, notes, checklist with add/toggle/edit/remove, assignee chips) persisting each edit to Firestore; an 8s dwell auto-advances and field focus suspends playback. Wired into StreamView's render switch. - Compose: a task button in the ComposeDock opens a TaskComposeSheet (createTaskParticle helper). Gated off in the new-stream flow, where a stream's first particle must be text or media. Note: particles are written client-side to Firestore, matching desktop; Orion's REST validator still only accepts `quest`, which is a pre-existing inconsistency to reconcile backend-side separately. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native';
|
||||
import {
|
||||
ListTodo,
|
||||
Mic,
|
||||
Type as TypeIcon,
|
||||
Video as VideoIcon,
|
||||
} from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||
import { toast } from 'sonner-native';
|
||||
@@ -8,13 +13,18 @@ import { cn } from '@/lib/utils';
|
||||
import { useEvent } from '@/hooks/use-event';
|
||||
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { createTextParticle, uploadMediaParticle } from '@/lib/upload';
|
||||
import {
|
||||
createTaskParticle,
|
||||
createTextParticle,
|
||||
uploadMediaParticle,
|
||||
} from '@/lib/upload';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
import {
|
||||
useStreamComposingBroadcastOptional,
|
||||
type ComposingMode,
|
||||
} from '@/features/stream-view/stream-presence-context';
|
||||
import { TextComposeModal } from './TextComposeModal';
|
||||
import { TaskComposeSheet } from './TaskComposeSheet';
|
||||
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||
import { ReviewSheet } from './ReviewSheet';
|
||||
@@ -48,6 +58,12 @@ interface ComposeDockProps {
|
||||
networkId: string;
|
||||
targetPath: ParticlePath;
|
||||
silentPresence?: boolean;
|
||||
/**
|
||||
* Whether to show the task compose button. Disabled in the new-stream flow,
|
||||
* where a stream's first particle must be text or media (a task can't open a
|
||||
* stream — it's added once the stream exists).
|
||||
*/
|
||||
allowTask?: boolean;
|
||||
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||
submitText?: (content: string) => Promise<void>;
|
||||
/**
|
||||
@@ -63,6 +79,7 @@ export function ComposeDock({
|
||||
networkId,
|
||||
targetPath,
|
||||
silentPresence = false,
|
||||
allowTask = true,
|
||||
submitMedia,
|
||||
submitText: submitTextOverride,
|
||||
onParticleCreated,
|
||||
@@ -72,6 +89,7 @@ export function ComposeDock({
|
||||
const [mode, setMode] = useState<RecordingMode>('video');
|
||||
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
const [taskOpen, setTaskOpen] = useState(false);
|
||||
|
||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||
@@ -79,13 +97,13 @@ export function ComposeDock({
|
||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||
const isComposing = ui.kind !== 'idle' || textOpen;
|
||||
const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
|
||||
useEffect(() => {
|
||||
setComposing(isComposing);
|
||||
return () => setComposing(false);
|
||||
}, [isComposing, setComposing]);
|
||||
|
||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
||||
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
|
||||
|
||||
const ensurePermissions = useCallback(
|
||||
async (forVideo: boolean): Promise<boolean> => {
|
||||
@@ -187,6 +205,20 @@ export function ComposeDock({
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
});
|
||||
|
||||
const submitTask = useEvent(
|
||||
async ({ title, notes }: { title: string; notes?: string }) => {
|
||||
if (!userId) throw new Error('Not signed in.');
|
||||
const particleId = await createTaskParticle({
|
||||
targetPath,
|
||||
title,
|
||||
notes,
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
onParticleCreated?.(particleId);
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
},
|
||||
);
|
||||
|
||||
const dockHidden =
|
||||
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
||||
|
||||
@@ -196,27 +228,31 @@ export function ComposeDock({
|
||||
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="flex-row items-center justify-between px-8 pb-10"
|
||||
className="flex-row items-center px-8 pb-10"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||
}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === 'video' ? 'audio' : 'video'
|
||||
} mode`}
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{mode === 'video' ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
)}
|
||||
</Pressable>
|
||||
{/* Left and right clusters flex equally so the record button stays
|
||||
centered regardless of how many side controls are present. */}
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||
}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === 'video' ? 'audio' : 'video'
|
||||
} mode`}
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{mode === 'video' ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Pressable
|
||||
@@ -230,17 +266,33 @@ export function ComposeDock({
|
||||
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setTextOpen(true)}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel="Compose text"
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||
</Pressable>
|
||||
<View className="flex-1 flex-row items-center justify-end gap-3">
|
||||
{allowTask ? (
|
||||
<Pressable
|
||||
onPress={() => setTaskOpen(true)}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel="Create task"
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<ListTodo color="white" size={20} strokeWidth={1.6} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
onPress={() => setTextOpen(true)}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel="Compose text"
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -277,6 +329,12 @@ export function ComposeDock({
|
||||
onClose={() => setTextOpen(false)}
|
||||
onSubmit={submitText}
|
||||
/>
|
||||
|
||||
<TaskComposeSheet
|
||||
open={taskOpen}
|
||||
onClose={() => setTaskOpen(false)}
|
||||
onSubmit={submitTask}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -284,17 +342,23 @@ export function ComposeDock({
|
||||
function useComposingBroadcast({
|
||||
ui,
|
||||
textOpen,
|
||||
taskOpen,
|
||||
silent,
|
||||
}: {
|
||||
ui: ComposeUiState;
|
||||
textOpen: boolean;
|
||||
taskOpen: boolean;
|
||||
silent: boolean;
|
||||
}) {
|
||||
// null when the dock is rendered outside a stream (no presence provider).
|
||||
const broadcast = useStreamComposingBroadcastOptional();
|
||||
|
||||
const mode: ComposingMode | null =
|
||||
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
||||
ui.kind === 'recording'
|
||||
? 'recording'
|
||||
: textOpen || taskOpen
|
||||
? 'typing'
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (silent || !broadcast) return;
|
||||
|
||||
Reference in New Issue
Block a user