feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
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:
163
lib/screens/app_shell.dart
Normal file
163
lib/screens/app_shell.dart
Normal 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
366
lib/screens/board_screen.dart
Normal file
366
lib/screens/board_screen.dart
Normal 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;
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user