From c65df455f2f8346b68e30a737004088283684ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=AE=9D=E6=96=B0?= Date: Thu, 10 Sep 2026 18:14:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(my):=20add=20=E6=88=91=E7=9A=84=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=20page=20and=20related=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增我的笔记页面,包含学科筛选Tab和游标分页列表 2. 新增user_note接口封装和对应数据模型 3. 在我的页面添加笔记入口跳转 4. 优化收藏/错题卡片的按压反馈效果 --- lib/core/api/user_note/api.dart | 67 ++++ lib/core/api/user_note/model.dart | 90 +++++ lib/core/router/app_router.dart | 3 + lib/features/my/my_page.dart | 2 +- lib/features/my/note/note_page.dart | 372 ++++++++++++++++++ .../my/question_list/question_list_page.dart | 105 +++-- 6 files changed, 597 insertions(+), 42 deletions(-) create mode 100644 lib/core/api/user_note/api.dart create mode 100644 lib/core/api/user_note/model.dart create mode 100644 lib/features/my/note/note_page.dart diff --git a/lib/core/api/user_note/api.dart b/lib/core/api/user_note/api.dart new file mode 100644 index 0000000..ff6dc92 --- /dev/null +++ b/lib/core/api/user_note/api.dart @@ -0,0 +1,67 @@ +import 'package:new_learn/core/api/user_collect/model.dart'; +import 'package:new_learn/core/api/user_note/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【user_note 模块接口】我的笔记 —— 对标 Vue3 端 api/userNote/index.ts +/// +/// getSubjects —— 学科筛选项(顶部 Tab,无参) +/// GET /api/v1/UserNote/GetUserCollectSubject +/// +/// getNoteList —— 笔记分页列表(游标分页) +/// GET /api/v1/UserNote/GetUserNotes +/// 参数:subjectId(可选) / pageIndex / pageSize / prevPageId +/// +/// 列表不按笔记类型过滤;type(1题目/2讲义) 由每条数据自带, +/// 前端用卡片角标图标区分。 +/// +/// 注意:Vue 同文件里的 OSS 凭证/上传地址接口归属 OSS 模块 +/// (Flutter 端见 core/network/oss_api.dart),不在此重复封装。 +/// ============================================================ +class UserNoteApi { + UserNoteApi._(); + + static final UserNoteApi instance = UserNoteApi._(); + + /// 学科筛选项 + Future> getSubjects() async { + final data = await DioClient.instance.get( + '/api/v1/UserNote/GetUserCollectSubject', + ); + // 兼容裸数组 / {data: [...]} 响应壳 + final List list = data is List + ? data + : (data is Map + ? (data['data'] as List? ?? const []) + : const []); + return list + .whereType>() + .map(CollectTab.fromJson) + .toList(); + } + + /// 笔记分页列表 + /// + /// [subjectId] 不传 = 全部学科;[prevPageId] 游标:首页传 0, + /// 翻页传上一页最后一条 id。 + Future getNoteList({ + int? subjectId, + required int pageIndex, + int pageSize = 10, + int prevPageId = 0, + }) async { + final query = { + 'pageIndex': pageIndex, + 'pageSize': pageSize, + 'prevPageId': prevPageId, + }; + // 对齐 Vue useList:subjectId 为 null 时不带该参数 + if (subjectId != null) query['subjectId'] = subjectId; + + final data = await DioClient.instance.get( + '/api/v1/UserNote/GetUserNotes', + query: query, + ); + return UserNotePage.fromJson(data as Map); + } +} diff --git a/lib/core/api/user_note/model.dart b/lib/core/api/user_note/model.dart new file mode 100644 index 0000000..c3b0538 --- /dev/null +++ b/lib/core/api/user_note/model.dart @@ -0,0 +1,90 @@ +/// ============================================================ +/// 【user_note 模块模型】我的笔记 —— 对标 Vue3 端 api/userNote/type.ts +/// +/// UserNoteItem 笔记列表项(type 1=题目笔记 2=讲义笔记) +/// UserNotePage 分页响应(游标分页:pageIndex + prevPageId) +/// +/// 学科筛选 Tab 复用收藏模块的 CollectTab(见 api.dart 的 import, +/// 与 Vue 端 api/userNote/index.ts 从 ../userCollect/type 引入一致)。 +/// ============================================================ +library; + +/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失) +int? _noteToInt(dynamic v) { + if (v == null) return null; + if (v is num) return v.toInt(); + if (v is String && v.isNotEmpty) return int.tryParse(v); + return null; +} + +/// 我的笔记列表项 +class UserNoteItem { + const UserNoteItem({ + this.id = 0, + this.subjectName = '', + this.type = 0, + this.title = '', + this.content = '', + this.createTime = '', + }); + + /// 笔记 id(游标分页 prevPageId 取本页最后一条的 id;详情页参数 id) + final int id; + + /// 学科名(SubjectTag 配色用) + final String subjectName; + + /// 笔记类型:1=题目笔记 2=讲义笔记(卡片角标/详情页区分用) + final int type; + + /// 笔记标题 + final String title; + + /// 笔记内容 HTML(RichTextContent 渲染) + final String content; + + /// 记笔记时间字符串 + final String createTime; + + factory UserNoteItem.fromJson(Map json) { + return UserNoteItem( + id: _noteToInt(json['id']) ?? 0, + subjectName: json['subjectName'] as String? ?? '', + type: _noteToInt(json['type']) ?? 0, + title: json['title'] as String? ?? '', + content: json['content'] as String? ?? '', + createTime: json['createTime'] as String? ?? '', + ); + } +} + +/// 我的笔记分页响应 +class UserNotePage { + const UserNotePage({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + }); + + final List data; + final int total; + final int pageIndex; + final int pageSize; + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory UserNotePage.fromJson(Map json) { + final payload = (json['data'] is Map) + ? json['data'] as Map + : json; + return UserNotePage( + data: (payload['data'] as List? ?? const []) + .whereType>() + .map(UserNoteItem.fromJson) + .toList(), + total: _noteToInt(payload['total']) ?? 0, + pageIndex: _noteToInt(payload['pageIndex']) ?? 1, + pageSize: _noteToInt(payload['pageSize']) ?? 10, + ); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 837f8bf..f500c8d 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -13,6 +13,7 @@ import 'package:new_learn/features/class/my_interact_page.dart'; import 'package:new_learn/features/class/my_update_detail_page.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/note/note_page.dart'; import 'package:new_learn/features/conquerPackage/captureList/index.dart'; import 'package:new_learn/features/my/my_point/my_point_page.dart'; import 'package:new_learn/features/my/question_list/question_list_page.dart'; @@ -139,6 +140,8 @@ final GoRouter appRouter = GoRouter( MyUpdateDetailPage(initialPost: state.extra as UserPostDto), ), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), + // 我的笔记 + GoRoute(path: '/myNote', builder: (_, _) => const NotePage()), // 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage GoRoute( path: '/myWrong', diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index ec0d05b..8217c88 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -117,7 +117,7 @@ class MyPage extends ConsumerWidget { '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png', title: '我的笔记', onTap: () { - // TODO: 跳转到我的笔记页 + context.push('/myNote'); }, ), _MenuItem( diff --git a/lib/features/my/note/note_page.dart b/lib/features/my/note/note_page.dart new file mode 100644 index 0000000..32a18d2 --- /dev/null +++ b/lib/features/my/note/note_page.dart @@ -0,0 +1,372 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/user_collect/model.dart'; +import 'package:new_learn/core/api/user_note/api.dart'; +import 'package:new_learn/core/api/user_note/model.dart'; +import 'package:new_learn/core/components/common_paged_list.dart'; +import 'package:new_learn/core/components/rich_text_content.dart'; +import 'package:new_learn/core/components/subject_tag.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/utils/img_url.dart'; + +/// ============================================================ +/// 【NotePage】我的笔记 —— 对标 Vue pages/myPackage/note/note.vue +/// +/// 页面结构(对齐 Vue,从上到下): +/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载) +/// 2. #F5F8FA 列表区:CommonPagedList 游标分页 +/// 卡片 = SubjectTag + 时间 + title + 内容富文本(限高渐隐) +/// + 右上角笔记类型角标 +/// +/// 与收藏/错题列表的差异: +/// ① 没有顶部总数头; +/// ② 列表不按类型过滤,type 由每条数据自带: +/// 1=题目笔记(note-stem.png)2=讲义笔记(note-teaching.png), +/// 仅用角标图标区分,详情页再按 type 渲染不同内容; +/// ③ 标题字段 title、正文字段 content。 +/// +/// 分页:pageKey 当 pageIndex;prevPageId 闭包维护——首页/刷新重置为 0, +/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表回首页。 +/// ============================================================ +class NotePage extends StatefulWidget { + const NotePage({super.key}); + + @override + State createState() => _NotePageState(); +} + +class _NotePageState extends State { + static const int _pageSize = 10; + + /// 学科 Tab 列表("全部"为客户端插入,id=null) + List _tabs = const [CollectTab(name: '全部')]; + + /// 当前选中的学科 id(null = 全部) + int? _subjectId; + + /// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0 + int _prevPageId = 0; + + @override + void initState() { + super.initState(); + talker.debug('打开页面:我的笔记'); + _loadTabs(); + } + + /// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑) + Future _loadTabs() async { + try { + final tabs = await UserNoteApi.instance.getSubjects(); + if (!mounted) return; + setState(() { + _tabs = [const CollectTab(name: '全部'), ...tabs]; + }); + } catch (e) { + talker.error('获取笔记学科Tab失败', e); + if (!mounted) return; + setState(() { + _tabs = const [CollectTab(name: '全部')]; + }); + } + } + + /// 分页加载(CommonPagedList 回调) + Future> _load(int pageKey) async { + // 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0) + if (pageKey == 1) _prevPageId = 0; + + final res = await UserNoteApi.instance.getNoteList( + subjectId: _subjectId, + pageIndex: pageKey, + pageSize: _pageSize, + prevPageId: _prevPageId, + ); + + // 更新游标为本页最后一条 id + _prevPageId = res.data.isNotEmpty ? res.data.last.id : 0; + + return PagingResult( + data: res.data, + hasNext: res.total > pageKey * _pageSize, + ); + } + + /// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页 + void _onTabTap(CollectTab tab) { + if (_subjectId == tab.id) return; + setState(() { + _subjectId = tab.id; + _prevPageId = 0; + }); + } + + /// 点击卡片 → 笔记详情页 + /// TODO: 详情页待接入,需要按 item.type 区分: + /// type=1 题目笔记 / type=2 讲义笔记,详情页内容结构不同; + /// Vue 跳转:/pages/myPackage/noteDetail/index?id=${item.id}&type=${item.type} + void _goDetail(UserNoteItem item) { + talker.debug('笔记详情待接入:id=${item.id} type=${item.type}(1题目笔记/2讲义笔记)'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + 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: Column( + children: [ + _buildTabBar(), + // 列表区(#F5F8FA 灰底) + Expanded( + child: Container( + color: const Color(0xFFF5F8FA), + child: CommonPagedList( + // 切学科时重建列表,状态自动回第一页 + key: ValueKey('note_list_$_subjectId'), + // Vue list-refresh-wrap padding-top:32rpx + listWrapper 28rpx + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + emptyWidget: _buildEmpty(), + load: _load, + buildItem: (context, item, index) => + _NoteCard(item: item, onTap: () => _goDetail(item)), + ), + ), + ), + ], + ), + ); + } + + /// 学科筛选胶囊 Tab 栏(横向滚动) + Widget _buildTabBar() { + return Container( + color: Colors.white, + height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx) + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 12.w), + itemCount: _tabs.length, + separatorBuilder: (_, _) => SizedBox(width: 12.w), + itemBuilder: (context, i) { + final tab = _tabs[i]; + final active = _subjectId == tab.id; + return Center( + child: GestureDetector( + onTap: () => _onTabTap(tab), + child: Container( + height: 32.h, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: active + ? const Color(0xFF3D7DF5) + : const Color(0xFFF5F8FA), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + tab.name, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w400, + color: active ? Colors.white : const Color(0xFF687FAD), + ), + ), + ), + ), + ); + }, + ), + ); + } + + /// 空状态:空态图 + 文案(对齐 Vue DataEmpty noNotess.png) + Widget _buildEmpty() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noNotess.png'), + width: 160.w, + fit: BoxFit.contain, + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + SizedBox(height: 12.h), + Text( + '暂无笔记', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + ], + ), + ); + } +} + +/// 单条笔记卡片:SubjectTag + 时间 + title + 内容富文本 + 右上角类型角标 +class _NoteCard extends StatelessWidget { + const _NoteCard({required this.item, required this.onTap}); + + final UserNoteItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: 14.h, + ), // Vue listItem margin-bottom: 28rpx + child: Stack( + clipBehavior: Clip.none, // 允许类型角标超出卡片顶部 + children: [ + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16.r), + // 去掉点击时默认的灰色水波纹/高亮(Vue 端卡片无按压反馈) + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + child: Padding( + // Vue padding: t-32 l/r-28 b-28 + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:学科标签 + 时间 + Row( + children: [ + SubjectTag(subject: item.subjectName), + SizedBox(width: 12.w), + Text( + item.createTime, + style: TextStyle( + fontSize: 12.sp, // Vue font-24 + color: const Color(0xFFB4C2E0), + ), + ), + ], + ), + SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx + // 笔记标题 + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, // Vue font-32 + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox( + height: 16.h, + ), // Vue question-stem-wrap margin-top: 32rpx + // 笔记内容富文本(限高渐隐) + _NoteContentBox(html: item.content), + ], + ), + ), + ), + ), + // 右上角类型角标:Vue note-type-icon top:-4rpx right:28rpx 56rpx + Positioned(top: -2.h, right: 14.w, child: _buildTypeIcon()), + ], + ), + ); + } + + /// 笔记类型角标:type=1 题目笔记(note-stem)/ 2 讲义笔记(note-teaching) + Widget _buildTypeIcon() { + final icon = item.type == 2 + ? '/StaticResource/2026-04-07/note-teaching.png' + : '/StaticResource/2026-04-07/note-stem.png'; + return CachedNetworkImage( + imageUrl: ImgUrl.build(icon), + width: 28.w, + height: 28.h, + fit: BoxFit.contain, + placeholder: (_, _) => SizedBox(width: 28.w, height: 28.h), + errorWidget: (_, _, _) => SizedBox(width: 28.w, height: 28.h), + ); + } +} + +/// 笔记内容容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx), +/// 超出部分底部渐隐遮罩(与收藏/错题列表题干样式一致) +class _NoteContentBox extends StatelessWidget { + const _NoteContentBox({required this.html}); + + final String html; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(12.w), // Vue padding: 24rpx + decoration: BoxDecoration( + color: const Color(0xFFFAFBFD), + borderRadius: BorderRadius.circular(8.r), // Vue 16rpx + border: Border.all(color: const Color(0xFFE3ECFF)), + ), + child: Stack( + children: [ + // 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉 + ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 70.h), + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: 0, + maxHeight: double.infinity, + child: RichTextContent( + html: html, + textStyle: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: const Color(0xFF495A7A), + height: 1.5, + ), + ), + ), + ), + ), + // 底部渐隐遮罩(Vue mask-layer 60rpx): + // 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见 + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 30.h, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)], + ), + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/my/question_list/question_list_page.dart b/lib/features/my/question_list/question_list_page.dart index 5ea1f63..2e40eb7 100644 --- a/lib/features/my/question_list/question_list_page.dart +++ b/lib/features/my/question_list/question_list_page.dart @@ -326,58 +326,81 @@ class _QuestionListPageState extends State { } /// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本 -class _QuestionCard extends StatelessWidget { +/// +/// 按压反馈:按下缩放到 0.97、抬起回弹,背景始终白色 +/// (不用 InkWell 灰底,改用 GestureDetector 驱动 AnimatedScale)。 +class _QuestionCard extends StatefulWidget { const _QuestionCard({required this.item, required this.onTap}); final UserCollectItem item; final VoidCallback onTap; + @override + State<_QuestionCard> createState() => _QuestionCardState(); +} + +class _QuestionCardState extends State<_QuestionCard> { + /// 是否处于按压状态(控制 0.97 缩放) + bool _pressed = false; + + void _setPressed(bool v) { + if (_pressed != v) setState(() => _pressed = v); + } + @override Widget build(BuildContext context) { + final item = widget.item; return Padding( padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx - child: Material( - color: Colors.white, - borderRadius: BorderRadius.circular(16.r), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16.r), - child: Padding( - // Vue padding: t-32 l/r-28 b-28 - padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 第一行:学科标签 + 时间 - Row( - children: [ - SubjectTag(subject: item.subjectName), - const Spacer(), - Text( - item.createTime, - style: TextStyle( - fontSize: 12.sp, // Vue font-24 - color: const Color(0xFFB4C2E0), + child: GestureDetector( + onTapDown: (_) => _setPressed(true), + onTapUp: (_) => _setPressed(false), + onTapCancel: () => _setPressed(false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.97 : 1.0, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + child: Padding( + // Vue padding: t-32 l/r-28 b-28 + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:学科标签 + 时间 + Row( + children: [ + SubjectTag(subject: item.subjectName), + const Spacer(), + Text( + item.createTime, + style: TextStyle( + fontSize: 12.sp, // Vue font-24 + color: const Color(0xFFB4C2E0), + ), ), - ), - ], - ), - SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx - // 试卷/礼包名 - Text( - item.packName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16.sp, // Vue font-32 - fontWeight: FontWeight.w600, - color: const Color(0xFF1D2B47), + ], ), - ), - SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx - // 题干富文本(限高渐隐) - _StemBox(html: item.questionStem), - ], + SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx + // 试卷/礼包名 + Text( + item.packName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, // Vue font-32 + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx + // 题干富文本(限高渐隐) + _StemBox(html: item.questionStem), + ], + ), ), ), ),