Files
MookNote/lib/main.dart
DelLevin-Home 103afaa65e v0.2.9
2026-08-07 21:26:10 +08:00

455 lines
16 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 '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 'package:window_manager/window_manager.dart';
import 'package:permission_handler/permission_handler.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 'services/sync/backup_service.dart';
import 'providers/app_provider.dart';
import 'widgets/app_shell.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;
// 初始化 window_manager隐藏原生标题栏
await windowManager.ensureInitialized();
windowManager.waitUntilReadyToShow().then((_) async {
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
await windowManager.setMinimumSize(const Size(900, 640));
await windowManager.show();
});
// 注册 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();
unawaited(appProvider.loadPlaylists());
} catch (e) {
debugPrint('[Startup] 数据库初始化失败: $e');
appProvider.markDbInitFailed();
}
appProvider.initMainTabIndex();
unawaited(_initUsageStats());
unawaited(_startAutoBackupAfterDbReady());
}
Future<void> _initUsageStats() async {
try {
await UsageStatsService.instance.start();
} catch (e) {
debugPrint('初始化用户统计失败: $e');
}
}
/// 数据库就绪后启动自动备份(延迟执行,不阻塞开屏)
Future<void> _startAutoBackupAfterDbReady() async {
if (!Platform.isAndroid) return;
// 等 UI 渲染完成后再执行,避免开屏卡顿
await Future.delayed(const Duration(seconds: 3));
final userPrefs = UserPrefs();
if (!userPrefs.localAutoBackupEnabled) return;
// 启动时立即执行一次(后台不阻塞)
try {
final result = await BackupService.instance.performLocalAutoBackup();
if (result.success) {
debugPrint('[AutoBackup] 启动自动备份完成');
} else {
debugPrint('[AutoBackup] 启动自动备份失败: ${result.errorMessage}');
}
} catch (e) {
debugPrint('[AutoBackup] 启动自动备份异常: $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>();
Timer? _autoBackupTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
widget.appProvider.loadThemeMode();
widget.appProvider.addListener(_onThemeChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
_applySystemUI();
_requestStoragePermissionIfNeeded();
_checkUpdate(); // 不阻塞,完成后自行弹窗
});
}
@override
void dispose() {
_autoBackupTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// Android 启动时请求存储权限(备份导出需要写入 Download 目录)
Future<void> _requestStoragePermissionIfNeeded() async {
if (!Platform.isAndroid) return;
var status = await Permission.manageExternalStorage.status;
if (status.isGranted) return;
status = await Permission.manageExternalStorage.request();
if (status.isGranted) return;
// Android 11 以下回退到 storage 权限
status = await Permission.storage.status;
if (status.isGranted) return;
await Permission.storage.request();
}
/// 启动本地自动备份定时器(仅启动定时器,不立即执行备份)
void _startAutoBackupTimer() {
_autoBackupTimer?.cancel();
if (!Platform.isAndroid) return;
final userPrefs = UserPrefs();
if (!userPrefs.localAutoBackupEnabled) return;
final intervalHours = userPrefs.localAutoBackupIntervalHours;
// 定时器:每小时检查一次是否到了备份时间
_autoBackupTimer = Timer.periodic(const Duration(hours: 1), (_) {
_checkAndRunAutoBackup();
});
debugPrint('[AutoBackup] 定时器已启动,间隔 $intervalHours 小时');
}
/// 检查是否需要执行自动备份(定时器调用,走间隔判断)
Future<void> _checkAndRunAutoBackup() async {
final userPrefs = UserPrefs();
if (!userPrefs.localAutoBackupEnabled) {
_autoBackupTimer?.cancel();
return;
}
final intervalHours = userPrefs.localAutoBackupIntervalHours;
final lastTime = userPrefs.lastLocalAutoBackupTime;
bool shouldRun = false;
if (lastTime == null) {
shouldRun = true;
} else {
final last = DateTime.tryParse(lastTime)?.toLocal();
if (last == null) {
shouldRun = true;
} else {
final now = DateTime.now();
shouldRun = now.difference(last).inHours >= intervalHours;
}
}
if (!shouldRun) return;
try {
final result = await BackupService.instance.performLocalAutoBackup();
if (result.success) {
debugPrint('[AutoBackup] 定时自动备份完成');
} else {
debugPrint('[AutoBackup] 定时自动备份失败: ${result.errorMessage}');
}
} catch (e) {
debugPrint('[AutoBackup] 定时自动备份异常: $e');
}
}
/// 延迟到首页渲染后再检查版本更新,确保 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,
));
// Windows: 同步窗口边框明暗
if (Platform.isWindows) {
windowManager.setBrightness(isDark ? Brightness.dark : Brightness.light);
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_applySystemUI();
// 从备份页返回后可能改了自动备份设置,重新启动定时器
_startAutoBackupTimer();
}
}
@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,
builder: (ctx, nav) {
Widget shell = AppShell(child: nav!);
// Android: 点击空白区域收起键盘
if (Platform.isAndroid) {
shell = GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: shell,
);
}
return shell;
},
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;
},
);
},
),
);
}
}