// lib/editor/canvas/pen_note_screen.dart // // Pen-first blank-note editor. Reuses the single performant inking engine // (PenCanvas) over a white logical page instead of a PDF page, and persists // strokes back to the Note model via the InkStroke<->PenStroke adapter. This is // the note half of "all note features on the pen-first canvas". import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../models/ink_stroke.dart'; import '../../models/note.dart'; import '../../providers/note_provider.dart'; import '../../providers/ocr_provider.dart'; import '../engine/brush.dart'; import '../engine/shape_geometry.dart'; import '../engine/stroke_model.dart'; import '../persistence/sidecar_repository.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import '../input/pen_slots.dart'; import '../input/pressure_curve.dart' show kNaturalPressureGamma; import '../layout/viewport_fit.dart'; import '../notebook/ink_stroke_adapter.dart'; import '../ui/pen_settings_page.dart'; import 'editor_tool.dart'; import 'note_background.dart'; import 'pen_canvas.dart'; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; class PenNoteScreen extends ConsumerStatefulWidget { const PenNoteScreen({super.key, this.note}); /// Existing note to edit, or null for a new note. final Note? note; @override ConsumerState createState() => _PenNoteScreenState(); } class _PenNoteScreenState extends ConsumerState { static const _uuid = Uuid(); /// Per-page live strokes in normalized coords (the canvas source of truth). final Map> _strokesByPage = {}; /// Current page index (0-based) and total page count (min 1). int _pageIndex = 0; int _pageCount = 1; /// Snapshot-before-change undo/redo scoped to the current page. Cleared on /// page switch so undo never crosses pages. final List> _undo = []; final List> _redo = []; bool _showPageScrubber = false; double? _pageScrub; /// The single active-tool state (shared model across the 3 editors). EditorToolKind _tool = EditorToolKind.brush; /// Selected shape for the SHAPE tool. ShapeKind _shapeKind = ShapeKind.line; /// Index of the currently selected committed stroke (SELECT tool), or null. int? _selectedStroke; /// Highlighter keeps its own color (not a pen slot). Color _highlighterColor = Colors.orange; /// Active pen brush from the selected slot (fallback until slots load). BrushKind get _penBrush => _penSlots?.active.brush ?? BrushKind.fountainPen; /// Active drawing color: highlighter tool uses [_highlighterColor], else the /// active pen slot's color. Color get _color => _tool == EditorToolKind.highlighter ? _highlighterColor : (_penSlots?.active.color ?? Colors.black); /// The page-background template painted behind the ink (rnote-style). Default /// blank; persisted per-notebook in the sidecar. NoteBackground _background = NoteBackground.blank; bool _allowFingerDrawing = false; bool _dirty = false; bool _needsCenter = true; /// The note's synthetic source path `/notebook` (also the note id). /// Persistence flows through this note's `notebook.badnote.json` sidecar. String? _notePath; /// Per-file sidecar persistence sink (strokes + pageCount + title), debounced /// and atomic — replaces the old SQLite Note/noteListProvider write path here. SidecarRepository? _repo; final TextEditingController _titleController = TextEditingController(); PenConfigController? _penConfig; PenSlotsController? _penSlots; final TransformationController _transform = TransformationController(); static const double _highlighterWidthFraction = 0.02; static const List _palette = kInkPalette; /// Current page's stroke list (PenCanvas source of truth). List get _strokes => _strokesByPage.putIfAbsent(_pageIndex, () => []); set _strokes(List value) { _strokesByPage[_pageIndex] = value; } @override void initState() { super.initState(); PenInputService.instance.start(); final note = widget.note; if (note != null) { _notePath = note.id; _titleController.text = note.title; // Seed from the in-memory note's strokes (e.g. tests) until the sidecar // load resolves and (if present) overrides with persisted strokes. _strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage); } else { _titleController.text = 'Untitled'; } _initPenConfig(); if (_notePath != null) _initPersistence(_notePath!); } /// Open the note's `notebook.badnote.json` sidecar and hydrate every page of /// strokes plus title / background / pageCount. Future _initPersistence(String notePath) async { final repo = await SidecarRepository.open(notePath, docType: 'notebook'); if (!mounted) { repo.dispose(); return; } _repo = repo; setState(() { _hydrateFromRepo(repo); }); } /// Load all pages from [repo]. pageCount = max(sidecar.pageCount ?? 1, /// highest stroke key + 1). Persists pageCount when the sidecar omitted it. void _hydrateFromRepo(SidecarRepository repo) { // Only replace in-memory strokes when the sidecar actually holds ink — // otherwise keep the seed from widget.note (widget tests / cold open). if (repo.loadedStrokes.isNotEmpty) { _strokesByPage.clear(); for (final entry in repo.loadedStrokes.entries) { if (entry.value.isEmpty) continue; _strokesByPage[entry.key] = [ for (final es in entry.value) _penStrokeFromEditor(es), ]; } } final fromKeys = _strokesByPage.isEmpty ? 1 : _strokesByPage.keys.reduce((a, b) => a > b ? a : b) + 1; final declared = repo.sidecar.pageCount ?? 1; _pageCount = declared > fromKeys ? declared : fromKeys; if (_pageCount < 1) _pageCount = 1; if (_pageIndex >= _pageCount) _pageIndex = _pageCount - 1; _undo.clear(); _redo.clear(); _selectedStroke = null; final title = repo.loadedTitle; if (title != null && title.isNotEmpty) { _titleController.text = title; } _background = noteBackgroundFromName(repo.loadedBackground); if (repo.sidecar.pageCount != _pageCount) { repo.schedulePageCountSave(_pageCount); } } /// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush /// is persisted on the EditorStroke now, so carry it through; old sidecars /// without the field decode to fountainPen (back-compat default). PenStroke _penStrokeFromEditor(EditorStroke es) => PenStroke( points: es.points .map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt)) .toList(), color: es.color, width: es.width, kind: es.tool == EditorTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen, brush: es.brush, ); Future _initPenConfig() async { final results = await Future.wait([ PenConfigController.load(), PenSlotsController.load(), ]); final config = results[0] as PenConfigController; final slots = results[1] as PenSlotsController; if (!mounted) { config.dispose(); slots.dispose(); return; } config.addListener(_onPenConfigChanged); slots.addListener(_onPenSlotsChanged); setState(() { _penConfig = config; _penSlots = slots; _allowFingerDrawing = config.value.fingerDrawing; }); } void _onPenConfigChanged() { if (mounted) setState(() {}); } void _onPenSlotsChanged() { if (mounted) setState(() {}); } @override void dispose() { // Flush any pending sidecar write before tearing down (atomic write // completes off the widget tree). final repo = _repo; if (repo != null) { repo.flush(); repo.dispose(); } _penConfig?.removeListener(_onPenConfigChanged); _penConfig?.dispose(); _penSlots?.removeListener(_onPenSlotsChanged); _penSlots?.dispose(); _titleController.dispose(); _transform.dispose(); super.dispose(); } // ── Mutations ────────────────────────────────────────────────────────────── void _pushUndo() { _undo.add(List.from(_strokes)); _redo.clear(); } void _commitStroke(PenStroke stroke) { setState(() { _pushUndo(); _strokes = [..._strokes, stroke]; _dirty = true; }); } void _eraseStroke(int index, List replacements) { if (index < 0 || index >= _strokes.length) return; setState(() { _pushUndo(); _strokes = [ ..._strokes.sublist(0, index), ...replacements, ..._strokes.sublist(index + 1), ]; _dirty = true; }); } void _performUndo() { if (_undo.isEmpty) return; setState(() { _redo.add(List.from(_strokes)); _strokes = _undo.removeLast(); _dirty = true; }); } void _performRedo() { if (_redo.isEmpty) return; setState(() { _undo.add(List.from(_strokes)); _strokes = _redo.removeLast(); _dirty = true; }); } void _toggleFingerDrawing() { final next = !_allowFingerDrawing; setState(() => _allowFingerDrawing = next); _penConfig?.setFingerDrawing(next); } // ── Persistence ────────────────────────────────────────────────────────────── /// Schedule a stroke save for [pageIndex] (defaults to current) without /// flushing. Used when switching pages so ink isn't lost mid-edit. void _schedulePageStrokeSave([int? pageIndex]) { final repo = _repo; if (repo == null) return; final idx = pageIndex ?? _pageIndex; final pageStrokes = _strokesByPage[idx] ?? const []; final editorStrokes = [ for (final s in pageStrokes) EditorStroke.fromPenStroke(s), ]; repo.scheduleStrokeSave(idx, editorStrokes); } void _goToPage(int index) { if (_pageCount < 1) return; final clamped = index.clamp(0, _pageCount - 1); if (clamped == _pageIndex) { setState(() { _pageScrub = null; _showPageScrubber = false; }); return; } _schedulePageStrokeSave(_pageIndex); setState(() { _pageIndex = clamped; _pageScrub = null; _showPageScrubber = false; _selectedStroke = null; _undo.clear(); _redo.clear(); }); } void _addPage() { _schedulePageStrokeSave(_pageIndex); setState(() { _pageCount += 1; _pageIndex = _pageCount - 1; _strokesByPage.putIfAbsent(_pageIndex, () => []); _pageScrub = null; _showPageScrubber = false; _selectedStroke = null; _undo.clear(); _redo.clear(); _dirty = true; }); _repo?.schedulePageCountSave(_pageCount); } /// Persist the live pen strokes + title + pageCount to the note's /// `notebook.badnote.json` sidecar, debounced/atomic via [SidecarRepository]. /// Creates the notebook folder lazily on first save when the screen was opened /// without a path. Refreshes the home list and triggers local OCR for search. Future _save() async { if (!_dirty) return; final notifier = ref.read(noteListProvider.notifier); final now = DateTime.now(); final title = _titleController.text.trim().isEmpty ? 'Untitled' : _titleController.text.trim(); // Lazily create the notebook folder + sidecar repo on first save. if (_repo == null) { final created = await notifier.createNote(title: title); if (!mounted) return; _notePath = created.id; final repo = await SidecarRepository.open(created.id, docType: 'notebook'); if (!mounted) { repo.dispose(); return; } _repo = repo; } final repo = _repo!; repo.scheduleTitleSave(title); repo.scheduleBackgroundSave(_background.name); repo.schedulePageCountSave(_pageCount); // Persist every page that has (or had) strokes in this session. Empty pages // clear their sidecar entry via scheduleStrokeSave. for (final idx in _strokesByPage.keys.toList()..sort()) { _schedulePageStrokeSave(idx); } // Also ensure the current page is written even if never putIfAbsent'd empty. _schedulePageStrokeSave(_pageIndex); await repo.flush(); // Refresh the home list so the title/recency update is visible on return. await notifier.loadNotes(); if (!mounted) return; setState(() => _dirty = false); // Build an in-memory Note (id = note path) for OCR/FTS indexing only — // flatten all pages into one stroke list. final inkStrokes = [ for (final page in _strokesByPage.values) for (final s in page) inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now), ]; _runLocalOcr(Note( id: _notePath!, title: title, strokes: inkStrokes, createdAt: now, updatedAt: now, )); } void _runLocalOcr(Note note) { final id = note.id; ref.read(ocrStatusProvider.notifier).state = { ...ref.read(ocrStatusProvider), id: OcrStatus.processing, }; ref.read(ocrServiceProvider).processNote(note).then((_) { if (!mounted) return; ref.read(ocrStatusProvider.notifier).state = { ...ref.read(ocrStatusProvider), id: OcrStatus.done, }; }).catchError((_) { if (!mounted) return; ref.read(ocrStatusProvider.notifier).state = { ...ref.read(ocrStatusProvider), id: OcrStatus.failed, }; }); } // ── Layout helpers ────────────────────────────────────────────────────────── void _centerPage(Size viewport, Size pageSize) { final o = centerOffset(pageSize, viewport, 1.0); _transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1); } double get _strokeWidth => _tool == EditorToolKind.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) : (_penSlots?.active.width ?? 0.006); // ── SELECT tool: select / move / delete (reuses the undo stacks) ───────────── /// Set (or clear) the selected stroke from a SELECT-tool tap. void _selectStroke(int? index) { setState(() => _selectedStroke = index); } /// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta /// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a /// single undo step. void _moveStroke(int index, double dx, double dy, bool isDragStart) { if (index < 0 || index >= _strokes.length) return; setState(() { if (isDragStart) _pushUndo(); final next = List.from(_strokes); next[index] = translateStroke(next[index], dx, dy); _strokes = next; _dirty = true; }); } /// Delete the selected stroke (button or long-press), as one undo step. void _deleteSelected() { final idx = _selectedStroke; if (idx == null || idx < 0 || idx >= _strokes.length) return; setState(() { _pushUndo(); _strokes = [ ..._strokes.sublist(0, idx), ..._strokes.sublist(idx + 1), ]; _selectedStroke = null; _dirty = true; }); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return PopScope( canPop: true, onPopInvokedWithResult: (didPop, _) { if (didPop && _dirty) _save(); }, child: Scaffold( body: Stack( children: [ Positioned.fill(child: _buildCanvas()), // Tool palette (top-center) — identical chrome to the PDF editor. SafeArea( child: Align( alignment: Alignment.topCenter, child: Padding( padding: const EdgeInsets.only(top: 8), child: _buildToolPalette(cs), ), ), ), // Back (saves on the way out). SafeArea( child: Padding( padding: const EdgeInsets.all(8), child: RoundIconButton( icon: Icons.arrow_back, tooltip: 'Back', onPressed: () async { final navigator = Navigator.of(context); await _save(); if (mounted) navigator.maybePop(); }, ), ), ), // Title + page chrome (bottom-center). SafeArea( child: Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildPagePill(cs), const SizedBox(height: 8), _buildTitlePill(cs), ], ), ), ), ), ], ), ), ); } Widget _buildCanvas() { return LayoutBuilder( builder: (context, constraints) { // Fit the logical note page into the viewport at scale 1.0. final fitW = constraints.maxWidth / kNoteLogicalPage.width; final fitH = constraints.maxHeight / kNoteLogicalPage.height; final scale = fitW < fitH ? fitW : fitH; final pageSize = Size( kNoteLogicalPage.width * scale, kNoteLogicalPage.height * scale, ); if (_needsCenter) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _centerPage( Size(constraints.maxWidth, constraints.maxHeight), pageSize); setState(() => _needsCenter = false); }); } return PenCanvas( pageSize: pageSize, strokes: _strokes, transformationController: _transform, tool: editorToolToCanvas(_tool), brush: _penBrush, shapeKind: _shapeKind, color: _color, strokeWidth: _strokeWidth, selectedStrokeIndex: _selectedStroke, onSelectStroke: _selectStroke, onMoveStroke: _moveStroke, pressureGamma: _penConfig?.value.pressureGamma ?? kNaturalPressureGamma, eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius, eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false, sideButtonAction: _penConfig?.value.sideButton ?? PenButtonAction.select, eraserEndAction: _penConfig?.value.eraserEnd ?? PenButtonAction.eraser, allowFingerDrawing: _allowFingerDrawing, onStrokeComplete: _commitStroke, onEraseStroke: _eraseStroke, onPenButtonAction: (action) { if (action == PenButtonAction.select) { setState(() => _tool = EditorToolKind.select); } else if (action == PenButtonAction.undo) { if (_undo.isNotEmpty) _performUndo(); } else if (action == PenButtonAction.toggleTool) { setState(() { _tool = _tool == EditorToolKind.eraser ? EditorToolKind.brush : EditorToolKind.eraser; }); } }, // A white sheet with a soft shadow — the note "paper" — overlaid with // the selected background template, painted in page-pixel space (so it // scales with zoom) and BEHIND the ink layers. pageWidget: DecoratedBox( decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.18), blurRadius: 12, spreadRadius: 1, ), ], ), child: CustomPaint( painter: NoteBackgroundPainter(_background), size: Size.infinite, ), ), ); }, ); } Widget _buildToolPalette(ColorScheme cs) { return Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ // OneNote-style: each pen slot restores brush + color + thickness. for (final slot in _penSlots?.slots ?? kDefaultPenSlots()) PenSlotButton( kind: slot.brush, selected: _tool == EditorToolKind.brush && (_penSlots?.activeId ?? 'slot_0') == slot.id, color: slot.color, widthHint: slot.width, tooltip: brushLabelEn(slot.brush), onPressed: () { _penSlots?.select(slot.id); setState(() => _tool = EditorToolKind.brush); }, ), ToolButton( icon: Icons.brush_outlined, selected: _tool == EditorToolKind.highlighter, tooltip: 'Highlighter', onPressed: () => setState(() => _tool = EditorToolKind.highlighter), ), ToolButton( icon: Icons.cleaning_services_outlined, selected: _tool == EditorToolKind.eraser, tooltip: 'Eraser', onPressed: () => setState(() => _tool = EditorToolKind.eraser), ), // Select (cursor) + shape tools. ToolButton( icon: Icons.ads_click, selected: _tool == EditorToolKind.select, tooltip: 'Select', onPressed: () => setState(() => _tool = EditorToolKind.select), ), ShapePickerButton( selected: _shapeKind, active: _tool == EditorToolKind.shape, tooltip: 'Shape', labelFor: shapeLabelEn, onActivate: () => setState(() => _tool = EditorToolKind.shape), onSelected: (s) => setState(() { _shapeKind = s; _tool = EditorToolKind.shape; }), ), if (_tool == EditorToolKind.select && _selectedStroke != null) ToolButton( icon: Icons.delete_outline, selected: false, tooltip: 'Delete selection', onPressed: _deleteSelected, ), PaletteDivider(cs: cs), ToolButton( icon: Icons.undo, selected: false, tooltip: 'Undo', onPressed: _undo.isNotEmpty ? _performUndo : null, ), ToolButton( icon: Icons.redo, selected: false, tooltip: 'Redo', onPressed: _redo.isNotEmpty ? _performRedo : null, ), PaletteDivider(cs: cs), for (final c in _palette) _colorDot(c, cs), ThicknessPickerButton( width: _penSlots?.active.width ?? 0.006, onChanged: (w) => _penSlots?.setActiveWidth(w), ), PaletteDivider(cs: cs), // Page-background template picker (rnote-style: blank / dots / ruled // / grid / cornell). Persists per-notebook in the sidecar. PopupMenuButton( tooltip: 'Page background', initialValue: _background, onSelected: (b) { setState(() { _background = b; _dirty = true; }); }, itemBuilder: (context) => [ for (final b in NoteBackground.values) PopupMenuItem( value: b, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(noteBackgroundIcon(b), size: 20), const SizedBox(width: 10), Text(noteBackgroundLabel(b)), if (b == _background) ...[ const SizedBox(width: 8), Icon(Icons.check, size: 18, color: cs.primary), ], ], ), ), ], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( noteBackgroundIcon(_background), size: 22, color: cs.onSurfaceVariant, ), Icon( Icons.arrow_drop_down, size: 18, color: cs.onSurfaceVariant, ), ], ), ), ), PaletteDivider(cs: cs), ToolButton( icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, selected: _allowFingerDrawing, tooltip: _allowFingerDrawing ? 'Finger drawing ON' : 'Finger drawing OFF (pen only)', onPressed: _toggleFingerDrawing, ), ToolButton( icon: Icons.settings_outlined, selected: false, tooltip: 'Pen settings (width, pressure, eraser…)', onPressed: _penConfig != null ? () => showPenSettingsSheet(context, _penConfig!) : null, ), ], ), ), ), ); } Widget _colorDot(Color c, ColorScheme cs) { // Selected against the ACTIVE slot (or highlighter) color. A color tap // updates only the active slot / highlighter — not other slots. final selected = _color.toARGB32() == c.toARGB32() && _tool != EditorToolKind.eraser && _tool != EditorToolKind.select; return GestureDetector( onTap: () { if (_tool == EditorToolKind.eraser || _tool == EditorToolKind.select) { setState(() => _tool = EditorToolKind.brush); } if (_tool == EditorToolKind.highlighter) { setState(() => _highlighterColor = c); } else { _penSlots?.setActiveColor(c); } }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.symmetric(horizontal: 3), width: 24, height: 24, decoration: BoxDecoration( color: c, shape: BoxShape.circle, border: Border.all( color: selected ? cs.onSurface : cs.outlineVariant, width: selected ? 3 : 1, ), ), ), ); } Widget _buildTitlePill(ColorScheme cs) { return Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), child: TextField( controller: _titleController, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), decoration: const InputDecoration( border: InputBorder.none, hintText: 'Note title…', isDense: true, ), onChanged: (_) => _dirty = true, ), ), ), ); } /// Compact page chrome: prev / "n / total" / next, plus add-page. Tapping the /// center label toggles a scrubber Slider when there is more than one page. Widget _buildPagePill(ColorScheme cs) { final total = _pageCount; final scrub = _pageScrub; final shown = (scrub ?? (_pageIndex + 1).toDouble()).round(); return Column( mainAxisSize: MainAxisSize.min, children: [ if (total > 1 && _showPageScrubber) Container( margin: const EdgeInsets.only(bottom: 8), constraints: const BoxConstraints(maxWidth: 420), child: Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Slider( min: 1, max: total.toDouble(), value: (scrub ?? (_pageIndex + 1).toDouble()) .clamp(1, total.toDouble()), divisions: total > 1 ? total - 1 : null, onChanged: (v) => setState(() => _pageScrub = v), onChangeEnd: (v) { setState(() => _pageScrub = v); _goToPage(v.round() - 1); }, ), ), ), ), Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Previous page', icon: const Icon(Icons.chevron_left), onPressed: _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null, ), TextButton( onPressed: () { if (total > 1) { setState(() => _showPageScrubber = !_showPageScrubber); } }, child: Text( '$shown / $total', style: TextStyle( color: cs.onSurface, fontWeight: FontWeight.w600, ), ), ), IconButton( tooltip: 'Next page', icon: const Icon(Icons.chevron_right), onPressed: _pageIndex < total - 1 ? () => _goToPage(_pageIndex + 1) : null, ), IconButton( tooltip: 'Add page', icon: const Icon(Icons.add), onPressed: _addPage, ), ], ), ), ), ], ); } }