feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署
This commit is contained in:
@@ -471,6 +471,7 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
}
|
||||
}),
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: task.skill == AssessmentSkill.speaking
|
||||
? '使用麦克风说出答案;文字仅作待评估记录'
|
||||
|
||||
@@ -123,6 +123,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
bool waitingForReply = false;
|
||||
String? validationError;
|
||||
|
||||
final Set<int> _shownTranslations = <int>{};
|
||||
|
||||
LessonDialogue get script => widget.isLessonDialogue
|
||||
? dialogueBySegmentId(
|
||||
lessonById(widget.state.activeLessonId)
|
||||
@@ -136,8 +138,63 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
goal: '姓名、地点、状态或喜好,并反问',
|
||||
prompts: prompts,
|
||||
hints: hints,
|
||||
translations: translations,
|
||||
);
|
||||
|
||||
String? _resolveTranslationFor(String text, int currentStage) {
|
||||
final cleanText = text.trim();
|
||||
// 1. Check current script prompts
|
||||
for (var i = 0; i < script.prompts.length; i++) {
|
||||
if (script.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < script.translations.length) {
|
||||
return script.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Check standalone prompts
|
||||
for (var i = 0; i < prompts.length; i++) {
|
||||
if (prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < translations.length) {
|
||||
return translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Check all lesson dialogues
|
||||
for (final dialogue in a0Dialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. Check all segment dialogues
|
||||
for (final dialogue in a0SegmentDialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. Common fallback phrases
|
||||
if (cleanText.toLowerCase().contains("wonderful") &&
|
||||
cleanText.toLowerCase().contains("nice meeting you")) {
|
||||
return "太棒了 — 很高兴认识你!";
|
||||
}
|
||||
if (cleanText.toLowerCase().contains("goodbye") ||
|
||||
cleanText.toLowerCase().contains("bye")) {
|
||||
return "再见!";
|
||||
}
|
||||
// 6. If stage index is within script.translations
|
||||
if (currentStage >= 0 && currentStage < script.translations.length) {
|
||||
return script.translations[currentStage];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static const prompts = [
|
||||
'Hi! My name is Mia. What’s your name?',
|
||||
'Nice to meet you. Where are you from?',
|
||||
@@ -170,9 +227,26 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
if (canRestore) {
|
||||
stage = draft.stage;
|
||||
usedHelp = draft.usedHelp;
|
||||
turns.addAll(draft.turns);
|
||||
for (var i = 0; i < draft.turns.length; i++) {
|
||||
final t = draft.turns[i];
|
||||
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
|
||||
final trans = _resolveTranslationFor(t.text, i ~/ 2);
|
||||
turns.add(t.copyWith(translation: trans));
|
||||
} else {
|
||||
turns.add(t);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
|
||||
final initialPrompt = script.prompts.first;
|
||||
final initialTranslation = script.translations.firstOrNull ??
|
||||
_resolveTranslationFor(initialPrompt, 0);
|
||||
turns.add(
|
||||
DialogueTurn(
|
||||
text: initialPrompt,
|
||||
isLearner: false,
|
||||
translation: initialTranslation,
|
||||
),
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
@@ -245,11 +319,16 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
(nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Wonderful — nice meeting you!');
|
||||
var replyTranslation = aiResponse?.translation;
|
||||
if (replyTranslation == null || replyTranslation.isEmpty) {
|
||||
replyTranslation = _resolveTranslationFor(replyText, nextStage);
|
||||
}
|
||||
setState(() {
|
||||
turns.add(
|
||||
DialogueTurn(
|
||||
text: replyText,
|
||||
isLearner: false,
|
||||
translation: replyTranslation,
|
||||
),
|
||||
);
|
||||
waitingForReply = false;
|
||||
@@ -360,6 +439,94 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleTurnTranslation(int index) async {
|
||||
if (index < 0 || index >= turns.length) return;
|
||||
final turn = turns[index];
|
||||
if (turn.isLearner) return;
|
||||
|
||||
if (_shownTranslations.contains(index)) {
|
||||
setState(() {
|
||||
_shownTranslations.remove(index);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
String? trans = turn.translation;
|
||||
if (trans == null || trans.isEmpty) {
|
||||
trans = _resolveTranslationFor(turn.text, index ~/ 2);
|
||||
}
|
||||
|
||||
if (trans != null && trans.isNotEmpty) {
|
||||
setState(() {
|
||||
turns[index] = turn.copyWith(translation: trans);
|
||||
_shownTranslations.add(index);
|
||||
});
|
||||
_saveDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_shownTranslations.add(index);
|
||||
turns[index] = turn.copyWith(translation: "正在翻译…");
|
||||
});
|
||||
final fetched = await AiService.instance.temporaryDefinition(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: turn.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final finalTrans =
|
||||
(fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译";
|
||||
setState(() {
|
||||
turns[index] = turn.copyWith(translation: finalTrans);
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _showLatestAiTranslation() async {
|
||||
final latestAiIndex = turns.lastIndexWhere((turn) => !turn.isLearner);
|
||||
if (latestAiIndex == -1) return;
|
||||
final latestAi = turns[latestAiIndex];
|
||||
|
||||
String? trans = latestAi.translation;
|
||||
if (trans == null || trans.isEmpty) {
|
||||
trans = _resolveTranslationFor(latestAi.text, stage);
|
||||
}
|
||||
|
||||
if (trans != null && trans.isNotEmpty) {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = "对方说:$trans";
|
||||
_shownTranslations.add(latestAiIndex);
|
||||
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
|
||||
});
|
||||
_saveDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = "正在获取对方英文翻译…";
|
||||
_shownTranslations.add(latestAiIndex);
|
||||
});
|
||||
|
||||
final fetched = await AiService.instance.temporaryDefinition(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: latestAi.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final finalTrans =
|
||||
(fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译";
|
||||
setState(() {
|
||||
hint = "对方说:$finalTrans";
|
||||
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _playLatestAi({required bool slow}) async {
|
||||
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
|
||||
if (latest == null) return;
|
||||
@@ -553,28 +720,83 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
children: [
|
||||
LexiconText(turn.text, state: widget.state),
|
||||
if (!turn.isLearner) ...[
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () => VoiceService.instance.speak(turn.text),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
if (_shownTranslations.contains(index) &&
|
||||
turn.translation != null &&
|
||||
turn.translation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
turn.translation!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF2D3748),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"播放",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
VoiceService.instance.speak(turn.text),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"播放",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => _toggleTurnTranslation(index),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_shownTranslations.contains(index)
|
||||
? Icons.translate
|
||||
: Icons.translate_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_shownTranslations.contains(index)
|
||||
? "隐藏翻译"
|
||||
: "翻译",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -594,20 +816,17 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = script.hints[stage];
|
||||
final hintIdx = stage < script.hints.length
|
||||
? stage
|
||||
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
|
||||
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '翻译',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = translations[stage];
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
onTap: _showLatestAiTranslation,
|
||||
),
|
||||
_AssistChip(
|
||||
label: '慢一点',
|
||||
|
||||
@@ -1145,6 +1145,7 @@ class _IndependentStepState extends State<_IndependentStep> {
|
||||
widget.onChanged();
|
||||
},
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入完整英文句子',
|
||||
filled: true,
|
||||
|
||||
@@ -32,6 +32,40 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||
child: SpacedColumn(
|
||||
spacing: 20,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/branding/logo_512.png',
|
||||
width: 44,
|
||||
height: 44,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'芽说英语',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'SpeakSprout',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Eyebrow('欢迎'),
|
||||
Text(
|
||||
'每天 20 分钟,\n说出能用的英语。',
|
||||
|
||||
@@ -7,6 +7,8 @@ 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({
|
||||
@@ -93,6 +95,46 @@ class ProgressPage extends StatelessWidget {
|
||||
'进入下一阶段条件: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,
|
||||
@@ -279,6 +321,27 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'本地优先架构:无网络时不影响学习,联网后自动双向合并学习进度与复习掌握度。',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (isLoggedIn) ...[
|
||||
// Logged In State
|
||||
SectionCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const 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: const 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你会怎么回答',
|
||||
@@ -653,6 +654,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
onChanged: (_) {
|
||||
if (usedVoice && controller.text != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
|
||||
Reference in New Issue
Block a user