316 lines
11 KiB
Dart
316 lines
11 KiB
Dart
import 'dart:async';
|
||
import 'dart:ui';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import 'app/router.dart';
|
||
import 'app/theme.dart';
|
||
import 'providers/locale_provider.dart';
|
||
import 'providers/theme_provider.dart';
|
||
import 'providers/display_provider.dart';
|
||
import 'utils/constants.dart';
|
||
import 'utils/credential_storage.dart';
|
||
import 'utils/i10n.dart';
|
||
import 'utils/store_local_cache.dart';
|
||
import 'config/prod_guard.dart';
|
||
|
||
/// 应用入口。
|
||
///
|
||
/// 三端(Android / iOS / HarmonyOS)共用同一套 lib/ 代码,
|
||
/// 通过 [ProviderScope] 注入全局状态,使用 go_router 进行声明式路由。
|
||
/// 根组件 key;重试时更换以强制整棵树重建。
|
||
GlobalKey _appRootKey = GlobalKey();
|
||
|
||
void main() {
|
||
// 全局 ErrorWidget:任何 widget 在 build/layout/paint 阶段抛异常时,
|
||
// 直接显示可读的红色错误页,而不是 release 模式默认的灰色错误块(静默灰屏)。
|
||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||
return _ErrorWidgetScreen(
|
||
message: details.exceptionAsString(),
|
||
stack: details.stack,
|
||
);
|
||
};
|
||
|
||
// 框架级异常兜底:记录并写入 _fatalError,供根组件在 build 时渲染错误页。
|
||
// 注意:不再调用 FlutterError.presentError,避免默认灰屏覆盖自定义错误页。
|
||
FlutterError.onError = (details) {
|
||
_reportFatal(details.exceptionAsString(), details.stack);
|
||
};
|
||
|
||
// 异步/Zone 异常兜底。
|
||
PlatformDispatcher.instance.onError = (error, stack) {
|
||
_reportFatal(error.toString(), stack);
|
||
return true;
|
||
};
|
||
|
||
runZonedGuarded<void>(
|
||
() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
// 启用 Edge-to-Edge 全屏:内容延伸至状态栏与导航栏区域,
|
||
// 避免手机四周出现黑边;同时保持系统手势/状态栏可见。
|
||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||
// 状态栏 / 导航栏图标亮度跟随系统明暗(修复此前硬编码 Brightness.light
|
||
// 导致浅色模式下图标不可见的 bug)。
|
||
final isDark =
|
||
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
|
||
Brightness.dark;
|
||
SystemChrome.setSystemUIOverlayStyle(
|
||
SystemUiOverlayStyle(
|
||
statusBarColor: Colors.transparent,
|
||
systemNavigationBarColor: Colors.transparent,
|
||
systemNavigationBarDividerColor: Colors.transparent,
|
||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||
systemNavigationBarIconBrightness:
|
||
isDark ? Brightness.light : Brightness.dark,
|
||
),
|
||
);
|
||
|
||
// 预初始化凭据持久化(记住密码 / 免登录恢复依赖 SharedPreferences 同步读取)。
|
||
await CredentialStorage.init();
|
||
|
||
// 生产环境强制守卫:启动即校验当前后端非生产,命中则失败退出并阻断启动。
|
||
ProductionGuard.enforce();
|
||
|
||
// 预初始化本地缓存(语言、主题等配置需要同步读取)。
|
||
// 即便失败也用默认值兜底,不让启动崩溃。
|
||
StoreLocalCache.instance.initialize().catchError((_) {
|
||
// 忽略:SharedPreferences 不可用时退化为内存态。
|
||
});
|
||
|
||
// 应用已保存的显示方向偏好(跟随系统 / 竖屏 / 横屏),
|
||
// 确保首帧即按用户设置的方向渲染,不闪一下系统默认朝向。
|
||
final savedOrientation = await loadSavedOrientation();
|
||
applyDisplayOrientation(savedOrientation);
|
||
|
||
_launchApp();
|
||
},
|
||
(error, stack) {
|
||
_reportFatal(error.toString(), stack);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 启动根组件(重试时再次调用即可重建整棵树)。
|
||
void _launchApp() {
|
||
runApp(
|
||
ProviderScope(
|
||
key: _appRootKey,
|
||
child: const OpsConsoleApp(),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 全局致命错误信息;非空时 [OpsConsoleApp] 渲染错误页而非正常界面。
|
||
String? _fatalError;
|
||
|
||
/// 记录致命错误并写入全局变量,供 [OpsConsoleApp] 在 build 时渲染错误页。
|
||
void _reportFatal(Object error, StackTrace? stack) {
|
||
if (_fatalError == null) {
|
||
_fatalError =
|
||
'$error\n\n${stack?.toString().split('\n').take(8).join('\n') ?? ''}';
|
||
}
|
||
debugPrint('FATAL: $error');
|
||
debugPrint('STACK: $stack');
|
||
}
|
||
|
||
/// 清空致命错误并重启根组件(错误页「重试」按钮调用)。
|
||
void _retryApp() {
|
||
_fatalError = null;
|
||
_appRootKey = GlobalKey();
|
||
_launchApp();
|
||
}
|
||
|
||
/// 运维管理台根组件。
|
||
class OpsConsoleApp extends ConsumerWidget {
|
||
const OpsConsoleApp({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
// 全局致命错误:直接渲染可读错误页,避免 release 模式静默灰屏。
|
||
if (_fatalError != null) {
|
||
return MaterialApp(
|
||
title: AppConstants.appName,
|
||
debugShowCheckedModeBanner: false,
|
||
theme: AppTheme.light(),
|
||
darkTheme: AppTheme.dark(),
|
||
themeMode: ThemeMode.system,
|
||
home: _FatalErrorScreen(error: _fatalError!),
|
||
);
|
||
}
|
||
|
||
final router = ref.watch(appRouterProvider);
|
||
final themeMode = ref.watch(themeModeProvider);
|
||
final appLocale = ref.watch(localeProvider);
|
||
|
||
return MaterialApp.router(
|
||
title: AppConstants.appName,
|
||
// 应用级名称,与 AndroidManifest/iOS Info.plist/鸿蒙配置保持一致
|
||
debugShowCheckedModeBanner: false,
|
||
theme: AppTheme.light(),
|
||
darkTheme: AppTheme.dark(),
|
||
// 默认深色主题,可在“设置”页切换
|
||
themeMode: themeMode,
|
||
locale: appLocale.locale,
|
||
supportedLocales: const [
|
||
Locale('zh', 'CN'),
|
||
Locale('en', 'US'),
|
||
],
|
||
localizationsDelegates: const [
|
||
// 必须包含 Flutter 默认的本地化委托,否则 MaterialLocalizations
|
||
// 不会被注册,所有 TextField/InputDecoration 会因取 null 而崩溃。
|
||
GlobalMaterialLocalizations.delegate,
|
||
GlobalWidgetsLocalizations.delegate,
|
||
GlobalCupertinoLocalizations.delegate,
|
||
_AppLocaleDelegate(),
|
||
],
|
||
routerConfig: router,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// [AppLocale] 的轻量 [LocalizationsDelegate],
|
||
/// 使 [AppLocalizations.of] 能按当前 locale 取到对应文案。
|
||
class _AppLocaleDelegate extends LocalizationsDelegate<AppLocale> {
|
||
const _AppLocaleDelegate();
|
||
|
||
@override
|
||
bool isSupported(Locale locale) =>
|
||
locale.languageCode == 'zh' || locale.languageCode == 'en';
|
||
|
||
@override
|
||
Future<AppLocale> load(Locale locale) async {
|
||
return locale.languageCode == 'en' ? AppLocale.en : AppLocale.zh;
|
||
}
|
||
|
||
@override
|
||
bool shouldReload(covariant LocalizationsDelegate<AppLocale> old) => false;
|
||
}
|
||
|
||
/// 全局致命错误展示页。
|
||
///
|
||
/// 当 [FlutterError.onError] / [PlatformDispatcher.onError] / Zone 异常捕获到
|
||
/// 未处理异常时,用可读的红色错误页替代默认的灰/白屏,便于真机直接看到根因。
|
||
class _FatalErrorScreen extends StatelessWidget {
|
||
const _FatalErrorScreen({required this.error});
|
||
|
||
final String error;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.red.shade50,
|
||
body: SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'应用启动时发生错误',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.red,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'请将下方错误信息反馈给开发人员:',
|
||
style: TextStyle(color: Colors.black87),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
child: SelectableText(
|
||
error,
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 12,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
onPressed: _retryApp,
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 全局渲染异常兜底页([ErrorWidget.builder] 使用)。
|
||
///
|
||
/// 与 [_FatalErrorScreen] 不同,此组件不依赖 MaterialApp/Theme(此时 app 可能
|
||
/// 尚未建立),是一个完全自包含、硬编码红色背景的可读错误页,确保任何 widget
|
||
/// 渲染异常都能暴露文字而非静默灰屏。
|
||
class _ErrorWidgetScreen extends StatelessWidget {
|
||
const _ErrorWidgetScreen({required this.message, this.stack});
|
||
|
||
final String message;
|
||
final StackTrace? stack;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Material(
|
||
color: Colors.red.shade50,
|
||
child: SafeArea(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 40, color: Colors.red),
|
||
const SizedBox(height: 12),
|
||
const Text(
|
||
'渲染异常(Widget Error)',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.red,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'请将下方文字反馈给开发人员:',
|
||
style: TextStyle(color: Colors.black87),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SelectableText(
|
||
message,
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 12,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
if (stack != null) ...[
|
||
const SizedBox(height: 8),
|
||
SelectableText(
|
||
stack.toString(),
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 11,
|
||
color: Colors.black54,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|