From 1764f80e6cd454931f28f57cb7dc0e80e6c6d1f0 Mon Sep 17 00:00:00 2001 From: YspritanHyzygy <176721281+YspritanHyzygy@users.noreply.github.com> Date: Sat, 16 May 2026 21:07:07 -0400 Subject: [PATCH] Add Simplified Chinese localization --- .gitignore | 11 +- android/app/build.gradle.kts | 16 +- assets/additional_resources.json | 4 +- assets/translations/languages.json | 5 + assets/translations/zh-Hans.json | 161 ++++++++++++++++++++ lib/engine.dart | 2 - lib/pages/chat.dart | 10 +- lib/pages/chats.dart | 4 +- lib/pages/intro.dart | 12 +- lib/pages/settings.dart | 12 +- lib/pages/settings/chat.dart | 12 +- lib/pages/settings/context.dart | 4 +- lib/pages/settings/logs.dart | 8 +- lib/pages/settings/model.dart | 4 +- lib/pages/settings/prompt_editor.dart | 2 +- lib/pages/settings/prompt_viewer.dart | 10 +- lib/pages/settings/prompts.dart | 4 +- lib/pages/settings/resources.dart | 6 +- lib/pages/support/elements.dart | 8 +- lib/parts/translator.dart | 209 +++++++++++++++++++------- lib/storage/prompt_repository.dart | 30 ++++ lib/storage/resource_repository.dart | 71 ++++++++- pubspec.yaml | 3 +- test/widget_test.dart | 74 ++++++--- 24 files changed, 541 insertions(+), 141 deletions(-) create mode 100644 assets/translations/zh-Hans.json diff --git a/.gitignore b/.gitignore index 972083d..5f174f8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ .dart_tool/ .packages build/ +release/ +*.apk +*.aab # If you're building an application, you may want to check-in your pubspec.lock pubspec.lock @@ -28,12 +31,8 @@ doc/api/ lib/firebase_options.dart *.exe android/app/google-services.json -.idea/caches/deviceStreaming.xml -.idea/caches/deviceStreaming.xml -.idea/deviceManager.xml +.idea/caches/ firebase.json .idea/deviceManager.xml -android/.kotlin/sessions/kotlin-compiler-11069125661880765564.salive -.idea/caches/deviceStreaming.xml -.idea/deviceManager.xml +android/.kotlin/ /.tmp.driveupload diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9c09a3d..38d1a64 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -49,17 +49,23 @@ android { signingConfigs { create("release") { - keyAlias = keystoreProperties["keyAlias"] as String - keyPassword = keystoreProperties["keyPassword"] as String - storeFile = keystoreProperties["storeFile"]?.let { file(it) } - storePassword = keystoreProperties["storePassword"] as String + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String + } } } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("release") + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } debug { signingConfig = signingConfigs.getByName("debug") diff --git a/assets/additional_resources.json b/assets/additional_resources.json index 76c3292..eb6e02c 100644 --- a/assets/additional_resources.json +++ b/assets/additional_resources.json @@ -33,7 +33,7 @@ "type": "link", "collection": "appdocs", "name": "Application's Google Store page", - "value": "https://play.google.com/store/apps/details?id=page.puzzak.geminilocal" + "value": "https://play.google.com/store/apps/details?id=page.puzzak.paios" }, { "type": "link", @@ -41,4 +41,4 @@ "name": "Developer's Website", "value": "https://puzzak.page" } -] \ No newline at end of file +] diff --git a/assets/translations/languages.json b/assets/translations/languages.json index 3b73c4f..83a5594 100644 --- a/assets/translations/languages.json +++ b/assets/translations/languages.json @@ -14,6 +14,11 @@ "name": "Traditional Chinese", "id": "zh" }, + { + "origin": "简体中文", + "name": "Simplified Chinese", + "id": "zh-Hans" + }, { "origin": "Deutsch", "name": "German", diff --git a/assets/translations/zh-Hans.json b/assets/translations/zh-Hans.json new file mode 100644 index 0000000..b23d0f6 --- /dev/null +++ b/assets/translations/zh-Hans.json @@ -0,0 +1,161 @@ +{ + "add_lang": "与 AI 分享应用语言", + "add_lang_desc": "让模型使用该语言回答", + "add_time": "告诉模型日期和时间", + "add_time_desc": "将日期和时间加入指令", + "ai_may_not_differ_prompt_and_instructions": "模型可能无法区分提示词和指令。", + "appdocs": "应用链接", + "available": "可用", + "cancel_generate": "停止生成", + "clear_context": "删除聊天", + "context_desc": "~%c 个 Token", + "context_title": "上下文大小", + "continue": "继续", + "current_language": "简体中文", + "documentation": "ML Kit GenAI 文档", + "downloading_model": "正在下载模型", + "downloading_model_error": "无法下载模型", + "error_retry": "出错时重试", + "error_retry_desc": "生成出错时自动重试", + "generate": "生成", + "generated_hint": "已在 %seconds% 秒内回复,使用 %tokens% 个 Token(%tokenspersec% Token/秒)。", + "generating_hint": "正在生成,已用时 %seconds% 秒,使用 %tokens% 个 Token(%tokenspersec% Token/秒)。", + "gh_repo": "GitHub 仓库", + "ignore_instructions": "忽略所有指令", + "ignore_instructions_desc": "输出可能会变得很奇怪", + "in_play_store": "在 Play 商店中", + "instructions": "用户指令", + "instructions_desc": "系统提示词描述了模型的行为。你无法直接修改它,但可以在 GitHub 上建议更改。\n\n其中包含用户提示词,你可以编辑它。这会根据你的偏好微调模型行为。", + "language_settings": "语言", + "long_tap_clear": "长按确认", + "long_tap_cleared": "上下文已清除!", + "mock_gemini_1": "你好!我是 Flan-T5,一个 100% 在你的设备本地运行的 AI 模型。因为我始终在本机工作,所以你的数据会保持私密,并且我可以离线运行。", + "mock_gemini_2": "我可以帮助你完成各种任务,例如总结文本、头脑风暴、编写代码或回答复杂问题。", + "mock_gemini_3": "量子计算是一种利用量子物理来解决普通计算机难以处理的问题的计算方式。这个话题很有意思,虽然确实有点难理解!", + "mock_user_1": "你好!你是谁?", + "mock_user_2": "有意思。你能做什么?", + "mock_user_3": "好的,用一句简单的话解释“量子计算”。", + "model_available": "你可以使用 %s 模型", + "modeldocs": "模型文档", + "no_context_yet": "AI 早期访问版本。模型可能会出错,也可能不回复。", + "open_aicore_settings": "打开 AICore 设置", + "prompt": "在这里输入消息", + "prompt_hint": "例如:“介绍一下你自己。”", + "recommend_changes_gh": "你可以在 GitHub 仓库中建议更改", + "reset_model_context": "清除上下文", + "reset_model_params": "重置参数", + "reset_model_params_desc": "温度、Token", + "reset_model_prompt": "清除提示词", + "reset_model_prompt_desc": "不添加任何指令", + "reset_model_settings": "重置选项", + "select_language": "选择应用语言", + "select_language_auto_long": "或使用自动检测", + "settings": "设置", + "settings_ai": "模型参数", + "settings_ai_desc": "指令、参数、重置选项", + "settings_app": "应用设置", + "settings_info": "应用仍在开发中。请在 GitHub 上报告错误、翻译问题并留下任何反馈。\n\n由 Puzzak 于 %year% 年在乌克兰开发。\n\n荣耀归于乌克兰!", + "settings_resources": "附加资源", + "settings_resources_desc": "文档、链接、信息", + "settings_status": "模型状态", + "share": "分享", + "shared_data": "附加数据", + "system_prompt": "模型指令", + "system_prompt_desc": "系统提示词、指令", + "tap_to_open": "点按打开", + "temperature": "温度", + "title": "PAIOS", + "tokens": "每次回复的 Token 数", + "unavailable": "不可用", + "waiting_engine": "正在检查模型可用性", + "waiting_for_AI": "正在等待模型开始生成。如果这条消息显示时间超过预期,可能是 ML Kit 发生了错误,或者你已达到配额限制(这种情况下请等待一分钟后再试)。", + "waiting_network": "AI Core 正在等待 WiFi", + "welcome": "模拟聊天。\n请记住,AI 可能会出错,也可能忽略你的请求。可以在设置中查看更多信息。\n希望你喜欢这次体验 - Puzzak :)", + "welcome_available": "这些设置是新聊天的默认值,你也可以为每个聊天单独配置。", + "welcome_download": "AI Core 正在下载模型,下载完成后你就可以离线使用它。通常模型大小约为 %size%,但具体取决于设备和模型版本。\n下载完成后,AI Core 会在后台管理并更新模型。下载需要 WiFi。", + "welcome_gtfo": "模型正在后台下载。你可以关闭应用。\n如果关闭应用,将无法查看精确进度(只能看到正在下载、错误等状态)。", + "welcome_unavailable": "很遗憾,你的设备可能不支持此模型,或者应用无法使用 Google AICore。要查看更多信息、排查步骤或报告问题,请访问 GitHub 仓库。\n\n有关模型可用性的更多信息,请访问 MLKit GenAI 文档。", + "whoops": "点按检查 AI Core", + "years": "年", + "year": "年", + "months": "个月", + "month": "个月", + "days": "天", + "day": "天", + "hours": "小时", + "hour": "小时", + "minutes": "分钟", + "minute": "分钟", + "updated": "已更新", + "created": "已创建", + "ago": "前", + "just_now": "刚刚", + "chats": "聊天", + "your_chats": "聊天", + "other_chats": "其他聊天", + "pinned_chats": "已置顶", + "pinned_chats_desc": "如需置顶聊天,请前往该聊天的设置", + "new_chat": "新聊天", + "chat_settings": "聊天设置", + "chat_settings_other": "其他聊天设置", + "chat_settings_desc": "这些设置仅适用于当前聊天。要编辑全局参数,请打开设置。", + "chat_settings_subtitle": "前往设置", + "chat_name": "聊天名称", + "name_wrong": "无法保存此名称", + "previous_names": "之前的聊天名称", + "change_name": "点按更改名称", + "edit_name": "更改名称", + "pin_chat": "置顶聊天", + "pin_chat_desc": "固定到列表顶部", + "generating_title": "正在生成新名称...", + "loading": "正在加载...", + "messages": "条消息", + "no_chats": "你还没有开始任何聊天。按下方按钮开始。", + "chats_desc": "你有 %chatnum% 个聊天。点按“新聊天”按钮开始新的聊天。", + "still_generating": "AI 仍在生成。请等待这条消息更新后再与其他聊天互动。", + "analytics_title": "分析", + "analytics": "允许分析", + "analytics_desc": "收集匿名使用情况和应用问题数据", + "logs_with_analytics": "日志和分析", + "logs_no_analytics": "日志", + "logs_info_local": "这些是应用日志,仅保存在本地。本地日志不会在应用重启之间保留,它们用于帮助你了解后台正在发生什么。如果你愿意帮助我改进应用,可以启用分析。", + "logs_info_analytics": "这些是应用日志,会与开发者共享。本地日志不会在应用重启之间保留,它们用于帮助你了解后台正在发生什么。数据是匿名的,仅用于衡量应用指标和崩溃情况,以便改进应用。聊天名称、消息或任何可识别信息都不会发送到分析系统。感谢你帮助这个应用变得更好!", + "advancedlinks": "高级用户", + "New for 1.2.0": "以下部分包含 1.1.5 到 1.2.0 版本的字符串", + "create_prompt_custom": "创建自定义提示词", + "new_prompt_name": "新提示词", + "prompt_manager_title": "提示词管理器", + "default_prompts_title": "默认提示词", + "by_author": "作者:%author%", + "last_updated": "最后更新", + "close": "关闭", + "clone_prompt": "克隆", + "user_prompts_title": "用户提示词", + "no_user_prompts": "没有自定义提示词", + "no_user_prompts_desc": "点按此处创建一个", + "prompt_name": "提示词名称", + "prompt_content_hint": "在这里输入你的系统指令...", + "chat_prompt": "聊天提示词", + "select_prompt": "选择提示词", + "save": "保存", + "saved": "已保存!", + "delete": "删除", + "try_prompt": "试用此提示词", + "edit": "编辑", + "prompt_md_title": "支持 Markdown", + "prompt_md_desc": "你可以在提示词中使用 Markdown 格式。AI 会看到纯文本,但查看器会渲染格式。", + "prompt_md_docs_link": "GitHub Markdown 语法参考", + "prompt_manager_info": "选中的提示词会作为新聊天的系统指令。在此处更改不会影响现有对话。\n\n无论你选择哪个提示词,以下部分都会自动追加:\n• [SYSTEM INSTRUCTIONS]:包裹你的提示词文本\n• [CONTEXTUAL DATA]:当前时间和语言(如果已在聊天设置中启用)\n• [DATA & INSTRUCTION RULES]:根据上下文数据设置生成的规则\n• [CHAT HISTORY]:用于多轮回复的对话上下文", + "model_continuing": "正在继续回答...", + "new_chat_hold": "长按可在开始前配置", + "configure_new_chat": "配置此聊天", + "temperature_desc": "%value%,默认值为 0.7", + "prompt_description": "描述", + "prompt_author": "作者", + "prompt_last_updated": "最后更新", + "prompt_empty": "此提示词为空。", + "export_prompt": "导出为 .md", + "prompt_dir_title": "提示词文件目录", + "prompt_dir_none": "点按选择文件夹", + "prompt_dir_desc": "你可以将 .md 提示词保存到所选文件夹,以便在应用内使用,也可以在那里添加新提示词。" +} diff --git a/lib/engine.dart b/lib/engine.dart index ea68e34..41a3c34 100644 --- a/lib/engine.dart +++ b/lib/engine.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; @@ -636,7 +635,6 @@ class AIEngine with md.ChangeNotifier { status = "Sending prompt..."; notifyListeners(); - final int runTokens = chatData.chats.containsKey(currentChat) && chatData.chats[currentChat].containsKey("chatTokens") ? chatData.chats[currentChat]["chatTokens"] : tokens; double runTemperature = chatData.chats.containsKey(currentChat) && chatData.chats[currentChat].containsKey("chatTemperature") ? chatData.chats[currentChat]["chatTemperature"] : temperature; final stream = gemini.generateTextEvents( diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart index 45aa493..082a1a8 100644 --- a/lib/pages/chat.dart +++ b/lib/pages/chat.dart @@ -12,8 +12,8 @@ class ChatPage extends StatefulWidget { } class ChatPageState extends State { - text tWid = text(); - @override + final TextBlocks textBlocks = TextBlocks(); + @override void initState() { super.initState(); @@ -104,7 +104,7 @@ class ChatPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( - children: tWid.chatlog( + children: textBlocks.chatlog( conversation: [ { "user": "User", @@ -136,7 +136,7 @@ class ChatPageState extends State { lastUser: "" ), ), - text.infoShort( + TextBlocks.infoShort( title: engine.dict.value("welcome"), context: context, subtitle: "", @@ -146,7 +146,7 @@ class ChatPageState extends State { ) : Column( children: [ - ...tWid.chatlog( + ...textBlocks.chatlog( conversation: engine.context, context: context, aiChunk: engine.responseText, diff --git a/lib/pages/chats.dart b/lib/pages/chats.dart index 72a0d39..0e020f1 100644 --- a/lib/pages/chats.dart +++ b/lib/pages/chats.dart @@ -273,7 +273,7 @@ class ChatsPageState extends State { }).toList().whereNot((crd) => crd == null).cast(), ] ), - text.info( + TextBlocks.info( title: engine.isLoading?engine.dict.value("still_generating"):engine.dict.value(engine.chats.isEmpty?"no_chats":"chats_desc").replaceAll("%chatnum%", engine.chats.length.toString()), subtitle: engine.chats.isEmpty?engine.dict.value("new_chat"):"", action: engine.chats.isNotEmpty?(){}:engine.isLoading?(){}:(){ @@ -303,4 +303,4 @@ class ChatsPageState extends State { ) ); } -} \ No newline at end of file +} diff --git a/lib/pages/intro.dart b/lib/pages/intro.dart index 8dc6368..43a06a7 100644 --- a/lib/pages/intro.dart +++ b/lib/pages/intro.dart @@ -220,7 +220,7 @@ class IntroPageState extends State { ]), if(engine.modelDownloadLog.isNotEmpty) if(engine.modelDownloadLog[engine.modelDownloadLog.length-1]["info"] == "downloading_model") - text.infoShort( + TextBlocks.infoShort( title: engine.dict.value("welcome_gtfo"), subtitle: "", action: () {}, @@ -257,9 +257,11 @@ class IntroPageState extends State { title: language["origin"], subtitle: language["name"] == language["origin"] ? "" : language["name"], action: () async { + final navigator = Navigator.of(dialogContext); await engine.dict.saveLanguage(language["id"]); + if (!mounted) return; setState(() {}); - Navigator.of(dialogContext).pop(); + navigator.pop(); } ); }).toList().cast() @@ -303,7 +305,7 @@ class IntroPageState extends State { SizedBox(height: 20,), if(engine.modelDownloadLog.isNotEmpty) if(engine.modelDownloadLog[engine.modelDownloadLog.length-1]["status"] == "Download") - text.info( + TextBlocks.info( title: engine.dict.value("welcome_download").replaceAll("%size%", convertSize(engine.usualModelSize, false)), subtitle: "", action: () {}, @@ -312,7 +314,7 @@ class IntroPageState extends State { if(engine.modelDownloadLog.isNotEmpty) if(!(engine.modelDownloadLog[engine.modelDownloadLog.length-1]["status"] == "Download")) - text.info( + TextBlocks.info( title: engine.modelInfo["version"] == null ? engine.dict.value("welcome_unavailable") : engine.modelInfo["version"] == "Unknown" @@ -343,4 +345,4 @@ class IntroPageState extends State { ); }); } -} \ No newline at end of file +} diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index f65aa73..80564f6 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -4,10 +4,8 @@ import 'package:geminilocal/pages/settings/prompts.dart'; import 'package:geminilocal/pages/settings/resources.dart'; import 'package:geminilocal/storage/file_access_service.dart'; import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../engine.dart'; import 'support/elements.dart'; -import 'package:intl/intl.dart'; import 'package:geminilocal/pages/settings/model.dart'; @@ -91,9 +89,11 @@ class SettingsPageState extends State { title: language["origin"], subtitle: language["name"] == language["origin"] ? "" : language["name"], action: () async { + final navigator = Navigator.of(dialogContext); await engine.dict.saveLanguage(language["id"]); + if (!mounted) return; setState(() {}); - Navigator.of(dialogContext).pop(); + navigator.pop(); } ); }).toList().cast() @@ -186,7 +186,7 @@ class SettingsPageState extends State { ), CardContents.tapIcon( title: engine.dict.value("prompt_manager_title"), - subtitle: engine.promptData.getPromptName(engine.config.defaultPromptId), + subtitle: engine.promptData.getPromptDisplayName(engine.config.defaultPromptId, engine.dict.locale), icon: Icons.edit_note_rounded, colorBG: Theme.of(context).colorScheme.primaryFixedDim, color: Theme.of(context).colorScheme.onPrimaryFixed, @@ -212,7 +212,7 @@ class SettingsPageState extends State { } ), ]), - text.info( + TextBlocks.info( title: engine.dict.value("prompt_dir_desc"), context: context, subtitle: "", @@ -230,4 +230,4 @@ class SettingsPageState extends State { ) ); } -} \ No newline at end of file +} diff --git a/lib/pages/settings/chat.dart b/lib/pages/settings/chat.dart index 98cc28c..772646b 100644 --- a/lib/pages/settings/chat.dart +++ b/lib/pages/settings/chat.dart @@ -234,7 +234,7 @@ class ChatSettingsPageState extends State { cards.cardGroup([ CardContents.tap( title: engine.dict.value("chat_prompt"), - subtitle: engine.promptData.getPromptName(engine.chats[engine.currentChat]?["promptId"] ?? engine.config.defaultPromptId), + subtitle: engine.promptData.getPromptDisplayName(engine.chats[engine.currentChat]?["promptId"] ?? engine.config.defaultPromptId, engine.dict.locale), action: () { showDialog( context: context, @@ -246,8 +246,8 @@ class ChatSettingsPageState extends State { child: cards.cardGroup([ ...engine.promptData.defaultPrompts.keys.map((key) { return CardContents.halfTap( - title: engine.promptData.defaultPrompts[key]["name"] ?? "Default", - subtitle: "System", + title: engine.promptData.getPromptDisplayName(key, engine.dict.locale), + subtitle: engine.dict.value("system_prompt"), action: () { setState(() { engine.chats[engine.currentChat]!["promptId"] = key; @@ -260,7 +260,7 @@ class ChatSettingsPageState extends State { ...engine.promptData.userPrompts.keys.map((key) { return CardContents.halfTap( title: engine.promptData.userPrompts[key]["name"] ?? "Custom", - subtitle: "User", + subtitle: engine.dict.value("user_prompts_title"), action: () { setState(() { engine.chats[engine.currentChat]!["promptId"] = key; @@ -332,7 +332,7 @@ class ChatSettingsPageState extends State { value: engine.chats[engine.currentChat]?["shareLocale"] ?? engine.shareLocale ), ]), - text.info( + TextBlocks.info( title: engine.dict.value("chat_settings_desc"), subtitle: engine.dict.value("chat_settings_subtitle"), action: (){ @@ -355,4 +355,4 @@ class ChatSettingsPageState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/settings/context.dart b/lib/pages/settings/context.dart index 892f9c8..809c033 100644 --- a/lib/pages/settings/context.dart +++ b/lib/pages/settings/context.dart @@ -92,7 +92,7 @@ class ModelSettingsContextState extends State { data: engine.testPrompt.split("replaceme")[1], ), ), - text.info( + TextBlocks.info( title: engine.dict.value("instructions_desc"), context: context, subtitle: engine.dict.value("recommend_changes_gh"), @@ -115,4 +115,4 @@ class ModelSettingsContextState extends State { ) ); } -} \ No newline at end of file +} diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 1c1ce3a..8c9fa81 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -1,10 +1,6 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:provider/provider.dart'; import '../../engine.dart'; -import '../settings.dart'; import '../support/elements.dart'; @@ -105,7 +101,7 @@ class LogsPageState extends State { ); }).toList().reversed.toList(), ), - text.info( + TextBlocks.info( title: engine.dict.value(engine.analytics?"logs_info_analytics":"logs_info_local"), subtitle: "", action: (){}, @@ -122,4 +118,4 @@ class LogsPageState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/settings/model.dart b/lib/pages/settings/model.dart index 2d6ee6c..cf702f0 100644 --- a/lib/pages/settings/model.dart +++ b/lib/pages/settings/model.dart @@ -101,7 +101,7 @@ class ModelSettingsState extends State { value: engine.shareLocale ), ]), - text.info( + TextBlocks.info( title: engine.dict.value("welcome_available"), context: context, subtitle: "", @@ -119,4 +119,4 @@ class ModelSettingsState extends State { ) ); } -} \ No newline at end of file +} diff --git a/lib/pages/settings/prompt_editor.dart b/lib/pages/settings/prompt_editor.dart index f0bc3c7..70d2e14 100644 --- a/lib/pages/settings/prompt_editor.dart +++ b/lib/pages/settings/prompt_editor.dart @@ -279,7 +279,7 @@ class PromptEditorPageState extends State { }, ) ), - text.infoShort( + TextBlocks.infoShort( title: engine.dict.value("prompt_md_desc"), subtitle: engine.dict.value("prompt_md_docs_link"), action: () async { diff --git a/lib/pages/settings/prompt_viewer.dart b/lib/pages/settings/prompt_viewer.dart index d479118..93fb38b 100644 --- a/lib/pages/settings/prompt_viewer.dart +++ b/lib/pages/settings/prompt_viewer.dart @@ -62,7 +62,9 @@ class PromptViewerPageState extends State { ? engine.promptData.userPrompts[widget.promptId] ?? {} : engine.promptData.defaultPrompts[widget.promptId] ?? {}; - final String? description = isUserPrompt ? null : promptMeta["description"] as String?; + final String? description = isUserPrompt + ? null + : engine.promptData.getPromptDisplayDescription(widget.promptId, engine.dict.locale); final String? author = isUserPrompt ? null : promptMeta["author"] as String?; final String updatedStr = _formatTimestamp(promptMeta["updated"]); @@ -76,7 +78,7 @@ class PromptViewerPageState extends State { ), ), surfaceTintColor: Colors.transparent, - title: Text(engine.promptData.getPromptName(widget.promptId)), + title: Text(engine.promptData.getPromptDisplayName(widget.promptId, engine.dict.locale)), actions: [ // Export button — always visible IconButton( @@ -121,7 +123,7 @@ class PromptViewerPageState extends State { engine.currentChat = "testing"; engine.contextSize = 0; engine.context.clear(); - engine.chats["testing"] = {"promptId": widget.promptId, "name": "Testing Prompt"}; + engine.chats["testing"] = {"promptId": widget.promptId, "name": engine.dict.value("try_prompt")}; Navigator.push( context, MaterialPageRoute(builder: (context) => ChatPage(), @@ -154,7 +156,7 @@ class PromptViewerPageState extends State { ), if ((description != null && description.isNotEmpty) && (author != null && author.isNotEmpty) && updatedStr.isNotEmpty) ...[ SliverToBoxAdapter( - child: text.info( + child: TextBlocks.info( title: "$description\n${engine.dict.value("by_author").replaceAll("%author%", author)}\n${engine.dict.value("prompt_last_updated")}: $updatedStr", context: context, subtitle: "", diff --git a/lib/pages/settings/prompts.dart b/lib/pages/settings/prompts.dart index 87a11f9..cd0f006 100644 --- a/lib/pages/settings/prompts.dart +++ b/lib/pages/settings/prompts.dart @@ -66,7 +66,7 @@ class PromptsPageState extends State { Map prompt = engine.promptData.defaultPrompts[key]; bool isSelected = (key == activeId); return CardContents.doubleTap( - title: prompt["name"] ?? "System Default", + title: engine.promptData.getPromptDisplayName(key, engine.dict.locale), subtitle: engine.dict.value("by_author").replaceAll("%author%", prompt["author"] ?? "Google"), action: () { Navigator.push( @@ -129,7 +129,7 @@ class PromptsPageState extends State { } ) ]), - text.infoShort( + TextBlocks.infoShort( title: engine.dict.value("prompt_manager_info"), subtitle: "", action: () {}, diff --git a/lib/pages/settings/resources.dart b/lib/pages/settings/resources.dart index 5d6c9bc..371de35 100644 --- a/lib/pages/settings/resources.dart +++ b/lib/pages/settings/resources.dart @@ -58,7 +58,7 @@ class SettingsResourcesState extends State { cards.cardGroup( engine.resourceData.grouped[collection]!.map((resource) { return CardContents.tap( - title: resource["name"] ?? "", + title: engine.resourceData.getResourceDisplayName(resource, engine.dict.locale), subtitle: (resource["value"] ?? "").toString().replaceFirst(RegExp(r'^https?://'), '').split('/')[0], action: () async { await launchUrl( @@ -73,7 +73,7 @@ class SettingsResourcesState extends State { ); }).toList(), ), - text.info( + TextBlocks.info( title: engine.dict.value("settings_info").replaceAll("%year%", DateFormat('yyyy').format(DateTime.now())), context: context, subtitle: engine.dict.value("gh_repo"), @@ -96,4 +96,4 @@ class SettingsResourcesState extends State { ) ); } -} \ No newline at end of file +} diff --git a/lib/pages/support/elements.dart b/lib/pages/support/elements.dart index 091f1ae..a693f59 100644 --- a/lib/pages/support/elements.dart +++ b/lib/pages/support/elements.dart @@ -603,7 +603,7 @@ class Category { -class text { +class TextBlocks { static Widget info({ required String title, required String subtitle, @@ -781,8 +781,8 @@ class text { } } if(line["user"] == "Gemini"){ - String AIMessage = line["message"]; - if(!(AIMessage == aiChunk) && !(AIMessage == "")) { + String aiMessage = line["message"]; + if(!(aiMessage == aiChunk) && !(aiMessage == "")) { splits.add( Row( mainAxisAlignment: MainAxisAlignment.start, @@ -813,7 +813,7 @@ class text { ); }, selectable: true, - data: AIMessage, + data: aiMessage, ), ), ), diff --git a/lib/parts/translator.dart b/lib/parts/translator.dart index 85b87be..2ad96d0 100644 --- a/lib/parts/translator.dart +++ b/lib/parts/translator.dart @@ -14,36 +14,138 @@ class Dictionary { String url = ""; Dictionary._internal(this.path, this.url); - factory Dictionary({required String path, required String url}){ + factory Dictionary({required String path, required String url}) { return Dictionary._internal(path, url); } + bool _hasLanguage(String id) { + return languages.any((language) => language is Map && language["id"] == id); + } + + List _decodeLanguageList(String raw) { + final decoded = jsonDecode(raw); + if (decoded is List) return decoded; + return []; + } + + Map _decodeDictionary(String raw) { + final decoded = jsonDecode(raw); + if (decoded is Map) return decoded; + return {}; + } + + void _mergeLanguages(List incoming) { + final merged = []; + + void addOrReplace(dynamic language) { + if (language is! Map || language["id"] == null) return; + final id = language["id"].toString(); + final normalizedLanguage = Map.from(language); + final existingIndex = merged.indexWhere((item) => item["id"] == id); + if (existingIndex >= 0) { + merged[existingIndex] = { + ...merged[existingIndex], + ...normalizedLanguage, + }; + } else { + merged.add(normalizedLanguage); + } + } + + for (final language in languages) { + addOrReplace(language); + } + for (final language in incoming) { + addOrReplace(language); + } + + languages = merged; + } + + String _resolveSystemLocale(String platformLocale) { + final normalized = platformLocale.replaceAll("-", "_"); + final parts = normalized + .split("_") + .where((part) => part.trim().isNotEmpty) + .toList(); + if (parts.isEmpty) return _hasLanguage("en") ? "en" : locale; + + final exactId = parts.join("-"); + if (_hasLanguage(exactId)) return exactId; + + final language = parts.first.toLowerCase(); + if (language == "zh") { + final markers = parts.skip(1).map((part) => part.toLowerCase()); + final wantsTraditional = markers.any( + (part) => part == "tw" || part == "hk" || part == "mo" || part == "hant", + ); + + if (wantsTraditional && _hasLanguage("zh")) return "zh"; + if (_hasLanguage("zh-Hans")) return "zh-Hans"; + if (_hasLanguage("zh")) return "zh"; + } + + if (_hasLanguage(language)) return language; + return _hasLanguage("en") ? "en" : locale; + } + + Future _loadBundledAndCachedDictionaries(Box box) async { + for (int i = 0; i < languages.length; i++) { + final language = languages[i]; + if (language is! Map || language["id"] == null) continue; + final langId = language["id"].toString(); + final mergedDict = {}; + + try { + final assetDict = await rootBundle.loadString('$path/$langId.json'); + mergedDict.addAll(_decodeDictionary(assetDict)); + } catch (e) { + // Fails silently if rootBundle doesn't have it (e.g. newly added lang) + } + + final cachedDict = box.get("cached_dict_$langId"); + if (cachedDict is String) { + try { + mergedDict.addAll(_decodeDictionary(cachedDict)); + } catch (e) { + if (kDebugMode) print("Failed to parse cached dictionary for $langId: $e"); + } + } + + if (mergedDict.isNotEmpty) { + dictionary[langId] = mergedDict; + } + } + } + decideLanguage() async { final box = Hive.box('paios_storage'); - if(box.containsKey("language")){ - locale = box.get("language", defaultValue: "en"); - }else{ - setSystemLanguage(); + if (box.containsKey("language")) { + final savedLocale = box.get("language", defaultValue: "en").toString(); + if (_hasLanguage(savedLocale)) { + locale = savedLocale; + } else { + await setSystemLanguage(); + } + } else { + await setSystemLanguage(); } } - + setSystemLanguage() async { final box = Hive.box('paios_storage'); // Remove the saved preference so decideLanguage() falls back to device locale on next boot await box.delete("language"); - String deviceLocale = Platform.localeName.split("_")[0]; - for(int a = 0; a < languages.length;a++){ - if(languages[a]["id"] == deviceLocale){ - locale = deviceLocale; - } - } + systemLanguage = true; + locale = _resolveSystemLocale(Platform.localeName); } - + saveLanguage(String variant) async { final box = Hive.box('paios_storage'); - for(int a = 0; a < languages.length;a++){ - if(languages[a]["id"] == variant){ + for (int a = 0; a < languages.length; a++) { + if (languages[a]["id"] == variant) { locale = variant; + systemLanguage = false; box.put("language", variant); } } @@ -51,52 +153,49 @@ class Dictionary { setup({Future Function(String, String, String)? log}) async { final box = Hive.box('paios_storage'); - - // 1. Triple-Tier System Step 1 & 2: Load Cache or Fallback Asset + + // Load bundled languages first so new built-in languages are never hidden by stale cache. + String assetLangList = await rootBundle.loadString('$path/languages.json'); + languages = _decodeLanguageList(assetLangList); + String? cachedLangList = box.get("cached_languages_json"); if (cachedLangList != null) { - languages = jsonDecode(cachedLangList); - } else { - String assetLangList = await rootBundle.loadString('$path/languages.json'); - languages = jsonDecode(assetLangList); - } - - await decideLanguage(); - - for(int i=0; i < languages.length; i++){ - String langId = languages[i]["id"]; - String? cachedDict = box.get("cached_dict_$langId"); - if (cachedDict != null) { - dictionary[langId] = jsonDecode(cachedDict); - } else { - try { - String assetDict = await rootBundle.loadString('$path/$langId.json'); - dictionary[langId] = jsonDecode(assetDict); - } catch (e) { - // Fails silently if rootBundle doesn't have it (e.g. newly added lang) - } + try { + _mergeLanguages(_decodeLanguageList(cachedLangList)); + } catch (e) { + if (kDebugMode) print("Failed to parse cached languages: $e"); } } - - // 2. Triple-Tier System Step 3: Network Check & Cache Refresh - if(!kDebugMode){ + + await decideLanguage(); + await _loadBundledAndCachedDictionaries(box); + + if (!kDebugMode) { try { if (log != null) await log("dict", "info", "Fetching languages from $url/$path/languages.json"); final response = await http.get(Uri.parse("$url/$path/languages.json")); - if(response.statusCode == 200) { + if (response.statusCode == 200) { if (log != null) await log("dict", "info", "Language list fetched successfully"); - languages = jsonDecode(response.body); + _mergeLanguages(_decodeLanguageList(response.body)); box.put("cached_languages_json", response.body); // Update persistent cache // Only re-decide language if the user has no saved preference. - // If they saved one, keep it — never let the network refresh override it. + // If they saved one, keep it - never let the network refresh override it. if (!box.containsKey("language")) await decideLanguage(); - + await _loadBundledAndCachedDictionaries(box); + for (int i = 0; i < languages.length; i++) { - String langId = languages[i]["id"]; + final language = languages[i]; + if (language is! Map || language["id"] == null) continue; + String langId = language["id"].toString(); final languageGet = await http.get(Uri.parse("$url/$path/$langId.json")); if (languageGet.statusCode == 200) { if (log != null) await log("dict", "info", "Downloaded dictionary for $langId"); - dictionary[langId] = jsonDecode(languageGet.body); + final mergedDict = {}; + if (dictionary[langId] is Map) { + mergedDict.addAll(dictionary[langId]); + } + mergedDict.addAll(_decodeDictionary(languageGet.body)); + dictionary[langId] = mergedDict; box.put("cached_dict_$langId", languageGet.body); // Update persistent cache } else { if (log != null) await log("dict", "warning", "Failed to download dictionary for $langId: ${languageGet.statusCode}"); @@ -105,28 +204,28 @@ class Dictionary { } else { if (log != null) await log("dict", "error", "Failed to fetch language list: ${response.statusCode}"); } - }catch(e){ + } catch (e) { if (kDebugMode) print("Falling back to strictly offline Languages! Error: $e"); if (log != null) await log("dict", "error", "Network error during dictionary setup: $e"); } } } - String value (String entry){ - if(!dictionary.containsKey(locale)){ + String value(String entry) { + if (!dictionary.containsKey(locale)) { return "Loading..."; } - if(!dictionary[locale].containsKey(entry)){ - if(!dictionary["en"].containsKey(entry)){ - if(kDebugMode) { + if (!dictionary[locale].containsKey(entry)) { + if (!dictionary["en"].containsKey(entry)) { + if (kDebugMode) { return "!!! $entry"; - }else{ + } else { return entry; } } - if(kDebugMode){ + if (kDebugMode) { return "!${dictionary["en"][entry].toString()}!"; - }else{ + } else { return dictionary["en"][entry].toString(); } } diff --git a/lib/storage/prompt_repository.dart b/lib/storage/prompt_repository.dart index 52eb3f4..02fee37 100644 --- a/lib/storage/prompt_repository.dart +++ b/lib/storage/prompt_repository.dart @@ -12,6 +12,23 @@ class PromptRepository { Map defaultPrompts = {}; Map userPrompts = {}; + + static const Map>> _localizedDefaultPrompts = { + "zh-Hans": { + "system_default": { + "name": "默认提示词", + "description": "简单的 PAIOS 提示词。", + }, + "pirate": { + "name": "海盗人格", + "description": "始终以海盗口吻回答。", + }, + "system_old": { + "name": "旧版提示词", + "description": "1.2.0 之前的提示词,适用于 nano-v2,效果有限。", + }, + }, + }; PromptRepository({required this.notifyEngine, this.logEvent}); @@ -166,6 +183,19 @@ class PromptRepository { return "Unknown Prompt"; } + String getPromptDisplayName(String id, String locale) { + if (userPrompts.containsKey(id)) return getPromptName(id); + return _localizedDefaultPrompts[locale]?[id]?["name"] ?? getPromptName(id); + } + + String? getPromptDisplayDescription(String id, String locale) { + if (userPrompts.containsKey(id)) return null; + final localizedDescription = _localizedDefaultPrompts[locale]?[id]?["description"]; + if (localizedDescription != null) return localizedDescription; + final description = defaultPrompts[id]?["description"]; + return description is String ? description : null; + } + // ── Mutations ───────────────────────────────────────────────────────────── Future addUserPrompt( diff --git a/lib/storage/resource_repository.dart b/lib/storage/resource_repository.dart index 96a6270..f7f200e 100644 --- a/lib/storage/resource_repository.dart +++ b/lib/storage/resource_repository.dart @@ -10,8 +10,70 @@ class ResourceRepository { List> resources = []; + static const Map> _localizedResourceNames = { + "zh-Hans": { + "Use AICore with root access": "Root 设备使用 AICore", + "Gemini Nano Availability": "Gemini Nano 可用性说明", + "Google ML Kit Guides": "Google ML Kit 指南", + "Original app idea": "原始应用灵感", + "Application's GitHub repository": "应用的 GitHub 仓库", + "Application's Google Store page": "应用的 Google Play 页面", + "Developer's Website": "开发者网站", + }, + "zh": { + "Use AICore with root access": "在 Root 裝置上使用 AICore", + "Gemini Nano Availability": "Gemini Nano 可用性說明", + "Google ML Kit Guides": "Google ML Kit 指南", + "Original app idea": "原始應用程式靈感", + "Application's GitHub repository": "應用程式的 GitHub 儲存庫", + "Application's Google Store page": "應用程式的 Google Play 頁面", + "Developer's Website": "開發者網站", + }, + "uk": { + "Use AICore with root access": "Використання AICore з root-доступом", + "Gemini Nano Availability": "Доступність Gemini Nano", + "Google ML Kit Guides": "Посібники Google ML Kit", + "Original app idea": "Оригінальна ідея додатка", + "Application's GitHub repository": "GitHub-репозиторій додатка", + "Application's Google Store page": "Сторінка додатка в Google Play", + "Developer's Website": "Сайт розробника", + }, + "de": { + "Use AICore with root access": "AICore mit Root-Zugriff verwenden", + "Gemini Nano Availability": "Verfügbarkeit von Gemini Nano", + "Google ML Kit Guides": "Google ML Kit-Anleitungen", + "Original app idea": "Ursprüngliche App-Idee", + "Application's GitHub repository": "GitHub-Repository der App", + "Application's Google Store page": "Google Play-Seite der App", + "Developer's Website": "Website des Entwicklers", + }, + "tr": { + "Use AICore with root access": "Root erişimiyle AICore kullan", + "Gemini Nano Availability": "Gemini Nano kullanılabilirliği", + "Google ML Kit Guides": "Google ML Kit kılavuzları", + "Original app idea": "Özgün uygulama fikri", + "Application's GitHub repository": "Uygulamanın GitHub deposu", + "Application's Google Store page": "Uygulamanın Google Play sayfası", + "Developer's Website": "Geliştiricinin web sitesi", + }, + }; + + static const Map _resourceValueOverrides = { + "Application's Google Store page": "https://play.google.com/store/apps/details?id=page.puzzak.paios", + }; + ResourceRepository({required this.notifyEngine, this.logEvent}); + void _applyResourceOverrides() { + for (final resource in resources) { + final name = resource["name"]?.toString(); + final value = _resourceValueOverrides[name]; + if (value != null) { + resource["value"] = value; + } + } + } + Future initFromHive(String url) async { final box = Hive.box('paios_storage'); @@ -30,6 +92,7 @@ class ResourceRepository { ); } catch (_) {} } + _applyResourceOverrides(); // Step 3: Network refresh and cache update if (!kDebugMode) { @@ -42,7 +105,8 @@ class ResourceRepository { (jsonDecode(response.body) as List).map((e) => Map.from(e)), ); resources = fetched; - box.put("cached_resources_json", response.body); + _applyResourceOverrides(); + box.put("cached_resources_json", jsonEncode(resources)); notifyEngine(); } else { if (logEvent != null) await logEvent!("resource_repo", "error", "Failed to fetch resources: ${response.statusCode}"); @@ -64,4 +128,9 @@ class ResourceRepository { } return out; } + + String getResourceDisplayName(Map resource, String locale) { + final name = resource["name"]?.toString() ?? ""; + return _localizedResourceNames[locale]?[name] ?? name; + } } diff --git a/pubspec.yaml b/pubspec.yaml index e94188f..c67c68e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,8 +48,9 @@ flutter: - assets/translations/uk.json - assets/translations/en.json - assets/translations/zh.json + - assets/translations/zh-Hans.json - assets/translations/de.json - assets/translations/tr.json - assets/system_prompt.txt - assets/additional_resources.json - - assets/prompts/ \ No newline at end of file + - assets/prompts/ diff --git a/test/widget_test.dart b/test/widget_test.dart index eab2b57..c29b9ed 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,62 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:geminilocal/storage/resource_repository.dart'; + +Map _readJsonObject(String path) { + final raw = File(path).readAsStringSync(); + return jsonDecode(raw) as Map; +} -import 'package:geminilocal/main.dart'; +List _readJsonList(String path) { + final raw = File(path).readAsStringSync(); + return jsonDecode(raw) as List; +} void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + test('Simplified Chinese dictionary matches English keys', () { + final english = _readJsonObject('assets/translations/en.json'); + final simplifiedChinese = _readJsonObject('assets/translations/zh-Hans.json'); + + expect(simplifiedChinese.keys.toSet(), english.keys.toSet()); + }); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + test('Simplified Chinese is declared as a bundled translation asset', () { + final languages = _readJsonList('assets/translations/languages.json'); + final languageIds = languages + .map((language) => (language as Map)['id']) + .toSet(); + final pubspec = File('pubspec.yaml').readAsStringSync(); + + expect(languageIds, contains('zh-Hans')); + expect(pubspec, contains('assets/translations/zh-Hans.json')); + }); + + test('Google Play resource points at the current application id', () { + final resources = _readJsonList('assets/additional_resources.json') + .cast>(); + final playStoreResource = resources.singleWhere( + (resource) => resource['name'] == "Application's Google Store page", + ); + + expect( + playStoreResource['value'], + 'https://play.google.com/store/apps/details?id=page.puzzak.paios', + ); + }); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + test('Resource titles are localized for bundled languages', () { + final repository = ResourceRepository(notifyEngine: () {}); + final resource = { + 'name': "Application's Google Store page", + 'value': 'https://play.google.com/store/apps/details?id=page.puzzak.paios', + }; - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(repository.getResourceDisplayName(resource, 'zh-Hans'), '应用的 Google Play 页面'); + expect(repository.getResourceDisplayName(resource, 'zh'), '應用程式的 Google Play 頁面'); + expect(repository.getResourceDisplayName(resource, 'uk'), 'Сторінка додатка в Google Play'); + expect(repository.getResourceDisplayName(resource, 'de'), 'Google Play-Seite der App'); + expect(repository.getResourceDisplayName(resource, 'tr'), 'Uygulamanın Google Play sayfası'); }); }