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'; 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 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 createState() => _SettingsPageState(); } class _SettingsPageState extends State { 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( '支持通过自建服务器在 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( value: 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 { 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 _confirmDeleteRecordings(BuildContext context) async { final confirmed = await showDialog( 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 _showRecordings( BuildContext context, ) => showModalBottomSheet( context: context, showDragHandle: true, builder: (context) => SafeArea( child: FutureBuilder>( 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 _chooseDuration(BuildContext context) async { final value = await showModalBottomSheet( 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( 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('确认清除'), ), ], ), ); }, ), ); }