feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s

Redesign the optional FastAPI companion around vault files (manifest /
PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a
client Server settings panel for health/login. Polish shell UX: l10n for
settings/home/board, sticky-board empty state, and a narrow-screen
diagnostics FAB.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 19:04:48 +08:00
parent d346cc2670
commit 198da00ecd
20 changed files with 1325 additions and 90 deletions

View File

@@ -9,6 +9,7 @@ import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/settings_provider.dart';
import '../screens/app_shell.dart' show exportDiagnosticPack;
import '../services/badnote_server_client.dart';
import '../services/vault_service.dart';
import '../services/webdav_sync_service.dart';
import '../utils/stroke_stabilizer.dart';
@@ -60,10 +61,7 @@ class SettingsScreen extends ConsumerWidget {
context: context,
builder: (ctx) => AlertDialog(
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
content: const Text(
'This will reset pen defaults and appearance settings. '
'Notes and documents are not affected.',
),
content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
@@ -107,15 +105,18 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => exportDiagnosticPack(context),
),
const Divider(),
_SectionHeader(title: 'Defaults', icon: Icons.tune),
_SectionHeader(
title: AppLocalizations.of(context).settingsDefaults,
icon: Icons.tune,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Default Tool',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsDefaultTool,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PenTool>(
@@ -135,9 +136,9 @@ class SettingsScreen extends ConsumerWidget {
},
),
const SizedBox(height: 16),
const Text(
'Default Color',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsDefaultColor,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
@@ -170,9 +171,9 @@ class SettingsScreen extends ConsumerWidget {
],
),
const SizedBox(height: 16),
const Text(
'Default Stroke Width',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsDefaultWidth,
style: const TextStyle(fontWeight: FontWeight.w500),
),
Slider(
value: settings.defaultStrokeWidth,
@@ -183,9 +184,9 @@ class SettingsScreen extends ConsumerWidget {
onChanged: settings.setDefaultStrokeWidth,
),
const SizedBox(height: 16),
const Text(
'Pressure Curve',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsPressureCurve,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PressureCurveType>(
@@ -232,15 +233,18 @@ class SettingsScreen extends ConsumerWidget {
),
),
const Divider(),
_SectionHeader(title: 'Appearance', icon: Icons.palette),
_SectionHeader(
title: AppLocalizations.of(context).settingsAppearance,
icon: Icons.palette,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Theme Mode',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsAppearance,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
SegmentedButton<ThemeMode>(
@@ -267,9 +271,9 @@ class SettingsScreen extends ConsumerWidget {
},
),
const SizedBox(height: 16),
const Text(
'Color Scheme Seed',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).seedColorDesc,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
@@ -302,7 +306,10 @@ class SettingsScreen extends ConsumerWidget {
),
),
const Divider(),
_SectionHeader(title: 'Vault', icon: Icons.folder_special),
_SectionHeader(
title: AppLocalizations.of(context).vaultSection,
icon: Icons.folder_special,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _VaultSettings(),
@@ -317,7 +324,19 @@ class SettingsScreen extends ConsumerWidget {
child: _SyncSettings(),
),
const Divider(),
_SectionHeader(title: 'About', icon: Icons.info),
_SectionHeader(
title: AppLocalizations.of(context).serverSection,
icon: Icons.dns_outlined,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _ServerSettings(),
),
const Divider(),
_SectionHeader(
title: AppLocalizations.of(context).settingsAbout,
icon: Icons.info,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
@@ -709,6 +728,166 @@ class _SyncSettingsState extends State<_SyncSettings> {
}
}
class _ServerSettings extends StatefulWidget {
const _ServerSettings();
@override
State<_ServerSettings> createState() => _ServerSettingsState();
}
class _ServerSettingsState extends State<_ServerSettings> {
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _busy = false;
bool _loggedIn = false;
String? _status;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final cfg = await BadNoteServerConfig.load(prefs);
if (!mounted) return;
setState(() {
_urlCtrl.text = cfg.baseUrl;
_userCtrl.text = cfg.username;
_passCtrl.text = cfg.password;
_loggedIn = cfg.isLoggedIn;
});
}
Future<void> _saveAndLogin() async {
final l = AppLocalizations.of(context);
setState(() => _busy = true);
try {
final prefs = await SharedPreferences.getInstance();
var cfg = BadNoteServerConfig(
baseUrl: _urlCtrl.text.trim(),
username: _userCtrl.text.trim(),
password: _passCtrl.text,
);
final client = BadNoteServerClient(cfg);
cfg = await client.registerOrLogin(
username: cfg.username,
password: cfg.password,
);
await cfg.save(prefs);
client.close();
if (!mounted) return;
setState(() {
_loggedIn = true;
_status = l.serverLoggedIn;
});
} catch (e) {
if (!mounted) return;
setState(() => _status = l.serverTestFail('$e'));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _test() async {
final l = AppLocalizations.of(context);
setState(() => _busy = true);
try {
final cfg = BadNoteServerConfig(baseUrl: _urlCtrl.text.trim());
final client = BadNoteServerClient(cfg);
final health = await client.health();
client.close();
if (!mounted) return;
setState(() {
_status = l.serverTestOk('${health['version'] ?? health['api']}');
});
} catch (e) {
if (!mounted) return;
setState(() => _status = l.serverTestFail('$e'));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.serverHint,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextField(
controller: _urlCtrl,
decoration: InputDecoration(
labelText: l.serverUrl,
hintText: l.serverUrlHint,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 8),
TextField(
controller: _userCtrl,
decoration: InputDecoration(
labelText: l.serverUsername,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 8),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: l.serverPassword,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton(
onPressed: _busy ? null : _saveAndLogin,
child: Text(l.serverSave),
),
OutlinedButton(
onPressed: _busy ? null : _test,
child: Text(l.serverTest),
),
if (_loggedIn)
Chip(
avatar: const Icon(Icons.check_circle, size: 16),
label: Text(l.serverLoggedIn),
),
],
),
if (_status != null) ...[
const SizedBox(height: 8),
Text(_status!, style: Theme.of(context).textTheme.bodySmall),
],
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final IconData icon;