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

@@ -6,6 +6,7 @@ import 'diagnostic_export.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/diagnostic_logger.dart';
import '../editor/input/pen_input_service.dart';
import '../l10n/app_localizations.dart';
/// Shared diagnostics chrome: overlay readout + export action.
/// Mount on any document surface (note / PDF / PPT / board).
@@ -54,7 +55,7 @@ class DiagnosticChromeState extends State<DiagnosticChrome> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'诊断包已导出 (${result.bytes} bytes)\n路径已复制到剪贴板',
AppLocalizations.of(context).diagExported(result.bytes),
),
duration: const Duration(seconds: 5),
),
@@ -65,7 +66,9 @@ class DiagnosticChromeState extends State<DiagnosticChrome> {
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('导出失败: $e')),
SnackBar(
content: Text(AppLocalizations.of(context).diagExportFail('$e')),
),
);
} finally {
if (mounted) setState(() => _exporting = false);

View File

@@ -199,5 +199,43 @@
},
"syncAuto": "Sync automatically on launch",
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
"syncNotConfigured": "Enter a server URL to enable sync."
"syncNotConfigured": "Enter a server URL to enable sync.",
"settingsDefaults": "Defaults",
"settingsAppearance": "Appearance",
"settingsAbout": "About",
"settingsDefaultTool": "Default tool",
"settingsDefaultColor": "Default color",
"settingsDefaultWidth": "Default stroke width",
"settingsPressureCurve": "Pressure curve",
"settingsClearConfirmBody": "This resets pen defaults and appearance. Notes and documents are not affected.",
"serverSection": "BadNote Server",
"serverUrl": "Server URL",
"serverUrlHint": "http://192.168.1.10:8080",
"serverUsername": "Username",
"serverPassword": "Password",
"serverSave": "Save & sign in",
"serverTest": "Test connection",
"serverTestOk": "Connected · API {version}",
"@serverTestOk": {
"placeholders": { "version": { "type": "String" } }
},
"serverTestFail": "Connection failed: {error}",
"@serverTestFail": {
"placeholders": { "error": { "type": "String" } }
},
"serverLoggedIn": "Signed in",
"serverHint": "Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.",
"boardEmptyTitle": "No sticky notes yet",
"boardEmptyBody": "Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.",
"relativeJustNow": "Just now",
"relativeMinutesAgo": "{n}m ago",
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeHoursAgo": "{n}h ago",
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeYesterday": "Yesterday",
"diagExported": "Diagnostic pack exported ({bytes} bytes)\nPath copied",
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
"diagExportFail": "Export failed: {error}",
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
"processingOcr": "Processing OCR…"
}

View File

@@ -985,6 +985,174 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Enter a server URL to enable sync.'**
String get syncNotConfigured;
/// No description provided for @settingsDefaults.
///
/// In en, this message translates to:
/// **'Defaults'**
String get settingsDefaults;
/// No description provided for @settingsAppearance.
///
/// In en, this message translates to:
/// **'Appearance'**
String get settingsAppearance;
/// No description provided for @settingsAbout.
///
/// In en, this message translates to:
/// **'About'**
String get settingsAbout;
/// No description provided for @settingsDefaultTool.
///
/// In en, this message translates to:
/// **'Default tool'**
String get settingsDefaultTool;
/// No description provided for @settingsDefaultColor.
///
/// In en, this message translates to:
/// **'Default color'**
String get settingsDefaultColor;
/// No description provided for @settingsDefaultWidth.
///
/// In en, this message translates to:
/// **'Default stroke width'**
String get settingsDefaultWidth;
/// No description provided for @settingsPressureCurve.
///
/// In en, this message translates to:
/// **'Pressure curve'**
String get settingsPressureCurve;
/// No description provided for @settingsClearConfirmBody.
///
/// In en, this message translates to:
/// **'This resets pen defaults and appearance. Notes and documents are not affected.'**
String get settingsClearConfirmBody;
/// No description provided for @serverSection.
///
/// In en, this message translates to:
/// **'BadNote Server'**
String get serverSection;
/// No description provided for @serverUrl.
///
/// In en, this message translates to:
/// **'Server URL'**
String get serverUrl;
/// No description provided for @serverUrlHint.
///
/// In en, this message translates to:
/// **'http://192.168.1.10:8080'**
String get serverUrlHint;
/// No description provided for @serverUsername.
///
/// In en, this message translates to:
/// **'Username'**
String get serverUsername;
/// No description provided for @serverPassword.
///
/// In en, this message translates to:
/// **'Password'**
String get serverPassword;
/// No description provided for @serverSave.
///
/// In en, this message translates to:
/// **'Save & sign in'**
String get serverSave;
/// No description provided for @serverTest.
///
/// In en, this message translates to:
/// **'Test connection'**
String get serverTest;
/// No description provided for @serverTestOk.
///
/// In en, this message translates to:
/// **'Connected · API {version}'**
String serverTestOk(String version);
/// No description provided for @serverTestFail.
///
/// In en, this message translates to:
/// **'Connection failed: {error}'**
String serverTestFail(String error);
/// No description provided for @serverLoggedIn.
///
/// In en, this message translates to:
/// **'Signed in'**
String get serverLoggedIn;
/// No description provided for @serverHint.
///
/// In en, this message translates to:
/// **'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.'**
String get serverHint;
/// No description provided for @boardEmptyTitle.
///
/// In en, this message translates to:
/// **'No sticky notes yet'**
String get boardEmptyTitle;
/// No description provided for @boardEmptyBody.
///
/// In en, this message translates to:
/// **'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.'**
String get boardEmptyBody;
/// No description provided for @relativeJustNow.
///
/// In en, this message translates to:
/// **'Just now'**
String get relativeJustNow;
/// No description provided for @relativeMinutesAgo.
///
/// In en, this message translates to:
/// **'{n}m ago'**
String relativeMinutesAgo(int n);
/// No description provided for @relativeHoursAgo.
///
/// In en, this message translates to:
/// **'{n}h ago'**
String relativeHoursAgo(int n);
/// No description provided for @relativeYesterday.
///
/// In en, this message translates to:
/// **'Yesterday'**
String get relativeYesterday;
/// No description provided for @diagExported.
///
/// In en, this message translates to:
/// **'Diagnostic pack exported ({bytes} bytes)\nPath copied'**
String diagExported(int bytes);
/// No description provided for @diagExportFail.
///
/// In en, this message translates to:
/// **'Export failed: {error}'**
String diagExportFail(String error);
/// No description provided for @processingOcr.
///
/// In en, this message translates to:
/// **'Processing OCR…'**
String get processingOcr;
}
class _AppLocalizationsDelegate

