feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s

Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 17:55:27 +08:00
parent 3cabc7e074
commit d346cc2670
49 changed files with 2883 additions and 2515 deletions

163
lib/screens/app_shell.dart Normal file
View File

@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../diagnostics/badnote_log.dart';
import '../diagnostics/diagnostic_chrome.dart';
import '../diagnostics/diagnostic_export.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_theme.dart';
import 'board_screen.dart';
import 'home_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
/// Unified product shell — single chrome for library, board, search, settings.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
int _index = 0;
final _diagKey = GlobalKey<DiagnosticChromeState>();
@override
void initState() {
super.initState();
BadNoteLog.instance.info(LogSubsystem.shell, 'shell_open');
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final wide = MediaQuery.sizeOf(context).width >= 900;
final destinations = [
_Dest(Icons.menu_book_outlined, Icons.menu_book, l.libraryTab),
_Dest(Icons.sticky_note_2_outlined, Icons.sticky_note_2, l.boardTab),
_Dest(Icons.search, Icons.search, l.search),
_Dest(Icons.tune, Icons.tune, l.settings),
];
final pages = const [
HomeScreen(embeddedInShell: true),
BoardScreen(),
SearchScreen(embeddedInShell: true),
SettingsScreen(embeddedInShell: true),
];
final body = DiagnosticChrome(
key: _diagKey,
child: pages[_index],
);
if (wide) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _index,
onDestinationSelected: _select,
extended: MediaQuery.sizeOf(context).width >= 1200,
labelType: MediaQuery.sizeOf(context).width >= 1200
? NavigationRailLabelType.none
: NavigationRailLabelType.all,
leading: Padding(
padding: const EdgeInsets.only(top: 12, bottom: 24),
child: Column(
children: [
Text(
'BadNote',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppTokens.copper,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l.shellTagline,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppTokens.inkMuted,
),
),
],
),
),
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: DiagnosticToggleButton(
onToggle: () => _diagKey.currentState?.toggle(),
onExport: () => _diagKey.currentState?.exportPack(),
),
),
),
),
destinations: [
for (final d in destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
),
VerticalDivider(width: 1, color: AppTokens.rule.withValues(alpha: 0.8)),
Expanded(child: body),
],
),
);
}
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,
),
],
),
);
}
void _select(int i) {
BadNoteLog.instance.info(LogSubsystem.shell, 'tab', fields: {'index': i});
setState(() => _index = i);
}
}
class _Dest {
const _Dest(this.icon, this.selectedIcon, this.label);
final IconData icon;
final IconData selectedIcon;
final String label;
}
/// Helper used by settings when not embedded — still export packs.
Future<void> exportDiagnosticPack(BuildContext context) async {
try {
final result = await DiagnosticExport.instance.exportPack();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('诊断包: ${result.zipPath}'),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('导出失败: $e')),
);
}
}

View File

