import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/router/routes.dart'; import '../../core/constants/app_colors.dart'; import '../../core/updater/update_service.dart'; import '../../core/updater/channels/self_hosted_updater.dart'; import '../../core/services/notification_service.dart'; import '../providers/notification_providers.dart'; import '../providers/user_providers.dart'; import 'force_read_notification_dialog.dart'; /// 主壳 Widget,包含底部导航栏和强制通知弹窗检查 /// /// 在 APP 启动和从后台恢复前台时: /// 1. 检查版本更新(已有逻辑) /// 2. 检查并展示强制阅读通知弹窗(新增逻辑) class MainShell extends ConsumerStatefulWidget { final Widget child; const MainShell({super.key, required this.child}); @override ConsumerState createState() => _MainShellState(); } class _MainShellState extends ConsumerState with WidgetsBindingObserver { /// 下次允许检查更新的时间(static 防止 widget rebuild 重置) static DateTime? _nextCheckAllowedTime; /// 强制阅读弹窗互斥锁:防止同时弹出多个弹窗 static bool _isShowingForceReadDialog = false; /// 上次展示强制阅读弹窗的时间(60秒冷却,防止快速切后台重弹) static DateTime? _lastForceReadDialogShownAt; @override void initState() { super.initState(); debugPrint('[MainShell] initState called, adding WidgetsBindingObserver'); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { debugPrint('[MainShell] postFrameCallback → 触发首次更新检查'); _checkForUpdateIfNeeded(); // 延迟 5 秒后检查强制阅读通知,避免和更新弹窗冲突 Future.delayed(const Duration(seconds: 5), () { if (mounted) _checkAndShowForceReadDialog(); }); }); } @override void dispose() { debugPrint('[MainShell] dispose called, removing WidgetsBindingObserver'); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { debugPrint('[MainShell] didChangeAppLifecycleState: $state'); if (state == AppLifecycleState.resumed) { _checkForUpdateIfNeeded(); // 恢复前台时也检查强制阅读通知 _checkAndShowForceReadDialog(); } } /// ==================== 版本更新检查(原有逻辑) ==================== Future _checkForUpdateIfNeeded() async { final now = DateTime.now(); // 如果从未检查过,或者已过冷却时间,则检查 if (_nextCheckAllowedTime != null && now.isBefore(_nextCheckAllowedTime!)) { debugPrint('[MainShell] 跳过更新检查,冷却中 (下次: $_nextCheckAllowedTime)'); return; } // 设置下次允许检查时间(90-300秒随机间隔) final randomSeconds = 90 + Random().nextInt(211); _nextCheckAllowedTime = now.add(Duration(seconds: randomSeconds)); debugPrint('[MainShell] 准备检查更新,延迟3秒...'); // 延迟3秒,避免启动时干扰用户 await Future.delayed(const Duration(seconds: 3)); if (!mounted) { debugPrint('[MainShell] widget 已 unmount,取消检查'); return; } try { debugPrint('[MainShell] 开始调用 UpdateService.checkForUpdate(force: true)'); // force: true 跳过 UpdateService 内部的60分钟间隔限制 // 因为我们已有自己的90-300秒冷却节流 final result = await UpdateService.instance.checkForUpdate(force: true); debugPrint('[MainShell] 检查结果: hasUpdate=${result.hasUpdate}, error=${result.error}'); if (!mounted) return; if (result.hasUpdate && result.versionInfo != null) { debugPrint('[MainShell] 发现新版本: ${result.versionInfo!.versionName},弹出更新对话框'); await SelfHostedUpdater.show( context, versionInfo: result.versionInfo!, isForceUpdate: result.versionInfo!.isForceUpdate, ); } } catch (e) { debugPrint('[MainShell] 检查更新失败: $e'); } } /// ==================== 强制阅读通知弹窗(新增逻辑) ==================== /// /// 仅展示标记了 [requiresForceRead] 且尚未读的通知。 /// 用户必须逐条查看,最后一条勾选"我已经阅读并知晓"后方可关闭。 /// 全部确认后逐条调用 markAsRead 并刷新未读角标。 Future _checkAndShowForceReadDialog() async { // 防护 1:已有强制阅读弹窗正在展示 if (_isShowingForceReadDialog) return; // 防护 2:60 秒内不重复触发(防止快速切后台后立刻重弹) if (_lastForceReadDialogShownAt != null && DateTime.now().difference(_lastForceReadDialogShownAt!) < const Duration(seconds: 60)) { return; } // 防护 3:用户未登录 final accountSequence = ref.read(currentAccountSequenceProvider); if (accountSequence == null || accountSequence.isEmpty) return; // 获取未读且需要强制阅读的通知 List forceReadList; try { final notifService = ref.read(notificationServiceProvider); final response = await notifService.getNotifications( userSerialNum: accountSequence, limit: 20, ); forceReadList = response.notifications .where((n) => !n.isRead && n.requiresForceRead) .toList(); } catch (_) { // API 失败时静默处理,不阻断用户使用 return; } if (forceReadList.isEmpty || !mounted) return; _isShowingForceReadDialog = true; _lastForceReadDialogShownAt = DateTime.now(); // 逐条展示强制阅读弹窗 for (int i = 0; i < forceReadList.length; i++) { if (!mounted) break; await ForceReadNotificationDialog.show( context: context, notification: forceReadList[i], currentIndex: i + 1, totalCount: forceReadList.length, ); } _isShowingForceReadDialog = false; // 全部通知看完后,逐条标记已读(不影响其他未读普通通知) if (mounted) { try { final notifService = ref.read(notificationServiceProvider); final currentAccount = ref.read(currentAccountSequenceProvider); if (currentAccount != null && currentAccount.isNotEmpty) { for (final n in forceReadList) { await notifService.markAsRead( userSerialNum: currentAccount, notificationId: n.id, ); } // 刷新真实未读数量(非直接清零,避免误清其他未读通知的 badge) ref.read(notificationBadgeProvider.notifier).refresh(); } } catch (_) { // 标记失败时静默处理;下次打开 App 仍会重新检查 } } } @override Widget build(BuildContext context) { final isDark = AppColors.isDark(context); return Scaffold( body: widget.child, bottomNavigationBar: Container( decoration: BoxDecoration( color: AppColors.cardOf(context), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withOpacity(0.3) : Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, -2), ), ], ), child: SafeArea( top: false, child: Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildNavItem( context, icon: Icons.favorite_outline, activeIcon: Icons.favorite, label: '贡献值', index: 0, ), _buildNavItem( context, icon: Icons.swap_horiz_outlined, activeIcon: Icons.swap_horiz, label: '兑换', index: 1, ), _buildNavItem( context, icon: Icons.account_balance_wallet_outlined, activeIcon: Icons.account_balance_wallet, label: '资产', index: 2, ), _buildNavItem( context, icon: Icons.person_outline, activeIcon: Icons.person, label: '我的', index: 3, ), ], ), ), ), ), ); } Widget _buildNavItem( BuildContext context, { required IconData icon, required IconData activeIcon, required String label, required int index, }) { final currentIndex = _calculateSelectedIndex(context); final isSelected = currentIndex == index; final inactiveColor = AppColors.textMutedOf(context); return GestureDetector( onTap: () => _onItemTapped(index, context), behavior: HitTestBehavior.opaque, child: SizedBox( width: 64, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( isSelected ? activeIcon : icon, size: 24, color: isSelected ? AppColors.orange : inactiveColor, ), const SizedBox(height: 4), Text( label, style: TextStyle( fontSize: 11, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? AppColors.orange : inactiveColor, ), ), ], ), ), ); } int _calculateSelectedIndex(BuildContext context) { final location = GoRouterState.of(context).uri.path; if (location.startsWith(Routes.contribution)) return 0; if (location.startsWith(Routes.trading)) return 1; if (location.startsWith(Routes.asset)) return 2; if (location.startsWith(Routes.profile)) return 3; return 0; } void _onItemTapped(int index, BuildContext context) { switch (index) { case 0: context.go(Routes.contribution); break; case 1: context.go(Routes.trading); break; case 2: context.go(Routes.asset); break; case 3: context.go(Routes.profile); break; } } }