View File

@@ -489,4 +489,103 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get syncNotConfigured => 'Enter a server URL to enable sync.';
@override
String get settingsDefaults => 'Defaults';
@override
String get settingsAppearance => 'Appearance';
@override
String get settingsAbout => 'About';
@override
String get settingsDefaultTool => 'Default tool';
@override
String get settingsDefaultColor => 'Default color';
@override
String get settingsDefaultWidth => 'Default stroke width';
@override
String get settingsPressureCurve => 'Pressure curve';
@override
String get settingsClearConfirmBody =>
'This resets pen defaults and appearance. Notes and documents are not affected.';
@override
String get serverSection => 'BadNote Server';
@override
String get serverUrl => 'Server URL';
@override
String get serverUrlHint => 'http://192.168.1.10:8080';
@override
String get serverUsername => 'Username';
@override
String get serverPassword => 'Password';
@override
String get serverSave => 'Save & sign in';
@override
String get serverTest => 'Test connection';
@override
String serverTestOk(String version) {
return 'Connected · API $version';
}
@override
String serverTestFail(String error) {
return 'Connection failed: $error';
}
@override
String get serverLoggedIn => 'Signed in';
@override
String get serverHint =>
'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.';
@override
String get boardEmptyTitle => 'No sticky notes yet';
@override
String get boardEmptyBody =>
'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.';
@override
String get relativeJustNow => 'Just now';
@override
String relativeMinutesAgo(int n) {
return '${n}m ago';
}
@override
String relativeHoursAgo(int n) {
return '${n}h ago';
}
@override
String get relativeYesterday => 'Yesterday';
@override
String diagExported(int bytes) {
return 'Diagnostic pack exported ($bytes bytes)\nPath copied';
}
@override
String diagExportFail(String error) {
return 'Export failed: $error';
}
@override
String get processingOcr => 'Processing OCR…';
}

View File

