feat: complete kouyu_english app codebase, A0 specifications and .gitignore
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
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('确认清除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user