import { GlassContainer, GlassView } from "expo-glass-effect"; import { useEffect, useState } from "react"; import { Text as SystemText, View } from "react-native"; import Animated, { Easing, FadeIn, FadeOut, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; const CONTROL_HEIGHT = 44; const CONTROL_COMPOSER_GAP = 8; const GLASS_MERGE_SPACING = 12; const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); const CONTROL_TIMING = { duration: 240, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System, } as const; const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; // Expo reapplies glass after native layout and window reattachment, when UIKit // can otherwise leave the label visible but lose the material behind it. const UniwindGlassView = withUniwind(GlassView, { style: { fromClassName: "className" }, }); const UniwindGlassContainer = withUniwind(GlassContainer, { style: { fromClassName: "className" }, }); const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; readonly startedAt: string | null; readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); useEffect(() => { separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); }, [props.showScrollToEnd, separationProgress]); const timerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], })); const arrowTransformStyle = useAnimatedStyle(() => ({ transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], })); const arrowContentStyle = useAnimatedStyle(() => ({ opacity: separationProgress.value, })); if (props.startedAt === null && !props.showScrollToEnd) { return null; } return ( {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( ) : props.startedAt !== null ? ( ) : NATIVE_LIQUID_GLASS_SUPPORTED ? ( ) : ( )} ); } function WorkingDuration(props: { readonly startedAt: string }) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { setNowMs(Date.now()); const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); return () => clearInterval(intervalId); }, [props.startedAt]); const duration = formatWorkingDuration(props.startedAt, nowMs); const label = `Working for ${duration}`; return ( Working for {duration} ); } function formatWorkingDuration(startedAt: string, nowMs: number): string { const startedAtMs = Date.parse(startedAt); if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) { return "0s"; } const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000); if (totalSeconds < 60) { return `${totalSeconds}s`; } const minutes = Math.floor(totalSeconds / 60); const seconds = String(totalSeconds % 60).padStart(2, "0"); return `${minutes}m ${seconds}s`; } function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) { return ( ); }