574 lines
20 KiB
Dart
574 lines
20 KiB
Dart
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});
|
||
final AppState state;
|
||
@override
|
||
State<SettingsPage> createState() => _SettingsPageState();
|
||
}
|
||
|
||
class _SettingsPageState extends State<SettingsPage> {
|
||
late final TextEditingController endpoint;
|
||
late final TextEditingController model;
|
||
final apiKey = TextEditingController();
|
||
@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(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://…/v1;Gemini: 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: '测试连接',
|
||
onPressed: () async {
|
||
final result = await AiService.instance.testConnection(
|
||
provider: widget.state.aiProvider,
|
||
endpoint: endpoint.text,
|
||
model: model.text,
|
||
);
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(result.message)));
|
||
},
|
||
),
|
||
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('确认清除'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|