import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/components/privacy_startup_gate.dart'; import 'package:new_learn/core/config/app_config.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/providers/network_status_provider.dart'; import 'package:new_learn/core/providers/privacy_provider.dart'; import 'package:new_learn/core/report/report_redirect.dart'; import 'package:new_learn/core/router/app_router.dart'; import 'package:new_learn/core/services/local_notification_service.dart'; import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置 import 'package:talker_flutter/talker_flutter.dart'; /// ============================================================ /// 【TabApp】App 顶层入口(MaterialApp) /// /// 职责分工(对照 Vue 项目): /// main.dart = 启动脚本(main 函数) /// app.dart = 根组件:ScreenUtilInit + MaterialApp(主题 + 首页) /// main_tab_page.dart = 底部 4 tab 主框架 /// pages/ = 各业务页面 /// /// 日志体系(talker_flutter + talker_dio_logger): /// 1. 全局唯一实例:core/logger/app_talker.dart 的 talker(全项目 import 即用); /// 2. Dio 网络日志:core/network/dio_client.dart 已接入 TalkerDioLogger; /// 3. App 内查看:本文件在 MaterialApp.builder 外层注入 TalkerWrapper + DebugTalkerBubble, /// 非 Release 模式会出现右下角悬浮小圆球,点开进入 TalkerScreen 查看全部日志; /// 4. Release 自动关闭:kReleaseMode 时不注入 TalkerWrapper/悬浮球,线上不可见。 /// /// 业务页面直接使用(不需要 new Talker): /// import 'package:new_learn/core/logger/app_talker.dart'; /// talker.debug('调试信息'); /// talker.warning('警告'); /// talker.error('错误'); /// /// 一、ScreenUtilInit 屏幕适配(flutter_screenutil 5.9.0): /// designSize = 设计稿尺寸(这里 375x812 = iPhone 标准逻辑尺寸, /// 如果你的设计稿是 750 宽(uni-app rpx),就写 Size(750, 1624))。 /// 适配原理:按"实际屏幕 / 设计稿"的比例统一缩放,保证不同手机上 /// UI 比例一致,不会大屏显小、小屏溢出。 /// /// 二、页面里怎么用(核心三件套): /// .w 宽度/横向间距 Container(width: 100.w) /// .h 高度/纵向间距 Container(height: 50.h) /// .sp 字号 TextStyle(fontSize: 16.sp) /// .r 圆角 BorderRadius.circular(12.r) /// /// 三、高频坑: /// 1. 必须在 ScreenUtilInit 的 builder 内部才能用 .w/.h/.sp/.r, /// 所以初始化要包在最外层(本文件就是最外层); /// 2. 改 main.dart 或这里,必须 Shift+R 热重启,热重载不生效; /// 3. 不要对图片宽高做 .w 缩放后还叠加 transform,会双倍缩放。 /// ============================================================ class TabApp extends StatelessWidget { const TabApp({super.key}); @override Widget build(BuildContext context) { return ScreenUtilInit( // 方案 B:平板(宽≥600dp)自动用大设计稿,缩放比例回到 ~1, // 否则 Pad 上 .sp 会随宽度放大 2 倍+,字体爆炸。 designSize: AppAdapt.designSizeOf(context), minTextAdapt: true, // 字号最小自适应,防小屏文字过小 splitScreenMode: true, // 支持分屏/平板 // 兜底:字体放大封顶 1.2 倍(防横屏/极端宽屏) fontSizeResolver: AppAdapt.fontSizeResolver, builder: (context, child) { // 品牌 × 环境 聚合:App 名 + 主题色板随品牌走(见 core/config/app_config.dart) final brandTheme = AppConfig.theme; return MaterialApp.router( title: AppConfig.appName, debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, // 品牌 seed 色生成整套 Material 色板(换品牌 = 全局换色) colorSchemeSeed: brandTheme.seedColor, scaffoldBackgroundColor: const Color(0xFFF5F7FA), appBarTheme: const AppBarTheme( backgroundColor: Colors.white, foregroundColor: Color(0xFF333333), elevation: 0, centerTitle: true, ), ), // 方案 A:启动层隐私 Gate。 // 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页, // 而是直接由全屏拦截页先接管。 builder: (context, child) { // ReportRedirectNotifier 必须包在 MaterialApp 里面(能拿到 GoRouterState) // 但在 PrivacyStartupGate 外层(隐私同意后的页面才能拦截) final withRedirect = ReportRedirectNotifier( child: child ?? const SizedBox.shrink(), ); final content = PrivacyStartupGate(child: withRedirect); final bootstrapped = _LocalNotificationBootstrapper(child: content); if (kReleaseMode) return bootstrapped; return TalkerWrapper( talker: talker, child: DebugTalkerBubble( child: _NetworkStatusGlobalLogger(child: bootstrapped), ), ); }, // 全局路由表:登录守卫 + 4 tab ShellRoute(见 app_router.dart) routerConfig: appRouter, ); }, ); } } class _LocalNotificationBootstrapper extends StatefulWidget { const _LocalNotificationBootstrapper({required this.child}); final Widget child; @override State<_LocalNotificationBootstrapper> createState() => _LocalNotificationBootstrapperState(); } class _LocalNotificationBootstrapperState extends State<_LocalNotificationBootstrapper> { @override void initState() { super.initState(); privacyAcceptedNotifier.addListener(_onPrivacyChanged); _onPrivacyChanged(); } @override void dispose() { privacyAcceptedNotifier.removeListener(_onPrivacyChanged); super.dispose(); } void _onPrivacyChanged() { if (!privacyAcceptedNotifier.value) return; unawaited(LocalNotificationService.instance.ensureInitialized()); } @override Widget build(BuildContext context) => widget.child; } class _NetworkStatusGlobalLogger extends ConsumerStatefulWidget { const _NetworkStatusGlobalLogger({required this.child}); final Widget child; @override ConsumerState<_NetworkStatusGlobalLogger> createState() => _NetworkStatusGlobalLoggerState(); } class _NetworkStatusGlobalLoggerState extends ConsumerState<_NetworkStatusGlobalLogger> { late final ProviderSubscription _sub; @override void initState() { super.initState(); final current = ref.read(networkStatusProvider); talker.debug( '网络状态(初始):' 'types=[${current.networkTypes.map((e) => e.name).join(',')}] ' 'hasInternet=${current.hasInternet} ' 'hasBackend=${current.hasBackend}', ); _sub = ref.listenManual(networkStatusProvider, (prev, next) { talker.debug( '网络状态变化:' 'types=[${next.networkTypes.map((e) => e.name).join(',')}] ' 'hasInternet=${next.hasInternet} ' 'hasBackend=${next.hasBackend}', ); }); } @override void dispose() { _sub.close(); super.dispose(); } @override Widget build(BuildContext context) => widget.child; }