Files
English/kouyu_english/lib/features/progress/progress_pages.dart
T
shenleiandClaude Opus 5 7e29c5449f feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动
阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:38:23 +09:00

710 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/assessment_bank.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import '../../core/sync/sync_coordinator.dart';
import 'sync_settings_sheet.dart';
class ProgressPage extends StatelessWidget {
const ProgressPage({
super.key,
required this.state,
required this.onSettings,
required this.onOpenAssessment,
});
final AppState state;
final VoidCallback onSettings;
final ValueChanged<AssessmentPack> onOpenAssessment;
@override
Widget build(BuildContext context) {
final recall = state.mastery.values
.where((item) => item.status == MasteryStatus.recall)
.length;
final recentEvidence = state.attemptEvidence.reversed.take(5).toList();
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('当前:A0 起步'),
Text('进度来自掌握证据。', style: Theme.of(context).textTheme.headlineMedium),
const Text('不是上完固定课数就升级。核心表达需要在不同时间、不同情境中独立用出。'),
_AbilityRow(
icon: Icons.psychology_outlined,
title: '已接触',
note: '${state.knownItemCount} 个词句或任务',
completed: state.knownItemCount > 0,
),
_AbilityRow(
icon: Icons.replay_outlined,
title: '能回忆',
note: '$recall 项正在巩固',
completed: recall > 0,
),
_AbilityRow(
icon: Icons.record_voice_over_outlined,
title: '可使用',
note: '${state.coreUsableCount} / 48 项核心内容已获得独立使用证据',
completed: state.coreUsableCount >= 48,
),
_AbilityRow(
icon: Icons.verified_outlined,
title: '已掌握(间隔复习)',
note: '${state.coreMasteredCount} / 30 项达到四次间隔复习要求',
completed: state.coreMasteredCount >= 30,
),
_AbilityRow(
icon: Icons.assignment_turned_in_outlined,
title: '两套四技能评估',
note: state.hasTwoValidAssessmentPasses
? '两套不同题组已在有效时间内通过'
: '尚需两套不同题组通过,间隔至少 24 小时',
completed: state.hasTwoValidAssessmentPasses,
),
SectionCard(
tint: state.dueReviewCount > 0
? AppColors.warm
: AppColors.softGreen,
child: Row(
children: [
Icon(
state.dueReviewCount > 0
? Icons.schedule
: Icons.check_circle_outline,
color: state.dueReviewCount > 0
? AppColors.warmInk
: AppColors.green,
),
const SizedBox(width: 10),
Expanded(
child: Text(
state.dueReviewCount > 0
? '有 ${state.dueReviewCount} 项到期复习,完成后会更新掌握证据。'
: '目前没有到期复习。',
),
),
],
),
),
const SectionCard(
child: Text(
'进入下一阶段条件:60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
),
),
AnimatedBuilder(
animation: SyncCoordinator.instance,
builder: (context, _) {
final loggedIn = SyncCoordinator.instance.isLoggedIn;
return SectionCard(
onTap: () => SyncSettingsSheet.show(context, state),
child: Row(
children: [
Icon(
loggedIn
? Icons.cloud_done_outlined
: Icons.cloud_queue_outlined,
color: loggedIn ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loggedIn
? '云同步:${SyncCoordinator.instance.username}'
: '云端同步与多端备份',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
loggedIn
? '多端学习进度与复习状态已连接 · 点击管理'
: '未登录 · 点击配置自建服务器,在手机与电脑间同步',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const Icon(Icons.chevron_right, color: AppColors.muted),
],
),
);
},
),
if (state.a0Passed)
const SectionCard(
tint: AppColors.softGreen,
child: Text('A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'),
),
if (recentEvidence.isNotEmpty) ...[
const Text(
'最近学习证据',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('用于解释进度,不是公开记录。'),
for (final evidence in recentEvidence)
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_evidenceLabel(evidence.outcome)} · ${evidence.skill}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (evidence.rawAnswer?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text('你的回答:${evidence.rawAnswer}'),
],
const SizedBox(height: 4),
Text(
_relativeTime(evidence.createdAt),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
const Text(
'A0 四技能评估',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'评估不会提供翻译或句框;第二套题组须在第一套通过至少 24 小时后完成。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final pack in a0AssessmentPacks)
SecondaryButton(
label: state.canStartAssessmentPack(pack.id)
? '开始 ${pack.id}'
: '${pack.id} 需等待第一套评估通过 24 小时',
onPressed: state.canStartAssessmentPack(pack.id)
? () => onOpenAssessment(pack)
: null,
),
SecondaryButton(label: '调整学习与 AI 设置', onPressed: onSettings),
],
),
);
}
static String _evidenceLabel(EvidenceKind outcome) => switch (outcome) {
EvidenceKind.independentSuccess => '独立完成',
EvidenceKind.assisted => '带提示完成',
EvidenceKind.languageError => '需要复核',
EvidenceKind.pending => '稍后完成',
EvidenceKind.exposure => '已查看',
};
static String _relativeTime(DateTime time) {
final difference = DateTime.now().difference(time);
if (difference.inMinutes < 1) return '刚刚';
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
if (difference.inDays < 1) return '${difference.inHours} 小时前';
return '${difference.inDays} 天前';
}
}
class _AbilityRow extends StatelessWidget {
const _AbilityRow({
required this.icon,
required this.title,
required this.note,
required this.completed,
});
final IconData icon;
final String title;
final String note;
final bool completed;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key, required this.state, this.onBack});
final AppState state;
final VoidCallback? onBack;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController endpoint;
late final TextEditingController model;
final apiKey = TextEditingController();
bool _testingConnection = false;
@override
void initState() {
super.initState();
endpoint = TextEditingController(text: widget.state.aiEndpoint);
model = TextEditingController(text: widget.state.aiModel);
}
@override
void dispose() {
endpoint.dispose();
model.dispose();
apiKey.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () {
if (widget.onBack != null) {
widget.onBack!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: const Text('学习设置'),
),
child: SpacedColumn(
spacing: 4,
children: [
_SettingTile(
title: '每日学习时间',
subtitle: '${widget.state.dailyMinutes} 分钟',
onTap: () => _chooseDuration(context),
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('默认显示中文提示'),
subtitle: const Text('A0 阶段开启'),
value: widget.state.showChineseHints,
activeThumbColor: AppColors.green,
onChanged: widget.state.toggleChineseHints,
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('保存原始录音'),
subtitle: const Text('录音功能启用后仅保存在本机'),
value: widget.state.keepRecordings,
activeThumbColor: AppColors.green,
onChanged: widget.state.toggleKeepRecordings,
),
_SettingTile(
title: '已保存的录音',
subtitle: '回听或删除本机英语练习录音',
onTap: () => _showRecordings(context),
),
_SettingTile(
title: '清除已保存的录音',
subtitle: '只删除本机原始音频,不影响学习进度或 AI 密钥',
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'离线语音识别引擎',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'已内置 SenseVoice-Small 高精度离线语音识别模型 (INT8)。随 App 安装包直接打包,离线即用,无需额外下载,零网络流量消耗。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
_SettingTile(
title: '离线语音识别:SenseVoice-Small',
subtitle: SherpaSttService.instance.isReady
? '已就绪 · 本地离线识别 (16kHz WAV · INT8)'
: '预加载就绪 · 已内置打包',
trailing: const Icon(Icons.check_circle, color: AppColors.green, size: 20),
),
const Divider(height: 28),
const Text(
'云同步与多端备份',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'支持通过自建服务器在 Android / iOS / macOS 之间同步学习进度与复习掌握度。离线自动缓存,联网自动双向合并。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
AnimatedBuilder(
animation: SyncCoordinator.instance,
builder: (context, _) => _SettingTile(
title: SyncCoordinator.instance.isLoggedIn
? '同步账号:${SyncCoordinator.instance.username}'
: '配置云端同步账号',
subtitle: SyncCoordinator.instance.isLoggedIn
? '已连接自建服务器 · 点击管理同步'
: '未登录 · 点击配置自建服务器并登录',
onTap: () => SyncSettingsSheet.show(context, widget.state),
),
),
const Divider(height: 28),
const Text(
'AI 对话服务',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。密钥不在此页面保存。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
DropdownButtonFormField<AiProviderType>(
initialValue: widget.state.aiProvider,
decoration: const InputDecoration(
labelText: '服务类型',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: AiProviderType.mock,
child: Text('内置练习模式(无需网络)'),
),
DropdownMenuItem(
value: AiProviderType.openAi,
child: Text('OpenAI API'),
),
DropdownMenuItem(
value: AiProviderType.gemini,
child: Text('Gemini API'),
),
DropdownMenuItem(
value: AiProviderType.compatible,
child: Text('OpenAI 兼容 / CLIProxyAPI'),
),
],
onChanged: (value) {
if (value == null) return;
setState(() {
widget.state.setAiProvider(value);
if (value == AiProviderType.gemini &&
endpoint.text.trim().isEmpty) {
endpoint.text =
'https://generativelanguage.googleapis.com/v1beta';
model.text = model.text.trim().isEmpty
? 'gemini-2.5-flash'
: model.text;
}
});
},
),
TextField(
controller: endpoint,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Base URL(可选)',
hintText:
'OpenAI / 兼容: https://…/v1Gemini https://generativelanguage.googleapis.com/v1beta',
border: OutlineInputBorder(),
),
),
TextField(
controller: model,
decoration: const InputDecoration(
labelText: '模型名称(可选)',
hintText: '例如 gpt-4.1-mini',
border: OutlineInputBorder(),
),
),
TextField(
controller: apiKey,
obscureText: true,
decoration: const InputDecoration(
labelText: 'API Key(仅保存到设备安全存储)',
border: OutlineInputBorder(),
),
),
PrimaryButton(
label: '保存服务设置',
onPressed: () async {
widget.state.saveAiConfiguration(
endpoint: endpoint.text,
model: model.text,
);
if (apiKey.text.trim().isNotEmpty) {
await AiService.instance.saveApiKey(apiKey.text);
}
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('服务设置已保存。')));
},
),
SecondaryButton(
label: _testingConnection ? '正在测试连接...' : '测试连接',
onPressed: _testingConnection
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
setState(() => _testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
explicitApiKey: apiKey.text,
);
if (!mounted) return;
setState(() => _testingConnection = false);
messenger.showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor:
result.ok ? AppColors.green : Colors.redAccent,
duration: const Duration(seconds: 4),
),
);
},
),
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
setState(() {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
messenger.showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
messenger.showSnackBar(
const SnackBar(content: Text('未找到配置文件或解析失败。')),
);
}
},
),
const Divider(height: 28),
_DangerAction(
label: '清除学习进度',
message:
'这会清除本机的课程/分段进度、草稿、复习队列、学习作答证据、掌握记录、对话草稿、AI 补练缓存和阶段评估结果,且不可恢复。不会删除 API 密钥、AI 服务设置或已保存的原始录音;录音请使用下方独立操作删除。',
onConfirm: widget.state.clearProgress,
),
],
),
);
Future<void> _confirmDeleteRecordings(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('清除已保存的录音?'),
content: const Text('这些原始音频只保存在本机。删除后无法恢复,学习进度不会改变。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(context, true),
child: const Text('清除'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final count = await VoiceService.instance.deleteAllRecordings();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(count == 0 ? '没有已保存的录音。' : '已清除 $count 段本机录音。')),
);
}
Future<void> _showRecordings(
BuildContext context,
) => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: FutureBuilder<List<String>>(
future: VoiceService.instance.listRecordingPaths(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
final paths = snapshot.data!;
if (paths.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Text('还没有保存的录音。开启“保存原始录音”后,在跟读步骤完成录音即可在这里回听。'),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: SpacedColumn(
children: [
const Text(
'已保存的录音',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
for (final path in paths)
SectionCard(
child: Row(
children: [
const Icon(Icons.mic_none, color: AppColors.green),
const SizedBox(width: 10),
Expanded(
child: Text(
path.split('/').last.replaceAll('.m4a', ''),
),
),
IconButton(
tooltip: '回听',
onPressed: () =>
VoiceService.instance.playRecording(path),
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除',
onPressed: () async {
await VoiceService.instance.deleteRecording(path);
if (context.mounted) Navigator.pop(context);
},
icon: const Icon(Icons.delete_outline),
),
],
),
),
],
),
);
},
),
),
);
Future<void> _chooseDuration(BuildContext context) async {
final value = await showModalBottomSheet<int>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final minute in [10, 20, 30])
ListTile(
title: Text('$minute 分钟'),
trailing: widget.state.dailyMinutes == minute
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, minute),
),
],
),
),
);
if (value != null) widget.state.setDailyMinutes(value);
}
}
class _SettingTile extends StatelessWidget {
const _SettingTile({
required this.title,
required this.subtitle,
this.onTap,
this.trailing,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null),
onTap: onTap,
);
}
class _DangerAction extends StatelessWidget {
const _DangerAction({
required this.label,
required this.message,
required this.onConfirm,
});
final String label;
final String message;
final VoidCallback? onConfirm;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
trailing: const Icon(Icons.chevron_right, color: Colors.redAccent),
onTap: () => showDialog<void>(
context: context,
builder: (context) {
var acknowledged = false;
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(label),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const SizedBox(height: 12),
const Text(
'会清除:课程步骤、复习、掌握记录、学习证据、评估草稿和课程对话草稿。\n不会清除:已保存录音、AI API Key 与应用设置。',
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: acknowledged,
onChanged: (value) =>
setDialogState(() => acknowledged = value ?? false),
title: const Text('我了解这些本机学习数据无法恢复'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
),
onPressed: acknowledged
? () {
onConfirm?.call();
Navigator.pop(context);
}
: null,
child: const Text('确认清除'),
),
],
),
);
},
),
);
}