Ver Fonte

合集解锁动画

guoziyun há 7 meses atrás
pai
commit
0138429865
83 ficheiros alterados com 4193 adições e 253 exclusões
  1. 5 3
      android/app/build.gradle.kts
  2. BIN
      assets/audio/bgm/loop.mp3
  3. BIN
      assets/audio/bgm/loop4.mp3
  4. BIN
      assets/audio/bgm/loop7.mp3
  5. BIN
      assets/audio/sfx/alert.mp3
  6. BIN
      assets/audio/sfx/appear.mp3
  7. BIN
      assets/audio/sfx/button_click8.mp3
  8. BIN
      assets/audio/sfx/card.mp3
  9. BIN
      assets/audio/sfx/click.mp3
  10. BIN
      assets/audio/sfx/click2.mp3
  11. BIN
      assets/audio/sfx/collect_coins.mp3
  12. BIN
      assets/audio/sfx/dealing.mp3
  13. BIN
      assets/audio/sfx/flip.mp3
  14. BIN
      assets/audio/sfx/hint.mp3
  15. BIN
      assets/audio/sfx/hint_collected.mp3
  16. BIN
      assets/audio/sfx/kick.mp3
  17. BIN
      assets/audio/sfx/pop.mp3
  18. BIN
      assets/audio/sfx/silence.mp3
  19. BIN
      assets/audio/sfx/star.mp3
  20. BIN
      assets/audio/sfx/success.mp3
  21. BIN
      assets/audio/sfx/success2.mp3
  22. BIN
      assets/audio/sfx/success3.mp3
  23. BIN
      assets/audio/sfx/success4.mp3
  24. BIN
      assets/audio/sfx/success5.mp3
  25. BIN
      assets/audio/sfx/success6.mp3
  26. BIN
      assets/audio/sfx/tap.mp3
  27. BIN
      assets/builtin/1.jpeg
  28. BIN
      assets/builtin/2.jpeg
  29. BIN
      assets/builtin/3.jpeg
  30. BIN
      assets/builtin/4.jpeg
  31. BIN
      assets/builtin/5.jpeg
  32. BIN
      assets/builtin/6.jpeg
  33. BIN
      assets/builtin/7.jpeg
  34. BIN
      assets/builtin/8.jpeg
  35. BIN
      assets/builtin/9.jpeg
  36. BIN
      assets/builtin/collection.jpeg
  37. 14 0
      assets/builtin/collection.json
  38. 78 0
      assets/builtin/latest.json
  39. BIN
      assets/icons/test.jpg
  40. 0 0
      assets/images/backcard_blue.png
  41. BIN
      assets/images/backcard_green.png
  42. BIN
      assets/images/backcard_red.png
  43. BIN
      assets/images/banner.png
  44. BIN
      assets/images/banner2.png
  45. BIN
      assets/images/banner3.png
  46. BIN
      assets/images/banner4.png
  47. BIN
      assets/images/opt.jpeg
  48. 0 0
      assets/lottie/box.json
  49. 0 0
      assets/lottie/loading.json
  50. 160 0
      lib/audio/audio_controller.dart
  51. 155 0
      lib/collection/collection_screen.dart
  52. 145 0
      lib/collection/detail_dialog.dart
  53. 138 0
      lib/collection/grid_item.dart
  54. 99 0
      lib/homepage/home_board.dart
  55. 507 0
      lib/homepage/home_board_play.dart
  56. 243 0
      lib/homepage/home_screen.dart
  57. 34 86
      lib/main.dart
  58. 27 0
      lib/models/api_helper.dart
  59. 163 0
      lib/models/cached_request.dart
  60. 88 0
      lib/models/data.dart
  61. 335 0
      lib/models/download.dart
  62. 174 0
      lib/models/items.dart
  63. 233 0
      lib/persistence/persistence.dart
  64. 18 37
      lib/play/board.dart
  65. 58 23
      lib/play/board_painter.dart
  66. 446 78
      lib/play/board_play.dart
  67. 42 0
      lib/play/confetti_layer.dart
  68. 12 18
      lib/play/piece.dart
  69. 52 0
      lib/settings/settings_controller.dart
  70. 204 0
      lib/settings/settings_dialog.dart
  71. 50 0
      lib/skin/skin.dart
  72. 30 0
      lib/statistics/statistics.dart
  73. 49 0
      lib/utils/mybutton.dart
  74. 52 0
      lib/utils/ui_image.dart
  75. 260 0
      lib/utils/utils.dart
  76. 4 0
      linux/flutter/generated_plugin_registrant.cc
  77. 1 0
      linux/flutter/generated_plugins.cmake
  78. 10 0
      macos/Flutter/GeneratedPluginRegistrant.swift
  79. 26 0
      macos/Podfile.lock
  80. 257 8
      pubspec.lock
  81. 20 0
      pubspec.yaml
  82. 3 0
      windows/flutter/generated_plugin_registrant.cc
  83. 1 0
      windows/flutter/generated_plugins.cmake

+ 5 - 3
android/app/build.gradle.kts

@@ -7,9 +7,11 @@ plugins {
 
 android {
     namespace = "com.example.image_puzzle"
-    compileSdk = flutter.compileSdkVersion
-    ndkVersion = flutter.ndkVersion
-
+    // compileSdk = flutter.compileSdkVersion
+    // ndkVersion = flutter.ndkVersion
+    compileSdk = 36
+    ndkVersion = "27.0.12077973"
+    
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_11
         targetCompatibility = JavaVersion.VERSION_11

BIN
assets/audio/bgm/loop.mp3


BIN
assets/audio/bgm/loop4.mp3


BIN
assets/audio/bgm/loop7.mp3


BIN
assets/audio/sfx/alert.mp3


BIN
assets/audio/sfx/appear.mp3


BIN
assets/audio/sfx/button_click8.mp3


BIN
assets/audio/sfx/card.mp3


BIN
assets/audio/sfx/click.mp3


BIN
assets/audio/sfx/click2.mp3


BIN
assets/audio/sfx/collect_coins.mp3


BIN
assets/audio/sfx/dealing.mp3


BIN
assets/audio/sfx/flip.mp3


BIN
assets/audio/sfx/hint.mp3


BIN
assets/audio/sfx/hint_collected.mp3


BIN
assets/audio/sfx/kick.mp3


BIN
assets/audio/sfx/pop.mp3


BIN
assets/audio/sfx/silence.mp3


BIN
assets/audio/sfx/star.mp3


BIN
assets/audio/sfx/success.mp3


BIN
assets/audio/sfx/success2.mp3


BIN
assets/audio/sfx/success3.mp3


BIN
assets/audio/sfx/success4.mp3


BIN
assets/audio/sfx/success5.mp3


BIN
assets/audio/sfx/success6.mp3


BIN
assets/audio/sfx/tap.mp3


BIN
assets/builtin/1.jpeg


BIN
assets/builtin/2.jpeg


BIN
assets/builtin/3.jpeg


BIN
assets/builtin/4.jpeg


BIN
assets/builtin/5.jpeg


BIN
assets/builtin/6.jpeg


BIN
assets/builtin/7.jpeg


BIN
assets/builtin/8.jpeg


BIN
assets/builtin/9.jpeg


BIN
assets/builtin/collection.jpeg


+ 14 - 0
assets/builtin/collection.json

@@ -0,0 +1,14 @@
+{
+  "data": [
+    {
+      "_id": "1",
+      "width": 2000,
+      "height": 2772,
+      "difficulty": 3,
+      "thumb": "assets/builtin/collection.jpeg",
+      "raw": "assets/builtin/collection.jpeg"
+    }
+  ],
+  "asset": true,
+  "total": 1
+}

+ 78 - 0
assets/builtin/latest.json

@@ -0,0 +1,78 @@
+{
+  "data": [
+    {
+      "_id": "1",
+      "width": 2375,
+      "height": 3316,
+      "difficulty": 3,
+      "thumb": "assets/builtin/1.jpeg",
+      "raw": "assets/builtin/1.jpeg"
+    },
+    {
+      "_id": "2",
+      "width": 2000,
+      "height": 2646,
+      "difficulty": 4,
+      "thumb": "assets/builtin/2.jpeg",
+      "raw": "assets/builtin/2.jpeg"
+    },
+    {
+      "_id": "3",
+      "width": 1956,
+      "height": 2520,
+      "difficulty": 5,
+      "thumb": "assets/builtin/3.jpeg",
+      "raw": "assets/builtin/3.jpeg"
+    },
+    {
+      "_id": "4",
+      "width": 1449,
+      "height": 1919,
+      "difficulty": 6,
+      "thumb": "assets/builtin/4.jpeg",
+      "raw": "assets/builtin/4.jpeg"
+    },
+    {
+      "_id": "5",
+      "width": 1892,
+      "height": 2627,
+      "difficulty": 7,
+      "thumb": "assets/builtin/5.jpeg",
+      "raw": "assets/builtin/5.jpeg"
+    },
+    {
+      "_id": "6",
+      "width": 3088,
+      "height": 3960,
+      "difficulty": 6,
+      "thumb": "assets/builtin/6.jpeg",
+      "raw": "assets/builtin/6.jpeg"
+    },
+    {
+      "_id": "7",
+      "width": 2000,
+      "height": 2614,
+      "difficulty": 5,
+      "thumb": "assets/builtin/7.jpeg",
+      "raw": "assets/builtin/7.jpeg"
+    },
+    {
+      "_id": "8",
+      "width": 1347,
+      "height": 1705,
+      "difficulty": 4,
+      "thumb": "assets/builtin/8.jpeg",
+      "raw": "assets/builtin/8.jpeg"
+    },
+    {
+      "_id": "9",
+      "width": 2304,
+      "height": 2854,
+      "difficulty": 4,
+      "thumb": "assets/builtin/9.jpeg",
+      "raw": "assets/builtin/9.jpeg"
+    }
+  ],
+  "asset": true,
+  "total": 9
+}

BIN
assets/icons/test.jpg


+ 0 - 0
assets/images/backcard.png → assets/images/backcard_blue.png


BIN
assets/images/backcard_green.png


BIN
assets/images/backcard_red.png


BIN
assets/images/banner.png


BIN
assets/images/banner2.png


BIN
assets/images/banner3.png


BIN
assets/images/banner4.png


BIN
assets/images/opt.jpeg


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
assets/lottie/box.json


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
assets/lottie/loading.json


+ 160 - 0
lib/audio/audio_controller.dart

@@ -0,0 +1,160 @@
+// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:flutter/widgets.dart';
+import 'package:image_puzzle/settings/settings_controller.dart';
+import 'package:jc_audio_player/jc_audio_player.dart';
+import 'package:logging/logging.dart';
+
+enum SfxType { drop, click, tap, kick, pop, appear, alert, star, success, flip, card }
+
+//AudioLogger.logLevel = AudioLogLevel.info;
+
+/// Allows playing music and sound. A facade to `package:audioplayers`.
+class AudioController {
+  static final _log = Logger('AudioController');
+
+  SettingsController? _settings;
+
+  ValueNotifier<AppLifecycleState>? _lifecycleNotifier;
+
+  final JcAudioPlayer _audioPlayer = JcAudioPlayer();
+
+  AudioController() {
+    _audioPlayer.addMusic(1, 'assets/audio/bgm/loop.mp3');
+    // _audioPlayer.addMusic(1, 'assets/audio/bgm/loop4.mp3');
+    // _audioPlayer.addMusic(1, 'assets/audio/bgm/loop7.mp3');
+
+    _audioPlayer.addSound(SfxType.drop.index, 'assets/audio/sfx/button_click8.mp3');
+    _audioPlayer.addSound(SfxType.click.index, 'assets/audio/sfx/click2.mp3');
+    _audioPlayer.addSound(SfxType.tap.index, 'assets/audio/sfx/tap.mp3');
+    _audioPlayer.addSound(SfxType.kick.index, 'assets/audio/sfx/kick.mp3');
+    _audioPlayer.addSound(SfxType.pop.index, 'assets/audio/sfx/pop.mp3');
+    _audioPlayer.addSound(SfxType.appear.index, 'assets/audio/sfx/appear.mp3');
+    _audioPlayer.addSound(SfxType.alert.index, 'assets/audio/sfx/alert.mp3');
+    _audioPlayer.addSound(SfxType.star.index, 'assets/audio/sfx/star.mp3');
+    _audioPlayer.addSound(SfxType.success.index, 'assets/audio/sfx/success3.mp3');
+    // _audioPlayer.addSound(SfxType.success.index, 'assets/audio/sfx/success5.mp3');
+    // _audioPlayer.addSound(SfxType.success.index, 'assets/audio/sfx/success4.mp3');
+    _audioPlayer.addSound(SfxType.card.index, 'assets/audio/sfx/card.mp3');
+    _audioPlayer.addSound(SfxType.flip.index, 'assets/audio/sfx/flip.mp3');
+  }
+
+  void initialize() {
+    _startMusic();
+  }
+
+  void dispose() {
+    _lifecycleNotifier?.removeListener(_handleAppLifecycle);
+    _stopMusic();
+    _stopSound();
+  }
+
+  /// Enables the [AudioController] to listen to [AppLifecycleState] events,
+  /// and therefore do things like stopping playback when the game
+  /// goes into the background.
+  void attachLifecycleNotifier(ValueNotifier<AppLifecycleState> lifecycleNotifier) {
+    _lifecycleNotifier?.removeListener(_handleAppLifecycle);
+
+    lifecycleNotifier.addListener(_handleAppLifecycle);
+    _lifecycleNotifier = lifecycleNotifier;
+  }
+
+  /// Enables the [AudioController] to track changes to settings.
+  /// Namely, when any of [SettingsController.muted],
+  /// [SettingsController.musicOn] or [SettingsController.soundsOn] changes,
+  /// the audio controller will act accordingly.
+  void attachSettings(SettingsController settingsController) {
+    if (_settings == settingsController) {
+      // Already attached to this instance. Nothing to do.
+      return;
+    }
+
+    // Remove handlers from the old settings controller if present
+    final oldSettings = _settings;
+    if (oldSettings != null) {
+      oldSettings.music.removeListener(_musicOnHandler);
+      oldSettings.sound.removeListener(_soundOnHandler);
+    }
+
+    _settings = settingsController;
+
+    // Add handlers to the new settings controller
+    settingsController.music.addListener(_musicOnHandler);
+    settingsController.sound.addListener(_soundOnHandler);
+  }
+
+  /// Plays a single sound effect, defined by [type].
+  ///
+  /// The controller will ignore this call when the attached settings'
+  /// [SettingsController.soundsOn] is `false`.
+  void playSfx(SfxType type, {Duration? duration}) async {
+    final soundsOn = _settings?.sound.value ?? false;
+    if (!soundsOn) {
+      _log.info(() => 'Ignoring playing sound ($type) because sounds are turned off.');
+      return;
+    }
+
+    _audioPlayer.playSound(type.index);
+    if (duration != null) {
+      Future.delayed(duration, () => _audioPlayer.stopSound());
+    }
+  }
+
+  void _handleAppLifecycle() {
+    switch (_lifecycleNotifier!.value) {
+      case AppLifecycleState.paused:
+      case AppLifecycleState.detached:
+      case AppLifecycleState.hidden:
+      case AppLifecycleState.inactive:
+        _pauseMusic();
+        break;
+      case AppLifecycleState.resumed:
+        _startMusic();
+        break;
+    }
+  }
+
+  void _musicOnHandler() {
+    if (_settings!.music.value) {
+      // Music got turned on.
+      // _resumeMusic();
+      _startMusic();
+    } else {
+      // Music got turned off.
+      _stopMusic();
+    }
+  }
+
+  void _soundOnHandler() {}
+
+  void _startMusic() {
+    _log.info('starting music');
+    final musicOn = _settings?.music.value ?? false;
+    if (!musicOn) {
+      _log.info(() => 'Ignoring playing music because music are turned off.');
+      return;
+    }
+    _audioPlayer.playMusic(1, volume: 0.5);
+  }
+
+  void _stopMusic() {
+    _log.info('Stopping music');
+    _audioPlayer.stopMusic();
+  }
+
+  void _pauseMusic() {
+    _log.info('pause music');
+    _audioPlayer.pauseMusic();
+  }
+
+  Future<void> _resumeMusic() async {
+    _log.info('Resuming music');
+    _audioPlayer.resumeMusic();
+  }
+
+  void _stopSound() {
+    _audioPlayer.stopSound();
+  }
+}

+ 155 - 0
lib/collection/collection_screen.dart

@@ -0,0 +1,155 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/audio/audio_controller.dart';
+import 'package:image_puzzle/collection/grid_item.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/models/cached_request.dart';
+import 'package:image_puzzle/models/data.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:logging/logging.dart';
+import 'package:lottie/lottie.dart';
+import 'package:provider/provider.dart';
+
+final Logger _log = Logger('collection_screen');
+
+class CollectionScreen extends StatefulWidget {
+  const CollectionScreen({super.key});
+
+  @override
+  State<StatefulWidget> createState() => _CollectionScreen();
+
+  static PageRouteBuilder buildRoute() {
+    return PageRouteBuilder(
+      pageBuilder: (context, animation, secondaryAnimation) {
+        return CollectionScreen();
+      },
+      transitionsBuilder: (context, animation, secondaryAnimation, child) {
+        // return FadeTransition(opacity: animation, child: child);
+        return SlideTransition(
+          position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(animation),
+          child: child,
+        );
+      },
+    );
+  }
+}
+
+class _CollectionScreen extends State<CollectionScreen> {
+  late AudioController audio;
+  late Data data;
+  List<ListItem>? collection;
+  late CachedRequest collectionCachedRequest;
+  late StreamSubscription? collectionSubscription;
+
+  @override
+  void initState() {
+    super.initState();
+
+    audio = context.read<AudioController>();
+    data = context.read<Data>();
+    collectionCachedRequest = data.collection;
+    // 主动获取缓存数据(关键)
+    final collectionCachedData = collectionCachedRequest.cachedData;
+    if (collectionCachedData != null) {
+      _onCollectionDataUpdate(collectionCachedData);
+    }
+    collectionSubscription = collectionCachedRequest.stream.listen(_onCollectionDataUpdate, onError: _onCollectionDataError);
+  }
+
+  @override
+  void dispose() {
+    collectionSubscription?.cancel();
+
+    super.dispose();
+  }
+
+  _onCollectionDataUpdate(data) async {
+    _log.info('_onCollectionDataUpdate.... ');
+    if (data != null) {
+      collection = data as List<ListItem>;
+      setState(() {});
+    }
+  }
+
+  _onCollectionDataError(error) {
+    _log.info('_onCollectionDataError.... $error');
+  }
+
+  Future<void> refresh() async {
+    _log.info('refresh...');
+    await collectionCachedRequest.refresh();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final device = context.read<Device>();
+    final isTablet = device.isTablet;
+    return Scaffold(
+      appBar: AppBar(
+        backgroundColor: Colors.white,
+        elevation: 1,
+        centerTitle: true,
+        leading: IconButton(
+          onPressed: () {
+            audio.playSfx(SfxType.tap);
+            Navigator.pop(context);
+          },
+          icon: const Icon(Icons.arrow_back_outlined, color: Colors.black54),
+        ),
+        title: const Text(
+          '收藏',
+          style: TextStyle(color: Colors.black54, fontFamily: 'Arial Black', fontWeight: FontWeight.bold, fontSize: 24),
+        ),
+      ),
+      body: collection == null
+          ? scrollableDummy
+          : RefreshIndicator(
+              onRefresh: refresh,
+              child: CustomScrollView(
+                slivers: <Widget>[
+                  // header
+                  SliverPadding(
+                    sliver: SliverGrid(
+                      gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: isTablet ? 300 : 210, childAspectRatio: 2 / 3),
+                      delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
+                        return _buildItem(context, index);
+                      }, childCount: collection!.length),
+                    ),
+                    padding: const EdgeInsets.only(left: 10.0, right: 10.0),
+                  ),
+                ],
+              ),
+            ),
+    );
+  }
+
+  Widget get scrollableDummy => LayoutBuilder(
+    builder: (p0, p1) {
+      return SingleChildScrollView(
+        physics: const AlwaysScrollableScrollPhysics(),
+        child: SizedBox(
+          height: p1.maxHeight,
+          child: Center(
+            child: ListView(
+              shrinkWrap: true,
+              children: [
+                Lottie.asset('assets/lottie/loading.json', height: 100),
+                const Center(child: Text("loading...")),
+              ],
+            ),
+          ),
+        ),
+      );
+    },
+  );
+
+  Widget _buildItem(context, index) {
+    ListItem item = collection![index];
+    final bool isLocked = index >= data.currentCollectionIndex; // 假设 currentCollectionIndex 之前是解锁的
+    return Padding(
+      padding: const EdgeInsets.all(10.0),
+      child: GridItem(item: item, lock: false, index: index),
+    );
+  }
+}

+ 145 - 0
lib/collection/detail_dialog.dart

