主要变更: 1. 重构"国学"相关模块为"经典名句",统一命名规范 2. 重命名"阅读报告"为"使用报告",调整相关文案与配置 3. 修复iOS模拟器图片缓存兼容问题,优化图表渲染逻辑 4. 新增设备活跃状态前端兜底判断,修复在线计数异常 5. 完善登录/注册流程,新增忘记密码路由与账户编辑提示 6. 优化文件传输与字体导入逻辑,废弃过时的bytes属性使用 7. 添加Spotlight全局快捷键支持,更新隐私权限与通知配置 8. 补充数据库迁移脚本与部署文档,修复后端接口兼容问题 9. 调整部分UI交互细节,优化内存占用与应用稳定性
453 lines
15 KiB
Dart
453 lines
15 KiB
Dart
// ============================================================
|
||
// 闲言APP — 路由配置(主入口)
|
||
// 创建时间: 2026-04-20
|
||
// 更新时间: 2026-06-05
|
||
// 作用: go_router 路由表组装 + ShellRoute 布局壳 + iOS 风格转场 + 深链接重定向
|
||
// 上次更新: 路由观察日志降级为debug级别,减少IDE日志量
|
||
// ============================================================
|
||
|
||
import 'package:bot_toast/bot_toast.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../utils/logger.dart' show Log, LogCategory;
|
||
import '../utils/platform/platform_utils.dart' as pu;
|
||
|
||
import '../../features/onboarding/presentation/onboarding_page.dart';
|
||
import '../../features/home/presentation/home_page.dart';
|
||
import '../../features/discover/presentation/pages/home/discover_page.dart';
|
||
import '../../features/mine/profile/presentation/profile_page.dart';
|
||
import '../layout/app_shell.dart';
|
||
import '../storage/kv_storage.dart';
|
||
import '../utils/ui/page_transitions.dart';
|
||
import '../services/device/shake_detector.dart';
|
||
|
||
import 'app_routes.dart';
|
||
import 'settings_routes.dart';
|
||
import 'tool_routes.dart';
|
||
import 'editor_router.dart';
|
||
import 'user_routes.dart';
|
||
import 'content_routes.dart';
|
||
import 'feature_routes.dart';
|
||
|
||
export 'app_routes.dart';
|
||
|
||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||
|
||
final shellNavigatorKey = GlobalKey<NavigatorState>();
|
||
|
||
final _homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'home');
|
||
final _discoverNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'discover');
|
||
final _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile');
|
||
|
||
final _shakeScopeObserver = ShakeScopeObserver();
|
||
|
||
final GoRouter appRouter = GoRouter(
|
||
navigatorKey: rootNavigatorKey,
|
||
// 初始路由始终为home,由redirect动态判断是否需要跳转引导页
|
||
// (KvStorage在GoRouter构造时可能尚未初始化,不能在initialLocation中依赖它)
|
||
initialLocation: AppRoutes.home,
|
||
debugLogDiagnostics: true,
|
||
observers: pu.isOhos
|
||
? [_OhosRouteObserver(), _shakeScopeObserver]
|
||
: [BotToastNavigatorObserver(), _shakeScopeObserver],
|
||
redirect: _handleRedirect,
|
||
routes: [
|
||
GoRoute(
|
||
path: AppRoutes.onboarding,
|
||
name: 'onboarding',
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
pageBuilder: (context, state) =>
|
||
iosSlideTransition(state: state, child: const OnboardingPage()),
|
||
),
|
||
|
||
StatefulShellRoute.indexedStack(
|
||
builder: (context, state, navigationShell) =>
|
||
AppShell(child: navigationShell),
|
||
branches: [
|
||
StatefulShellBranch(
|
||
navigatorKey: _homeNavigatorKey,
|
||
routes: [
|
||
GoRoute(
|
||
path: AppRoutes.home,
|
||
name: 'home',
|
||
pageBuilder: (context, state) =>
|
||
iosFadeTransition(state: state, child: const HomePage()),
|
||
),
|
||
],
|
||
),
|
||
StatefulShellBranch(
|
||
navigatorKey: _discoverNavigatorKey,
|
||
routes: [
|
||
GoRoute(
|
||
path: AppRoutes.discover,
|
||
name: 'discover',
|
||
pageBuilder: (context, state) =>
|
||
iosFadeTransition(state: state, child: const DiscoverPage()),
|
||
),
|
||
],
|
||
),
|
||
StatefulShellBranch(
|
||
navigatorKey: _profileNavigatorKey,
|
||
routes: [
|
||
GoRoute(
|
||
path: AppRoutes.profile,
|
||
name: 'profile',
|
||
pageBuilder: (context, state) =>
|
||
iosFadeTransition(state: state, child: const ProfilePage()),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
|
||
...buildEditorRoutes(rootNavigatorKey),
|
||
...buildSettingsRoutes(rootNavigatorKey),
|
||
...buildToolRoutes(rootNavigatorKey),
|
||
...buildUserRoutes(rootNavigatorKey),
|
||
...buildContentRoutes(rootNavigatorKey),
|
||
...buildFeatureRoutes(rootNavigatorKey),
|
||
],
|
||
errorBuilder: (context, state) => const NotFoundPage(),
|
||
);
|
||
|
||
/// 统一重定向:引导页判断 + 深度链接解析
|
||
/// 在每次导航时执行,此时KvStorage已初始化完成
|
||
String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||
final currentPath = state.matchedLocation;
|
||
|
||
// 1. 引导页判断(仅在KvStorage已初始化时生效)
|
||
if (KvStorage.isReady) {
|
||
final onboardingDone = KvStorage.isOnboardingCompleted;
|
||
final shouldShow = KvStorage.shouldShowOnboarding;
|
||
|
||
// 需要显示引导页且当前不在引导页 → 跳转引导页
|
||
if ((!onboardingDone || shouldShow) && currentPath != AppRoutes.onboarding) {
|
||
if (pu.isOhos) {
|
||
Log.d('🟢 [OHOS] redirect: onboardingDone=$onboardingDone, shouldShow=$shouldShow → /onboarding', null, null, LogCategory.router);
|
||
}
|
||
return AppRoutes.onboarding;
|
||
}
|
||
|
||
// 引导页已完成但当前在引导页 → 跳转首页
|
||
// 例外:如果带有 review=true 参数,说明是用户主动查看,不拦截
|
||
if (onboardingDone && !shouldShow && currentPath == AppRoutes.onboarding) {
|
||
final isReview = state.uri.queryParameters['review'] == 'true';
|
||
if (!isReview) {
|
||
final savedPage = KvStorage.getString('general_startup_page');
|
||
final location = switch (savedPage) {
|
||
'inspiration' => AppRoutes.inspiration,
|
||
'discover' => AppRoutes.discover,
|
||
'profile' => AppRoutes.profile,
|
||
_ => AppRoutes.home,
|
||
};
|
||
return location;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 深度链接解析
|
||
final uri = state.uri;
|
||
final resolved = AppRouter.resolveDeepLinkUri(uri);
|
||
if (resolved != null && resolved != currentPath) {
|
||
final scheme = uri.scheme.toLowerCase();
|
||
if (scheme == 'xianyan') {
|
||
Log.d('🔗 [DeepLink] xianyan://${uri.host}${uri.path} → $resolved', null, null, LogCategory.router);
|
||
} else if (scheme == 'https' || scheme == 'http') {
|
||
Log.d('🔗 [DeepLink] ${uri.scheme}://${uri.host}${uri.path} → $resolved', null, null, LogCategory.router);
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// 统一深度链接URI解析入口
|
||
/// 支持 xianyan:// scheme 和 https://s2ss.com 通用链接
|
||
/// 供 GoRouter redirect 和 DeepLinkService 共用
|
||
class AppRouter {
|
||
AppRouter._();
|
||
|
||
/// 解析 URI 为内部路由路径
|
||
/// 返回 null 表示不是深度链接,不需要重定向
|
||
static String? resolveDeepLinkUri(Uri uri) {
|
||
final scheme = uri.scheme.toLowerCase();
|
||
|
||
if (scheme == 'xianyan') {
|
||
return _resolveCustomScheme(uri);
|
||
}
|
||
|
||
if (scheme == 'https' || scheme == 'http') {
|
||
final host = uri.host.toLowerCase();
|
||
if (host == 's2ss.com' || host == 'www.s2ss.com') {
|
||
return _resolveHttps(uri);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// 解析 xianyan:// scheme 链接
|
||
/// 格式: xianyan://<host>[/<path>]
|
||
/// 例如: xianyan://tool/hanzi, xianyan://settings/theme
|
||
static String? _resolveCustomScheme(Uri uri) {
|
||
final host = uri.host;
|
||
final path = uri.path;
|
||
|
||
return switch (host) {
|
||
'home' => AppRoutes.home,
|
||
'discover' => AppRoutes.discover,
|
||
'profile' => AppRoutes.profile,
|
||
'search' => AppRoutes.search,
|
||
'fortune' => AppRoutes.dailyFortune,
|
||
'article' => path.isNotEmpty ? path : AppRoutes.home,
|
||
'notes' => AppRoutes.noteList,
|
||
'inspiration' => AppRoutes.inspiration,
|
||
'favorites' => AppRoutes.favorites,
|
||
'history' => AppRoutes.history,
|
||
'tool' => _resolveToolPath(path),
|
||
'editor' => AppRoutes.editor,
|
||
'signin' => AppRoutes.signin,
|
||
'weather' =>
|
||
path.isNotEmpty ? AppRoutes.weatherSettings : AppRoutes.weather,
|
||
'poetry' => path.isNotEmpty ? AppRoutes.poetrySettings : AppRoutes.poetry,
|
||
'pomodoro' => AppRoutes.pomodoro,
|
||
'countdown' => AppRoutes.countdown,
|
||
'solar-term' => AppRoutes.solarTerm,
|
||
'knowledge-graph' => AppRoutes.knowledgeGraph,
|
||
'study-plan' => AppRoutes.studyPlan,
|
||
'notification-settings' => AppRoutes.notificationSettings,
|
||
'statistics' => AppRoutes.statistics,
|
||
'achievement' => AppRoutes.achievement,
|
||
'rank' => AppRoutes.rank,
|
||
'learning' => AppRoutes.learning,
|
||
'checkin' => AppRoutes.achievement,
|
||
'settings' => _resolveSettingsPath(path),
|
||
_ => _resolvePathFallback(path),
|
||
};
|
||
}
|
||
|
||
/// 解析 https://s2ss.com 通用链接
|
||
/// 格式: https://s2ss.com/<segment>[/<sub>]
|
||
static String? _resolveHttps(Uri uri) {
|
||
final segments = uri.pathSegments;
|
||
if (segments.isEmpty) return AppRoutes.home;
|
||
|
||
final first = segments.first;
|
||
return switch (first) {
|
||
'fortune' => AppRoutes.dailyFortune,
|
||
'article' => '/${segments.join('/')}',
|
||
'notes' => AppRoutes.noteList,
|
||
'home' => AppRoutes.home,
|
||
'discover' => AppRoutes.discover,
|
||
'profile' => AppRoutes.profile,
|
||
'inspiration' => AppRoutes.inspiration,
|
||
'search' => AppRoutes.search,
|
||
'favorites' => AppRoutes.favorites,
|
||
'history' => AppRoutes.history,
|
||
'tool' =>
|
||
segments.length > 1
|
||
? _resolveToolPath('/${segments[1]}')
|
||
: AppRoutes.discover,
|
||
'editor' => AppRoutes.editor,
|
||
'weather' =>
|
||
segments.length > 1 ? AppRoutes.weatherSettings : AppRoutes.weather,
|
||
'poetry' =>
|
||
segments.length > 1 ? AppRoutes.poetrySettings : AppRoutes.poetry,
|
||
'settings' => _resolveSettingsPath('/${segments.skip(1).join('/')}'),
|
||
'achievement' => AppRoutes.achievement,
|
||
'rank' => AppRoutes.rank,
|
||
'learning' => AppRoutes.learning,
|
||
'checkin' => AppRoutes.achievement,
|
||
_ => _resolvePathFallback('/${segments.join('/')}'),
|
||
};
|
||
}
|
||
|
||
/// 解析工具子路由路径
|
||
static String? _resolveToolPath(String path) {
|
||
return switch (path) {
|
||
'/hanzi' => AppRoutes.hanziTool,
|
||
'/ocr' => '/tool/ocr',
|
||
'/colors' => '/tool/china_colors',
|
||
'/china_colors' => '/tool/china_colors',
|
||
'/hot' => '/tool/list',
|
||
'/calc' => '/tool/calc',
|
||
'/list' => '/tool/list',
|
||
'/offline' => AppRoutes.offline,
|
||
'/cache' => AppRoutes.cacheManagement,
|
||
'/readlater' => AppRoutes.readLater,
|
||
'/favorites' => AppRoutes.favorites,
|
||
'/history' => AppRoutes.history,
|
||
'/notes' => AppRoutes.noteList,
|
||
'/signin' => AppRoutes.signin,
|
||
'/weather' => AppRoutes.weather,
|
||
'/poetry' => AppRoutes.poetry,
|
||
'/pomodoro' => AppRoutes.pomodoro,
|
||
'/countdown' => AppRoutes.countdown,
|
||
'/solar-term' => AppRoutes.solarTerm,
|
||
'/knowledge-graph' => AppRoutes.knowledgeGraph,
|
||
'/study-plan' => AppRoutes.studyPlan,
|
||
'/chengyu' => '/tool/chengyu',
|
||
'/zuci' => '/tool/zuci',
|
||
'/cidian' => '/tool/cidian',
|
||
'/jinyici' => '/tool/jinyici',
|
||
'/juzi' => '/tool/juzi',
|
||
'/danci' => '/tool/danci',
|
||
'/suoxie' => '/tool/suoxie',
|
||
'/nick' => '/tool/nick',
|
||
'/drug' => '/tool/drug',
|
||
'/food' => '/tool/food',
|
||
'/herbal' => '/tool/herbal',
|
||
'/pianfang' => '/tool/pianfang',
|
||
'/tisana' => '/tool/tisana',
|
||
'/changshi' => '/tool/changshi',
|
||
'/zgjm' => '/tool/zgjm',
|
||
'/illness' => '/tool/illness',
|
||
'/surname' => '/tool/surname',
|
||
'/jieqi' => '/tool/jieqi',
|
||
'/nation' => '/tool/nation',
|
||
'/xiehouyu' => '/tool/xiehouyu',
|
||
'/riddle' => '/tool/riddle',
|
||
'/brainteaser' => '/tool/brainteaser',
|
||
'/couplet' => '/tool/couplet',
|
||
'/wisdom' => '/tool/wisdom',
|
||
'/saying' => '/tool/saying',
|
||
'/lyric' => '/tool/lyric',
|
||
'/story' => '/tool/story',
|
||
'/zuowen' => '/tool/zuowen',
|
||
'/why' => '/tool/why',
|
||
'/joke' => '/tool/joke',
|
||
'/jiufang' => '/tool/jiufang',
|
||
'/exchange_rate' => '/tool/exchange_rate',
|
||
'/rss_reader' => '/tool/rss_reader',
|
||
'/game' => '/game',
|
||
'/classics' => '/classics',
|
||
'/health' => '/health',
|
||
'/articles' => '/articles',
|
||
'/check' => '/check',
|
||
_ => AppRoutes.discover,
|
||
};
|
||
}
|
||
|
||
/// 解析设置子路由路径
|
||
static String? _resolveSettingsPath(String path) {
|
||
return switch (path) {
|
||
'/theme' => AppRoutes.themeSettings,
|
||
'/general' => AppRoutes.generalSettings,
|
||
'/account' => AppRoutes.accountSettings,
|
||
'/data' => AppRoutes.dataManagement,
|
||
'/notifications' => AppRoutes.notificationSettings,
|
||
'/language' => AppRoutes.languageSettings,
|
||
'/fonts' => AppRoutes.fontManagement,
|
||
'/app-lock' => AppRoutes.appLockSettings,
|
||
_ => AppRoutes.generalSettings,
|
||
};
|
||
}
|
||
|
||
/// 路径兜底解析:将 URI path 段直接映射为内部路由
|
||
static String? _resolvePathFallback(String path) {
|
||
if (path.isEmpty || path == '/') return AppRoutes.home;
|
||
|
||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||
if (segments.isEmpty) return AppRoutes.home;
|
||
|
||
return switch (segments.first) {
|
||
'fortune' => AppRoutes.dailyFortune,
|
||
'article' => path,
|
||
'notes' => AppRoutes.noteList,
|
||
'home' => AppRoutes.home,
|
||
'discover' => AppRoutes.discover,
|
||
'profile' => AppRoutes.profile,
|
||
'inspiration' => AppRoutes.inspiration,
|
||
'search' => AppRoutes.search,
|
||
'favorites' => AppRoutes.favorites,
|
||
'history' => AppRoutes.history,
|
||
'settings' => path,
|
||
'weather' =>
|
||
segments.length > 1 ? AppRoutes.weatherSettings : AppRoutes.weather,
|
||
'poetry' =>
|
||
segments.length > 1 ? AppRoutes.poetrySettings : AppRoutes.poetry,
|
||
'achievement' => AppRoutes.achievement,
|
||
'rank' => AppRoutes.rank,
|
||
'learning' => AppRoutes.learning,
|
||
'checkin' => AppRoutes.achievement,
|
||
_ => path,
|
||
};
|
||
}
|
||
}
|
||
|
||
class _OhosRouteObserver extends NavigatorObserver {
|
||
@override
|
||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
Log.d(
|
||
'🟢 [OHOS] Route didPush: ${route.settings.name} (${route.runtimeType})',
|
||
null,
|
||
null,
|
||
LogCategory.router,
|
||
);
|
||
}
|
||
|
||
@override
|
||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
Log.d('🟢 [OHOS] Route didPop: ${route.settings.name}', null, null, LogCategory.router);
|
||
}
|
||
|
||
@override
|
||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||
Log.d(
|
||
'🟢 [OHOS] Route didReplace: ${oldRoute?.settings.name} → ${newRoute?.settings.name}',
|
||
null,
|
||
null,
|
||
LogCategory.router,
|
||
);
|
||
}
|
||
|
||
@override
|
||
void didStartUserGesture(
|
||
Route<dynamic> route,
|
||
Route<dynamic>? previousRoute,
|
||
) {
|
||
Log.d('🟢 [OHOS] Route didStartUserGesture: ${route.settings.name}', null, null, LogCategory.router);
|
||
}
|
||
}
|
||
|
||
/// 摇一摇路由作用域观察者
|
||
/// 监听 GoRouter 路由变化,自动更新 ShakeDetector 的作用域
|
||
/// 仅当当前路由为 /home 时允许摇一摇触发
|
||
///
|
||
/// 继承 NavigatorObserver 以便加入 GoRouter.observers 列表,
|
||
/// 确保变量被初始化;同时懒加载 routerDelegate.addListener
|
||
/// 以捕获 StatefulShellRoute 内的 tab 切换事件
|
||
class ShakeScopeObserver extends NavigatorObserver {
|
||
bool _listenerAttached = false;
|
||
|
||
void _ensureListenerAttached() {
|
||
if (_listenerAttached) return;
|
||
_listenerAttached = true;
|
||
appRouter.routerDelegate.addListener(_onRouteChanged);
|
||
}
|
||
|
||
void _onRouteChanged() {
|
||
final location =
|
||
appRouter.routerDelegate.currentConfiguration.last.matchedLocation;
|
||
final scope = location == AppRoutes.home ? AppRoutes.home : '';
|
||
ShakeDetector.instance.setScope(scope);
|
||
}
|
||
|
||
@override
|
||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
_ensureListenerAttached();
|
||
_onRouteChanged();
|
||
}
|
||
|
||
@override
|
||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
_onRouteChanged();
|
||
}
|
||
|
||
@override
|
||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||
_onRouteChanged();
|
||
}
|
||
}
|