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

@@ -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;