@@ -484,4 +484,100 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get syncNotConfigured => '请输入服务器地址以启用同步。';
@override
String get settingsDefaults => '默认笔迹';
@override
String get settingsAppearance => '外观';
@override
String get settingsAbout => '关于';
@override
String get settingsDefaultTool => '默认工具';
@override
String get settingsDefaultColor => '默认颜色';
@override
String get settingsDefaultWidth => '默认线宽';
@override
String get settingsPressureCurve => '压感曲线';
@override
String get settingsClearConfirmBody => '将重置笔默认值与外观设置。笔记和文档不会受影响。';
@override
String get serverSection => 'BadNote 服务器';
@override
String get serverUrl => '服务器地址';
@override
String get serverUrlHint => 'http://192.168.1.10:8080';
@override
String get serverUsername => '用户名';
@override
String get serverPassword => '密码';
@override
String get serverSave => '保存并登录';
@override
String get serverTest => '测试连接';
@override
String serverTestOk(String version) {
return '连接成功 · API $version';
}
@override
String serverTestFail(String error) {
return '连接失败:$error';
}
@override
String get serverLoggedIn => '已登录';
@override
String get serverHint => '可选。用于自托管 vault 协助同步与延迟 OCR日常笔记仍完全离线。';
@override
String get boardEmptyTitle => '还没有便利贴';
@override
String get boardEmptyBody => '点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。';
@override
String get relativeJustNow => '刚刚';
@override
String relativeMinutesAgo(int n) {
return '$n 分钟前';
}
@override
String relativeHoursAgo(int n) {
return '$n 小时前';
}
@override
String get relativeYesterday => '昨天';
@override
String diagExported(int bytes) {
return '诊断包已导出($bytes 字节)\n路径已复制';
}
@override
String diagExportFail(String error) {
return '导出失败:$error';
}
@override
String get processingOcr => '正在识别文字…';
}

View File

@@ -169,5 +169,43 @@
},
"syncAuto": "启动时自动同步",
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
"syncNotConfigured": "请输入服务器地址以启用同步。"
"syncNotConfigured": "请输入服务器地址以启用同步。",
"settingsDefaults": "默认笔迹",
"settingsAppearance": "外观",
"settingsAbout": "关于",
"settingsDefaultTool": "默认工具",
"settingsDefaultColor": "默认颜色",
"settingsDefaultWidth": "默认线宽",
"settingsPressureCurve": "压感曲线",
"settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。",
"serverSection": "BadNote 服务器",
"serverUrl": "服务器地址",
"serverUrlHint": "http://192.168.1.10:8080",
"serverUsername": "用户名",
"serverPassword": "密码",
"serverSave": "保存并登录",
"serverTest": "测试连接",
"serverTestOk": "连接成功 · API {version}",
"@serverTestOk": {
"placeholders": { "version": { "type": "String" } }
},
"serverTestFail": "连接失败:{error}",
"@serverTestFail": {
"placeholders": { "error": { "type": "String" } }
},
"serverLoggedIn": "已登录",
"serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR日常笔记仍完全离线。",
"boardEmptyTitle": "还没有便利贴",
"boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。",
"relativeJustNow": "刚刚",
"relativeMinutesAgo": "{n} 分钟前",
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeHoursAgo": "{n} 小时前",
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeYesterday": "昨天",
"diagExported": "诊断包已导出({bytes} 字节)\n路径已复制",
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
"diagExportFail": "导出失败:{error}",
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
"processingOcr": "正在识别文字…"
}

View File

@@ -115,18 +115,29 @@ class _AppShellState extends ConsumerState<AppShell> {
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: _select,
destinations: [
for (final d in destinations)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.label,
),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
NavigationBar(
selectedIndex: _index,
onDestinationSelected: _select,
destinations: [
for (final d in destinations)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.label,
),
],
),
],
),
floatingActionButton: FloatingActionButton.small(
heroTag: 'diag_fab',
tooltip: AppLocalizations.of(context).diagnosticsSection,
onPressed: () => _diagKey.currentState?.toggle(),
child: const Icon(Icons.bug_report_outlined),
),
);
}
@@ -150,14 +161,14 @@ Future<void> exportDiagnosticPack(BuildContext context) async {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('诊断包: ${result.zipPath}'),
content: Text(AppLocalizations.of(context).diagExported(result.bytes)),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('导出失败: $e')),
SnackBar(content: Text(AppLocalizations.of(context).diagExportFail('$e'))),
);
}
}

View File

