Compare commits
2
Commits
fe86b6d824
...
64060831c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64060831c8 | ||
|
|
27eba92315 |
@@ -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<String, dynamic> && data['data'] != null
|
||||
? data['data']
|
||||
: data;
|
||||
|
||||
/// 章节列表(model 内部兼容响应壳)
|
||||
Future<ChapterResponse> getChapters(int bookId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Lexicon/GetChapters/$bookId',
|
||||
);
|
||||
return ChapterResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 关卡列表
|
||||
Future<LevelResponse> getLevels(int chapterId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Lexicon/GetLevels/$chapterId',
|
||||
);
|
||||
return LevelResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 开局取题
|
||||
Future<LevelStartInfo> levelStart(int levelId) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/Lexicon/LevelStart/$levelId',
|
||||
);
|
||||
return LevelStartInfo.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 提交答案(返回值与开局信息同构,但页面只关心成功与否)
|
||||
Future<bool> 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<bool> getLevelIsFinish(int levelId) async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/Lexicon/GetLevelIsFinish/$levelId'),
|
||||
);
|
||||
return data == true;
|
||||
}
|
||||
|
||||
/// 闯关小结
|
||||
Future<LevelOverInfo> getLevelOverInfo(int userLevelId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Lexicon/GetLevelOverInfo/$userLevelId',
|
||||
);
|
||||
return LevelOverInfo.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<Chapter> chapters;
|
||||
final double totalFinishProgress; // 0~1
|
||||
|
||||
factory ChapterResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return ChapterResponse(
|
||||
chapters: (payload['chapters'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<String, dynamic> 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<Level> levels;
|
||||
|
||||
factory LevelResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? 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<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<String> options;
|
||||
|
||||
factory Question.fromJson(Map<String, dynamic> 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<dynamic>? ?? 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<String, dynamic> 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<Question> questions;
|
||||
final List<Lexicon> lexicons;
|
||||
|
||||
factory LevelStartInfo.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? 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<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Question.fromJson)
|
||||
.toList(),
|
||||
lexicons: (payload['lexicons'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<Lexicon> lexicons;
|
||||
|
||||
/// 正确率(0~100,四舍五入;无单词兜底 0)
|
||||
int get rate =>
|
||||
lexiconCount <= 0 ? 0 : (correctCount * 100 / lexiconCount).round();
|
||||
|
||||
factory LevelOverInfo.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? 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<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Lexicon.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> && data['data'] != null
|
||||
? data['data']
|
||||
: data;
|
||||
|
||||
/// 当前学习的词书(未选择时后端可能返回 null)
|
||||
Future<BookInfo?> getCurrentBook() async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/LexiconBook/GetCurrentLexiconBooks'),
|
||||
);
|
||||
if (data is Map<String, dynamic>) return BookInfo.fromJson(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 坚持学习天数
|
||||
Future<int> 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<List<BookInfo>> getAllBooks() async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/LexiconBook/GetAllLexiconBooks'),
|
||||
);
|
||||
if (data is List) {
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(BookInfo.fromJson)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// 切换 / 开始学习某书
|
||||
Future<bool> startLearn(int bookId) async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.post(
|
||||
'/api/v1/LexiconBook/StartLearn/$bookId',
|
||||
),
|
||||
);
|
||||
return data == true;
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<ChaptersPage> createState() => _ChaptersPageState();
|
||||
}
|
||||
|
||||
class _ChaptersPageState extends State<ChaptersPage> {
|
||||
final List<Chapter> _list = [];
|
||||
double _rate = 0;
|
||||
bool _loading = false;
|
||||
|
||||
bool get _isPad =>
|
||||
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_getList();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<Chapter> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LevelOverPage> createState() => _LevelOverPageState();
|
||||
}
|
||||
|
||||
class _LevelOverPageState extends State<LevelOverPage> {
|
||||
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<void> _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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LevelStartPage> createState() => _LevelStartPageState();
|
||||
}
|
||||
|
||||
class _LevelStartPageState extends State<LevelStartPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _title = '';
|
||||
final List<Question> _questions = [];
|
||||
final List<Lexicon> _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<void> _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<void> _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<bool> _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<void>.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<String> 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 = <TextSpan>[];
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LevelsPage> createState() => _LevelsPageState();
|
||||
}
|
||||
|
||||
class _LevelsPageState extends State<LevelsPage> {
|
||||
List<Level> _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<void> _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<void> _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<ScrollNotification>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RecitePage> createState() => _RecitePageState();
|
||||
}
|
||||
|
||||
/// 通用占位组件:一个居中图标 + 标题 + 副标题
|
||||
class _Placeholder extends StatelessWidget {
|
||||
const _Placeholder({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
class _RecitePageState extends State<RecitePage> {
|
||||
BookInfo? _currBook;
|
||||
int _learnDayCount = 0;
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
// 两个接口互不依赖,并发拉取;均静默失败
|
||||
final results = await Future.wait<dynamic>([
|
||||
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<void> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TextbookListPage> createState() => _TextbookListPageState();
|
||||
}
|
||||
|
||||
class _TextbookListPageState extends State<TextbookListPage> {
|
||||
BookInfo? _currBook;
|
||||
List<BookInfo> _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<void> _getCurrBook() async {
|
||||
try {
|
||||
final res = await LexiconBookApi.instance.getCurrentBook();
|
||||
if (mounted) setState(() => _currBook = res);
|
||||
} catch (_) {
|
||||
// Vue:静默 catch
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<ScrollNotification>(
|
||||
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<BookInfo> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerState>? _playerSub;
|
||||
|
||||
/// 当前播放令牌(自增),null = 无播放;4 帧按钮据此判断是否该动
|
||||
final ValueNotifier<int?> playingToken = ValueNotifier<int?>(null);
|
||||
|
||||
/// 当前播放的音频 URL,null = 无播放
|
||||
/// —— VoiceAnimButton 用它判断"当前播的是不是我的音频",
|
||||
/// 比 token 归属更可靠(自动播放 / 手动播放 / 跨组件触发都能联动)。
|
||||
String? currentUrl;
|
||||
|
||||
int _seq = 0;
|
||||
|
||||
bool get hasActive => playingToken.value != null;
|
||||
|
||||
/// 播放网络音频,返回本次播放令牌(失败返回 null)
|
||||
Future<int?> 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<void> stopAll() async {
|
||||
await _player.stop();
|
||||
playingToken.value = null;
|
||||
currentUrl = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_playerSub?.cancel();
|
||||
_player.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>(
|
||||
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<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Color> 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<double> _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],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<VoiceAnimButton> createState() => _VoiceAnimButtonState();
|
||||
}
|
||||
|
||||
class _VoiceAnimButtonState extends State<VoiceAnimButton> {
|
||||
/// 4 帧序列,按"灰→少绿→中绿→全绿"递增排列
|
||||
static final List<String> _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<void> _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user