import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:app_tracking_transparency/app_tracking_transparency.dart'; import 'package:applovin_max/applovin_max.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:logging/logging.dart'; import 'package:lottie/lottie.dart'; import 'package:provider/provider.dart'; import 'package:puzzleweave/ads/applovin_ads_controller.dart'; import 'package:puzzleweave/audio/jc_audio_controller.dart'; import 'package:puzzleweave/collection/collection_screen.dart'; import 'package:puzzleweave/config/config.dart'; import 'package:puzzleweave/config/device.dart'; import 'package:puzzleweave/firebase/adjust_helper.dart'; import 'package:puzzleweave/homepage/home_board_play.dart'; import 'package:puzzleweave/l10n/app_localizations.dart'; import 'package:puzzleweave/models/cached_request.dart'; import 'package:puzzleweave/models/data.dart'; import 'package:puzzleweave/models/download.dart'; import 'package:puzzleweave/models/items.dart'; import 'package:puzzleweave/persistence/persistence.dart'; import 'package:puzzleweave/platform/my_method_channel.dart'; import 'package:puzzleweave/play/board_play.dart'; import 'package:puzzleweave/settings/settings_screen.dart'; import 'package:puzzleweave/skin/skin.dart'; import 'package:puzzleweave/utils/mybutton.dart'; import 'package:puzzleweave/utils/utils.dart'; import '../ads/ads_state.dart'; final Logger _log = Logger('home_screen'); class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreen(); } const int minimumRemoteLoadCount = 30; // 假设加载到 30 张图才算网络畅通 class _HomeScreen extends AdsState with TickerProviderStateMixin { late Device device; late JcAudioController audio; late Data data; List? latest; late CachedRequest latestCachedRequest; late StreamSubscription? latestSubscription; // 自定义画布控制器(可选,用于控制画布绘制逻辑) final _canvasKey = GlobalKey(); // !!! 新增:用于定位 Collection 按钮的 GlobalKey final GlobalKey _collectionKey = GlobalKey(); bool isLoading = true; // !!! 新增:Collection 按钮的动画控制器和动画 late AnimationController _collectionController; // 左上角 collection button 的动画控制器 late Animation _collectionAnimation; // 放大/缩小动画 bool firstRun = false; @override void initState() { super.initState(); _log.info("首页初始化"); // 在组件绘制后检查 firstRun 并导航 if (Persistence().firstRun) { firstRun = true; WidgetsBinding.instance.addPostFrameCallback((_) { // 仅当未跳转过时执行 _handleFirstRunNavigation(); }); Persistence().firstRun = false; } device = context.read(); audio = context.read(); data = context.read(); latestCachedRequest = data.latest; // 主动获取缓存数据(关键) final cachedData = latestCachedRequest.cachedData; if (cachedData != null) { _onLatestDataUpdate(cachedData); } latestSubscription = latestCachedRequest.stream.listen(_onLatestDataUpdate, onError: _onLatestDataError); // !!! 改造点 1: 初始化 Collection 按钮动画 _collectionController = AnimationController( // 设定总时长 duration: const Duration(milliseconds: 300), vsync: this, )..addStatusListener((status) { if (status == AnimationStatus.completed) { audio.playSfx(SfxType.pop); } }); // !!! 改造点 2: 使用 TweenSequence 实现平滑的放大和缩小 _collectionAnimation = TweenSequence([ // 阶段 1: 放大到 1.3 (占总时长的 50%) TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.4).chain(CurveTween(curve: Curves.easeOut)), weight: 40.0), // 阶段 2: 缩小回 1.0 (占总时长的 50%) TweenSequenceItem(tween: Tween(begin: 1.4, end: 1.0).chain(CurveTween(curve: Curves.easeIn)), weight: 60.0), ]).animate(_collectionController); audio.startMusic(); } // 首页初始化之后的跳转,首次运行直接进入play页面,上次从play页面退出有缓存存在也跳转到play页面 void _handleFirstRunNavigation() async { _log.info('First run detected, navigating to initial play page.'); final AssetItem initialItem = AssetItem( Config.firstId, '', 2000, 3000, 3, false, 'assets/builtin/${Config.firstId}.jpeg', 'assets/builtin/${Config.firstId}.jpeg', ); return gotoPlay(initialItem, firstRun: true); } // 检查是否需要跳转到boardplay void checkGoPlay() async { if (currentItem != null) { final jsonFile = await localFile(currentItem!.jsonPath); final exists = await jsonFile.exists(); // !!! 关键修复:检查当前组件是否还在组件树中 if (!mounted) return; if (exists) { gotoPlay(currentItem!); } } } @override void dispose() { latestSubscription?.cancel(); _collectionController.dispose(); super.dispose(); } _onLatestDataUpdate(datalist) { _log.info('_onLatestDataUpdate.... '); if (datalist != null) { bool check = false; if (currentItem == null && datalist != null && !firstRun) { check = true; } latest = datalist as List; isLoading = false; setState(() {}); // 1. 检查数据量是否达到最低要求 (>= 30) final bool hasSufficientData = datalist.length >= minimumRemoteLoadCount; // 2. 检查数据是否来自最近一次成功的网络请求 final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch; // !!! 关键检查点 if (hasSufficientData) { // 如果数据完整,无论是否是缓存数据,都尝试初始化第三方服务(因为主页已经可以显示了) if (!hasInit) { initThird(); } if (!isNetworkActive) { // 如果是从缓存读取的,网络状态未知,静默刷新列表即可,不触发图片下载 Future.delayed(Duration(seconds: 3), () => refresh()); } } else { // 数据不足 (例如,只有内置图),无论是缓存还是远程失败,都需要重试 _log.info('Data insufficient (only ${datalist.length} items). Attempting refresh in 3s.'); Future.delayed(Duration(seconds: 3), () => refresh()); } if (check) { checkGoPlay(); } } } _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 refresh() async { _log.info('refresh...'); await latestCachedRequest.refresh(); } // ListItem? get currentItem { // if (latest != null && latest!.isNotEmpty && data.currentLevel < latest!.length) { // // return latest![data.currentLevel]; // 原来的逻辑,太过简单,如果后台图片有调整顺序变了,用户可能会遇到重复的图 // // todo... 改成从latest列表中查找首个 data.completedWorks 中不存在的图(即首个未完成图) // } // return null; // } ListItem? get currentItem { // 1. 确保 latest 数据已加载 if (latest == null || latest!.isEmpty) { return null; } // 2. 获取已完成作品的唯一标识符集合,方便快速查找 // 假设 ListItem 的 id/url/name 等属性是其唯一标识。 // 我们使用 id 作为唯一标识符。 final Set completedIds = data.completedWorks.value.map((work) => work.id).toSet(); // 3. 遍历 latest 列表,查找第一个未完成的 Item for (final item in latest!) { // 假设 ListItem 有一个唯一的 id 属性。 // 如果 ListItem 没有 id,您需要使用其 URL 或其他唯一标识。 // 这里我们假设 ListItem 是 RemoteItem/AssetItem 的基类,它们有一个 String 类型的 id 属性。 final String itemId = item.id; // 检查这个 id 是否在已完成集合中 if (!completedIds.contains(itemId)) { _log.info('Found current item: $itemId'); return item; // 返回找到的第一个未完成的 Item } } // 4. 如果所有图片都完成了 _log.info('All items in the latest list have been completed.'); return null; } /// 预加载未来 N 张图片到磁盘,并最后触发当前关卡下载以最大化内存缓存命中率。 void _preloadNextImages() async { // 预加载数量 (包括当前关卡在内,共 20 个) const int totalPreloadCount = 20; // 1. 确保 latest 数据已加载 if (latest == null || latest!.isEmpty || latest!.length < minimumRemoteLoadCount) { _log.info('Preload failed: latest list is empty.'); return; } // 2. 查找当前未完成的第一张图片的索引 (Index of currentItem) final Set completedIds = data.completedWorks.value.map((work) => work.id).toSet(); int startIndex = -1; for (int i = 0; i < latest!.length; i++) { if (!completedIds.contains(latest![i].id)) { startIndex = i; break; } } if (startIndex == -1) { _log.info('Preload: All images completed, nothing to preload.'); return; } // 确定预加载范围 (从当前图片startIndex到 totalPreloadCount 个图片) final int endPreloadIndex = min(startIndex + totalPreloadCount, latest!.length); // 3. 准备要加载的列表 (从 startIndex 开始) final List itemsToLoad = latest!.sublist(startIndex, endPreloadIndex); if (itemsToLoad.isEmpty) { _log.info('Preload: No items found in the range.'); return; } // 4. 将当前关卡 (第一个元素) 移动到列表的末尾 final ListItem currentItemToLoad = itemsToLoad.removeAt(0); itemsToLoad.add(currentItemToLoad); _log.info('Preloading ${itemsToLoad.length} images. Current item: ${currentItemToLoad.id} will be loaded last.'); // 5. 循环触发 ItemLoader 加载 int preloadCount = 0; for (final item in itemsToLoad) { // 对远程图片进行预加载 // 我们不关心返回值或 Future,只是触发下载 if (item is RemoteItem) { try { // 使用静态 preload 方法,不再创建复杂的 Loader 实例 ItemLoader.preload(item, device.suggestedQuality); // 稍微给一点延迟,避免瞬时并发 I/O 导致 UI 顿挫 await Future.delayed(const Duration(milliseconds: 100)); preloadCount++; } catch (e) { _log.warning('Failed to load item for preloading: ${item.id}, error: $e'); } } } _log.info('Preload initiated for $preloadCount remote images, current item was last.'); } @override Widget build(BuildContext context) { if (isLoading) return scrollableDummy; // 2. 计算画布尺寸(宽=屏幕宽-60,高=宽×3/2) // final canvasWidth = device.screenSize.width - 30 * 2; // 左右各30px // final canvasHeight = canvasWidth * 3 / 2; final double availableHeight = device.screenSize.height - device.appBarHeight - device.bannerHeight - 120; final double paddedWidth = device.screenSize.width - 2 * 30; // padding width 30 final double paddedHeight = availableHeight; final double targetWidth = paddedWidth; final double targetHeight = targetWidth * device.aspectRatio; final double canvasWidth; final double canvasHeight; if (targetHeight > paddedHeight) { canvasHeight = paddedHeight; canvasWidth = paddedHeight / device.aspectRatio; } else { canvasWidth = targetWidth; canvasHeight = targetHeight; } return Scaffold( backgroundColor: SkinHelper.colorWhite, appBar: AppBar( backgroundColor: SkinHelper.colorWhite, // elevation: 1, centerTitle: true, leading: RepaintBoundary( // !!! 改造点 3: 添加 ScaleTransition key: _collectionKey, // 关联 GlobalKey child: ScaleTransition( scale: _collectionAnimation, // 使用定义的放大/缩小动画 child: IconButton( onPressed: () { audio.playSfx(SfxType.click); Navigator.push(context, CollectionScreen.buildRoute()); }, icon: const Icon(Icons.collections, color: Colors.black87), ), ), ), // title: const Text( // 'Jigsort Solitaire', // style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 24), // ), // 🚀 改造点:将 Text 标题替换为 SvgPicture title: SvgPicture.asset( 'assets/images/title.svg', // 替换为您的 SVG 文件路径 height: 32, // 根据您的设计调整高度,确保它在 AppBar 中显示良好 // colorFilter: const ColorFilter.mode(Colors.black87, BlendMode.srcIn), // 如果SVG是单色,可以设置颜色 placeholderBuilder: (BuildContext context) => const Text( // 占位符,以防SVG加载失败 'Jigsort Solitaire', style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 24), ), ), actions: [ IconButton( onPressed: () { audio.playSfx(SfxType.click); // AppLovinMAX.showMediationDebugger(); // Navigator.push(context, SettingsDialog.buildRoute()); Navigator.push(context, SettingScreen.buildRoute()); }, icon: const Icon(Icons.settings, color: Colors.black87), ), ], ), 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: () { // collection unlocking 动画结束,启动collection button 的接收反馈动画 _log.info('onCollectionDone, 启动合集收纳反馈动画'); audio.playSfx(SfxType.appear); _collectionController.forward(from: 0.0); }, ); }, ), ), ), playButton, ], ), ), SafeArea( child: SizedBox( // 始终预留高度,防止 Banner 出现时下方 UI 整体上跳(Layout Jitter) height: context.read().bannerHeight, width: double.infinity, child: isBannerVisible ? getBanner('home_bottom') : const SizedBox.shrink(), // 隐藏时完全不占位或保持留白 ), ), ], ), ); } void gotoPlay(ListItem item, {bool firstRun = false}) async { _log.info('goto play, firstRun = $firstRun'); // !!! 增加保护 if (!mounted) return; PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(item, firstRun: firstRun); final result = await Navigator.push(context, pageRouteBuilder); if (!mounted) return; if (result != null && result == true) { // 通关返回, 展示翻牌 _canvasKey.currentState?.startFlipAnimation(); final bool hasSufficientData = latest != null && latest!.length >= minimumRemoteLoadCount; final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch; if (hasSufficientData) { // 1. 数据完整:如果网络活跃,立即顺延预加载。 if (isNetworkActive) { _log.info('Game finished, data complete & Network Active. Triggering sequential preloading...'); _preloadNextImages(); } else { // 2. 数据完整但网络不活跃/状态未知:尝试刷新,让 _onLatestDataUpdate 负责后续处理 _log.info('Game finished, data complete but Network inactive. Attempting refresh.'); refresh(); } } else { // 3. 数据不完整:无论如何都需要刷新,让 _onLatestDataUpdate 重新处理 _log.info('Game finished, remote data incomplete. Attempting refresh...'); refresh(); } } else { // 非关卡通关返回,在这里播放插屏广告 // showInterstitialAd("level_exit", currentItem!.id, data.currentLevel); } } Widget get playButton { return MyElevatedButton( width: device.isTablet ? 300 : 200, height: 70, borderRadius: BorderRadius.circular(20), gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]), onPressed: () async { audio.playSfx(SfxType.click); if (currentItem != null) { gotoPlay(currentItem!); } else { Fluttertoast.showToast( msg: AppLocalizations.of(context)!.noMorePicture, toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.CENTER, timeInSecForIosWeb: 1, backgroundColor: SkinHelper.slotBorderColor, textColor: Colors.white, fontSize: 16.0, ); } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( AppLocalizations.of(context)!.play, style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold), ), ValueListenableBuilder>( valueListenable: data.completedWorks, builder: (context, isSoundOn, child) { return Text('${AppLocalizations.of(context)!.level} ${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)])), // ), // ); // }, // ), // ); Widget get scrollableDummy => Scaffold( backgroundColor: SkinHelper.colorWhite, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 使用原生最轻量的进度指示器 SizedBox(width: 40, height: 40, child: CircularProgressIndicator(strokeWidth: 3, valueColor: AlwaysStoppedAnimation(SkinHelper.coreBgColor))), const SizedBox(height: 20), // 可选:添加一个简单的文字,让用户知道在加载 Text( "Loading...", style: TextStyle(color: SkinHelper.slotBorderColor.withOpacity(0.7), fontSize: 14, fontWeight: FontWeight.w500), ), ], ), ), ); ///////////////////////// 初始化相关 ///////////////////////// static bool hasInit = false; static MyMethodChannel platform = MyMethodChannel(); // 在列表刷出来后才正式初始化admod等组件 void initThird() async { if (hasInit) return; hasInit = true; // 有了UMP后, 这里的ATT就不需要了 // bool auth = await initATT(); // if (auth) { // await platform.setHasUserConsent(true); // await platform.setAdvertiserTrackingEnabled(true); // } // await initUMP(); // 征询欧洲用户同意 // applovin max 已经可以自动处理,这里不需要了 TrackingStatus attStatus = await AppTrackingTransparency.trackingAuthorizationStatus; if (attStatus == TrackingStatus.authorized && Platform.isIOS) { // ATT 通过之后,ios需要调用相关的原生sdk接口做进一步的初始化 // await platform.setHasUserConsent(true); // await platform.setAdvertiserTrackingEnabled(true); } initFCM(); // 消息推送许可弹窗 initAd(); // admod 的广告加载安排在iOS ATT 之后,以便能够加载到个性化广告 AdjustHelper.init(Persistence().uuid); // 初始化Adjust final idfa = await AppTrackingTransparency.getAdvertisingIdentifier(); _log.info("idfa: $idfa"); } /////////////////////////// ATT /////////////////////////// // Platform messages are asynchronous, so we initialize in an async method. Future initATT() async { TrackingStatus status = await AppTrackingTransparency.trackingAuthorizationStatus; _log.info('initATT111 $status'); // If the system can show an authorization request dialog if (status == TrackingStatus.notDetermined) { // Show a custom explainer dialog before the system dialog // await showCustomTrackingDialog(context); // Wait for dialog popping animation // await Future.delayed(const Duration(milliseconds: 200)); // Request system's tracking authorization dialog status = await AppTrackingTransparency.requestTrackingAuthorization(); _log.info('initATT222 $status'); } if (status == TrackingStatus.authorized) { return true; } return false; } // no need Future showCustomTrackingDialog(BuildContext context) async => await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Dear User'), content: const Text( 'We care about your privacy and data security. We keep this app free by showing ads. ' 'Can we continue to use your data to tailor ads for you?\n\nYou can change your choice anytime in the app settings. ' 'Our partners will collect data and use a unique identifier on your device to show you ads.', ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Continue'))], ), ); ///////////////////////////////////////////////////////// /// 初始化广告模块 initAd() { _log.info('initAd'); // AdsController adsController = context.read(); // adsController.initialize(); ApplovinAdsController applovinAdsController = context.read(); applovinAdsController.initialize(); } /////////////////////////// FCM /////////////////////////// // 消息推送许可弹框 initFCM() async { try { final fcmToken = await FirebaseMessaging.instance.getToken(); _log.info("FCM Token: $fcmToken"); FirebaseMessaging messaging = FirebaseMessaging.instance; NotificationSettings settings = await messaging.requestPermission( alert: true, announcement: false, badge: true, carPlay: false, criticalAlert: false, provisional: false, sound: true, ); _log.warning('User granted permission: ${settings.authorizationStatus}'); } catch (e) { FirebaseCrashlytics.instance.log("FCM FirebaseMessaging.instance.getToken error: $e"); _log.warning(e); } } }