整个课程和数据UI重新设计版本
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user