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 (
);
}