feat(sync): WebDAV vault sync
All checks were successful
CI / Windows build (push) Successful in 12m32s
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user