Files
English/kouyu_english/lib/features/progress/progress_pages.dart
T

623 lines
22 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 '../../widgets/app_widgets.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 小时。',
),
),
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(
'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;
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 {
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);
ScaffoldMessenger.of(context).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 ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
setState(() {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
ScaffoldMessenger.of(context).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,
required this.onTap,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
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('确认清除'),
),
],
),
);
},
),
);
}