diff --git a/.github/workflows/FlutterBuild.yml b/.github/workflows/FlutterBuild.yml deleted file mode 100644 index 3601cf1..0000000 --- a/.github/workflows/FlutterBuild.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: Flutter Build - -on: - push: - branches: [ main ] - tags: - - 'v*' - workflow_dispatch: - -jobs: - build-android: - name: Build Android APK - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: '3.41.0' - - name: Decode keystore - run: | - echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/release.jks - echo "storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" > android/key.properties - echo "keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}" >> android/key.properties - echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" >> android/key.properties - echo "storeFile=../release.jks" >> android/key.properties - - - run: flutter pub get - - run: flutter build apk --release --obfuscate --split-per-abi --split-debug-info=symbols - - - uses: actions/upload-artifact@v4 - with: - name: android-arm-v7a-apk - path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk - - - uses: actions/upload-artifact@v4 - with: - name: android-arm-v8a-apk - path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk - - - uses: actions/upload-artifact@v4 - with: - name: android-x86_64-apk - path: build/app/outputs/flutter-apk/app-x86_64-release.apk - - build-windows: - name: Build Windows App - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: '3.41.0' - - - run: flutter pub get - - run: flutter build windows - - - uses: actions/upload-artifact@v4 - with: - name: windows-build - path: build/windows/x64/runner/Release - - build-macos: - name: Build macOS App - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: '3.41.0' - - - run: flutter pub get - - run: flutter build macos - - - name: Post handling - run: hdiutil create -volname arabic_learning -srcfolder build/macos/Build/Products/Release/arabic_learning.app -ov -format UDZO arabic_learning.dmg - - - uses: actions/upload-artifact@v4 - with: - name: macos-build - path: arabic_learning.dmg - - build-web: - name: Build Flutter Web - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.41.0' - - run: flutter pub get - - run: flutter build web --release --base-href /arabic_learning/ - - # 上传 Web 构建结果 - - uses: actions/upload-artifact@v4 - with: - name: web-build - path: build/web - - deploy-web: - permissions: - contents: write - pages: write - name: Deploy Flutter Web to GitHub Pages - runs-on: ubuntu-latest - needs: build-web - if: github.ref == 'refs/heads/main' # 仅 main 分支部署 - steps: - - uses: actions/download-artifact@v4 - with: - name: web-build - path: build/web - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: build/web - publish_branch: gh-pages - - release: - name: Create GitHub Release - runs-on: ubuntu-latest - needs: [build-android, build-windows, build-macos] - if: startsWith(github.ref, 'refs/tags/') - permissions: - contents: write - steps: - - uses: actions/download-artifact@v4 - with: - name: android-arm-v7a-apk - path: . - - uses: actions/download-artifact@v4 - with: - name: android-arm-v8a-apk - path: . - - uses: actions/download-artifact@v4 - with: - name: android-x86_64-apk - path: . - - uses: actions/download-artifact@v4 - with: - name: windows-build - path: ./windows - - uses: actions/download-artifact@v4 - with: - name: macos-build - path: ./macos - - - name: Prepare artifacts - run: | - zip -r windows.zip windows/ - zip -r macos.zip macos/ - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: | - app-armeabi-v7a-release.apk - app-arm64-v8a-release.apk - app-x86_64-release.apk - windows.zip - macos.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/FlutterBuildTest.yml b/.github/workflows/FlutterBuildTest.yml deleted file mode 100644 index 33a7a90..0000000 --- a/.github/workflows/FlutterBuildTest.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Flutter Build(PR) - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - build-windows: - name: Build Windows App - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - flutter-version: '3.41.0' - - - run: flutter pub get - - run: flutter build windows - - - uses: actions/upload-artifact@v4 - with: - name: windows-build - path: build/windows/x64/runner/Release - - build-web: - name: Build Flutter Web - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.41.0' - - run: flutter pub get - - run: flutter build web --release - - uses: actions/upload-artifact@v4 - with: - name: web-build - path: build/web \ No newline at end of file diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..6ff9091 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,48 @@ +name: Build & Release Dev APK + + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout 代码 + uses: actions/checkout@v4 + + - name: 配置 Java 环境 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + - name: 配置 Flutter 环境 + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.2' + channel: 'stable' + cache: true + + - name: 安装依赖 + run: flutter pub get + + - name: 代码分析 + run: flutter analyze --no-fatal-infos + + - name: 构建 APK + run: flutter build apk --release + + - name: 发布 GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: dev-${{ github.run_number }} + name: "Dev Build #${{ github.run_number }}" + body: | + 自动构建版本 + - 分支:${{ github.ref_name }} + - Commit:${{ github.sha }} + files: build/app/outputs/flutter-apk/app-release.apk + prerelease: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/lib/funcs/fsrs_func.dart b/lib/funcs/fsrs_func.dart index b7e75e8..a0749e5 100644 --- a/lib/funcs/fsrs_func.dart +++ b/lib/funcs/fsrs_func.dart @@ -164,6 +164,7 @@ class FSRSConfig { final bool selfEvaluate; final int pushAmount; final bool reinforceMemory; + final List selectedSources; const FSRSConfig({ bool? enabled, @@ -176,7 +177,8 @@ class FSRSConfig { bool? preferSimilar, bool? selfEvaluate, int? pushAmount, - bool? reinforceMemory + bool? reinforceMemory, + List? selectedSources }) : enabled = enabled??false, cards = cards??const [], @@ -187,7 +189,8 @@ class FSRSConfig { preferSimilar = preferSimilar??false, selfEvaluate = selfEvaluate??false, pushAmount = pushAmount??0, - reinforceMemory = reinforceMemory??false; + reinforceMemory = reinforceMemory??false, + selectedSources = selectedSources??const []; Map toMap(){ return { @@ -201,7 +204,8 @@ class FSRSConfig { "preferSimilar": preferSimilar, "selfEvaluate": selfEvaluate, "pushAmount": pushAmount, - "reinforceMemory": reinforceMemory + "reinforceMemory": reinforceMemory, + "selectedSources": selectedSources }; } @@ -216,7 +220,8 @@ class FSRSConfig { bool? preferSimilar, bool? selfEvaluate, int? pushAmount, - bool? reinforceMemory + bool? reinforceMemory, + List? selectedSources }) { return FSRSConfig( enabled: enabled??this.enabled, @@ -229,7 +234,8 @@ class FSRSConfig { preferSimilar: preferSimilar??this.preferSimilar, selfEvaluate: selfEvaluate??this.selfEvaluate, pushAmount: pushAmount??this.pushAmount, - reinforceMemory: reinforceMemory??this.reinforceMemory + reinforceMemory: reinforceMemory??this.reinforceMemory, + selectedSources: selectedSources??this.selectedSources ); } @@ -246,7 +252,8 @@ class FSRSConfig { preferSimilar: configData["preferSimilar"], selfEvaluate: configData["selfEvaluate"], pushAmount: configData["pushAmount"], - reinforceMemory: configData["reinforceMemory"] + reinforceMemory: configData["reinforceMemory"], + selectedSources: configData["selectedSources"] == null ? const [] : List.from(configData["selectedSources"]) ); } return FSRSConfig(enabled: false); diff --git a/lib/funcs/ui.dart b/lib/funcs/ui.dart index 1fdda31..dfd8a78 100644 --- a/lib/funcs/ui.dart +++ b/lib/funcs/ui.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:ui'; import 'package:arabic_learning/funcs/fsrs_func.dart'; -import 'package:arabic_learning/vars/config_structure.dart' show ClassItem, SourceItem, WordItem, ClassSelection; +import 'package:arabic_learning/vars/config_structure.dart' show ClassItem, SourceItem, WordItem, WordMeaning, ClassSelection; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -211,6 +211,19 @@ void alart(BuildContext context, String e, {Function? onConfirmed, Duration dela } /// 弹出详解页面 +/// 根据 FSRS 当前选中词库,优先返回对应词库的中文释义; +/// 如未限制词库或找不到对应释义,则使用 meanings[0](主释义)。 +String getDisplayChinese(WordItem word) { + final selectedSources = FSRS().config.selectedSources; + if (selectedSources.isNotEmpty) { + for (final source in selectedSources) { + final meaning = word.getMeaningForSource(source); + if (meaning != null) return meaning.chinese; + } + } + return word.chinese; +} + void viewAnswer(BuildContext context, WordItem wordData) async { context.read().uiLogger.info("弹出详解页面"); MediaQueryData mediaQuery = MediaQuery.of(context); @@ -493,22 +506,45 @@ class _ChooseButtonBoxState extends State { /// [height] :限定高度,默认自动 /// /// [useMask] :是否显示高斯遮罩 -class WordCard extends StatelessWidget { +class WordCard extends StatefulWidget { final WordItem word; final double? width; final double? height; final bool useMask; const WordCard({super.key, required this.word, this.width, this.height, this.useMask = true}); + @override + State createState() => _WordCardState(); +} + +class _WordCardState extends State { + late final PageController _meaningController; + int _currentPage = 0; + + @override + void initState() { + super.initState(); + _meaningController = PageController(); + } + + @override + void dispose() { + _meaningController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { MediaQueryData mediaQuery = MediaQuery.of(context); - bool hide = useMask; - double useWidth = width ?? mediaQuery.size.width * 0.9; - double useHeight = height ?? mediaQuery.size.height * 0.5; + double useWidth = widget.width ?? mediaQuery.size.width * 0.9; + double useHeight = widget.height ?? mediaQuery.size.height * 0.5; + final meanings = widget.word.meanings; + final bool hasMultiple = meanings.length > 1; + return Column( mainAxisAlignment: MainAxisAlignment.start, children: [ + // ── 顶部:阿拉伯语发音区(固定,不参与分页)────────────────── ElevatedButton.icon( style: ElevatedButton.styleFrom( fixedSize: Size(useWidth, useHeight * 0.3), @@ -517,69 +553,140 @@ class WordCard extends StatelessWidget { padding: const EdgeInsets.all(16.0), ), icon: const Icon(Icons.volume_up, size: 24.0), - label: FittedBox(child: Text(word.arabic, style: TextStyle(fontSize: 64.0, fontFamily: context.read().arFont))), - onPressed: (){ - playTextToSpeech(word.arabic); + label: FittedBox(child: Text(widget.word.arabic, style: TextStyle(fontSize: 64.0, fontFamily: context.read().arFont))), + onPressed: () { + playTextToSpeech(widget.word.arabic); }, ), + // ── 底部:释义区(多义时横向分页,单义时保持原样)──────────── Stack( children: [ - Container( + SizedBox( width: useWidth, height: useHeight * 0.6, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onInverseSurface.withAlpha(150), - borderRadius: BorderRadius.vertical(bottom: Radius.circular(25.0)), - ), - child: Column( - children: [ - Row( - children: [ - Container( - height: useHeight*0.14, - width: useWidth*0.2, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary.withAlpha(150), + child: PageView.builder( + controller: _meaningController, + physics: hasMultiple ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), + itemCount: meanings.length, + onPageChanged: (index) => setState(() => _currentPage = index), + itemBuilder: (context, pageIndex) { + final WordMeaning meaning = meanings[pageIndex]; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onInverseSurface.withAlpha(150), + borderRadius: BorderRadius.vertical(bottom: Radius.circular(25.0)), + ), + child: Column( + children: [ + // 来源标签行(仅多义时显示) + if (hasMultiple) + Container( + height: useHeight * 0.09, + width: useWidth, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withAlpha(180), + ), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.library_books_outlined, size: 14), + SizedBox(width: 4), + Text( + meaning.source.isNotEmpty ? meaning.source : "主词库", + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + SizedBox(width: 8), + // 页面指示器小圆点(可点击) + Row( + children: List.generate(meanings.length, (dotIndex) { + return GestureDetector( + onTap: () { + _meaningController.animateToPage( + dotIndex, + duration: Durations.medium2, + curve: Curves.easeInOut, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 8), + child: AnimatedContainer( + duration: Durations.short4, + width: dotIndex == _currentPage ? 10 : 6, + height: dotIndex == _currentPage ? 10 : 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotIndex == _currentPage + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + ), + ), + ), + ); + }), + ), + ], + ), + ), + ), + ), + // 中文行 + Row( + children: [ + Container( + height: useHeight * (hasMultiple ? 0.12 : 0.14), + width: useWidth * 0.2, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSecondary.withAlpha(150), + ), + child: Center(child: Text("中文", style: TextStyle(fontSize: 16))), + ), + Expanded(child: FittedBox(fit: BoxFit.scaleDown, child: Text(meaning.chinese, style: TextStyle(fontSize: 24)))) + ], ), - child: Center(child: Text("中文", style: TextStyle(fontSize: 16),)), - ), - Expanded(child: FittedBox(fit: BoxFit.scaleDown, child: Text(word.chinese, style: TextStyle(fontSize: 24)))) - ], - ), - Divider(height: 0), - Row( - children: [ - Container( - height: useHeight*0.32, - width: useWidth*0.2, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onPrimary.withAlpha(150), + Divider(height: 0), + // 解释行 + Row( + children: [ + Container( + height: useHeight * (hasMultiple ? 0.23 : 0.32), + width: useWidth * 0.2, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onPrimary.withAlpha(150), + ), + child: Center(child: Text("解释", style: TextStyle(fontSize: 18))), + ), + Expanded(child: Text(meaning.explanation, style: TextStyle(fontSize: 16), textAlign: TextAlign.center, maxLines: 3)) + ], ), - child: Center(child: Text("解释", style: TextStyle(fontSize: 18))), - ), - Expanded(child: Text(word.explanation, style: TextStyle(fontSize: 16), textAlign: TextAlign.center, maxLines: 3)) - ], - ), - Divider(height: 0), - Row( - children: [ - Container( - height: useHeight*0.14, - width: useWidth*0.2, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary.withAlpha(150), - borderRadius: BorderRadius.only(bottomLeft: Radius.circular(25.0)) + Divider(height: 0), + // 归属课程行 + Row( + children: [ + Container( + height: useHeight * 0.14, + width: useWidth * 0.2, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSecondary.withAlpha(150), + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(25.0)) + ), + child: FittedBox(fit: BoxFit.scaleDown, child: Text("归属课程", style: TextStyle(fontSize: 16))), + ), + Expanded(child: FittedBox(fit: BoxFit.scaleDown, child: Text(meaning.className, style: TextStyle(fontSize: 18), textAlign: TextAlign.center))) + ], ), - child: FittedBox(fit: BoxFit.scaleDown, child: Text("归属课程", style: TextStyle(fontSize: 16))), - ), - Expanded(child: FittedBox(fit: BoxFit.scaleDown, child: Text(word.className, style: TextStyle(fontSize: 18), textAlign: TextAlign.center))) - ], - ) - ], - ) + ], + ), + ); + }, + ), ), + // ── 遮罩层(useMask 时显示,点击揭开) ───────────────────── StatefulBuilder( builder: (context, setLocalState) { + bool hide = widget.useMask; return TweenAnimationBuilder( tween: Tween( begin: 1.0, @@ -591,7 +698,7 @@ class WordCard extends StatelessWidget { return ClipRRect( borderRadius: BorderRadiusGeometry.vertical(bottom: Radius.circular(25.0)), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15.0 * value,sigmaY: 15.0 * value), + filter: ImageFilter.blur(sigmaX: 15.0 * value, sigmaY: 15.0 * value), enabled: true, child: value == 0.0 ? null : ElevatedButton( style: ElevatedButton.styleFrom( @@ -600,11 +707,11 @@ class WordCard extends StatelessWidget { shadowColor: Colors.transparent, shape: RoundedRectangleBorder(borderRadius: BorderRadiusGeometry.vertical(bottom: Radius.circular(25.0))) ), - onPressed: (){ + onPressed: () { setLocalState(() { hide = false; - },); - }, + }); + }, child: hide ? Text("点此查看释义") : SizedBox() ), ), @@ -620,6 +727,7 @@ class WordCard extends StatelessWidget { } } + // Page Widget 可复用的页面Widget /// 课程选择页面 diff --git a/lib/pages/learning_page.dart b/lib/pages/learning_page.dart index 6b125f0..73b54a1 100644 --- a/lib/pages/learning_page.dart +++ b/lib/pages/learning_page.dart @@ -100,7 +100,9 @@ class LearningPage extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: StaticsVar.br), ), onPressed: (){ - if(AppData().wordData.words.isEmpty) { + final AppData appData = AppData(); + final FSRS fsrs = FSRS(); + if(appData.wordData.words.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("词库为空,无法推送!请先导入词库"), duration: Duration(seconds: 1),), ); @@ -111,10 +113,36 @@ class LearningPage extends StatelessWidget { final Set pushWords = {}; final Random rnd = Random(seed); int tries = 0; - while(pushWords.length < FSRS().config.pushAmount && tries < FSRS().config.pushAmount * 10){ - int chosen = rnd.nextInt(AppData().wordData.words.length); - if(!FSRS().isContained(chosen)) { - pushWords.add(AppData().wordData.words.elementAt(chosen)); + + List validWordIds = []; + final bool hasRestrictedClasses = fsrs.config.selectedSources.isNotEmpty; + if (hasRestrictedClasses) { + for (var source in appData.wordData.classes) { + if (fsrs.config.selectedSources.contains(source.sourceJsonFileName)) { + for (var subclass in source.subClasses) { + validWordIds.addAll(subclass.wordIndexs); + } + } + } + } + + // Exclude already added cards + if (hasRestrictedClasses) { + validWordIds.removeWhere((id) => fsrs.isContained(id)); + } + + while(pushWords.length < fsrs.config.pushAmount && tries < fsrs.config.pushAmount * 10) { + int chosen; + if (hasRestrictedClasses) { + if (validWordIds.isEmpty) break; // no more valid words to push + int rndIdx = rnd.nextInt(validWordIds.length); + chosen = validWordIds[rndIdx]; + } else { + chosen = rnd.nextInt(appData.wordData.words.length); + } + + if(!fsrs.isContained(chosen)) { + pushWords.add(appData.wordData.words.elementAt(chosen)); } tries++; } diff --git a/lib/pages/setting_page.dart b/lib/pages/setting_page.dart index ce0b0b6..ecd7101 100644 --- a/lib/pages/setting_page.dart +++ b/lib/pages/setting_page.dart @@ -259,6 +259,61 @@ class _SettingPage extends State { ), ], ), + // ── 旧数据迁移按钮(仅当存在未迁移词汇时需要点击)──────────── + Builder(builder: (context) { + // 统计有多少词仍是未带 source 的旧格式 + final int pendingCount = AppData().wordData.words + .where((w) => w.meanings.length == 1 && w.meanings[0].source.isEmpty) + .length; + if (pendingCount == 0) return const SizedBox.shrink(); + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + minimumSize: Size.fromHeight(mediaQuery.size.height * 0.07), + backgroundColor: Theme.of(context).colorScheme.tertiaryContainer, + shape: BeveledRectangleBorder(), + ), + icon: const Icon(Icons.upgrade), + label: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("迁移旧词库数据(一词多义兼容)"), + Text( + "检测到 $pendingCount 个词缺少来源信息,点击自动补充。", + style: TextStyle(fontSize: 10.0, color: Colors.grey.shade600), + ), + ], + ), + onPressed: () async { + final bool? confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("迁移旧词库数据"), + content: Text( + "检测到 $pendingCount 个词的来源信息需要补充。\n\n" + "此操作会根据现有分类结构自动填充来源字段," + "无需重新导入词库文件,且不会丢失任何词汇。\n\n" + "确定要继续吗?", + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("取消")), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("确定")), + ], + ), + ); + if (confirmed != true) return; + final int count = AppData().migrateOldWordData(); + if (!context.mounted) return; + setState(() {}); // 刷新页面隐藏按钮 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(count > 0 ? "迁移完成,共补充了 $count 个词的来源信息。" : "无需迁移,数据已是最新格式。"), + duration: const Duration(seconds: 3), + ), + ); + }, + ); + }), ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: Size.fromHeight(mediaQuery.size.height * 0.08), diff --git a/lib/sub_pages_builder/learning_pages/fsrs_pages.dart b/lib/sub_pages_builder/learning_pages/fsrs_pages.dart index 4b16bbd..a35b72c 100644 --- a/lib/sub_pages_builder/learning_pages/fsrs_pages.dart +++ b/lib/sub_pages_builder/learning_pages/fsrs_pages.dart @@ -229,6 +229,91 @@ class ForeFSRSSettingPage extends StatelessWidget { ], ), ), + Container( + decoration: BoxDecoration( + borderRadius: StaticsVar.br, + color: Theme.of(context).colorScheme.onPrimary + ), + margin: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text("限制复习词库", style: Theme.of(context).textTheme.bodyLarge)), + TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setLocalState) { + return AlertDialog( + title: Text("选择复习词库"), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: AppData().wordData.classes.map((source) { + bool selected = fsrs.config.selectedSources.contains(source.sourceJsonFileName); + return CheckboxListTile( + title: Text(source.sourceJsonFileName), + value: selected, + onChanged: (val) { + setLocalState(() { + if (val == true) { + List newSelection = List.from(fsrs.config.selectedSources)..add(source.sourceJsonFileName); + fsrs.config = fsrs.config.copyWith(selectedSources: newSelection); + } else { + List newSelection = List.from(fsrs.config.selectedSources)..remove(source.sourceJsonFileName); + fsrs.config = fsrs.config.copyWith(selectedSources: newSelection); + } + fsrs.save(); + }); + setState(() {}); + } + ); + }).toList() + ) + ), + actions: [ + TextButton( + onPressed: () { + setLocalState(() { + fsrs.config = fsrs.config.copyWith(selectedSources: AppData().wordData.classes.map((s) => s.sourceJsonFileName).toList()); + fsrs.save(); + }); + setState(() {}); + }, + child: Text("全选") + ), + TextButton( + onPressed: () { + setLocalState(() { + fsrs.config = fsrs.config.copyWith(selectedSources: []); + fsrs.save(); + }); + setState(() {}); + }, + child: Text("清除") + ), + TextButton(onPressed: ()=>Navigator.pop(context), child: Text("完成", style: TextStyle(fontWeight: FontWeight.bold))), + ] + ); + } + ); + } + ); + }, + child: Text(fsrs.config.selectedSources.isEmpty ? "全部词库" : "已选 ${fsrs.config.selectedSources.length} 个"), + ) + ], + ), + Text("限制复习词库 选择后,复习将仅抽取选中词库内的卡片,并仅从选中词库中获取每日推送。"), + Text("不选则默认从全部已导入词库中抽取。") + ], + ), + ), Container( decoration: BoxDecoration( borderRadius: StaticsVar.br, @@ -331,8 +416,25 @@ class _MainFSRSPageState extends State { } void _extendQueue() { + final AppData appData = AppData(); + Set allowedWordIds = {}; + bool hasRestrictedClasses = widget.fsrs.config.selectedSources.isNotEmpty; + if (hasRestrictedClasses) { + for (var source in appData.wordData.classes) { + if (widget.fsrs.config.selectedSources.contains(source.sourceJsonFileName)) { + for (var subclass in source.subClasses) { + allowedWordIds.addAll(subclass.wordIndexs); + } + } + } + } + final List uniqueIds = widget.fsrs.config.cards - .where((card) => widget.fsrs.willDueIn(card) < 1) + .where((card) { + if (widget.fsrs.willDueIn(card) >= 1) return false; + if (hasRestrictedClasses && !allowedWordIds.contains(card.cardId)) return false; + return true; + }) .map((card) => card.cardId) .toList(); @@ -596,7 +698,8 @@ class _FSRSLearningPageState extends State { final Random rnd = Random(); for(WordItem word in widget.words) { List optionWords = getRandomWords(4, AppData().wordData, include: word, preferClass: !widget.fsrs.config.preferSimilar, rnd: rnd); - List option = List.generate(4, (int index) => optionWords[index].chinese, growable: false); + // 使用 getDisplayChinese 确保选项文本与用户选中词库一致 + List option = List.generate(4, (int index) => getDisplayChinese(optionWords[index]), growable: false); options.add(option); } super.initState(); diff --git a/lib/sub_pages_builder/learning_pages/learning_pages_build.dart b/lib/sub_pages_builder/learning_pages/learning_pages_build.dart index f81d54c..fe9a694 100644 --- a/lib/sub_pages_builder/learning_pages/learning_pages_build.dart +++ b/lib/sub_pages_builder/learning_pages/learning_pages_build.dart @@ -575,6 +575,7 @@ class _ConcludePageState extends State { } } + @immutable class TestItem { /// 测试单词 @@ -601,7 +602,7 @@ class TestItem { this.correctIndex }); - static TestItem buildTestItem(WordItem word, int testType, DictData wordData,bool preferSimilar,Random rnd){ + static TestItem buildTestItem(WordItem word, int testType, DictData wordData, bool preferSimilar, Random rnd){ if(testType == 0 || testType == 3){ return TestItem(testWord: word, testType: testType); } else { @@ -609,7 +610,8 @@ class TestItem { return TestItem( testWord: word, testType: testType, - options: List.generate(4, (int index) => ((testType == 2 || (testType == 4 && rnd.nextBool())) ? optionWords[index].chinese : optionWords[index].arabic), growable: false), + // 使用 getDisplayChinese 确保选项文本与用户选中词库一致 + options: List.generate(4, (int index) => ((testType == 2 || (testType == 4 && rnd.nextBool())) ? getDisplayChinese(optionWords[index]) : optionWords[index].arabic), growable: false), correctIndex: optionWords.indexOf(word) ); } @@ -906,28 +908,96 @@ class WordLookupLayout extends StatelessWidget { Widget build(BuildContext context) { if(lookfor.isEmpty) return SizedBox(); MediaQueryData mediaQuery = MediaQuery.of(context); - List match = []; - if(lookfor.isArabic()) { - match.addAll(BKSearch.search( - WordItem(arabic: lookfor, chinese: lookfor, explanation: "", id: 0, className: ""), - threshold: 4~/(lookfor.length * 0.5 + 1) // 输入越多 容差越小 - )); // 从BK树找 + + int forceColumn = AppData().config.learning.overviewForceColumn; + int crossAxisCount = forceColumn == 0 ? (mediaQuery.size.width ~/ 300) : forceColumn; + if (crossAxisCount <= 0) crossAxisCount = 1; + double itemWidth = mediaQuery.size.width / crossAxisCount; + if(lookfor.isArabic()) { + WordItem dummyWord = WordItem(arabic: lookfor, meanings: [WordMeaning(chinese: lookfor, explanation: "", className: "", source: "")], id: 0); + + Map> tiers = BKSearch.searchWithTiers(dummyWord); + + List exactMatches = []; + String lookforClean = lookfor.removeAracicExtensionPart(); for(WordItem word in AppData().wordData.words) { - if(match.contains(word)) continue; - if(word.arabic.removeAracicExtensionPart().contains(lookfor.removeAracicExtensionPart())) { - match.add(word); - continue; - } - if(lookfor.length >=3 && getLevenshtein(lookfor.removeAracicExtensionPart(), word.arabic.removeAracicExtensionPart()) < 6~/(lookfor.length * 0.5 + 1)) { - match.add(word); - continue; + if(word.arabic.removeAracicExtensionPart().contains(lookforClean)) { + exactMatches.add(word); } } - match.sort((WordItem a, WordItem b) => - getLevenshtein(lookfor.removeAracicExtensionPart(), a.arabic.removeAracicExtensionPart()) - getLevenshtein(lookfor.removeAracicExtensionPart(), b.arabic.removeAracicExtensionPart()) + + for(int i = 1; i <= 4; i++) { + tiers[i]?.removeWhere((w) => exactMatches.contains(w)); + } + + List> sections = []; + if(exactMatches.isNotEmpty) { + sections.add({"title": "完全匹配/包含", "items": exactMatches}); + } + if(tiers[1] != null && tiers[1]!.isNotEmpty) { + sections.add({"title": "极高相似 (同根同词性)", "items": tiers[1]!}); + } + if(tiers[2] != null && tiers[2]!.isNotEmpty) { + sections.add({"title": "较高相似 (近根同词性)", "items": tiers[2]!}); + } + if(tiers[3] != null && tiers[3]!.isNotEmpty) { + sections.add({"title": "中等相似 (同根异性)", "items": tiers[3]!}); + } + if(tiers[4] != null && tiers[4]!.isNotEmpty) { + sections.add({"title": "近似词汇 (近根干扰项)", "items": tiers[4]!}); + } + + int totalMatches = sections.fold(0, (sum, sec) => sum + (sec["items"] as List).length); + + if(!AppData().config.learning.wordLookupRealtime){ + Future.delayed(Durations.medium1, () { + if(context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("检索到$totalMatches个结果"), duration: Duration(seconds: 1),), + ); + } + }); + } + + return CustomScrollView( + slivers: sections.map((section) { + String title = section["title"]; + List items = section["items"]; + + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Text( + title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), + ), + ), + ), + SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: crossAxisCount), + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + margin: EdgeInsets.all(8.0), + child: WordCard( + word: items[index], + useMask: false, + width: itemWidth, + height: itemWidth, + ), + ); + }, + childCount: items.length, + ), + ), + ]; + }).expand((element) => element).toList(), ); + } else { + List match = []; for(WordItem word in AppData().wordData.words) { if(match.contains(word)) continue; if(word.chinese.contains(lookfor)) { @@ -941,35 +1011,50 @@ class WordLookupLayout extends StatelessWidget { } } match.sort((WordItem a, WordItem b) => - a.chinese.contains(lookfor) ? -1 : a.chinese.contains(lookfor) ? 1 : getLevenshtein(lookfor, a.chinese) - getLevenshtein(lookfor, b.chinese) + a.chinese.contains(lookfor) ? -1 : (b.chinese.contains(lookfor) ? 1 : getLevenshtein(lookfor, a.chinese) - getLevenshtein(lookfor, b.chinese)) ); - } - - context.read().uiLogger.finer("单词检索结果: $match"); - if(!AppData().config.learning.wordLookupRealtime){ - Future.delayed(Durations.medium1, () { - if(context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("检索到${match.length}个结果"), duration: Duration(seconds: 1),), - ); - } - }); - } - return GridView.builder( - itemCount: match.length, - gridDelegate: AppData().config.learning.overviewForceColumn == 0 ? SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: mediaQuery.size.width ~/ 300) : SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: AppData().config.learning.overviewForceColumn), - itemBuilder: (context, index) { - return Container( - margin: EdgeInsets.all(8.0), - child: WordCard( - word: match[index], - useMask: false, - width: mediaQuery.size.width / (AppData().config.learning.overviewForceColumn == 0 ? (mediaQuery.size.width ~/ 300) : AppData().config.learning.overviewForceColumn), - height: mediaQuery.size.width / (AppData().config.learning.overviewForceColumn == 0 ? (mediaQuery.size.width ~/ 300) : AppData().config.learning.overviewForceColumn), - ), - ); + context.read().uiLogger.finer("单词检索结果: $match"); + if(!AppData().config.learning.wordLookupRealtime){ + Future.delayed(Durations.medium1, () { + if(context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("检索到${match.length}个结果"), duration: Duration(seconds: 1),), + ); + } + }); } - ); + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Text( + "中文检索结果", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), + ), + ), + ), + SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: crossAxisCount), + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + margin: EdgeInsets.all(8.0), + child: WordCard( + word: match[index], + useMask: false, + width: itemWidth, + height: itemWidth, + ), + ); + }, + childCount: match.length, + ), + ), + ] + ); + } } } diff --git a/lib/vars/config_structure.dart b/lib/vars/config_structure.dart index e4c56f6..1514345 100644 --- a/lib/vars/config_structure.dart +++ b/lib/vars/config_structure.dart @@ -721,28 +721,94 @@ class ClassItem { } } +/// 单个释义条目,包含中文、解释、所属课程和来源词典 @immutable -class WordItem { - final String arabic; +class WordMeaning { + /// 中文含义 final String chinese; + + /// 补充解释 final String explanation; + + /// 所属子课程名称 final String className; - final int id; - const WordItem({ - required this.arabic, + /// 词典来源(SourceItem.sourceJsonFileName) + final String source; + + const WordMeaning({ required this.chinese, required this.explanation, required this.className, - required this.id + required this.source, }); - Map toMap(){ + Map toMap() { return { - "arabic": arabic, "chinese": chinese, "explanation": explanation, - "subClass": className + "className": className, + "source": source, + }; + } + + static WordMeaning buildFromMap(Map m) { + return WordMeaning( + chinese: m["chinese"] ?? "", + explanation: m["explanation"] ?? "", + className: m["className"] ?? "", + source: m["source"] ?? "", + ); + } +} + +@immutable +class WordItem { + final String arabic; + + /// 所有来源的释义列表(一词多义) + final List meanings; + + final int id; + + const WordItem({ + required this.arabic, + required this.meanings, + required this.id, + }); + + // ── 向后兼容 getter,旧代码无需修改 ───────────────────────── + /// 主释义的中文(取 meanings[0]) + String get chinese => meanings.isNotEmpty ? meanings[0].chinese : ''; + + /// 主释义的解释(取 meanings[0]) + String get explanation => meanings.isNotEmpty ? meanings[0].explanation : ''; + + /// 主释义的课程(取 meanings[0]) + String get className => meanings.isNotEmpty ? meanings[0].className : ''; + + // ── 多义辅助方法 ────────────────────────────────────────────── + /// 按词典来源获取对应的释义,找不到时返回 null + WordMeaning? getMeaningForSource(String sourceFileName) { + for (final m in meanings) { + if (m.source == sourceFileName) return m; + } + return null; + } + + /// 返回一个追加了新义项的新实例(immutable 模式) + WordItem addMeaning(WordMeaning meaning) { + return WordItem( + arabic: arabic, + meanings: List.unmodifiable([...meanings, meaning]), + id: id, + ); + } + + Map toMap() { + return { + "arabic": arabic, + "meanings": meanings.map((m) => m.toMap()).toList(), }; } @@ -750,15 +816,25 @@ class WordItem { String toString() { return jsonEncode(toMap()); } - - static WordItem buildFromMap(Map word, int id){ - return WordItem( - arabic: word["arabic"], - chinese: word["chinese"], - explanation: word["explanation"], - className: word["subClass"], - id: id - ); + + /// 支持新格式(含 meanings 列表)和旧格式(chinese/explanation/subClass 扁平字段) + static WordItem buildFromMap(Map word, int id) { + if (word.containsKey("meanings") && word["meanings"] is List) { + // 新格式 + final List meanings = (word["meanings"] as List) + .map((m) => WordMeaning.buildFromMap(m as Map)) + .toList(); + return WordItem(arabic: word["arabic"], meanings: List.unmodifiable(meanings), id: id); + } else { + // 旧格式 fallback:封装为单元素列表(source 字段留空,由外层补充) + final meaning = WordMeaning( + chinese: word["chinese"] ?? "", + explanation: word["explanation"] ?? "", + className: word["subClass"] ?? "", + source: "", + ); + return WordItem(arabic: word["arabic"], meanings: List.unmodifiable([meaning]), id: id); + } } } diff --git a/lib/vars/global.dart b/lib/vars/global.dart index 96ec1ba..8f69a30 100644 --- a/lib/vars/global.dart +++ b/lib/vars/global.dart @@ -1,335 +1,397 @@ -import 'dart:convert'; - -import 'package:arabic_learning/funcs/fsrs_func.dart'; -import 'package:arabic_learning/funcs/utili.dart'; -import 'package:logging/logging.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart' show rootBundle, FontLoader; -import 'package:path_provider/path_provider.dart' as path_provider; - -import 'package:arabic_learning/vars/statics_var.dart'; -import 'package:arabic_learning/package_replacement/storage.dart'; -import 'package:arabic_learning/vars/config_structure.dart' show ClassItem, SourceItem, Config, DictData, WordItem; -import 'package:arabic_learning/package_replacement/fake_dart_io.dart' if (dart.library.io) 'dart:io' as io; -import 'package:arabic_learning/package_replacement/fake_sherpa_onnx.dart' if (dart.library.io) 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; - -class Global with ChangeNotifier { - final Logger uiLogger = Logger("UI"); - final Logger logger = Logger("Global"); - - bool backupFontLoaded = false; - String? arFont; - String? zhFont; - bool updateLogRequire = false; //是否需要显示更新日志 - - ThemeData get themeData => ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: StaticsVar.themeList[AppData().config.regular.theme], - brightness: AppData().config.regular.darkMode ? Brightness.dark : Brightness.light, - ), - fontFamily: zhFont, - ); - - - Future init() async { - logger.info("开始全局控制类初始化"); - - AppData appData = AppData(); - await appData.init(); - FSRS().init(); - - if(appData.isFirstStart) { - logger.info("首次启动检测为真"); - appData.initStorageValue(); - await refreshApp(); - } else { - conveySetting(); - await updateSetting(); - } - - logger.info("初始化完成"); - return true; - } - - // 预处理一些版本更新的配置文件兼容 - void conveySetting() { - logger.info("处理配置文件"); - - Config oldConfig = Config.buildFromMap(jsonDecode(AppData().storage.getString("settingData")!)); - if(oldConfig.lastVersion != AppData().config.lastVersion) { - logger.info("检测到当前版本与上次启动版本不同"); - updateLogRequire = true; - oldConfig=oldConfig.copyWith(lastVersion: AppData().config.lastVersion); - } - - AppData().config = oldConfig; - logger.info("配置文件合成完成"); - } - - // 更新配置到存储中 - Future updateSetting({Map? settingData, bool refresh = true}) async { - logger.info("保存配置文件中"); - if(settingData != null) AppData().config = Config.buildFromMap(settingData); - AppData().storage.setString("settingData", jsonEncode(AppData().config.toMap())); - if(refresh) await refreshApp(); - } - - Future loadFont() async { - if(backupFontLoaded) return; - try{ - final ByteData bundle = await rootBundle.load("assets/fonts/zh/NotoSansSC-Medium.ttf"); - final FontLoader loader = FontLoader(StaticsVar.zhBackupFont)..addFont(Future.value(bundle)); - await loader.load(); - } catch (e) { - logger.severe("无法加载备用字体"); - return; - } - backupFontLoaded = true; - notifyListeners(); - } - - void changeLoggerBehavior() { - Logger.root.clearListeners(); - if(kDebugMode){ - Logger.root.onRecord.listen((record) async { - debugPrint('${record.time}-[${record.loggerName}][${record.level.name}]: ${record.message}'); - }); - } - if(AppData().config.debug.enableInternalLog){ - Logger.root.level = Level.ALL; - const List levelList = [Level.ALL, Level.FINEST, Level.FINER, Level.FINE, Level.INFO, Level.WARNING, Level.SEVERE, Level.SHOUT, Level.OFF]; - AppData appData = AppData(); - Logger.root.onRecord.listen((record) async { - if(record.level < levelList[AppData().config.debug.internalLevel]) return; - appData.internalLogCapture.add('${record.time}-[${record.loggerName}][${record.level.name}]: ${record.message}'); - }); - } - } - - Future refreshApp() async { - logger.info("应用设置中"); - AppData appData = AppData(); - if(appData.config.audio.audioSource == 2) await appData.loadTTS(appData.config.audio.playRate); - if(appData.config.egg.stella) await appData.loadEggs(); - changeLoggerBehavior(); - updateTheme(); - notifyListeners(); - logger.info("应用设置完成"); - } - - void updateTheme() { - logger.info("更新主题中"); - if(AppData().config.regular.font == 2) { - arFont = StaticsVar.arBackupFont; - zhFont = StaticsVar.zhBackupFont; - loadFont(); - } else if(AppData().config.regular.font == 1) { - arFont = StaticsVar.arBackupFont; - zhFont = null; - } else { - arFont = null; - zhFont = null; - } - } - - void updateLearningStreak(){ - final int nowDate = DateTime.now().difference(DateTime(2025, 11, 1)).inDays; - if (nowDate == AppData().config.learning.lastDate) return; - logger.info("保存学习进度中"); - // 以 2025/11/1 为基准计算天数(因为这个bug是这天修的:} ) - if (nowDate - AppData().config.learning.lastDate > 1) { - AppData().config = AppData().config.copyWith(learning: AppData().config.learning.copyWith(startDate: nowDate)); - } - AppData().config = AppData().config.copyWith(learning: AppData().config.learning.copyWith(lastDate: nowDate)); - updateSetting(refresh: false); - logger.info("学习进度保存完成"); - } -} - -class AppData { - // 作为单例 - static final AppData _instance = AppData._internal(); - factory AppData() => _instance; - AppData._internal(); - - bool inited = false; - Logger logger = Logger("AppData"); - - List internalLogCapture = []; - Uint8List? stella; - bool isWideScreen = false; - Config config = Config(); - - late final SharedPreferences storage; - late final io.Directory basePath; - late FSRS fsrs; - late DictData wordData; - sherpa_onnx.OfflineTts? vitsTTS; - - int get wordCount => wordData.words.length; - bool get isFirstStart => storage.getString("settingData") == null; - bool get modelTTSDownloaded => io.File("${basePath.path}/${StaticsVar.modelPath}/ar_JO-kareem-medium.onnx").existsSync(); - - Future init() async { - if(inited) return; - storage = await SharedPreferences.getInstance(); - if(!kIsWeb) { - basePath = (await path_provider.getApplicationDocumentsDirectory()) as io.Directory; - } - - if(!isFirstStart) { - wordData = DictData.buildFromMap(jsonDecode(storage.getString("wordData")!)); - if(!BKSearch.isReady) BKSearch.init(wordData.words); - FSRS().init(); - } - inited = true; - } - - Future initStorageValue() async { - await storage.setString("wordData", jsonEncode({"Words": [], "Classes": {}})); - wordData = DictData(words: [], classes: []); - logger.info("配置表初始化完成"); - } - - // load TTS model if any - Future loadTTS(double playRate) async { - if(kIsWeb || vitsTTS != null || !modelTTSDownloaded) return; - logger.info("TTS: 加载本地TTS中"); - sherpa_onnx.initBindings(); - final vits = sherpa_onnx.OfflineTtsVitsModelConfig( - model: "${basePath.path}/${StaticsVar.modelPath}/ar_JO-kareem-medium.onnx", - dataDir: "${basePath.path}/${StaticsVar.modelPath}/espeak-ng-data", - tokens: '${basePath.path}/${StaticsVar.modelPath}/tokens.txt', - lengthScale: 1 / playRate, - ); - final modelConfig = sherpa_onnx.OfflineTtsModelConfig( - vits: vits, - numThreads: 2, - debug: false, - provider: 'cpu', - ); - final config = sherpa_onnx.OfflineTtsConfig( - model: modelConfig, - maxNumSenetences: 1, - ); - - vitsTTS = sherpa_onnx.OfflineTts(config); - logger.info("TTS: 本地TTS加载完成"); - } - - Future loadEggs() async { - if(stella == null){ - final rawString = await rootBundle.loadString("assets/eggs/s.txt"); - stella = base64Decode(rawString); - } - } - - /// Non-Format Data: - /// { - /// "ClassName": [ - /// { - /// "chinese": {Chinese}, - /// "arabic": {arabic}, - /// "explanation": {explanation} - /// }, ... - /// ] - /// } - /// Format Data: - /// { - /// "Words" : [ - /// { - /// "arabic": {arabic}, - /// "chinese": {Chinese}, - /// "explanation": {explanation}, - /// "subClass": {ClassName}, - /// "learningProgress": {times} //int - /// }, ... - /// ], - /// "Classes": { - /// "SourceJsonFileName": { - /// "ClassName": [wordINDEX], - /// } - /// } - /// } - DictData dataFormater(Map data, DictData existData, String sourceName) { - logger.info("开始词汇格式化"); - - // Use Maps for O(1) lookup speed instead of O(N) List.indexOf - Map rawWordMap = {}; - Map pureWordMap = {}; - List chineseList = []; - - for(int i = 0; i < existData.words.length; i++) { - WordItem x = existData.words[i]; - rawWordMap[x.arabic] = i; - pureWordMap[x.arabic.removeAracicExtensionPart().trim()] = i; - chineseList.add(x.chinese); // Keep list for indexing since it maps 1:1 with word id - } - - int counter = existData.words.length; - - SourceItem? exSource; - // 查找已有数据中是否有同名的源数据组 - for(SourceItem x in existData.classes) { - if(x.sourceJsonFileName == sourceName) exSource = x; - } - if(exSource == null){ - existData.classes.add(SourceItem(sourceJsonFileName: sourceName, subClasses: [])); - exSource = existData.classes.last; - } - - for(var className in data.keys){ - ClassItem exClass = ClassItem(className: className, wordIndexs: []); - for(var word in data[className]){ - String newRaw = word["arabic"]; - String newPure = newRaw.removeAracicExtensionPart().trim(); - int existingIndex = -1; - - if (rawWordMap.containsKey(newRaw)) { - existingIndex = rawWordMap[newRaw]!; - } else if (pureWordMap.containsKey(newPure)) { - int potentialIndex = pureWordMap[newPure]!; - // Pure arabic is the same, but different vowels. Are they the same meaning? - if (chineseList[potentialIndex].hasSimilarMeaning(word["chinese"])) { - existingIndex = potentialIndex; - } - } - - if (existingIndex != -1) { - // If it already exists globally, just add it to this class - if(!exClass.wordIndexs.contains(existingIndex)) { - exClass.wordIndexs.add(existingIndex); - } - continue; - } - - exClass.wordIndexs.add(counter); - existData.words.add( - WordItem( - arabic: word["arabic"], - chinese: word["chinese"], - explanation: word["explanation"], - className: className, - id: counter - ) - ); - rawWordMap[newRaw] = counter; - pureWordMap[newPure] = counter; - chineseList.add(word["chinese"]); - counter ++; - } - exSource.subClasses.add(exClass); - } - return existData; - } - - void importDictData(Map importData, String source) { - logger.info("收到词汇导入请求"); - wordData = dataFormater(importData, wordData, source); - storage.setString("wordData", jsonEncode(wordData.toMap())); - BKSearch.init(wordData.words); // 重新建树 - logger.info("词汇导入完成"); - } -} \ No newline at end of file +import 'dart:convert'; + +import 'package:arabic_learning/funcs/fsrs_func.dart'; +import 'package:arabic_learning/funcs/utili.dart'; +import 'package:logging/logging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle, FontLoader; +import 'package:path_provider/path_provider.dart' as path_provider; + +import 'package:arabic_learning/vars/statics_var.dart'; +import 'package:arabic_learning/package_replacement/storage.dart'; +import 'package:arabic_learning/vars/config_structure.dart' show ClassItem, SourceItem, Config, DictData, WordItem, WordMeaning; +import 'package:arabic_learning/package_replacement/fake_dart_io.dart' if (dart.library.io) 'dart:io' as io; +import 'package:arabic_learning/package_replacement/fake_sherpa_onnx.dart' if (dart.library.io) 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; + +class Global with ChangeNotifier { + final Logger uiLogger = Logger("UI"); + final Logger logger = Logger("Global"); + + bool backupFontLoaded = false; + String? arFont; + String? zhFont; + bool updateLogRequire = false; //是否需要显示更新日志 + + ThemeData get themeData => ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: StaticsVar.themeList[AppData().config.regular.theme], + brightness: AppData().config.regular.darkMode ? Brightness.dark : Brightness.light, + ), + fontFamily: zhFont, + ); + + + Future init() async { + logger.info("开始全局控制类初始化"); + + AppData appData = AppData(); + await appData.init(); + FSRS().init(); + + if(appData.isFirstStart) { + logger.info("首次启动检测为真"); + appData.initStorageValue(); + await refreshApp(); + } else { + conveySetting(); + await updateSetting(); + } + + logger.info("初始化完成"); + return true; + } + + // 预处理一些版本更新的配置文件兼容 + void conveySetting() { + logger.info("处理配置文件"); + + Config oldConfig = Config.buildFromMap(jsonDecode(AppData().storage.getString("settingData")!)); + if(oldConfig.lastVersion != AppData().config.lastVersion) { + logger.info("检测到当前版本与上次启动版本不同"); + updateLogRequire = true; + oldConfig=oldConfig.copyWith(lastVersion: AppData().config.lastVersion); + } + + AppData().config = oldConfig; + logger.info("配置文件合成完成"); + } + + // 更新配置到存储中 + Future updateSetting({Map? settingData, bool refresh = true}) async { + logger.info("保存配置文件中"); + if(settingData != null) AppData().config = Config.buildFromMap(settingData); + AppData().storage.setString("settingData", jsonEncode(AppData().config.toMap())); + if(refresh) await refreshApp(); + } + + Future loadFont() async { + if(backupFontLoaded) return; + try{ + final ByteData bundle = await rootBundle.load("assets/fonts/zh/NotoSansSC-Medium.ttf"); + final FontLoader loader = FontLoader(StaticsVar.zhBackupFont)..addFont(Future.value(bundle)); + await loader.load(); + } catch (e) { + logger.severe("无法加载备用字体"); + return; + } + backupFontLoaded = true; + notifyListeners(); + } + + void changeLoggerBehavior() { + Logger.root.clearListeners(); + if(kDebugMode){ + Logger.root.onRecord.listen((record) async { + debugPrint('${record.time}-[${record.loggerName}][${record.level.name}]: ${record.message}'); + }); + } + if(AppData().config.debug.enableInternalLog){ + Logger.root.level = Level.ALL; + const List levelList = [Level.ALL, Level.FINEST, Level.FINER, Level.FINE, Level.INFO, Level.WARNING, Level.SEVERE, Level.SHOUT, Level.OFF]; + AppData appData = AppData(); + Logger.root.onRecord.listen((record) async { + if(record.level < levelList[AppData().config.debug.internalLevel]) return; + appData.internalLogCapture.add('${record.time}-[${record.loggerName}][${record.level.name}]: ${record.message}'); + }); + } + } + + Future refreshApp() async { + logger.info("应用设置中"); + AppData appData = AppData(); + if(appData.config.audio.audioSource == 2) await appData.loadTTS(appData.config.audio.playRate); + if(appData.config.egg.stella) await appData.loadEggs(); + changeLoggerBehavior(); + updateTheme(); + notifyListeners(); + logger.info("应用设置完成"); + } + + void updateTheme() { + logger.info("更新主题中"); + if(AppData().config.regular.font == 2) { + arFont = StaticsVar.arBackupFont; + zhFont = StaticsVar.zhBackupFont; + loadFont(); + } else if(AppData().config.regular.font == 1) { + arFont = StaticsVar.arBackupFont; + zhFont = null; + } else { + arFont = null; + zhFont = null; + } + } + + void updateLearningStreak(){ + final int nowDate = DateTime.now().difference(DateTime(2025, 11, 1)).inDays; + if (nowDate == AppData().config.learning.lastDate) return; + logger.info("保存学习进度中"); + // 以 2025/11/1 为基准计算天数(因为这个bug是这天修的:} ) + if (nowDate - AppData().config.learning.lastDate > 1) { + AppData().config = AppData().config.copyWith(learning: AppData().config.learning.copyWith(startDate: nowDate)); + } + AppData().config = AppData().config.copyWith(learning: AppData().config.learning.copyWith(lastDate: nowDate)); + updateSetting(refresh: false); + logger.info("学习进度保存完成"); + } +} + +class AppData { + // 作为单例 + static final AppData _instance = AppData._internal(); + factory AppData() => _instance; + AppData._internal(); + + bool inited = false; + Logger logger = Logger("AppData"); + + List internalLogCapture = []; + Uint8List? stella; + bool isWideScreen = false; + Config config = Config(); + + late final SharedPreferences storage; + late final io.Directory basePath; + late FSRS fsrs; + late DictData wordData; + sherpa_onnx.OfflineTts? vitsTTS; + + int get wordCount => wordData.words.length; + bool get isFirstStart => storage.getString("settingData") == null; + bool get modelTTSDownloaded => io.File("${basePath.path}/${StaticsVar.modelPath}/ar_JO-kareem-medium.onnx").existsSync(); + + Future init() async { + if(inited) return; + storage = await SharedPreferences.getInstance(); + if(!kIsWeb) { + basePath = (await path_provider.getApplicationDocumentsDirectory()) as io.Directory; + } + + if(!isFirstStart) { + wordData = DictData.buildFromMap(jsonDecode(storage.getString("wordData")!)); + if(!BKSearch.isReady) BKSearch.init(wordData.words); + FSRS().init(); + } + inited = true; + } + + Future initStorageValue() async { + await storage.setString("wordData", jsonEncode({"Words": [], "Classes": {}})); + wordData = DictData(words: [], classes: []); + logger.info("配置表初始化完成"); + } + + // load TTS model if any + Future loadTTS(double playRate) async { + if(kIsWeb || vitsTTS != null || !modelTTSDownloaded) return; + logger.info("TTS: 加载本地TTS中"); + sherpa_onnx.initBindings(); + final vits = sherpa_onnx.OfflineTtsVitsModelConfig( + model: "${basePath.path}/${StaticsVar.modelPath}/ar_JO-kareem-medium.onnx", + dataDir: "${basePath.path}/${StaticsVar.modelPath}/espeak-ng-data", + tokens: '${basePath.path}/${StaticsVar.modelPath}/tokens.txt', + lengthScale: 1 / playRate, + ); + final modelConfig = sherpa_onnx.OfflineTtsModelConfig( + vits: vits, + numThreads: 2, + debug: false, + provider: 'cpu', + ); + final config = sherpa_onnx.OfflineTtsConfig( + model: modelConfig, + maxNumSenetences: 1, + ); + + vitsTTS = sherpa_onnx.OfflineTts(config); + logger.info("TTS: 本地TTS加载完成"); + } + + Future loadEggs() async { + if(stella == null){ + final rawString = await rootBundle.loadString("assets/eggs/s.txt"); + stella = base64Decode(rawString); + } + } + + /// Non-Format Data: + /// { + /// "ClassName": [ + /// { + /// "chinese": {Chinese}, + /// "arabic": {arabic}, + /// "explanation": {explanation} + /// }, ... + /// ] + /// } + /// Format Data: + /// { + /// "Words" : [ + /// { + /// "arabic": {arabic}, + /// "chinese": {Chinese}, + /// "explanation": {explanation}, + /// "subClass": {ClassName}, + /// "learningProgress": {times} //int + /// }, ... + /// ], + /// "Classes": { + /// "SourceJsonFileName": { + /// "ClassName": [wordINDEX], + /// } + /// } + /// } + DictData dataFormater(Map data, DictData existData, String sourceName) { + logger.info("开始词汇格式化"); + + // Use Maps for O(1) lookup speed instead of O(N) List.indexOf + Map rawWordMap = {}; + Map pureWordMap = {}; + List chineseList = []; + + for(int i = 0; i < existData.words.length; i++) { + WordItem x = existData.words[i]; + rawWordMap[x.arabic] = i; + pureWordMap[x.arabic.removeAracicExtensionPart().trim()] = i; + chineseList.add(x.chinese); // Keep list for indexing since it maps 1:1 with word id + } + + int counter = existData.words.length; + + SourceItem? exSource; + // 查找已有数据中是否有同名的源数据组 + for(SourceItem x in existData.classes) { + if(x.sourceJsonFileName == sourceName) exSource = x; + } + if(exSource == null){ + existData.classes.add(SourceItem(sourceJsonFileName: sourceName, subClasses: [])); + exSource = existData.classes.last; + } + + for(var className in data.keys){ + ClassItem exClass = ClassItem(className: className, wordIndexs: []); + for(var word in data[className]){ + String newRaw = word["arabic"]; + String newPure = newRaw.removeAracicExtensionPart().trim(); + int existingIndex = -1; + + if (rawWordMap.containsKey(newRaw)) { + existingIndex = rawWordMap[newRaw]!; + } else if (pureWordMap.containsKey(newPure)) { + int potentialIndex = pureWordMap[newPure]!; + // Pure arabic is the same, but different vowels. Are they the same meaning? + if (chineseList[potentialIndex].hasSimilarMeaning(word["chinese"])) { + existingIndex = potentialIndex; + } + } + + if (existingIndex != -1) { + // 已存在该词:尝试追加来自新词库的释义 + WordItem existing = existData.words[existingIndex]; + bool alreadyHasThisSource = existing.meanings + .any((m) => m.source == sourceName && m.className == className); + if (!alreadyHasThisSource) { + existData.words[existingIndex] = existing.addMeaning(WordMeaning( + chinese: word["chinese"], + explanation: word["explanation"], + className: className, + source: sourceName, + )); + } + if(!exClass.wordIndexs.contains(existingIndex)) { + exClass.wordIndexs.add(existingIndex); + } + continue; + } + + // 全新词:构造含 source 的 WordMeaning + exClass.wordIndexs.add(counter); + existData.words.add( + WordItem( + arabic: word["arabic"], + meanings: List.unmodifiable([ + WordMeaning( + chinese: word["chinese"], + explanation: word["explanation"], + className: className, + source: sourceName, + ) + ]), + id: counter, + ) + ); + rawWordMap[newRaw] = counter; + pureWordMap[newPure] = counter; + chineseList.add(word["chinese"]); + counter++; + } + exSource.subClasses.add(exClass); + } + return existData; + } + + void importDictData(Map importData, String source) { + logger.info("收到词汇导入请求"); + wordData = dataFormater(importData, wordData, source); + storage.setString("wordData", jsonEncode(wordData.toMap())); + BKSearch.init(wordData.words); // 重新建树 + logger.info("词汇导入完成"); + } + + /// 原地迁移旧格式词汇数据,为 meanings[0].source == "" 的词补充正确的来源信息。 + /// + /// 旧数据是扁平格式(单条 meaning,source 为空字符串), + /// 但 SourceItem → ClassItem → wordIndexs 结构仍然保存了词汇与词典的映射关系, + /// 因此可以在不重读 JSON 文件的情况下完成迁移。 + /// + /// 返回值:本次迁移的词数(0 表示无需迁移) + int migrateOldWordData() { + logger.info("开始旧词汇数据迁移"); + int migratedCount = 0; + + for (final SourceItem src in wordData.classes) { + for (final ClassItem classItem in src.subClasses) { + for (final int wordIndex in classItem.wordIndexs) { + if (wordIndex >= wordData.words.length) continue; + final WordItem word = wordData.words[wordIndex]; + // 只迁移旧格式:meanings 只有一条且 source 为空 + if (word.meanings.length == 1 && word.meanings[0].source.isEmpty) { + wordData.words[wordIndex] = WordItem( + arabic: word.arabic, + id: word.id, + meanings: List.unmodifiable([ + WordMeaning( + chinese: word.meanings[0].chinese, + explanation: word.meanings[0].explanation, + className: classItem.className, + source: src.sourceJsonFileName, + ) + ]), + ); + migratedCount++; + } + } + } + } + + if (migratedCount > 0) { + storage.setString("wordData", jsonEncode(wordData.toMap())); + logger.info("旧词汇数据迁移完成,共迁移 $migratedCount 个词"); + } else { + logger.info("无需迁移旧词汇数据"); + } + return migratedCount; + } +} \ No newline at end of file