Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import type { TFunction } from "i18next";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { Check, X, XCircle } from "lucide-react-native";
|
|
import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/stores/download-store";
|
|
|
|
const AUTO_DISMISS_DELAY = 3000;
|
|
|
|
function getDownloadStatusText(download: Download, t: TFunction): string {
|
|
if (download.status === "downloading") {
|
|
if (download.progress) {
|
|
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
|
|
}
|
|
return t("common.states.starting");
|
|
}
|
|
if (download.status === "complete") return t("common.states.downloadComplete");
|
|
return download.message ?? t("common.states.downloadFailed");
|
|
}
|
|
|
|
export function DownloadToast() {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
const downloads = useDownloadStore((state) => state.downloads);
|
|
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
|
|
const dismissDownload = useDownloadStore((state) => state.dismissDownload);
|
|
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const activeDownload = activeDownloadId ? downloads.get(activeDownloadId) : null;
|
|
|
|
useEffect(() => {
|
|
if (dismissTimeoutRef.current) {
|
|
clearTimeout(dismissTimeoutRef.current);
|
|
dismissTimeoutRef.current = null;
|
|
}
|
|
|
|
if (activeDownload && activeDownload.status !== "downloading") {
|
|
dismissTimeoutRef.current = setTimeout(() => {
|
|
dismissDownload(activeDownload.id);
|
|
}, AUTO_DISMISS_DELAY);
|
|
}
|
|
|
|
return () => {
|
|
if (dismissTimeoutRef.current) {
|
|
clearTimeout(dismissTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [activeDownload, dismissDownload]);
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.container, { bottom: theme.spacing[4] + insets.bottom }],
|
|
[theme.spacing, insets.bottom],
|
|
);
|
|
|
|
const handleDismiss = useCallback(() => {
|
|
if (activeDownload) {
|
|
dismissDownload(activeDownload.id);
|
|
}
|
|
}, [activeDownload, dismissDownload]);
|
|
|
|
if (!activeDownload) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={containerStyle} pointerEvents="box-none">
|
|
<View style={styles.toast}>
|
|
{activeDownload.status === "downloading" ? (
|
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
|
) : null}
|
|
{activeDownload.status === "complete" ? (
|
|
<Check size={18} color={theme.colors.primary} />
|
|
) : null}
|
|
{activeDownload.status !== "downloading" && activeDownload.status !== "complete" ? (
|
|
<XCircle size={18} color={theme.colors.destructive} />
|
|
) : null}
|
|
<View style={styles.textContainer}>
|
|
<Text style={styles.fileName} numberOfLines={1}>
|
|
{activeDownload.fileName}
|
|
</Text>
|
|
<Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
|
|
{activeDownload.status === "downloading" && activeDownload.progress && (
|
|
<View style={styles.progressBar}>
|
|
<ProgressFill percent={activeDownload.progress.percent} />
|
|
</View>
|
|
)}
|
|
</View>
|
|
{activeDownload.status !== "downloading" && (
|
|
<Pressable onPress={handleDismiss} hitSlop={8} style={styles.dismiss}>
|
|
<X size={16} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProgressFill({ percent }: { percent: number }) {
|
|
const width: `${number}%` = `${Math.round(percent * 100)}%`;
|
|
const fillStyle = useMemo(() => [styles.progressFill, { width }], [width]);
|
|
return <View style={fillStyle} />;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
position: "absolute",
|
|
left: theme.spacing[4],
|
|
right: theme.spacing[4],
|
|
zIndex: 1000,
|
|
},
|
|
toast: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[3],
|
|
backgroundColor: theme.colors.surface2,
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
paddingVertical: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
...theme.shadow.md,
|
|
},
|
|
textContainer: {
|
|
flex: 1,
|
|
gap: theme.spacing[1],
|
|
},
|
|
fileName: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
status: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
progressBar: {
|
|
height: 3,
|
|
backgroundColor: theme.colors.surface2,
|
|
borderRadius: theme.borderRadius.full,
|
|
marginTop: theme.spacing[1],
|
|
overflow: "hidden",
|
|
},
|
|
progressFill: {
|
|
height: "100%",
|
|
backgroundColor: theme.colors.primary,
|
|
borderRadius: theme.borderRadius.full,
|
|
},
|
|
dismiss: {
|
|
padding: theme.spacing[1],
|
|
},
|
|
}));
|