diff --git a/lib/core/api/lexicon/api.dart b/lib/core/api/lexicon/api.dart new file mode 100644 index 0000000..fd724a4 --- /dev/null +++ b/lib/core/api/lexicon/api.dart @@ -0,0 +1,86 @@ +import 'package:new_learn/core/api/lexicon/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【lexicon 模块接口】背单词接口 —— 对标 Vue3 端 api/lexicon/index.ts +/// +/// getChapters → GET GetChapters/{bookId} 章节列表 +/// getLevels → GET GetLevels/{chapterId} 关卡列表 +/// levelStart → POST LevelStart/{levelId} 开局取题 +/// answerQuestion → POST Answer 提交答案 +/// getLevelIsFinish → GET GetLevelIsFinish/{levelId} 关卡是否完成(轮询) +/// getLevelOverInfo → GET GetLevelOverInfo/{userLevelId} 闯关小结 +/// +/// 注意:getChapters Vue 端带 hideErrorToast,toast 由页面自行处理; +/// 异常统一向上抛,页面 catch 后决定提示。 +/// ============================================================ +class LexiconApi { + LexiconApi._(); + + static final LexiconApi instance = LexiconApi._(); + + /// 后端响应壳 {code, message, data} 统一取 data(用于裸值响应) + dynamic _unwrap(dynamic data) => + data is Map && data['data'] != null + ? data['data'] + : data; + + /// 章节列表(model 内部兼容响应壳) + Future getChapters(int bookId) async { + final data = await DioClient.instance.get( + '/api/v1/Lexicon/GetChapters/$bookId', + ); + return ChapterResponse.fromJson(data as Map); + } + + /// 关卡列表 + Future getLevels(int chapterId) async { + final data = await DioClient.instance.get( + '/api/v1/Lexicon/GetLevels/$chapterId', + ); + return LevelResponse.fromJson(data as Map); + } + + /// 开局取题 + Future levelStart(int levelId) async { + final data = await DioClient.instance.post( + '/api/v1/Lexicon/LevelStart/$levelId', + ); + return LevelStartInfo.fromJson(data as Map); + } + + /// 提交答案(返回值与开局信息同构,但页面只关心成功与否) + Future answerQuestion({ + required int levelId, + required int userLevelId, + required int lexiconId, + required String answer, + }) async { + final data = await DioClient.instance.post( + '/api/v1/Lexicon/Answer', + data: { + 'levelId': levelId, + 'userLevelId': userLevelId, + 'lexiconId': lexiconId, + 'answer': answer, + }, + ); + return data != null; + } + + /// 关卡是否完成(完关后后端有短暂处理延迟,需轮询) + Future getLevelIsFinish(int levelId) async { + final data = _unwrap( + await DioClient.instance.get('/api/v1/Lexicon/GetLevelIsFinish/$levelId'), + ); + return data == true; + } + + /// 闯关小结 + Future getLevelOverInfo(int userLevelId) async { + final data = await DioClient.instance.get( + '/api/v1/Lexicon/GetLevelOverInfo/$userLevelId', + ); + return LevelOverInfo.fromJson(data as Map); + } +} diff --git a/lib/core/api/lexicon/model.dart b/lib/core/api/lexicon/model.dart new file mode 100644 index 0000000..cf69b35 --- /dev/null +++ b/lib/core/api/lexicon/model.dart @@ -0,0 +1,300 @@ +/// ============================================================ +/// 【lexicon 模块模型】背单词:章节 / 关卡 / 题目 / 小结 +/// —— 对标 Vue3 端 api/lexicon/type.ts +/// ============================================================ +library; + +/// 兼容 number | String 的 id 安全转换(对标 TS 的 number | string) +int safeLexiconId(dynamic v) { + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; +} + +double _safeDouble(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v) ?? 0; + return 0; +} + +/// 章节(词汇章节列表项) +class Chapter { + const Chapter({ + this.id = 0, + this.title = '', + this.icon = '', + this.finishLexiconCount = 0, + this.levelCount = 0, + this.lexiconCount = 0, + this.finishProgress = 0, + this.cover = '', + this.index = '', + }); + + final int id; + final String title; + final String icon; + final int finishLexiconCount; + final int levelCount; + final int lexiconCount; + final double finishProgress; // 0~1 + final String cover; + final String index; // 封面叠字序号(接口可能下发,Vue 端直接渲染 item.index) + + factory Chapter.fromJson(Map json) { + return Chapter( + id: safeLexiconId(json['id']), + title: json['title'] as String? ?? '', + icon: json['icon'] as String? ?? '', + finishLexiconCount: (json['finishLexiconCount'] as num?)?.toInt() ?? 0, + levelCount: (json['levelCount'] as num?)?.toInt() ?? 0, + lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0, + finishProgress: _safeDouble(json['finishProgress']), + cover: json['cover'] as String? ?? '', + index: json['index']?.toString() ?? '', + ); + } +} + +/// 章节列表响应 +class ChapterResponse { + const ChapterResponse({this.chapters = const [], this.totalFinishProgress = 0}); + + final List chapters; + final double totalFinishProgress; // 0~1 + + factory ChapterResponse.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return ChapterResponse( + chapters: (payload['chapters'] as List? ?? const []) + .whereType>() + .map(Chapter.fromJson) + .toList(), + totalFinishProgress: _safeDouble(payload['totalFinishProgress']), + ); + } +} + +/// 关卡 +class Level { + const Level({ + this.id = 0, + this.title = '', + this.lexiconCount = 0, + this.isOpen = false, + this.finishLexiconCount = 0, + this.finishProgress = 0, + }); + + final int id; + final String title; + final int lexiconCount; + final bool isOpen; // 是否解锁 + final int finishLexiconCount; + final double finishProgress; // 0~1 + + factory Level.fromJson(Map json) { + return Level( + id: safeLexiconId(json['id']), + title: json['title'] as String? ?? '', + lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0, + isOpen: json['isOpen'] as bool? ?? false, + finishLexiconCount: (json['finishLexiconCount'] as num?)?.toInt() ?? 0, + finishProgress: _safeDouble(json['finishProgress']), + ); + } +} + +/// 关卡列表响应(含章节头部汇总) +class LevelResponse { + const LevelResponse({ + this.id = 0, + this.title = '', + this.lexiconCount = 0, + this.finishLexiconCount = 0, + this.finishProgress = 0, + this.levels = const [], + }); + + final int id; + final String title; + final int lexiconCount; + final int finishLexiconCount; + final double finishProgress; + final List levels; + + factory LevelResponse.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return LevelResponse( + id: safeLexiconId(payload['id']), + title: payload['title'] as String? ?? '', + lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0, + finishLexiconCount: + (payload['finishLexiconCount'] as num?)?.toInt() ?? 0, + finishProgress: _safeDouble(payload['finishProgress']), + levels: (payload['levels'] as List? ?? const []) + .whereType>() + .map(Level.fromJson) + .toList(), + ); + } +} + +/// 答题页单词题目 +class Question { + const Question({ + this.id = 0, + this.americanEnglish = '', + this.americanPhonetic = '', + this.americanVoiceUrl = '', + this.imageDesc = '', + this.contextMatch = '', + this.english = '', + this.phonetic = '', + this.voiceUrl = '', + this.chineseDesc = '', + this.options = const [], + }); + + final int id; + final String americanEnglish; + final String americanPhonetic; + final String americanVoiceUrl; + final String imageDesc; // 配图 + final String contextMatch; // 语境搭配(空则不可翻转) + final String english; + final String phonetic; + final String voiceUrl; + final String chineseDesc; // 正确答案 + final List options; + + factory Question.fromJson(Map json) { + return Question( + id: safeLexiconId(json['id']), + americanEnglish: json['americanEnglish'] as String? ?? '', + americanPhonetic: json['americanPhonetic'] as String? ?? '', + americanVoiceUrl: json['americanVoiceUrl'] as String? ?? '', + imageDesc: json['imageDesc'] as String? ?? '', + contextMatch: json['contextMatch'] as String? ?? '', + english: json['english'] as String? ?? '', + phonetic: json['phonetic'] as String? ?? '', + voiceUrl: json['voiceUrl'] as String? ?? '', + chineseDesc: json['chineseDesc'] as String? ?? '', + options: (json['options'] as List? ?? const []) + .map((e) => e.toString()) + .toList(), + ); + } +} + +/// 已作答单词(小结回顾用) +class Lexicon { + const Lexicon({ + this.id = 0, + this.english = '', + this.phonetic = '', + this.voiceUrl = '', + this.chineseDesc = '', + this.isCorrect = false, + }); + + final int id; + final String english; + final String phonetic; + final String voiceUrl; + final String chineseDesc; + final bool isCorrect; + + factory Lexicon.fromJson(Map json) { + return Lexicon( + id: safeLexiconId(json['id']), + english: json['english'] as String? ?? '', + phonetic: json['phonetic'] as String? ?? '', + voiceUrl: json['voiceUrl'] as String? ?? '', + chineseDesc: json['chineseDesc'] as String? ?? '', + isCorrect: json['isCorrect'] as bool? ?? false, + ); + } +} + +/// LevelStart 开局信息 +class LevelStartInfo { + const LevelStartInfo({ + this.id = 0, + this.userLevelId = 0, + this.finishLexiconCount = 0, + this.lexiconCount = 0, + this.questions = const [], + this.lexicons = const [], + }); + + final int id; + final int userLevelId; + final int finishLexiconCount; + final int lexiconCount; + final List questions; + final List lexicons; + + factory LevelStartInfo.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return LevelStartInfo( + id: safeLexiconId(payload['id']), + userLevelId: (payload['userLevelId'] as num?)?.toInt() ?? 0, + finishLexiconCount: + (payload['finishLexiconCount'] as num?)?.toInt() ?? 0, + lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0, + questions: (payload['questions'] as List? ?? const []) + .whereType>() + .map(Question.fromJson) + .toList(), + lexicons: (payload['lexicons'] as List? ?? const []) + .whereType>() + .map(Lexicon.fromJson) + .toList(), + ); + } +} + +/// 闯关小结信息 +class LevelOverInfo { + const LevelOverInfo({ + this.id = 0, + this.userLevelId = 0, + this.lexiconCount = 0, + this.correctCount = 0, + this.wrongCount = 0, + this.nextLevelId = 0, + this.nextChapterId = 0, + this.lexicons = const [], + }); + + final int id; + final int userLevelId; + final int lexiconCount; + final int correctCount; + final int wrongCount; + final int nextLevelId; // 0 表示没有下一关 + final int nextChapterId; // 0 表示没有下一章 + final List lexicons; + + /// 正确率(0~100,四舍五入;无单词兜底 0) + int get rate => + lexiconCount <= 0 ? 0 : (correctCount * 100 / lexiconCount).round(); + + factory LevelOverInfo.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return LevelOverInfo( + id: safeLexiconId(payload['id']), + userLevelId: (payload['userLevelId'] as num?)?.toInt() ?? 0, + lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0, + correctCount: (payload['correctCount'] as num?)?.toInt() ?? 0, + wrongCount: (payload['wrongCount'] as num?)?.toInt() ?? 0, + nextLevelId: safeLexiconId(payload['nextLevelId']), + nextChapterId: safeLexiconId(payload['nextChapterId']), + lexicons: (payload['lexicons'] as List? ?? const []) + .whereType>() + .map(Lexicon.fromJson) + .toList(), + ); + } +} diff --git a/lib/core/api/lexicon_book/api.dart b/lib/core/api/lexicon_book/api.dart new file mode 100644 index 0000000..af418c7 --- /dev/null +++ b/lib/core/api/lexicon_book/api.dart @@ -0,0 +1,68 @@ +import 'package:new_learn/core/api/lexicon_book/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【lexicon_book 模块接口】词书接口 —— 对标 Vue3 端 api/lexiconBook/index.ts +/// +/// getCurrentBook → GET GetCurrentLexiconBooks 当前学习的词书 +/// getLearnDayCount→ GET GetLearnDayCount 坚持学习天数 +/// getAllBooks → GET GetAllLexiconBooks 我的书库 +/// startLearn → POST StartLearn/{bookId} 切换/开始学习某书 +/// +/// 说明:Vue 端这 4 个接口都带 hideErrorToast(静默失败), +/// 异常不在此层吞,交页面按需要 catch 空处理。 +/// ============================================================ +class LexiconBookApi { + LexiconBookApi._(); + + static final LexiconBookApi instance = LexiconBookApi._(); + + /// 后端响应壳 {code, message, data} 统一取 data + dynamic _unwrap(dynamic data) => + data is Map && data['data'] != null + ? data['data'] + : data; + + /// 当前学习的词书(未选择时后端可能返回 null) + Future getCurrentBook() async { + final data = _unwrap( + await DioClient.instance.get('/api/v1/LexiconBook/GetCurrentLexiconBooks'), + ); + if (data is Map) return BookInfo.fromJson(data); + return null; + } + + /// 坚持学习天数 + Future getLearnDayCount() async { + final data = _unwrap( + await DioClient.instance.get('/api/v1/LexiconBook/GetLearnDayCount'), + ); + if (data is num) return data.toInt(); + if (data is String) return int.tryParse(data) ?? 0; + return 0; + } + + /// 我的书库全部词书 + Future> getAllBooks() async { + final data = _unwrap( + await DioClient.instance.get('/api/v1/LexiconBook/GetAllLexiconBooks'), + ); + if (data is List) { + return data + .whereType>() + .map(BookInfo.fromJson) + .toList(); + } + return const []; + } + + /// 切换 / 开始学习某书 + Future startLearn(int bookId) async { + final data = _unwrap( + await DioClient.instance.post( + '/api/v1/LexiconBook/StartLearn/$bookId', + ), + ); + return data == true; + } +} diff --git a/lib/core/api/lexicon_book/model.dart b/lib/core/api/lexicon_book/model.dart new file mode 100644 index 0000000..791078f --- /dev/null +++ b/lib/core/api/lexicon_book/model.dart @@ -0,0 +1,53 @@ +/// ============================================================ +/// 【lexicon_book 模块模型】词书实体 —— 对标 Vue3 端 api/lexiconBook/type.ts +/// +/// BookInfo:词书(背诵首页当前书 / 教材列表我的书库) +/// ============================================================ +library; + +/// 兼容 number | String 的 id 安全转换(对标 TS 的 number | string) +int _safeId(dynamic v) { + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; +} + +/// 词书信息 +class BookInfo { + const BookInfo({ + this.id = 0, + this.title = '', + this.cover = '', + this.poster = '', + this.slogan = '', + this.lexiconCount = 0, + this.totalCount = 0, + this.isStudying = false, + }); + + final int id; + final String title; // 书名 + final String cover; // 封面图 + final String poster; // 顶部海报图 + final String slogan; // 海报标语 + final int lexiconCount; // 已学词数 + final int totalCount; // 总词数 + final bool isStudying; // 是否学习中 + + /// 学习进度百分比(0~100,取整;totalCount 为 0 时兜底 0) + int get progressPercent => + totalCount <= 0 ? 0 : (lexiconCount * 100 ~/ totalCount); + + factory BookInfo.fromJson(Map json) { + return BookInfo( + id: _safeId(json['id']), + title: json['title'] as String? ?? '', + cover: json['cover'] as String? ?? '', + poster: json['poster'] as String? ?? '', + slogan: json['slogan'] as String? ?? '', + lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0, + totalCount: (json['totalCount'] as num?)?.toInt() ?? 0, + isStudying: json['isStudying'] as bool? ?? false, + ); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 6d47f03..313d437 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -23,7 +23,14 @@ import 'package:new_learn/features/my/my_report/report_detail_page.dart'; import 'package:new_learn/features/my/exam/exam_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/core/api/lexicon/model.dart'; +import 'package:new_learn/features/recite/chapters_page.dart'; +import 'package:new_learn/features/recite/level_over_page.dart'; +import 'package:new_learn/features/recite/level_start_page.dart'; +import 'package:new_learn/features/recite/levels_page.dart'; +import 'package:new_learn/features/recite/recite_args.dart'; import 'package:new_learn/features/recite/recite_page.dart'; +import 'package:new_learn/features/recite/textbook_list_page.dart'; import 'package:new_learn/features/report/report_computation_page.dart'; import 'package:new_learn/features/report/report_web_view_page.dart'; import 'package:new_learn/features/report/student_info_page.dart'; @@ -211,6 +218,35 @@ final GoRouter appRouter = GoRouter( ), ), GoRoute(path: '/register', builder: (_, _) => const RegisterPage()), + // ---------- 背诵模块二级页(不在 ShellBranch 内,push 时不显示 tabbar) ---------- + // 教材列表 + GoRoute( + path: '/recite/textbooks', + builder: (_, _) => const TextbookListPage(), + ), + // 词汇章节(bookId 走路径参数,替代 Vue 缓存) + GoRoute( + path: '/recite/chapters/:bookId', + builder: (_, state) => ChaptersPage( + bookId: int.parse(state.pathParameters['bookId']!), + ), + ), + // 关卡列表(Chapter 对象走 extra) + GoRoute( + path: '/recite/levels', + builder: (_, state) => LevelsPage(chapter: state.extra as Chapter), + ), + // 答题页(LevelStartArgs 显式传参,替代 curChapterInfo 缓存) + GoRoute( + path: '/recite/level-start', + builder: (_, state) => + LevelStartPage(args: state.extra as LevelStartArgs), + ), + // 学习小结 + GoRoute( + path: '/recite/level-over', + builder: (_, state) => LevelOverPage(args: state.extra as LevelOverArgs), + ), // app_router.dart 的 routes 里加,不用管跳转逻辑 // GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()), diff --git a/lib/features/recite/chapters_page.dart b/lib/features/recite/chapters_page.dart new file mode 100644 index 0000000..bb64114 --- /dev/null +++ b/lib/features/recite/chapters_page.dart @@ -0,0 +1,453 @@ +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/lexicon/api.dart'; +import 'package:new_learn/core/api/lexicon/model.dart'; +import 'package:new_learn/core/components/center_toast.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/recite/widgets/recite_circle_progress.dart'; +import 'package:new_learn/features/recite/widgets/recite_progress.dart'; + +/// ============================================================ +/// 【ChaptersPage】词汇章节 —— 对标 Vue pages/recitePackage/lexicon/lexicon.vue +/// +/// 入参 bookId(路由 path 参数,对标 Vue onLoad options.bookId,默认 1)。 +/// 数据:getChapters(bookId),失败 toast「页面加载失败,请重试」。 +/// 点章节 → /recite/levels(extra 传整个 Chapter,替代 eventChannel); +/// 从关卡链路返回后重新拉取(对标 Vue onShow 刷新)。 +/// ============================================================ +class ChaptersPage extends StatefulWidget { + const ChaptersPage({super.key, required this.bookId}); + + final int bookId; + + @override + State createState() => _ChaptersPageState(); +} + +class _ChaptersPageState extends State { + final List _list = []; + double _rate = 0; + bool _loading = false; + + bool get _isPad => + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + @override + void initState() { + super.initState(); + _getList(); + } + + Future _getList() async { + if (mounted) setState(() => _loading = true); + try { + final res = await LexiconApi.instance.getChapters(widget.bookId); + if (!mounted) return; + setState(() { + _rate = res.totalFinishProgress * 100; + _list + ..clear() + ..addAll(res.chapters); + }); + } catch (_) { + if (mounted) { + CenterToast.show( + context, + '页面加载失败,请重试', + duration: const Duration(milliseconds: 2000), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _onClickCell(Chapter item) async { + await context.push('/recite/levels', extra: item); + if (mounted) _getList(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: Color(0xFF1D2B47)), + ), + body: Stack( + children: [ + // 顶部背景大图 + Positioned( + top: 0, + left: 0, + right: 0, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-01/img_index_bgtop.png', + ), + fit: BoxFit.fitWidth, + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + // 白色内容卡(mp margin-top 19%,pad 30%) + Positioned.fill( + top: 0, + child: SafeArea( + child: Column( + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final topGap = + constraints.maxHeight * (_isPad ? 0.30 : 0.19); + return Padding( + // Vue: -8rpx 让卡片向两侧溢出 + padding: EdgeInsets.only(top: topGap), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(_isPad ? 30.r : 24.r), + ), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + _buildContent(), + // 鳄鱼装饰(top:-35rpx left:30rpx;pad 加大) + Positioned( + top: -17.5.h, + left: _isPad ? 30.w : 15.w, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-01/img_index_crocodile.png', + ), + width: _isPad ? 160.w : 109.w, + fit: BoxFit.fitWidth, + placeholder: (_, _) => + const SizedBox.shrink(), + errorWidget: (_, _, _) => + const SizedBox.shrink(), + ), + ), + // 总进度圆环(top:-35rpx right:50rpx) + Positioned( + top: -17.5.h, + right: _isPad ? 50.w : 25.w, + child: _CircleTotalProgress(rate: _rate), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent() { + return Padding( + // Vue chapter-list: 40rpx 横向,50rpx 顶 + padding: EdgeInsets.fromLTRB(20.w, 25.h, 20.w, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + Expanded( + child: _loading + ? Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16.w, + height: 16.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF1BC26E), + ), + ), + SizedBox(width: 8.w), + Text( + '努力加载中...', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ) + : ListView.builder( + padding: EdgeInsets.only(top: 20.h), + itemCount: _list.length, + itemBuilder: (_, i) => + _ChapterItem(item: _list[i], onTap: _onClickCell), + ), + ), + ], + ), + ); + } + + /// 词汇章节标题 + 短渐变线 + Widget _buildTitle() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '词汇章节', + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF333333), + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2.h), + Container( + width: 62.w, + height: 4.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2.r), + gradient: const LinearGradient( + colors: [Color(0x661BC26E), Color(0x667ED8AA)], + ), + ), + ), + ], + ); + } +} + +/// 总进度圆环(白色衬底圆 + 绿色环 + 总进度文案) +class _CircleTotalProgress extends StatelessWidget { + const _CircleTotalProgress({required this.rate}); + + final double rate; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 67.5.w, + height: 67.5.w, + child: Stack( + alignment: Alignment.center, + children: [ + // 白圆衬底(Vue 135rpx,top/left -8rpx) + Container( + width: 67.5.w, + height: 67.5.w, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + ), + ReciteCircleProgress( + percent: rate, + size: 60, + strokeWidth: 4, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${rate.toInt()}', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF2E3238), + height: 1, + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 1.h), + child: Text( + '%', + style: TextStyle( + fontSize: 8.sp, + color: const Color(0xFF2E3238), + height: 1, + ), + ), + ), + ], + ), + ), + ], + ), + ), + SizedBox(height: 2.h), + Text( + '总进度', + style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)), + ), + ], + ); + } +} + +/// 章节列表项(左封面叠字 + 右侧渐变信息卡) +class _ChapterItem extends StatelessWidget { + const _ChapterItem({required this.item, required this.onTap}); + + final Chapter item; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final percent = (item.finishProgress * 100).toInt(); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(item), + child: Container( + height: 100.h, + margin: EdgeInsets.symmetric(vertical: 10.h), + child: Stack( + children: [ + // 渐变信息卡(左留 96 给封面;高度 90 贴底) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 90.h, + child: Container( + padding: EdgeInsets.fromLTRB(96.w, 7.5.h, 14.w, 10.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.r), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFE1FAED), Colors.white], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + color: const Color(0xFF333333), + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + '已完成', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(width: 3.w), + Text( + '$percent%', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF1BC26E), + ), + ), + ], + ), + Row( + children: [ + Text( + '${item.finishLexiconCount}', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF1BC26E), + ), + ), + Text( + '/${item.lexiconCount}', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ], + ), + SizedBox(height: 6.h), + SegmentedProgressBar(progress: item.finishProgress * 100), + ], + ), + ), + ), + // 左侧封面(height 100,叠白色 “index title”) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: CachedNetworkImage( + imageUrl: ImgUrl.build(item.cover), + fit: BoxFit.fitHeight, + placeholder: (_, _) => SizedBox(width: 96.w), + errorWidget: (_, _, _) => SizedBox(width: 96.w), + imageBuilder: (_, provider) => Container( + foregroundDecoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.35), + Colors.transparent, + ], + stops: const [0, 0.45], + ), + ), + child: Image(image: provider, fit: BoxFit.fitHeight), + ), + ), + ), + Positioned( + left: 12.5.w, + top: 9.h, + right: 4.w, + child: Text( + item.index.isEmpty ? item.title : '${item.index} ${item.title}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11.sp, color: Colors.white), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/recite/level_over_page.dart b/lib/features/recite/level_over_page.dart new file mode 100644 index 0000000..cede9e5 --- /dev/null +++ b/lib/features/recite/level_over_page.dart @@ -0,0 +1,512 @@ +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/lexicon/api.dart'; +import 'package:new_learn/core/api/lexicon/model.dart'; +import 'package:new_learn/core/components/center_toast.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/recite/recite_args.dart'; +import 'package:new_learn/features/recite/widgets/recite_audio.dart'; +import 'package:new_learn/features/recite/widgets/recite_circle_progress.dart'; +import 'package:new_learn/features/recite/widgets/voice_anim_button.dart'; + +/// ============================================================ +/// 【LevelOverPage】学习小结 —— 对标 Vue levelOver.vue +/// +/// 入参:extra = LevelOverArgs { info, chapterId }(替代 Vue URL JSON + storage) +/// 章节标题:getLevels(chapterId)(对标 onLoad 里的 getLevels) +/// 下一关:nextLevelId/nextChapterId 都有 → pushReplacement 答题页; +/// 否则 toast「已到最后一关」。 +/// ============================================================ +class LevelOverPage extends StatefulWidget { + const LevelOverPage({super.key, required this.args}); + + final LevelOverArgs args; + + @override + State createState() => _LevelOverPageState(); +} + +class _LevelOverPageState extends State { + String _title = ''; + + LevelOverInfo get _info => widget.args.info; + + bool get _isPad => + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + @override + void initState() { + super.initState(); + _loadTitle(); + } + + @override + void dispose() { + ReciteAudioManager.instance.stopAll(); + super.dispose(); + } + + Future _loadTitle() async { + try { + final res = await LexiconApi.instance.getLevels(widget.args.chapterId); + if (mounted && res.title.isNotEmpty) { + setState(() => _title = res.title); + } + } catch (_) { + // 标题仅展示用,失败静默 + } + } + + void _onNext() { + if (_info.nextLevelId != 0 && _info.nextChapterId != 0) { + // 对标 Vue uni.redirectTo + context.pushReplacement( + '/recite/level-start', + extra: LevelStartArgs( + chapterId: _info.nextChapterId, + levelId: _info.nextLevelId, + ), + ); + } else { + CenterToast.show(context, '已到最后一关'); + } + } + + @override + Widget build(BuildContext context) { + final hPadding = _isPad ? 40.w : 20.w; + + return Scaffold( + backgroundColor: const Color(0xFF1BC26E), + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: Text( + '学习小结', + style: TextStyle(fontSize: 17.sp, color: Colors.white), + ), + iconTheme: const IconThemeData(color: Colors.white), + ), + body: Stack( + children: [ + // 背景线纹图(铺满宽度,顶部对齐) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-03/img_summary_bgline.png', + ), + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + SafeArea( + child: Padding( + padding: EdgeInsets.only(top: 56.h), + child: Stack( + clipBehavior: Clip.none, + children: [ + // 彩旗(564rpx) + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-03/img_summary_bunting.png', + ), + width: 282.w, + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + // 猴子(192rpx) + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-03/img_summary_monkey.png', + ), + width: 96.w, + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + // 白色信息卡 + Positioned.fill( + top: 54.h, + child: Padding( + padding: EdgeInsets.fromLTRB( + hPadding, + 0, + hPadding, + _isPad ? 40.h : 20.h, + ), + child: _OverInfoPanel( + info: _info, + title: _title, + onNext: _onNext, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// 白色信息卡:标题图 + 汇总灰板(正确率/总数/正确数)+ 单词回顾 + 下一关 +class _OverInfoPanel extends StatelessWidget { + const _OverInfoPanel({ + required this.info, + required this.title, + required this.onNext, + }); + + final LevelOverInfo info; + final String title; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(20.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 顶部标题图(336rpx x 83rpx,悬浮在卡片上沿) + Transform.translate( + offset: Offset(0, -37.h), + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-03/img_summary_title.png', + ), + width: 168.w, + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => SizedBox(height: 20.h), + ), + ), + Transform.translate( + offset: Offset(0, -20.h), + child: _SummaryBoard(info: info, title: title), + ), + // 单词回顾标题行 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _ReviewTitle(), + Row( + children: [ + Text( + '错误:${info.wrongCount}', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFF56245), + ), + ), + SizedBox(width: 8.w), + Text( + '可在"错题"进行复习', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ], + ), + SizedBox(height: 7.h), + // 单词回顾列表(弹性高度) + Flexible( + child: ListView.separated( + padding: EdgeInsets.symmetric(vertical: 7.h), + itemCount: info.lexicons.length, + separatorBuilder: (_, _) => SizedBox(height: 10.h), + itemBuilder: (_, i) => _ReviewItem(item: info.lexicons[i]), + ), + ), + SizedBox(height: 10.h), + // 下一关 + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onNext, + child: Container( + width: double.infinity, + height: 44.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF1BC26E), + borderRadius: BorderRadius.circular(999.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + '下一关', + style: TextStyle(fontSize: 15.sp, color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} + +/// 汇总灰板:章节标题角标 + 正确率圆环 + 单词总数 + 正确单词 +class _SummaryBoard extends StatelessWidget { + const _SummaryBoard({required this.info, required this.title}); + + final LevelOverInfo info; + final String title; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB(20.w, 25.5.h, 20.w, 14.h), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10.r), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + // 悬浮章节标题角标 + if (title.isNotEmpty) + Positioned( + top: -34.h / 2 - 8.h, + left: 0, + right: 0, + child: Center( + child: Container( + constraints: BoxConstraints(minWidth: 103.w), + padding: EdgeInsets.symmetric(horizontal: 16.w), + height: 34.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(999.r), + border: Border.all(color: Colors.white, width: 0.5), + ), + child: Text( + title, + maxLines: 1, + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF666666), + ), + ), + ), + ), + ), + Row( + children: [ + // 正确率圆环(Vue :clockwise="false") + Expanded( + child: Center( + child: ReciteCircleProgress( + percent: info.rate.toDouble(), + size: 77, + strokeWidth: 5, + clockwise: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${info.rate}', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: const Color(0xFF2E3238), + height: 1, + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 1.h), + child: Text( + '%', + style: TextStyle( + fontSize: 8.sp, + color: const Color(0xFF2E3238), + height: 1, + ), + ), + ), + ], + ), + SizedBox(height: 4.h), + Text( + '正确率', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: _Stat(value: '${info.lexiconCount}', label: '单词总数'), + ), + Expanded( + child: _Stat(value: '${info.correctCount}', label: '正确单词'), + ), + ], + ), + ], + ), + ); + } +} + +/// 绿字数字 + 灰字标签 +class _Stat extends StatelessWidget { + const _Stat({required this.value, required this.label}); + + final String value; + final String label; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: TextStyle(fontSize: 21.sp, color: const Color(0xFF1BC26E)), + ), + SizedBox(height: 6.h), + Text( + label, + style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)), + ), + ], + ); + } +} + +/// 「单词回顾」左侧绿色竖条标题 +class _ReviewTitle extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 3.w, height: 16.h, color: const Color(0xFF1BC26E)), + SizedBox(width: 8.w), + Text( + '单词回顾', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF333333)), + ), + ], + ); + } +} + +/// 回顾行:状态圆点 + 英文/中文 + 发音按钮 +class _ReviewItem extends StatelessWidget { + const _ReviewItem({required this.item}); + + final Lexicon item; + + @override + Widget build(BuildContext context) { + final dotColor = item.isCorrect + ? const Color(0xFF1BC26E) + : const Color(0xFFF56245); + + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 9.5.h), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10.r), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: dotColor, width: 1.5), + ), + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + item.english, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + color: const Color(0xFF333333), + ), + ), + ), + ], + ), + SizedBox(height: 4.h), + Text( + item.chineseDesc, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ), + SizedBox(width: 12.w), + VoiceAnimButton(url: item.voiceUrl, size: 18), + ], + ), + ); + } +} diff --git a/lib/features/recite/level_start_page.dart b/lib/features/recite/level_start_page.dart new file mode 100644 index 0000000..e231f48 --- /dev/null +++ b/lib/features/recite/level_start_page.dart @@ -0,0 +1,912 @@ +import 'dart:async'; + +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/lexicon/api.dart'; +import 'package:new_learn/core/api/lexicon/model.dart'; +import 'package:new_learn/core/components/center_toast.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/recite/recite_args.dart'; +import 'package:new_learn/features/recite/widgets/recite_audio.dart'; +import 'package:new_learn/features/recite/widgets/recite_loading.dart'; +import 'package:new_learn/features/recite/widgets/voice_anim_button.dart'; + +/// ============================================================ +/// 【LevelStartPage】闯关答题 —— 对标 Vue levelStart.vue +/// +/// 完整流程: +/// 1. getLevels(chapterId) 拿章节标题 + levelStart(levelId) 拿题; +/// 2. 进场 1s 后自动播英式发音; +/// 3. 提交答案(loading「正在提交」,失败重置选择允许重选)→ 标对错 → +/// contextMatch 非空自动翻转看语境; +/// 4. 非末题:4s 后切下一题(瞬时翻回 + splice + 自动发音); +/// 5. 末题:4s 后显示「加载中...」,轮询 GetLevelIsFinish(200ms/次, +/// 最多 30 次 ≈ 6s,替代 Vue 无限 while 死循环)→ 取小结跳转。 +/// +/// 音频:ReciteAudioManager 同时只播一个;dispose 时 stopAll(cleanupAllAudio)。 +/// ============================================================ +class LevelStartPage extends StatefulWidget { + const LevelStartPage({super.key, required this.args}); + + final LevelStartArgs args; + + @override + State createState() => _LevelStartPageState(); +} + +class _LevelStartPageState extends State + with SingleTickerProviderStateMixin { + String _title = ''; + final List _questions = []; + final List _answered = []; + + int _userLevelId = 0; + int _finishLexiconCount = 0; + int _lexiconCount = 0; + + bool _loading = true; + String _selectAnswer = ''; + String _correctAnswer = ''; + + /// 答题后到下一题的延迟(Vue intervalTime = 4000) + static const Duration _interval = Duration(milliseconds: 4000); + + /// 轮询最多 30 次 ×200ms ≈ 6s + static const int _maxPollAttempts = 30; + static const Duration _pollInterval = Duration(milliseconds: 200); + + Timer? _transitionTimer; + bool _submitting = false; + + late final AnimationController _flipCtl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + + bool get _isPad => + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + Question? get _current => _questions.isEmpty ? null : _questions.first; + + bool get _canFlip { + final q = _current; + return q != null && q.contextMatch.isNotEmpty; + } + + bool get _hasResult => _correctAnswer.isNotEmpty; + + @override + void initState() { + super.initState(); + _bootstrap(); + } + + Future _bootstrap() async { + try { + final res = await LexiconApi.instance.getLevels(widget.args.chapterId); + final info = await LexiconApi.instance.levelStart(widget.args.levelId); + if (!mounted) return; + setState(() { + if (res.title.isNotEmpty) _title = res.title; + _questions.addAll(info.questions); + _answered.addAll(info.lexicons); + _finishLexiconCount = info.finishLexiconCount; + _lexiconCount = info.lexiconCount; + _userLevelId = info.userLevelId; + _loading = false; + }); + } catch (_) { + if (mounted) { + setState(() => _loading = false); + CenterToast.show(context, '页面加载失败,请重试'); + } + return; + } + // Vue:挂载 1s 后自动播英式发音 + _transitionTimer = Timer(const Duration(seconds: 1), () { + if (mounted) _playVoice(isAmerican: false); + }); + } + + // ---------------- 发音 ---------------- + + void _playVoice({required bool isAmerican}) { + final q = _current; + if (q == null) return; + // 对标 Vue:hasActiveAudio 时直接忽略 + if (ReciteAudioManager.instance.hasActive) return; + ReciteAudioManager.instance.play( + isAmerican ? q.americanVoiceUrl : q.voiceUrl, + ); + } + + // ---------------- 翻转 ---------------- + + void _toggleFlip() { + if (_flipCtl.isAnimating) return; + if (_flipCtl.value > 0.5) { + _flipCtl.reverse(); + } else { + _flipCtl.forward(); + } + } + + // ---------------- 答题 ---------------- + + Future _onSelectAnswer(String answer) async { + if (_selectAnswer.isNotEmpty || _submitting) return; // 防重复提交 + final q = _current; + if (q == null) return; + + setState(() => _selectAnswer = answer); + ReciteLoading.show(context, '正在提交'); + _submitting = true; + bool ok = false; + try { + ok = await LexiconApi.instance.answerQuestion( + levelId: widget.args.levelId, + userLevelId: _userLevelId, + lexiconId: q.id, + answer: answer, + ); + } catch (_) { + // Vue catch:重置 selectAnswer,允许重新作答 + if (mounted) setState(() => _selectAnswer = ''); + } finally { + _submitting = false; + if (mounted) ReciteLoading.hide(context); + } + if (!ok || !mounted) return; + + setState(() { + _correctAnswer = q.chineseDesc; + _answered.add( + Lexicon( + id: q.id, + english: q.english, + phonetic: q.phonetic, + voiceUrl: q.voiceUrl, + chineseDesc: q.chineseDesc, + isCorrect: answer == q.chineseDesc, + ), + ); + _finishLexiconCount++; + }); + // 答对后自动翻到语境面(Vue:未翻转且可翻 → 翻) + if (_canFlip && _flipCtl.value < 0.5) { + _flipCtl.forward(); + } + + if (_questions.length <= 1) { + _scheduleFinish(); + } else { + _scheduleNextQuestion(); + } + } + + /// 非末题:4s 后清状态 → 瞬时翻回正面 → 换下一题 → 自动发音 + void _scheduleNextQuestion() { + _transitionTimer?.cancel(); + _transitionTimer = Timer(_interval, () { + if (!mounted) return; + setState(() { + _correctAnswer = ''; + _selectAnswer = ''; + if (_finishLexiconCount < _lexiconCount) { + _questions.removeAt(0); + } + }); + _flipCtl.value = 0; // 无动画复位(Vue isAnimation=false 那一套) + _playVoice(isAmerican: false); + }); + } + + /// 末题:4s 后轮询完关 → 取小结 → redirect 小结页 + void _scheduleFinish() { + _transitionTimer?.cancel(); + _transitionTimer = Timer(_interval, () async { + if (!mounted) return; + ReciteLoading.show(context, '加载中...'); + final finished = await _pollingIsFinish(); + if (!mounted) return; + if (!finished) { + ReciteLoading.hide(context); + CenterToast.show(context, '闯关结果获取失败,请重试'); + return; + } + try { + final over = await LexiconApi.instance.getLevelOverInfo(_userLevelId); + if (!mounted) return; + ReciteLoading.hide(context); + // 对标 Vue uni.redirectTo(替换当前栈) + context.pushReplacement( + '/recite/level-over', + extra: LevelOverArgs(info: over, chapterId: widget.args.chapterId), + ); + } catch (_) { + if (mounted) { + ReciteLoading.hide(context); + CenterToast.show(context, '获取闯关结束信息失败'); + } + } + }); + } + + /// 轮询关卡是否完成(加超时保护:最多 30 次 ≈ 6s) + Future _pollingIsFinish() async { + for (var i = 0; i < _maxPollAttempts; i++) { + try { + if (await LexiconApi.instance.getLevelIsFinish(widget.args.levelId)) { + return true; + } + } catch (_) { + // 单次失败继续轮询(对标 Vue finally sleep 后继续) + } + await Future.delayed(_pollInterval); + } + return false; + } + + @override + void dispose() { + _transitionTimer?.cancel(); + _flipCtl.dispose(); + ReciteAudioManager.instance.stopAll(); // 对标 cleanupAllAudio + super.dispose(); + } + + // ================= UI ================= + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: Color(0xFF333333)), + ), + body: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + height: 250.h, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFBAF5D7), Color(0xFFF5F5F5)], + ), + ), + ), + ), + SafeArea( + child: _loading + ? const Center( + child: CircularProgressIndicator(color: Color(0xFF1BC26E)), + ) + : Column( + children: [ + Padding( + padding: EdgeInsets.only(top: 4.h), + child: Text( + _title, + style: TextStyle( + fontSize: 17.sp, + color: Colors.black, + ), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + _isPad ? 40.w : 20.w, + 10.h, + _isPad ? 40.w : 20.w, + 20.h, + ), + child: _current == null + ? SizedBox( + height: 300.h, + child: Center( + child: Text( + '暂无题目', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + ), + ) + : Column( + children: [ + _AnswerProgress( + finishLexiconCount: _finishLexiconCount, + lexiconCount: _lexiconCount, + isPad: _isPad, + ), + SizedBox(height: 23.h), + _QuestionCard( + question: _current!, + selectAnswer: _selectAnswer, + correctAnswer: _correctAnswer, + hasResult: _hasResult, + flipCtl: _flipCtl, + canFlip: _canFlip, + isPad: _isPad, + onToggleFlip: _toggleFlip, + onPlayVoice: (v) => + _playVoice(isAmerican: v), + onSelectAnswer: _onSelectAnswer, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// ============== 顶部答题进度(轨道 + 小鳄鱼 + 移动小猴子) ============== +class _AnswerProgress extends StatelessWidget { + const _AnswerProgress({ + required this.finishLexiconCount, + required this.lexiconCount, + required this.isPad, + }); + + final int finishLexiconCount; + final int lexiconCount; + final bool isPad; + + @override + Widget build(BuildContext context) { + final progress = lexiconCount > 0 + ? (finishLexiconCount / lexiconCount).clamp(0.0, 1.0) + : 0.0; + final trackHeight = isPad ? 20.h : 16.h; + final monkeyWidth = isPad ? 31.5.w : 26.5.w; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 11.w), + child: LayoutBuilder( + builder: (context, constraints) { + final trackWidth = constraints.maxWidth; + final monkeyLeft = trackWidth * progress - monkeyWidth / 2; + return SizedBox( + height: trackHeight + 24.h, + child: Stack( + clipBehavior: Clip.none, + children: [ + // 进度 > 10% 时左侧出现小鳄鱼 + if (progress > 0.1) + Positioned( + left: -7.5.w, + top: -5.h, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-06-03/crocodile1780457600126.png', + ), + width: isPad ? 57.5.w : 49.w, + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + // 轨道 + Positioned( + left: 0, + right: 0, + top: 8.h, + child: SizedBox( + height: trackHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(trackHeight / 2), + child: Stack( + children: [ + Container(color: const Color(0xFFF5F5F5)), + FractionallySizedBox( + widthFactor: progress, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF1BC26E), + Color(0xFF7ED8AA), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + // 随进度移动的小猴子 + Positioned( + left: monkeyLeft, + top: 0, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-02/img_answer_progress.png', + ), + width: monkeyWidth, + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + // 右侧计数 + Positioned( + right: 0, + top: trackHeight + 2.h, + child: Row( + children: [ + Text( + '$finishLexiconCount', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF1BC26E), + ), + ), + Text( + '/$lexiconCount', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +/// 题目卡(单词 + 音标 + 翻转卡 + 选项) +class _QuestionCard extends StatelessWidget { + const _QuestionCard({ + required this.question, + required this.selectAnswer, + required this.correctAnswer, + required this.hasResult, + required this.flipCtl, + required this.canFlip, + required this.isPad, + required this.onToggleFlip, + required this.onPlayVoice, + required this.onSelectAnswer, + }); + + final Question question; + final String selectAnswer; + final String correctAnswer; + final bool hasResult; + final AnimationController flipCtl; + final bool canFlip; + final bool isPad; + final VoidCallback onToggleFlip; + final void Function(bool isAmerican) onPlayVoice; + final ValueChanged onSelectAnswer; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 30.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10.r), + ), + child: Column( + children: [ + // 英/美 单词与音标 + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12.w, + runSpacing: 12.h, + children: [ + _WordColumn( + word: question.english, + phonetic: question.phonetic, + voiceUrl: question.voiceUrl, + tag: '英', + onPlay: () => onPlayVoice(false), + ), + if (question.americanPhonetic.isNotEmpty) + _WordColumn( + word: question.americanEnglish, + phonetic: question.americanPhonetic, + voiceUrl: question.americanVoiceUrl, + tag: '美', + onPlay: () => onPlayVoice(true), + ), + ], + ), + ), + SizedBox(height: 30.h), + // 翻转卡片(540x360rpx;pad 1080x720rpx) + _FlipCard( + question: question, + flipCtl: flipCtl, + canFlip: canFlip, + isPad: isPad, + onToggleFlip: onToggleFlip, + ), + SizedBox(height: 20.h), + // 选项 + Padding( + padding: EdgeInsets.symmetric(horizontal: 32.5.w), + child: Column( + children: [ + for (var i = 0; i < question.options.length; i++) ...[ + if (i > 0) SizedBox(height: 12.h), + _OptionItem( + text: question.options[i], + status: _optionStatus(question.options[i]), + onTap: () => onSelectAnswer(question.options[i]), + ), + ], + ], + ), + ), + ], + ), + ); + } + + /// 0 默认 / 1 正确(绿)/ 2 错选(红) + int _optionStatus(String option) { + if (!hasResult) return 0; + if (option == correctAnswer) return 1; + if (option == selectAnswer) return 2; + return 0; + } +} + +/// 单词 + 音标胶囊(带 4 帧发音按钮) +class _WordColumn extends StatelessWidget { + const _WordColumn({ + required this.word, + required this.phonetic, + required this.voiceUrl, + required this.tag, + required this.onPlay, + }); + + final String word; + final String phonetic; + final String voiceUrl; + final String tag; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + word, + style: TextStyle( + fontSize: 28.sp, + color: const Color(0xFF333333), + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 16.h), + Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(999.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (phonetic.isNotEmpty) ...[ + Text( + tag, + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF666666), + ), + ), + SizedBox(width: 4.w), + Text( + phonetic, + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(width: 6.w), + ], + VoiceAnimButton(url: voiceUrl, size: 27), + ], + ), + ), + ], + ); + } +} + +/// 翻转卡片(正面配图 / 背面语境搭配,0.6s Y 轴翻转) +class _FlipCard extends StatelessWidget { + const _FlipCard({ + required this.question, + required this.flipCtl, + required this.canFlip, + required this.isPad, + required this.onToggleFlip, + }); + + final Question question; + final AnimationController flipCtl; + final bool canFlip; + final bool isPad; + final VoidCallback onToggleFlip; + + @override + Widget build(BuildContext context) { + final width = isPad ? 540.w : 270.w; + final height = isPad ? 360.h : 180.h; + + return AnimatedBuilder( + animation: flipCtl, + builder: (context, _) { + final angle = flipCtl.value * 3.1415926; + final showBack = angle > 3.1415926 / 2; + return SizedBox( + width: width, + height: height, + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: showBack + ? Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..rotateY(3.1415926), + child: _backSide(width, height), + ) + : _frontSide(width, height), + ), + ); + }, + ); + } + + /// 「翻转」小胶囊(正反两面右上角都有) + Widget _flipChip() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleFlip, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 2.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(999.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '翻转', + style: TextStyle(fontSize: 11.sp, color: const Color(0xFF999999)), + ), + SizedBox(width: 4.w), + CachedNetworkImage( + imageUrl: ImgUrl.build('/StaticResource/2026-04-02/flip.png'), + width: 12.w, + height: 12.w, + errorWidget: (_, _, _) => + Icon(Icons.flip, size: 12.w, color: const Color(0xFF999999)), + ), + ], + ), + ), + ); + } + + Widget _frontSide(double width, double height) { + return ClipRRect( + borderRadius: BorderRadius.circular(10.r), + child: Stack( + children: [ + Positioned.fill( + child: CachedNetworkImage( + imageUrl: ImgUrl.build(question.imageDesc), + fit: BoxFit.cover, + placeholder: (_, _) => Container(color: const Color(0xFFF8F8F8)), + errorWidget: (_, _, _) => + Container(color: const Color(0xFFF8F8F8)), + ), + ), + // 仅 contextMatch 非空正面才显示翻转入口 + if (canFlip) Positioned(top: 14.h, right: 18.w, child: _flipChip()), + ], + ), + ); + } + + Widget _backSide(double width, double height) { + return Container( + padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 12.h), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(10.r), + ), + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + '语境搭配', + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF333333), + ), + ), + ], + ), + SizedBox(height: 6.h), + Container( + width: 62.w, + height: 4.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2.r), + gradient: const LinearGradient( + colors: [Color(0x661BC26E), Color(0x667ED8AA)], + ), + ), + ), + SizedBox(height: 14.h), + Expanded( + child: SingleChildScrollView( + child: _HighlightText( + source: question.contextMatch, + keyword: question.english, + ), + ), + ), + ], + ), + Positioned(top: 0, right: 0, child: _flipChip()), + ], + ), + ); + } +} + +/// 语境搭配高亮(对标 r-highlight:单词本身绿色) +class _HighlightText extends StatelessWidget { + const _HighlightText({required this.source, required this.keyword}); + + final String source; + final String keyword; + + @override + Widget build(BuildContext context) { + if (keyword.isEmpty) { + return Text(source, style: _baseStyle); + } + final spans = []; + final pattern = RegExp(RegExp.escape(keyword), caseSensitive: false); + var lastEnd = 0; + for (final m in pattern.allMatches(source)) { + if (m.start > lastEnd) { + spans.add(TextSpan(text: source.substring(lastEnd, m.start))); + } + spans.add( + TextSpan( + text: source.substring(m.start, m.end), + style: const TextStyle( + color: Color(0xFF1BC26E), + fontWeight: FontWeight.w600, + ), + ), + ); + lastEnd = m.end; + } + if (lastEnd < source.length) { + spans.add(TextSpan(text: source.substring(lastEnd))); + } + return Text.rich( + TextSpan(children: spans, style: _baseStyle), + style: const TextStyle(height: 1.5), + ); + } + + TextStyle get _baseStyle => + TextStyle(fontSize: 14.sp, color: const Color(0xFF333333), height: 1.5); +} + +/// 选项胶囊(默认灰 / 正确绿 / 错选红 + 角标) +class _OptionItem extends StatelessWidget { + const _OptionItem({ + required this.text, + required this.status, + required this.onTap, + }); + + final String text; + + /// 0 默认 / 1 正确 / 2 错选 + final int status; + final VoidCallback onTap; + + static const Color _green = Color(0xFF1BC26E); + static const Color _red = Color(0xFFF56245); + + @override + Widget build(BuildContext context) { + final bg = switch (status) { + 1 => _green, + 2 => _red, + _ => const Color(0xFFF5F5F5), + }; + final fg = status == 0 ? const Color(0xFF333333) : Colors.white; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 14.h), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(999.r), + ), + child: Row( + children: [ + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 15.sp, color: fg, height: 1.3), + ), + ), + if (status != 0) + CachedNetworkImage( + imageUrl: ImgUrl.build( + status == 1 + ? '/StaticResource/2026-04-02/checkSmall.png' + : '/StaticResource/2026-04-02/closeSmall.png', + ), + width: 16.w, + height: 16.w, + errorWidget: (_, _, _) => Icon( + status == 1 ? Icons.check_circle : Icons.cancel, + size: 16.w, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/recite/levels_page.dart b/lib/features/recite/levels_page.dart new file mode 100644 index 0000000..e3a5f55 --- /dev/null +++ b/lib/features/recite/levels_page.dart @@ -0,0 +1,506 @@ +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/lexicon/api.dart'; +import 'package:new_learn/core/api/lexicon/model.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/recite/recite_args.dart'; +import 'package:new_learn/features/recite/widgets/recite_progress.dart'; + +/// ============================================================ +/// 【LevelsPage】关卡列表 —— 对标 Vue pages/recitePackage/level/level.vue +/// +/// 入参:extra = Chapter(替代 Vue eventChannel 'getChapter')。 +/// 数据:getLevels(chapterId),头部汇总 = 各关 finishLexiconCount 累加, +/// 总进度 = round(完成数/总词数*100)。 +/// 闯关:未解锁拦截;点击 → /recite/level-start(路由参数替代 storage), +/// 返回后刷新(对标 Vue onShow)。 +/// Vue 端收到 chapter 后 setTimeout 1s 才加载为历史延迟,此处去掉。 +/// phone 纵向卡片 / pad 横向卡片双布局。 +/// ============================================================ +class LevelsPage extends StatefulWidget { + const LevelsPage({super.key, required this.chapter}); + + final Chapter chapter; + + @override + State createState() => _LevelsPageState(); +} + +class _LevelsPageState extends State { + List _list = const []; + String _title = ''; + int _finishLexiconCount = 0; + int _lexiconCount = 0; + + double _navBarOpacity = 0; + bool _loading = false; + + bool get _isPad => + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + @override + void initState() { + super.initState(); + // eventChannel 传来的章节头部先兜底 + _title = widget.chapter.title; + _finishLexiconCount = widget.chapter.finishLexiconCount; + _lexiconCount = widget.chapter.lexiconCount; + _getList(); + } + + Future _getList() async { + setState(() => _loading = true); + try { + final res = await LexiconApi.instance.getLevels(widget.chapter.id); + if (!mounted) return; + var finished = 0; + for (final l in res.levels) { + finished += l.finishLexiconCount; + } + setState(() { + if (res.title.isNotEmpty) _title = res.title; + _list = res.levels; + _lexiconCount = res.lexiconCount; + _finishLexiconCount = finished; + }); + } catch (_) { + // Vue 端无 catch(错误经全局拦截器);保留现状,列表维持空态 + } finally { + if (mounted) setState(() => _loading = false); + } + } + + int get _finishProgress => _lexiconCount <= 0 + ? 0 + : (_finishLexiconCount * 100 / _lexiconCount).round(); + + Future _onClickLevel(Level item) async { + if (!item.isOpen) return; // 对标 Vue:未解锁直接拦截 + await context.push( + '/recite/level-start', + extra: LevelStartArgs(chapterId: widget.chapter.id, levelId: item.id), + ); + if (mounted) _getList(); + } + + @override + Widget build(BuildContext context) { + final hPadding = _isPad ? 40.w : 20.w; + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.white.withValues(alpha: _navBarOpacity), + surfaceTintColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: Text( + '猴鳄冲鸭记单词', + style: TextStyle( + fontSize: 17.sp, + color: const Color(0xFF333333), + fontWeight: FontWeight.w600, + ), + ), + iconTheme: const IconThemeData(color: Color(0xFF333333)), + ), + body: Stack( + children: [ + // 顶部绿色渐变背景(500rpx) + Positioned( + top: 0, + left: 0, + right: 0, + height: 250.h, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFBAF5D7), Color(0xFFF5F5F5)], + ), + ), + ), + ), + SafeArea( + child: NotificationListener( + onNotification: (n) { + final opacity = n.metrics.pixels <= 0 + ? 0.0 + : n.metrics.pixels >= 25 + ? 1.0 + : (n.metrics.pixels / 25).clamp(0, 1).toDouble(); + if (opacity != _navBarOpacity) { + setState(() => _navBarOpacity = opacity); + } + return false; + }, + child: ListView( + padding: EdgeInsets.fromLTRB(hPadding, 60.h, hPadding, 24.h), + children: [ + _buildHeaderCard(), + Padding( + padding: EdgeInsets.only(top: 20.h), + child: Text( + '前面关卡完成后,才可以解锁后面关卡哦~', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ), + if (_loading && _list.isEmpty) + Padding( + padding: EdgeInsets.only(top: 16.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16.w, + height: 16.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF1BC26E), + ), + ), + SizedBox(width: 8.w), + Text( + '努力加载中...', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ), + for (var i = 0; i < _list.length; i++) + _LevelCard( + index: i, + level: _list[i], + isPad: _isPad, + onClick: () => _onClickLevel(_list[i]), + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// 顶部章节大卡(封面叠字 + 标题 + 章节总进度) + Widget _buildHeaderCard() { + final coverSize = _isPad ? 125.w : 120.w; + + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB( + _isPad ? 142.w : 137.w, + 16.h, + 16.w, + 16.h, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints(minHeight: _isPad ? 60.h : 46.h), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _title, + maxLines: 2, + style: TextStyle( + fontSize: 15.sp, + color: const Color(0xFF333333), + ), + ), + ), + ), + SizedBox(height: 8.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + '已完成', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(width: 3.w), + Text( + '$_finishProgress%', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF1BC26E), + ), + ), + ], + ), + Row( + children: [ + Text( + '$_finishLexiconCount', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF1BC26E), + ), + ), + Text( + '/$_lexiconCount', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ], + ), + SizedBox(height: 8.h), + SegmentedProgressBar(progress: _finishProgress.toDouble()), + ], + ), + ), + // 封面(top:-52rpx left:44rpx) + Positioned( + left: 22.w, + top: -26.h, + child: SizedBox( + height: coverSize, + child: CachedNetworkImage( + imageUrl: ImgUrl.build(widget.chapter.cover), + fit: BoxFit.fitHeight, + placeholder: (_, _) => SizedBox(width: 96.w, height: coverSize), + errorWidget: (_, _, _) => + SizedBox(width: 96.w, height: coverSize), + imageBuilder: (_, provider) => Container( + foregroundDecoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.35), + Colors.transparent, + ], + stops: const [0, 0.45], + ), + ), + child: Image(image: provider, fit: BoxFit.fitHeight), + ), + ), + ), + ), + Positioned( + left: 14.w, + top: 10.5.h, + width: 96.w, + child: Text( + _title, + maxLines: 3, + style: TextStyle( + fontSize: _isPad ? 14.sp : 13.sp, + color: Colors.white, + ), + ), + ), + ], + ); + } +} + +/// 单个关卡卡(phone 纵向 / pad 横向) +class _LevelCard extends StatelessWidget { + const _LevelCard({ + required this.index, + required this.level, + required this.isPad, + required this.onClick, + }); + + final int index; + final Level level; + final bool isPad; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final percent = (level.finishProgress * 100).toInt(); + + final titleRow = Row( + children: [ + _NumberBadge(open: level.isOpen, index: index), + SizedBox(width: 4.w), + Expanded( + child: Text( + level.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15.sp, color: const Color(0xFF333333)), + ), + ), + ], + ); + + final countRow = Padding( + padding: EdgeInsets.symmetric(horizontal: 4.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${level.finishLexiconCount}', + style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)), + ), + Text( + '${level.lexiconCount}', + style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)), + ), + ], + ), + ); + + final bar = Padding( + padding: EdgeInsets.symmetric(horizontal: 4.w), + child: GradientProgressBar( + percent: percent.toDouble(), + colors: level.isOpen + ? const [Color(0xFF1BC26E), Color(0xFF7ED8AA)] + : const [Color(0xFFCCCCCC), Color(0xFFCCCCCC)], + ), + ); + + final button = _ChallengeButton(open: level.isOpen, onTap: onClick); + + return Container( + margin: EdgeInsets.only(top: 16.h), + padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 15.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + child: isPad + ? Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleRow, + SizedBox(height: 7.h), + countRow, + SizedBox(height: 6.h), + bar, + ], + ), + ), + SizedBox(width: 65.w), + button, + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: titleRow), + button, + ], + ), + SizedBox(height: 7.h), + countRow, + SizedBox(height: 6.h), + bar, + ], + ), + ); + } +} + +/// 序号圆标:开放绿色数字 / 未开放灰底锁 +class _NumberBadge extends StatelessWidget { + const _NumberBadge({required this.open, required this.index}); + + final bool open; + final int index; + + @override + Widget build(BuildContext context) { + return Container( + width: 18.w, + height: 18.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: open ? const Color(0xFF1BC26E) : const Color(0xFFCCCCCC), + borderRadius: BorderRadius.circular(9.r), + ), + child: open + ? Text( + '${index + 1}', + style: TextStyle(fontSize: 12.sp, color: Colors.white, height: 1), + ) + : CachedNetworkImage( + imageUrl: ImgUrl.build('/StaticResource/2026-04-02/lock.png'), + width: 12.w, + height: 12.w, + fit: BoxFit.contain, + errorWidget: (_, _, _) => + Icon(Icons.lock, size: 12.w, color: Colors.white), + ), + ); + } +} + +/// 「闯关」按钮(110rpx x 60rpx;未解锁置灰禁用) +class _ChallengeButton extends StatelessWidget { + const _ChallengeButton({required this.open, required this.onTap}); + + final bool open; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: open ? onTap : null, + child: Container( + width: 55.w, + height: 30.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: open ? const Color(0xFF1BC26E) : const Color(0xFFCCCCCC), + borderRadius: BorderRadius.circular(999.r), + boxShadow: open + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 0, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + '闯关', + style: TextStyle(fontSize: 13.sp, color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/features/recite/recite_args.dart b/lib/features/recite/recite_args.dart new file mode 100644 index 0000000..d0ed264 --- /dev/null +++ b/lib/features/recite/recite_args.dart @@ -0,0 +1,26 @@ +/// ============================================================ +/// 背诵模块路由参数(GoRouter extra 传参用) +/// +/// 用对象参数替代 Vue 的两套机制: +/// 1. eventChannel.emit('getChapter', chapter) → Chapter 直接 extra +/// 2. uni.setStorageSync('curChapterInfo', ...) → LevelStartArgs 显式传参 +/// ============================================================ +library; + +import 'package:new_learn/core/api/lexicon/model.dart'; + +/// 答题页入参:chapterId(取章节标题/下一章)+ levelId(开局取题) +class LevelStartArgs { + const LevelStartArgs({required this.chapterId, required this.levelId}); + + final int chapterId; + final int levelId; +} + +/// 学习小结入参:小结数据 + 当前章节 id(取章节标题 / 下一关跳转) +class LevelOverArgs { + const LevelOverArgs({required this.info, required this.chapterId}); + + final LevelOverInfo info; + final int chapterId; +} diff --git a/lib/features/recite/recite_page.dart b/lib/features/recite/recite_page.dart index 73fb228..a2c05cf 100644 --- a/lib/features/recite/recite_page.dart +++ b/lib/features/recite/recite_page.dart @@ -1,61 +1,417 @@ +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/lexicon_book/api.dart'; +import 'package:new_learn/core/api/lexicon_book/model.dart'; +import 'package:new_learn/core/components/center_toast.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/features/recite/widgets/recite_progress.dart'; /// ============================================================ -/// 【RecitePage】「背诵」tab 页 —— 空页面占位 +/// 【RecitePage】「背诵」tab 首页 —— 对标 Vue pages/tabbar/recite/recite.vue /// -/// 后续开发计划:这里放背单词的卡片流(正面/背面翻转、记忆曲线等)。 -/// 现在先占位,保证 4 个 tab 都能正常切换。 +/// 一、数据(Vue onShow,Flutter 在 initState + push 返回后刷新): +/// 1. getCurrentLexiconBooks → 当前词书(可能为 null,卡片整体隐藏) +/// 2. getLearnDayCount → 坚持学习天数 +/// 两个接口均静默失败(Vue hideErrorToast),catch 后保留旧值/默认值。 +/// +/// 二、跳转: +/// [切换教材] → /recite/textbooks,返回后重新拉数据(书可能已切换) +/// [开始学习] → /recite/chapters/:bookId;无当前书时拦截(Vue 端此处 +/// 会读 null.id 崩溃,Flutter 迁移时补判空容错) +/// +/// 三、与 Vue 差异说明: +/// Vue 自定义 Tabbar 需手动预留 paddingBottom;Flutter 端底部导航由 +/// StatefulShellRoute 壳提供,body 天然在 tabbar 上方,无需该逻辑。 /// ============================================================ -class RecitePage extends StatelessWidget { +class RecitePage extends StatefulWidget { const RecitePage({super.key}); @override - Widget build(BuildContext context) { - return const _Placeholder( - icon: Icons.auto_stories, - title: '背诵', - subtitle: '功能开发中,敬请期待', - ); - } + State createState() => _RecitePageState(); } -/// 通用占位组件:一个居中图标 + 标题 + 副标题 -class _Placeholder extends StatelessWidget { - const _Placeholder({ - required this.icon, - required this.title, - required this.subtitle, - }); +class _RecitePageState extends State { + BookInfo? _currBook; + int _learnDayCount = 0; - final IconData icon; - final String title; - final String subtitle; + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + // 两个接口互不依赖,并发拉取;均静默失败 + final results = await Future.wait([ + LexiconBookApi.instance.getCurrentBook().catchError((_) => null), + LexiconBookApi.instance.getLearnDayCount().catchError((_) => 0), + ]); + if (!mounted) return; + setState(() { + _currBook = results[0] as BookInfo?; + _learnDayCount = results[1] as int? ?? 0; + }); + } + + /// 切换教材:返回后刷新(对标 Vue onShow) + Future _changeBook() async { + await context.push('/recite/textbooks'); + if (mounted) _load(); + } + + /// 开始学习:进入章节列表 + void _startLearn() { + final book = _currBook; + if (book == null) { + // 容错:Vue 端 currBook 为 null 时点按钮会 null.id 崩溃 + CenterToast.show(context, '请先选择教材'); + return; + } + context.push('/recite/chapters/${book.id}'); + } @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 64.w, color: const Color(0xFFC9CFD8)), - SizedBox(height: 12.h), - Text( - title, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: const Color(0xFF666666), + backgroundColor: const Color(0xFFF0F8FF), + body: Stack( + children: [ + // HeaderBac:顶部主题背景图(fit-width 顶部对齐) + Positioned( + top: 0, + left: 0, + right: 0, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png', + ), + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + // 右上角装饰图(mp: top 164rpx / right 24rpx / width 341rpx) + Positioned( + top: 82.h, + right: 12.w, + child: SafeArea( + bottom: false, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-09/recite-top-img.png', + ), + width: 170.w, + fit: BoxFit.fitWidth, + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), - SizedBox(height: 6.h), + ), + // 主体内容 + SafeArea( + bottom: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(25.w, 88.h, 25.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLearnDay(), + if (_currBook != null) ...[ + SizedBox(height: 23.h), + _CurrBookCard(book: _currBook!, onChangeBook: _changeBook), + ], + SizedBox(height: 24.h), + _StartButton(onTap: _startLearn), + ], + ), + ), + ), + ], + ), + ); + } + + /// 坚持学习天数 + Widget _buildLearnDay() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ Text( - subtitle, - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)), + '$_learnDayCount', + style: TextStyle( + fontSize: 50.sp, + height: 1, + color: const Color(0xFF7DAAFF), + ), + ), + Padding( + padding: EdgeInsets.only(left: 10.w, bottom: 4.h), + child: Text( + '天', + style: TextStyle( + fontSize: 20.sp, + color: const Color(0xFF66A6FF), + ), + ), ), ], ), + SizedBox(height: 4.h), + Padding( + padding: EdgeInsets.only(left: 2.w), + child: Text( + '坚持学习天数', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF495A7A)), + ), + ), + ], + ); + } +} + +/// 当前词书卡片(海报 + slogan + 切换按钮 + 封面 + 进度) +class _CurrBookCard extends StatelessWidget { + const _CurrBookCard({required this.book, required this.onChangeBook}); + + final BookInfo book; + final VoidCallback onChangeBook; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.r), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 海报 + slogan 遮罩 + 切换按钮 + Stack( + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build(book.poster), + width: double.infinity, + fit: BoxFit.fitWidth, + placeholder: (_, _) => Container(height: 120.h), + errorWidget: (_, _, _) => Container(height: 120.h), + ), + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.4), + ), + ), + Padding( + padding: EdgeInsets.only( + top: 127.h, + left: 22.w, + right: 6.w, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '“', + style: TextStyle( + fontSize: 32.sp, + color: Colors.white, + ), + ), + SizedBox(width: 4.w), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: 15.h), + child: Text( + book.slogan, + softWrap: true, + style: TextStyle( + fontSize: 15.sp, + color: Colors.white, + height: 1.4, + ), + ), + ), + ), + ], + ), + ), + // 切换教材角标按钮 + Positioned( + right: 0, + bottom: 0, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onChangeBook, + child: Container( + width: 95.w, + height: 30.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15.r), + ), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF8AD1FD), + Color(0xFF3C84FF), + Color(0xFF3D7DF5), + ], + stops: [0, 0.64, 1], + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '切换教材', + style: TextStyle( + fontSize: 12.sp, + color: Colors.white, + ), + ), + Icon( + Icons.chevron_right, + size: 14.w, + color: Colors.white, + ), + ], + ), + ), + ), + ), + ], + ), + // 书籍信息(左侧留出封面位置:277rpx) + Padding( + padding: EdgeInsets.fromLTRB(138.5.w, 20.h, 13.5.w, 37.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.sp, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 16.5.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + '进度', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(width: 5.w), + Text( + '${book.progressPercent}%', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF3D7DF5), + ), + ), + ], + ), + Row( + children: [ + Text( + '${book.lexiconCount}词', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF3D7DF5), + ), + ), + Text( + ' / ${book.totalCount}词', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ], + ), + SizedBox(height: 8.5.h), + GradientProgressBar( + percent: book.progressPercent.toDouble(), + ), + ], + ), + ), + ], + ), + // 左下封面(mp: width 230rpx / bottom 50rpx / left 24rpx) + Positioned( + left: 12.w, + bottom: 25.h, + child: CachedNetworkImage( + imageUrl: ImgUrl.build(book.cover), + width: 115.w, + fit: BoxFit.fitWidth, + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + ], + ), + ); + } +} + +/// 「开始学习」大按钮(mp: height 96rpx / 圆角 999) +class _StartButton extends StatelessWidget { + const _StartButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: double.infinity, + height: 48.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999.r), + gradient: const LinearGradient( + colors: [Color(0xFF5A9EFF), Color(0xFF3C7CF6)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF3D7DF5).withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + alignment: Alignment.center, + child: Text( + '开始学习', + style: TextStyle( + fontSize: 17.sp, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), ), ); } diff --git a/lib/features/recite/textbook_list_page.dart b/lib/features/recite/textbook_list_page.dart new file mode 100644 index 0000000..e2c8ddc --- /dev/null +++ b/lib/features/recite/textbook_list_page.dart @@ -0,0 +1,510 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/api/lexicon_book/api.dart'; +import 'package:new_learn/core/api/lexicon_book/model.dart'; +import 'package:new_learn/core/components/center_toast.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/recite/widgets/recite_loading.dart'; +import 'package:new_learn/features/recite/widgets/recite_progress.dart'; + +/// ============================================================ +/// 【TextbookListPage】教材列表 —— 对标 Vue pages/recitePackage/textbookList +/// +/// 一、数据: +/// 1. getCurrentLexiconBooks 顶部「当前学习」卡(静默失败) +/// 2. getAllLexiconBooks 我的书库列表(失败 toast「页面加载失败,请重试」) +/// +/// 二、切换教材 handleStudy(容错逻辑保留): +/// showLoading「切换中...」(mask) → POST startLearn → 成功刷新两列表 +/// → finally hideLoading。 +/// +/// 三、NavBar:随滚动 0→25px 从透明渐变到纯白(对标 updateNavBarBgColor)。 +/// ============================================================ +class TextbookListPage extends StatefulWidget { + const TextbookListPage({super.key}); + + @override + State createState() => _TextbookListPageState(); +} + +class _TextbookListPageState extends State { + BookInfo? _currBook; + List _bookList = const []; + + /// AppBar 白色不透明度 0~1 + double _navBarOpacity = 0; + + bool get _isPad => + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + @override + void initState() { + super.initState(); + _getCurrBook(); + _getList(); + } + + Future _getCurrBook() async { + try { + final res = await LexiconBookApi.instance.getCurrentBook(); + if (mounted) setState(() => _currBook = res); + } catch (_) { + // Vue:静默 catch + } + } + + Future _getList() async { + try { + final res = await LexiconBookApi.instance.getAllBooks(); + if (mounted) setState(() => _bookList = res); + } catch (_) { + if (mounted) { + CenterToast.show( + context, + '页面加载失败,请重试', + duration: const Duration(milliseconds: 2000), + ); + } + } + } + + /// 切换教材(对标 handleStudy) + Future _handleStudy(BookInfo item) async { + ReciteLoading.show(context, '切换中...'); + try { + final ok = await LexiconBookApi.instance.startLearn(item.id); + if (ok) { + await Future.wait([_getList(), _getCurrBook()]); + } + } catch (_) { + // DioClient 已统一记录错误;此处保持 loading 必定关闭即可 + } finally { + if (mounted) ReciteLoading.hide(context); + } + } + + /// 滚动联动 NavBar 底色(0~25px 线性渐变) + void _onScroll(double offset) { + final opacity = offset <= 0 + ? 0.0 + : offset >= 25 + ? 1.0 + : (offset / 25).clamp(0, 1).toDouble(); + if (opacity != _navBarOpacity && mounted) { + setState(() => _navBarOpacity = opacity); + } + } + + @override + Widget build(BuildContext context) { + final hPadding = _isPad ? 40.w : 12.5.w; + + return Scaffold( + backgroundColor: const Color(0xFFF5F8FA), + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.white.withValues(alpha: _navBarOpacity), + surfaceTintColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: Text( + '教材列表', + style: TextStyle( + fontSize: 17.sp, + color: const Color(0xFF1D2B47), + fontWeight: FontWeight.w600, + ), + ), + iconTheme: const IconThemeData(color: Color(0xFF1D2B47)), + ), + body: NotificationListener( + onNotification: (n) { + _onScroll(n.metrics.pixels); + return false; + }, + child: SingleChildScrollView( + child: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 顶部背景图卡片 + // —— 与 Vue bg-img 对齐:背景图 width:100%, height:auto, position:top + // "当前学习" + "助力英语..." 同一行两端对齐,书卡叠在图上 + Container( + width: double.infinity, + margin: EdgeInsets.fromLTRB(hPadding, 20.h, hPadding, 0), + decoration: BoxDecoration( + image: DecorationImage( + image: CachedNetworkImageProvider( + ImgUrl.build( + _isPad + ? '/StaticResource/2026-06-03/textbookList-top-bg-pad1780469931684.png' + : '/StaticResource/2026-04-09/textbookList-top-bg.png', + ), + ), + alignment: Alignment.topCenter, + fit: BoxFit.fitWidth, + ), + borderRadius: BorderRadius.circular(16.r), + ), + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 17.h, 16.w, 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 顶部标题行:左"当前学习" + 右"助力英语..." + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _SectionTitle(title: '当前学习'), + const Spacer(), + Padding( + padding: EdgeInsets.only(bottom: 6.h), + child: Text( + '助力英语全方位提升!', + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF687FAD), + ), + ), + ), + ], + ), + if (_currBook != null) ...[ + SizedBox(height: 20.h), + _CurrentBook(book: _currBook!), + ], + ], + ), + ), + ), + // 我的书库 + Padding( + padding: EdgeInsets.fromLTRB(hPadding, 12.h, hPadding, 24.h), + child: Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB(16.w, 19.h, 16.w, 0.5.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(title: '我的书库'), + SizedBox(height: 19.h), + ..._bookList.map( + (book) => + _BookItem(book: book, onStudy: _handleStudy), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 标题 + 底部渐变短线(study-title / library-title) +/// +/// 用 IntrinsicWidth 包裹:组件宽度 shrink-wrap 到标题文字宽度, +/// 渐变线 width:infinity 即等于文字宽度——1:1 对应 Vue 里 +/// .text-line(absolute, width:100%)+ relative 父容器(shrink-wrap)。 +/// 同时保证本组件放进 Row(无界水平约束)时不会因 infinity 宽度布局崩溃。 +class _SectionTitle extends StatelessWidget { + const _SectionTitle({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 3.h), + Container( + height: 4.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999.r), + gradient: const LinearGradient( + colors: [ + Color(0x003D7DF5), + Color(0x331D9CFF), + Color(0x808AECFD), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// 顶部当前学习书卡 +class _CurrentBook extends StatelessWidget { + const _CurrentBook({required this.book}); + + final BookInfo book; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build(book.cover), + width: 95.w, + fit: BoxFit.fitWidth, + placeholder: (_, _) => SizedBox(width: 95.w, height: 112.h), + errorWidget: (_, _, _) => SizedBox(width: 95.w, height: 112.h), + ), + SizedBox(width: 17.5.w), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: 2.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.sp, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 29.h - 2.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + '进度', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + Text( + '${book.progressPercent}%', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF3D7DF5), + ), + ), + ], + ), + Row( + children: [ + Text( + '${book.lexiconCount}词', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF3D7DF5), + ), + ), + Text( + ' / ${book.totalCount}词', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ], + ), + SizedBox(height: 8.5.h), + GradientProgressBar(percent: book.progressPercent.toDouble()), + ], + ), + ), + ), + ], + ); + } +} + +/// 书库列表项(封面浮在左侧渐变内容卡上 + 状态角标 + 学习按钮) +class _BookItem extends StatelessWidget { + const _BookItem({required this.book, required this.onStudy}); + + final BookInfo book; + final ValueChanged onStudy; + + @override + Widget build(BuildContext context) { + final completed = + book.lexiconCount >= book.totalCount && book.totalCount > 0; + final notStarted = book.lexiconCount == 0; + final buttonText = notStarted + ? '学习此书' + : completed + ? '已学完' + : '继续学习'; + + return Container( + height: 100.h, + margin: EdgeInsets.only(bottom: 25.h), + child: Stack( + children: [ + // 渐变内容底卡(左留 97 给封面) + Positioned.fill( + child: Container( + padding: EdgeInsets.fromLTRB(97.w, 13.h, 10.w, 10.h), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFEDF7FF), Colors.white], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 13.5.h), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '进度${book.lexiconCount}词/${book.totalCount}词', + style: TextStyle( + fontSize: 10.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 8.h), + GradientProgressBar( + percent: book.progressPercent.toDouble(), + ), + ], + ), + ), + SizedBox(width: 30.w), + _StudyButton( + text: buttonText, + disabled: completed, + onTap: () => onStudy(book), + ), + ], + ), + ], + ), + ), + ), + // 左侧浮起封面(170rpx x 200rpx) + Positioned( + left: 0, + top: 0, + child: CachedNetworkImage( + imageUrl: ImgUrl.build(book.cover), + width: 85.w, + height: 100.h, + fit: BoxFit.fill, + placeholder: (_, _) => SizedBox(width: 85.w, height: 100.h), + errorWidget: (_, _, _) => SizedBox(width: 85.w, height: 100.h), + ), + ), + // 右上角状态标签 + if (book.isStudying || completed) + Positioned( + top: 0, + right: 0, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8.5.w, vertical: 2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(5.r), + bottomLeft: Radius.circular(5.r), + ), + gradient: LinearGradient( + colors: book.isStudying + ? const [Color(0xFF7ED8AA), Color(0xFF1BC26E)] + : const [Color(0xFFF5963D), Color(0xFFFFD769)], + ), + ), + child: Text( + book.isStudying ? '学习中' : '已学完', + style: TextStyle(fontSize: 10.sp, color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} + +/// 「学习此书 / 继续学习 / 已学完」胶囊按钮 +class _StudyButton extends StatelessWidget { + const _StudyButton({ + required this.text, + required this.disabled, + required this.onTap, + }); + + final String text; + final bool disabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = disabled ? const Color(0xFFCCCCCC) : const Color(0xFF3D7DF5); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: disabled ? null : onTap, + child: Container( + width: 75.w, + height: 30.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(999.r), + ), + child: Text( + text, + style: TextStyle( + fontSize: 12.sp, + color: Colors.white, + decoration: TextDecoration.none, + ), + ), + ), + ); + } +} diff --git a/lib/features/recite/widgets/recite_audio.dart b/lib/features/recite/widgets/recite_audio.dart new file mode 100644 index 0000000..570652e --- /dev/null +++ b/lib/features/recite/widgets/recite_audio.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; + +/// ============================================================ +/// 【ReciteAudioManager】背诵模块单例音频管理 +/// —— 对标 Vue utils/audioManager +/// +/// 规则(与 Vue 完全一致): +/// 1. 全局同时只播放一个音频:新 play 会先 stop 旧的; +/// 2. hasActive 为 true 时页面层直接忽略点击; +/// 3. 播放结束/失败/取消都清空 playingToken(驱动 4 帧动画停在第 1 帧); +/// 4. 离开答题/小结页调 stopAll() 释放(对标 cleanupAllAudio)。 +/// ============================================================ +class ReciteAudioManager { + ReciteAudioManager._() { + _playerSub = _player.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + playingToken.value = null; + currentUrl = null; + } + }); + } + + static final ReciteAudioManager instance = ReciteAudioManager._(); + + final AudioPlayer _player = AudioPlayer(); + StreamSubscription? _playerSub; + + /// 当前播放令牌(自增),null = 无播放;4 帧按钮据此判断是否该动 + final ValueNotifier playingToken = ValueNotifier(null); + + /// 当前播放的音频 URL,null = 无播放 + /// —— VoiceAnimButton 用它判断"当前播的是不是我的音频", + /// 比 token 归属更可靠(自动播放 / 手动播放 / 跨组件触发都能联动)。 + String? currentUrl; + + int _seq = 0; + + bool get hasActive => playingToken.value != null; + + /// 播放网络音频,返回本次播放令牌(失败返回 null) + Future play(String url) async { + if (url.isEmpty) return null; + final token = ++_seq; + try { + await _player.stop(); + currentUrl = url; + playingToken.value = token; + await _player.setUrl(url); + await _player.play(); + return token; + } catch (e) { + talker.error('背诵音频播放失败: $e'); + if (playingToken.value == token) { + playingToken.value = null; + currentUrl = null; + } + return null; + } + } + + /// 停止并清空播放态(页面 dispose 时必须调) + Future stopAll() async { + await _player.stop(); + playingToken.value = null; + currentUrl = null; + } + + void dispose() { + _playerSub?.cancel(); + _player.dispose(); + } +} diff --git a/lib/features/recite/widgets/recite_circle_progress.dart b/lib/features/recite/widgets/recite_circle_progress.dart new file mode 100644 index 0000000..29eb9cd --- /dev/null +++ b/lib/features/recite/widgets/recite_circle_progress.dart @@ -0,0 +1,108 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// ============================================================ +/// 【ReciteCircleProgress】圆环进度 —— 对标 Vue l-circle(canvas) +/// +/// 用在: +/// 1. 章节页右上角「总进度」(顺时针,绿色 #1BC26E,底 #EBEBEB) +/// 2. 学习小结正确率(Vue :clockwise="false",逆时针绘制,视觉对称) +/// +/// percent:0~100;中间内容由 child 自定义。 +/// ============================================================ +class ReciteCircleProgress extends StatelessWidget { + const ReciteCircleProgress({ + super.key, + required this.percent, + required this.size, + this.strokeWidth = 4, + this.color = const Color(0xFF1BC26E), + this.trailColor = const Color(0xFFEBEBEB), + this.clockwise = true, + this.child, + }); + + final double percent; + final double size; // 直径 + final double strokeWidth; + final Color color; + final Color trailColor; + + /// false = 逆时针起笔(对标 l-circle :clockwise="false") + final bool clockwise; + final Widget? child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size.w, + height: size.w, + child: CustomPaint( + painter: _CirclePainter( + percent: percent.clamp(0, 100).toDouble(), + strokeWidth: strokeWidth.w, + color: color, + trailColor: trailColor, + clockwise: clockwise, + ), + child: Center(child: child), + ), + ); + } +} + +class _CirclePainter extends CustomPainter { + _CirclePainter({ + required this.percent, + required this.strokeWidth, + required this.color, + required this.trailColor, + required this.clockwise, + }); + + final double percent; + final double strokeWidth; + final Color color; + final Color trailColor; + final bool clockwise; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + final trackPaint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..color = trailColor; + + final progressPaint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..color = color; + + canvas.drawCircle(center, radius, trackPaint); + + const start = -math.pi / 2; // 12 点方向 + final sweep = 2 * math.pi * percent / 100; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + clockwise ? start : start - sweep, + sweep, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant _CirclePainter old) => + old.percent != percent || + old.strokeWidth != strokeWidth || + old.color != color || + old.clockwise != clockwise; +} diff --git a/lib/features/recite/widgets/recite_loading.dart b/lib/features/recite/widgets/recite_loading.dart new file mode 100644 index 0000000..3ec4c08 --- /dev/null +++ b/lib/features/recite/widgets/recite_loading.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// ============================================================ +/// 【ReciteLoading】模态 loading —— 对标 uni.showLoading({mask:true}) +/// +/// 用于「切换中...」「正在提交」「加载中...」: +/// ReciteLoading.show(context, '切换中...'); +/// ...await... +/// if (mounted) ReciteLoading.hide(context); +/// +/// 用根 Navigator 弹出,可盖在底部 tabbar 之上;barrier 拦截所有点击。 +/// ============================================================ +class ReciteLoading { + ReciteLoading._(); + + static void show(BuildContext context, String text) { + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (_) => _ReciteLoadingView(text: text), + ); + } + + static void hide(BuildContext context) { + final nav = Navigator.of(context, rootNavigator: true); + if (nav.canPop()) nav.pop(); + } +} + +class _ReciteLoadingView extends StatelessWidget { + const _ReciteLoadingView({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 20.h), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + SizedBox(height: 12.h), + Text( + text, + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + decoration: TextDecoration.none, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/recite/widgets/recite_progress.dart b/lib/features/recite/widgets/recite_progress.dart new file mode 100644 index 0000000..95ee87e --- /dev/null +++ b/lib/features/recite/widgets/recite_progress.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// ============================================================ +/// 背诵模块进度条组件 +/// +/// 1. GradientProgressBar —— 对标 Vue r-progress(linear-gradient 填充) +/// 词书卡蓝色渐变 / 关卡绿色渐变 / 未解锁灰色,全靠 colors 切换。 +/// +/// 2. SegmentedProgressBar —— 对标 Vue SegmentedProgressBar.vue +/// 三段独立进度,每段各自按 33/33/34 比例填充,绿色渐变。 +/// ============================================================ + +/// 渐变线性进度条(percent:0~100) +class GradientProgressBar extends StatelessWidget { + const GradientProgressBar({ + super.key, + required this.percent, + this.height = 6, + this.colors = const [Color(0xFF84AFFF), Color(0xFF3C7CF6)], + this.trackColor = const Color(0xFFF5F5F5), + this.radius, + }); + + /// 0~100(自动 clamp) + final double percent; + final double height; + + /// 填充渐变(单色时传同色两个即可) + final List colors; + final Color trackColor; + final double? radius; + + @override + Widget build(BuildContext context) { + final safe = percent.clamp(0, 100).toDouble(); + return ClipRRect( + borderRadius: BorderRadius.circular(radius ?? (height / 2).r), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return Container( + height: height.w, + color: trackColor, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: width * safe / 100, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: colors, + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +/// 三段式分段进度条(对标 SegmentedProgressBar.vue;progress:0~100) +class SegmentedProgressBar extends StatelessWidget { + const SegmentedProgressBar({super.key, required this.progress}); + + final double progress; + + static const Color _green1 = Color(0xFF1BC26E); + static const Color _green2 = Color(0xFF7ED8AA); + + /// 与 Vue updateBarProgress 完全一致的分段算法 + List _segmentPercents(double p) { + double bar1; + if (p >= 33) { + bar1 = 100; + } else { + bar1 = (p / 33 * 100).clamp(0, 100).toDouble(); + } + + double bar2; + if (p >= 66) { + bar2 = 100; + } else if (p > 33) { + bar2 = ((p - 33) / 33 * 100).clamp(0, 100).toDouble(); + } else { + bar2 = 0; + } + + double bar3; + if (p >= 100) { + bar3 = 100; + } else if (p > 66) { + bar3 = ((p - 66) / 34 * 100).clamp(0, 100).toDouble(); + } else { + bar3 = 0; + } + return [bar1, bar2, bar3]; + } + + @override + Widget build(BuildContext context) { + final segs = _segmentPercents(progress); + return Row( + children: [ + for (var i = 0; i < 3; i++) ...[ + if (i > 0) SizedBox(width: 5.w), + Expanded( + child: GradientProgressBar( + percent: segs[i], + height: 6, + colors: const [_green1, _green2], + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/recite/widgets/voice_anim_button.dart b/lib/features/recite/widgets/voice_anim_button.dart new file mode 100644 index 0000000..cb44cf6 --- /dev/null +++ b/lib/features/recite/widgets/voice_anim_button.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/features/recite/widgets/recite_audio.dart'; + +/// ============================================================ +/// 【VoiceAnimButton】发音 4 帧动画按钮 +/// —— 1:1 对标 Vue components/FrameAnimation(:fps="5" :loop="true") +/// +/// 帧图内容(绿色从无到有递进): +/// _frames[0] = icon_voice_2.png 全灰(0% 绿) +/// _frames[1] = icon_voice_3.png 仅小圆点绿(~15%) +/// _frames[2] = icon_voice_4.png 圆点+内弧绿(~55%) +/// _frames[3] = icon_voice_1.png 全绿(100%)—— 静止帧 +/// +/// 行为: +/// - 未播放时显示全绿静止帧(icon_voice_1.png); +/// - 只要 ReciteAudioManager 正在播的是当前按钮的 url,就播动画 +/// (自动播放 / 手动点击 / 跨组件触发都能联动,不依赖谁调用了 play); +/// - 动画从全灰帧起步,5fps(200ms/帧)循环: +/// 灰 → 少绿 → 中绿 → 全绿 → 灰 → ... +/// - 点击时若无播放,则触发播放;已有播放则忽略; +/// - 播放结束 / 失败 / 被顶掉 → 回到全绿静止帧。 +/// ============================================================ +class VoiceAnimButton extends StatefulWidget { + const VoiceAnimButton({super.key, required this.url, this.size = 27}); + + /// 网络音频地址(空串时点击无效) + final String url; + + /// 按钮边长(答题页 27px、小结页 18px) + final double size; + + @override + State createState() => _VoiceAnimButtonState(); +} + +class _VoiceAnimButtonState extends State { + /// 4 帧序列,按"灰→少绿→中绿→全绿"递增排列 + static final List _frames = [ + ImgUrl.build('/StaticResource/2026-04-02/icon_voice_2.png'), // 全灰 + ImgUrl.build('/StaticResource/2026-04-02/icon_voice_3.png'), // 仅圆点绿 + ImgUrl.build('/StaticResource/2026-04-02/icon_voice_4.png'), // 圆点+内弧绿 + ImgUrl.build('/StaticResource/2026-04-02/icon_voice_1.png'), // 全绿 + ]; + + /// 静止帧索引(全绿 = icon_voice_1.png) + static const int _restIndex = 3; + + /// 5fps = 200ms 切一帧 + static const Duration _frameInterval = Duration(milliseconds: 200); + + Timer? _timer; + int _frameIndex = _restIndex; + + @override + void initState() { + super.initState(); + // 全局播放 token 变化 → 同步本按钮动画启停 + // (用 token 作为"播放状态变化"的信号,用 currentUrl 判断是不是我的音频) + ReciteAudioManager.instance.playingToken.addListener(_onPlayingChanged); + // 初始化时如果正好在播我的 url,直接启动画 + if (_isMinePlaying) _startAnim(); + } + + /// 当前播的是不是我的音频 + bool get _isMinePlaying { + final mgr = ReciteAudioManager.instance; + return mgr.playingToken.value != null && + mgr.currentUrl == widget.url && + widget.url.isNotEmpty; + } + + void _onPlayingChanged() { + if (_isMinePlaying) { + _startAnim(); + } else { + _stopAnim(); + } + } + + void _startAnim() { + if (_timer?.isActive ?? false) return; + _frameIndex = 0; // 全灰起步 + if (mounted) setState(() {}); + _timer = Timer.periodic(_frameInterval, (_) { + if (!mounted) return; + setState(() => _frameIndex = (_frameIndex + 1) % 4); + }); + } + + void _stopAnim() { + if (_timer == null) return; + _timer?.cancel(); + _timer = null; + _frameIndex = _restIndex; + if (mounted) setState(() {}); + } + + Future _onTap() async { + // 对标 Vue:if (audioManager.hasActiveAudio()) return; + if (ReciteAudioManager.instance.hasActive) return; + if (widget.url.isEmpty) return; + await ReciteAudioManager.instance.play(widget.url); + // 动画启停由 _onPlayingChanged 统一处理 + } + + @override + void dispose() { + _timer?.cancel(); + ReciteAudioManager.instance.playingToken.removeListener(_onPlayingChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onTap, + child: CachedNetworkImage( + imageUrl: _frames[_frameIndex], + width: widget.size.w, + height: widget.size.w, + fit: BoxFit.contain, + placeholder: (_, _) => + SizedBox(width: widget.size.w, height: widget.size.w), + errorWidget: (_, _, _) => + SizedBox(width: widget.size.w, height: widget.size.w), + ), + ); + } +}