阅读"在对话里找到答案": - 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答 - 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理 - 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对) AI 情境对话: - 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单 - JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence - AI 不可用时页面明确提示当前回复来自内置示范脚本 - 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上 - 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤 - 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次 - 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注 同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、 复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
710 lines
26 KiB
Dart
710 lines
26 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 '../../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://…/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: _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('确认清除'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|