feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署

This commit is contained in:
shen
2026-09-16 09:15:53 +08:00
parent 293b838341
commit eda5dfce62
66 changed files with 3564 additions and 80 deletions
@@ -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. Whats 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;