feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s
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:
@@ -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'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user