@@ -0,0 +1,145 @@
+// collection/collection_screen.dart
+
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/models/download.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'dart:ui' as ui;
+
+import 'package:provider/provider.dart';
+
+class ImageDetailDialog extends StatefulWidget {
+  final ListItem item;
+  const ImageDetailDialog({super.key, required this.item});
+
+  @override
+  State<StatefulWidget> createState() => _ImageDetailDialog();
+}
+
+class _ImageDetailDialog extends State<ImageDetailDialog> {
+  bool _isLoading = true;
+
+  ui.Image? image;
+
+  String? _error; // 新增错误状态
+
+  @override
+  void initState() {
+    super.initState();
+
+    loadImage();
+  }
+
+  void loadImage() async {
+    try {
+      Device device = context.read<Device>();
+      double dpr = device.devicePixelRatio;
+
+      // 计算最佳尺寸(屏幕宽度90%,按2:3比例计算高度)
+      final bestWidth = (device.screenSize.width * 0.9 * dpr).round();
+      final bestHeight = (bestWidth * 3 / 2).round();
+
+      ItemLoader itemLoader = ItemLoader.load(widget.item);
+      final loadedImage = await itemLoader.getImageBySize(bestWidth, bestHeight);
+
+      if (mounted) {
+        setState(() {
+          image = loadedImage;
+          _isLoading = false;
+          _error = null;
+        });
+      }
+    } catch (e) {
+      if (mounted) {
+        setState(() {
+          _isLoading = false;
+          _error = "图片加载失败";
+        });
+      }
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final device = context.read<Device>();
+    final screenWidth = device.screenSize.width;
+
+    return Scaffold(
+      // 背景设为黑色半透明,突出图片
+      backgroundColor: Colors.black.withOpacity(0.8),
+      body: Stack(
+        children: [
+          // 主内容区:加载中/错误/图片
+          Center(child: _buildContent(screenWidth)),
+          // 左上角关闭按钮
+          Positioned(
+            top: device.appBarHeight + 16, // 适配状态栏高度
+            left: 16,
+            child: _buildCloseButton(),
+          ),
+        ],
+      ),
+    );
+  }
+
+  // 构建主体内容(加载中/错误/图片)
+  Widget _buildContent(double screenWidth) {
+    if (_isLoading) {
+      // 加载状态:居中显示进度条
+      return const CircularProgressIndicator(color: Colors.white, strokeWidth: 3);
+    }
+
+    if (_error != null) {
+      // 错误状态:显示错误信息和重试按钮
+      return Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          const Icon(Icons.error_outline, color: Colors.red, size: 48),
+          const SizedBox(height: 16),
+          Text(_error!, style: const TextStyle(color: Colors.white, fontSize: 18)),
+          TextButton(
+            onPressed: loadImage,
+            child: const Text("重试", style: TextStyle(color: Colors.blue, fontSize: 16)),
+          ),
+        ],
+      );
+    }
+
+    return ConstrainedBox(
+      constraints: BoxConstraints(
+        maxWidth: screenWidth * 0.9, // 最大宽度为屏幕90%
+      ),
+      child: ClipRRect(
+        borderRadius: BorderRadius.circular(16), // 圆角大小(可自定义,如12、16)
+        // 可选:添加边框+背景,让圆角更有层次感
+        child: DecoratedBox(
+          decoration: BoxDecoration(
+            color: Colors.grey[900], // 图片加载前的背景色(避免白边)
+            // 可选:添加边框
+            border: Border.all(
+              color: Colors.white.withOpacity(0.3), // 边框颜色(半透明白色)
+              width: 2, // 边框宽度
+            ),
+          ),
+          child: RawImage(image: image!, fit: BoxFit.contain, alignment: Alignment.center),
+        ),
+      ),
+    );
+  }
+
+  // 构建关闭按钮
+  Widget _buildCloseButton() {
+    return GestureDetector(
+      onTap: () => Navigator.pop(context),
+      child: Container(
+        width: 40,
+        height: 40,
+        decoration: BoxDecoration(
+          color: Colors.black54, // 半透黑背景
+          shape: BoxShape.circle,
+        ),
+        child: const Icon(Icons.close, color: Colors.white, size: 24),
+      ),
+    );
+  }
+}

+ 138 - 0
lib/collection/grid_item.dart

@@ -0,0 +1,138 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/collection/detail_dialog.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/skin/skin.dart';
+import 'package:provider/provider.dart';
+
+class GridItem extends StatefulWidget {
+  final ListItem item;
+  final bool lock;
+  final int index;
+  const GridItem({super.key, required this.item, required this.lock, required this.index});
+
+  @override
+  State<StatefulWidget> createState() {
+    return _GridItemState();
+  }
+}
+
+class _GridItemState extends State<GridItem> {
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: [
+        Hero(
+          tag: widget.item.id,
+          child: Material(
+            color: Colors.transparent,
+            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
+            elevation: 1,
+            clipBehavior: Clip.hardEdge,
+            child: Listener(
+              child: GestureDetector(
+                onTapUp: (details) {
+                  if (widget.lock) {
+                    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('这个合集还未解锁!')));
+                  } else {
+                    // 跳转到大图页面 (以全屏 Dialog 形式)
+                    Navigator.push(
+                      context,
+                      PageRouteBuilder(
+                        opaque: false, // 允许背景半透明
+                        pageBuilder: (context, animation, secondaryAnimation) => FadeTransition(
+                          opacity: animation,
+                          child: ImageDetailDialog(item: widget.item),
+                        ),
+                      ),
+                    );
+                  }
+                },
+                child: LayoutBuilder(
+                  builder: (context, constraints) {
+                    if (widget.lock) {
+                      return _buildLockedPlaceholder(constraints.biggest);
+                    } else {
+                      return _buildImage(constraints.biggest);
+                    }
+                  },
+                ),
+              ),
+            ),
+          ),
+        ),
+
+        Positioned(
+          bottom: 0,
+          right: 0,
+          child: DecoratedBox(
+            decoration: BoxDecoration(
+              color: SkinHelper.color5,
+              borderRadius: const BorderRadius.only(bottomRight: Radius.circular(4), topLeft: Radius.circular(10)),
+            ),
+            child: Padding(
+              padding: const EdgeInsets.all(6),
+              child: Text(
+                badgeStr,
+                style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
+              ),
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+
+  String get badgeStr => '${widget.index * 25 + 1}-${(widget.index + 1) * 25}';
+
+  Widget _buildImage(Size size) {
+    final device = context.read<Device>();
+    int cacheWidth = (size.width * device.devicePixelRatio).toInt();
+    int cacheHeight = (size.height * device.devicePixelRatio).toInt();
+
+    if (widget.item is AssetItem) {
+      AssetItem assetItem = widget.item as AssetItem;
+      return Image.asset(assetItem.thumb, cacheWidth: cacheWidth, cacheHeight: cacheHeight);
+    } else {
+      return network(widget.item, size.width, size.height, cacheWidth, cacheHeight);
+    }
+  }
+
+  Widget network(ListItem item, double width, double height, int cacheWidth, int cacheHeight) {
+    return CachedNetworkImage(
+      imageUrl: item.thumb,
+      fit: BoxFit.fill,
+      width: width,
+      height: height,
+      memCacheWidth: cacheWidth,
+      memCacheHeight: cacheHeight,
+      // placeholder: (context, url) => JigsawPiece.placeHolder(scale: 0.6),
+    );
+  }
+
+  Widget _buildLockedPlaceholder(Size size) {
+    return Container(
+      width: size.width,
+      height: size.height,
+      decoration: BoxDecoration(color: SkinHelper.slotBorderColor, borderRadius: BorderRadius.circular(8)),
+      child: const Center(
+        child: Icon(
+          Icons.lock,
+          size: 60, // 锁图标大小
+          color: Colors.white, // 锁图标颜色
+        ),
+      ),
+    );
+  }
+}

+ 99 - 0
lib/homepage/home_board.dart

@@ -0,0 +1,99 @@
+import 'dart:typed_data';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/models/download.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:logging/logging.dart';
+import 'dart:ui' as ui;
+
+final Logger _log = Logger('home_board');
+
+enum HomeBoardStatus { loading, shuffle, playing, done, unlocking }
+
+class HomeBoard {
+  final Device device;
+
+  // 原合集图
+  ui.Image? image;
+
+  // 纸牌背面图
+  late ui.Image cardImage;
+
+  // board 的canvas绘制区域尺寸
+  final double canvasWidth;
+  final double canvasHeight;
+
+  /// 一张合集分为几宫格,固定5x5
+  final int rows = 5;
+  final int cols = 5;
+
+  // 每个piece的逻辑尺寸
+  double get pieceLogicalWidth => canvasWidth / cols;
+  double get pieceLogicalHeight => canvasHeight / rows;
+
+  ValueNotifier boardNotifier = ValueNotifier(1);
+
+  HomeBoardStatus status = HomeBoardStatus.loading;
+
+  ListItem? _currentCollectionItem;
+  ListItem? get currentCollectionItem => _currentCollectionItem;
+  set currentCollectionItem(ListItem? item) {
+    if (item == null) return;
+    if (_currentCollectionItem == null || _currentCollectionItem!.id != item.id) {
+      _currentCollectionItem = item;
+      _loadImage();
+    }
+  }
+
+  ValueNotifier isReadyNotifier = ValueNotifier(false);
+
+  // 用于存储合集解锁动画相关信息
+  Offset _unlockTargetOffset = Offset.zero;
+  double _unlockTargetScale = 1.0;
+
+  Offset get unlockTargetOffset => _unlockTargetOffset;
+  double get unlockTargetScale => _unlockTargetScale;
+
+  void setUnlockAnimationTarget({required Offset targetOffset, required double targetScale}) {
+    _unlockTargetOffset = targetOffset;
+    _unlockTargetScale = targetScale;
+  }
+
+  HomeBoard({required this.canvasWidth, required this.canvasHeight, required this.device}) {
+    _loadCardImage();
+  }
+
+  void invalidate() {
+    boardNotifier.value++;
+  }
+
+  // 加载合集图
+  void _loadImage() async {
+    double dpr = device.devicePixelRatio;
+
+    ItemLoader itemLoader = ItemLoader.load(currentCollectionItem!);
+    image = await itemLoader.getImageBySize((canvasWidth * dpr).round(), (canvasHeight * dpr).round());
+
+    isReadyNotifier.value = true;
+    invalidate();
+  }
+
+  // 加载卡片图
+  void _loadCardImage() async {
+    double dpr = device.devicePixelRatio;
+    // 加载扑克背面图片,用于制作发牌动画
+    final Size bestCardImageSize = Size(pieceLogicalWidth * dpr, pieceLogicalHeight * dpr);
+    final ByteData cardData = await rootBundle.load('assets/images/backcard_green.png');
+    final ui.Codec cardCodec = await ui.instantiateImageCodec(
+      cardData.buffer.asUint8List(),
+      targetWidth: bestCardImageSize.width.round(),
+      targetHeight: bestCardImageSize.height.round(),
+    );
+    final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
+    cardImage = cardFrameInfo.image;
+
+    invalidate();
+  }
+}

+ 507 - 0
lib/homepage/home_board_play.dart

@@ -0,0 +1,507 @@
+import 'dart:async';
+import 'dart:math';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/audio/audio_controller.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/homepage/home_board.dart';
+import 'package:image_puzzle/models/cached_request.dart';
+import 'package:image_puzzle/models/data.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/play/confetti_layer.dart';
+import 'package:image_puzzle/skin/skin.dart';
+import 'package:logging/logging.dart';
+import 'package:provider/provider.dart';
+
+final Logger _log = Logger('home_board_play');
+
+// ignore: must_be_immutable
+class HomeBoardPlay extends StatefulWidget {
+  final double canvasWidth;
+  final double canvasHeight;
+  final GlobalKey collectionKey; // !!! 改造点 2: 接收 Collection Key
+  VoidCallback? onCollectionDone; // 新增一个合集完成的回调,外部home_screen可能会关心,当合集解锁动画完成,home_screen的左上角合集icon需要放大再复原
+
+  // todo ... 可能需要传递外部home_screen 的左上角leading IconButton 空间的global key或者位置信息过来,方便执行unlock动画定位
+
+  HomeBoardPlay({super.key, required this.canvasWidth, required this.canvasHeight, required this.collectionKey, this.onCollectionDone});
+
+  @override
+  State<HomeBoardPlay> createState() => HomeBoardPlayState();
+}
+
+class HomeBoardPlayState extends State<HomeBoardPlay> with TickerProviderStateMixin {
+  late HomeBoard board;
+  late AudioController audio;
+  late Data data;
+  List<ListItem>? collection;
+  late CachedRequest collectionCachedRequest;
+  late StreamSubscription? collectionSubscription;
+
+  late ConfettiLayer confettiLayer;
+
+  // 翻牌动画,完成一个关卡后翻开一个卡片
+  late AnimationController _flipController;
+  late Animation<double> _flipAnimation;
+
+  // todo... 合集解锁动画,完成整个合集之后执行, 效果是整个图片缩小并位移到左上角的colleciton iconbutton, 形成合集“收纳”的效果
+  late AnimationController _unlockController; //  解锁动画控制器
+  late Animation<double> _unlockAnimation; // 0.0 -> 1.0
+
+  // 当前合集是否已经完成(一个合集需要完成5x5即25个关卡才能解锁)
+  // bool get _isCollectionDone => data.currentLevel != 0 && data.currentCollectionIndex * 25 == data.currentLevel;
+  bool get _isCollectionDone => true; // for test
+
+  OverlayEntry? _overlayEntry; // 新增:用于管理全屏动画层
+
+  @override
+  void initState() {
+    super.initState();
+
+    Device device = context.read<Device>();
+    board = HomeBoard(canvasWidth: widget.canvasWidth, canvasHeight: widget.canvasHeight, device: device);
+    board.isReadyNotifier.addListener(_onBoardReady);
+
+    confettiLayer = ConfettiLayer(this);
+
+    Future.delayed(Duration.zero, () {
+      if (mounted) {
+        confettiLayer.setup(context);
+      }
+    });
+
+    audio = context.read<AudioController>();
+    data = context.read<Data>();
+    collectionCachedRequest = data.collection;
+    // 主动获取缓存数据(关键)
+    final collectionCachedData = collectionCachedRequest.cachedData;
+    if (collectionCachedData != null) {
+      _onCollectionDataUpdate(collectionCachedData);
+    }
+    collectionSubscription = collectionCachedRequest.stream.listen(_onCollectionDataUpdate, onError: _onCollectionDataError);
+
+    // 初始化翻牌动画控制器
+    _flipController = AnimationController(
+      duration: const Duration(milliseconds: 1000), // 动画时长
+      vsync: this, // HomeBoardState 必须实现 TickerProviderStateMixin
+    );
+    // 创建一个 0.0 到 1.0 的动画,用于控制翻转角度
+    _flipAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _flipController, curve: Curves.easeOut))
+      ..addStatusListener((status) {
+        if (status == AnimationStatus.completed) {
+          // 检查整个合集是否全部完成
+          _checkCollectionDone();
+        }
+      });
+
+    // 初始化解锁动画控制器
+    _unlockController = AnimationController(
+      duration: const Duration(milliseconds: 1000), // 动画时长
+      vsync: this,
+    );
+    _unlockAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _unlockController, curve: Curves.easeInBack))
+      ..addStatusListener((status) {
+        if (status == AnimationStatus.completed) {
+          // 动画结束后,通知外部(HomeScreen)
+          widget.onCollectionDone?.call();
+          // 可选:将状态最终置回 playing,准备加载下一个合集
+          // setState(() {
+          //   _status = HomeBoardStatus.playing;
+          // });
+        }
+      });
+  }
+
+  _onCollectionDataUpdate(data) async {
+    _log.info('_onCollectionDataUpdate.... ');
+    if (data != null) {
+      collection = data as List<ListItem>;
+      if (collection != null && collection!.isNotEmpty) {
+        board.currentCollectionItem = currentCollectionItem;
+      }
+
+      setState(() {});
+    }
+  }
+
+  _onCollectionDataError(error) {
+    _log.info('_onCollectionDataError.... $error');
+    if (collection == null || collection!.isEmpty || collection!.length <= 2) {
+      // 列表数据如果少于20,说明只是内置图,仍然刷新远程请求
+      _log.warning("_onCollectionDataError, retry again");
+      Future.delayed(Duration(seconds: 3), () => refresh());
+    }
+  }
+
+  Future<void> refresh() async {
+    _log.info('refresh...');
+    await collectionCachedRequest.refresh();
+  }
+
+  _onBoardReady() {
+    if (board.isReadyNotifier.value == true) {
+      setState(() {
+        board.status = HomeBoardStatus.playing;
+      });
+    }
+  }
+
+  ListItem? get currentCollectionItem {
+    if (collection != null && collection!.isNotEmpty) {
+      return collection![data.currentCollectionIndex];
+    }
+    return null;
+  }
+
+  @override
+  void dispose() {
+    board.isReadyNotifier.removeListener(_onBoardReady);
+    confettiLayer.dispose();
+    _flipController.dispose();
+    _unlockController.dispose();
+    collectionSubscription?.cancel();
+    super.dispose();
+  }
+
+  // 翻牌动画结束后,检查整个合集是否全部完成,如果全部完成,需要展示一系列的动画
+  void _checkCollectionDone() async {
+    if (_isCollectionDone) {
+      // 将状态置为done,canvas绘制一整张图,不再是单个卡片
+      setState(() {
+        board.status = HomeBoardStatus.done;
+        board.invalidate();
+      });
+
+      // 展示撒花动画
+      audio.playSfx(SfxType.success);
+      confettiLayer.play();
+
+      await Future.delayed(Duration(milliseconds: 200)); // confetti动画结束
+
+      // todo... 开始位移+缩放动画,将整个合集图片
+      _startUnlockAnimation();
+    }
+  }
+
+  // !!! 改造点 5: 实现解锁动画启动方法
+  void _startUnlockAnimation() {
+    // 检查 Key 是否关联到 RenderBox
+    final RenderBox? targetRenderBox = widget.collectionKey.currentContext?.findRenderObject() as RenderBox?;
+    if (targetRenderBox == null || !mounted) return;
+
+    // 获取目标图标的中心点(屏幕全局坐标)
+    final targetPosition = targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero));
+
+    // 获取画布的中心点(屏幕全局坐标)
+    final RenderBox? canvasRenderBox = context.findRenderObject() as RenderBox?;
+    if (canvasRenderBox == null) return;
+    final canvasCenter = canvasRenderBox.localToGlobal(canvasRenderBox.size.center(Offset.zero));
+
+    // 计算位移量
+    // dx: 目标中心X - 画布中心X
+    // dy: 目标中心Y - 画布中心Y
+    final Offset delta = targetPosition - canvasCenter;
+
+    // 存储计算出的动画目标数据,供 CustomPainter 使用
+    board.setUnlockAnimationTarget(
+      targetOffset: delta,
+      // 目标缩放比例
+      // targetScale: targetRenderBox.size.width / widget.canvasWidth,
+      targetScale: 0,
+    );
+
+    // 启动动画
+    setState(() {
+      board.status = HomeBoardStatus.unlocking; // 切换到 unlocking 状态
+    });
+    _unlockController.forward(from: 0.0);
+  }
+
+  // 对外暴露的触发动画方法 (供 HomeScreen 调用)
+  void startFlipAnimation() {
+    _flipController.forward(from: 0.0);
+    audio.playSfx(SfxType.flip);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return CustomPaint(
+      size: Size(widget.canvasWidth, widget.canvasHeight), // 固定画布尺寸
+      painter: CanvasPainter(board: board, level: data.currentLevel, flipAnimation: _flipAnimation, unlockAnimation: _unlockAnimation),
+      child: board.status == HomeBoardStatus.loading
+          ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.slotBorderColor)))
+          : Container(), // 加载完成后不显示child
+    );
+  }
+}
+
+// 自定义画笔实现(实际绘制逻辑在这里)
+class CanvasPainter extends CustomPainter {
+  final HomeBoard board;
+  final int level;
+
+  final Animation<double> flipAnimation; // 0.0 -> 1.0
+  final Animation<double> unlockAnimation;
+
+  CanvasPainter({required this.board, required this.level, required this.flipAnimation, required this.unlockAnimation})
+    : super(repaint: Listenable.merge([board.boardNotifier, flipAnimation, unlockAnimation])); // 触发重绘;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    if (board.status == HomeBoardStatus.playing) {
+      _paintPlaying(canvas, size);
+    } else if (board.status == HomeBoardStatus.done) {
+      _paintSuccess(canvas, size);
+    } else if (board.status == HomeBoardStatus.unlocking) {
+      // !!! 改造点 2: 处理 unlocking 状态
+      _paintUnlocking(canvas, size);
+    }
+  }
+
+  void _paintUnlocking(Canvas canvas, Size size) {
+    // 1. 获取动画进度 (0.0 -> 1.0)
+    final progress = unlockAnimation.value;
+
+    // 3. 计算当前的位移和缩放
+    // 缩放:从 1.0 缩小到 targetScale
+    final startScale = 1.0;
+    final endScale = board.unlockTargetScale;
+    final currentScale = ui.lerpDouble(startScale, endScale, progress)!;
+
+    // 位移:从 (0, 0) 平移到 targetOffset
+    final startOffset = Offset.zero;
+    final endOffset = board.unlockTargetOffset;
+    final currentOffset = Offset(ui.lerpDouble(startOffset.dx, endOffset.dx, progress)!, ui.lerpDouble(startOffset.dy, endOffset.dy, progress)!);
+
+    // 4. 应用 Canvas 变换
+    canvas.save();
+
+    // 位移:移动 Canvas 原点到新的位置
+    canvas.translate(currentOffset.dx, currentOffset.dy);
+
+    // 缩放:以 Canvas 中心为缩放原点进行缩放
+    final centerX = size.width / 2;
+    final centerY = size.height / 2;
+
+    // 缩放操作
+    canvas.translate(centerX, centerY);
+    canvas.scale(currentScale);
+    canvas.translate(-centerX, -centerY);
+
+    // 5. 绘制完整的合集图片 (与 _paintSuccess 逻辑相同)
+    final cornerRadius = 4.0;
+    final rect = Rect.fromLTWH(0, 0, size.width, size.height);
+    // final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
+    final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
+    final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
+
+    final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble());
+
+    // 裁剪并绘制图片
+    // canvas.clipRRect(rrect);  // 动画就不用clipRRect了
+    canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true);
+
+    // 绘制边框
+    // canvas.drawRRect(outerRRect, outerBorderPaint);
+    // canvas.drawRRect(innerRRect, innerBorderPaint);
+
+    canvas.restore();
+  }
+
+  _paintSuccess(Canvas canvas, Size size) {
+    _log.info('_paintSuccess');
+    final cornerRadius = 4.0;
+    final rect = Rect.fromLTWH(0, 0, size.width, size.height);
+    final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
+    final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
+    final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
+
+    final sourceRect = Rect.fromLTWH(0, 0, board.image!.width.toDouble(), board.image!.height.toDouble());
+
+    canvas.save();
+
+    canvas.clipRRect(rrect);
+
+    canvas.drawImageRect(board.image!, sourceRect, rect, Paint()..isAntiAlias = true);
+
+    canvas.restore();
+
+    // 绘制边框
+    canvas.drawRRect(outerRRect, outerBorderPaint);
+    canvas.drawRRect(innerRRect, innerBorderPaint);
+  }
+
+  _paintPlaying(Canvas canvas, Size size) {
+    _log.info('_paintPlaying');
+    for (var i = 0; i < board.rows; i++) {
+      for (var j = 0; j < board.cols; j++) {
+        // 玩过的关卡翻正面显示, 否则显示卡片背面
+        // final int curIndex = i * rows + j;
+        // bool flipped = level % (rows * cols) > curIndex;
+
+        // for test:
+        bool flipped = (i == 4 && j == 4) ? false : true;
+
+        _drawPiece(canvas, size, i, j, flipped);
+      }
+    }
+  }
+
+  final Paint outerBorderPaint = Paint()
+    ..color = SkinHelper.outLineBorderColor
+    ..style = PaintingStyle.stroke
+    ..strokeWidth = 1.0
+    ..isAntiAlias = true;
+
+  // 边框画笔
+  final Paint innerBorderPaint = Paint()
+    ..color = SkinHelper.innerLineBorderColor
+    ..style = PaintingStyle.stroke
+    ..strokeWidth = 1.0
+    ..isAntiAlias = true;
+
+  void _drawPiece(Canvas canvas, Size size, int row, int col, bool flipped) {
+    final cornerRadius = 4.0;
+
+    final w = size.width / board.cols;
+    final h = size.height / board.rows;
+
+    final pieceWidth = board.image!.width / board.cols;
+    final pieceHeight = board.image!.height / board.rows;
+
+    final left = col * w;
+    final top = row * h;
+    final right = left + w;
+    final bottom = top + h;
+
+    // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
+    final rect = Rect.fromLTRB(left, top, right, bottom);
+    final rrect = RRect.fromRectAndRadius(rect, Radius.circular(cornerRadius));
+    final outerRRect = RRect.fromRectAndRadius(rect.deflate(0.5), Radius.circular(cornerRadius));
+    final innerRRect = RRect.fromRectAndRadius(rect.deflate(1.5), Radius.circular(cornerRadius));
+
+    final cardSourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble());
+    final imageSourceRect = Rect.fromLTWH(pieceWidth * col, pieceHeight * row, pieceWidth, pieceHeight);
+
+    // 0-based index
+    final int curCollectionIndex = (level / (board.rows * board.cols)).floor();
+    final curIndex = curCollectionIndex * board.rows * board.cols + row * board.rows + col;
+
+    // 1. 计算当前的翻转状态
+    double flipProgress = 0.0;
+
+    // if (flipAnimation.isAnimating && curIndex == level - 1) {
+    // for test,只为方便查看动画效果,真正的代码是上面注释掉的
+    if (flipAnimation.isAnimating && curIndex == 24) {
+      flipProgress = flipAnimation.value; // 0.0 -> 1.0
+      flipped = (flipProgress > 0.5);
+    }
+    // _log.info('level=$level, row=$row, col=$col, flippingIndex=$flippingIndex, flipProgress=$flipProgress, currentPieceFlipped=$currentPieceFlipped');
+
+    canvas.save();
+
+    // 2. 居中变换原点到拼图块中心
+    final centerX = left + w / 2;
+    final centerY = top + h / 2;
+
+    canvas.translate(centerX, centerY);
+    // 3. 应用 3D 旋转 (围绕 Y 轴)
+    if (flipProgress > 0.0) {
+      // 旋转角度从 0 到 pi (180度)
+      double angle = flipProgress * pi;
+      // 引入透视投影(z轴缩放),让翻转效果更立体
+      const double perspective = 0.0015;
+
+      // 3D 变换矩阵
+      Matrix4 transform;
+      if (flipProgress <= 0.5) {
+        transform = Matrix4.identity()
+          ..setEntry(3, 2, perspective) // 3D 效果
+          ..rotateY(angle);
+      } else {
+        transform = Matrix4.identity()
+          ..setEntry(3, 2, perspective) // 3D 效果
+          ..rotateY(angle)
+          ..scale(-1.0, 1.0, 1.0); // 3. X轴缩放-1:抵消旋转带来的左右镜像
+      }
+
+      canvas.transform(transform.storage);
+    }
+
+    // 4. 移回原点
+    canvas.translate(-centerX, -centerY);
+
+    // ... 现有裁剪逻辑 ...
+    canvas.clipRRect(rrect);
+
+    if (flipped) {
+      // 绘制正面
+      canvas.drawImageRect(board.image!, imageSourceRect, rect, Paint()..isAntiAlias = true);
+    } else {
+      // 绘制背面
+      // 必须反转图片源矩形,以修正翻转180度后图像的镜像问题
+      final sourceRect = flipProgress > 0.5
+          ? imageSourceRect // 翻转后使用正面图像
+          : cardSourceRect; // 翻转前使用背面卡片
+
+      final targetImage = flipProgress > 0.5 ? board.image! : board.cardImage;
+
+      canvas.drawImageRect(targetImage, sourceRect, rect, Paint()..isAntiAlias = true);
+
+      if (flipProgress <= 0.5) {
+        // todo... 绘制关卡数字, 在卡片中间位置把curIndex绘制上去, 颜色白色
+        // 1. 配置文字样式:白色、加粗、动态字体大小(适配卡片尺寸)
+        final textStyle = TextStyle(
+          color: Colors.white,
+          fontFamily: 'Roboto',
+          fontSize: h * 0.25, // 字体大小为卡片高度的40%,适配不同尺寸
+          fontWeight: FontWeight.bold,
+          shadows: [
+            // 增加黑色阴影,让白色文字在卡片背景上更清晰(可选但推荐)
+            Shadow(offset: const Offset(1, 1), blurRadius: 2, color: Colors.black38),
+          ],
+        );
+
+        // 2. 初始化文字绘制器
+        final textSpan = TextSpan(text: (curIndex + 1).toString(), style: textStyle);
+        final textPainter = TextPainter(
+          text: textSpan,
+          textDirection: TextDirection.ltr,
+          textAlign: TextAlign.center, // 文字水平居中
+        );
+
+        // 3. 计算文字尺寸(必须调用layout())
+        textPainter.layout(
+          minWidth: 0,
+          maxWidth: w, // 文字最大宽度不超过卡片宽度
+        );
+
+        // 4. 计算文字居中偏移量
+        final textOffset = Offset(
+          left + (w - textPainter.width) / 2, // 水平居中
+          top + (h - textPainter.height) / 2, // 垂直居中
+        );
+
+        // 5. 绘制文字
+        textPainter.paint(canvas, textOffset);
+      }
+    }
+
+    canvas.restore(); // 恢复 Canvas 状态 (移除裁剪和 transform)
+
+    // --- 绘制边框 ---
+    // 为了确保边框线宽向外延伸的部分不会被裁剪,我们需要在裁剪区域被移除后重新绘制。
+    canvas.save();
+
+    canvas.drawRRect(outerRRect, outerBorderPaint);
+    canvas.drawRRect(innerRRect, innerBorderPaint);
+
+    canvas.restore();
+  }
+
+  @override
+  bool shouldRepaint(covariant CanvasPainter oldDelegate) {
+    return oldDelegate.level != level || oldDelegate.flipAnimation != flipAnimation || oldDelegate.board != board;
+  }
+}

