整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import '../../core/ai_service.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
String aiProviderLabel(AiProviderType provider) => switch (provider) {
AiProviderType.mock => '内置练习模式',
AiProviderType.openAi => 'OpenAI API',
AiProviderType.gemini => 'Gemini API',
AiProviderType.compatible => 'OpenAI 兼容',
};
/// AI 服务:服务类型、接口地址、模型与 API Key。
class AiServiceView extends StatefulWidget {
const AiServiceView({super.key, required this.state});
final AppState state;
@override
State<AiServiceView> createState() => _AiServiceViewState();
}
class _AiServiceViewState extends State<AiServiceView> {
late final endpoint = TextEditingController(text: widget.state.aiEndpoint);
late final model = TextEditingController(text: widget.state.aiModel);
final apiKey = TextEditingController();
bool testingConnection = false;
@override
void dispose() {
endpoint.dispose();
model.dispose();
apiKey.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SpacedColumn(
children: [
Text(
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。AI 只提供建议,不会直接决定你的掌握程度。',
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: _changeProvider,
),
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: _save),
SecondaryButton(
label: testingConnection ? '正在测试连接...' : '测试连接',
onPressed: testingConnection ? null : _testConnection,
),
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: _reloadFromAsset,
),
],
);
void _changeProvider(AiProviderType? 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';
if (model.text.trim().isEmpty) model.text = 'gemini-2.5-flash';
}
});
}
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context);
widget.state.saveAiConfiguration(
endpoint: endpoint.text,
model: model.text,
);
if (apiKey.text.trim().isNotEmpty) {
await AiService.instance.saveApiKey(apiKey.text);
}
messenger.showSnackBar(const SnackBar(content: Text('服务设置已保存。')));
}
Future<void> _testConnection() 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),
),
);
}
Future<void> _reloadFromAsset() 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(
SnackBar(
content: Text(
ok ? '已从 assets/config/ai_config.json 载入配置。' : '未找到配置文件或解析失败。',
),
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/courses/courses.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
/// 四技能评估:两套题组的状态与入口。
class AssessmentListView extends StatelessWidget {
const AssessmentListView({
super.key,
required this.state,
required this.onOpenAssessment,
});
final AppState state;
final ValueChanged<AssessmentPack> onOpenAssessment;
@override
Widget build(BuildContext context) => SpacedColumn(
children: [
const Text('评估不提供翻译或句框。两套都通过,且间隔 24 小时以上,才计入升级。'),
for (final pack in a0AssessmentPacks) _packCard(context, pack),
],
);
Widget _packCard(BuildContext context, AssessmentPack pack) {
final record = state.assessments
.where((item) => item.packId == pack.id)
.firstOrNull;
final canStart = state.canStartAssessmentPack(pack.id);
final (status, color) = switch (record) {
AssessmentRecord(passed: true) => ('已通过', AppColors.green),
AssessmentRecord() => ('未通过', AppColors.warmInk),
null when canStart => ('未开始', AppColors.muted),
null => ('未解锁', AppColors.muted),
};
return SectionCard(
tint: record?.passed == true ? AppColors.softGreen : null,
child: SpacedColumn(
children: [
Row(
children: [
Expanded(
child: Text(
pack.id,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
Text(status, style: TextStyle(color: color)),
],
),
Text(
canStart
? record == null
? '听 · 说 · 读 · 写 · 约 15 分钟'
: record.passed
? '四项技能都已通过'
: '还需通过:${record.failedSkills.map((skill) => skill.label).join('、')}'
: '第一套评估通过 24 小时后可开始',
style: Theme.of(context).textTheme.bodyMedium,
),
if (canStart && record?.passed != true)
PrimaryButton(
label: record == null ? '开始 ${pack.id}' : '重新评估 ${pack.id}',
onPressed: () => onOpenAssessment(pack),
),
],
),
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
enum _RecordFilter { all, independent, assisted }
/// 学习记录:按天分组的作答证据,用于解释进度。
class LearningRecordsView extends StatefulWidget {
const LearningRecordsView({super.key, required this.state});
final AppState state;
@override
State<LearningRecordsView> createState() => _LearningRecordsViewState();
}
class _LearningRecordsViewState extends State<LearningRecordsView> {
static const _limit = 100;
var filter = _RecordFilter.all;
bool _matches(AttemptEvidence evidence) => switch (filter) {
_RecordFilter.all => true,
_RecordFilter.independent =>
evidence.outcome == EvidenceKind.independentSuccess,
_RecordFilter.assisted => evidence.outcome == EvidenceKind.assisted,
};
@override
Widget build(BuildContext context) {
final records = widget.state.attemptEvidence.reversed
.where(_matches)
.take(_limit)
.toList();
final days = <String, List<AttemptEvidence>>{};
for (final record in records) {
days.putIfAbsent(_dayLabel(record.createdAt), () => []).add(record);
}
return SpacedColumn(
spacing: 16,
children: [
SizedBox(
width: double.infinity,
child: SegmentedButton<_RecordFilter>(
segments: const [
ButtonSegment(value: _RecordFilter.all, label: Text('全部')),
ButtonSegment(
value: _RecordFilter.independent,
label: Text('独立完成'),
),
ButtonSegment(value: _RecordFilter.assisted, label: Text('带帮助')),
],
selected: {filter},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() => filter = value.first),
),
),
if (records.isEmpty)
const Text('还没有记录。完成课程、复习或对话后,会在这里看到每次作答。')
else
for (final MapEntry(key: day, value: items) in days.entries)
MenuGroup(
title: day,
children: [for (final item in items) _RecordTile(evidence: item)],
),
Text(
'记录只保存在本机,用于解释进度,不是公开记录。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
);
}
static String _dayLabel(DateTime time) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final days = today.difference(DateTime(time.year, time.month, time.day));
return switch (days.inDays) {
0 => '今天',
1 => '昨天',
_ => '${time.month} 月 ${time.day} 日',
};
}
}
class _RecordTile extends StatelessWidget {
const _RecordTile({required this.evidence});
final AttemptEvidence evidence;
@override
Widget build(BuildContext context) {
final answer = evidence.rawAnswer;
final (label, color, tint) = switch (evidence.outcome) {
EvidenceKind.independentSuccess => (
'独立完成',
AppColors.green,
AppColors.softGreen,
),
EvidenceKind.assisted => ('带提示', AppColors.warmInk, AppColors.warm),
EvidenceKind.languageError => ('需要复核', AppColors.warmInk, AppColors.warm),
EvidenceKind.pending => ('稍后完成', AppColors.muted, AppColors.surfaceMuted),
EvidenceKind.exposure => ('已查看', AppColors.muted, AppColors.surfaceMuted),
};
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
title: Text(
answer?.isNotEmpty == true ? answer! : evidence.skill,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text('${evidence.skill} · ${relativeTime(evidence.createdAt)}'),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tint,
borderRadius: BorderRadius.circular(99),
),
child: Text(label, style: TextStyle(fontSize: 12, color: color)),
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
/// 升级进度:A0 出口的三项条件。
class LevelProgressView extends StatelessWidget {
const LevelProgressView({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) => SpacedColumn(
spacing: 16,
children: [
const Eyebrow('A0 → A1'),
Text(
state.a0Passed
? 'A0 已通过'
: '已完成 ${(state.a0StageProgress * 100).round()}%',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
state.a0Passed ? 'A1 主线内容尚未提供,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
),
MenuGroup(
children: [
GoalProgressRow(
icon: Icons.record_voice_over_outlined,
title: '可使用',
note: '核心内容已获得独立使用证据',
value: state.coreUsableCount,
goal: a0UsableGoal,
),
GoalProgressRow(
icon: Icons.verified_outlined,
title: '已掌握(间隔复习)',
note: '达到四次间隔复习要求',
value: state.coreMasteredCount,
goal: a0MasteredGoal,
),
GoalProgressRow(
icon: Icons.assignment_turned_in_outlined,
title: '两套四技能评估',
note: '两套不同题组都通过,间隔至少 24 小时',
value: state.passedAssessmentPackCount,
goal: 2,
),
],
),
SectionCard(
padding: EdgeInsets.zero,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
leading: Icon(Icons.info_outline, color: AppColors.muted),
title: const Text('进入下一阶段的条件'),
children: const [
Text(
'60 项固定核心内容中至少 $a0UsableGoal 项可使用、$a0MasteredGoal 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
),
],
),
),
),
],
);
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
/// 学习偏好:每日时长、中文提示与语音识别。
class LearningPreferencesView extends StatelessWidget {
const LearningPreferencesView({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) => SpacedColumn(
spacing: 16,
children: [
MenuGroup(
children: [
MenuTile(
icon: Icons.timer_outlined,
title: '每日学习时间',
value: '${state.dailyMinutes} 分钟',
onTap: () => _chooseDuration(context),
),
MenuSwitch(
icon: Icons.translate,
title: '默认显示中文提示',
subtitle: 'A0 阶段建议开启',
value: state.showChineseHints,
onChanged: state.toggleChineseHints,
),
],
),
MenuGroup(
title: '语音',
children: [
MenuTile(
icon: Icons.graphic_eq,
title: '离线语音识别',
subtitle: SherpaSttService.instance.isReady
? 'SenseVoice-Small · 已就绪,本地识别'
: 'SenseVoice-Small · 已内置,无需下载',
trailing: Icon(
Icons.check_circle,
color: AppColors.green,
size: 20,
),
),
],
),
],
);
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: state.dailyMinutes == minute
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, minute),
),
],
),
),
);
if (value != null) state.setDailyMinutes(value);
}
}
/// 外观:浅色 / 深色 / 跟随系统。
class AppearanceView extends StatelessWidget {
const AppearanceView({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) => SpacedColumn(
children: [
SizedBox(
width: double.infinity,
child: SegmentedButton<AppThemeMode>(
segments: const [
ButtonSegment(value: AppThemeMode.system, label: Text('跟随系统')),
ButtonSegment(value: AppThemeMode.light, label: Text('浅色')),
ButtonSegment(value: AppThemeMode.dark, label: Text('深色')),
],
selected: {state.themeMode},
showSelectedIcon: false,
onSelectionChanged: (selection) =>
state.setThemeMode(selection.first),
),
),
Text(
'深色模式适合夜间使用;跟随系统会随手机的深色开关自动切换。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
);
}
String themeModeLabel(AppThemeMode mode) => switch (mode) {
AppThemeMode.system => '跟随系统',
AppThemeMode.light => '浅色',
AppThemeMode.dark => '深色',
};
@@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/courses/courses.dart';
import '../../core/sync/sync_coordinator.dart';
import '../../widgets/app_widgets.dart';
import 'ai_service_page.dart';
import 'assessment_list_page.dart';
import 'learning_records_page.dart';
import 'level_progress_page.dart';
import 'preference_pages.dart';
import 'profile_widgets.dart';
import 'recordings_page.dart';
import 'sync_settings_sheet.dart';
/// 我的:概览卡、升级进度,以及进入各二级页面的入口。
class ProfilePage extends StatelessWidget {
const ProfilePage({
super.key,
required this.state,
required this.onOpenAssessment,
required this.onOpenReview,
});
final AppState state;
final ValueChanged<AssessmentPack> onOpenAssessment;
final VoidCallback onOpenReview;
void _open(BuildContext context, String title, WidgetBuilder builder) =>
openProfilePage(context, state: state, title: title, builder: builder);
void _openLevel(BuildContext context) =>
_open(context, '升级进度', (_) => LevelProgressView(state: state));
void _openRecords(BuildContext context) =>
_open(context, '学习记录', (_) => LearningRecordsView(state: state));
@override
Widget build(BuildContext context) {
final passedPacks = state.passedAssessmentPackCount;
return AppPage(
child: SpacedColumn(
spacing: 16,
children: [
Text('我的', style: Theme.of(context).textTheme.headlineMedium),
_OverviewCard(
state: state,
onOpenLevel: () => _openLevel(context),
onOpenRecords: () => _openRecords(context),
onOpenReview: onOpenReview,
),
_StageCard(state: state, onTap: () => _openLevel(context)),
MenuGroup(
title: '学习',
children: [
MenuTile(
icon: Icons.trending_up,
title: '升级进度',
value: '${(state.a0StageProgress * 100).round()}%',
onTap: () => _openLevel(context),
),
MenuTile(
icon: Icons.quiz_outlined,
title: '四技能评估',
value: '$passedPacks/2 通过',
onTap: () => _open(
context,
'四技能评估',
(context) => AssessmentListView(
state: state,
onOpenAssessment: (pack) {
Navigator.of(context).pop();
onOpenAssessment(pack);
},
),
),
),
MenuTile(
icon: Icons.history,
title: '学习记录',
onTap: () => _openRecords(context),
),
MenuTile(
icon: Icons.library_music_outlined,
title: '我的录音',
onTap: () =>
_open(context, '我的录音', (_) => RecordingsView(state: state)),
),
],
),
MenuGroup(
title: '设置',
children: [
MenuTile(
icon: Icons.tune,
title: '学习偏好',
value: '${state.dailyMinutes} 分钟',
onTap: () => _open(
context,
'学习偏好',
(_) => LearningPreferencesView(state: state),
),
),
MenuTile(
icon: Icons.palette_outlined,
title: '外观',
value: themeModeLabel(state.themeMode),
onTap: () =>
_open(context, '外观', (_) => AppearanceView(state: state)),
),
ListenableBuilder(
listenable: SyncCoordinator.instance,
builder: (context, _) {
final loggedIn = SyncCoordinator.instance.isLoggedIn;
return MenuTile(
icon: loggedIn
? Icons.cloud_done_outlined
: Icons.cloud_queue_outlined,
iconColor: loggedIn ? AppColors.green : null,
title: '云同步',
value: loggedIn ? '已登录' : '未登录',
onTap: () => SyncSettingsSheet.show(context, state),
);
},
),
MenuTile(
icon: Icons.smart_toy_outlined,
title: 'AI 服务',
value: aiProviderLabel(state.aiProvider),
onTap: () =>
_open(context, 'AI 服务', (_) => AiServiceView(state: state)),
),
],
),
MenuGroup(
children: [
DangerAction(
label: '清除学习进度',
message:
'这会清除本机的课程/分段进度、草稿、复习队列、学习作答证据、掌握记录、对话草稿、AI 补练缓存和阶段评估结果,且不可恢复。',
details: '不会清除:已保存录音、AI API Key 与应用设置。录音请在“我的录音”中单独删除。',
acknowledgement: '我了解这些本机学习数据无法恢复',
onConfirm: state.clearProgress,
),
],
),
],
),
);
}
}
class _OverviewCard extends StatelessWidget {
const _OverviewCard({
required this.state,
required this.onOpenLevel,
required this.onOpenRecords,
required this.onOpenReview,
});
final AppState state;
final VoidCallback onOpenLevel;
final VoidCallback onOpenRecords;
final VoidCallback onOpenReview;
@override
Widget build(BuildContext context) {
final due = state.dueReviewCount;
return SectionCard(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColors.softGreen,
child: Icon(Icons.person, color: AppColors.green),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.a0Passed ? 'A0 已通过' : 'A0 起步',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'每天 ${state.dailyMinutes} 分钟',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
Divider(height: 28, color: AppColors.line),
Row(
children: [
_Stat(
value: state.completedLessonIds.length,
label: '已学课',
onTap: onOpenRecords,
),
_Stat(
value: state.coreUsableCount,
label: '可使用',
onTap: onOpenLevel,
),
_Stat(
value: state.coreMasteredCount,
label: '已掌握',
onTap: onOpenLevel,
),
_Stat(
value: due,
label: '待复习',
color: due > 0 ? AppColors.warmInk : null,
onTap: onOpenReview,
),
],
),
],
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({
required this.value,
required this.label,
required this.onTap,
this.color,
});
final int value;
final String label;
final VoidCallback onTap;
final Color? color;
@override
Widget build(BuildContext context) => Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: [
Text(
'$value',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: color ?? AppColors.ink,
),
),
Text(label, style: TextStyle(fontSize: 12, color: AppColors.muted)),
],
),
),
),
);
}
/// 距离下一阶段的总进度,点击进入升级进度。
class _StageCard extends StatelessWidget {
const _StageCard({required this.state, required this.onTap});
final AppState state;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final missing = [
if (state.coreUsableCount < a0UsableGoal)
'${a0UsableGoal - state.coreUsableCount} 项可使用',
if (state.coreMasteredCount < a0MasteredGoal)
'${a0MasteredGoal - state.coreMasteredCount} 项掌握',
if (state.passedAssessmentPackCount < 2)
'${2 - state.passedAssessmentPackCount} 套评估',
];
return SectionCard(
tint: AppColors.softGreen,
onTap: onTap,
child: SpacedColumn(
spacing: 8,
children: [
Row(
children: [
const Expanded(
child: Text(
'距离 A1',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'${(state.a0StageProgress * 100).round()}%',
style: TextStyle(color: AppColors.green),
),
Icon(Icons.chevron_right, color: AppColors.green),
],
),
ProgressBar(
value: state.a0StageProgress,
background: AppColors.surface,
),
Text(
missing.isEmpty
? 'A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'
: '还需:${missing.join(' · ')}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}
@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../widgets/app_widgets.dart';
/// Opens a "我的" sub page. Pushed routes sit above the shell, so each page
/// listens to [state] itself to stay current.
Future<void> openProfilePage(
BuildContext context, {
required AppState state,
required String title,
required WidgetBuilder builder,
}) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => AppPage(
appBar: AppBar(title: Text(title)),
child: ListenableBuilder(
listenable: state,
builder: (context, _) => builder(context),
),
),
),
);
/// A titled card whose rows are separated by dividers.
class MenuGroup extends StatelessWidget {
const MenuGroup({super.key, this.title, this.note, required this.children});
final String? title;
final String? note;
final List<Widget> children;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null || note != null)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(
title!,
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
if (note != null)
Text(
note!,
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) Divider(height: 1, color: AppColors.line),
children[i],
],
],
),
),
],
);
}
/// One row of a [MenuGroup]: icon, title, optional current value and chevron.
class MenuTile extends StatelessWidget {
const MenuTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.value,
this.iconColor,
this.trailing,
this.onTap,
});
final IconData icon;
final String title;
final String? subtitle;
final String? value;
final Color? iconColor;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(icon, color: iconColor ?? AppColors.muted),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
trailing:
trailing ??
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (value != null)
Text(
value!,
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
if (onTap != null)
Icon(Icons.chevron_right, color: AppColors.muted),
],
),
onTap: onTap,
);
}
/// A [MenuTile]-aligned switch row.
class MenuSwitch extends StatelessWidget {
const MenuSwitch({
super.key,
required this.icon,
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
final IconData icon;
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
secondary: Icon(icon, color: AppColors.muted),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
value: value,
activeThumbColor: AppColors.green,
onChanged: onChanged,
);
}
/// A labelled progress bar, e.g. "可使用 31 / 48".
class GoalProgressRow extends StatelessWidget {
const GoalProgressRow({
super.key,
required this.icon,
required this.title,
required this.note,
required this.value,
required this.goal,
});
final IconData icon;
final String title;
final String note;
final int value;
final int goal;
@override
Widget build(BuildContext context) {
final completed = value >= goal;
return Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'$value / $goal',
style: TextStyle(color: AppColors.muted),
),
],
),
const SizedBox(height: 6),
ProgressBar(value: goal == 0 ? 0 : value / goal),
const SizedBox(height: 6),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
}
class ProgressBar extends StatelessWidget {
const ProgressBar({super.key, required this.value, this.background});
final double value;
final Color? background;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: value.clamp(0, 1).toDouble(),
minHeight: 6,
color: AppColors.green,
backgroundColor: background ?? AppColors.surfaceMuted,
),
);
}
/// A red row that asks for explicit acknowledgement before [onConfirm].
class DangerAction extends StatelessWidget {
const DangerAction({
super.key,
required this.label,
required this.message,
this.details,
this.acknowledgement,
required this.onConfirm,
});
final String label;
final String message;
final String? details;
/// When set, the confirm button stays disabled until this box is ticked.
final String? acknowledgement;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: const Icon(Icons.delete_forever_outlined, color: Colors.redAccent),
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
onTap: () => showDialog<void>(
context: context,
builder: (context) {
var acknowledged = acknowledgement == null;
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(label),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
if (details != null) ...[
const SizedBox(height: 12),
Text(details!),
],
if (acknowledgement != null)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: acknowledged,
onChanged: (value) =>
setDialogState(() => acknowledged = value ?? false),
title: Text(acknowledgement!),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
),
onPressed: acknowledged
? () {
Navigator.pop(context);
onConfirm();
}
: null,
child: const Text('确认清除'),
),
],
),
);
},
),
);
}
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} 天前';
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
/// 我的录音:回听或删除本机保存的练习录音。
class RecordingsView extends StatefulWidget {
const RecordingsView({super.key, required this.state});
final AppState state;
@override
State<RecordingsView> createState() => _RecordingsViewState();
}
class _RecordingsViewState extends State<RecordingsView> {
late Future<List<String>> recordings = VoiceService.instance
.listRecordingPaths();
void _reload() => setState(() {
recordings = VoiceService.instance.listRecordingPaths();
});
@override
Widget build(BuildContext context) => SpacedColumn(
spacing: 16,
children: [
MenuGroup(
children: [
MenuSwitch(
icon: Icons.mic_none,
title: '保存原始录音',
subtitle: '仅保存在本机',
value: widget.state.keepRecordings,
onChanged: widget.state.toggleKeepRecordings,
),
],
),
FutureBuilder<List<String>>(
future: recordings,
builder: (context, snapshot) {
final paths = snapshot.data;
if (paths == null) {
return const Center(child: CircularProgressIndicator());
}
if (paths.isEmpty) {
return const Text('还没有保存的录音。开启“保存原始录音”后,在跟读步骤完成录音即可在这里回听。');
}
return SpacedColumn(
spacing: 16,
children: [
MenuGroup(
title: '共 ${paths.length} 条',
children: [for (final path in paths) _recordingTile(path)],
),
MenuGroup(
children: [
DangerAction(
label: '清除全部录音',
message: '这些原始音频只保存在本机。删除后无法恢复,学习进度不会改变。',
onConfirm: _deleteAll,
),
],
),
],
);
},
),
],
);
Widget _recordingTile(String path) => ListTile(
contentPadding: const EdgeInsets.only(left: 4, right: 4),
leading: IconButton(
tooltip: '回听',
onPressed: () => VoiceService.instance.playRecording(path),
icon: Icon(Icons.play_circle_outline, color: AppColors.green),
),
title: Text(path.split('/').last.replaceAll('.m4a', '')),
trailing: IconButton(
tooltip: '删除',
onPressed: () async {
await VoiceService.instance.deleteRecording(path);
if (mounted) _reload();
},
icon: Icon(Icons.delete_outline, color: AppColors.muted),
),
);
Future<void> _deleteAll() async {
final messenger = ScaffoldMessenger.of(context);
final count = await VoiceService.instance.deleteAllRecordings();
if (!mounted) return;
_reload();
messenger.showSnackBar(
SnackBar(content: Text(count == 0 ? '没有已保存的录音。' : '已清除 $count 段本机录音。')),
);
}
}
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/sync/sync_coordinator.dart';
import '../../core/sync/sync_models.dart';
import '../../widgets/app_widgets.dart';
/// 跨平台同步设置底部面板
class SyncSettingsSheet extends StatefulWidget {
const SyncSettingsSheet({super.key, required this.state});
final AppState state;
static Future<void> show(BuildContext context, AppState state) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(child: SyncSettingsSheet(state: state)),
),
);
}
@override
State<SyncSettingsSheet> createState() => _SyncSettingsSheetState();
}
class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
final _coordinator = SyncCoordinator.instance;
late final TextEditingController _serverController;
final _userController = TextEditingController();
final _passwordController = TextEditingController();
bool _isRegisterMode = false;
bool _testingConnection = false;
String? _testMessage;
bool? _testOk;
@override
void initState() {
super.initState();
_serverController = TextEditingController(text: _coordinator.serverUrl);
}
@override
void dispose() {
_serverController.dispose();
_userController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleTestConnection() async {
setState(() {
_testingConnection = true;
_testMessage = null;
_testOk = null;
});
final ok = await _coordinator.testConnection(_serverController.text);
if (!mounted) return;
setState(() {
_testingConnection = false;
_testOk = ok;
_testMessage = ok ? '服务器连接正常' : '无法连接到服务器,请检查地址或网络';
});
}
Future<void> _handleAuth() async {
final server = _serverController.text.trim();
final user = _userController.text.trim();
final pwd = _passwordController.text;
if (server.isEmpty || user.isEmpty || pwd.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请填写完整的服务器地址、账号和密码')));
return;
}
final success = _isRegisterMode
? await _coordinator.register(
serverUrl: server,
username: user,
password: pwd,
)
: await _coordinator.login(
serverUrl: server,
username: user,
password: pwd,
);
if (!mounted) return;
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isRegisterMode ? '注册并登录成功!' : '登录成功!'),
backgroundColor: AppColors.green,
),
);
// 登录成功后自动执行一次同步
await _coordinator.syncNow(widget.state);
}
}
Future<void> _handleSyncNow() async {
final ok = await _coordinator.syncNow(widget.state);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ok ? '同步完成,进度已合并。' : '同步失败:${_coordinator.errorMessage}'),
backgroundColor: ok ? AppColors.green : Colors.redAccent,
),
);
}
String _formatTime(DateTime? time) {
if (time == null) return '从未同步';
final local = time.toLocal();
final now = DateTime.now();
final diff = now.difference(local);
if (diff.inSeconds < 60) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24) return '${diff.inHours} 小时前';
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _coordinator,
builder: (context, _) {
final isLoggedIn = _coordinator.isLoggedIn;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: SpacedColumn(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'云同步与多端备份',
style: Theme.of(context).textTheme.titleLarge,
),
if (isLoggedIn)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: _coordinator.state == SyncState.syncing
? AppColors.warm
: _coordinator.state == SyncState.error
? Colors.red.shade50
: AppColors.softGreen,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_coordinator.state == SyncState.syncing
? Icons.sync
: _coordinator.state == SyncState.error
? Icons.error_outline
: Icons.cloud_done_outlined,
size: 14,
color: _coordinator.state == SyncState.syncing
? AppColors.warmInk
: _coordinator.state == SyncState.error
? Colors.redAccent
: AppColors.green,
),
const SizedBox(width: 4),
Text(
_coordinator.state == SyncState.syncing
? '同步中'
: _coordinator.state == SyncState.error
? '同步异常'
: '已连接',
style: TextStyle(
fontSize: 12,
color: _coordinator.state == SyncState.syncing
? AppColors.warmInk
: _coordinator.state == SyncState.error
? Colors.redAccent
: AppColors.green,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
Text(
'本地优先架构:无网络时不影响学习,联网后自动双向合并学习进度与复习掌握度。',
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
const SizedBox(height: 8),
if (isLoggedIn) ...[
// Logged In State
SectionCard(
child: Column(
children: [
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: AppColors.softGreen,
child: Icon(Icons.person, color: AppColors.green),
),
const SizedBox(width: 12),
Expanded(
child: Column(
children: [
Text(
_coordinator.username ?? '同步用户',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
_coordinator.serverUrl,
style: TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
TextButton(
onPressed: () => _coordinator.logout(),
child: const Text('退出登录'),
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('上次同步:'),
Text(
_formatTime(_coordinator.lastSyncTime),
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
],
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('自动后台同步'),
subtitle: const Text('在关卡完成与复习提交后自动静默同步'),
value: _coordinator.autoSyncEnabled,
activeThumbColor: AppColors.green,
onChanged: (val) => _coordinator.setAutoSyncEnabled(val),
),
if (_coordinator.errorMessage != null)
SectionCard(
tint: Colors.red.shade50,
child: Row(
children: [
const Icon(
Icons.error_outline,
color: Colors.redAccent,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'同步失败:${_coordinator.errorMessage}',
style: const TextStyle(
color: Colors.redAccent,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 8),
PrimaryButton(
label: _coordinator.state == SyncState.syncing
? '正在同步...'
: '立即同步 (双向合并)',
icon: Icons.sync,
onPressed: _coordinator.state == SyncState.syncing
? null
: _handleSyncNow,
),
] else ...[
// Not Logged In State (Login / Register Form)
Row(
children: [
Expanded(
child: ChoiceChip(
label: const Center(child: Text('登录已有账号')),
selected: !_isRegisterMode,
onSelected: (val) =>
setState(() => _isRegisterMode = false),
),
),
const SizedBox(width: 12),
Expanded(
child: ChoiceChip(
label: const Center(child: Text('注册新账号')),
selected: _isRegisterMode,
onSelected: (val) =>
setState(() => _isRegisterMode = true),
),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _serverController,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: '自建同步服务器地址',
hintText: 'https://syncenglish.slcydia.fun',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: '测试连通性',
icon: _testingConnection
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check),
onPressed: _testingConnection
? null
: _handleTestConnection,
),
),
),
if (_testMessage != null)
Text(
_testMessage!,
style: TextStyle(
fontSize: 12,
color: _testOk == true
? AppColors.green
: Colors.redAccent,
),
),
TextField(
controller: _userController,
decoration: const InputDecoration(
labelText: '用户名',
border: OutlineInputBorder(),
),
),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
border: OutlineInputBorder(),
),
),
if (_coordinator.errorMessage != null)
SectionCard(
tint: Colors.red.shade50,
child: Text(
_coordinator.errorMessage!,
style: const TextStyle(
color: Colors.redAccent,
fontSize: 13,
),
),
),
PrimaryButton(
label: _coordinator.state == SyncState.syncing
? '处理中...'
: (_isRegisterMode ? '注册账号并开启同步' : '登录并同步进度'),
onPressed: _coordinator.state == SyncState.syncing
? null
: _handleAuth,
),
],
],
),
);
},
);
}
}