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

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)),