import type { VoiceInputPhase, VoiceInputState } from "@t3tools/client-runtime/voice-input";
import { memo, useCallback, useLayoutEffect, useState, type ReactNode } from "react";
import {
ActivityIndicator,
Linking,
Platform,
Pressable,
View,
type LayoutChangeEvent,
} from "react-native";
import Animated, {
Easing,
LinearTransition,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withTiming,
type EntryExitAnimationFunction,
type SharedValue,
} from "react-native-reanimated";
import { AppText as Text } from "../../components/AppText";
import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
import { cn } from "../../lib/cn";
import type { VoiceComposerPresentation } from "./voiceInputPresentation";
import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
const DICTATION_TIMING = {
duration: 220,
easing: Easing.out(Easing.cubic),
reduceMotion: ReduceMotion.System,
} as const;
const DICTATION_LAYOUT =
Platform.OS === "android"
? undefined
: LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System);
const TOOLBAR_FLIP_TIMING = {
duration: 260,
easing: Easing.inOut(Easing.cubic),
reduceMotion: ReduceMotion.System,
} as const;
const TOOLBAR_HALF_HEIGHT = 22;
const TOOLBAR_PERSPECTIVE = 600;
/** Moves each face around the same horizontal axis, keeping their edges together. */
function toolbarFlip(fromDegrees: number, toDegrees: number): EntryExitAnimationFunction {
return () => {
"worklet";
const fromRadians = (fromDegrees * Math.PI) / 180;
const toRadians = (toDegrees * Math.PI) / 180;
const fromSine = Math.sin(fromRadians);
const toSine = Math.sin(toRadians);
return {
initialValues: {
opacity: fromDegrees === 0 ? 1 : 0,
transform: [
{ perspective: TOOLBAR_PERSPECTIVE },
{ translateY: -TOOLBAR_HALF_HEIGHT * fromSine },
{ rotateX: `${fromDegrees}deg` },
],
},
animations: {
opacity: withTiming(toDegrees === 0 ? 1 : 0, TOOLBAR_FLIP_TIMING),
transform: [
{ perspective: TOOLBAR_PERSPECTIVE },
{
translateY: withTiming(-TOOLBAR_HALF_HEIGHT * toSine, {
...TOOLBAR_FLIP_TIMING,
easing: (time) => {
const angle =
fromRadians + (toRadians - fromRadians) * TOOLBAR_FLIP_TIMING.easing(time);
return (Math.sin(angle) - fromSine) / (toSine - fromSine);
},
}),
},
{ rotateX: withTiming(`${toDegrees}deg`, TOOLBAR_FLIP_TIMING) },
],
},
};
};
}
const DRAFT_TOOLBAR_ENTERING = toolbarFlip(90, 0);
const DRAFT_TOOLBAR_EXITING = toolbarFlip(0, 90);
const DICTATION_TOOLBAR_ENTERING = toolbarFlip(-90, 0);
const DICTATION_TOOLBAR_EXITING = toolbarFlip(0, -90);
const WAVEFORM_BAR_HEIGHT = 32;
const WAVEFORM_MIN_BAR_HEIGHT = 2;
const WAVEFORM_BAR_SPACING = 5;
const WAVEFORM_TIMING = {
duration: 100,
easing: Easing.out(Easing.quad),
reduceMotion: ReduceMotion.System,
} as const;
/** Rotates the compact draft away without unmounting or resizing its native editor. */
export function ComposerDictationDraftContent(props: {
readonly children: ReactNode;
readonly className?: string;
readonly compact: boolean;
readonly hidden: boolean;
}) {
const rotation = useSharedValue(props.hidden ? 1 : 0);
useLayoutEffect(() => {
rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING);
}, [props.hidden, rotation]);
const compact = props.compact;
const animatedStyle = useAnimatedStyle(() => ({
opacity: compact ? 1 - rotation.value : 1,
transform: compact
? [
{ perspective: TOOLBAR_PERSPECTIVE },
{ translateY: -TOOLBAR_HALF_HEIGHT * Math.sin((rotation.value * Math.PI) / 2) },
{ rotateX: `${rotation.value * 90}deg` },
]
: [],
}));
return (
{props.children}
);
}
/** Flips the entire row while keeping the outgoing controls intact until it leaves. */
export function ComposerDictationToolbar(props: {
readonly children: ReactNode;
readonly showsDictation: boolean;
readonly visible?: boolean;
}) {
return (
{props.visible !== false ? (
{props.children}
) : null}
);
}
const WaveformBar = memo(function WaveformBar(props: {
readonly audioLevels: SharedValue;
readonly sampleIndex: number;
}) {
const { audioLevels, sampleIndex } = props;
const animatedStyle = useAnimatedStyle(() => {
const level = audioLevels.value[sampleIndex] ?? 0;
return {
opacity: withTiming(0.22 + level * 0.78, WAVEFORM_TIMING),
transform: [
{
scaleY: withTiming(
(WAVEFORM_MIN_BAR_HEIGHT + level * (WAVEFORM_BAR_HEIGHT - WAVEFORM_MIN_BAR_HEIGHT)) /
WAVEFORM_BAR_HEIGHT,
WAVEFORM_TIMING,
),
},
],
};
});
return (
);
});
const VoiceWaveform = memo(function VoiceWaveform(props: {
readonly audioLevels: SharedValue;
}) {
const [barCount, setBarCount] = useState(0);
const handleLayout = useCallback((event: LayoutChangeEvent) => {
setBarCount(
Math.max(
1,
Math.min(
VOICE_WAVEFORM_SAMPLE_COUNT,
Math.floor(event.nativeEvent.layout.width / WAVEFORM_BAR_SPACING),
),
),
);
}, []);
return (
{Array.from({ length: barCount }, (_, index) => (
))}
);
});
function VoiceActionButton(props: {
readonly accessibilityLabel: string;
readonly disabled?: boolean;
readonly icon: AppSymbolName;
readonly loading?: boolean;
readonly onPress: () => void;
readonly variant?: "plain" | "primary";
}) {
const variant = props.variant ?? "plain";
const loadingVisibility = useSharedValue(props.loading ? 1 : 0);
useLayoutEffect(() => {
loadingVisibility.value = withTiming(props.loading ? 1 : 0, DICTATION_TIMING);
}, [loadingVisibility, props.loading]);
const primaryStyle = useAnimatedStyle(() => ({ opacity: 1 - loadingVisibility.value }));
return (
{variant === "primary" ? (
) : null}
{props.loading ? (
) : (
)}
);
}
export function ComposerDictationStatus(props: {
readonly audioLevels: SharedValue;
readonly elapsedSeconds: number;
readonly phase: VoiceInputPhase;
readonly presentation: VoiceComposerPresentation;
readonly onDismissError: () => void;
}) {
const recordingVisibility = useSharedValue(props.phase === "recording" ? 1 : 0);
useLayoutEffect(() => {
recordingVisibility.value = withTiming(props.phase === "recording" ? 1 : 0, DICTATION_TIMING);
}, [props.phase, recordingVisibility]);
const waveformStyle = useAnimatedStyle(() => ({
opacity: recordingVisibility.value,
}));
const labelStyle = useAnimatedStyle(() => ({
opacity: 1 - recordingVisibility.value,
}));
if (!props.presentation.statusLabel) return null;
const isError = props.presentation.statusKind === "error";
const elapsedLabel = `${Math.floor(props.elapsedSeconds / 60)}:${String(props.elapsedSeconds % 60).padStart(2, "0")}`;
return (
{isError ? (
{props.presentation.statusLabel}
) : (
{elapsedLabel}
{props.presentation.statusLabel}
)}
);
}
export function ComposerDictationCancelAction(props: {
readonly presentation: VoiceComposerPresentation;
readonly onCancel: () => void;
}) {
if (props.presentation.leadingAction !== "cancel") return null;
return (
);
}
export function ComposerDictationPrimaryAction(props: {
readonly state: VoiceInputState;
readonly presentation: VoiceComposerPresentation;
readonly isAvailable: boolean;
readonly disabled?: boolean;
readonly onStart: () => void;
readonly onConfirm: () => void;
readonly onCancel: () => void;
}) {
if (props.presentation.trailingAction === "confirm") {
return (
);
}
return ;
}
export function ComposerDictationStartAction(props: {
readonly state: VoiceInputState;
readonly isAvailable: boolean;
readonly disabled?: boolean;
readonly onStart: () => void;
readonly onCancel: () => void;
}) {
if (!props.isAvailable) return null;
const openSettings = props.state.phase === "error" && props.state.errorAction === "settings";
return (
{
props.onCancel();
void Linking.openSettings();
}
: props.onStart
}
/>
);
}