home_screen.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'package:app_tracking_transparency/app_tracking_transparency.dart';
  5. import 'package:firebase_crashlytics/firebase_crashlytics.dart';
  6. import 'package:firebase_messaging/firebase_messaging.dart';
  7. import 'package:flutter/material.dart';
  8. import 'package:flutter_svg/svg.dart';
  9. import 'package:fluttertoast/fluttertoast.dart';
  10. import 'package:logging/logging.dart';
  11. import 'package:provider/provider.dart';
  12. import 'package:puzzleweave/ads/applovin_ads_controller.dart';
  13. import 'package:puzzleweave/audio/jc_audio_controller.dart';
  14. import 'package:puzzleweave/collection/collection_screen.dart';
  15. import 'package:puzzleweave/config/config.dart';
  16. import 'package:puzzleweave/config/device.dart';
  17. import 'package:puzzleweave/firebase/adjust_helper.dart';
  18. import 'package:puzzleweave/homepage/home_board_play.dart';
  19. import 'package:puzzleweave/l10n/app_localizations.dart';
  20. import 'package:puzzleweave/models/cached_request.dart';
  21. import 'package:puzzleweave/models/download.dart';
  22. import 'package:puzzleweave/models/items.dart';
  23. import 'package:puzzleweave/persistence/persistence.dart';
  24. import 'package:puzzleweave/play/board_play.dart';
  25. import 'package:puzzleweave/settings/settings_screen.dart';
  26. import 'package:puzzleweave/skin/skin.dart';
  27. import 'package:puzzleweave/utils/mybutton.dart';
  28. import 'package:puzzleweave/utils/utils.dart';
  29. import '../ads/ad_helper.dart';
  30. import '../ads/ads_state.dart';
  31. import '../utils/memory_monitor.dart';
  32. final Logger _log = Logger('home_screen');
  33. class HomeScreen extends StatefulWidget {
  34. const HomeScreen({super.key});
  35. @override
  36. State<StatefulWidget> createState() => _HomeScreen();
  37. }
  38. const int minimumRemoteLoadCount = 30;
  39. class _HomeScreen extends AdsState<HomeScreen> with TickerProviderStateMixin {
  40. late Device device;
  41. late JcAudioController audio;
  42. List<ListItem>? latest;
  43. late CachedRequest latestCachedRequest;
  44. late StreamSubscription? latestSubscription;
  45. final _canvasKey = GlobalKey<HomeBoardPlayState>();
  46. final GlobalKey _collectionKey = GlobalKey();
  47. bool isLoading = true;
  48. bool firstRun = false;
  49. // ✅ 优化点1: 导航状态管理
  50. bool _isNavigating = false;
  51. // ✅ 优化点2: 防抖机制
  52. Timer? _refreshDebouncer;
  53. // ✅ 优化点3: 缓存计算结果
  54. double? _cachedCanvasWidth;
  55. double? _cachedCanvasHeight;
  56. bool _layoutCalculated = false;
  57. late AnimationController _collectionController;
  58. late Animation<double> _collectionAnimation;
  59. bool interPending = false;
  60. @override
  61. void initState() {
  62. super.initState();
  63. _log.info("首页初始化");
  64. _initializeComponents();
  65. _setupAnimations();
  66. _handleInitialNavigation();
  67. }
  68. void _initializeComponents() {
  69. device = context.read<Device>();
  70. audio = context.read<JcAudioController>();
  71. latestCachedRequest = data.latest;
  72. final cachedData = latestCachedRequest.cachedData;
  73. if (cachedData != null) {
  74. _onLatestDataUpdate(cachedData);
  75. }
  76. latestSubscription = latestCachedRequest.stream.listen(_onLatestDataUpdate, onError: _onLatestDataError);
  77. onInterstitialAdState = _createInterStateListener();
  78. }
  79. Function(AdState state) _createInterStateListener() {
  80. return (AdState state) {
  81. _log.info('Interstitial ad state changed: $state');
  82. if (state == AdState.dismissed && interPending) {
  83. _log.info('Interstitial ad dismissed, executing pending post-ad logic.');
  84. _canvasKey.currentState?.startFlipAnimation();
  85. final bool hasSufficientData = latest != null && latest!.length >= minimumRemoteLoadCount;
  86. final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch;
  87. if (hasSufficientData) {
  88. if (isNetworkActive) {
  89. _log.info('Game finished, data complete & Network Active. Triggering sequential preloading...');
  90. _preloadNextImages();
  91. } else {
  92. _log.info('Game finished, data complete but Network inactive. Attempting refresh.');
  93. refresh();
  94. }
  95. } else {
  96. _log.info('Game finished, remote data incomplete. Attempting refresh...');
  97. refresh();
  98. }
  99. interPending = false;
  100. }
  101. };
  102. }
  103. void _setupAnimations() {
  104. _collectionController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this)
  105. ..addStatusListener((status) {
  106. if (status == AnimationStatus.completed) {
  107. audio.playSfx(SfxType.pop);
  108. }
  109. });
  110. _collectionAnimation = TweenSequence<double>([
  111. TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.4).chain(CurveTween(curve: Curves.easeOut)), weight: 40.0),
  112. TweenSequenceItem(tween: Tween<double>(begin: 1.4, end: 1.0).chain(CurveTween(curve: Curves.easeIn)), weight: 60.0),
  113. ]).animate(_collectionController);
  114. }
  115. void _handleInitialNavigation() {
  116. if (Persistence().firstRun) {
  117. firstRun = true;
  118. WidgetsBinding.instance.addPostFrameCallback((_) {
  119. if (mounted && !_isNavigating) {
  120. _handleFirstRunNavigation();
  121. }
  122. });
  123. Persistence().firstRun = false;
  124. }
  125. WidgetsBinding.instance.addPostFrameCallback((_) {
  126. if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed && mounted) {
  127. audio.startMusic();
  128. }
  129. });
  130. }
  131. // ✅ 优化点4: 异步化首次运行导航
  132. void _handleFirstRunNavigation() {
  133. if (_isNavigating) return;
  134. _log.info('First run detected, navigating to initial play page.');
  135. final AssetItem initialItem = AssetItem(
  136. Config.firstId,
  137. '',
  138. 2000,
  139. 3000,
  140. 3,
  141. false,
  142. 'assets/builtin/${Config.firstId}.jpeg',
  143. 'assets/builtin/${Config.firstId}.jpeg',
  144. );
  145. return gotoPlay(initialItem, firstRun: true);
  146. }
  147. // ✅ 优化点5: 异步化文件检查
  148. void checkGoPlay() {
  149. if (currentItem == null || _isNavigating) return;
  150. Future.microtask(() async {
  151. try {
  152. final jsonFile = await localFile(currentItem!.jsonPath);
  153. final exists = await jsonFile.exists();
  154. if (mounted && exists && !_isNavigating) {
  155. gotoPlay(currentItem!);
  156. }
  157. } catch (e) {
  158. _log.warning('Error checking play file: $e');
  159. }
  160. });
  161. }
  162. @override
  163. void dispose() {
  164. // 清理 banner 广告资源
  165. cleanBanner();
  166. _refreshDebouncer?.cancel();
  167. latestSubscription?.cancel();
  168. _collectionController.dispose();
  169. super.dispose();
  170. }
  171. // ✅ 优化点6: 简化数据更新逻辑
  172. _onLatestDataUpdate(datalist) {
  173. _log.info('_onLatestDataUpdate....');
  174. if (datalist == null) return;
  175. bool shouldCheckGoPlay = false;
  176. if (currentItem == null && !firstRun) {
  177. shouldCheckGoPlay = true;
  178. }
  179. latest = datalist as List<ListItem>;
  180. isLoading = false;
  181. setState(() {});
  182. final bool hasSufficientData = datalist.length >= minimumRemoteLoadCount;
  183. final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch;
  184. if (hasSufficientData) {
  185. if (!hasInit) {
  186. initThird();
  187. }
  188. if (!isNetworkActive) {
  189. _debouncedRefresh();
  190. }
  191. } else {
  192. _log.info('Data insufficient (only ${datalist.length} items). Attempting refresh.');
  193. _debouncedRefresh();
  194. }
  195. if (shouldCheckGoPlay) {
  196. checkGoPlay();
  197. }
  198. }
  199. _onLatestDataError(error) {
  200. _log.info('_onLatestDataError.... $error');
  201. if (latest == null || latest!.isEmpty || latest!.length < 20) {
  202. _log.warning("_onLatestDataError, retry again");
  203. _debouncedRefresh();
  204. }
  205. }
  206. // ✅ 优化点7: 防抖刷新
  207. void _debouncedRefresh() {
  208. _refreshDebouncer?.cancel();
  209. _refreshDebouncer = Timer(const Duration(seconds: 2), () {
  210. if (mounted) {
  211. refresh();
  212. }
  213. });
  214. }
  215. Future<void> refresh() async {
  216. _log.info('refresh...');
  217. await latestCachedRequest.refresh();
  218. }
  219. ListItem? get currentItem {
  220. if (latest == null || latest!.isEmpty) {
  221. return null;
  222. }
  223. final Set<String> completedIds = data.completedWorks.value.map((work) => work.id).toSet();
  224. for (final item in latest!) {
  225. final String itemId = item.id;
  226. if (!completedIds.contains(itemId)) {
  227. _log.info('Found current item: $itemId');
  228. return item;
  229. }
  230. }
  231. _log.info('All items in the latest list have been completed.');
  232. return null;
  233. }
  234. // ✅ 优化点8: 后台预加载
  235. void _preloadNextImages() {
  236. Future.microtask(() async {
  237. const int totalPreloadCount = 20;
  238. if (latest == null || latest!.isEmpty || latest!.length < minimumRemoteLoadCount) {
  239. _log.info('Preload failed: latest list is empty.');
  240. return;
  241. }
  242. final Set<String> completedIds = data.completedWorks.value.map((work) => work.id).toSet();
  243. int startIndex = -1;
  244. for (int i = 0; i < latest!.length; i++) {
  245. if (!completedIds.contains(latest![i].id)) {
  246. startIndex = i;
  247. break;
  248. }
  249. }
  250. if (startIndex == -1) {
  251. _log.info('Preload: All images completed, nothing to preload.');
  252. return;
  253. }
  254. final int endPreloadIndex = min(startIndex + totalPreloadCount, latest!.length);
  255. final List<ListItem> itemsToLoad = latest!.sublist(startIndex, endPreloadIndex);
  256. if (itemsToLoad.isEmpty) {
  257. _log.info('Preload: No items found in the range.');
  258. return;
  259. }
  260. final ListItem currentItemToLoad = itemsToLoad.removeAt(0);
  261. itemsToLoad.add(currentItemToLoad);
  262. _log.info('Preloading ${itemsToLoad.length} images. Current item: ${currentItemToLoad.id} will be loaded last.');
  263. int preloadCount = 0;
  264. for (final item in itemsToLoad) {
  265. if (item is RemoteItem) {
  266. try {
  267. ItemLoader.preload(item, device.suggestedQuality);
  268. await Future.delayed(const Duration(milliseconds: 100));
  269. preloadCount++;
  270. } catch (e) {
  271. _log.warning('Failed to load item for preloading: ${item.id}, error: $e');
  272. }
  273. }
  274. }
  275. _log.info('Preload initiated for $preloadCount remote images, current item was last.');
  276. });
  277. }
  278. // ✅ 优化点9: 缓存布局计算
  279. void _calculateLayout() {
  280. if (_layoutCalculated) return;
  281. final double availableHeight = device.screenSize.height - device.appBarHeight - device.bannerHeight - 120;
  282. final double paddedWidth = device.screenSize.width - 2 * 30;
  283. final double paddedHeight = availableHeight;
  284. final double targetWidth = paddedWidth;
  285. final double targetHeight = targetWidth * device.aspectRatio;
  286. if (targetHeight > paddedHeight) {
  287. _cachedCanvasHeight = paddedHeight;
  288. _cachedCanvasWidth = paddedHeight / device.aspectRatio;
  289. } else {
  290. _cachedCanvasWidth = targetWidth;
  291. _cachedCanvasHeight = targetHeight;
  292. }
  293. _layoutCalculated = true;
  294. }
  295. @override
  296. Widget build(BuildContext context) {
  297. if (isLoading) return scrollableDummy;
  298. _calculateLayout();
  299. return Scaffold(
  300. backgroundColor: SkinHelper.colorWhite,
  301. appBar: AppBar(
  302. backgroundColor: SkinHelper.colorWhite,
  303. centerTitle: true,
  304. leading: RepaintBoundary(
  305. key: _collectionKey,
  306. child: ScaleTransition(
  307. scale: _collectionAnimation,
  308. child: IconButton(
  309. onPressed: () {
  310. audio.playSfx(SfxType.click);
  311. cleanBanner();
  312. Navigator.push(context, CollectionScreen.buildRoute());
  313. },
  314. icon: const Icon(Icons.collections, color: Colors.black87),
  315. ),
  316. ),
  317. ),
  318. title: SvgPicture.asset(
  319. 'assets/images/title.svg',
  320. height: 32,
  321. placeholderBuilder: (BuildContext context) => const Text(
  322. 'Jigsort Solitaire',
  323. style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 24),
  324. ),
  325. ),
  326. actions: [
  327. IconButton(
  328. onPressed: () {
  329. audio.playSfx(SfxType.click);
  330. cleanBanner();
  331. Navigator.push(context, SettingScreen.buildRoute());
  332. // showAppLovinDebugger();
  333. },
  334. icon: const Icon(Icons.settings, color: Colors.black87),
  335. ),
  336. ],
  337. ),
  338. body: Column(
  339. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  340. children: [
  341. if (Config.isDebug) MemoryMonitor.getMemoryWidget(),
  342. Expanded(
  343. child: Column(
  344. mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  345. children: [
  346. Padding(
  347. padding: const EdgeInsets.symmetric(horizontal: 30),
  348. child: SizedBox(
  349. width: _cachedCanvasWidth!,
  350. height: _cachedCanvasHeight!,
  351. child: ValueListenableBuilder(
  352. valueListenable: data.completedWorks,
  353. builder: (context, value, child) {
  354. return HomeBoardPlay(
  355. key: _canvasKey,
  356. canvasWidth: _cachedCanvasWidth!,
  357. canvasHeight: _cachedCanvasHeight!,
  358. collectionKey: _collectionKey,
  359. onCollectionDone: () {
  360. _log.info('onCollectionDone, 启动合集收纳反馈动画');
  361. audio.playSfx(SfxType.appear);
  362. _collectionController.forward(from: 0.0);
  363. },
  364. );
  365. },
  366. ),
  367. ),
  368. ),
  369. playButton,
  370. ],
  371. ),
  372. ),
  373. SafeArea(
  374. child: SizedBox(
  375. height: context.read<Device>().bannerHeight,
  376. width: double.infinity,
  377. child: isBannerVisible ? getBanner('home_bottom') : const SizedBox.shrink(),
  378. ),
  379. ),
  380. ],
  381. ),
  382. );
  383. }
  384. // ✅ 优化点10: 导航状态管理
  385. void gotoPlay(ListItem item, {bool firstRun = false}) async {
  386. if (_isNavigating) {
  387. _log.info('Navigation already in progress, ignoring');
  388. return;
  389. }
  390. _log.info('goto play, firstRun = $firstRun');
  391. if (!mounted) return;
  392. _isNavigating = true;
  393. interPending = false;
  394. try {
  395. cleanBanner();
  396. if (!mounted) return;
  397. PageRouteBuilder? pageRouteBuilder = BoardPlay.buildRoute(item, firstRun: firstRun);
  398. final result = await Navigator.push(context, pageRouteBuilder);
  399. if (!mounted) return;
  400. // result 是 true, 说明关卡已经完成。 但此时可能播放插屏广告中, 需要等待用户关闭广告之后才能执行翻牌等逻辑
  401. if (result != null && result == true) {
  402. // 打印下当下的插屏广告状态
  403. _log.info('==================>interState = $intersState');
  404. if (intersState == AdState.ready && data.currentLevel % 25 != 0 && shouldShowInterstitialAd("level_done", data.currentLevel - 1)) {
  405. // 这种情况表示有插屏广告在播放,需要等待广告关闭之后才能执行翻牌动画等逻辑
  406. interPending = true;
  407. _log.info('Interstitial ad is currently playing, will execute post-ad logic after dismissal.');
  408. return;
  409. }
  410. _canvasKey.currentState?.startFlipAnimation();
  411. final bool hasSufficientData = latest != null && latest!.length >= minimumRemoteLoadCount;
  412. final bool isNetworkActive = latestCachedRequest.hasRecentSuccessfulFetch;
  413. if (hasSufficientData) {
  414. if (isNetworkActive) {
  415. _log.info('Game finished, data complete & Network Active. Triggering sequential preloading...');
  416. _preloadNextImages();
  417. } else {
  418. _log.info('Game finished, data complete but Network inactive. Attempting refresh.');
  419. refresh();
  420. }
  421. } else {
  422. _log.info('Game finished, remote data incomplete. Attempting refresh...');
  423. refresh();
  424. }
  425. }
  426. } finally {
  427. _isNavigating = false;
  428. }
  429. }
  430. Widget get playButton {
  431. return MyElevatedButton(
  432. width: device.isTablet ? 300 : 200,
  433. height: 70,
  434. borderRadius: BorderRadius.circular(20),
  435. gradient: LinearGradient(colors: [SkinHelper.coreBgColor, SkinHelper.slotBorderColor]),
  436. onPressed: () async {
  437. audio.playSfx(SfxType.click);
  438. if (currentItem != null) {
  439. gotoPlay(currentItem!);
  440. } else {
  441. Fluttertoast.showToast(
  442. msg: AppLocalizations.of(context)!.noMorePicture,
  443. toastLength: Toast.LENGTH_SHORT,
  444. gravity: ToastGravity.CENTER,
  445. timeInSecForIosWeb: 1,
  446. backgroundColor: SkinHelper.slotBorderColor,
  447. textColor: Colors.white,
  448. fontSize: 16.0,
  449. );
  450. }
  451. },
  452. child: Column(
  453. mainAxisAlignment: MainAxisAlignment.center,
  454. children: [
  455. Text(
  456. AppLocalizations.of(context)!.play,
  457. style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
  458. ),
  459. Text('${AppLocalizations.of(context)!.level} ${data.currentLevel + 1}', style: const TextStyle(color: Colors.white, fontSize: 16)),
  460. ],
  461. ),
  462. );
  463. }
  464. Widget get scrollableDummy => Scaffold(
  465. backgroundColor: SkinHelper.colorWhite,
  466. body: Center(
  467. child: Column(
  468. mainAxisAlignment: MainAxisAlignment.center,
  469. children: [
  470. SizedBox(width: 40, height: 40, child: CircularProgressIndicator(strokeWidth: 3, valueColor: AlwaysStoppedAnimation<Color>(SkinHelper.coreBgColor))),
  471. const SizedBox(height: 20),
  472. Text(
  473. "Loading...",
  474. style: TextStyle(color: SkinHelper.slotBorderColor.withOpacity(0.7), fontSize: 14, fontWeight: FontWeight.w500),
  475. ),
  476. ],
  477. ),
  478. ),
  479. );
  480. ///////////////////////// 初始化相关 /////////////////////////
  481. static bool hasInit = false;
  482. void initThird() async {
  483. if (hasInit) return;
  484. hasInit = true;
  485. // TrackingStatus attStatus = await AppTrackingTransparency.trackingAuthorizationStatus;
  486. // if (attStatus == TrackingStatus.authorized && Platform.isIOS) {
  487. // // ATT 通过之后,ios需要调用相关的原生sdk接口做进一步的初始化
  488. // }
  489. initFCM();
  490. initAd();
  491. AdjustHelper.init(Persistence().uuid);
  492. // final idfa = await AppTrackingTransparency.getAdvertisingIdentifier();
  493. // _log.info("idfa: $idfa");
  494. }
  495. Future<bool> initATT() async {
  496. TrackingStatus status = await AppTrackingTransparency.trackingAuthorizationStatus;
  497. _log.info('initATT111 $status');
  498. if (status == TrackingStatus.notDetermined) {
  499. status = await AppTrackingTransparency.requestTrackingAuthorization();
  500. _log.info('initATT222 $status');
  501. }
  502. return status == TrackingStatus.authorized;
  503. }
  504. initAd() {
  505. _log.info('initAd');
  506. ApplovinAdsController applovinAdsController = context.read<ApplovinAdsController>();
  507. applovinAdsController.initialize();
  508. }
  509. initFCM() async {
  510. try {
  511. final fcmToken = await FirebaseMessaging.instance.getToken();
  512. _log.info("FCM Token: $fcmToken");
  513. FirebaseMessaging messaging = FirebaseMessaging.instance;
  514. NotificationSettings settings = await messaging.requestPermission(
  515. alert: true,
  516. announcement: false,
  517. badge: true,
  518. carPlay: false,
  519. criticalAlert: false,
  520. provisional: false,
  521. sound: true,
  522. );
  523. _log.warning('User granted permission: ${settings.authorizationStatus}');
  524. } catch (e) {
  525. FirebaseCrashlytics.instance.log("FCM FirebaseMessaging.instance.getToken error: $e");
  526. _log.warning(e);
  527. }
  528. }
  529. }