@@ -0,0 +1,366 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../diagnostics/badnote_log.dart';
import '../editor/board/board.dart';
import '../l10n/app_localizations.dart';
import '../services/database_service.dart';
import '../theme/app_theme.dart';
const _kDefaultBoardId = 'main';
/// Infinite sticky-note board — first-class shell destination (F7).
class BoardScreen extends ConsumerStatefulWidget {
const BoardScreen({super.key});
@override
ConsumerState<BoardScreen> createState() => _BoardScreenState();
}
class _BoardScreenState extends ConsumerState<BoardScreen> {
Board _board = Board.empty;
bool _loading = true;
String? _selectedId;
final _transform = TransformationController();
Timer? _saveDebounce;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_saveDebounce?.cancel();
_transform.dispose();
super.dispose();
}
Future<void> _load() async {
final db = await DatabaseService.getInstance();
final board = await db.loadBoard(_kDefaultBoardId);
if (!mounted) return;
setState(() {
_board = board;
_loading = false;
});
BadNoteLog.instance.info(
LogSubsystem.board,
'board_loaded',
fields: {'cards': board.length},
);
}
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 400), () async {
final db = await DatabaseService.getInstance();
await db.saveBoardCards(_kDefaultBoardId, _board.cards);
BadNoteLog.instance.debug(
LogSubsystem.board,
'board_saved',
fields: {'cards': _board.length},
);
});
}
void _addCard() {
final l = AppLocalizations.of(context);
final id = const Uuid().v4();
// Place near viewport center in scene coords.
final matrix = _transform.value;
final inv = Matrix4.inverted(matrix);
final center = MatrixUtils.transformPoint(
inv,
Offset(
MediaQuery.sizeOf(context).width / 2,
MediaQuery.sizeOf(context).height / 2,
),
);
setState(() {
_board = _board.add(
BoardCard(
id: id,
position: center - const Offset(120, 80),
size: const Size(240, 160),
text: l.boardNewCardText,
),
);
_selectedId = id;
});
_scheduleSave();
}
void _deleteSelected() {
final id = _selectedId;
if (id == null) return;
final l = AppLocalizations.of(context);
showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.boardDeleteCardTitle),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.boardDeleteCard),
),
],
),
).then((ok) {
if (ok != true) return;
setState(() {
_board = _board.removeById(id);
_selectedId = null;
});
_scheduleSave();
});
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
final selected = _selectedId != null ? _board.cardById(_selectedId!) : null;
final backlinks =
selected != null ? _board.backlinksOf(selected.id) : <String>{};
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
title: Text(l.boardTitle),
actions: [
IconButton(
tooltip: l.boardAddCard,
onPressed: _addCard,
icon: const Icon(Icons.add),
),
if (_selectedId != null)
IconButton(
tooltip: l.boardDeleteCard,
onPressed: _deleteSelected,
icon: const Icon(Icons.delete_outline),
),
],
),
body: Row(
children: [
Expanded(
child: InteractiveViewer(
transformationController: _transform,
constrained: false,
boundaryMargin: const EdgeInsets.all(2000),
minScale: 0.25,
maxScale: 3,
child: SizedBox(
width: 4000,
height: 3000,
child: CustomPaint(
painter: _BoardGridPainter(
color: AppTokens.rule.withValues(alpha: 0.45),
),
child: Stack(
children: [
for (final card in _board.cards)
Positioned(
left: card.position.dx,
top: card.position.dy,
width: card.size.width,
height: card.size.height,
child: _StickyCard(
card: card,
selected: card.id == _selectedId,
onTap: () => setState(() => _selectedId = card.id),
onDrag: (delta) {
setState(() {
_board = _board.moveCard(
card.id,
card.position + delta,
);
});
_scheduleSave();
},
onTextChanged: (text) {
setState(() {
_board = _board.setText(card.id, text);
});
_scheduleSave();
},
),
),
],
),
),
),
),
),
if (selected != null)
SizedBox(
width: 260,
child: Material(
elevation: 1,
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.all(AppTokens.chromePad),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.boardBacklinks,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (backlinks.isEmpty)
Text(
l.boardNoBacklinks,
style: Theme.of(context).textTheme.bodySmall,
)
else
...backlinks.map((id) {
final c = _board.cardById(id);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(c?.text.split('\n').first ?? id),
onTap: () => setState(() => _selectedId = id),
);
}),
const Divider(),
Text(
'[[links]]',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
Text(
'在便利贴正文里写 [[另一张卡片id]] 建立双链',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTokens.inkMuted,
),
),
],
),
),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addCard,
icon: const Icon(Icons.sticky_note_2),
label: Text(l.boardAddCard),
),
);
}
}
class _StickyCard extends StatefulWidget {
const _StickyCard({
required this.card,
required this.selected,
required this.onTap,
required this.onDrag,
required this.onTextChanged,
});
final BoardCard card;
final bool selected;
final VoidCallback onTap;
final ValueChanged<Offset> onDrag;
final ValueChanged<String> onTextChanged;
@override
State<_StickyCard> createState() => _StickyCardState();
}
class _StickyCardState extends State<_StickyCard> {
late final TextEditingController _controller =
TextEditingController(text: widget.card.text);
@override
void didUpdateWidget(covariant _StickyCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.card.text != widget.card.text &&
_controller.text != widget.card.text) {
_controller.text = widget.card.text;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onPanUpdate: (d) => widget.onDrag(d.delta),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTokens.sticky,
borderRadius: BorderRadius.circular(AppTokens.radiusSm),
border: Border.all(
color: widget.selected ? AppTokens.copper : AppTokens.rule,
width: widget.selected ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: TextField(
controller: _controller,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTokens.ink,
),
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onChanged: widget.onTextChanged,
),
),
);
}
}
class _BoardGridPainter extends CustomPainter {
_BoardGridPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 1;
const step = 48.0;
for (double x = 0; x < size.width; x += step) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = 0; y < size.height; y += step) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(covariant _BoardGridPainter oldDelegate) =>
oldDelegate.color != color;
}

View File

