diff --git a/packages/app-expo/src/lib/platform/mobile-cache.ts b/packages/app-expo/src/lib/platform/mobile-cache.ts new file mode 100644 index 00000000..15122ad7 --- /dev/null +++ b/packages/app-expo/src/lib/platform/mobile-cache.ts @@ -0,0 +1,68 @@ +import { Directory, File, Paths } from "expo-file-system"; + +export interface ClearMobileCacheResult { + deletedFiles: number; + deletedBytes: number; +} + +function getFileSize(file: File): number { + try { + return file.info().size ?? 0; + } catch { + return 0; + } +} + +function deleteEntry(entry: File | Directory): ClearMobileCacheResult { + if (entry instanceof File) { + const deletedBytes = getFileSize(entry); + entry.delete(); + return { deletedFiles: 1, deletedBytes }; + } + + let result: ClearMobileCacheResult = { deletedFiles: 0, deletedBytes: 0 }; + try { + for (const child of entry.list()) { + const childResult = deleteEntry(child); + result = { + deletedFiles: result.deletedFiles + childResult.deletedFiles, + deletedBytes: result.deletedBytes + childResult.deletedBytes, + }; + } + } catch { + // Still try deleting the directory itself below. + } + entry.delete(); + return result; +} + +export async function clearMobileRuntimeCache(): Promise { + const cacheDir = new Directory(Paths.cache); + if (!cacheDir.exists) { + cacheDir.create({ idempotent: true, intermediates: true }); + return { deletedFiles: 0, deletedBytes: 0 }; + } + + let result: ClearMobileCacheResult = { deletedFiles: 0, deletedBytes: 0 }; + for (const entry of cacheDir.list()) { + try { + const entryResult = deleteEntry(entry); + result = { + deletedFiles: result.deletedFiles + entryResult.deletedFiles, + deletedBytes: result.deletedBytes + entryResult.deletedBytes, + }; + } catch (error) { + console.warn("[mobile-cache] Failed to delete cache entry:", entry.uri, error); + } + } + + cacheDir.create({ idempotent: true, intermediates: true }); + return result; +} + +export function formatCacheSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} diff --git a/packages/app-expo/src/screens/ProfileScreen.tsx b/packages/app-expo/src/screens/ProfileScreen.tsx index 41eedc87..bc19ebce 100644 --- a/packages/app-expo/src/screens/ProfileScreen.tsx +++ b/packages/app-expo/src/screens/ProfileScreen.tsx @@ -13,22 +13,21 @@ import { MessageSquareIcon, PaletteIcon, PuzzleIcon, + Trash2Icon, TypeIcon, Volume2Icon, } from "@/components/ui/Icon"; import { SyncButton } from "@/components/ui/SyncButton"; import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; +import { clearMobileRuntimeCache, formatCacheSize } from "@/lib/platform/mobile-cache"; +import { stopTTSPreview } from "@/lib/platform/tts-preview"; import { mergeCurrentSessionIntoDailyStats, mergeCurrentSessionIntoOverallStats, } from "@/lib/stats/live-reading-stats"; import type { RootStackParamList } from "@/navigation/RootNavigator"; -import { - formatCharacterCount, - formatCharactersPerMinute, - formatTimeLocalized, -} from "@/screens/stats/stats-utils"; -import { useReadingSessionStore } from "@/stores"; +import { formatCharacterCount, formatTimeLocalized } from "@/screens/stats/stats-utils"; +import { useReadingSessionStore, useTTSStore } from "@/stores"; import { type ThemeColors, fontSize, @@ -53,6 +52,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, + Alert, Linking, ScrollView, type StyleProp, @@ -65,6 +65,42 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; type Nav = NativeStackNavigationProp; +type ProfileMenuIcon = (props: { + size?: number; + color?: string; + strokeWidth?: number; +}) => React.ReactNode; +type ProfileMenuRoute = Extract< + keyof RootStackParamList, + | "AppearanceSettings" + | "FontSettings" + | "SyncSettings" + | "AISettings" + | "TTSSettings" + | "TranslationSettings" + | "Skills" + | "VectorModelSettings" + | "Feedback" + | "About" +>; +type ProfileMenuItem = + | { + icon: ProfileMenuIcon; + label: string; + route: ProfileMenuRoute; + showDot?: boolean; + } + | { + icon: ProfileMenuIcon; + label: string; + url: string; + } + | { + icon: ProfileMenuIcon; + label: string; + action: () => void; + disabled?: boolean; + }; const ICP_NUMBER = "粤ICP备2025444251号-2A"; const ICP_URL = "https://beian.miit.gov.cn/"; @@ -289,8 +325,10 @@ export function ProfileScreen() { const [dailyStats, setDailyStats] = useState([]); const [statsLoading, setStatsLoading] = useState(true); const [unreadFeedback, setUnreadFeedback] = useState(0); + const [clearingCache, setClearingCache] = useState(false); const saveCurrentSession = useReadingSessionStore((s) => s.saveCurrentSession); const currentSession = useReadingSessionStore((s) => s.currentSession); + const stopTTS = useTTSStore((s) => s.stop); const loadStats = useCallback(async () => { try { @@ -338,9 +376,50 @@ export function ProfileScreen() { [overall, dailyStats, currentSession], ); const isZh = i18n.language.startsWith("zh"); + const handleClearCache = useCallback(() => { + Alert.alert( + t("profile.clearCacheTitle", "清除缓存"), + t( + "profile.clearCacheConfirm", + "将清除临时导入文件、TTS 音频缓存和其他运行缓存。书籍、笔记、设置和同步数据不会被删除。", + ), + [ + { text: t("common.cancel", "取消"), style: "cancel" }, + { + text: t("profile.clearCacheAction", "清除缓存"), + style: "destructive", + onPress: async () => { + setClearingCache(true); + try { + stopTTS(); + stopTTSPreview(); + const result = await clearMobileRuntimeCache(); + Alert.alert( + t("profile.clearCacheDoneTitle", "缓存已清除"), + t("profile.clearCacheDoneDesc", { + count: result.deletedFiles, + size: formatCacheSize(result.deletedBytes), + }), + ); + } catch (error) { + console.error("[ProfileScreen] Failed to clear cache:", error); + Alert.alert( + t("profile.clearCacheFailedTitle", "清除失败"), + error instanceof Error + ? error.message + : t("profile.clearCacheFailedDesc", "请稍后重试。"), + ); + } finally { + setClearingCache(false); + } + }, + }, + ], + ); + }, [stopTTS, t]); // Settings menu — matching Tauri ProfilePage exactly - const menuSections = useMemo( + const menuSections = useMemo<{ title: string; items: ProfileMenuItem[] }[]>( () => [ { title: t("settings.general", "通用"), @@ -358,6 +437,19 @@ export function ProfileScreen() { { icon: CloudIcon, label: t("settings.sync", "同步"), route: "SyncSettings" as const }, ], }, + { + title: t("profile.storage", "存储"), + items: [ + { + icon: Trash2Icon, + label: clearingCache + ? t("profile.clearingCache", "清除中...") + : t("profile.clearCache", "清除缓存"), + action: handleClearCache, + disabled: clearingCache, + }, + ], + }, { title: t("settings.skills", "能力"), items: [ @@ -398,7 +490,7 @@ export function ProfileScreen() { ], }, ], - [t, i18n.language, unreadFeedback], + [t, i18n.language, unreadFeedback, clearingCache, handleClearCache], ); const booksRead = liveOverall?.totalBooks ?? 0; @@ -409,12 +501,6 @@ export function ProfileScreen() { ? formatCharacterCount(liveOverall.totalCharactersRead ?? 0, isZh) : formatCharacterCount(0, isZh); const streak = liveOverall?.currentStreak ?? 0; - const activeDays = liveOverall?.totalReadingDays ?? 0; - const totalSessions = liveOverall?.totalSessions ?? 0; - const avgSpeed = liveOverall - ? formatCharactersPerMinute(liveOverall.avgCharactersPerMinute ?? 0, isZh) - : formatCharactersPerMinute(0, isZh); - const longestStreak = liveOverall?.longestStreak ?? 0; const overviewCards = [ { key: "time", @@ -516,12 +602,18 @@ export function ProfileScreen() { {section.items.map((item, idx) => { const Icon = item.icon; - const itemKey = "route" in item ? item.route : item.url; + const itemKey = + "route" in item ? item.route : "url" in item ? item.url : item.label; const handlePress = () => { - if ("url" in item && item.url) { + if ("disabled" in item && item.disabled) { + return; + } + if ("action" in item && item.action) { + item.action(); + } else if ("url" in item && item.url) { Linking.openURL(item.url); } else if ("route" in item) { - nav.navigate(item.route as any); + nav.navigate(item.route); } }; return ( @@ -529,6 +621,7 @@ export function ProfileScreen() { key={itemKey} style={[s.menuItem, idx < section.items.length - 1 && s.menuItemBorder]} onPress={handlePress} + disabled={"disabled" in item && item.disabled} activeOpacity={0.7} > diff --git a/packages/core/src/i18n/locales/en/profile.json b/packages/core/src/i18n/locales/en/profile.json index ec8eb32e..b6870cbf 100644 --- a/packages/core/src/i18n/locales/en/profile.json +++ b/packages/core/src/i18n/locales/en/profile.json @@ -14,7 +14,17 @@ "avgDaily": "Daily Avg", "readingActivity": "Reading Activity", "viewDetails": "View Details", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "Storage", + "clearCache": "Clear Cache", + "clearingCache": "Clearing...", + "clearCacheTitle": "Clear Cache", + "clearCacheConfirm": "This will remove temporary import files, TTS audio cache, and other runtime cache. Books, notes, settings, and sync data will not be deleted.", + "clearCacheAction": "Clear Cache", + "clearCacheDoneTitle": "Cache Cleared", + "clearCacheDoneDesc": "Removed {{count}} cached file(s), freeing {{size}}.", + "clearCacheFailedTitle": "Failed to Clear Cache", + "clearCacheFailedDesc": "Please try again later." }, "skills": { "title": "Skills", diff --git a/packages/core/src/i18n/locales/es/profile.json b/packages/core/src/i18n/locales/es/profile.json index e9b9b2c1..96046670 100644 --- a/packages/core/src/i18n/locales/es/profile.json +++ b/packages/core/src/i18n/locales/es/profile.json @@ -14,7 +14,17 @@ "avgDaily": "Promedio diario", "readingActivity": "Actividad de lectura", "viewDetails": "Ver detalles", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "Almacenamiento", + "clearCache": "Limpiar caché", + "clearingCache": "Limpiando...", + "clearCacheTitle": "Limpiar caché", + "clearCacheConfirm": "Esto eliminará archivos temporales de importación, caché de audio TTS y otros cachés de ejecución. No se eliminarán libros, notas, ajustes ni datos de sincronización.", + "clearCacheAction": "Limpiar caché", + "clearCacheDoneTitle": "Caché limpiada", + "clearCacheDoneDesc": "Se eliminaron {{count}} archivo(s) en caché y se liberaron {{size}}.", + "clearCacheFailedTitle": "No se pudo limpiar la caché", + "clearCacheFailedDesc": "Inténtalo de nuevo más tarde." }, "skills": { "title": "Habilidades", diff --git a/packages/core/src/i18n/locales/fr/profile.json b/packages/core/src/i18n/locales/fr/profile.json index 92ef1e1b..140f262d 100644 --- a/packages/core/src/i18n/locales/fr/profile.json +++ b/packages/core/src/i18n/locales/fr/profile.json @@ -14,7 +14,17 @@ "avgDaily": "Moy. quotidienne", "readingActivity": "Activité de lecture", "viewDetails": "Voir les détails", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "Stockage", + "clearCache": "Vider le cache", + "clearingCache": "Nettoyage...", + "clearCacheTitle": "Vider le cache", + "clearCacheConfirm": "Cela supprimera les fichiers d'import temporaires, le cache audio TTS et les autres caches d'exécution. Les livres, notes, paramètres et données de synchronisation ne seront pas supprimés.", + "clearCacheAction": "Vider le cache", + "clearCacheDoneTitle": "Cache vidé", + "clearCacheDoneDesc": "{{count}} fichier(s) en cache supprimé(s), {{size}} libérés.", + "clearCacheFailedTitle": "Échec du nettoyage", + "clearCacheFailedDesc": "Veuillez réessayer plus tard." }, "skills": { "title": "Compétences", diff --git a/packages/core/src/i18n/locales/ja/profile.json b/packages/core/src/i18n/locales/ja/profile.json index 423070ad..db9ac3d9 100644 --- a/packages/core/src/i18n/locales/ja/profile.json +++ b/packages/core/src/i18n/locales/ja/profile.json @@ -14,7 +14,17 @@ "avgDaily": "日平均", "readingActivity": "読書アクティビティ", "viewDetails": "詳細を見る", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "ストレージ", + "clearCache": "キャッシュを削除", + "clearingCache": "削除中...", + "clearCacheTitle": "キャッシュを削除", + "clearCacheConfirm": "一時インポートファイル、TTS音声キャッシュ、その他の実行時キャッシュを削除します。書籍、ノート、設定、同期データは削除されません。", + "clearCacheAction": "キャッシュを削除", + "clearCacheDoneTitle": "キャッシュを削除しました", + "clearCacheDoneDesc": "{{count}}件のキャッシュファイルを削除し、{{size}}を解放しました。", + "clearCacheFailedTitle": "キャッシュの削除に失敗しました", + "clearCacheFailedDesc": "後でもう一度お試しください。" }, "skills": { "title": "スキル", diff --git a/packages/core/src/i18n/locales/ko/profile.json b/packages/core/src/i18n/locales/ko/profile.json index 23150980..303b6740 100644 --- a/packages/core/src/i18n/locales/ko/profile.json +++ b/packages/core/src/i18n/locales/ko/profile.json @@ -14,7 +14,17 @@ "avgDaily": "일 평균", "readingActivity": "독서 활동", "viewDetails": "상세 보기", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "저장 공간", + "clearCache": "캐시 삭제", + "clearingCache": "삭제 중...", + "clearCacheTitle": "캐시 삭제", + "clearCacheConfirm": "임시 가져오기 파일, TTS 오디오 캐시 및 기타 실행 캐시를 삭제합니다. 책, 노트, 설정 및 동기화 데이터는 삭제되지 않습니다.", + "clearCacheAction": "캐시 삭제", + "clearCacheDoneTitle": "캐시 삭제 완료", + "clearCacheDoneDesc": "캐시 파일 {{count}}개를 삭제하고 {{size}}를 확보했습니다.", + "clearCacheFailedTitle": "캐시 삭제 실패", + "clearCacheFailedDesc": "나중에 다시 시도해주세요." }, "skills": { "title": "스킬", diff --git a/packages/core/src/i18n/locales/zh-TW/profile.json b/packages/core/src/i18n/locales/zh-TW/profile.json index b4152d65..acc26286 100644 --- a/packages/core/src/i18n/locales/zh-TW/profile.json +++ b/packages/core/src/i18n/locales/zh-TW/profile.json @@ -14,7 +14,17 @@ "avgDaily": "日均", "readingActivity": "閱讀活動", "viewDetails": "查看詳情", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "儲存空間", + "clearCache": "清除快取", + "clearingCache": "清除中...", + "clearCacheTitle": "清除快取", + "clearCacheConfirm": "將清除暫存匯入檔案、TTS 音訊快取和其他執行快取。書籍、筆記、設定和同步資料不會被刪除。", + "clearCacheAction": "清除快取", + "clearCacheDoneTitle": "快取已清除", + "clearCacheDoneDesc": "已清除 {{count}} 個快取檔案,釋放 {{size}}。", + "clearCacheFailedTitle": "清除失敗", + "clearCacheFailedDesc": "請稍後再試。" }, "skills": { "title": "技能管理", diff --git a/packages/core/src/i18n/locales/zh/profile.json b/packages/core/src/i18n/locales/zh/profile.json index e384ce16..a0f529f2 100644 --- a/packages/core/src/i18n/locales/zh/profile.json +++ b/packages/core/src/i18n/locales/zh/profile.json @@ -14,7 +14,17 @@ "avgDaily": "日均", "readingActivity": "阅读活动", "viewDetails": "查看详情", - "version": "ReadAny Mobile v{{version}}" + "version": "ReadAny Mobile v{{version}}", + "storage": "存储", + "clearCache": "清除缓存", + "clearingCache": "清除中...", + "clearCacheTitle": "清除缓存", + "clearCacheConfirm": "将清除临时导入文件、TTS 音频缓存和其他运行缓存。书籍、笔记、设置和同步数据不会被删除。", + "clearCacheAction": "清除缓存", + "clearCacheDoneTitle": "缓存已清除", + "clearCacheDoneDesc": "已清除 {{count}} 个缓存文件,释放 {{size}}。", + "clearCacheFailedTitle": "清除失败", + "clearCacheFailedDesc": "请稍后重试。" }, "skills": { "title": "技能管理",