feat: 新增我的报告模块,集成inappwebview支持H5详情页

1.  添加flutter_inappwebview依赖并配置各平台插件注册
2.  新增我的报告列表页、详情WebView页
3.  新增报告模块API和数据模型
4.  配置路由跳转,修复我的菜单跳转逻辑
5.  添加AGP9兼容补丁修复插件构建问题
6.  注释掉登录成功的SnackBar提示
This commit is contained in:
崔宝新
2026-09-09 11:08:47 +08:00
parent 955d6455d8
commit c0c673e2d2
15 changed files with 1481 additions and 9 deletions
File diff suppressed because one or more lines are too long
+30
View File
@@ -23,3 +23,33 @@ plugins {
} }
include ":app" include ":app"
// ============================================================
// 【Gradle 兼容补丁】flutter_inappwebview_android 1.1.3 的 AGP 9 兼容问题
//
// 问题:AGP 9 不支持 proguard-android.txt,必须用 proguard-android-optimize.txt。
// 插件 build.gradle 写死了 proguard-android.txt → Gradle 解析时就报错
// (debug 构建也炸,因为 AGP 9 在解析阶段就校验了这个方法调用)。
//
// 修复:在 Gradle 解析插件 build.gradle 之前(beforeProject 钩子),
// 把插件文件里的字符串替换掉。这个钩子比 Gradle 自带机制还早触发,
// 所以能赶在 Gradle 报错前修好。
//
// 优势:
// - Gradle 原生,不依赖 Python/PowerShell
// - 跨平台(Windows/Mac/Linux,Gradle 必备)
// - 所有同事拉代码后自动生效
// - Gradle daemon 每次构建都会重新解析 build.gradle,不会有缓存问题
// ============================================================
gradle.beforeProject { Project project ->
if (project.name == 'flutter_inappwebview_android') {
def gradleFile = project.file('build.gradle')
def text = gradleFile.text
if (text.contains("proguard-android.txt")) {
gradleFile.text = text.replace(
"proguard-android.txt", "proguard-android-optimize.txt"
)
println "✅ Patched flutter_inappwebview_android/build.gradle (AGP 9 compat)"
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:new_learn/core/api/my_report/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【my_report 模块接口】我的报告 —— 对标 Vue3 端 api/myReport/index.ts
///
/// getReportPage —— 报告列表分页(页码分页)
/// GET /api/v1/UserReport/GetUserReportList
/// 参数:pageIndex / pageSize / reportType(可选)
/// ============================================================
class UserReportApi {
UserReportApi._();
static final UserReportApi instance = UserReportApi._();
/// 报告列表分页(页码分页:pageIndex / pageSize)
/// [reportType] 可选筛选条件,Vue 端默认不传
Future<UserReportPage> getReportPage({
int pageIndex = 1,
int pageSize = 10,
int? reportType,
}) async {
final query = <String, dynamic>{
'pageIndex': pageIndex,
'pageSize': pageSize,
};
if (reportType != null) query['reportType'] = reportType;
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetUserReportList',
query: query,
);
return UserReportPage.fromJson(data as Map<String, dynamic>);
}
}
+91
View File
@@ -0,0 +1,91 @@
/// ============================================================
/// 【my_report 模块模型】我的报告 —— 对标 Vue3 端 api/myReport/type.ts
///
/// UserReportDto(单条报告)/ UserReportPage(分页响应)
///
/// 字段说明:
/// id 报告 ID(后端大整数,返回 string 类型,前端也用 String 存)
/// reportName 报告名称(如"数学分析报告")
/// reportType 报告类型
/// createTime 创建时间字符串(如 "2026-08-28 15:24:39" 或 ISO "T" 分隔)
/// reportUrl 报告详情 H5 页面 URL(点击列表项后 WebView 加载这个)
///
/// 序列化:手动 fromJson,做了类型兼容(后端 id 有时 string 有时 number,
/// 统一转 String;reportUrl 清理首尾空白和反引号)。
/// 与 my_point/model.dart 保持一致,不依赖 build_runner。
/// ============================================================
library;
/// 辅助:id 字段兼容 string / num 两种后端返回,统一转 String
String _asStringId(dynamic value) {
if (value == null) return '';
if (value is String) return value.trim();
if (value is num) return value.toInt().toString();
return value.toString();
}
/// 辅助:清理 reportUrl 首尾可能的反引号 / 空白(后端可能包了一层引号)
String _cleanUrl(String raw) {
final cleaned = raw.trim();
// 去掉首尾反引号(用户粘贴时可能加的,或后端真返回了)
if (cleaned.startsWith('`') && cleaned.endsWith('`')) {
return cleaned.substring(1, cleaned.length - 1).trim();
}
return cleaned;
}
/// 单条报告
class UserReportDto {
const UserReportDto({
this.id = '',
this.reportName = '',
this.reportType = 0,
this.createTime = '',
this.reportUrl = '',
});
final String id; // 后端大整数 ID,用 String 存避免精度丢失
final String reportName;
final int reportType;
final String createTime;
final String reportUrl;
factory UserReportDto.fromJson(Map<String, dynamic> json) {
return UserReportDto(
id: _asStringId(json['id']),
reportName: (json['reportName'] as String?) ?? '',
reportType: (json['reportType'] as num?)?.toInt() ?? 0,
createTime: (json['createTime'] as String?) ?? '',
reportUrl: _cleanUrl((json['reportUrl'] as String?) ?? ''),
);
}
}
/// 报告分页响应
class UserReportPage {
const UserReportPage({
this.data = const [],
this.total = 0,
this.pageIndex = 1,
this.pageSize = 10,
});
final List<UserReportDto> data;
final int total;
final int pageIndex;
final int pageSize;
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory UserReportPage.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return UserReportPage(
data: (payload['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(UserReportDto.fromJson)
.toList(),
total: (payload['total'] as num?)?.toInt() ?? 0,
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
);
}
}
+14
View File
@@ -14,6 +14,8 @@ import 'package:new_learn/features/class/my_update_detail_page.dart';
import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/my/my_page.dart'; import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart'; import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/my/my_report/my_report_page.dart';
import 'package:new_learn/features/my/my_report/report_detail_page.dart';
import 'package:new_learn/features/my/settings/settings_page.dart'; import 'package:new_learn/features/my/settings/settings_page.dart';
import 'package:new_learn/features/my/settings/about_app_page.dart'; import 'package:new_learn/features/my/settings/about_app_page.dart';
import 'package:new_learn/features/recite/recite_page.dart'; import 'package:new_learn/features/recite/recite_page.dart';
@@ -125,6 +127,18 @@ final GoRouter appRouter = GoRouter(
MyUpdateDetailPage(initialPost: state.extra as UserPostDto), MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
), ),
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()),
GoRoute(
path: '/report-detail',
builder: (_, state) {
// extra 格式:{'title': '...', 'url': '...'}
final extra = state.extra as Map<String, dynamic>? ?? {};
return ReportDetailPage(
title: extra['title'] as String? ?? '我的报告',
url: extra['url'] as String? ?? '',
);
},
),
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()), GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()), GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
// ---------- 首次启动隐私协议页 ---------- // ---------- 首次启动隐私协议页 ----------
+8 -8
View File
@@ -395,14 +395,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
if (mounted) { if (mounted) {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
context.go('/'); context.go('/');
messenger.showSnackBar( // messenger.showSnackBar(
SnackBar( // SnackBar(
content: Text( // content: Text(
'登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}', // '登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}',
), // ),
duration: const Duration(seconds: 2), // duration: const Duration(seconds: 2),
), // ),
); // );
} }
} on ApiException catch (e) { } on ApiException catch (e) {
if (mounted) _toast(e.message); if (mounted) _toast(e.message);
+1 -1
View File
@@ -101,7 +101,7 @@ class MyPage extends ConsumerWidget {
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
title: '我的报告', title: '我的报告',
onTap: () { onTap: () {
// TODO: 跳转到我的报告页 context.push('/myReport');
}, },
), ),
_MenuItem( _MenuItem(
@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/my_report/api.dart';
import 'package:new_learn/core/api/my_report/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
///
/// 一、数据来源:
/// 报告分页 → UserReportApi.instance.getReportPage → CommonPagedList
///
/// 二、UI 结构:
/// Scaffold 白色 AppBar + 背景 #F5F8FA 的列表区域
/// 卡片背景图 + 左对齐 icon/标签 + 中间文字 + 右侧箭头
///
/// 三、点击卡片 → 跳 /report-detail(WebView 详情页)
/// reportUrl 为空 → SnackBar "报告暂未生成"
///
/// 四、先只做 phone 布局,Pad 后续再说
/// ============================================================
/// 学科 icon 映射表(对标 Vue utils/constant.js SUBJECT_ICON_MAP)
const Map<String, String> _subjectIconMap = {
'语文': '/StaticResource/2026-06-11/shuxue1781163770612.png',
'化学': '/StaticResource/2026-06-11/huaxue1781163828443.png',
'英语': '/StaticResource/2026-06-11/yingyu1781163725441.png',
'物理': '/StaticResource/2026-06-11/wuli1781148438346.png',
'数学': '/StaticResource/2026-06-11/shuxue1781163770612.png',
'生物': '/StaticResource/2026-06-11/shengwu1781163791768.png',
'历史': '/StaticResource/2026-06-11/lishi1781163811822.png',
'政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png',
'地理': '/StaticResource/2026-06-11/dili1781163844311.png',
};
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
String _extractSubjectName(UserReportDto item) {
final name = item.reportName.trim();
if (name.length < 2) return name;
return name.substring(0, 2);
}
/// 取学科 icon URL(无匹配则用数学兜底,对标 Vue getSubjectIcon)
String _getSubjectIconUrl(String subjectName) {
final path = _subjectIconMap[subjectName] ?? _subjectIconMap['数学']!;
return ImgUrl.build(path);
}
/// 格式化创建时间(对标 Vue getReportTime)
String _formatCreateTime(String raw) {
if (raw.isEmpty) return '';
final normalized = raw.replaceAll('T', ' ');
return normalized.length > 16 ? normalized.substring(0, 16) : normalized;
}
class MyReportPage extends ConsumerStatefulWidget {
const MyReportPage({super.key});
@override
ConsumerState<MyReportPage> createState() => _MyReportPageState();
}
class _MyReportPageState extends ConsumerState<MyReportPage> {
static const int _pageSize = 10;
@override
void initState() {
super.initState();
talker.debug('打开页面:我的报告');
}
/// 分页加载回调
Future<PagingResult<UserReportDto>> _loadPage(int pageIndex) async {
final page = await UserReportApi.instance.getReportPage(
pageIndex: pageIndex,
pageSize: _pageSize,
);
final hasNext = page.data.length >= _pageSize;
return PagingResult(data: page.data, hasNext: hasNext);
}
/// 点击卡片:跳 WebView 详情页
void _onTapCard(UserReportDto item) {
if (item.reportUrl.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('报告暂未生成')));
return;
}
context.push(
'/report-detail',
extra: {
'title': item.reportName.isEmpty ? '我的报告' : item.reportName,
'url': item.reportUrl,
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('我的报告', style: TextStyle(color: Color(0xFF1D2B47))),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Container(
color: const Color(0xFFF5F8FA),
child: CommonPagedList<UserReportDto>(
load: _loadPage,
padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 0),
separatorBuilder: (_, _) => SizedBox(height: 14.h),
buildItem: (context, item, index) {
return _ReportCard(item: item, onTap: () => _onTapCard(item));
},
emptyWidget: Transform.translate(
offset: Offset(0, -80.h),
child: const Center(
child: Text('暂无内容', style: TextStyle(color: Color(0xFF687FAD))),
),
),
),
),
);
}
}
/// ============================================================
/// 【_ReportCard】报告卡片
///
/// 布局(Row 从左到右):
/// [icon + 学科标签] → [报告名 + 时间] → [箭头]
///
/// 背景图叠底,InkWell 提供点击水波纹。
/// 图标/标签自然靠左,不做偏移 hack。
/// ============================================================
class _ReportCard extends StatelessWidget {
const _ReportCard({required this.item, required this.onTap});
final UserReportDto item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final subjectName = _extractSubjectName(item);
final subjectIconUrl = _getSubjectIconUrl(subjectName);
final timeText = _formatCreateTime(item.createTime);
const bgPath =
'/StaticResource/2026-06-10/examListItem_bg1781074465963.png';
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12.r),
child: Container(
height: 109.h,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)),
child: Stack(
children: [
// 底层:卡片背景图
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(bgPath),
fit: BoxFit.fill,
placeholder: (_, _) => Container(color: Colors.white),
errorWidget: (_, _, _) => Container(color: Colors.white),
),
),
),
// 上层内容:Row 左对齐,padding 控制整体边距
Padding(
padding: EdgeInsets.symmetric(horizontal: 22.w),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// ── 左侧:学科 icon + 标签(垂直排列,自然靠左) ──
Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: subjectIconUrl,
width: 56.w,
height: 56.w,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
SizedBox(height: 4.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w),
height: 25.h,
decoration: BoxDecoration(
color: const Color(0xFFEAF5FE),
borderRadius: BorderRadius.circular(7.r),
),
alignment: Alignment.center,
child: Text(
subjectName,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF3D7DF5),
),
),
),
],
),
SizedBox(width: 40.w),
// ── 中间:报告名 + 时间(Expanded 占剩余) ──
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 10.h),
Text(
item.reportName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16.sp,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 20.h),
Text(
timeText,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF687FAD),
),
),
],
),
),
// ── 右侧:箭头 ──
Padding(
padding: EdgeInsets.only(top: 10.h),
child: Icon(
Icons.chevron_right,
color: const Color(0xFF4B6B99),
size: 22.sp,
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/logger/app_talker.dart';
/// ============================================================
/// 【ReportDetailPage】报告详情 WebView 页 —— 对标 Vue reportWebView.vue
///
/// 一、为什么用 flutter_inappwebview 而不是官方 webview_flutter:
/// ✅ 需要 JS → Flutter 回调(addJavaScriptHandler)
/// ✅ 需要 Flutter → JS 主动调用(callAsyncJavaScript / evaluateJavascript)
/// ✅ 比 webview_flutter 功能更全,后续扩展方便
///
/// 二、路由参数(通过 GoRouteState.extra 传 Map):
/// context.push('/report-detail', extra: {'title': '...', 'url': '...'})
/// - title:AppBar 标题
/// - url:WebView 加载的 H5 报告 URL
///
/// 三、JS Bridge 完整指南(H5 ↔ Flutter 双向通信):
///
/// ┌─────────────── H5 调 Flutter ───────────────┐
/// │ │
/// │ Flutter 注册 handler(见 _registerJsHandlers)│
/// │ controller.addJavaScriptHandler( │
/// │ handlerName: 'closePage', │
/// │ callback: (args) { ... } │
/// │ ); │
/// │ │
/// │ H5 侧调用: │
/// │ // 关闭当前 WebView 页 │
/// │ window.flutter_inappwebview │
/// │ .callHandler('closePage'); │
/// │ │
/// │ // 带参数调用: │
/// │ window.flutter_inappwebview │
/// │ .callHandler('onReportAction', { │
/// │ action: 'download', │
/// │ data: 'xxx' │
/// │ }); │
/// │ │
/// │ H5 兼容写法(推荐,同时支持 UniApp web-view): │
/// │ function callFlutter(handlerName, args) { │
/// │ // Flutter inappwebview │
/// │ if (window.flutter_inappwebview) { │
/// │ window.flutter_inappwebview │
/// │ .callHandler(handlerName, args); │
/// │ } │
/// │ // UniApp web-view(可选,按需扩展) │
/// │ // #ifdef APP-PLUS │
/// │ // plus.webview.currentWebview()... │
/// │ // #endif │
/// │ } │
/// │ callFlutter('closePage'); │
/// │ │
/// └───────────────────────────────────────────────┘
///
/// ┌─────────────── Flutter 调 H5 ───────────────┐
/// │ │
/// │ // 主动调 H5 全局函数: │
/// │ await controller.callAsyncJavaScript( │
/// │ functionBody: │
/// │ 'window.onFlutterMessage("hello");', │
/// │ ); │
/// │ │
/// │ // 或者执行任意 JS 代码: │
/// │ await controller.evaluateJavascript( │
/// │ source: 'document.title', │
/// │ ); │
/// │ │
/// └───────────────────────────────────────────────┘
///
/// 四、支持的 JS handler 列表(H5 可调用这些名字):
///
/// handlerName 参数 作用
/// ──────────────────────────────────────────────────
/// closePage 无 关闭当前 WebView 页
/// onReportAction {action,data} H5 主动通知 Flutter 做某事
/// action 可取:
/// 'share' → 触发分享(TODO: 接 share_plus)
/// 'download' → 下载/保存报告
/// 'refresh' → Flutter 刷列表后回退
///
/// 五、平台配置(如需):
/// Android:AndroidManifest.xml 需 INTERNET 权限(flutter 默认已有)
/// iOS:Info.plist 需 NSAppTransportSecurity 允许 HTTP(如果报告页是 HTTPS 可忽略)
/// ============================================================
class ReportDetailPage extends StatefulWidget {
const ReportDetailPage({super.key, required this.title, required this.url});
final String title;
final String url;
@override
State<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends State<ReportDetailPage> {
InAppWebViewController? _webController;
/// WebView 加载进度(0~1),用于底部蓝色进度条
double _loadingProgress = 0;
@override
void initState() {
super.initState();
talker.debug('打开报告详情:${widget.title} → ${widget.url}');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text(
widget.title,
style: const TextStyle(color: Color(0xFF1D2B47)),
),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
// 先尝试 WebView 内部 history 回退,不行再 pop 页面
onPressed: () async {
final controller = _webController;
if (controller != null && await controller.canGoBack()) {
await controller.goBack();
} else if (context.mounted) {
context.canPop() ? context.pop() : context.go('/my');
}
},
),
// 底部加载进度条(可选,提升体验)
bottom: PreferredSize(
preferredSize: const Size.fromHeight(2),
child: _loadingProgress < 1.0
? LinearProgressIndicator(
value: _loadingProgress,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)),
)
: const SizedBox.shrink(),
),
),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
initialSettings: InAppWebViewSettings(
// 允许 JS(报告页大概率需要)
javaScriptEnabled: true,
// 支持内联视频播放
allowsInlineMediaPlayback: true,
// 允许文件访问
allowFileAccess: true,
// 自适应内容(防止移动端 H5 在 WebView 里显示过宽)
useHybridComposition: true,
),
onWebViewCreated: (controller) {
_webController = controller;
// ✅【关键】注册 JS Bridge:H5 通过 window.flutter_inappwebview.callHandler 调这些
_registerJsHandlers(controller);
talker.debug('WebView created, JS handlers registered');
},
onLoadStart: (controller, url) {
setState(() => _loadingProgress = 0);
talker.debug('WebView loading start: $url');
},
onLoadStop: (controller, url) async {
setState(() => _loadingProgress = 1);
talker.debug('WebView loading stop: $url');
// TODO: 可选:页面加载完后主动调 H5 的初始化函数
// await controller.callAsyncJavaScript(
// functionBody: 'window.onFlutterReady?.();',
// );
},
onProgressChanged: (controller, progress) {
setState(() => _loadingProgress = progress / 100.0);
},
// WebView 内部请求拦截(可选:如需给 H5 接口加 token,可在这里处理)
// shouldInterceptRequest: (controller, request) async {
// final url = request.url?.toString() ?? '';
// if (url.contains('/api/')) {
// // 给 H5 的接口请求追加 Authorization header
// return request.copyWith(
// headers: {
// ...request.headers ?? {},
// 'Authorization': 'Bearer ${DioClient.instance.accessToken}',
// },
// );
// }
// return null;
// },
),
);
}
// ============================================================
// JS Bridge 注册 —— 所有 handler 在这里统一定义
//
// H5 调用方式:window.flutter_inappwebview.callHandler('closePage')
// 或带参数:window.flutter_inappwebview.callHandler('onReportAction', {action:'xxx'})
//
// 注意:handler 必须在 onWebViewCreated 里注册,页面加载前就要就绪。
// ============================================================
void _registerJsHandlers(InAppWebViewController controller) {
// ---- 1. closePage:关闭当前 WebView 页 ----
// H5 调用:window.flutter_inappwebview.callHandler('closePage')
controller.addJavaScriptHandler(
handlerName: 'closePage',
callback: (args) {
talker.debug('[JS→Flutter] closePage called, args: $args');
if (context.mounted) {
context.canPop() ? context.pop() : context.go('/my');
}
},
);
// ---- 2. onReportAction:H5 通知 Flutter 执行某动作 ----
// H5 调用:
// window.flutter_inappwebview.callHandler('onReportAction', {
// action: 'download',
// data: 'report_xxx.pdf'
// })
controller.addJavaScriptHandler(
handlerName: 'onReportAction',
callback: (args) {
// args 是 H5 传过来的参数数组,第一个元素通常是对象
final payload = args.isNotEmpty ? args.first : null;
talker.debug('[JS→Flutter] onReportAction called, payload: $payload');
// 解析 action 字段
String? action;
String? data;
if (payload is Map) {
action = payload['action']?.toString();
data = payload['data']?.toString();
}
switch (action) {
case 'share':
// TODO: 接 share_plus 分享报告链接
talker.debug('[JS→Flutter] → share, data: $data');
break;
case 'download':
// TODO: 下载/保存报告文件
talker.debug('[JS→Flutter] → download, data: $data');
break;
case 'refresh':
// H5 处理完后要求 Flutter 刷列表并回退
talker.debug('[JS→Flutter] → refresh');
if (context.mounted) {
context.canPop() ? context.pop(true) : context.go('/myReport');
}
break;
default:
talker.debug('[JS→Flutter] → unknown action: $action');
}
},
);
// ---- 3. 更多 handler 按需添加 ----
// 模板(复制下面这几行,改 handlerName 和 callback 即可):
//
// controller.addJavaScriptHandler(
// handlerName: 'yourHandlerName',
// callback: (args) {
// // TODO: 你的处理逻辑
// },
// );
}
@override
void dispose() {
_webController?.dispose();
super.dispose();
}
}
+4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:new_learn/core/app.dart'; import 'package:new_learn/core/app.dart';
@@ -108,6 +109,9 @@ Future<void> main() async {
// 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前! // 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前!
// 作用: 初始化 Flutter 和原生的绑定桥梁 // 作用: 初始化 Flutter 和原生的绑定桥梁
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// flutter_inappwebview 平台实现注册(Chrome Web / Windows desktop 必须显式触发)
// 否则会报 InAppWebViewPlatform.instance != null 断言错误
InAppWebViewPlatform.instance;
// go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器 // go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器
// 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用 // 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用
// appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。 // appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。
@@ -0,0 +1,15 @@
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -41,14 +41,14 @@
buildTypes {
debug {
minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled true
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
+64
View File
@@ -499,6 +499,70 @@ packages:
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted source: hosted
version: "0.1.5+1" version: "0.1.5+1"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
source: hosted
version: "0.6.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
+8
View File
@@ -166,6 +166,14 @@ dependencies:
# url_launcher:跳转应用商店 / 外部浏览器下载地址 # url_launcher:跳转应用商店 / 外部浏览器下载地址
url_launcher: ^6.3.1 url_launcher: ^6.3.1
# WebView(内嵌 H5 报告详情页,支持 JS ↔ Flutter 双向通信)
# 为什么用 flutter_inappwebview 而不是 webview_flutter:
# - 需要 H5 内部按钮回调到 Flutter(JS bridge:addJavaScriptHandler)
# - 比官方 webview_flutter 功能强:支持 callAsyncJavaScript、cookie 管理等
# - 6.1.5 stable 最低 Flutter 3.24 / Dart 3.5,项目 Flutter 3.44.8 完全兼容
# ⚠ Android 端要求 minSdkVersion >= 19(flutter_inappwebview 6.x 默认)
flutter_inappwebview: ^6.1.5
# 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动) # 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动)
# 用法:lib/core/components/persistent_snap_sheet.dart 封装组件 # 用法:lib/core/components/persistent_snap_sheet.dart 封装组件
# 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式) # 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式)
@@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h> #include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <gal/gal_plugin_c_api.h> #include <gal/gal_plugin_c_api.h>
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTimezonePluginCApiRegisterWithRegistrar( FlutterTimezonePluginCApiRegisterWithRegistrar(
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
file_selector_windows file_selector_windows
flutter_inappwebview_windows
flutter_secure_storage_windows flutter_secure_storage_windows
flutter_timezone flutter_timezone
gal gal