feat(sync): WebDAV vault sync
All checks were successful
CI / Windows build (push) Successful in 12m32s

Two-way sync of the vault folder to a user-configured WebDAV server,
so annotations (which travel with the file) sync with the file.

- WebDavSyncService.syncNow: per-file decision — local-only uploads,
  remote-only downloads, and when BOTH sides changed since the last
  sync it keeps the loser as <file>.conflict-<mtime> on both sides
  (last-write-wins by mtime) so no data is ever lost. Creates dirs as
  needed; deletes are conservative.
- The decision logic is pure and unit-tested against a fake WebDAV
  client; the real client is a thin http adapter (no dio dependency).
- Settings: WebDAV URL / user / password / remote folder, Test
  connection, Sync now (with status + last-synced), and an auto-sync
  toggle (default OFF).

Real server round-trips are device/server-validated. Credentials are in
SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
This commit is contained in:
2026-06-25 01:35:13 +08:00
parent e939759458
commit 3cabc7e074
12 changed files with 2012 additions and 5 deletions

View File

@@ -2,12 +2,14 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/app_localizations.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/settings_provider.dart';
import '../services/vault_service.dart';
import '../services/webdav_sync_service.dart';
import '../utils/stroke_stabilizer.dart';
/// Material 3 settings screen for BadNote.
@@ -290,6 +292,15 @@ class SettingsScreen extends ConsumerWidget {
child: _VaultSettings(),
),
const Divider(),
_SectionHeader(
title: AppLocalizations.of(context).syncSection,
icon: Icons.cloud_sync,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _SyncSettings(),
),
const Divider(),
_SectionHeader(title: 'About', icon: Icons.info),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -415,6 +426,273 @@ class _VaultSettingsState extends State<_VaultSettings> {
}
}
/// WebDAV sync configuration + actions. Persists config through
/// [WebDavSyncService] (SharedPreferences-backed), exposes Test connection /
/// Sync now buttons, the last-synced time and last result, and an
/// "auto-sync on launch" toggle (default OFF). All network ops are
/// time-bounded inside the service and surface friendly errors here.
class _SyncSettings extends StatefulWidget {
const _SyncSettings();
@override
State<_SyncSettings> createState() => _SyncSettingsState();
}
class _SyncSettingsState extends State<_SyncSettings> {
WebDavSyncService? _sync;
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _folderCtrl = TextEditingController();
bool _autoSync = false;
bool _busy = false;
bool _testing = false;
bool _obscure = true;
DateTime? _lastSync;
SyncResult? _lastResult;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
_folderCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final sync = WebDavSyncService(prefs);
if (!mounted) return;
final c = sync.config;
setState(() {
_sync = sync;
_urlCtrl.text = c.baseUrl;
_userCtrl.text = c.username;
_passCtrl.text = c.password;
_folderCtrl.text = c.remoteRoot;
_autoSync = c.autoSync;
_lastSync = sync.lastSyncTime;
});
}
WebDavConfig _currentConfig() => WebDavConfig(
baseUrl: _urlCtrl.text,
username: _userCtrl.text,
password: _passCtrl.text,
remoteRoot: _folderCtrl.text,
autoSync: _autoSync,
);
Future<void> _save() async {
final sync = _sync;
if (sync == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await sync.saveConfig(_currentConfig());
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l.syncSaved)));
}
Future<void> _testConnection() async {
final sync = _sync;
if (sync == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await sync.saveConfig(_currentConfig());
setState(() => _testing = true);
final client = sync.buildClient();
try {
if (client == null) {
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
return;
}
await client.testConnection();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l.syncTestOk)));
} catch (e) {
if (!mounted) return;
messenger
.showSnackBar(SnackBar(content: Text(l.syncTestFailed(e.toString()))));
} finally {
client?.close();
if (mounted) setState(() => _testing = false);
}
}
Future<void> _syncNow() async {
final sync = _sync;
if (sync == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await sync.saveConfig(_currentConfig());
final vault = await VaultService.getInstance();
final root = vault.vaultRoot;
if (root == null || root.isEmpty) {
messenger.showSnackBar(SnackBar(content: Text(l.vaultNoneSelected)));
return;
}
setState(() => _busy = true);
final client = sync.buildClient();
try {
if (client == null) {
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
return;
}
final result = await sync.syncNow(vaultRoot: root, client: client);
if (!mounted) return;
setState(() {
_lastResult = result;
_lastSync = sync.lastSyncTime;
});
messenger.showSnackBar(SnackBar(
content: Text(result.ok
? l.syncResultSummary(
result.uploaded, result.downloaded, result.conflicts)
: l.syncFailed(result.error ?? '')),
));
} finally {
client?.close();
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final configured = _urlCtrl.text.trim().isNotEmpty;
final lastResult = _lastResult;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _urlCtrl,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncServerUrl,
hintText: l.syncServerUrlHint,
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
TextField(
controller: _userCtrl,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncUsername,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: _passCtrl,
obscureText: _obscure,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncPassword,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _folderCtrl,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncRemoteFolder,
hintText: l.syncRemoteFolderHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 8),
Text(
l.syncCredentialsNote,
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l.syncAuto),
value: _autoSync,
onChanged: (v) {
setState(() => _autoSync = v);
_save();
},
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: (_busy || _testing) ? null : _save,
icon: const Icon(Icons.save),
label: Text(l.syncSave),
),
OutlinedButton.icon(
onPressed: (!configured || _busy || _testing)
? null
: _testConnection,
icon: _testing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_tethering),
label: Text(l.syncTestConnection),
),
FilledButton.icon(
onPressed: (!configured || _busy || _testing) ? null : _syncNow,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(_busy ? l.syncRunning : l.syncNow),
),
],
),
const SizedBox(height: 12),
Text(
_lastSync == null
? l.syncNeverRun
: l.syncLastRun(_lastSync!.toLocal().toString()),
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
),
if (lastResult != null && lastResult.ok) ...[
const SizedBox(height: 4),
Text(
l.syncResultSummary(lastResult.uploaded, lastResult.downloaded,
lastResult.conflicts),
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
),
],
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final IconData icon;