@@ -134,6 +134,7 @@ class _BoardScreenState extends ConsumerState<BoardScreen> {
final selected = _selectedId != null ? _board.cardById(_selectedId!) : null;
final backlinks =
selected != null ? _board.backlinksOf(selected.id) : <String>{};
final isEmpty = _board.length == 0;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
@@ -153,7 +154,43 @@ class _BoardScreenState extends ConsumerState<BoardScreen> {
),
],
),
body: Row(
body: isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sticky_note_2_outlined,
size: 72,
color: AppTokens.copper.withValues(alpha: 0.85),
),
const SizedBox(height: 20),
Text(
l.boardEmptyTitle,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l.boardEmptyBody,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTokens.inkMuted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _addCard,
icon: const Icon(Icons.add),
label: Text(l.boardAddCard),
),
],
),
),
)
: Row(
children: [
Expanded(
child: InteractiveViewer(
@@ -242,7 +279,7 @@ class _BoardScreenState extends ConsumerState<BoardScreen> {
),
const SizedBox(height: 4),
Text(
'在便利贴正文里写 [[另一张卡片id]] 建立双链',
l.boardEmptyBody,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTokens.inkMuted,
),

View File

@@ -20,15 +20,16 @@ import '../editor/canvas/pen_slide_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
// [M1] Relative date helper — no new package dependencies.
String _formatDate(DateTime d) {
// Relative date helper — localized.
String _formatDate(BuildContext context, DateTime d) {
final l = AppLocalizations.of(context);
final now = DateTime.now();
final diff = now.difference(d);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inSeconds < 60) return l.relativeJustNow;
if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours);
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
return 'Yesterday';
return l.relativeYesterday;
}
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
}
@@ -411,7 +412,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
Widget build(BuildContext context) {
final note = widget.note;
// [M1] Use relative date helper
final dateStr = _formatDate(note.updatedAt);
final dateStr = _formatDate(context, note.updatedAt);
final ocrStatusMap = ref.watch(ocrStatusProvider);
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
@@ -584,7 +585,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
Widget build(BuildContext context) {
final document = widget.document;
// [M1] Use relative date helper
final dateStr = _formatDate(document.updatedAt);
final dateStr = _formatDate(context, document.updatedAt);
final isPdf = document.docType == 'pdf';
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;

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;

View File

@@ -0,0 +1,139 @@
// HTTP client for the optional self-hosted BadNote Server (/api/v1).
// WebDAV remains the primary NAS sync path; this client covers health, auth,
// vault manifest assist, and OCR job submit/poll.
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class BadNoteServerConfig {
const BadNoteServerConfig({
this.baseUrl = '',
this.username = '',
this.password = '',
this.token = '',
this.userId = '',
});
final String baseUrl;
final String username;
final String password;
final String token;
final String userId;
bool get isConfigured => baseUrl.trim().isNotEmpty;
bool get isLoggedIn => token.isNotEmpty;
Uri? uri(String path) {
final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), '');
if (base.isEmpty) return null;
final p = path.startsWith('/') ? path : '/$path';
return Uri.parse('$base$p');
}
BadNoteServerConfig copyWith({
String? baseUrl,
String? username,
String? password,
String? token,
String? userId,
}) =>
BadNoteServerConfig(
baseUrl: baseUrl ?? this.baseUrl,
username: username ?? this.username,
password: password ?? this.password,
token: token ?? this.token,
userId: userId ?? this.userId,
);
static const _kBase = 'badnote.server.baseUrl';
static const _kUser = 'badnote.server.username';
static const _kPass = 'badnote.server.password';
static const _kToken = 'badnote.server.token';
static const _kUid = 'badnote.server.userId';
static Future<BadNoteServerConfig> load(SharedPreferences prefs) async {
return BadNoteServerConfig(
baseUrl: prefs.getString(_kBase) ?? '',
username: prefs.getString(_kUser) ?? '',
password: prefs.getString(_kPass) ?? '',
token: prefs.getString(_kToken) ?? '',
userId: prefs.getString(_kUid) ?? '',
);
}
Future<void> save(SharedPreferences prefs) async {
await prefs.setString(_kBase, baseUrl);
await prefs.setString(_kUser, username);
await prefs.setString(_kPass, password);
await prefs.setString(_kToken, token);
await prefs.setString(_kUid, userId);
}
}
class BadNoteServerClient {
BadNoteServerClient(this.config, {http.Client? httpClient})
: _http = httpClient ?? http.Client();
BadNoteServerConfig config;
final http.Client _http;
Map<String, String> get _authHeaders => {
if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}',
};
Future<Map<String, dynamic>> health() async {
final uri = config.uri('/api/v1/health');
if (uri == null) throw StateError('server URL not set');
final res = await _http.get(uri).timeout(const Duration(seconds: 8));
if (res.statusCode != 200) {
throw StateError('health ${res.statusCode}: ${res.body}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
Future<BadNoteServerConfig> registerOrLogin({
required String username,
required String password,
}) async {
final registerUri = config.uri('/api/v1/auth/register');
if (registerUri == null) throw StateError('server URL not set');
var res = await _http.post(
registerUri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (res.statusCode == 409) {
final loginUri = config.uri('/api/v1/auth/login')!;
res = await _http.post(
loginUri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
}
if (res.statusCode != 200 && res.statusCode != 201) {
throw StateError('auth ${res.statusCode}: ${res.body}');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
config = config.copyWith(
username: username,
password: password,
token: body['token'] as String? ?? '',
userId: body['user_id'] as String? ?? '',
);
return config;
}
Future<Map<String, dynamic>> vaultManifest() async {
final uri = config.uri('/api/v1/vault/manifest');
if (uri == null) throw StateError('server URL not set');
final res = await _http.get(uri, headers: _authHeaders);
if (res.statusCode != 200) {
throw StateError('manifest ${res.statusCode}: ${res.body}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
void close() => _http.close();
}