+ 243 - 0
lib/homepage/home_screen.dart

@@ -0,0 +1,243 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+import 'package:image_puzzle/audio/audio_controller.dart';
+import 'package:image_puzzle/collection/collection_screen.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/homepage/home_board.dart';
+import 'package:image_puzzle/homepage/home_board_play.dart';
+import 'package:image_puzzle/models/cached_request.dart';
+import 'package:image_puzzle/models/data.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/play/board_play.dart';
+import 'package:image_puzzle/settings/settings_dialog.dart';
+import 'package:image_puzzle/skin/skin.dart';
+import 'package:image_puzzle/utils/mybutton.dart';
+import 'package:logging/logging.dart';
+import 'package:lottie/lottie.dart';
+import 'package:provider/provider.dart';
+
+final Logger _log = Logger('home_screen');
+
+class HomeScreen extends StatefulWidget {
+  const HomeScreen({super.key});
+
+  @override
+  State<StatefulWidget> createState() => _HomeScreen();
+}
+
+class _HomeScreen extends State<HomeScreen> {
+  late AudioController audio;
+  late Data data;
+  List<ListItem>? latest;
+  late CachedRequest latestCachedRequest;
+  late StreamSubscription? latestSubscription;
+
+  // 自定义画布控制器(可选,用于控制画布绘制逻辑)
+  final _canvasKey = GlobalKey<HomeBoardPlayState>();
+  // !!! 新增:用于定位 Collection 按钮的 GlobalKey
+  final GlobalKey _collectionKey = GlobalKey();
+
+  bool get isLoading => currentItem == null;
+
+  @override
+  void initState() {
+    super.initState();
+
+    audio = context.read<AudioController>();
+    data = context.read<Data>();
+    latestCachedRequest = data.latest;
+    // 主动获取缓存数据(关键)
+    final cachedData = latestCachedRequest.cachedData;
+    if (cachedData != null) {
+      _onLatestDataUpdate(cachedData);
+    }
+    latestSubscription = latestCachedRequest.stream.listen(_onLatestDataUpdate, onError: _onLatestDataError);
+  }
+
+  @override
+  void dispose() {
+    latestSubscription?.cancel();
+
+    super.dispose();
+  }
+
+  _onLatestDataUpdate(data) {
+    _log.info('_onLatestDataUpdate.... ');
+    if (data != null) {
+      latest = data as List<ListItem>;
+      setState(() {});
+      if (data.length >= 20) {
+        // 远程latest列表已加载,说明网络已通,这个时候再来初始化Admod,ATT, UMP这些东西
+        initThird();
+      }
+    }
+  }
+
+  _onLatestDataError(error) {
+    _log.info('_onLatestDataError.... $error');
+    if (latest == null || latest!.isEmpty || latest!.length < 20) {
+      // 列表数据如果少于20,说明只是内置图,仍然刷新远程请求
+      _log.warning("_onLatestDataError, retry again");
+      // refresh();
+      Future.delayed(Duration(seconds: 3), () => refresh());
+    }
+  }
+
+  Future<void> refresh() async {
+    _log.info('refresh...');
+    await latestCachedRequest.refresh();
+  }
+
+  void initThird() async {}
+
+  ListItem? get currentItem {
+    if (latest != null && latest!.isNotEmpty) {
+      return latest![data.currentLevel];
+    }
+    return null;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (isLoading) return scrollableDummy;
+
+    Device device = context.read<Device>();
+    // 2. 计算画布尺寸(宽=屏幕宽-60,高=宽×3/2)
+    final canvasWidth = device.screenSize.width - 30 * 2; // 左右各30px
+    final canvasHeight = canvasWidth * 3 / 2;
+
+    return Scaffold(
+      appBar: AppBar(
+        backgroundColor: Colors.white,
+        elevation: 1,
+        centerTitle: true,
+        leading: RepaintBoundary(
+          // !!! 改造点 1: 包裹 RepaintBoundary
+          key: _collectionKey, // !!! 改造点 2: 关联 GlobalKey
+          child: IconButton(
+            onPressed: () {
+              audio.playSfx(SfxType.tap);
+              Navigator.push(context, CollectionScreen.buildRoute());
+            },
+            icon: const Icon(Icons.collections, color: Colors.black54),
+          ),
+        ),
+        title: const Text(
+          'PuzzleWeave',
+          style: TextStyle(color: Colors.black54, fontFamily: 'Arial Black', fontWeight: FontWeight.bold, fontSize: 24),
+        ),
+        actions: [
+          IconButton(
+            onPressed: () {
+              audio.playSfx(SfxType.tap);
+              Navigator.push(context, SettingsDialog.buildRoute());
+            },
+            icon: const Icon(Icons.settings, color: Colors.black54),
+          ),
+        ],
+      ),
+      body: Column(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Expanded(
+            child: Column(
+              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+              children: [
+                // 2. 画布区域(固定尺寸)
+                Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 30), // 左右30px
+                  child: SizedBox(
+                    width: canvasWidth,
+                    height: canvasHeight,
+                    child: ValueListenableBuilder(
+                      valueListenable: data.completedWorks,
+                      builder: (context, value, child) {
+                        return HomeBoardPlay(
+                          key: _canvasKey,
+                          canvasWidth: canvasWidth,
+                          canvasHeight: canvasHeight,
+                          collectionKey: _collectionKey,
+                          onCollectionDone: () {
+                            // 可选:在这里处理合集解锁后的其他逻辑
+                          },
+                        );
+                      },
+                    ),
+                  ),
+                ),
+                playButton,
+              ],
+            ),
+          ),
+          Container(
+            height: device.bannerHeight,
+            width: double.infinity,
+            color: Colors.green.shade100,
+            child: const Center(child: Text('Banner 广告区域', style: TextStyle(fontSize: 12))),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget get playButton {
+    return MyElevatedButton(
+      width: 200,
+      height: 70,
+      borderRadius: BorderRadius.circular(20),
+      gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
+      onPressed: () async {
+        audio.playSfx(SfxType.tap);
+        _canvasKey.currentState?.startFlipAnimation();
+        // if (currentItem != null) {
+        //   PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(currentItem!);
+        //   final result = await Navigator.push(context, pageRouteBuilder);
+        //   if (result == true) {
+        //     _canvasKey.currentState?.startFlipAnimation();
+        //   }
+        // } else {
+        //   Fluttertoast.showToast(
+        //     msg: "更多图片敬请期待...",
+        //     toastLength: Toast.LENGTH_SHORT,
+        //     gravity: ToastGravity.CENTER,
+        //     timeInSecForIosWeb: 1,
+        //     backgroundColor: SkinHelper.slotBorderColor,
+        //     textColor: Colors.white,
+        //     fontSize: 16.0,
+        //   );
+        // }
+      },
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          const Text(
+            '玩',
+            style: TextStyle(color: Colors.white, fontFamily: 'Arial Black', fontSize: 24, fontWeight: FontWeight.bold),
+          ),
+          ValueListenableBuilder<List<Work>>(
+            valueListenable: data.completedWorks,
+            builder: (context, isSoundOn, child) {
+              return Text('关卡 ${data.currentLevel + 1}', style: const TextStyle(color: Colors.white, fontSize: 16));
+            },
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget get scrollableDummy => Scaffold(
+    body: LayoutBuilder(
+      builder: (p0, p1) {
+        return SingleChildScrollView(
+          physics: const AlwaysScrollableScrollPhysics(),
+          child: SizedBox(
+            height: p1.maxHeight,
+            child: Center(child: ListView(shrinkWrap: true, children: [Lottie.asset('assets/lottie/loading.json', height: 100)])),
+          ),
+        );
+      },
+    ),
+  );
+}

+ 34 - 86
lib/main.dart

@@ -4,7 +4,13 @@ import 'package:device_info_plus/device_info_plus.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:image_puzzle/app_lifecycle/app_lifecycle.dart';
+import 'package:image_puzzle/audio/audio_controller.dart';
+import 'package:image_puzzle/homepage/home_screen.dart';
+import 'package:image_puzzle/models/data.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/persistence/persistence.dart';
 import 'package:image_puzzle/play/board_play.dart';
+import 'package:image_puzzle/settings/settings_controller.dart';
 import 'package:logging/logging.dart';
 import 'dart:developer' as dev;
 
@@ -50,6 +56,15 @@ void main() async {
     ),
   );
 
+  //本地参数存储初始化
+  await Persistence().initialize();
+
+  // 程序首次运行时间
+  DateTime firstRunTime = Persistence().firstRunTime;
+  _log.info("first_run_time: $firstRunTime, now: ${DateTime.now()}");
+  // 记录程序运行时间
+  Persistence().lastRunTime = DateTime.now();
+
   Directory baseDir = await getApplicationDocumentsDirectory();
 
   runApp(MyApp(baseDir: baseDir));
@@ -66,6 +81,22 @@ class MyApp extends StatelessWidget {
     return AppLifecycleObserver(
       child: MultiProvider(
         providers: [
+          Provider<Data>(lazy: false, create: (context) => Data(persistence: Persistence())..loadDataFromPersistence()),
+          Provider<SettingsController>(lazy: false, create: (context) => SettingsController(persistence: Persistence())..loadStateFromPersistence()),
+          ProxyProvider2<SettingsController, ValueNotifier<AppLifecycleState>, AudioController>(
+            // Ensures that the AudioController is created on startup,
+            // and not "only when it's needed", as is default behavior.
+            // This way, music starts immediately.
+            lazy: false,
+            create: (context) => AudioController()..initialize(),
+            update: (context, settings, lifecycleNotifier, audio) {
+              if (audio == null) throw ArgumentError.notNull();
+              audio.attachSettings(settings);
+              audio.attachLifecycleNotifier(lifecycleNotifier);
+              return audio;
+            },
+            dispose: (context, audio) => audio.dispose(),
+          ),
           Provider<Config>(lazy: false, create: (context) => config),
           Provider<Device>(lazy: false, create: (context) => config.device),
         ],
@@ -73,11 +104,11 @@ class MyApp extends StatelessWidget {
           child: MaterialApp(
             key: GlobalKey(),
             title: 'Image Puzzle',
-            initialRoute: '/play',
+            initialRoute: '/',
             navigatorObservers: [routeObserver],
             routes: {
-              '/': (context) => const MyHomePage(title: "Image Puzzle"),
-              '/play': (context) => BoardPlay(cols: 4, rows: 4),
+              '/': (context) => const HomeScreen(),
+              '/play': (context) => BoardPlay(item: AssetItem('0', 2057, 2878, 4, true, 'assets/images/test.jpeg', 'assets/images/test.jpeg')),
             },
             theme: ThemeData(brightness: Brightness.light, primaryColor: Colors.green, primarySwatch: Colors.blue),
           ),
@@ -87,89 +118,6 @@ class MyApp extends StatelessWidget {
   }
 }
 
-class MyHomePage extends StatefulWidget {
-  const MyHomePage({super.key, required this.title});
-
-  // This widget is the home page of your application. It is stateful, meaning
-  // that it has a State object (defined below) that contains fields that affect
-  // how it looks.
-
-  // This class is the configuration for the state. It holds the values (in this
-  // case the title) provided by the parent (in this case the App widget) and
-  // used by the build method of the State. Fields in a Widget subclass are
-  // always marked "final".
-
-  final String title;
-
-  @override
-  State<MyHomePage> createState() => _MyHomePageState();
-}
-
-class _MyHomePageState extends State<MyHomePage> {
-  int _counter = 0;
-
-  void _incrementCounter() {
-    setState(() {
-      // This call to setState tells the Flutter framework that something has
-      // changed in this State, which causes it to rerun the build method below
-      // so that the display can reflect the updated values. If we changed
-      // _counter without calling setState(), then the build method would not be
-      // called again, and so nothing would appear to happen.
-      _counter++;
-    });
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    // This method is rerun every time setState is called, for instance as done
-    // by the _incrementCounter method above.
-    //
-    // The Flutter framework has been optimized to make rerunning build methods
-    // fast, so that you can just rebuild anything that needs updating rather
-    // than having to individually change instances of widgets.
-    return Scaffold(
-      appBar: AppBar(
-        // TRY THIS: Try changing the color here to a specific color (to
-        // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
-        // change color while the other colors stay the same.
-        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
-        // Here we take the value from the MyHomePage object that was created by
-        // the App.build method, and use it to set our appbar title.
-        title: Text(widget.title),
-      ),
-      body: Center(
-        // Center is a layout widget. It takes a single child and positions it
-        // in the middle of the parent.
-        child: Column(
-          // Column is also a layout widget. It takes a list of children and
-          // arranges them vertically. By default, it sizes itself to fit its
-          // children horizontally, and tries to be as tall as its parent.
-          //
-          // Column has various properties to control how it sizes itself and
-          // how it positions its children. Here we use mainAxisAlignment to
-          // center the children vertically; the main axis here is the vertical
-          // axis because Columns are vertical (the cross axis would be
-          // horizontal).
-          //
-          // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
-          // action in the IDE, or press "p" in the console), to see the
-          // wireframe for each widget.
-          mainAxisAlignment: MainAxisAlignment.center,
-          children: <Widget>[
-            const Text('You have pushed the button this many times:'),
-            Text('$_counter', style: Theme.of(context).textTheme.headlineMedium),
-          ],
-        ),
-      ),
-      floatingActionButton: FloatingActionButton(
-        onPressed: _incrementCounter,
-        tooltip: 'Increment',
-        child: const Icon(Icons.add),
-      ), // This trailing comma makes auto-formatting nicer for build methods.
-    );
-  }
-}
-
 class Prepare extends StatefulWidget {
   final Widget child;
   const Prepare({super.key, required this.child});

+ 27 - 0
lib/models/api_helper.dart

@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+
+import '../config/config.dart';
+
+const String cdnHost = 'd2mb6s2cy1zg97.cloudfront.net';
+const String developmentHost = 'color.jccytech.cn';
+const String productionHost = 'app.pcoloring.com';
+const String localAVDHost = '10.0.2.2:6888';
+
+class ApiHelper {
+  static String get countryCode => WidgetsBinding.instance.window.locale.countryCode ?? 'CN';
+  static String get languageCode => WidgetsBinding.instance.window.locale.languageCode;
+  static bool get isCN => countryCode == 'CN';
+
+  // static String get apiHost => Config.isDebug ? developmentHost : productionHost;
+  // static String get resHost => Config.isDebug ? developmentHost : cdnHost;
+
+  static String get apiHost => Config.isDebug ? localAVDHost : productionHost;
+  static String get resHost => Config.isDebug ? localAVDHost : cdnHost;
+
+  static String thumbUri(String id) => 'http://$resHost/res/jigstack/thumb/320/$id.jpg';
+  static String imageUri(String id) => 'http://$resHost/res/jigstack/coded/org/$id.jpg';
+
+  static String get dailyUri => 'https://$apiHost/napi/jigstack/mobi/list/daily';
+  static String get latestUri => 'http://$apiHost/napi/jigstack/mobi/list/latest';
+  static String get collectionUri => 'http://$apiHost/napi/jigstack/mobi/list/collection';
+}

+ 163 - 0
lib/models/cached_request.dart

@@ -0,0 +1,163 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/material.dart'; // 引入 Flutter 核心包
+import 'package:http/http.dart' as http;
+import 'package:image_puzzle/models/api_helper.dart';
+import 'package:image_puzzle/utils/utils.dart';
+import 'package:logging/logging.dart';
+
+final Logger _log = Logger('cached_request.dart');
+
+typedef TransformFunction = Future<dynamic> Function(dynamic json);
+
+// 混入 WidgetsBindingObserver 以监听应用生命周期
+class CachedRequest with WidgetsBindingObserver {
+  static final Map<String, CachedRequest> _cache = {};
+
+  final String url;
+  TransformFunction? transformFunction;
+  // 仅使用 .broadcast(),但 onListen 只会触发一次
+  final StreamController _streamController = StreamController.broadcast();
+
+  dynamic _transformed;
+  // --- 【新增】Getter:允许外部同步访问最新的缓存数据 ---
+  dynamic get cachedData => _transformed;
+
+  CachedRequest._internal(this.url, this.transformFunction) {
+    _log.info('New cached request: $url');
+    // 注册生命周期监听器
+    WidgetsBinding.instance.addObserver(this);
+    _init();
+  }
+
+  factory CachedRequest.fromUrl(String url, {TransformFunction? transformFunction}) {
+    // 确保单例模式下,只注册一次监听器
+    if (!_cache.containsKey(url)) {
+      _cache[url] = CachedRequest._internal(url, transformFunction);
+    }
+    return _cache[url]!;
+  }
+
+  // --- 关键修改:生命周期监听 ---
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      _log.info('App Resumed from background. Forcing refresh for $url');
+      // 应用程序从后台恢复到前台时,强制刷新数据
+      refresh();
+    }
+  }
+
+  // 由于 CachedRequest 是一个单例,它不会被销毁,除非应用完全关闭。
+  // 但是,为了严谨性,如果添加了 Dispose 逻辑,应记得移除 Observer。
+  // 注意:在 Flutter Provider 或 InheritedWidget 依赖的单例中,通常不需要手动调用 dispose。
+  // 如果需要清理:
+  /*
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    _streamController.close();
+    // ... clean up
+  }
+  */
+  // ---------------------------------
+
+  _init() {
+    // FIX: 首次初始化时,立即尝试加载缓存(如果有)
+    _cacheLoad();
+    _remoteLoad();
+    // _streamController.onListen = () {
+    //   if (_transformed != null) {
+    //     // 如果有缓存数据,立即发送给新的监听者
+    //     _streamController.add(_transformed);
+    //   }
+    // };
+
+    _streamController.onListen = () {
+      _log.info('Stream listener added for $url. Current data available? ${_transformed != null}');
+      if (_transformed != null) {
+        // 确保新订阅者立即获得缓存数据 (用于热重载或首次进入)
+        // 使用 Future.microtask 确保在 onListen 结束后再触发 add,避免同步递归
+        Future.microtask(() => _streamController.add(_transformed));
+      }
+    };
+  }
+
+  Future<void> refresh() async {
+    await _remoteLoad();
+  }
+
+  Future<void> reload() async {
+    await _cacheLoad();
+  }
+
+  _remoteLoad() async {
+    try {
+      final response = await http.get(Uri.parse(url));
+      if (response.statusCode != 200) {
+        throw Exception('Invalid status code: ${response.statusCode} when fetching: $url');
+      }
+      _log.info('${response.statusCode}, $url');
+
+      final data = jsonDecode(response.body);
+      _emit(data);
+
+      await saveString(cachePath, response.body);
+    } catch (error) {
+      _streamController.addError(error);
+      _log.severe('Remote load failed for $url: $error');
+    }
+  }
+
+  _emit(dynamic data) async {
+    _log.info('Emiting data..... ');
+    try {
+      if (transformFunction != null) {
+        data = await transformFunction?.call(data);
+      }
+
+      // 仅当有监听者时才尝试添加数据
+      if (_streamController.hasListener) {
+        _streamController.add(data);
+      }
+      _transformed = data;
+    } catch (error) {
+      _streamController.addError(error);
+      _log.severe('Data transformation or emission failed: $error');
+    }
+  }
+
+  _cacheLoad() async {
+    try {
+      final file = await localFile(cachePath);
+      if (await file.exists()) {
+        _log.info('File $file exists, try loading from cache..');
+        final data = await loadJson(cachePath);
+        _emit(data);
+      } else {
+        if (url == ApiHelper.latestUri) {
+          _log.info('Loading builtin latest asset data...');
+          final data = await loadJSONFromAsset('assets/builtin/latest.json');
+          _emit(data);
+        }
+        if (url == ApiHelper.collectionUri) {
+          _log.info('Loading builtin collection asset data...');
+          final data = await loadJSONFromAsset('assets/builtin/collection.json');
+          _emit(data);
+        }
+      }
+    } catch (error) {
+      // 缓存加载失败不应该阻止远程加载
+      _streamController.addError(error);
+    }
+  }
+
+  String get hash => md5Hash(url);
+  String get cachePath => 'api_cache/$hash';
+  Stream get stream => _streamController.stream;
+
+  @override
+  String toString() {
+    return 'CachedRequest(url=$url)';
+  }
+}

+ 88 - 0
lib/models/data.dart

@@ -0,0 +1,88 @@
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/models/api_helper.dart';
+import 'package:image_puzzle/models/cached_request.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/persistence/persistence.dart';
+import 'package:logging/logging.dart';
+
+final Logger _log = Logger('data.dart');
+
+class Data {
+  final Persistence _persistence;
+
+  ValueNotifier<List<Work>> completedWorks = ValueNotifier([]); // 已完成的图
+  ValueNotifier<List<Work>> completedCollections = ValueNotifier([]); // 已完成的收藏图
+
+  Data({required Persistence persistence}) : _persistence = persistence;
+
+  Future<void> loadDataFromPersistence() async {
+    completedWorks.value = _persistence.completedWorks;
+    completedCollections.value = _persistence.completedCollections;
+  }
+
+  // 完成某个作品调用此接口记录到存储中
+  // !!! 改造点:接受 ListItem 和可选的耗时
+  void workDone(ListItem item, {Duration? timeSpent}) {
+    final newWork = Work.fromListItem(item, timeSpent: timeSpent);
+    final updatedWorks = [...completedWorks.value, newWork];
+    completedWorks.value = updatedWorks;
+    _persistence.completedWorks = updatedWorks; // 存储更新后的列表
+  }
+
+  // 获取当前的关卡index (基于已完成作品的数量)
+  int get currentLevel => completedWorks.value.length;
+
+  // 完成某个collection
+  // !!! 改造点:接受 CollectionItem
+  void collectionDone(ListItem item) {
+    final newCollection = Work.fromListItem(item);
+    final updatedCollections = [...completedCollections.value, newCollection];
+    completedCollections.value = updatedCollections;
+    _persistence.completedCollections = updatedCollections; // 存储更新后的列表
+  }
+
+  // 获取当前的合集index (基于已完成收藏的数量)
+  int get currentCollectionIndex => completedCollections.value.length;
+
+  CachedRequest? _latest;
+  CachedRequest get latest {
+    _latest ??= CachedRequest.fromUrl(
+      ApiHelper.latestUri,
+      transformFunction: (json) async {
+        late List<ListItem> list;
+
+        if (json['asset'] != null) {
+          // from asset
+          list = List<AssetItem>.from((json['data'] as Iterable).map((e) => AssetItem.fromJSON(e)));
+        } else {
+          // from remote
+          list = List<RemoteItem>.from((json['data'] as Iterable).map((e) => RemoteItem.fromJSON(e)));
+        }
+
+        return list;
+      },
+    );
+    return _latest!;
+  }
+
+  CachedRequest? _collection;
+  CachedRequest get collection {
+    _collection ??= CachedRequest.fromUrl(
+      ApiHelper.collectionUri,
+      transformFunction: (json) async {
+        late List<ListItem> list;
+
+        if (json['asset'] != null) {
+          // from asset
+          list = List<AssetItem>.from((json['data'] as Iterable).map((e) => AssetItem.fromJSON(e)));
+        } else {
+          // from remote
+          list = List<RemoteItem>.from((json['data'] as Iterable).map((e) => RemoteItem.fromJSON(e)));
+        }
+
+        return list;
+      },
+    );
+    return _collection!;
+  }
+}

+ 335 - 0
lib/models/download.dart

@@ -0,0 +1,335 @@
+import 'dart:async';
+import 'dart:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart';
+import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/utils/utils.dart';
+import 'package:logging/logging.dart';
+
+final Logger _log = Logger('download.dart');
+
+/// 最多缓存/并发下载n个图到内存
+const maxCachedItems = 1;
+
+/// Sigeleton
+class Download {
+  static final Download _instance = Download._internal();
+
+  factory Download() {
+    return _instance;
+  }
+  Download._internal();
+
+  final Map<String, DownloadItem> _cache = {};
+
+  DownloadItem download(url, cachePath) {
+    _clean();
+    if (_cache[url] != null) {
+      _log.info('Cache hit for $url');
+      _cache[url]!.touch(); //update last use time.
+      return _cache[url]!;
+    } else {
+      final item = DownloadItem(url, cachePath);
+      _cache[url] = item;
+      _watch(item);
+      return item;
+    }
+  }
+
+  _watch(DownloadItem item) async {
+    try {
+      await item.loadCompleter.future;
+    } catch (err) {
+      _log.info('Watch download item got error: $err');
+      _cache.remove(item.url);
+    }
+  }
+
+  _clean() {
+    final list = _cache.values.toList();
+    if (list.length <= maxCachedItems) return;
+    _log.info('cleaning...');
+    list.sort((a, b) => a.lastUsed.compareTo(b.lastUsed));
+    while (list.length > maxCachedItems) {
+      final item = list.removeAt(0);
+      _log.info('clean item: $item');
+      item.dispose();
+      _cache.remove(item.url);
+    }
+  }
+
+  clearAllCached() async {
+    final file = await localFile('cache');
+    await file.delete(recursive: true);
+  }
+}
+
+class DownloadItem {
+  final String url;
+  final String cachePath;
+  ValueNotifier<double> progress = ValueNotifier(0.0);
+  final Completer<ui.Image> loadCompleter = Completer();
+  Client? client;
+  StreamSubscription? subscription;
+  int lastUsed;
+  int size = 0;
+  ui.Image? image;
+  Uint8List? data;
+
+  DownloadItem(this.url, this.cachePath) : lastUsed = DateTime.now().millisecondsSinceEpoch {
+    _log.info('New download item for: $url');
+    _start();
+  }
+
+  touch() {
+    lastUsed = DateTime.now().millisecondsSinceEpoch;
+  }
+
+  _start() async {
+    try {
+      final image = await _download();
+      loadCompleter.complete(image);
+    } catch (err) {
+      loadCompleter.completeError(err);
+    }
+  }
+
+  Future<ui.Image> _download() async {
+    progress.value = 0;
+
+    final file = await localFile(cachePath);
+    _checkDispose();
+
+    final Uint8List data;
+    bool shouldSave = false;
+
+    //if (await file.exists()) {
+    if (await file.exists()) {
+      _log.info('Disk cache hit..');
+      data = await file.readAsBytes();
+      _checkDispose();
+      progress.value = 1;
+    } else {
+      final List<int> bytes = [];
+
+      client = Client();
+
+      final uri = Uri.parse(url);
+      final request = Request('GET', uri);
+
+      final response = await client!.send(request);
+      _checkDispose();
+
+      if (response.statusCode != 200) {
+        throw Exception('Download error, stauts:${response.statusCode}, url=$uri');
+      }
+
+      if (response.contentLength == null) {
+        throw Exception('Download error, no length, url=$uri');
+      }
+
+      final length = response.contentLength!;
+
+      _log.info('message: contentLength=$length');
+
+      final streamCompleter = Completer();
+
+      subscription = response.stream.listen(
+        (value) {
+          try {
+            // 有可能内存溢出, 先try/catch包一下
+            bytes.addAll(value);
+            progress.value = bytes.length / length;
+            _log.info('message: progress=${progress.value}');
+          } catch (e) {
+            _log.warning("Out of memory: $e");
+            // FirebaseCrashlytics.instance.log("OOM from download url: $uri, error: $e");
+            streamCompleter.completeError(e);
+          }
+        },
+        onDone: () {
+          //_log.info('onDone..');
+          streamCompleter.complete();
+        },
+        onError: (e) {
+          _log.info('onError: $e');
+          streamCompleter.completeError(e);
+        },
+        cancelOnError: true,
+      );
+
+      await streamCompleter.future;
+      //await response.stream.first;
+      _log.info('xxxxxxxxxxxxxxxxxxxx stream complete');
+
+      _checkDispose();
+      _log.info('message: download succeed, progress=$progress, length=${bytes.length}');
+
+      bytes.removeRange(0, 24);
+      data = Uint8List.fromList(bytes);
+
+      shouldSave = true;
+      _checkDispose();
+    }
+
+    _log.info('message: realbytes: ${data.length}');
+
+    int size = Device.physicalSize.width.toInt();
+
+    final ui.Codec codec = await ui.instantiateImageCodec(data, targetHeight: size, targetWidth: size);
+    final ui.FrameInfo frameInfo = await codec.getNextFrame();
+    final image = frameInfo.image;
+    this.image = image;
+
+    this.data = data;
+    size = data.length;
+
+    if (shouldSave) {
+      await saveBytes(cachePath, data);
+    }
+
+    _checkDispose();
+
+    _log.info('image: ${image.width}x${image.height}');
+    client?.close();
+
+    return image;
+  }
+
+  Future<ui.Image> getImageBySize(int dim, {allowUpscaling = false}) async {
+    await loadCompleter.future;
+    final ui.Codec codec = await ui.instantiateImageCodec(data!, targetHeight: dim, targetWidth: dim, allowUpscaling: allowUpscaling);
+    final ui.FrameInfo frameInfo = await codec.getNextFrame();
+    return frameInfo.image;
+  }
+
+  bool _isDisposed = false;
+
+  _checkDispose() {
+    _log.info('$this,checkDispose: $_isDisposed');
+    if (_isDisposed) throw Exception('Request disposed');
+  }
+
+  dispose() async {
+    _log.info('Disposing $this, client:$client');
+    _isDisposed = true;
+    // do clean.
+    try {
+      subscription?.cancel();
+      client?.close();
+      image?.dispose();
+    } catch (error) {
+      _log.info('xxxxxxxxxxxx $error');
+    }
+  }
+
+  @override
+  String toString() {
+    return '[$cachePath]';
+  }
+}
+
+abstract class ItemLoader {
+  final Completer completer = Completer();
+  Uint8List? get data;
+  ValueNotifier<double> get progress;
+
+  ItemLoader();
+
+  Future<ui.Image> getImageBySize(int width, int height, {allowUpscaling = true}) async {
+    await completer.future;
+    final ui.Codec codec = await ui.instantiateImageCodec(data!, targetHeight: height, targetWidth: width, allowUpscaling: allowUpscaling);
+    final ui.FrameInfo frameInfo = await codec.getNextFrame();
+    return frameInfo.image;
+  }
+
+  factory ItemLoader.load(ListItem item) {
+    switch (item.runtimeType) {
+      case RemoteItem:
+        return RemoteItemLoader((item as RemoteItem).image, item.cachePath);
+      case AssetItem:
+        return AssetItemLoader((item as AssetItem).image);
+      default:
+        throw 'Can\'t create ${item.runtimeType}';
+    }
+  }
+}
+
+class LocalItemLoader extends ItemLoader {
+  final String path;
+  Uint8List? _data;
+
+  @override
+  ValueNotifier<double> progress = ValueNotifier(0);
+
+  LocalItemLoader(this.path) {
+    _load();
+  }
+
+  _load() async {
+    try {
+      final file = await localFile(path);
+      _data = await file.readAsBytes();
+      completer.complete(data);
+      progress.value = 1.0;
+    } catch (error) {
+      completer.completeError(error);
+    }
+  }
+
+  @override
+  Uint8List? get data => _data;
+}
+
+class AssetItemLoader extends ItemLoader {
+  final String path;
+  Uint8List? _data;
+
+  @override
+  ValueNotifier<double> progress = ValueNotifier(0);
+
+  AssetItemLoader(this.path) {
+    _load();
+  }
+
+  _load() async {
+    try {
+      _data = await loadFileDataFromAsset(path);
+      completer.complete(data);
+      progress.value = 1.0;
+    } catch (error) {
+      completer.completeError(error);
+    }
+  }
+
+  @override
+  Uint8List? get data => _data;
+}
+
+class RemoteItemLoader extends ItemLoader {
+  final String url;
+  final String cachePath;
+  late final DownloadItem downloadItem;
+
+  RemoteItemLoader(this.url, this.cachePath) {
+    downloadItem = Download().download(url, cachePath);
+    _load();
+  }
+
+  _load() async {
+    try {
+      await downloadItem.loadCompleter.future;
+      completer.complete();
+    } catch (err) {
+      completer.completeError(err);
+    }
+  }
+
+  @override
+  Uint8List? get data => downloadItem.data;
+
+  @override
+  ValueNotifier<double> get progress => downloadItem.progress;
+}

+ 174 - 0
lib/models/items.dart

@@ -0,0 +1,174 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+
+import 'package:intl/intl.dart';
+
+abstract class ListItem {
+  String get id;
+  int get width;
+  int get height;
+  int get difficulty;
+  int get rows => difficulty;
+  int get cols => difficulty;
+  bool get hard;
+  String get thumb;
+  String get image;
+  String get cachePath => 'cache/$id.jpeg';
+  ValueNotifier notifier = ValueNotifier(0);
+
+  // 新增:用于序列化
+  Map<String, dynamic> toJson(); // 添加抽象方法,强制子类实现
+}
+
+/// assets图片, 内置图
+class AssetItem extends ListItem {
+  @override
+  final String id;
+  @override
+  final int width;
+  @override
+  final int height;
+  @override
+  final int difficulty;
+  @override
+  final bool hard;
+  @override
+  final String thumb;
+  @override
+  final String image;
+
+  AssetItem(this.id, this.width, this.height, this.difficulty, this.hard, this.thumb, this.image);
+
+  // 从JSON文件读取
+  AssetItem.fromJSON(Map<String, dynamic> json)
+    : id = json['_id'],
+      width = json['width'],
+      height = json['height'],
+      difficulty = json['difficulty'],
+      hard = json['hard'] ?? false,
+      thumb = json['thumb'],
+      image = json['raw'];
+
+  @override
+  String toString() {
+    return 'AssetItem(id=$id, width=$width, height=$height, rows=$rows, cols=$cols, thumb=$thumb, image=$image';
+  }
+
+  @override
+  Map<String, dynamic> toJson() => {'_id': id, 'width': width, 'height': height, 'difficulty': difficulty, 'hard': hard, 'thumb': thumb, 'raw': image};
+}
+
+class RemoteItem extends ListItem {
+  @override
+  final String id;
+  @override
+  final int width;
+  @override
+  final int height;
+  @override
+  final int difficulty;
+  @override
+  final bool hard;
+  @override
+  final String thumb;
+  @override
+  final String image;
+
+  RemoteItem(this.id, this.width, this.height, this.difficulty, this.hard, this.thumb, this.image);
+
+  factory RemoteItem.fromJSON(Map<String, dynamic> json) {
+    // return RemoteItem(json['_id'], json['width'], json['height'], json['difficulty'], json['raw']);
+    return RemoteItem(
+      json['_id'],
+      json['width'],
+      json['height'],
+      json['difficulty'],
+      json['hard'] ?? false,
+      json['thumb'].replaceAll('localhost', '10.0.2.2'),
+      json['raw'].replaceAll('localhost', '10.0.2.2'),
+    ); // 临时本地调试
+  }
+
+  @override
+  Map<String, dynamic> toJson() => {'_id': id, 'width': width, 'height': height, 'difficulty': difficulty, 'hard': hard, 'thumb': thumb, 'raw': image};
+}
+
+// 记录已完成作品的信息
+class Work extends ListItem {
+  @override
+  final String id; // 作品ID
+  @override
+  final int difficulty; // 难度 (rows/cols)
+  @override
+  final bool hard; // 是否困难模式
+  @override
+  final int width; // 原始图片宽度
+  @override
+  final int height; // 原始图片高度
+  @override
+  final String thumb;
+  @override
+  final String image;
+
+  final DateTime completedTime; // 完成时间
+  final Duration? timeSpent; // 完成耗时 (可选)
+
+  Work({
+    required this.id,
+    required this.difficulty,
+    required this.hard,
+    required this.width,
+    required this.height,
+    required this.thumb,
+    required this.image,
+    required this.completedTime,
+    this.timeSpent,
+  });
+
+  // 序列化为 JSON
+  @override
+  Map<String, dynamic> toJson() {
+    return {
+      'id': id,
+      'difficulty': difficulty,
+      'hard': hard,
+      'width': width,
+      'height': height,
+      'thumb': thumb,
+      'image': image,
+      'completedTime': completedTime.toIso8601String(), // 日期时间转为ISO字符串
+      'timeSpentSeconds': timeSpent?.inSeconds, // 耗时转为秒
+    };
+  }
+
+  // 从 JSON 反序列化
+  factory Work.fromJson(Map<String, dynamic> json) {
+    return Work(
+      id: json['id'],
+      difficulty: json['difficulty'],
+      hard: json['hard'] ?? false,
+      width: json['width'],
+      height: json['height'],
+      thumb: json['thumb'],
+      image: json['image'],
+      completedTime: DateTime.parse(json['completedTime']),
+      timeSpent: json['timeSpentSeconds'] != null ? Duration(seconds: json['timeSpentSeconds']) : null,
+    );
+  }
+
+  // 从 ListItem 创建 Work
+  factory Work.fromListItem(ListItem item, {Duration? timeSpent}) {
+    return Work(
+      id: item.id,
+      difficulty: item.difficulty,
+      hard: item.hard,
+      width: item.width,
+      height: item.height,
+      thumb: item.thumb,
+      image: item.image,
+      completedTime: DateTime.now(),
+      timeSpent: timeSpent,
+    );
+  }
+}

+ 233 - 0
lib/persistence/persistence.dart

@@ -0,0 +1,233 @@
+import 'dart:async';
+import 'dart:convert'; // 导入 json 编码解码
+import 'dart:io';
+
+import 'package:image_puzzle/models/items.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:rate_my_app/rate_my_app.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:uuid/uuid.dart';
+
+///持久化存储数据
+///全局类
+class Persistence {
+  late final SharedPreferences _prefs;
+
+  Persistence._internal();
+  static final Persistence _instance = Persistence._internal();
+  factory Persistence() => _instance;
+
+  int get projectId {
+    if (Platform.isAndroid) {
+      return 9;
+    } else if (Platform.isIOS) {
+      return 10;
+    }
+    return 0;
+  }
+
+  String get libraryName {
+    if (Platform.isAndroid) {
+      return "android";
+    } else if (Platform.isIOS) {
+      return "ios";
+    }
+    return "unkown";
+  }
+
+  late String packageVersion;
+
+  ///初始化并给各参数分配默认值
+  Future<void> initialize() async {
+    PackageInfo packageInfo = await PackageInfo.fromPlatform();
+    packageVersion = packageInfo.version;
+
+    _prefs = await SharedPreferences.getInstance();
+
+    //系统
+    _uuid = PreferencesValue<String>('uuid', const Uuid().v4(), _prefs);
+    _firstRunTime = PreferencesValue<DateTime>('first_run_time', DateTime.now(), _prefs, saveDefault: true);
+    _lastRunTime = PreferencesValue<DateTime>('last_run_time', DateTime.now(), _prefs, saveDefault: true);
+    _lastDailyRewardTime = PreferencesValue<DateTime>('last_daily_reward_time', DateTime.now().subtract(const Duration(days: 1)), _prefs);
+
+    //评分
+    _rating = PreferencesValue<double>('rating', 0, _prefs);
+    _rateShowTimes = PreferencesValue<int>('rate_show_times', 0, _prefs);
+    _rateLastShowTime = PreferencesValue<DateTime>('last_rate_show_time', DateTime.now().subtract(const Duration(days: 1)), _prefs);
+
+    //settings
+    _sound = PreferencesValue<bool>('sound', true, _prefs);
+    _music = PreferencesValue<bool>('music', true, _prefs);
+    _vibrate = PreferencesValue<bool>('vibrate', true, _prefs);
+    _skin = PreferencesValue<int>('skin', 0, _prefs);
+
+    //banner 广告收益
+    _lastBannerPaidReportTimestamp = PreferencesValue<int>('last_banner_paid_report_timestamp', DateTime.now().millisecondsSinceEpoch, _prefs);
+    _bannerPaidValueMicros = PreferencesValue<double>('banner_paid_value_micros', 0, _prefs);
+
+    // !!! 改造点:完成作品集 - 存储 Work 对象列表
+    _completedWorks = PreferencesValue<List<Work>>(
+      'completed_works',
+      [],
+      _prefs,
+      encoder: (list) => list.map((w) => jsonEncode(w.toJson())).toList(), // 序列化
+      decoder: (jsonList) => jsonList.map((jsonStr) => Work.fromJson(jsonDecode(jsonStr))).toList(), // 反序列化
+    );
+    // !!! 改造点:完成收藏图集 - 存储 Work 对象列表
+    _completedCollections = PreferencesValue<List<Work>>(
+      'completed_collections',
+      [],
+      _prefs,
+      encoder: (list) => list.map((c) => jsonEncode(c.toJson())).toList(), // 序列化
+      decoder: (jsonList) => jsonList.map((jsonStr) => Work.fromJson(jsonDecode(jsonStr))).toList(), // 反序列化
+    );
+  }
+
+  // uuid
+  late PreferencesValue<String> _uuid;
+  String get uuid => _uuid.value;
+  set uuid(String value) => _uuid.value = value;
+
+  // 程序第一次运行
+  late PreferencesValue<DateTime> _firstRunTime;
+  DateTime get firstRunTime => _firstRunTime.value;
+  set firstRunTime(DateTime value) => _firstRunTime.value = value;
+
+  //程序最后一次运行时间
+  late PreferencesValue<DateTime> _lastRunTime;
+  DateTime get lastRunTime => _lastRunTime.value;
+  set lastRunTime(DateTime value) => _lastRunTime.value = value;
+
+  //上一次 daily reward 时间
+  late PreferencesValue<DateTime> _lastDailyRewardTime;
+  DateTime get lastDailyRewardTime => _lastDailyRewardTime.value;
+  set lastDailyRewardTime(DateTime value) => _lastDailyRewardTime.value = value;
+
+  //评分
+  late PreferencesValue<double> _rating;
+  double get rating => _rating.value;
+  set rating(double value) => _rating.value = value;
+
+  //评分弹框次数
+  late PreferencesValue<int> _rateShowTimes;
+  int get rateShowTimes => _rateShowTimes.value;
+  set rateShowTimes(int value) => _rateShowTimes.value = value;
+
+  //评分上次弹框时间
+  late PreferencesValue<DateTime> _rateLastShowTime;
+  DateTime get rateLastShowTime => _rateLastShowTime.value;
+  set rateLastShowTime(DateTime value) => _rateLastShowTime.value = value;
+
+  // 各种按键声音效果
+  late PreferencesValue<bool> _sound;
+  bool get sound => _sound.value;
+  set sound(bool value) => _sound.value = value;
+
+  // 背景音乐
+  late PreferencesValue<bool> _music;
+  bool get music => _music.value;
+  set music(bool value) => _music.value = value;
+
+  // 振动
+  late PreferencesValue<bool> _vibrate;
+  bool get vibrate => _vibrate.value;
+  set vibrate(bool value) => _vibrate.value = value;
+
+  // 皮肤方案
+  late PreferencesValue<int> _skin;
+  int get skin => _skin.value;
+  set skin(int value) => _skin.value = value;
+
+  // banner 上一次广告收益上报时间
+  late PreferencesValue<int> _lastBannerPaidReportTimestamp;
+  int get lastBannerPaidReportTimestamp => _lastBannerPaidReportTimestamp.value;
+  set lastBannerPaidReportTimestamp(int value) => _lastBannerPaidReportTimestamp.value = value;
+
+  // banner 广告收益累积金额
+  late PreferencesValue<double> _bannerPaidValueMicros;
+  double get bannerPaidValueMicros => _bannerPaidValueMicros.value;
+  set bannerPaidValueMicros(double value) => _bannerPaidValueMicros.value = value;
+
+  // !!! 改造点:已完成作品集合
+  late PreferencesValue<List<Work>> _completedWorks;
+  List<Work> get completedWorks => _completedWorks.value;
+  set completedWorks(List<Work> value) => _completedWorks.value = value;
+
+  // !!! 改造点:已完成收藏集合
+  late PreferencesValue<List<Work>> _completedCollections;
+  List<Work> get completedCollections => _completedCollections.value;
+  set completedCollections(List<Work> value) => _completedCollections.value = value;
+}
+
+///----------------------------
+class PreferencesValue<T> {
+  final String key;
+  final T defaultValue;
+  final SharedPreferences prefs;
+  // 新增:用于自定义对象列表的编码和解码函数
+  final List<String> Function(T value)? encoder;
+  final T Function(List<String> jsonList)? decoder;
+
+  late T _value;
+  T get value => _value;
+  set value(T v) {
+    _value = v;
+    if (_value is bool) {
+      unawaited(prefs.setBool(key, _value as bool));
+    } else if (_value is int) {
+      unawaited(prefs.setInt(key, _value as int));
+    } else if (_value is String) {
+      unawaited(prefs.setString(key, _value as String));
+    } else if (_value is double) {
+      unawaited(prefs.setDouble(key, _value as double));
+    } else if (_value is DateTime) {
+      unawaited(prefs.setInt(key, (_value as DateTime).millisecondsSinceEpoch));
+    } // !!! 改造点:处理 List<CompletedWork> 或 List<CompletedCollection>
+    else if (_value is List<String> && encoder == null) {
+      // 兼容旧的List<String>
+      unawaited(prefs.setStringList(key, _value as List<String>));
+    } else if (_value is List && encoder != null) {
+      // 针对自定义对象列表
+      unawaited(prefs.setStringList(key, encoder!(_value))); // 使用 encoder 编码
+    }
+    // 注意:这里没有对List<T> (非List<String>) 和非列表的自定义T进行处理,
+    // 如果有其他非List<String>的自定义T需要持久化,需要在这里添加逻辑,
+    // 例如通过 jsonEncode(value.toJson()) / T.fromJson(jsonDecode(string))
+  }
+
+  PreferencesValue(this.key, this.defaultValue, this.prefs, {bool saveDefault = false, this.encoder, this.decoder}) {
+    _value = defaultValue;
+    bool isExist = prefs.containsKey(key);
+    if (_value is bool) {
+      _value = (prefs.getBool(key) ?? defaultValue) as T;
+      if (!isExist && saveDefault) unawaited(prefs.setBool(key, _value as bool));
+    } else if (_value is int) {
+      _value = (prefs.getInt(key) ?? defaultValue) as T;
+      if (!isExist && saveDefault) unawaited(prefs.setInt(key, _value as int));
+    } else if (_value is String) {
+      _value = (prefs.getString(key) ?? defaultValue) as T;
+      if (!isExist && saveDefault) unawaited(prefs.setString(key, _value as String));
+    } else if (_value is double) {
+      _value = (prefs.getDouble(key) ?? defaultValue) as T;
+      if (!isExist && saveDefault) unawaited(prefs.setDouble(key, _value as double));
+    } else if (_value is DateTime) {
+      int? tmp = prefs.getInt(key);
+      _value = (tmp == null ? defaultValue : DateTime.fromMillisecondsSinceEpoch(tmp)) as T;
+      if (!isExist && saveDefault) unawaited(prefs.setInt(key, (_value as DateTime).millisecondsSinceEpoch));
+    } // !!! 改造点:处理 List<CompletedWork> 或 List<CompletedCollection>
+    else if (_value is List<String> && decoder == null) {
+      // 兼容旧的List<String>
+      _value = (prefs.getStringList(key) ?? defaultValue) as T;
+      if (!isExist && saveDefault) unawaited(prefs.setStringList(key, _value as List<String>));
+    } else if (_value is List && decoder != null) {
+      // 针对自定义对象列表
+      final List<String>? jsonList = prefs.getStringList(key);
+      if (jsonList != null && jsonList.isNotEmpty) {
+        _value = decoder!(jsonList); // 使用 decoder 反序列化
+      } else {
+        _value = defaultValue;
+      }
+      if (!isExist && saveDefault) unawaited(prefs.setStringList(key, encoder!(_value))); // 保存默认值
+    }
+  }
+}

+ 18 - 37
lib/play/board.dart

@@ -7,6 +7,8 @@ import 'dart:ui' as ui;
 import 'package:flutter/material.dart';
 import 'package:image_puzzle/config/device.dart';
 import 'package:image_puzzle/play/piece.dart';
+import 'package:image_puzzle/skin/skin.dart';
+import 'package:image_puzzle/utils/utils.dart';
 import 'package:logging/logging.dart';
 import 'package:vector_math/vector_math.dart' as vmath;
 
@@ -36,13 +38,22 @@ class Board {
   /// 拼图列数(3/4/5)
   final int cols;
 
+  /// 困难模式
+  final bool hard;
+
   /// 整个拼图在屏幕上的目标区域(最终完整显示的位置和大小)
   final Rect targetRect;
 
+  // 初始等于targetRect, 关卡成功后, 核心绘制区上移, 这个rect用于做动画控制,success之后使用这个
+  Rect finalRect;
+
   // 碎片的逻辑宽高
   double get pieceLogicalWidth => targetRect.width / cols;
   double get pieceLogicalHeight => targetRect.height / rows;
 
+  // 碎片的圆角半径,宫格越多圆角半径越小
+  double get cornerRadius => 10.0 - rows.toDouble();
+
   // 设备信息
   final Device device;
 
@@ -52,9 +63,6 @@ class Board {
 
   ValueNotifier boardNotifier = ValueNotifier(1);
 
-  // 用户是否真正可以开始动手开玩(所有加载初始化完成,并且进入的插屏广告播放完成)
-  final Completer<bool> PlayCompleter = Completer();
-
   BoardStatus _status = BoardStatus.loading;
   BoardStatus get status => _status;
 
@@ -94,7 +102,8 @@ class Board {
 
   final TickerProviderStateMixin ticker;
 
-  Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.targetRect, this.device, {Map<String, dynamic>? json}) {
+  Board(this.ticker, this.image, this.cardImage, this.rows, this.cols, this.hard, this.targetRect, this.device, {Map<String, dynamic>? json})
+    : finalRect = targetRect {
     _recordBackground(); // 录制静态背景,提升性能
     _initPieces();
     rebuildAllGroups();
@@ -112,23 +121,10 @@ class Board {
       for (int j = 0; j < cols; j++) {
         final index = i * cols + j;
         final sourceRect = Rect.fromLTWH(j * pieceWidth, i * pieceHeight, pieceWidth, pieceHeight);
-        final correctOffset = Offset(targetRect.left + j * pieceLogicalWidth, targetRect.top + i * pieceLogicalHeight);
         final transform = getTransformByCoordinate(i, j);
 
         pieces.add(
-          Piece(
-            board: this,
-            index: index,
-            row: i,
-            col: j,
-            rows: rows,
-            cols: cols,
-            correctOffset: correctOffset,
-            sourceRect: sourceRect,
-            curRow: i,
-            curCol: j,
-            transform: transform,
-          ),
+          Piece(board: this, index: index, row: i, col: j, rows: rows, cols: cols, sourceRect: sourceRect, curRow: i, curCol: j, transform: transform),
         );
       }
     }
@@ -349,7 +345,6 @@ class Board {
     final canvas = Canvas(recorder, recordBounds);
 
     // --- 静态绘制配置 ---
-    const double cornerRadius = 8.0;
     const double strokeWidth = 1.0; // 拼图槽位的线宽
     final double halfStroke = strokeWidth / 2.0;
 
@@ -357,20 +352,18 @@ class Board {
     canvas.drawRect(
       recordBounds,
       Paint()
-        ..color = Colors
-            .lightGreen // 主背景色
+        ..color = SkinHelper.wholeBgColor
         ..style = PaintingStyle.fill,
     );
 
     // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容)
     final slotFillPaint = Paint()
-      ..color = Colors.green
+      ..color = SkinHelper.slotBgColor
       // .shade100 // 槽位填充色
       ..style = PaintingStyle.fill;
 
     final slotStrokePaint = Paint()
-      ..color =
-          Color(0xff26600c) // 槽位边框色
+      ..color = SkinHelper.slotBorderColor
       ..style = PaintingStyle.stroke
       ..strokeWidth = strokeWidth;
 
@@ -385,7 +378,7 @@ class Board {
         // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
         final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke);
 
-        final slotRRect = RRect.fromRectAndRadius(slotRect, const Radius.circular(cornerRadius));
+        final slotRRect = RRect.fromRectAndRadius(slotRect, Radius.circular(cornerRadius));
 
         // 绘制填充
         canvas.drawRRect(slotRRect, slotFillPaint);
@@ -400,15 +393,3 @@ class Board {
     _log.info('Static background picture recorded. Size: ${recordBounds.size}');
   }
 }
-
-// 辅助扩展:解决 Dart 缺少 firstWhereOrNull 的问题
-extension IterableExtension<T> on Iterable<T> {
-  T? firstWhereOrNull(bool Function(T element) test) {
-    for (final element in this) {
-      if (test(element)) {
-        return element;
-      }
-    }
-    return null;
-  }
-}

+ 58 - 23
lib/play/board_painter.dart

@@ -1,12 +1,12 @@
 // board_painter.dart
 
-import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
 
 import 'package:image_puzzle/play/board.dart';
 import 'package:image_puzzle/play/piece.dart';
+import 'package:image_puzzle/skin/skin.dart';
 import 'package:logging/logging.dart';
 
 final Logger _log = Logger('board_painter.dart');
@@ -20,21 +20,18 @@ class BoardPainter extends CustomPainter {
 
   // 边框画笔 (外边框,黑色)
   final Paint _outerBorderPaint = Paint()
-    ..color = Colors.black
+    ..color = SkinHelper.outLineBorderColor
     ..style = PaintingStyle.stroke
     ..strokeWidth = _pieceStrokeWidth
     ..isAntiAlias = true;
 
   // 边框画笔 (内边框,白色)
   final Paint _innerBorderPaint = Paint()
-    ..color = Colors.white
+    ..color = SkinHelper.innerLineBorderColor
     ..style = PaintingStyle.stroke
     ..strokeWidth = _pieceStrokeWidth
     ..isAntiAlias = true;
 
-  // 卡片背面画笔
-  final Paint _cardBackPaint = Paint()..isAntiAlias = true;
-
   BoardPainter({required this.board, required this.prepareAnimation}) : super(repaint: Listenable.merge([board.boardNotifier, prepareAnimation])); // 触发重绘
 
   @override
@@ -64,7 +61,7 @@ class BoardPainter extends CustomPainter {
     canvas.drawRect(
       Rect.fromLTWH(0, 0, size.width, size.height),
       Paint()
-        ..color = Colors.lightGreen
+        ..color = SkinHelper.wholeBgColor
         ..style = PaintingStyle.fill,
     );
   }
@@ -75,7 +72,7 @@ class BoardPainter extends CustomPainter {
     canvas.drawRect(
       Rect.fromLTWH(0, 0, size.width, size.height),
       Paint()
-        ..color = Colors.lightGreen
+        ..color = SkinHelper.wholeBgColor
         ..style = PaintingStyle.fill,
     );
     if (prepareAnimation.isAnimating) {
@@ -107,7 +104,7 @@ class BoardPainter extends CustomPainter {
     }
 
     // 2. 收集所有不重复的群组(避免重复绘制)
-    final Set<PieceGroup> drawnGroups = {};
+    // final Set<PieceGroup> drawnGroups = {};
     for (final piece in board.pieces) {
       _drawPiece(canvas, size, piece);
       // if (piece.group != null) {
@@ -123,20 +120,60 @@ class BoardPainter extends CustomPainter {
   }
 
   _paintSuccess(Canvas canvas, Size size) {
-    // 成功状态的绘制逻辑,目前和 playing 相同
-    _paintPlaying(canvas, size);
+    final cornerRadius = board.cornerRadius;
+    final targetRect = board.finalRect;
+    final rrect = RRect.fromRectAndRadius(targetRect, Radius.circular(cornerRadius));
+    final sourceRect = Rect.fromLTWH(0, 0, board.image.width.toDouble(), board.image.height.toDouble());
+
+    final Paint outerBorderPaint = Paint()
+      ..color = SkinHelper.outLineBorderColor
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0
+      ..isAntiAlias = true;
+
+    // 边框画笔
+    final Paint innerBorderPaint = Paint()
+      ..color = SkinHelper.innerLineBorderColor
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0
+      ..isAntiAlias = true;
+
+    final outerRRect = RRect.fromRectAndRadius(targetRect.deflate(0.5), Radius.circular(cornerRadius));
+    final innerRRect = RRect.fromRectAndRadius(targetRect.deflate(1.5), Radius.circular(cornerRadius));
+
+    // 1. 绘制整个屏幕背景
+    canvas.drawRect(
+      Rect.fromLTWH(0, 0, size.width, size.height),
+      Paint()
+        ..color = SkinHelper.wholeBgColor
+        ..style = PaintingStyle.fill,
+    );
+
+    canvas.save();
+
+    canvas.clipRRect(rrect);
+
+    canvas.drawImageRect(board.image, sourceRect, targetRect, Paint()..isAntiAlias = true);
+
+    canvas.restore();
+
+    // 绘制边框
+    canvas.drawRRect(outerRRect, outerBorderPaint);
+    canvas.drawRRect(innerRRect, innerBorderPaint);
   }
 
   void _drawDealingPiece(Canvas canvas, Size size, Piece piece) {
+    final cornerRadius = board.cornerRadius;
+
     final Paint outerBorderPaint = Paint()
-      ..color = Colors.black
+      ..color = SkinHelper.outLineBorderColor
       ..style = PaintingStyle.stroke
       ..strokeWidth = 1.0
       ..isAntiAlias = true;
 
     // 边框画笔
     final Paint innerBorderPaint = Paint()
-      ..color = Colors.white
+      ..color = SkinHelper.innerLineBorderColor
       ..style = PaintingStyle.stroke
       ..strokeWidth = 2.0
       ..isAntiAlias = true;
@@ -145,9 +182,9 @@ class BoardPainter extends CustomPainter {
     final h = piece.height;
     final storage64 = Float64List.fromList(piece.transform.storage);
 
-    final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, w, h), Radius.circular(8.0));
-    final outerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(0.5, 0.5, w - 1.0, h - 1.0), Radius.circular(8.0));
-    final innerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(2.5, 2.5, w - 5.0, h - 5.0), Radius.circular(8.0));
+    final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, w, h), Radius.circular(cornerRadius));
+    final outerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(0.5, 0.5, w - 1.0, h - 1.0), Radius.circular(cornerRadius));
+    final innerRRect = RRect.fromRectAndRadius(Rect.fromLTWH(2.5, 2.5, w - 5.0, h - 5.0), Radius.circular(cornerRadius));
 
     final Rect dstRect = Rect.fromLTWH(0, 0, w, h);
 
@@ -159,11 +196,9 @@ class BoardPainter extends CustomPainter {
 
     // 绘制图片:只有落在 rrect 内部的部分图片会被绘制
     if (!piece.isFlipped) {
-      // _log.info("show card");
       final Rect sourceRect = Rect.fromLTWH(0, 0, board.cardImage.width.toDouble(), board.cardImage.height.toDouble());
       canvas.drawImageRect(board.cardImage, sourceRect, dstRect, Paint()..isAntiAlias = true);
     } else {
-      // _log.info("show image");
       canvas.drawImageRect(board.image, piece.sourceRect, dstRect, Paint()..isAntiAlias = true);
     }
 
@@ -297,7 +332,7 @@ class BoardPainter extends CustomPainter {
     final pieceLogicalHeight = board.pieceLogicalHeight;
 
     // --- 静态绘制配置 ---
-    const double cornerRadius = 8.0;
+    final double cornerRadius = board.cornerRadius;
     const double strokeWidth = 1.0; // 拼图槽位的线宽
     final double halfStroke = strokeWidth / 2.0;
 
@@ -305,17 +340,17 @@ class BoardPainter extends CustomPainter {
     canvas.drawRect(
       Rect.fromLTWH(0, 0, size.width, size.height),
       Paint()
-        ..color = Colors.lightGreen
+        ..color = SkinHelper.wholeBgColor
         ..style = PaintingStyle.fill,
     );
 
     // 2. 绘制每个拼图槽位(圆角矩形)作为背景的一部分 (静态内容)
     final slotFillPaint = Paint()
-      ..color = Colors.green.withAlpha(alpha)
+      ..color = SkinHelper.slotBgColor.withAlpha(alpha)
       ..style = PaintingStyle.fill;
 
     final slotStrokePaint = Paint()
-      ..color = Color(0xff26600c).withAlpha(alpha)
+      ..color = SkinHelper.slotBorderColor.withAlpha(alpha)
       ..style = PaintingStyle.stroke
       ..strokeWidth = strokeWidth;
 
@@ -330,7 +365,7 @@ class BoardPainter extends CustomPainter {
         // 为了避免描边溢出到相邻槽位,将矩形向内收缩 halfStroke
         final slotRect = Rect.fromLTRB(left + halfStroke, top + halfStroke, right - halfStroke, bottom - halfStroke);
 
-        final slotRRect = RRect.fromRectAndRadius(slotRect, const Radius.circular(cornerRadius));
+        final slotRRect = RRect.fromRectAndRadius(slotRect, Radius.circular(cornerRadius));
 
         // 绘制填充
         canvas.drawRRect(slotRRect, slotFillPaint);

+ 446 - 78
lib/play/board_play.dart

@@ -1,16 +1,28 @@
+import 'dart:async';
+import 'dart:io';
 import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+import 'package:image_puzzle/audio/audio_controller.dart';
 import 'package:image_puzzle/config/device.dart';
+import 'package:image_puzzle/models/data.dart';
+import 'package:image_puzzle/models/download.dart';
+import 'package:image_puzzle/models/items.dart';
 import 'package:image_puzzle/play/board.dart';
 import 'package:image_puzzle/play/board_painter.dart';
+import 'package:image_puzzle/play/confetti_layer.dart';
 import 'package:image_puzzle/play/piece.dart';
+import 'package:image_puzzle/settings/settings_dialog.dart';
+import 'package:image_puzzle/skin/skin.dart';
+import 'package:image_puzzle/utils/mybutton.dart';
 import 'package:logging/logging.dart';
 import 'package:provider/provider.dart';
 import 'dart:ui' as ui;
 import 'package:vector_math/vector_math.dart' as vmath;
+import 'package:vibration/vibration.dart';
 
 final Logger _log = Logger('board_play.dart');
 
@@ -27,21 +39,45 @@ enum Action {
 }
 
 class BoardPlay extends StatefulWidget {
-  final int cols;
-  final int rows;
+  final ListItem item;
 
-  const BoardPlay({super.key, required this.cols, required this.rows});
+  const BoardPlay({super.key, required this.item});
 
   @override
   State<StatefulWidget> createState() {
     return _BoardPlayState();
   }
+
+  static PageRouteBuilder buildRoute(ListItem item, {difficult = 1, count = 6}) {
+    return PageRouteBuilder(
+      pageBuilder: (context, animation, secondaryAnimation) {
+        return BoardPlay(item: item);
+      },
+      transitionsBuilder: (context, animation, secondaryAnimation, child) {
+        return FadeTransition(opacity: animation, child: child);
+        // return SlideTransition(
+        //   position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(animation),
+        //   child: child,
+        // );
+      },
+    );
+  }
 }
 
 class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
   final GlobalKey boardKey = GlobalKey();
   Board? board;
   bool _isLoading = true;
+  int progress = 0;
+  bool isDownloadSlow = false;
+  late Timer timer;
+
+  late ItemLoader itemLoader;
+
+  late AudioController audio;
+  late Data data;
+
+  late ConfettiLayer confettiLayer;
 
   Piece? _draggingPiece;
 
@@ -64,14 +100,88 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
   // 发牌动画相关
   late Animation<double> _dealingAnimation;
   // 发牌动画参数
-  final List<double> _pieceStartTimes = []; // 每个卡片的启动时间(单位:ms)
-  final List<double> _pieceDurations = []; // 每个卡片的移动持续时间(单位:ms)
-  double _totalDealingDuration = 0; // 发牌动画总时长(ms)
+  // 发牌间隔(ms)
+  int get _dealingPieceInterval {
+    if (board!.rows <= 3) return 120;
+    if (board!.rows == 4) return 100;
+    if (board!.rows == 5) return 80;
+    if (board!.rows == 6) return 60;
+    return 50;
+  }
+
+  // 每个卡片移动时间(ms)
+  int get _dealingPieceDuration {
+    if (board!.rows <= 3) return 500;
+    if (board!.rows == 4) return 400;
+    if (board!.rows == 5) return 300;
+    if (board!.rows == 6) return 200;
+    return 100;
+  }
+
+  // 发牌动画总时长
+  int get _totalDealingDuration => (board!.pieces.length - 1) * _dealingPieceInterval + _dealingPieceDuration; // 发牌动画总时长(ms)
+
+  Timer? _dealingPeriodicTimer;
+  int _dealingCount = 0; // 计数:记录执行次数
+
+  // 成功动画控制器
+  late AnimationController _successAnimationController;
+  late Animation<double> _offsetAnimation; // 用于控制核心绘制区上移
+  late Animation<double> _bottomSlideAnimation; // 用于控制next按钮从屏幕下方移动上来
+  late Animation<double> _topSlideAnimation; // 用于控制通关banner从屏幕上方移动上来
+
+  // Hard Mode Banner 动画控制器
+  late AnimationController _hardModeBannerController;
+  // 缩放动画
+  late Animation<double> _bannerScaleAnimation;
+  // 透明度动画
+  late Animation<double> _bannerFadeAnimation;
+  // 是否显示 Hard Mode Banner 的标志
+  bool _showHardModeBanner = false;
 
   @override
   initState() {
     super.initState();
 
+    itemLoader = ItemLoader.load(widget.item);
+    _onProgressUpdate();
+    itemLoader.progress.addListener(_onProgressUpdate);
+    timer = Timer(const Duration(seconds: 5), () {
+      if (mounted && progress < 50) {
+        if (progress <= 1) {
+          //啥都没下载到, 直接弹toast然后退出
+          Fluttertoast.showToast(
+            msg: "网络状况不佳",
+            toastLength: Toast.LENGTH_SHORT,
+            gravity: ToastGravity.CENTER,
+            timeInSecForIosWeb: 1,
+            backgroundColor: SkinHelper.slotBorderColor,
+            textColor: Colors.white,
+            fontSize: 16.0,
+          );
+          Navigator.pop(context);
+        } else {
+          // 有下载只是慢
+          setState(() {
+            isDownloadSlow = true;
+          });
+        }
+      }
+    });
+
+    Device device = context.read<Device>();
+
+    audio = context.read<AudioController>();
+    data = context.read<Data>();
+
+    confettiLayer = ConfettiLayer(this);
+
+    Future.delayed(Duration.zero, () {
+      if (mounted) {
+        confettiLayer.setup(context);
+      }
+    });
+
     // 初始化移动动画,在dragging结束松手后的swap或evert操作都需要用到移动
     _moveAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
     _moveAnimationController.addListener(_moveAnimationListener);
@@ -109,31 +219,78 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     flipAnimationController.addListener(_flipAnimationListener);
     flipAnimationController.addStatusListener(_flipAnimationStatusListener);
 
+    // 初始化成功动画
+    _successAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
+    final deltaY = (device.targetRect.top - device.appBarHeight) / 2;
+    _offsetAnimation = Tween<double>(begin: 0.0, end: -deltaY).animate(_successAnimationController);
+    _bottomSlideAnimation =
+        Tween<double>(
+          begin: -500, // 初始在屏幕外
+          end: device.screenSize.height - device.targetRect.bottom + deltaY - 60,
+        ).animate(
+          CurvedAnimation(
+            parent: _successAnimationController,
+            curve: Curves.easeOut, // 缓出曲线,滑入更自然
+          ),
+        );
+    _topSlideAnimation =
+        Tween<double>(
+          begin: -200, // 初始在屏幕外
+          end: device.targetRect.top - deltaY - 70,
+        ).animate(
+          CurvedAnimation(
+            parent: _successAnimationController,
+            curve: Curves.easeOut, // 缓出曲线,滑入更自然
+          ),
+        );
+    _successAnimationController.addListener(_successAnimationListener);
+    _successAnimationController.addStatusListener(_successAnimationStatusListener);
+
+    // 初始化 Hard Mode Banner 动画
+    _hardModeBannerController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500));
+    // 缩放:0.0 -> 1.0 (前 40% 时间快速放大)
+    _bannerScaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
+      CurvedAnimation(
+        parent: _hardModeBannerController,
+        curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
+      ),
+    );
+    // 透明度:1.0 -> 0.0 (后 70% 时间逐渐淡出)
+    _bannerFadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
+      CurvedAnimation(
+        parent: _hardModeBannerController,
+        curve: const Interval(0.3, 1.0, curve: Curves.easeIn),
+      ),
+    );
+    // 监听器用于触发重绘
+    _hardModeBannerController.addListener(() {
+      // if (mounted) setState(() {}); // 效率较低,改用AnimatedBuilder来实现局部重绘
+    });
+    // 动画完成时,设置标志为 false,完全隐藏
+    _hardModeBannerController.addStatusListener((status) {
+      if (status == AnimationStatus.completed) {
+        if (mounted) setState(() => _showHardModeBanner = false);
+      }
+    });
+
     _init();
   }
 
-  // 初始化发牌时间点
-  void _initDealTimes() {
-    _pieceStartTimes.clear();
-    _pieceDurations.clear();
-
-    // 3. 计算动画参数(速度恒定)
-    const double interval = 120; // 发牌间隔(ms)
-    const double duration = 500; // 每个卡片移动时间(ms)
-
-    // 为每个卡片计算启动时间和持续时间
-    for (int i = 0; i < board!.pieces.length; i++) {
-      final startTime = i * interval;
-
-      _pieceStartTimes.add(startTime);
-      _pieceDurations.add(duration);
-    }
+  _onProgressUpdate() {
+    // progress = (downloadItem.progress.value * 100).ceil();
+    progress = (itemLoader.progress.value * 100).ceil();
+    _log.info('onProgressUpdate: progress=$progress');
+    setState(() {});
+  }
 
-    // 总动画时长 = 最后一张卡片的启动时间 + 其持续时间
-    _totalDealingDuration = _pieceStartTimes.last + _pieceDurations.last;
+  void _successAnimationListener() {
+    final delta = _offsetAnimation.value;
+    board!.finalRect = board!.targetRect.translate(0, delta);
+    board!.invalidate();
+  }
 
-    // 更新动画控制器时长
-    dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration.round());
+  void _successAnimationStatusListener(AnimationStatus status) {
+    if (status == AnimationStatus.completed) {}
   }
 
   void _dealingAnimationListener() {
@@ -145,8 +302,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     // 逐个更新卡片位置(最后一张不需要动)
     for (int i = 0; i < board!.pieces.length - 1; i++) {
       final piece = board!.pieces[i];
-      final startTime = _pieceStartTimes[i];
-      final duration = _pieceDurations[i];
+      final startTime = i * _dealingPieceInterval;
+      final duration = _dealingPieceDuration;
 
       // 尚未到启动时间:保持在起点
       if (currentTime < startTime) {
@@ -174,6 +331,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
       board!.shuffle(ShuffleStep.flipping);
       flipAnimationController.forward(from: 0.0);
+      audio.playSfx(SfxType.flip);
     }
   }
 
@@ -213,6 +371,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
         // 启动 Merge 动画
         _mergeGroups = mergeGroups;
         _mergeAnimationController.forward(from: 0.0);
+        audio.playSfx(SfxType.pop);
       }
 
       board!.start();
@@ -255,6 +414,7 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
             // 启动 Merge 动画
             _mergeGroups = mergeGroups;
             _mergeAnimationController.forward(from: 0.0);
+            audio.playSfx(SfxType.pop);
 
             // 如果执行了 merge 动画,将胜利条件检查推迟到 merge 动画完成时
             moveItems = null;
@@ -262,11 +422,6 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
           }
         }
 
-        // 如果没有 merge 发生,或者 move action 是 revert,检查胜利条件
-        if (board!.checkWinCondition()) {
-          board!.success();
-        }
-
         moveItems = null;
       }
     }
@@ -318,9 +473,9 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
       _mergeGroups = null;
 
-      // 检查胜利条件 (现在所有 pieces 都在正确位置且 scale=1.0)
+      // 检查胜利条件
       if (board!.checkWinCondition()) {
-        board!.success();
+        _onSuccess();
       }
 
       board!.invalidate();
@@ -334,13 +489,40 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
   // prepare动画结束,进入洗牌动画
   void _prepareAnimationStatusListener(AnimationStatus status) {
     if (status == AnimationStatus.completed) {
-      _initDealTimes();
+      if (board != null && board!.hard == true) {
+        setState(() => _showHardModeBanner = true);
+        _hardModeBannerController.forward(from: 0.0);
+      }
+
       board!.setAllPieceToBottomRight();
       board!.shuffle(ShuffleStep.dealing);
+      dealingAnimationController.duration = Duration(milliseconds: _totalDealingDuration);
       dealingAnimationController.forward(from: 0.0);
+      audio.playSfx(SfxType.card);
+      _dealingPeriodicTimer = Timer.periodic(Duration(milliseconds: 120), (timer) {
+        if (mounted) {
+          _dealingCount++;
+          if (_dealingCount >= (_totalDealingDuration / 120) - 2) {
+            timer.cancel();
+          } else {
+            audio.playSfx(SfxType.card);
+          }
+        }
+      });
     }
   }
 
+  _onSuccess() {
+    _log.info('success! 游戏完成!');
+    data.workDone(widget.item);
+    board!.success();
+    audio.playSfx(SfxType.success);
+    confettiLayer.play();
+
+    _successAnimationController.forward(from: 0.0);
+    setState(() {});
+  }
+
   _init() async {
     Device device = context.read<Device>();
 
@@ -352,19 +534,23 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     final targetRect = device.targetRect;
     final bestImageSize = device.bestImageSize;
 
+    final image = await itemLoader.getImageBySize(bestImageSize.width.round(), bestImageSize.height.round());
+    _log.info('imageSize: (${image.width},${image.height}), bestImageSize: ($bestImageSize)');
+
     // 加载图片,后续改为从远程服务器加载, 目前demo从本地assets读取
-    final ByteData data = await rootBundle.load('assets/images/test.jpeg');
-    final ui.Codec codec = await ui.instantiateImageCodec(
-      data.buffer.asUint8List(),
-      targetWidth: bestImageSize.width.round(),
-      targetHeight: bestImageSize.height.round(),
-    );
-    final ui.FrameInfo frameInfo = await codec.getNextFrame();
-    final image = frameInfo.image;
+    // final ByteData data = await rootBundle.load('assets/images/test.jpeg');
+    // final ByteData data = await rootBundle.load(widget.item.image);
+    // final ui.Codec codec = await ui.instantiateImageCodec(
+    //   data.buffer.asUint8List(),
+    //   targetWidth: bestImageSize.width.round(),
+    //   targetHeight: bestImageSize.height.round(),
+    // );
+    // final ui.FrameInfo frameInfo = await codec.getNextFrame();
+    // final image = frameInfo.image;
 
     // 加载扑克背面图片,用于制作发牌动画
-    final Size bestCardImageSize = Size(targetRect.width * dpr / widget.rows, targetRect.height * dpr / widget.cols);
-    final ByteData cardData = await rootBundle.load('assets/images/backcard.png');
+    final Size bestCardImageSize = Size(targetRect.width * dpr / widget.item.rows, targetRect.height * dpr / widget.item.cols);
+    final ByteData cardData = await rootBundle.load(widget.item.hard ? 'assets/images/backcard_red.png' : 'assets/images/backcard_blue.png');
     final ui.Codec cardCodec = await ui.instantiateImageCodec(
       cardData.buffer.asUint8List(),
       targetWidth: bestCardImageSize.width.round(),
@@ -373,11 +559,14 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     final ui.FrameInfo cardFrameInfo = await cardCodec.getNextFrame();
     final cardImage = cardFrameInfo.image;
 
-    board = Board(this, image, cardImage, widget.rows, widget.cols, targetRect, device);
+    board = Board(this, image, cardImage, widget.item.rows, widget.item.cols, widget.item.hard, targetRect, device);
     board!.prepare();
-    _prepareAnimationController.forward(from: 0.0);
 
+    // **修正:在调用 AnimationController 之前检查 `mounted` 状态**
     if (!mounted) return;
+
+    _prepareAnimationController.forward(from: 0.0);
+
     setState(() {
       _isLoading = false;
     });
@@ -391,6 +580,9 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   @override
   dispose() {
+    timer.cancel();
+    itemLoader.progress.removeListener(_onProgressUpdate);
+
     _moveAnimationController.removeListener(_moveAnimationListener);
     _moveAnimationController.removeStatusListener(_moveAnimationStatusListener);
     _moveAnimationController.dispose();
@@ -411,7 +603,17 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
     flipAnimationController.removeStatusListener(_flipAnimationStatusListener);
     flipAnimationController.dispose();
 
+    _successAnimationController.removeListener(_successAnimationListener);
+    _successAnimationController.removeStatusListener(_successAnimationStatusListener);
+
+    _hardModeBannerController.dispose();
+
+    _dealingPeriodicTimer?.cancel();
+
+    confettiLayer.dispose();
+
     board?.dispose();
+
     super.dispose();
   }
 
@@ -423,7 +625,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
       body: Stack(
         children: <Widget>[
           if (!_isLoading) Positioned.fill(child: _buildPuzzleCanvas(device.screenSize.width, device.screenSize.height)),
-          Positioned(top: 0, left: 0, right: 0, child: appBar),
+          if (board == null || board!.status != BoardStatus.success) Positioned(top: 0, left: 0, right: 0, child: appBar),
+          // Positioned(top: 0, left: 0, right: 0, child: appBar),
           Positioned(
             bottom: 0,
             left: 0,
@@ -435,45 +638,194 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
               child: const Text('Banner 广告区域', style: TextStyle(fontSize: 12)),
             ),
           ),
+          successBanner,
+          nextButton,
           if (_isLoading)
             Positioned.fill(
               child: Container(
-                color: Colors.lightGreen, // 填充绿色背景(可根据需要调整色值,如 Colors.green.shade100 浅绿)
-                child: const Center(
-                  child: CircularProgressIndicator(
-                    // 可选:调整进度条颜色,与绿色背景对比更明显
-                    valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
+                color: SkinHelper.wholeBgColor,
+                child: const Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white))),
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+
+  Widget get appBar => SafeArea(
+    child: Padding(
+      padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 10.0),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          // 左侧占位(保持标题居中)
+          const SizedBox(width: 30),
+          // 中间标题
+          Text(
+            board != null && board!.status == BoardStatus.success ? '关卡通过!' : '关卡${data.currentLevel}',
+            style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
+          ),
+          // 右侧设置按钮(30x30 圆形、深绿色背景、白色图标)
+          SizedBox(
+            width: 30,
+            height: 30,
+            child: IconButton(
+              icon: const Icon(Icons.settings, color: Colors.white, size: 22),
+              iconSize: 22,
+              padding: EdgeInsets.zero, // 清除默认内边距,确保按钮尺寸准确
+              onPressed: () {
+                Navigator.push(context, SettingsDialog.buildRoute(showReturn: true, showRestart: true, item: widget.item));
+              },
+              style: ButtonStyle(
+                // 深绿色背景(与你之前的按钮风格一致,使用 Color(0xff26600c) 深绿色)
+                backgroundColor: WidgetStateProperty.all(SkinHelper.slotBorderColor),
+                // 圆形形状
+                shape: WidgetStateProperty.all(
+                  RoundedRectangleBorder(
+                    borderRadius: BorderRadius.circular(15), // 30x30 按钮对应 15 圆角
                   ),
                 ),
+                // 固定按钮尺寸(30x30)
+                minimumSize: WidgetStateProperty.all(const Size(30, 30)),
+                maximumSize: WidgetStateProperty.all(const Size(30, 30)),
               ),
             ),
+          ),
         ],
       ),
+    ),
+  );
+
+  Widget _buildPuzzleCanvas(double width, double height) {
+    return RepaintBoundary(
+      child: CustomPaint(
+        painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
+        size: Size(width, height),
+        child: GestureDetector(
+          key: boardKey,
+          onPanStart: _onPanStart,
+          onPanUpdate: _onPanUpdate,
+          onPanEnd: _onPanEnd,
+          child: // 根据游戏状态动态显示提示动画或透明容器
+          board != null && board!.hard && _showHardModeBanner
+              ? _hardModeBanner
+              : Container(color: Colors.transparent), // 非显示条件时,使用透明容器
+        ),
+      ),
     );
   }
 
-  final appBar = SafeArea(
-    child: Center(
-      child: Padding(
-        padding: const EdgeInsets.all(10.0),
-        child: Text(
-          '关卡1',
-          style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
+  // 困难模式提示动画组件
+  Widget get _hardModeBanner => Center(
+    // 使用 AnimatedBuilder 包裹需要动画的组件
+    child: AnimatedBuilder(
+      animation: _hardModeBannerController, // 监听控制器
+      builder: (context, child) {
+        return FadeTransition(
+          opacity: _bannerFadeAnimation, // 使用控制器驱动的动画值
+          child: ScaleTransition(
+            scale: _bannerScaleAnimation, // 使用控制器驱动的动画值
+            child: child, // 不随动画重建的子组件
+          ),
+        );
+      },
+      // child 是不依赖动画状态变化的组件,只会构建一次
+      child: Container(
+        width: double.infinity,
+        height: 60,
+        margin: const EdgeInsets.symmetric(horizontal: 10),
+        decoration: BoxDecoration(
+          color: Colors.red,
+          borderRadius: BorderRadius.circular(10),
+          border: Border.all(color: const Color.fromARGB(255, 247, 143, 135), width: 2),
+          boxShadow: [BoxShadow(color: Color.fromRGBO(0, 0, 0, 0.3), blurRadius: 5, offset: const Offset(0, 3))],
+        ),
+        child: const Center(
+          child: Text(
+            '困难模式',
+            style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold),
+          ),
         ),
       ),
     ),
   );
 
-  Widget _buildPuzzleCanvas(double width, double height) {
-    return CustomPaint(
-      painter: BoardPainter(board: board!, prepareAnimation: _prepareAnimationController),
-      size: Size(width, height),
-      child: GestureDetector(
-        key: boardKey,
-        onPanStart: _onPanStart,
-        onPanUpdate: _onPanUpdate,
-        onPanEnd: _onPanEnd,
-        child: Container(color: Colors.transparent),
+  Widget get nextButton {
+    Device device = context.read<Device>();
+    return AnimatedBuilder(
+      // 监听显式动画 _bottomSlideAnimation
+      animation: _bottomSlideAnimation,
+      builder: (context, child) {
+        return AnimatedPositioned(
+          duration: _successAnimationController.duration!,
+          // 从动画中获取实时 value,赋值给 bottom
+          bottom: _bottomSlideAnimation.value,
+          left: (device.screenSize.width - 200) / 2,
+          child: child!, // 固定子组件,优化性能
+        );
+      },
+      // 固定的按钮组件(仅构建一次,优化性能)
+      child: MyElevatedButton(
+        width: 200,
+        borderRadius: BorderRadius.circular(20),
+        gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
+        onPressed: () {
+          audio.playSfx(SfxType.tap);
+          Navigator.pop(context, true);
+        },
+        child: const Text('Next', style: TextStyle(color: Colors.white, fontSize: 20)),
+      ),
+    );
+  }
+
+  Widget get successBanner {
+    Device device = context.read<Device>();
+    // 计算banner宽高
+    final bannerWidth = device.screenSize.width - 60; // 左右各30间距
+    final bannerHeight = 60.0;
+
+    return AnimatedBuilder(
+      animation: _bottomSlideAnimation,
+      builder: (context, child) {
+        return AnimatedPositioned(
+          duration: _successAnimationController.duration!,
+          top: _topSlideAnimation.value, // 固定底部位置
+          left: 30, // 左间距30,与bannerWidth配合实现水平居中
+          child: child!,
+        );
+      },
+      // 核心:用Container固定尺寸,Stack填充Container,确保图片和文字尺寸对齐
+      child: SizedBox(
+        width: bannerWidth, // 容器宽=图片宽
+        height: bannerHeight, // 容器高=图片高
+        child: Stack(
+          children: [
+            // 1. 图片充满容器(与容器尺寸一致)
+            Image.asset(
+              'assets/images/banner3.png',
+              width: double.infinity, // 图片宽=容器宽
+              height: double.infinity, // 图片高=容器高
+              fit: BoxFit.cover, // 图片填充容器(不拉伸,超出部分裁剪)
+              cacheWidth: (context.watch<Device>().devicePixelRatio * bannerWidth).toInt(),
+              cacheHeight: (context.watch<Device>().devicePixelRatio * bannerHeight).toInt(),
+            ),
+            const Center(
+              child: Padding(
+                padding: EdgeInsets.only(top: 16.0),
+                child: Text(
+                  '关卡通过!',
+                  style: TextStyle(
+                    color: Colors.white,
+                    fontSize: 22,
+                    fontWeight: FontWeight.bold,
+                    shadows: [Shadow(color: Colors.black54, offset: Offset(1, 1), blurRadius: 2)],
+                  ),
+                ),
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }
@@ -485,6 +837,10 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
   void _onPanStart(DragStartDetails details) {
     _log.info('_onPanStart');
+    if (board!.status != BoardStatus.playing) {
+      _log.info('不是playing状态,不响应onPanStart');
+      return;
+    }
     // 动画中断逻辑:如果动画正在进行,立即停止并强制所有 pieces 归位到最终位置
     if (_moveAnimationController.isAnimating && moveItems != null) {
       _log.info('移动动画中断,强制归位/交换');
@@ -522,6 +878,8 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
       board!.invalidate();
     }
 
+    // audio.playSfx(SfxType.tap);
+
     // 停止所有正在运行的动画(如果尚未停止)
     _moveAnimationController.stop();
     _mergeAnimationController.stop();
@@ -544,10 +902,15 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
       }
       board!.invalidate();
     }
+
+    if (Platform.isAndroid) {
+      Vibration.vibrate(duration: 60, amplitude: 50);
+    } else {
+      HapticFeedback.mediumImpact();
+    }
   }
 
   void _onPanUpdate(DragUpdateDetails details) {
-    _log.info('_onPanUpdate');
     if (_draggingPiece == null) return;
 
     final Offset delta = details.delta;
@@ -665,26 +1028,31 @@ class _BoardPlayState extends State<BoardPlay> with TickerProviderStateMixin {
 
     // 4. 为所有涉及移动的碎片创建 MoveItem
 
-    // a. 拖拽群组/碎片
-    for (var p in draggingPieces) {
-      // 动画起点:拖拽结束时的实际 Canvas 坐标 (p.transform)
+    // a. 被推开的碎片
+    for (var p in displacedPieces) {
+      // 动画起点:旧的逻辑网格坐标 (p.transform)
       final startTransform = p.transform;
       // 动画终点:新的逻辑网格坐标
       final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
       final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
 
       items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
+
+      board!.pieces.remove(p);
+      board!.pieces.add(p);
     }
 
-    // b. 被推开的碎片
-    for (var p in displacedPieces) {
-      // 动画起点:旧的逻辑网格坐标 (p.transform)
+    // b. 拖拽群组/碎片
+    for (var p in draggingPieces) {
+      // 动画起点:拖拽结束时的实际 Canvas 坐标 (p.transform)
       final startTransform = p.transform;
       // 动画终点:新的逻辑网格坐标
       final endTransform = board!.getTransformByCoordinate(p.curRow, p.curCol);
       final animation = Matrix4Tween(begin: startTransform, end: endTransform).animate(_moveAnimationController);
 
       items.add(MoveItem(piece: p, animation: animation, startTransform: startTransform, endTransform: endTransform, action: Action.swap));
+      board!.pieces.remove(p);
+      board!.pieces.add(p);
     }
 
     // 5. 启动动画

+ 42 - 0
lib/play/confetti_layer.dart

@@ -0,0 +1,42 @@
+import 'package:confetti/confetti.dart';
+import 'package:flutter/material.dart';
+
+class ConfettiLayer {
+  OverlayEntry? _overlayEntry;
+  final TickerProvider tickerProvider;
+  final int numberOfParticles;
+  late ConfettiController controller;
+
+  ConfettiLayer(this.tickerProvider, {this.numberOfParticles = 20}) : controller = ConfettiController(duration: const Duration(milliseconds: 200));
+
+  play() {
+    controller.play();
+  }
+
+  setup(BuildContext context) {
+    if (_overlayEntry != null) return;
+    _overlayEntry = OverlayEntry(
+      builder: (context) {
+        return IgnorePointer(
+          child: Align(
+            alignment: Alignment.center,
+            child: ConfettiWidget(
+              confettiController: controller,
+              blastDirectionality: BlastDirectionality.explosive,
+              numberOfParticles: numberOfParticles,
+              gravity: 1,
+              shouldLoop: false,
+              maxBlastForce: 400,
+              emissionFrequency: 1,
+            ),
+          ),
+        );
+      },
+    );
+    Overlay.of(context).insert(_overlayEntry!);
+  }
+
+  dispose() {
+    _overlayEntry?.remove();
+  }
+}

+ 12 - 18
lib/play/piece.dart

@@ -9,7 +9,6 @@ import 'package:vector_math/vector_math.dart' as vmath;
 
 final Logger _log = Logger('piece.dart');
 
-const double _cornerRadius = 8.0;
 const double _outLineOffset = 0.5;
 const double _innerLineOffset = 1.5;
 
@@ -150,9 +149,8 @@ class PieceGroup {
     final board = pieces[0].board;
     final w = board.pieceLogicalWidth;
     final h = board.pieceLogicalHeight;
-    final radius = _cornerRadius;
 
-    final r = radius; // 使用原始半径,偏移量通过坐标实现
+    final r = board.cornerRadius;
 
     // 1. 确定起始 Piece 和起始点
     Piece startPiece = topLeftPiece;
@@ -526,9 +524,6 @@ class Piece {
   int curCol;
   int curRow;
 
-  // 正确目标位置的左上角坐标 (在 Canvas 坐标系内,这是碎片的最终位置)
-  final Offset correctOffset;
-
   // 碎片的当前几何变换状态 (包括位置、旋转等)
   vmath.Matrix4 transform;
 
@@ -554,7 +549,6 @@ class Piece {
     required this.col,
     required this.rows,
     required this.cols,
-    required this.correctOffset,
     required this.sourceRect,
     required this.curCol,
     required this.curRow,
@@ -883,7 +877,7 @@ class Piece {
         path.moveTo(pTL_T.dx, pTL_T.dy); // 从TL弧的终点开始
       } else {
         // path.moveTo(x0, y0); // 从左上尖角开始
-        path.moveTo(0, y0); // 从左上尖角开始
+        path.moveTo(0 - offset, y0); // 从左上尖角开始
       }
 
       // 绘制 Top 直线段
@@ -891,7 +885,7 @@ class Piece {
         path.lineTo(pTR_T.dx, pTR_T.dy);
       } else {
         // path.lineTo(x1, y0); // 到右上尖角
-        path.lineTo(w, y0); // 到右上尖角
+        path.lineTo(w + offset, y0); // 到右上尖角
       }
     }
 
@@ -905,7 +899,7 @@ class Piece {
         path.moveTo(pTR_R.dx, pTR_R.dy); // 从 TR 弧终点开始
       } else {
         // path.moveTo(x1, y0); // 从右上尖角开始
-        path.moveTo(x1, 0); // 从右上尖角开始
+        path.moveTo(x1, 0 - offset); // 从右上尖角开始
       }
     }
 
@@ -915,7 +909,7 @@ class Piece {
         path.lineTo(pBR_R.dx, pBR_R.dy);
       } else {
         // path.lineTo(x1, y1); // 到右下尖角
-        path.lineTo(x1, h); // 到右下尖角
+        path.lineTo(x1, h + offset); // 到右下尖角
       }
     }
 
@@ -929,7 +923,7 @@ class Piece {
         path.moveTo(pBR_B.dx, pBR_B.dy); // 从 BR 弧终点开始
       } else {
         // path.moveTo(x1, y1); // 从右下尖角开始
-        path.moveTo(w, y1); // 从右下尖角开始
+        path.moveTo(w + offset, y1); // 从右下尖角开始
       }
     }
 
@@ -939,7 +933,7 @@ class Piece {
         path.lineTo(pBL_B.dx, pBL_B.dy);
       } else {
         // path.lineTo(x0, y1); // 到左下尖角
-        path.lineTo(0, y1); // 到左下尖角
+        path.lineTo(0 - offset, y1); // 到左下尖角
       }
     }
 
@@ -953,7 +947,7 @@ class Piece {
         path.moveTo(pBL_L.dx, pBL_L.dy); // 从 BL 弧终点开始
       } else {
         // path.moveTo(x0, y1); // 从左下尖角开始
-        path.moveTo(x0, h); // 从左下尖角开始
+        path.moveTo(x0, h + offset); // 从左下尖角开始
       }
     }
 
@@ -963,7 +957,7 @@ class Piece {
         path.lineTo(pTL_L.dx, pTL_L.dy);
       } else {
         // path.lineTo(x0, y0); // 到左上尖角
-        path.lineTo(x0, 0); // 到左上尖角
+        path.lineTo(x0, 0 - offset); // 到左上尖角
       }
     }
 
@@ -994,9 +988,9 @@ class Piece {
       borders = [_hasTopBorder, _hasRightBorder, _hasBottomBorder, _hasLeftBorder];
     }
 
-    path = _generateClipPath(width, height, borders, _cornerRadius);
-    outLinePath = _generateBorderPath(width, height, borders, _cornerRadius, _outLineOffset);
-    innerLinePath = _generateBorderPath(width, height, borders, _cornerRadius, _innerLineOffset);
+    path = _generateClipPath(width, height, borders, board.cornerRadius);
+    outLinePath = _generateBorderPath(width, height, borders, board.cornerRadius, _outLineOffset);
+    innerLinePath = _generateBorderPath(width, height, borders, board.cornerRadius, _innerLineOffset);
 
     return [path!, outLinePath!, innerLinePath!];
   }

+ 52 - 0
lib/settings/settings_controller.dart

@@ -0,0 +1,52 @@
+// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:image_puzzle/persistence/persistence.dart';
+
+/// An class that holds settings like [playerName] or [musicOn],
+/// and saves them to an injected persistence store.
+class SettingsController {
+  final Persistence _persistence;
+
+  /// Whether or not the sound is on at all. This overrides both music
+  /// and sound.
+  ValueNotifier<bool> sound = ValueNotifier(true);
+  ValueNotifier<bool> music = ValueNotifier(true);
+  ValueNotifier<bool> vibrate = ValueNotifier(true);
+  ValueNotifier<int> skin = ValueNotifier(0);
+
+  /// Creates a new instance of [SettingsController] backed by [persistence].
+  SettingsController({required Persistence persistence}) : _persistence = persistence;
+
+  /// Asynchronously loads values from the injected persistence store.
+  Future<void> loadStateFromPersistence() async {
+    sound.value = _persistence.sound;
+    music.value = _persistence.music;
+    vibrate.value = _persistence.vibrate;
+    skin.value = _persistence.skin;
+  }
+
+  void toggleSound() {
+    sound.value = !sound.value;
+    _persistence.sound = sound.value;
+  }
+
+  void toggleMusic() {
+    music.value = !music.value;
+    _persistence.music = music.value;
+  }
+
+  void toggleVibrate() {
+    vibrate.value = !vibrate.value;
+    _persistence.vibrate = vibrate.value;
+  }
+
+  void setSkin(int value) {
+    skin.value = value;
+    _persistence.skin = value;
+  }
+}

+ 204 - 0
lib/settings/settings_dialog.dart

@@ -0,0 +1,204 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/models/items.dart';
+import 'package:image_puzzle/play/board_play.dart';
+import 'package:image_puzzle/settings/settings_controller.dart';
+import 'package:image_puzzle/skin/skin.dart';
+import 'package:image_puzzle/utils/mybutton.dart';
+import 'package:provider/provider.dart';
+
+class SettingsDialog extends StatefulWidget {
+  final ListItem? item;
+  final bool showReturn;
+  final bool showRestart;
+
+  const SettingsDialog({super.key, required this.showReturn, required this.showRestart, required this.item});
+
+  @override
+  State<StatefulWidget> createState() => _SettingDialogState();
+
+  static PageRouteBuilder buildRoute({bool showReturn = false, bool showRestart = false, ListItem? item}) {
+    return PageRouteBuilder(
+      opaque: false, // 不遮盖原来的图层
+      pageBuilder: (context, animation, secondaryAnimation) {
+        return SettingsDialog(showReturn: showReturn, showRestart: showRestart, item: item);
+      },
+      transitionsBuilder: (context, animation, secondaryAnimation, child) {
+        return FadeTransition(opacity: animation, child: child);
+      },
+    );
+  }
+}
+
+class _SettingDialogState extends State<SettingsDialog> {
+  @override
+  Widget build(BuildContext context) {
+    final settings = context.watch<SettingsController>();
+    Widget gap = const SizedBox(height: 10);
+    // 背景色(开启/关闭状态)
+    const activeBgColor = Colors.white;
+    const inactiveBgColor = Color.fromARGB(255, 205, 195, 195);
+    // 图标颜色(固定,与背景形成对比)
+    final iconColor = SkinHelper.slotBorderColor;
+
+    return Scaffold(
+      // backgroundColor: SkinHelper.coreBgColor,
+      backgroundColor: Colors.black.withAlpha(128),
+      body: Center(
+        child: LayoutBuilder(
+          builder: (context, constraints) {
+            const double maxContainerWidth = 500; // 设定一个对话框最大宽度,避免在pad上显示过宽过大不好看
+            final containerWidth = min(constraints.biggest.shortestSide * 0.75, maxContainerWidth);
+            final buttonWidth = containerWidth * 0.8;
+            return Container(
+              width: containerWidth,
+              decoration: BoxDecoration(color: SkinHelper.coreBgColor, borderRadius: const BorderRadius.all(Radius.circular(16))),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                mainAxisAlignment: MainAxisAlignment.center,
+                children: [
+                  // 标题栏(关闭按钮 + 标题)
+                  Row(
+                    mainAxisSize: MainAxisSize.max,
+                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                    children: [
+                      IconButton(
+                        icon: const Icon(Icons.close_rounded, color: Colors.white),
+                        onPressed: () => Navigator.pop(context),
+                      ),
+                      const Text(
+                        '设置',
+                        style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
+                      ),
+                      const SizedBox(width: 48), // 占位,与左侧关闭按钮平衡
+                    ],
+                  ),
+                  gap,
+                  gap,
+                  // 重新开始按钮
+                  if (widget.showRestart)
+                    MyElevatedButton(
+                      onPressed: () {
+                        PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(widget.item!);
+                        Navigator.pop(context);
+                        Navigator.pushReplacement(context, pageRouteBuilder);
+                      },
+                      width: buttonWidth,
+                      borderRadius: BorderRadius.circular(20),
+                      gradient: const LinearGradient(colors: [Colors.white, Colors.white]),
+                      child: Row(
+                        mainAxisAlignment: MainAxisAlignment.center,
+                        children: [
+                          Icon(Icons.restart_alt_rounded, size: 24, weight: 600, color: SkinHelper.slotBorderColor),
+                          const SizedBox(width: 5),
+                          Text('重新开始', style: TextStyle(color: SkinHelper.slotBorderColor, fontSize: 18)),
+                        ],
+                      ),
+                    ),
+                  if (widget.showReturn) gap,
+                  if (widget.showReturn) gap,
+                  // 回到主页按钮
+                  if (widget.showReturn)
+                    MyElevatedButton(
+                      onPressed: () {
+                        Navigator.pop(context);
+                        Navigator.pop(context);
+                      },
+                      width: buttonWidth,
+                      borderRadius: BorderRadius.circular(20),
+                      gradient: const LinearGradient(colors: [Colors.white, Colors.white]),
+                      child: Row(
+                        mainAxisAlignment: MainAxisAlignment.center,
+                        children: [
+                          Icon(Icons.arrow_back, size: 24, weight: 600, color: SkinHelper.slotBorderColor),
+                          const SizedBox(width: 5),
+                          Text('回到主页', style: TextStyle(color: SkinHelper.slotBorderColor, fontSize: 18)),
+                        ],
+                      ),
+                    ),
+                  gap,
+                  gap,
+                  gap,
+                  // 三个设置项(用ValueListenableBuilder监听状态变化)
+                  Padding(
+                    padding: const EdgeInsets.symmetric(horizontal: 20),
+                    child: Row(
+                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+                      children: [
+                        // 1. 背景音乐设置(监听music状态)
+                        ValueListenableBuilder<bool>(
+                          valueListenable: settings.music, // 监听music的变化
+                          builder: (context, isMusicOn, child) {
+                            // 状态变化时,自动重建此按钮
+                            return _buildSettingButton(
+                              icon: isMusicOn ? Icons.music_note : Icons.music_off,
+                              bgColor: isMusicOn ? activeBgColor : inactiveBgColor,
+                              iconColor: iconColor,
+                              onPressed: () => settings.toggleMusic(),
+                              tooltip: isMusicOn ? "关闭背景音乐" : "开启背景音乐",
+                            );
+                          },
+                        ),
+                        // 2. 音效设置(监听sound状态)
+                        ValueListenableBuilder<bool>(
+                          valueListenable: settings.sound, // 监听sound的变化
+                          builder: (context, isSoundOn, child) {
+                            return _buildSettingButton(
+                              icon: isSoundOn ? Icons.volume_up : Icons.volume_off,
+                              bgColor: isSoundOn ? activeBgColor : inactiveBgColor,
+                              iconColor: iconColor,
+                              onPressed: () => settings.toggleSound(),
+                              tooltip: isSoundOn ? "关闭音效" : "开启音效",
+                            );
+                          },
+                        ),
+                        // 3. 震动设置(监听vibrate状态)
+                        ValueListenableBuilder<bool>(
+                          valueListenable: settings.vibrate, // 监听vibrate的变化
+                          builder: (context, isVibrateOn, child) {
+                            return _buildSettingButton(
+                              icon: isVibrateOn ? Icons.vibration : Icons.crop_portrait,
+                              bgColor: isVibrateOn ? activeBgColor : inactiveBgColor,
+                              iconColor: iconColor,
+                              onPressed: () => settings.toggleVibrate(),
+                              tooltip: isVibrateOn ? "关闭震动" : "开启震动",
+                            );
+                          },
+                        ),
+                      ],
+                    ),
+                  ),
+                  gap,
+                  gap,
+                  gap,
+                ],
+              ),
+            );
+          },
+        ),
+      ),
+    );
+  }
+
+  // 封装设置项按钮(无需额外key,由ValueListenableBuilder保证重绘)
+  Widget _buildSettingButton({
+    required IconData icon,
+    required Color bgColor,
+    required Color iconColor,
+    required VoidCallback onPressed,
+    required String tooltip,
+  }) {
+    return InkWell(
+      // 圆形点击区域
+      borderRadius: BorderRadius.circular(30),
+      onTap: onPressed,
+      child: Container(
+        width: 56,
+        height: 56,
+        decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle),
+        child: Icon(icon, size: 28, color: iconColor),
+      ),
+    );
+  }
+}

+ 50 - 0
lib/skin/skin.dart

@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:image_puzzle/persistence/persistence.dart';
+
+class Skin {
+  final Color color1;
+  final Color color2;
+  final Color color3;
+  final Color color4;
+  final Color color5;
+  final Color colorWhite = Colors.white;
+  final Color colorBlack = Colors.black;
+
+  Color get wholeBgColor => color1; // 整个游戏背景色
+  Color get coreBgColor => color2; // 核心绘制区域背景颜色
+  Color get slotBgColor => color3; // 格子槽位背景颜色
+  Color get slotBorderColor => color4; // 每个格子槽位的边框颜色
+
+  final Color outLineBorderColor; // 外边框颜色
+  final Color innerLineBorderColor; // 内边框颜色
+
+  Skin(this.color1, this.color2, this.color3, this.color4, this.color5, this.outLineBorderColor, this.innerLineBorderColor);
+}
+
+class SkinHelper {
+  // 未来可能有多套皮肤方案,在此定义
+  static List<Skin> skins = [
+    Skin(
+      Colors.lightGreen,
+      Colors.green,
+      const Color.fromARGB(255, 44, 147, 47),
+      Color.fromARGB(255, 38, 96, 12),
+      const Color.fromARGB(255, 8, 66, 9),
+      Colors.black,
+      Colors.white,
+    ),
+  ];
+
+  static Color get color1 => skins[Persistence().skin].color1;
+  static Color get color2 => skins[Persistence().skin].color2;
+  static Color get color3 => skins[Persistence().skin].color3;
+  static Color get color4 => skins[Persistence().skin].color4;
+  static Color get color5 => skins[Persistence().skin].color5;
+
+  static Color get wholeBgColor => skins[Persistence().skin].wholeBgColor;
+  static Color get coreBgColor => skins[Persistence().skin].coreBgColor;
+  static Color get slotBgColor => skins[Persistence().skin].slotBgColor;
+  static Color get slotBorderColor => skins[Persistence().skin].slotBorderColor;
+  static Color get outLineBorderColor => skins[Persistence().skin].outLineBorderColor;
+  static Color get innerLineBorderColor => skins[Persistence().skin].innerLineBorderColor;
+}

+ 30 - 0
lib/statistics/statistics.dart

@@ -0,0 +1,30 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:logging/logging.dart';
+
+final Logger _log = Logger('statistics.dart');
+
+class Statistics {
+  static String get eventUri => 'https://pcoloring.com/napi/event/v2';
+
+  static postEvent(Map data) async {
+    try {
+      _log.info(jsonEncode(data));
+      final response = await http.post(
+        Uri.parse(eventUri),
+        headers: <String, String>{
+          'Content-Type': 'application/json; charset=UTF-8',
+        },
+        body: jsonEncode(data),
+      );
+      if (response.statusCode != 200) {
+        // throw Exception('Invalid status code: ${response.statusCode} when post event to: $eventUri');
+        _log.info('Invalid status code: ${response.statusCode} when post event to: $eventUri');
+      }
+      _log.info('${response.statusCode}, $eventUri');
+    } catch (error) {
+      _log.info('$error');
+    }
+  }
+}

+ 49 - 0
lib/utils/mybutton.dart

@@ -0,0 +1,49 @@
+import 'package:flutter/material.dart';
+
+class MyElevatedButton extends StatelessWidget {
+  final BorderRadiusGeometry? borderRadius;
+  final double? width;
+  final double height;
+  final Gradient gradient;
+  final VoidCallback? onPressed;
+  final Widget child;
+
+  const MyElevatedButton({
+    super.key,
+    required this.onPressed,
+    required this.child,
+    this.borderRadius,
+    this.width,
+    this.height = 44.0,
+    this.gradient = const LinearGradient(colors: [Colors.cyan, Colors.indigo]),
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final borderRadius = this.borderRadius ?? BorderRadius.circular(0);
+    return Container(
+      width: width,
+      height: height,
+      decoration: BoxDecoration(
+        gradient: gradient,
+        borderRadius: borderRadius,
+        boxShadow: const [
+          BoxShadow(
+            color: Colors.black,
+            //spreadRadius: 10,
+            //blurRadius: 2,
+          ),
+        ],
+      ),
+      child: ElevatedButton(
+        onPressed: onPressed,
+        style: ElevatedButton.styleFrom(
+          backgroundColor: Colors.transparent,
+          shadowColor: Colors.transparent,
+          shape: RoundedRectangleBorder(borderRadius: borderRadius),
+        ),
+        child: child,
+      ),
+    );
+  }
+}

+ 52 - 0
lib/utils/ui_image.dart

@@ -0,0 +1,52 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'dart:ui' as ui;
+
+import 'package:logging/logging.dart';
+
+final Logger _log = Logger('ui_image.dart');
+
+class UIImage extends StatelessWidget {
+  final ui.Image image;
+  final Size size;
+
+  const UIImage({super.key, required this.image, this.size = Size.infinite});
+
+  @override
+  Widget build(BuildContext context) {
+    return CustomPaint(
+      size: size,
+      painter: _UIImagePainter(image: image),
+    );
+  }
+}
+
+class _UIImagePainter extends CustomPainter {
+  final ui.Image image;
+  _UIImagePainter({required this.image});
+
+  Rect getRect(Rect content, Rect canvas) {
+    double scale = min(content.width / canvas.width, content.height / canvas.height);
+    Rect rect = Rect.fromLTWH(0, 0, canvas.width * scale, canvas.height * scale);
+    Offset offset = content.center - rect.center;
+    return rect.shift(offset);
+  }
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    Rect content = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
+    Rect canvasRect = Rect.fromLTWH(0, 0, size.width, size.height);
+
+    try {
+      canvas.drawImageRect(image, getRect(content, canvasRect), canvasRect, Paint());
+    } catch (e) {
+      _log.warning(e);
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) {
+    return true;
+  }
+}

+ 260 - 0
lib/utils/utils.dart

@@ -0,0 +1,260 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math' as math;
+import 'dart:ui' as ui;
+
+import 'package:crypto/crypto.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+// import 'package:fluttertoast/fluttertoast.dart';
+import 'package:logging/logging.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:share_plus/share_plus.dart';
+
+T? tryCast<T>(dynamic object) => object is T ? object : null;
+
+final Logger _log = Logger('utils.dart');
+
+Future<ui.Image> loadUiImageFromFile(String filePath) async {
+  final file = await localFile(filePath);
+  final data = await file.readAsBytes();
+  final Completer<ui.Image> completer = Completer();
+  ui.decodeImageFromList(data, (ui.Image img) {
+    return completer.complete(img);
+  });
+  return completer.future;
+}
+
+Future<ui.Image> loadUiImageFromXFile(XFile xfile) async {
+  final file = File(xfile.path);
+  final data = await file.readAsBytes();
+  final Completer<ui.Image> completer = Completer();
+  ui.decodeImageFromList(data, (ui.Image img) {
+    return completer.complete(img);
+  });
+  return completer.future;
+}
+
+Future<ui.Image> loadUiImageFromAsset(String assetPath) async {
+  final ByteData data = await rootBundle.load(assetPath);
+  final Completer<ui.Image> completer = Completer();
+  ui.decodeImageFromList(Uint8List.view(data.buffer), (ui.Image img) {
+    return completer.complete(img);
+  });
+  return completer.future;
+}
+
+Future<ui.Image> loadUiImageFromNetwork(String url) async {
+  final ByteData data = await NetworkAssetBundle(Uri.parse(url)).load('');
+  final Completer<ui.Image> completer = Completer();
+  ui.decodeImageFromList(Uint8List.view(data.buffer), (ui.Image img) {
+    return completer.complete(img);
+  });
+  return completer.future;
+}
+
+Future<Map<String, dynamic>> loadJSONFromAsset(String assetPath) async {
+  final jsonStr = await rootBundle.loadString(assetPath);
+  final Map<String, dynamic> json = jsonDecode(jsonStr);
+  return json;
+}
+
+Future<Uint8List> loadFileDataFromAsset(String assetPath) async {
+  final ByteData data = await rootBundle.load(assetPath);
+  return Uint8List.view(data.buffer);
+}
+
+Future<List<String>> listAssets(String path) async {
+  final manifestContent = await rootBundle.loadString('AssetManifest.json');
+  final Map<String, dynamic> manifestMap = jsonDecode(manifestContent);
+  // _log.info('manifestMap: $manifestMap');
+  final paths = manifestMap.keys.where((String key) => key.contains(path)).toList();
+  return paths;
+}
+
+Future<String> localDir() async {
+  final Directory dir = await getApplicationDocumentsDirectory();
+  return dir.path;
+}
+
+Future<File> localFile(String filePath) async {
+  final String path = await localDir();
+  return File('$path/$filePath');
+}
+
+// Future<Map<String, dynamic>> loadJson(String filePath) async {
+Future<Object> loadJson(String filePath) async {
+  File file = await localFile(filePath);
+  final String str = await file.readAsString();
+  final json = jsonDecode(str);
+  _log.info('json: ${json.runtimeType}');
+  return json;
+}
+
+Future<bool> ensureDirectory(File file) async {
+  Directory directory = file.parent;
+  if (!await directory.exists()) {
+    await directory.create(recursive: true);
+    return true;
+  }
+  return true;
+}
+
+Future<bool> saveBytes(String filePath, Uint8List data) async {
+  File file = await localFile(filePath);
+
+  _log.info('saveImage: file=$file');
+  await ensureDirectory(file);
+  await file.writeAsBytes(data);
+  return true;
+}
+
+Future<bool> saveImage(String filePath, ui.Image image) async {
+  final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
+  if (bytes == null) return false;
+  return saveBytes(filePath, bytes.buffer.asUint8List());
+}
+
+Future<bool> saveJson(String filePath, dynamic data) async {
+  File file = await localFile(filePath);
+  await ensureDirectory(file);
+  String str = jsonEncode(data);
+  await file.writeAsString(str);
+  return true;
+}
+
+Future<bool> saveString(String filePath, String data) async {
+  File file = await localFile(filePath);
+  await ensureDirectory(file);
+  await file.writeAsString(data);
+  return true;
+}
+
+Future<ui.Image> mosaicImage(ui.Image image) async {
+  ui.PictureRecorder recorder = ui.PictureRecorder();
+  ui.Canvas canvas = ui.Canvas(recorder);
+  ui.Paint paint = ui.Paint();
+
+  canvas.drawImageRect(image, Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), const Rect.fromLTWH(0, 0, 8, 8), paint);
+
+  ui.Image mosaicImage = await recorder.endRecording().toImage(8, 8);
+  return mosaicImage;
+}
+
+Future<ui.Image> mosaicImage2(ui.Image image) async {
+  const int piece = 8;
+  final data = await image.toByteData();
+  final pixels = data!.buffer.asUint32List();
+  ui.PictureRecorder recorder = ui.PictureRecorder();
+  ui.Canvas canvas = ui.Canvas(recorder);
+  ui.Paint paint = ui.Paint();
+
+  double width = image.width / piece;
+  double height = image.height / piece;
+  for (int i = 0; i < piece; i++) {
+    for (int j = 0; j < piece; j++) {
+      Rect rect = Rect.fromLTWH(i * width, j * height, width, height);
+      int pixel32 = pixels[rect.center.dy.toInt() * image.width + rect.center.dx.toInt()];
+      int hex = abgrToArgb(pixel32);
+      Color color = Color(hex);
+      canvas.drawRect(rect, paint..color = color);
+    }
+  }
+  ui.Image mosaicImage = await recorder.endRecording().toImage(image.width, image.height);
+  return mosaicImage;
+}
+
+int abgrToArgb(int argbColor) {
+  int r = (argbColor >> 16) & 0xFF;
+  int b = argbColor & 0xFF;
+  return (argbColor & 0xFF00FF00) | (b << 16) | r;
+}
+
+class TimingLogSplit {
+  String tag;
+  Duration duration;
+  TimingLogSplit(this.tag, this.duration);
+  @override
+  String toString() => '$tag: ${duration.inMilliseconds}';
+}
+
+class TimingLog {
+  final String label;
+  final DateTime _start = DateTime.now();
+  DateTime? _lastSplit;
+
+  final List<TimingLogSplit> splits = [];
+
+  TimingLog(this.label);
+
+  TimingLog addSplit(String tag) {
+    _lastSplit ??= _start;
+    final current = DateTime.now();
+    splits.add(TimingLogSplit('$label.$tag', Duration(microseconds: (current.microsecondsSinceEpoch - _lastSplit!.microsecondsSinceEpoch))));
+    _lastSplit = current;
+    return this;
+  }
+
+  TimingLog stop() {
+    final current = DateTime.now();
+    splits.add(TimingLogSplit('$label.total', Duration(microseconds: (current.microsecondsSinceEpoch - _start.microsecondsSinceEpoch))));
+    return this;
+  }
+
+  @override
+  String toString() {
+    return splits.join('\n');
+  }
+}
+
+String md5Hash(String str) {
+  return md5.convert(utf8.encode(str)).toString();
+}
+
+String niceBytes(bytes, {decimals = 2}) {
+  if (!bytes) return '0 Bytes';
+  const k = 1024;
+  final dm = decimals < 0 ? 0 : decimals;
+  final sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+  final i = (math.log(bytes) / math.log(k)).floor();
+  return '${((bytes / math.pow(k, i)).toFixed(dm))} ${sizes[i]}';
+}
+
+String timeSpentFormat(int seconds) {
+  int hour = seconds ~/ 3600;
+  int minute = seconds % 3600 ~/ 60;
+  int second = seconds % 60;
+
+  if (hour > 0) {
+    return "${formatNum(hour)}:${formatNum(minute)}:${formatNum(second)}";
+  } else {
+    return "${formatNum(minute)}:${formatNum(second)}";
+  }
+}
+
+String formatNum(int num) {
+  return num < 10 ? "0$num" : "$num";
+}
+
+void checkMemory() {
+  ImageCache imageCache = PaintingBinding.instance.imageCache;
+  debugPrint("liveImageCount = ${imageCache.liveImageCount}");
+  if (imageCache.liveImageCount >= 100) {
+    debugPrint("liveImageCount = ${imageCache.liveImageCount}, clear!");
+    imageCache.clear();
+    imageCache.clearLiveImages();
+  }
+}
+
+// 辅助扩展:解决 Dart 缺少 firstWhereOrNull 的问题
+extension IterableExtension<T> on Iterable<T> {
+  T? firstWhereOrNull(bool Function(T element) test) {
+    for (final element in this) {
+      if (test(element)) {
+        return element;
+      }
+    }
+    return null;
+  }
+}

+ 4 - 0
linux/flutter/generated_plugin_registrant.cc

@@ -6,9 +6,13 @@
 
 #include "generated_plugin_registrant.h"
 
+#include <jc_audio_player/jc_audio_player_plugin.h>
 #include <url_launcher_linux/url_launcher_plugin.h>
 
 void fl_register_plugins(FlPluginRegistry* registry) {
+  g_autoptr(FlPluginRegistrar) jc_audio_player_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "JcAudioPlayerPlugin");
+  jc_audio_player_plugin_register_with_registrar(jc_audio_player_registrar);
   g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
       fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
   url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

+ 1 - 0
linux/flutter/generated_plugins.cmake

@@ -3,6 +3,7 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
+  jc_audio_player
   url_launcher_linux
 )
 

+ 10 - 0
macos/Flutter/GeneratedPluginRegistrant.swift

@@ -6,11 +6,21 @@ import FlutterMacOS
 import Foundation
 
 import device_info_plus
+import jc_audio_player
+import package_info_plus
 import path_provider_foundation
+import rate_my_app
 import share_plus
+import shared_preferences_foundation
+import sqflite_darwin
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
   DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
+  JcAudioPlayerPlugin.register(with: registry.registrar(forPlugin: "JcAudioPlayerPlugin"))
+  FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
+  SwiftRateMyAppPlugin.register(with: registry.registrar(forPlugin: "SwiftRateMyAppPlugin"))
   SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
+  SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+  SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
 }

+ 26 - 0
macos/Podfile.lock

@@ -2,33 +2,59 @@ PODS:
   - device_info_plus (0.0.1):
     - FlutterMacOS
   - FlutterMacOS (1.0.0)
+  - jc_audio_player (0.0.1):
+    - FlutterMacOS
+  - package_info_plus (0.0.1):
+    - FlutterMacOS
   - path_provider_foundation (0.0.1):
     - Flutter
     - FlutterMacOS
+  - rate_my_app (2.3.2):
+    - Flutter
+    - FlutterMacOS
   - share_plus (0.0.1):
     - FlutterMacOS
+  - shared_preferences_foundation (0.0.1):
+    - Flutter
+    - FlutterMacOS
 
 DEPENDENCIES:
   - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
   - FlutterMacOS (from `Flutter/ephemeral`)
+  - jc_audio_player (from `Flutter/ephemeral/.symlinks/plugins/jc_audio_player/macos`)
+  - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
   - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
+  - rate_my_app (from `Flutter/ephemeral/.symlinks/plugins/rate_my_app/darwin`)
   - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
+  - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
 
 EXTERNAL SOURCES:
   device_info_plus:
     :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
   FlutterMacOS:
     :path: Flutter/ephemeral
+  jc_audio_player:
+    :path: Flutter/ephemeral/.symlinks/plugins/jc_audio_player/macos
+  package_info_plus:
+    :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
   path_provider_foundation:
     :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
+  rate_my_app:
+    :path: Flutter/ephemeral/.symlinks/plugins/rate_my_app/darwin
   share_plus:
     :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
+  shared_preferences_foundation:
+    :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
 
 SPEC CHECKSUMS:
   device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
   FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
+  jc_audio_player: 4e4500996192898ba6c31d36ebc7a09ff477b9c2
+  package_info_plus: f0052d280d17aa382b932f399edf32507174e870
   path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
+  rate_my_app: cbb89973f870601f80f7bad63aba107260cabc5b
   share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
+  shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
 
 PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
 

+ 257 - 8
pubspec.lock

@@ -1,6 +1,14 @@
 # Generated by pub
 # See https://dart.dev/tools/pub/glossary#lockfile
 packages:
+  archive:
+    dependency: transitive
+    description:
+      name: archive
+      sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.7"
   async:
     dependency: transitive
     description:
@@ -17,6 +25,30 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.2"
+  cached_network_image:
+    dependency: "direct main"
+    description:
+      name: cached_network_image
+      sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.4.1"
+  cached_network_image_platform_interface:
+    dependency: transitive
+    description:
+      name: cached_network_image_platform_interface
+      sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.1.1"
+  cached_network_image_web:
+    dependency: transitive
+    description:
+      name: cached_network_image_web
+      sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.1"
   characters:
     dependency: transitive
     description:
@@ -41,6 +73,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.19.1"
+  confetti:
+    dependency: "direct main"
+    description:
+      name: confetti
+      sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.8.0"
   cross_file:
     dependency: transitive
     description:
@@ -118,6 +158,14 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_cache_manager:
+    dependency: transitive
+    description:
+      name: flutter_cache_manager
+      sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.4.1"
   flutter_lints:
     dependency: "direct dev"
     description:
@@ -126,6 +174,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "5.0.0"
+  flutter_rating_bar:
+    dependency: transitive
+    description:
+      name: flutter_rating_bar
+      sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.1"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -136,6 +192,47 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  fluttertoast:
+    dependency: "direct main"
+    description:
+      name: fluttertoast
+      sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2"
+      url: "https://pub.dev"
+    source: hosted
+    version: "9.0.0"
+  http:
+    dependency: "direct main"
+    description:
+      name: http
+      sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.5.0"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.1.2"
+  intl:
+    dependency: "direct main"
+    description:
+      name: intl
+      sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.20.2"
+  jc_audio_player:
+    dependency: "direct main"
+    description:
+      path: "."
+      ref: master
+      resolved-ref: "3412ec4bbf2231cd7e09cdeadf9fed717f951142"
+      url: "git@git.jccytech.cn:guoziyi/jc_audio_player.git"
+    source: git
+    version: "1.0.1"
   leak_tracker:
     dependency: transitive
     description:
@@ -176,6 +273,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.3.0"
+  lottie:
+    dependency: "direct main"
+    description:
+      name: lottie
+      sha256: c5fa04a80a620066c15cf19cc44773e19e9b38e989ff23ea32e5903ef1015950
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.3.1"
   matcher:
     dependency: transitive
     description:
@@ -216,8 +321,32 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.0"
-  path:
+  octo_image:
     dependency: transitive
+    description:
+      name: octo_image
+      sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
+  package_info_plus:
+    dependency: "direct main"
+    description:
+      name: package_info_plus
+      sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
+      url: "https://pub.dev"
+    source: hosted
+    version: "9.0.0"
+  package_info_plus_platform_interface:
+    dependency: transitive
+    description:
+      name: package_info_plus_platform_interface
+      sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.2.1"
+  path:
+    dependency: "direct main"
     description:
       name: path
       sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -288,6 +417,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.8"
+  posix:
+    dependency: transitive
+    description:
+      name: posix
+      sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.3"
   provider:
     dependency: "direct main"
     description:
@@ -296,6 +433,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "6.1.5+1"
+  rate_my_app:
+    dependency: "direct main"
+    description:
+      name: rate_my_app
+      sha256: aeb400808f99aba7e8b853b911915bca10308aff711fa8e2d46b5f68dc383786
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  rxdart:
+    dependency: transitive
+    description:
+      name: rxdart
+      sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.28.0"
   share_plus:
     dependency: "direct main"
     description:
@@ -312,6 +465,62 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "6.1.0"
+  shared_preferences:
+    dependency: "direct main"
+    description:
+      name: shared_preferences
+      sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.3"
+  shared_preferences_android:
+    dependency: transitive
+    description:
+      name: shared_preferences_android
+      sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.13"
+  shared_preferences_foundation:
+    dependency: transitive
+    description:
+      name: shared_preferences_foundation
+      sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.4"
+  shared_preferences_linux:
+    dependency: transitive
+    description:
+      name: shared_preferences_linux
+      sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.1"
+  shared_preferences_platform_interface:
+    dependency: transitive
+    description:
+      name: shared_preferences_platform_interface
+      sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.1"
+  shared_preferences_web:
+    dependency: transitive
+    description:
+      name: shared_preferences_web
+      sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.3"
+  shared_preferences_windows:
+    dependency: transitive
+    description:
+      name: shared_preferences_windows
+      sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.1"
   sky_engine:
     dependency: transitive
     description: flutter
@@ -325,14 +534,46 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.10.1"
-  sprintf:
+  sqflite:
     dependency: transitive
     description:
-      name: sprintf
-      sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
+      name: sqflite
+      sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
       url: "https://pub.dev"
     source: hosted
-    version: "7.0.0"
+    version: "2.4.2"
+  sqflite_android:
+    dependency: transitive
+    description:
+      name: sqflite_android
+      sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.1"
+  sqflite_common:
+    dependency: transitive
+    description:
+      name: sqflite_common
+      sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.6"
+  sqflite_darwin:
+    dependency: transitive
+    description:
+      name: sqflite_darwin
+      sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.2"
+  sqflite_platform_interface:
+    dependency: transitive
+    description:
+      name: sqflite_platform_interface
+      sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.0"
   stack_trace:
     dependency: transitive
     description:
@@ -357,6 +598,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.4.1"
+  synchronized:
+    dependency: transitive
+    description:
+      name: synchronized
+      sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.4.0"
   term_glyph:
     dependency: transitive
     description:
@@ -414,13 +663,13 @@ packages:
     source: hosted
     version: "3.1.4"
   uuid:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: uuid
-      sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
+      sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
       url: "https://pub.dev"
     source: hosted
-    version: "4.5.1"
+    version: "4.5.2"
   vector_math:
     dependency: "direct main"
     description:

+ 20 - 0
pubspec.yaml

@@ -41,6 +41,22 @@ dependencies:
   vector_math: ^2.1.4
   vibration: ^3.1.4
   share_plus: ^12.0.1
+  shared_preferences: ^2.5.3
+  package_info_plus: ^9.0.0
+  rate_my_app: ^2.3.2
+  uuid: ^4.5.2
+  http: ^1.5.0
+  jc_audio_player:
+    version: ^1.0.0
+    git:
+      url: git@git.jccytech.cn:guoziyi/jc_audio_player.git
+      ref: master
+  confetti: ^0.8.0
+  intl: ^0.20.2
+  path: ^1.9.1
+  fluttertoast: ^9.0.0
+  lottie: ^3.3.1
+  cached_network_image: ^3.4.1
 
 dev_dependencies:
   flutter_test:
@@ -70,6 +86,10 @@ flutter:
 
   assets:
     - assets/images/
+    - assets/audio/sfx/
+    - assets/audio/bgm/
+    - assets/builtin/
+    - assets/lottie/
 
   # An image asset can refer to one or more resolution-specific "variants", see
   # https://flutter.dev/to/resolution-aware-images

+ 3 - 0
windows/flutter/generated_plugin_registrant.cc

@@ -6,10 +6,13 @@
 
 #include "generated_plugin_registrant.h"
 
+#include <jc_audio_player/jc_audio_player_plugin_c_api.h>
 #include <share_plus/share_plus_windows_plugin_c_api.h>
 #include <url_launcher_windows/url_launcher_windows.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
+  JcAudioPlayerPluginCApiRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("JcAudioPlayerPluginCApi"));
   SharePlusWindowsPluginCApiRegisterWithRegistrar(
       registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
   UrlLauncherWindowsRegisterWithRegistrar(

+ 1 - 0
windows/flutter/generated_plugins.cmake

@@ -3,6 +3,7 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
+  jc_audio_player
   share_plus
   url_launcher_windows
 )

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff