rwadurian/frontend/mining-app/lib/presentation/widgets/main_shell.dart

317 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell> 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<void> _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<void> _checkAndShowForceReadDialog() async {
// 防护 1已有强制阅读弹窗正在展示
if (_isShowingForceReadDialog) return;
// 防护 260 秒内不重复触发(防止快速切后台后立刻重弹)
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<NotificationItem> 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;
}
}
}