generated from dellevin/template
云备份界面优化
This commit is contained in:
@@ -580,6 +580,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
'read' => ('已读', colors.primary, colors.onPrimary),
|
'read' => ('已读', colors.primary, colors.onPrimary),
|
||||||
'reading' => ('在读', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
|
'reading' => ('在读', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
|
||||||
'want_to_read' => ('想读', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)),
|
'want_to_read' => ('想读', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)),
|
||||||
|
'abandoned' => ('弃读', colors.error.withValues(alpha: 0.15), colors.error),
|
||||||
_ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)),
|
_ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)),
|
||||||
};
|
};
|
||||||
if (label.isEmpty) return const SizedBox.shrink();
|
if (label.isEmpty) return const SizedBox.shrink();
|
||||||
@@ -702,6 +703,11 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
bgColor = colors.surfaceContainerHighest;
|
bgColor = colors.surfaceContainerHighest;
|
||||||
textColor = colors.onSurface.withValues(alpha: 0.4);
|
textColor = colors.onSurface.withValues(alpha: 0.4);
|
||||||
break;
|
break;
|
||||||
|
case 'abandoned':
|
||||||
|
label = '弃读';
|
||||||
|
bgColor = colors.error.withValues(alpha: 0.15);
|
||||||
|
textColor = colors.error;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
label = '未知';
|
label = '未知';
|
||||||
bgColor = colors.outlineVariant;
|
bgColor = colors.outlineVariant;
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ class _BookFormPageState extends State<BookFormPage> {
|
|||||||
_buildStatusOption('想读', 'want_to_read'),
|
_buildStatusOption('想读', 'want_to_read'),
|
||||||
_buildStatusOption('在读', 'reading'),
|
_buildStatusOption('在读', 'reading'),
|
||||||
_buildStatusOption('已读', 'read'),
|
_buildStatusOption('已读', 'read'),
|
||||||
|
_buildStatusOption('弃读', 'abandoned'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class _BookTabPageState extends State<BookTabPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'};
|
static const _statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read', 3: 'abandoned'};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -166,7 +166,7 @@ class _BookTabPageState extends State<BookTabPage> {
|
|||||||
final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个
|
final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个
|
||||||
final provider = context.read<AppProvider>();
|
final provider = context.read<AppProvider>();
|
||||||
final currentIndex = provider.bookStatusIndex;
|
final currentIndex = provider.bookStatusIndex;
|
||||||
final newIndex = (currentIndex + direction + 3) % 3;
|
final newIndex = (currentIndex + direction + 4) % 4;
|
||||||
setState(() => _dragDelta = 0.0);
|
setState(() => _dragDelta = 0.0);
|
||||||
provider.setBookStatusIndex(newIndex);
|
provider.setBookStatusIndex(newIndex);
|
||||||
},
|
},
|
||||||
@@ -297,7 +297,7 @@ class _BookTabPageState extends State<BookTabPage> {
|
|||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final provider = context.read<AppProvider>();
|
final provider = context.read<AppProvider>();
|
||||||
final isWallMode = provider.bookshelfMode;
|
final isWallMode = provider.bookshelfMode;
|
||||||
final statusText = isWallMode ? '' : ['已读', '在读', '想读'][statusIndex];
|
final statusText = isWallMode ? '' : ['已读', '在读', '想读', '弃读'][statusIndex];
|
||||||
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
Container(width: 80, height: 80,
|
Container(width: 80, height: 80,
|
||||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
|
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ class CloudSyncPage extends StatelessWidget {
|
|||||||
onTap: () =>
|
onTap: () =>
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())),
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildOption(
|
||||||
|
colors: colors,
|
||||||
|
icon: Icons.cloud_sync_outlined,
|
||||||
|
title: '服务器云同步',
|
||||||
|
subtitle: '自建云同步服务器,多端同步更改',
|
||||||
|
onTap: () {},
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildInfo(colors),
|
_buildInfo(colors),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isConfigured = false;
|
bool _isConfigured = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _isAutoSyncEnabled = false;
|
|
||||||
int _autoSyncInterval = 5;
|
|
||||||
SyncDirection _syncDirection = SyncDirection.upload;
|
SyncDirection _syncDirection = SyncDirection.upload;
|
||||||
|
|
||||||
|
// 远程备份信息
|
||||||
|
DateTime? _remoteModifiedTime;
|
||||||
|
int? _remoteFileSize;
|
||||||
|
bool _isLoadingRemoteInfo = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -50,14 +53,24 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
_pathController.text = config['path'] ?? '/mooknote';
|
_pathController.text = config['path'] ?? '/mooknote';
|
||||||
_isConfigured = true;
|
_isConfigured = true;
|
||||||
});
|
});
|
||||||
|
_loadRemoteInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
final autoSyncEnabled = await WebDAVService.instance.isAutoSyncEnabled();
|
final autoSyncEnabled = await WebDAVService.instance.isAutoSyncEnabled();
|
||||||
final autoSyncInterval = await WebDAVService.instance.getAutoSyncInterval();
|
final autoSyncInterval = await WebDAVService.instance.getAutoSyncInterval();
|
||||||
setState(() {
|
debugPrint('WebDAV auto sync: enabled=$autoSyncEnabled, interval=$autoSyncInterval');
|
||||||
_isAutoSyncEnabled = autoSyncEnabled;
|
}
|
||||||
_autoSyncInterval = autoSyncInterval;
|
|
||||||
});
|
Future<void> _loadRemoteInfo() async {
|
||||||
|
setState(() => _isLoadingRemoteInfo = true);
|
||||||
|
final info = await WebDAVService.instance.getRemoteBackupInfo();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_remoteModifiedTime = info?['modifiedTime'] as DateTime?;
|
||||||
|
_remoteFileSize = info?['size'] as int?;
|
||||||
|
_isLoadingRemoteInfo = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveConfig() async {
|
Future<void> _saveConfig() async {
|
||||||
@@ -118,6 +131,8 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// 同步成功后刷新远程文件信息
|
||||||
|
_loadRemoteInfo();
|
||||||
final details = '上传: ${result.uploadedFiles} 文件, ${result.uploadedImages} 图片\n'
|
final details = '上传: ${result.uploadedFiles} 文件, ${result.uploadedImages} 图片\n'
|
||||||
'下载: ${result.downloadedFiles} 文件, ${result.downloadedImages} 图片';
|
'下载: ${result.downloadedFiles} 文件, ${result.downloadedImages} 图片';
|
||||||
|
|
||||||
@@ -189,76 +204,79 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleAutoSync(bool value) async {
|
Future<void> _showUploadConfirm() async {
|
||||||
setState(() => _isLoading = true);
|
final confirmed = await showDialog<bool>(
|
||||||
try {
|
|
||||||
if (value) {
|
|
||||||
await WebDAVService.instance.startAutoSync();
|
|
||||||
if (mounted) ToastUtil.show(context, '自动同步已开启,每 $_autoSyncInterval 分钟同步一次');
|
|
||||||
} else {
|
|
||||||
await WebDAVService.instance.stopAutoSync();
|
|
||||||
if (mounted) ToastUtil.show(context, '自动同步已关闭');
|
|
||||||
}
|
|
||||||
setState(() => _isAutoSyncEnabled = value);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) ToastUtil.show(context, '设置失败: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showIntervalPicker() async {
|
|
||||||
final intervals = [1, 3, 5, 10, 15, 30, 60];
|
|
||||||
final selected = await showModalBottomSheet<int>(
|
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
final colors = Theme.of(ctx).colorScheme;
|
final colors = Theme.of(ctx).colorScheme;
|
||||||
return SafeArea(
|
return AlertDialog(
|
||||||
child: Column(
|
backgroundColor: colors.surface,
|
||||||
mainAxisSize: MainAxisSize.min,
|
elevation: 0,
|
||||||
children: [
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
const SizedBox(height: 8),
|
title: Text('确认上传',
|
||||||
Container(
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
width: 36,
|
content: Text('该操作会覆盖掉远程数据,请谨慎操作,点击确定继续上传',
|
||||||
height: 4,
|
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6)),
|
||||||
decoration: BoxDecoration(
|
actions: [
|
||||||
color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
|
TextButton(
|
||||||
const SizedBox(height: 12),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
Text('同步间隔',
|
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
),
|
||||||
const SizedBox(height: 8),
|
ElevatedButton(
|
||||||
...intervals.map((i) => ListTile(
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
title: Text('$i 分钟',
|
style: ElevatedButton.styleFrom(
|
||||||
style: TextStyle(
|
backgroundColor: colors.primary,
|
||||||
fontSize: 15,
|
foregroundColor: colors.onPrimary,
|
||||||
fontWeight: _autoSyncInterval == i ? FontWeight.w600 : FontWeight.w400,
|
elevation: 0,
|
||||||
color: colors.onSurface)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
trailing: _autoSyncInterval == i
|
),
|
||||||
? Icon(Icons.check, color: colors.primary, size: 20)
|
child: const Text('确定'),
|
||||||
: null,
|
),
|
||||||
onTap: () => Navigator.pop(ctx, i),
|
],
|
||||||
)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
setState(() => _syncDirection = SyncDirection.upload);
|
||||||
|
_syncData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (selected != null && selected != _autoSyncInterval) {
|
Future<void> _showDownloadConfirm() async {
|
||||||
setState(() => _isLoading = true);
|
final confirmed = await showDialog<bool>(
|
||||||
try {
|
context: context,
|
||||||
await WebDAVService.instance.setAutoSyncInterval(selected);
|
builder: (ctx) {
|
||||||
setState(() => _autoSyncInterval = selected);
|
final colors = Theme.of(ctx).colorScheme;
|
||||||
if (mounted) ToastUtil.show(context, '同步间隔已设置为 $selected 分钟');
|
return AlertDialog(
|
||||||
} catch (e) {
|
backgroundColor: colors.surface,
|
||||||
if (mounted) ToastUtil.show(context, '设置失败: $e');
|
elevation: 0,
|
||||||
} finally {
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
if (mounted) setState(() => _isLoading = false);
|
title: Text('确认下载',
|
||||||
}
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
|
content: Text('该操作会拉取远程数据覆盖掉本地数据,请谨慎操作,点击确定将数据拉取到本地',
|
||||||
|
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
foregroundColor: colors.onPrimary,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
setState(() => _syncDirection = SyncDirection.download);
|
||||||
|
_syncData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +343,6 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
_passwordController.clear();
|
_passwordController.clear();
|
||||||
_pathController.text = '/mooknote';
|
_pathController.text = '/mooknote';
|
||||||
_isConfigured = false;
|
_isConfigured = false;
|
||||||
_isAutoSyncEnabled = false;
|
|
||||||
});
|
});
|
||||||
if (mounted) ToastUtil.show(context, '配置已清除');
|
if (mounted) ToastUtil.show(context, '配置已清除');
|
||||||
}
|
}
|
||||||
@@ -339,18 +356,18 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
appBar: AppBar(title: const Text('WebDAV 备份')),
|
appBar: AppBar(title: const Text('WebDAV 备份')),
|
||||||
body: _isLoading && !_isConfigured
|
body: Stack(
|
||||||
? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
|
children: [
|
||||||
: ListView(
|
ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// 已连接提示
|
// 已连接提示
|
||||||
if (_isConfigured) _buildConnectedBanner(colors),
|
if (_isConfigured) _buildConnectedBanner(colors),
|
||||||
|
|
||||||
// 服务器配置
|
// 服务器配置
|
||||||
_buildSectionLabel(colors, '服务器配置'),
|
_buildSectionLabel(colors, '服务器配置'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildInput(
|
_buildInput(
|
||||||
colors: colors,
|
colors: colors,
|
||||||
@@ -391,61 +408,39 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
|
|
||||||
// 测试并保存
|
// 测试并保存
|
||||||
_buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig),
|
_buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig),
|
||||||
|
if (_isConfigured) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _isLoading ? null : _clearConfig,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text('清除配置',
|
||||||
|
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
if (_isConfigured) ...[
|
if (_isConfigured) ...[
|
||||||
// 手动同步
|
// 远程备份信息卡片(包含操作按钮)
|
||||||
_buildSectionLabel(colors, '手动同步'),
|
_buildRemoteInfoCard(colors),
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildDirectionChip(
|
|
||||||
colors, '上传到云端', SyncDirection.upload, Icons.upload)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildDirectionChip(
|
|
||||||
colors, '下载到本地', SyncDirection.download, Icons.download)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildBtn(colors, '立即同步', onTap: _isLoading ? null : _syncData, loading: _isLoading),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 自动同步
|
|
||||||
_buildSectionLabel(colors, '自动同步'),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
_buildSwitchRow(
|
|
||||||
colors: colors,
|
|
||||||
icon: Icons.sync,
|
|
||||||
label: '自动同步',
|
|
||||||
sub: _isAutoSyncEnabled ? '每 $_autoSyncInterval 分钟自动同步' : '关闭',
|
|
||||||
value: _isAutoSyncEnabled,
|
|
||||||
onChanged: _isLoading ? null : _toggleAutoSync,
|
|
||||||
),
|
|
||||||
if (_isAutoSyncEnabled) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_buildTappableRow(
|
|
||||||
colors: colors,
|
|
||||||
label: '同步间隔',
|
|
||||||
value: '$_autoSyncInterval 分钟',
|
|
||||||
onTap: _isLoading ? null : _showIntervalPicker,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 清除配置
|
|
||||||
_buildTextBtn(colors, '清除配置', onTap: _isLoading ? null : _clearConfig),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildTips(colors),
|
_buildTips(colors),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
if (_isLoading)
|
||||||
|
Container(
|
||||||
|
color: colors.surface.withValues(alpha: 0.7),
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,126 +553,92 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTextBtn(ColorScheme colors, String text, {VoidCallback? onTap}) {
|
Widget _buildRemoteInfoCard(ColorScheme colors) {
|
||||||
return GestureDetector(
|
String timeText;
|
||||||
onTap: onTap,
|
String sizeText = '';
|
||||||
child: Center(
|
|
||||||
child: Padding(
|
if (_isLoadingRemoteInfo) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
timeText = '加载中...';
|
||||||
child: Text(
|
} else if (_remoteModifiedTime != null) {
|
||||||
text,
|
final dt = _remoteModifiedTime!;
|
||||||
style: TextStyle(
|
timeText = '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||||
fontSize: 14,
|
if (_remoteFileSize != null) {
|
||||||
color: onTap == null
|
sizeText = _formatFileSize(_remoteFileSize!);
|
||||||
? colors.onSurface.withValues(alpha: 0.25)
|
}
|
||||||
: colors.onSurface.withValues(alpha: 0.4))),
|
} else {
|
||||||
),
|
timeText = '暂无备份文件';
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSwitchRow({
|
|
||||||
required ColorScheme colors,
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
required String sub,
|
|
||||||
required bool value,
|
|
||||||
required ValueChanged<bool>? onChanged,
|
|
||||||
}) {
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon,
|
Row(
|
||||||
size: 18,
|
children: [
|
||||||
color: value ? colors.primary : colors.onSurface.withValues(alpha: 0.3)),
|
Icon(Icons.cloud_outlined, size: 18, color: colors.primary),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Text('云端备份', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
child: Text(
|
const Spacer(),
|
||||||
value ? sub : label,
|
if (_isLoadingRemoteInfo)
|
||||||
style: TextStyle(
|
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)),
|
||||||
fontSize: 13,
|
],
|
||||||
color: value ? colors.onSurface.withValues(alpha: 0.6) : colors.onSurface),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Switch(
|
const SizedBox(height: 12),
|
||||||
value: value,
|
Row(
|
||||||
onChanged: onChanged,
|
children: [
|
||||||
activeThumbColor: colors.primary,
|
Expanded(
|
||||||
activeTrackColor: colors.primary.withValues(alpha: 0.3),
|
child: Column(
|
||||||
inactiveThumbColor: colors.surface,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
inactiveTrackColor: colors.onSurface.withValues(alpha: 0.15),
|
children: [
|
||||||
|
Text('上传时间', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(timeText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (sizeText.isNotEmpty)
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('文件大小', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(sizeText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(height: 0.5, color: Color(0xFFE0E0E0)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildBtn(colors, '上传', onTap: _isLoading ? null : () => _showUploadConfirm(), loading: _isLoading),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildBtn(colors, '下载', onTap: _isLoading ? null : () => _showDownloadConfirm(), loading: _isLoading),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTappableRow({
|
String _formatFileSize(int bytes) {
|
||||||
required ColorScheme colors,
|
if (bytes < 1024) return '${bytes}B';
|
||||||
required String label,
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB';
|
||||||
required String value,
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB';
|
||||||
VoidCallback? onTap,
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB';
|
||||||
}) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 28),
|
|
||||||
Text(label,
|
|
||||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
|
|
||||||
const Spacer(),
|
|
||||||
Text(value,
|
|
||||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(Icons.chevron_right,
|
|
||||||
size: 16, color: colors.onSurface.withValues(alpha: 0.25)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDirectionChip(ColorScheme colors, String label, SyncDirection dir, IconData icon) {
|
|
||||||
final selected = _syncDirection == dir;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => setState(() => _syncDirection = dir),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: selected ? colors.primary : colors.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon,
|
|
||||||
size: 16,
|
|
||||||
color: selected
|
|
||||||
? colors.onPrimary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.4)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: selected
|
|
||||||
? colors.onPrimary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.6))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTips(ColorScheme colors) {
|
Widget _buildTips(ColorScheme colors) {
|
||||||
|
|||||||
@@ -490,7 +490,57 @@ class WebDAVService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取远程文件的修改时间
|
/// 获取远程备份文件信息(修改时间和大小)
|
||||||
|
Future<Map<String, dynamic>?> getRemoteBackupInfo() async {
|
||||||
|
final config = await getConfig();
|
||||||
|
if (config == null) return null;
|
||||||
|
|
||||||
|
final url = config['url']!;
|
||||||
|
final username = config['username']!;
|
||||||
|
final password = config['password']!;
|
||||||
|
final path = config['path']!;
|
||||||
|
|
||||||
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
|
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
||||||
|
|
||||||
|
final client = http.Client();
|
||||||
|
try {
|
||||||
|
var request = http.Request('HEAD', Uri.parse(zipUrl));
|
||||||
|
request.headers['Authorization'] = _basicAuth(username, password);
|
||||||
|
|
||||||
|
var response = await client.send(request);
|
||||||
|
|
||||||
|
// 处理重定向
|
||||||
|
if (response.statusCode == 301 || response.statusCode == 302 ||
|
||||||
|
response.statusCode == 307 || response.statusCode == 308) {
|
||||||
|
final location = response.headers['location'];
|
||||||
|
if (location != null) {
|
||||||
|
request = http.Request('HEAD', Uri.parse(location));
|
||||||
|
request.headers['Authorization'] = _basicAuth(username, password);
|
||||||
|
response = await client.send(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final lastModified = response.headers['last-modified'];
|
||||||
|
final contentLength = response.headers['content-length'];
|
||||||
|
DateTime? modifiedTime;
|
||||||
|
if (lastModified != null) {
|
||||||
|
modifiedTime = HttpDate.parse(lastModified).toLocal();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'modifiedTime': modifiedTime,
|
||||||
|
'size': contentLength != null ? int.tryParse(contentLength) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[WebDAV] 获取远程备份信息失败: $e');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Future<DateTime?> _getRemoteFileModifiedTime(
|
Future<DateTime?> _getRemoteFileModifiedTime(
|
||||||
http.Client client,
|
http.Client client,
|
||||||
String url,
|
String url,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ void showQuickAddSheet(BuildContext context, AppProvider provider) {
|
|||||||
Navigator.pushNamed(context, '/movie-form',
|
Navigator.pushNamed(context, '/movie-form',
|
||||||
arguments: {'initialStatus': currentStatus});
|
arguments: {'initialStatus': currentStatus});
|
||||||
case 1:
|
case 1:
|
||||||
final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'};
|
final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read', 3: 'abandoned'};
|
||||||
final currentStatus =
|
final currentStatus =
|
||||||
statusMap[provider.bookStatusIndex] ?? 'want_to_read';
|
statusMap[provider.bookStatusIndex] ?? 'want_to_read';
|
||||||
Navigator.pushNamed(context, '/book-form',
|
Navigator.pushNamed(context, '/book-form',
|
||||||
@@ -93,6 +93,7 @@ void showAddSheet(BuildContext context, AppProvider provider) {
|
|||||||
0: 'read',
|
0: 'read',
|
||||||
1: 'reading',
|
1: 'reading',
|
||||||
2: 'want_to_read',
|
2: 'want_to_read',
|
||||||
|
3: 'abandoned',
|
||||||
};
|
};
|
||||||
final s =
|
final s =
|
||||||
statusMap[provider.bookStatusIndex] ?? 'want_to_read';
|
statusMap[provider.bookStatusIndex] ?? 'want_to_read';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class BookStatusBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final tabWidth = constraints.maxWidth / 3;
|
final tabWidth = constraints.maxWidth / 4;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -57,6 +57,8 @@ class BookStatusBar extends StatelessWidget {
|
|||||||
() => provider.setBookStatusIndex(1)),
|
() => provider.setBookStatusIndex(1)),
|
||||||
_buildTab(colors, '想读', Icons.bookmark_outlined, currentIndex == 2,
|
_buildTab(colors, '想读', Icons.bookmark_outlined, currentIndex == 2,
|
||||||
() => provider.setBookStatusIndex(2)),
|
() => provider.setBookStatusIndex(2)),
|
||||||
|
_buildTab(colors, '弃读', Icons.cancel_outlined, currentIndex == 3,
|
||||||
|
() => provider.setBookStatusIndex(3)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -39,75 +39,53 @@ class _NoteListItemContent extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
onLongPress: () => _showActions(context),
|
onLongPress: () => _showActions(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: selected ? Border.all(color: colors.primary, width: 1.5) : null,
|
border: selected ? Border.all(color: colors.primary, width: 1.5) : null,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// 标题(带置顶图标)
|
// 标题行(带置顶图标 + 时间)
|
||||||
if (note.title.isNotEmpty) ...[
|
Row(
|
||||||
const SizedBox(height: 6),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Row(
|
children: [
|
||||||
children: [
|
if (note.isPinned)
|
||||||
if (note.isPinned)
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.only(right: 4),
|
||||||
padding: const EdgeInsets.only(right: 4),
|
child: Icon(Icons.push_pin, size: 14, color: colors.primary),
|
||||||
child: Icon(Icons.push_pin, size: 14, color: colors.primary),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
note.title,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
Expanded(
|
||||||
Text(
|
child: Text(
|
||||||
_formatDate(note.createdAt),
|
note.title.isNotEmpty ? note.title : previewText.isNotEmpty ? previewText : '(无内容)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 15,
|
||||||
color: colors.onSurface.withValues(alpha: 0.35),
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.onSurface,
|
||||||
|
height: 1.3,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
],
|
Text(
|
||||||
|
_formatDate(note.createdAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.onSurface.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
// 内容预览(无标题时才显示置顶和时间,替换原本只有置顶的逻辑)
|
// 内容预览
|
||||||
if (previewText.isNotEmpty) ...[
|
if (previewText.isNotEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (note.title.isEmpty)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (note.isPinned)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: Icon(Icons.push_pin, size: 14, color: colors.primary),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
_formatDate(note.createdAt),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.35),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (note.title.isEmpty)
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
Text(
|
||||||
previewText,
|
previewText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -115,12 +93,12 @@ class _NoteListItemContent extends StatelessWidget {
|
|||||||
color: colors.onSurface.withValues(alpha: 0.5),
|
color: colors.onSurface.withValues(alpha: 0.5),
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
maxLines: 3,
|
maxLines: note.title.isNotEmpty ? 2 : 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 标签(换行显示)
|
// 标签
|
||||||
if (note.tags.isNotEmpty) ...[
|
if (note.tags.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
|
|||||||
Reference in New Issue
Block a user