Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/app-expo/src/lib/platform/mobile-cache.ts
Original file line number Diff line number Diff line change
@@ -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<ClearMobileCacheResult> {
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`;
}
127 changes: 110 additions & 17 deletions packages/app-expo/src/screens/ProfileScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -53,6 +52,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
Linking,
ScrollView,
type StyleProp,
Expand All @@ -65,6 +65,42 @@ import {
import { SafeAreaView } from "react-native-safe-area-context";

type Nav = NativeStackNavigationProp<RootStackParamList>;
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/";
Expand Down Expand Up @@ -289,8 +325,10 @@ export function ProfileScreen() {
const [dailyStats, setDailyStats] = useState<DailyStats[]>([]);
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 {
Expand Down Expand Up @@ -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", "通用"),
Expand All @@ -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: [
Expand Down Expand Up @@ -398,7 +490,7 @@ export function ProfileScreen() {
],
},
],
[t, i18n.language, unreadFeedback],
[t, i18n.language, unreadFeedback, clearingCache, handleClearCache],
);

const booksRead = liveOverall?.totalBooks ?? 0;
Expand All @@ -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",
Expand Down Expand Up @@ -516,19 +602,26 @@ export function ProfileScreen() {
<View style={s.menuCard}>
{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 (
<TouchableOpacity
key={itemKey}
style={[s.menuItem, idx < section.items.length - 1 && s.menuItemBorder]}
onPress={handlePress}
disabled={"disabled" in item && item.disabled}
activeOpacity={0.7}
>
<Icon size={20} color={colors.mutedForeground} />
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/i18n/locales/en/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/i18n/locales/es/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/i18n/locales/fr/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/i18n/locales/ja/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "スキル",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/i18n/locales/ko/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "스킬",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/i18n/locales/zh-TW/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "技能管理",
Expand Down
Loading