@@ -12,6 +12,7 @@ import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../providers/search_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/canvas/office_document_screen.dart';
import '../services/pptx_service.dart';
import '../services/vault_service.dart';
import '../editor/canvas/pen_note_screen.dart';
@@ -33,7 +34,10 @@ String _formatDate(DateTime d) {
}
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
const HomeScreen({super.key, this.embeddedInShell = false});
/// When true, chrome (settings/search) is owned by [AppShell].
final bool embeddedInShell;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -42,37 +46,40 @@ class HomeScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: Text(l.appTitle),
centerTitle: true,
title: Text(embeddedInShell ? l.libraryTab : l.appTitle),
centerTitle: !embeddedInShell,
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: l.settings,
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
if (!embeddedInShell) ...[
IconButton(
icon: const Icon(Icons.settings),
tooltip: l.settings,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: l.search,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SearchScreen()),
);
},
),
],
IconButton(
icon: const Icon(Icons.file_open),
tooltip: l.importFile,
onPressed: () => _importFile(context, ref),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: l.search,
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
},
),
],
),
floatingActionButton: FloatingActionButton(
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _createAndOpenNote(context, ref),
child: const Icon(Icons.add),
icon: const Icon(Icons.add),
label: Text(l.createNotebook),
),
body: notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -93,15 +100,14 @@ class HomeScreen extends ConsumerWidget {
},
child: CustomScrollView(
slivers: [
// Notes section header always shown when documents exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
l.notesSection,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
fontWeight: FontWeight.bold,
),
),
),
),
@@ -133,19 +139,19 @@ class HomeScreen extends ConsumerWidget {
),
),
),
// Documents section header always shown when notes exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Recent Documents',
l.documentsSection,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
fontWeight: FontWeight.bold,
),
),
),
),
if (documents.isNotEmpty)
// continue existing document list below — marker for patch
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
@@ -154,7 +160,6 @@ class HomeScreen extends ConsumerWidget {
),
)
else
// [M2] Per-section empty hint when notes exist but documents don't
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -284,9 +289,8 @@ class HomeScreen extends ConsumerWidget {
}
/// Route an in-vault [filePath] to the correct editor by extension:
/// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to
/// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed
/// conversions surface a friendly message instead of crashing.
/// pdf → [PenEditorScreen]; pptx/docx → native [OfficeDocumentScreen];
/// legacy .ppt may still use image fallback.
Future<void> _openVaultFile(
BuildContext context,
WidgetRef ref,
@@ -303,27 +307,15 @@ class HomeScreen extends ConsumerWidget {
),
);
case 'pptx':
case 'ppt':
await _openPresentation(context, filePath);
case 'docx':
final pptxService = PptxService();
final pdfPath = await pptxService.convertToPdf(filePath);
if (!context.mounted) return;
if (pdfPath == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.convertNeedsLibreOffice)),
);
return;
}
// The converted PDF lives next to the docx in the notebook folder, so
// it becomes the annotatable artifact; re-scan picks it up.
await ref.read(documentListProvider.notifier).loadDocuments();
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: pdfPath),
builder: (_) => OfficeDocumentScreen(filePath: filePath),
),
);
case 'ppt':
// Legacy binary PPT — try native-ish image path for now.
await _openPresentation(context, filePath);
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.unsupportedFileType(ext))),
@@ -374,14 +366,14 @@ class HomeScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
Text(
'No notes yet',
AppLocalizations.of(context).emptyLibraryTitle,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Create your first note',
AppLocalizations.of(context).emptyLibraryBody,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
@@ -639,8 +631,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
);
}
// Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen,
// docx → best-effort convert-to-PDF then open as PDF.
// Route by docType: pdf → PenEditorScreen; pptx/docx → native OfficeDocumentScreen.
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final l = AppLocalizations.of(context);
@@ -654,27 +645,17 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
return;
}
if (document.docType == 'docx') {
final pdfPath = await PptxService().convertToPdf(document.filePath);
if (!mounted) return;
if (pdfPath == null) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(l.convertNeedsLibreOffice)),
);
return;
}
await ref.read(documentListProvider.notifier).loadDocuments();
if (!mounted) return;
Navigator.of(this.context).push(
if (document.docType == 'docx' || document.docType == 'pptx') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: pdfPath),
builder: (_) => OfficeDocumentScreen(filePath: document.filePath),
),
);
return;
}
{
// PPT/PPTX: convert to images then push PenSlideScreen
// Legacy .ppt: convert to images then push PenSlideScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),

View File

@@ -10,7 +10,9 @@ import '../providers/search_provider.dart';
import '../editor/canvas/pen_note_screen.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
const SearchScreen({super.key, this.embeddedInShell = false});
final bool embeddedInShell;
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();

View File

@@ -8,13 +8,16 @@ import '../l10n/app_localizations.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/settings_provider.dart';
import '../screens/app_shell.dart' show exportDiagnosticPack;
import '../services/vault_service.dart';
import '../services/webdav_sync_service.dart';
import '../utils/stroke_stabilizer.dart';
/// Material 3 settings screen for BadNote.
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
const SettingsScreen({super.key, this.embeddedInShell = false});
final bool embeddedInShell;
void _showColorPicker(
BuildContext context,
@@ -88,9 +91,22 @@ class SettingsScreen extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).settings)),
appBar: embeddedInShell
? AppBar(title: Text(AppLocalizations.of(context).settings))
: AppBar(title: Text(AppLocalizations.of(context).settings)),
body: ListView(
children: [
_SectionHeader(
title: AppLocalizations.of(context).diagnosticsSection,
icon: Icons.bug_report_outlined,
),
ListTile(
title: Text(AppLocalizations.of(context).diagnosticsExport),
subtitle: Text(AppLocalizations.of(context).diagnosticsExportHint),
trailing: const Icon(Icons.ios_share),
onTap: () => exportDiagnosticPack(context),
),
const Divider(),
_SectionHeader(title: 'Defaults', icon: Icons.tune),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),