课程内容 - 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本, 单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。 - 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。 - 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、 选项子串等校验全部通过;course_content_test 通过。 黑夜模式 - app_theme 拆分明/暗两套调色板,AppColors 随亮度切换; main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度; 主题偏好持久化到快照;进度页新增“外观主题”切换。 文档 - COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
394 lines
15 KiB
Dart
394 lines
15 KiB
Dart
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,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|