import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:advertising_id/advertising_id.dart'; import 'package:app_tracking_transparency/app_tracking_transparency.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/device.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/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'; 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 State 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; // 放大/缩小动画 @override void initState() { super.initState(); 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(); } @override void dispose() { latestSubscription?.cancel(); _collectionController.dispose(); super.dispose(); } _onLatestDataUpdate(data) { _log.info('_onLatestDataUpdate.... '); if (data != null) { latest = data as List; isLoading = false; setState(() {}); // 1. 检查数据量是否达到最低要求 (>= 30) final bool hasSufficientData = data.length >= minimumRemoteLoadCount; // 2. 检查数据是否来自最近一次成功的网络请求 final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch; // !!! 关键检查点 if (hasSufficientData) { // 如果数据完整,无论是否是缓存数据,都尝试初始化第三方服务(因为主页已经可以显示了) if (!hasInit) { initThird(); } // !!! 核心修改:只有在数据完整且最近网络请求成功时,才启动预加载 if (isNetworkActive) { _log.info('Data sufficient AND Network Active. Starting preload.'); _preloadNextImages(); } else { // 数据完整,但来自缓存,网络状态未知,3秒后尝试刷新(refresh) _log.info('Data sufficient BUT Network status unknown/inactive. Attempting refresh in 3s.'); Future.delayed(Duration(seconds: 3), () => refresh()); } } else { // 数据不足 (例如,只有内置图),无论是缓存还是远程失败,都需要重试 _log.info('Data insufficient (only ${data.length} items). Attempting refresh in 3s.'); Future.delayed(Duration(seconds: 3), () => refresh()); } } } _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() { // 预加载数量 (包括当前关卡在内,共 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 itemToLoad in itemsToLoad) { // 对远程图片进行预加载 // 调用 ItemLoader.load,它会使用 Download 单例进行下载和缓存 // 我们不关心返回值或 Future,只是触发下载 if (itemToLoad is RemoteItem) { try { // 触发下载。对于非当前关卡,下载器会完成下载并写入磁盘,然后可能释放内存。 // 对于当前关卡 (最后一个被调用的),它留在内存中的可能性最大。 ItemLoader.load(itemToLoad); preloadCount++; } catch (e) { _log.warning('Failed to load item for preloading: ${itemToLoad.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( appBar: AppBar( backgroundColor: Colors.white, 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); // 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, ], ), ), Container(), // SafeArea( // child: SizedBox( // // 始终预留一个固定的高度,防止布局跳变 // height: context.read().bannerHeight, // width: double.infinity, // child: FutureBuilder( // future: _bannerReadyAndShouldShow(), // builder: (context, snapshot) { // if (snapshot.hasData && snapshot.data == true) { // return adBanner; // } // return Container( // // color: Colors.grey.shade100, // ); // }, // ), // ), // ), ], ), ); } 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); // _canvasKey.currentState?.startFlipAnimation(); // for test // _canvasKey.currentState?.testAnimation(); // for test; if (currentItem != null) { PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(currentItem!); final result = await Navigator.push(context, pageRouteBuilder); if (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 { 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)])), ), ); }, ), ); ///////////////////////// 初始化相关 ///////////////////////// 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 之后,以便能够加载到个性化广告 final idfa = await AppTrackingTransparency.getAdvertisingIdentifier(); _log.info("idfa: $idfa"); if (kDebugMode) { _printInfo(); } } _printInfo() async { String? advertisingId; // Platform messages may fail, so we use a try/catch PlatformException. try { advertisingId = await AdvertisingId.id(true); } on PlatformException { advertisingId = null; } bool? isLimitAdTrackingEnabled; // Platform messages may fail, so we use a try/catch PlatformException. try { isLimitAdTrackingEnabled = await AdvertisingId.isLimitAdTrackingEnabled; } on PlatformException { isLimitAdTrackingEnabled = false; } _log.info('advertisingId: $advertisingId, isLimitAdTrackingEnabled: $isLimitAdTrackingEnabled'); } /////////////////////////// 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(); } /// gallery页面加载的时候,可能广告模块还没有初始化完毕 // Future _bannerReadyAndShouldShow() async { // bool ready = await adSDKReady(); // return ready && shouldShowBannerAd(data.currentLevel); // } /////////////////////////// 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); } } }