317 lines
10 KiB
Dart
317 lines
10 KiB
Dart
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;
|
||
|
||
// 防护 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<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;
|
||
}
|
||
}
|
||
}
|