Files
flutterApp/lib/main.dart
2026-08-18 08:52:33 +08:00

724 lines
30 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:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_patcher/flutter_patcher.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/infrastructure/logging/app_bloc_observer.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import 'package:maibu_satabot_v2/core/theme/theme_cubit.dart';
import 'package:maibu_satabot_v2/core/update/update_cubit.dart';
import 'package:maibu_satabot_v2/core/update/version_check_service.dart';
import 'package:maibu_satabot_v2/core/update/update_dialog.dart';
import 'package:maibu_satabot_v2/core/update/update_state.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/mqtt_manager.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart';
import 'package:maibu_satabot_v2/features/main_container/presentation/main_wrapper.dart';
import 'core/di/injection.dart';
import 'core/localization/app_localizations.dart';
import 'core/localization/locale_cubit.dart';
import 'features/auth/presentation/bloc/auth_state.dart';
import 'features/auth/presentation/bloc/login_cubit.dart';
import 'features/devices/presentation/bloc/device_status_bloc.dart';
import 'features/devices/presentation/bloc/device_task_cubit.dart'; // 🔥 添加 DeviceTaskCubit
import 'features/home/presentation/bloc/permission_request_bloc.dart';
import 'features/remote_control/presentation/bloc/remote_control_cubit.dart';
// 🔥 定义全局 Navigator Key
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterPatcher.init();
await init();
final logger = sl<ILoggerService>();
await logger.init();
Bloc.observer = AppBlocObserver(logger);
// 🔥 初始化 MQTT 连接(异步,不阻塞启动)
MqttManager().initialize().catchError((e) {
debugPrint('⚠️ [Main] MQTT 初始化失败,但不影响应用启动: $e');
});
// ⚠️ 注意:不要在启动时清除补丁版本记录!
// 补丁版本记录只在整包更新成功后才清除
// 如果在启动时清除,会导致差量更新后划掉App再进入时循环更新
runApp(const MyApp());
},
(error, stack) {
sl<ILoggerService>().captureException(error, stackTrace: stack);
},
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
sl<AuthCubit>().appStarted();
final deviceStatusBloc = sl<DeviceStatusBloc>();
final localeCubit = sl<LocaleCubit>();
final themeCubit = sl<ThemeCubit>();
return MultiBlocProvider(
providers: [
BlocProvider<AuthCubit>(create: (_) => sl<AuthCubit>()),
BlocProvider<AppUserCubit>(create: (_) => sl<AppUserCubit>()),
BlocProvider<LoginCubit>(create: (_) => sl<LoginCubit>()),
BlocProvider<DevicesCubit>(
create: (_) {
final user = sl<AppUserCubit>().state.user;
final devicesCubit = sl<DevicesCubit>();
// 🔥 修复:检查是否已有 targetDevice,如果有则不需要加载设备列表
final remoteControlCubit = sl<RemoteControlCubit>();
if (user != null && remoteControlCubit.state.targetDevice == null) {
debugPrint('📱 [Main] 无 targetDevice,开始加载设备列表');
devicesCubit.fetchAllDevices(user.username);
} else if (user != null) {
debugPrint(
'✅ [Main] 已有 targetDevice: ${remoteControlCubit.state.targetDevice!.deviceName},跳过设备列表加载',
);
}
return devicesCubit;
},
),
BlocProvider<DeviceStatusBloc>.value(value: deviceStatusBloc),
BlocProvider<LocaleCubit>.value(value: localeCubit),
BlocProvider<ThemeCubit>.value(value: themeCubit),
BlocProvider<TabConfigCubit>.value(value: sl<TabConfigCubit>()),
BlocProvider<PermissionRequestBloc>(
create: (_) => sl<PermissionRequestBloc>(),
),
BlocProvider<UpdateCubit>(
create: (_) => UpdateCubit(VersionCheckService()),
),
BlocProvider<RemoteControlCubit>(
create: (_) => sl<RemoteControlCubit>(),
),
BlocProvider<DeviceTaskCubit>(create: (_) => sl<DeviceTaskCubit>()),
],
child: BlocBuilder<LocaleCubit, Locale>(
bloc: localeCubit,
builder: (context, locale) {
return BlocBuilder<ThemeCubit, ThemeMode>(
bloc: themeCubit,
builder: (context, themeMode) {
return MaterialApp.router(
title: 'Maibu Satabot',
locale: locale,
supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
routerConfig: sl<GoRouter>(),
builder: (context, child) {
return _LifecycleListener(child: _UpdateChecker(child: child!));
},
);
},
);
},
),
);
}
}
class _LifecycleListener extends StatefulWidget {
final Widget? child;
const _LifecycleListener({required this.child});
@override
State<_LifecycleListener> createState() => _LifecycleListenerState();
}
class _LifecycleListenerState extends State<_LifecycleListener>
with WidgetsBindingObserver {
String? _currentSessionId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initSession();
}
Future<void> _initSession() async {
final prefs = sl<SharedPreferences>();
final savedCurrentId = prefs.getString('current_session_id');
if (savedCurrentId != null) {
_currentSessionId = savedCurrentId;
debugPrint('📱 [生命周期] 恢复会话ID: $savedCurrentId');
} else {
debugPrint('📱 [生命周期] 首次启动,等待 appStarted 初始化会话');
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_handleAppPaused();
} else if (state == AppLifecycleState.resumed) {
_handleAppResumed();
} else if (state == AppLifecycleState.detached) {
_handleAppDetached();
}
}
Future<void> _handleAppPaused() async {
final prefs = sl<SharedPreferences>();
await prefs.setBool('pending_kill_logout', true);
if (_currentSessionId != null) {
await prefs.setString('saved_session_id', _currentSessionId!);
}
debugPrint('📱 [生命周期] 应用进入后台,已保存会话ID和杀后台标记');
}
Future<void> _handleAppResumed() async {
final prefs = sl<SharedPreferences>();
final savedSessionId = prefs.getString('saved_session_id');
final currentSessionId =
prefs.getString('current_session_id') ?? _currentSessionId;
if (currentSessionId != null && savedSessionId == currentSessionId) {
debugPrint('📱 [生命周期] 应用正常恢复(会话ID匹配),清除杀后台标记');
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
} else if (savedSessionId != null && savedSessionId != currentSessionId) {
debugPrint('📱 [生命周期] 应用从后台恢复但会话ID不匹配,设置杀后台标记');
await prefs.setBool('pending_kill_logout', true);
} else {
debugPrint('📱 [生命周期] 应用恢复(未进入过后台),清除杀后台标记');
await prefs.setBool('pending_kill_logout', false);
}
_handleResume();
}
Future<void> _handleAppDetached() async {
final prefs = sl<SharedPreferences>();
final savedSessionId = prefs.getString('saved_session_id');
final currentSessionId =
prefs.getString('current_session_id') ?? _currentSessionId;
if (savedSessionId != null &&
currentSessionId != null &&
savedSessionId == currentSessionId) {
debugPrint('📱 [生命周期] 应用在后台被杀(detached),保留杀后台标记');
} else {
debugPrint('📱 [生命周期] 应用在前台被杀(detached),清除杀后台标记');
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
}
}
Future<void> _handleResume() async {
try {
final authCubit = context.read<AuthCubit>();
if (authCubit.state is! AuthAuthenticated) return;
final remoteControlCubit = sl<RemoteControlCubit>();
if (remoteControlCubit.state.targetDevice == null) {
debugPrint('📱 [生命周期] 未选择设备,跳过 TCP 重连(避免创建无用连接)');
return;
}
await Future.delayed(const Duration(milliseconds: 500));
await authCubit.reconnectAfterResume();
} catch (e) {
debugPrint('❌ [生命周期] 重连错误:$e');
}
}
@override
Widget build(BuildContext context) => widget.child!;
}
class _UpdateChecker extends StatefulWidget {
final Widget child;
const _UpdateChecker({required this.child});
@override
State<_UpdateChecker> createState() => _UpdateCheckerState();
}
class _UpdateCheckerState extends State<_UpdateChecker> {
@override
void initState() {
super.initState();
// TODO: 暂时注释掉应用启动时的自动更新检查
// Future.delayed(const Duration(seconds: 2), () {
// if (mounted) context.read<UpdateCubit>().checkUpdate();
// });
}
@override
Widget build(BuildContext context) {
return BlocConsumer<UpdateCubit, UpdateState>(
listener: (context, state) {
if (state is UpdateFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('更新失败: ${state.error}'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
return Stack(
children: [
widget.child,
// 🔥 核心修正:直接在 UI 树中渲染更新提示,完全不依赖 Navigator
if (state is UpdateAvailable)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.6), // 半透明遮罩
child: Center(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 30),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🎉 发现新版本',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'版本号: ${state.versionInfo.version}',
style: const TextStyle(fontSize: 16),
),
Text(
'更新类型: ${state.versionInfo.updateType == "patch" ? "差量更新" : "整包更新"}',
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
if (state.versionInfo.updateDesc.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'更新内容:',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(state.versionInfo.updateDesc),
],
const SizedBox(height: 20),
if (state.versionInfo.updateType != 'patch') ...[
// 🔥 整包更新:显示两个选项
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.orange.shade200,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'💡 选择下载方式',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
const Text(
'• 浏览器下载(推荐):打开浏览器下载,手动安装并卸载旧版本\n'
'• App 内下载:App 内下载 APK,调起系统安装界面',
style: TextStyle(
fontSize: 13,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 16),
],
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (!state.versionInfo.forceUpdate)
TextButton(
onPressed: () => context
.read<UpdateCubit>()
.cancelUpdate(),
child: const Text('稍后'),
),
const SizedBox(width: 10),
if (state.versionInfo.updateType == 'patch')
// 🔥 差量更新:单个按钮
ElevatedButton(
onPressed: () {
final info = state.versionInfo;
if (info.patchUrl != null) {
context.read<UpdateCubit>().applyPatch(
info.patchUrl!,
info.version,
info.versionCode,
md5: info.patchMd5,
);
}
},
child: const Text('立即更新'),
)
else ...[
// 🔥 整包更新:两个按钮
OutlinedButton(
onPressed: () {
final info = state.versionInfo;
if (info.apkUrl != null) {
// 🔥 浏览器下载 + 引导弹窗
context
.read<UpdateCubit>()
.fullApkUpdateWithBrowser(
info.apkUrl!,
);
// 显示引导弹窗
showBrowserDownloadGuide(context);
}
},
child: const Text('浏览器下载'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () {
final info = state.versionInfo;
if (info.apkUrl != null) {
// App 内下载(保留备用)
context
.read<UpdateCubit>()
.downloadAndInstallApk(
info.apkUrl!,
);
}
},
child: const Text('App 内下载'),
),
],
],
),
],
),
),
),
),
),
),
// 🔥 整包更新步骤引导弹窗
if (state is UpdateInstalling && !(state.isPatch ?? false))
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.7),
child: Center(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 30),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'📱 整包更新步骤',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildStepItem(1, '在浏览器中下载 APK', true),
const SizedBox(height: 8),
_buildStepItem(2, '手动安装下载的 APK', false),
const SizedBox(height: 8),
_buildStepItem(3, '清除旧版本记录', false),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
context.read<UpdateCubit>().cancelUpdate();
},
child: const Text('我知道了'),
),
),
],
),
),
),
),
),
),
// 🔥 下载进度显示(差量补丁和整包APK)
if (state is UpdateDownloading)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.7),
child: Center(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 40),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.isPatch ? '⬇️ 正在下载补丁...' : '⬇️ 正在下载 APK...',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
SizedBox(
width: 80,
height: 80,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: state.progress,
strokeWidth: 5,
),
Text(
'${(state.progress * 100).toInt()}%',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
Text(
'请稍候...',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
),
),
),
),
// 🔥 更新成功提示
if (state is UpdateSuccess)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.7),
child: Center(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 40),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 60,
),
const SizedBox(height: 16),
const Text(
'✅ 更新已完成',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
const Text(
'补丁已成功应用!\n\n请完全关闭 App 后重新打开,\n即可体验新版本功能。',
style: TextStyle(fontSize: 15),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// 提示用户手动重启
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请从后台划掉 App,然后重新打开'),
duration: Duration(seconds: 3),
),
);
context.read<UpdateCubit>().cancelUpdate();
},
child: const Text('我知道了'),
),
],
),
),
),
),
),
),
// 调试面板 - 暂时关闭
// Positioned(
// top: 40,
// right: 10,
// child: Material(
// color: Colors.black54,
// borderRadius: BorderRadius.circular(8),
// child: Padding(
// padding: const EdgeInsets.all(8.0),
// child: Column(
// mainAxisSize: MainAxisSize.min,
// children: [
// Text(
// '状态: ${state.runtimeType.toString().replaceAll('Update', '')}',
// style: const TextStyle(
// color: Colors.white,
// fontSize: 12,
// ),
// ),
// // 🔥 暂时关闭刷新按钮和点击阴影
// // IconButton(
// // icon: const Icon(Icons.refresh, color: Colors.white),
// // onPressed: () => context.read<UpdateCubit>().checkUpdate(),
// // ),
// ],
// ),
// ),
// ),
// ),
],
);
},
);
}
/// 构建步骤项
Widget _buildStepItem(int step, String text, bool completed) {
return Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: completed ? Colors.green : Colors.grey.shade300,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$step',
style: TextStyle(
color: completed ? Colors.white : Colors.black54,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 15,
color: completed ? Colors.green : Colors.black87,
decoration: completed ? TextDecoration.lineThrough : null,
),
),
),
if (completed) const Icon(Icons.check, color: Colors.green, size: 20),
],
);
}
}
/// 显示浏览器下载引导弹窗(全局方法)
void showBrowserDownloadGuide(BuildContext context) {
Future.delayed(const Duration(milliseconds: 500), () {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('🔔 重要提示'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'请在浏览器中完成以下操作:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text('✅ 等待 APK 下载完成'),
const SizedBox(height: 8),
const Text('✅ 点击 APK 文件进行安装'),
const SizedBox(height: 8),
const Text('✅ 安装完成后,卸载当前旧版本'),
const SizedBox(height: 8),
const Text('✅ 重新打开新版本 APP'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.shade200),
),
child: const Text(
'⚠️ 注意:必须先卸载旧版本,否则无法正常使用新功能!',
style: TextStyle(color: Colors.red, fontSize: 12),
),
),
],
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
},
child: const Text('我知道了'),
),
],
),
);
});
}