Files
MookNote/lib/main.dart
2026-07-13 23:57:08 +08:00

331 lines
12 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'pages/home/home_page.dart';
import 'utils/theme/app_theme.dart';
import 'utils/app_router.dart';
import 'utils/user_prefs.dart';
import 'services/changelog_service.dart';
import 'services/usage_stats_service.dart';
import 'providers/app_provider.dart';
final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>();
/// Windows 桌面版 WebView2 环境,注册 epub:// 自定义协议
WebViewEnvironment? windowsWebViewEnvironment;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Windows 桌面:使用 FFI 初始化 sqflite
if (Platform.isWindows) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
// 注册 epub:// 自定义协议,使 WebView2 能拦截该协议的请求
try {
windowsWebViewEnvironment = await WebViewEnvironment.create(settings:
WebViewEnvironmentSettings(customSchemeRegistrations: [
CustomSchemeRegistration(
scheme: 'epub',
hasAuthorityComponent: true,
treatAsSecure: true,
),
]),
);
debugPrint('[Startup] WebViewEnvironment created successfully');
} catch (e) {
debugPrint('[Startup] WebViewEnvironment 初始化失败: $e');
}
}
await UserPrefs.init();
final appProvider = AppProvider();
runApp(MyApp(appProvider: appProvider));
unawaited(_bootstrap(appProvider));
}
/// 启动后台任务:先校验同步状态,再按顺序初始化数据库与主标签,
/// 保持 UI 先渲染、数据后就绪。
Future<void> _bootstrap(AppProvider appProvider) async {
// 数据库优先初始化(不被 sync 阻塞)
try {
await appProvider.initDatabase();
} catch (e) {
debugPrint('[Startup] 数据库初始化失败: $e');
appProvider.markDbInitFailed();
}
appProvider.initMainTabIndex();
unawaited(_initUsageStats());
}
Future<void> _initUsageStats() async {
try {
await UsageStatsService.instance.start();
} catch (e) {
debugPrint('初始化用户统计失败: $e');
}
}
class MyApp extends StatefulWidget {
final AppProvider appProvider;
const MyApp({super.key, required this.appProvider});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
ThemeMode? _lastAppliedTheme;
bool _updateCheckDone = false;
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
widget.appProvider.loadThemeMode();
widget.appProvider.addListener(_onThemeChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
_applySystemUI();
_checkUpdate(); // 不阻塞,完成后自行弹窗
});
}
/// 延迟到首页渲染后再检查版本更新,确保 context 已就绪
Future<void> _checkUpdate() async {
if (_updateCheckDone) return;
_updateCheckDone = true;
await Future.delayed(const Duration(milliseconds: 500));
var ctx = _navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
try {
final items = await ChangelogService.fetchChangelog();
if (items.isEmpty) return;
final latest = items.first;
final info = await PackageInfo.fromPlatform();
final localVersion = 'v${info.version}';
if (ChangelogService.compareVersion(latest.version, localVersion) <= 0) return;
// 24 小时内 snooze 不弹
if (DateTime.now().millisecondsSinceEpoch < UserPrefs().dismissedUpdateUntil) return;
ctx = _navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
_showUpdateDialog(ctx, latest.version, latest.features, localVersion);
} catch (_) {}
}
void _showUpdateDialog(BuildContext context, String version, List<String> features, String localVersion) {
if (!context.mounted) return;
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
title: Row(
children: [
Icon(Icons.system_update, size: 22, color: colors.primary),
const SizedBox(width: 8),
Text('发现新版本', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
content: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.5),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前版本:$localVersion',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
const SizedBox(height: 4),
Text('最新版本:$version',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.primary)),
if (features.isNotEmpty) ...[
const SizedBox(height: 12),
Text('更新内容', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 8),
...features.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
f,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7), height: 1.5),
),
),
],
),
)),
],
],
),
),
),
actions: [
TextButton(
onPressed: () {
final until = DateTime.now().add(const Duration(hours: 24)).millisecondsSinceEpoch;
UserPrefs().setDismissedUpdateUntil(until);
Navigator.pop(ctx);
},
child: Text('24小时内不显示', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
try {
await launchUrl(Uri.parse('https://mooknote.iletter.top/#/'),
mode: LaunchMode.externalApplication);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('链接失效')));
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('去官网下载'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
void _onThemeChanged() {
final current = widget.appProvider.themeMode;
if (_lastAppliedTheme != current) {
_applySystemUI();
}
}
void _applySystemUI() {
final mode = widget.appProvider.themeMode;
final Brightness brightness;
switch (mode) {
case ThemeMode.light: brightness = Brightness.light;
case ThemeMode.dark: brightness = Brightness.dark;
case ThemeMode.system: brightness = PlatformDispatcher.instance.platformBrightness;
}
_lastAppliedTheme = mode;
final isDark = brightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? Colors.black : Colors.white,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
));
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_applySystemUI();
}
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: widget.appProvider),
],
child: DynamicColorBuilder(
builder: (lightScheme, darkScheme) {
final monetColor = lightScheme?.primary;
AppTheme.setMonetColor(monetColor);
return Consumer<AppProvider>(
builder: (context, provider, _) {
AppTheme.setFontFamily(provider.fontFamily);
final light = AppTheme.getLightTheme(
provider.colorSchemeIndex,
monetColor: monetColor,
);
ThemeData dark;
if (provider.colorSchemeIndex == -1 && monetColor != null) {
final scheme = ColorScheme.fromSeed(
seedColor: monetColor,
brightness: Brightness.dark,
);
dark = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
scrolledUnderElevation: 0,
),
cardTheme: CardThemeData(
color: scheme.surfaceContainerHighest,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
} else {
dark = AppTheme.darkTheme;
}
final child = MaterialApp(
title: 'MookNote',
debugShowCheckedModeBanner: false,
theme: light,
darkTheme: dark,
themeMode: provider.themeMode,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('zh', 'CN'),
Locale('en', 'US'),
],
home: const HomePage(),
navigatorKey: _navigatorKey,
navigatorObservers: [routeObserver],
onGenerateRoute: AppRouter.generateRoute,
);
return child;
},
);
},
),
);
}
}