Some checks failed
CI / Windows build (push) Has been cancelled
A blank note can show a page-background template painted behind the ink, picked from the toolbar and persisted per notebook. - NoteBackground: blank / dots / ruled / grid / cornell, drawn in page space (scales with zoom), subtle grey. Cornell = left margin + bottom summary rule over a ruled body. - Stored as the enum name in the notebook sidecar (back-compat: missing/unknown -> blank), saved/loaded via SidecarRepository so it restores on reopen. - Picker added to the note tool palette. PDF backgrounds skipped (PDFs have their own page content). analyze clean, 386 tests green.
713 lines
24 KiB
Dart
713 lines
24 KiB
Dart
// 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/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<PenNoteScreen> createState() => _PenNoteScreenState();
|
|
}
|
|
|
|
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|
static const _uuid = Uuid();
|
|
|
|
/// Live strokes in normalized coords (the canvas source of truth). Persisted
|
|
/// back to the note as InkStroke via the adapter on save.
|
|
List<PenStroke> _strokes = const [];
|
|
|
|
/// Snapshot-before-change undo/redo of the stroke list.
|
|
final List<List<PenStroke>> _undo = [];
|
|
final List<List<PenStroke>> _redo = [];
|
|
|
|
/// The single active-tool state (shared model across the 3 editors).
|
|
EditorToolKind _tool = EditorToolKind.brush;
|
|
|
|
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
|
|
/// highlighter tool always uses [BrushKind.highlighter]; local state only for
|
|
/// this increment (not persisted — see TODO(brush-persist-selection)).
|
|
BrushKind _penBrush = BrushKind.fountainPen;
|
|
|
|
/// Selected shape for the SHAPE tool.
|
|
ShapeKind _shapeKind = ShapeKind.line;
|
|
|
|
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
|
int? _selectedStroke;
|
|
|
|
/// rnote-style per-brush color memory: each brush (and the highlighter)
|
|
/// remembers its own color. Selecting a brush restores its color; picking a
|
|
/// color updates ONLY the active brush's entry. In-memory only for this
|
|
/// increment (TODO(brush-color-persist)).
|
|
final Map<BrushKind, Color> _brushColors = {
|
|
BrushKind.fountainPen: Colors.black,
|
|
BrushKind.ballpoint: Colors.blue,
|
|
BrushKind.pencil: Colors.green,
|
|
BrushKind.highlighter: Colors.orange,
|
|
};
|
|
|
|
/// The brush whose color the color-dots edit: the highlighter when the
|
|
/// highlighter tool is active, otherwise the selected pen brush.
|
|
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
|
? BrushKind.highlighter
|
|
: _penBrush;
|
|
|
|
/// The active drawing color (the active brush's remembered color).
|
|
Color get _color => _brushColors[_activeColorBrush] ?? 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 `<folder>/notebook` (also the note id).
|
|
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
|
|
String? _notePath;
|
|
|
|
/// Per-file sidecar persistence sink (strokes page 0 + title), debounced and
|
|
/// atomic — replaces the old SQLite Note/noteListProvider write path here.
|
|
SidecarRepository? _repo;
|
|
|
|
/// Page index a standalone note's strokes live under in the sidecar.
|
|
static const int _notePageIndex = 0;
|
|
|
|
final TextEditingController _titleController = TextEditingController();
|
|
|
|
PenConfigController? _penConfig;
|
|
final TransformationController _transform = TransformationController();
|
|
|
|
static const double _penWidthFraction = 0.006;
|
|
static const double _highlighterWidthFraction = 0.02;
|
|
|
|
static const List<Color> _palette = [
|
|
Colors.black,
|
|
Colors.red,
|
|
Colors.blue,
|
|
Colors.green,
|
|
Colors.orange,
|
|
];
|
|
|
|
@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.
|
|
_strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
|
|
} else {
|
|
_titleController.text = 'Untitled';
|
|
}
|
|
_initPenConfig();
|
|
if (_notePath != null) _initPersistence(_notePath!);
|
|
}
|
|
|
|
/// Open the note's `notebook.badnote.json` sidecar and, if it holds persisted
|
|
/// strokes / a title, hydrate the canvas from them. Strokes load as page-0
|
|
/// [EditorStroke]s converted to [PenStroke] (mirrors the PDF editor).
|
|
Future<void> _initPersistence(String notePath) async {
|
|
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
|
|
if (!mounted) {
|
|
repo.dispose();
|
|
return;
|
|
}
|
|
_repo = repo;
|
|
final loaded = repo.loadedStrokes[_notePageIndex];
|
|
setState(() {
|
|
if (loaded != null && loaded.isNotEmpty) {
|
|
_strokes = [for (final es in loaded) _penStrokeFromEditor(es)];
|
|
}
|
|
final title = repo.loadedTitle;
|
|
if (title != null && title.isNotEmpty) {
|
|
_titleController.text = title;
|
|
}
|
|
_background = noteBackgroundFromName(repo.loadedBackground);
|
|
});
|
|
}
|
|
|
|
/// 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<void> _initPenConfig() async {
|
|
final controller = await PenConfigController.load();
|
|
if (!mounted) {
|
|
controller.dispose();
|
|
return;
|
|
}
|
|
controller.addListener(_onPenConfigChanged);
|
|
setState(() {
|
|
_penConfig = controller;
|
|
_allowFingerDrawing = controller.value.fingerDrawing;
|
|
});
|
|
}
|
|
|
|
void _onPenConfigChanged() {
|
|
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();
|
|
_titleController.dispose();
|
|
_transform.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
|
|
|
void _pushUndo() {
|
|
_undo.add(List<PenStroke>.from(_strokes));
|
|
_redo.clear();
|
|
}
|
|
|
|
void _commitStroke(PenStroke stroke) {
|
|
setState(() {
|
|
_pushUndo();
|
|
_strokes = [..._strokes, stroke];
|
|
_dirty = true;
|
|
});
|
|
}
|
|
|
|
void _eraseStroke(int index, List<PenStroke> 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<PenStroke>.from(_strokes));
|
|
_strokes = _undo.removeLast();
|
|
_dirty = true;
|
|
});
|
|
}
|
|
|
|
void _performRedo() {
|
|
if (_redo.isEmpty) return;
|
|
setState(() {
|
|
_undo.add(List<PenStroke>.from(_strokes));
|
|
_strokes = _redo.removeLast();
|
|
_dirty = true;
|
|
});
|
|
}
|
|
|
|
void _toggleFingerDrawing() {
|
|
final next = !_allowFingerDrawing;
|
|
setState(() => _allowFingerDrawing = next);
|
|
_penConfig?.setFingerDrawing(next);
|
|
}
|
|
|
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
|
|
|
/// Persist the live pen strokes + title to the note's `notebook.badnote.json`
|
|
/// sidecar (strokes as page-0 [EditorStroke]s; title via the sidecar's title
|
|
/// field), 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 indexing.
|
|
Future<void> _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!;
|
|
|
|
final editorStrokes = <EditorStroke>[
|
|
for (final s in _strokes) EditorStroke.fromPenStroke(s),
|
|
];
|
|
repo.scheduleTitleSave(title);
|
|
repo.scheduleBackgroundSave(_background.name);
|
|
repo.scheduleStrokeSave(_notePageIndex, editorStrokes);
|
|
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.
|
|
final inkStrokes = <InkStroke>[
|
|
for (final s in _strokes)
|
|
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)
|
|
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
|
|
|
// ── 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<PenStroke>.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 pill (bottom-center).
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: _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.eraser,
|
|
eraserEndAction:
|
|
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
|
allowFingerDrawing: _allowFingerDrawing,
|
|
onStrokeComplete: _commitStroke,
|
|
onEraseStroke: _eraseStroke,
|
|
// 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: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Pen tool with brush picker (fountain / ballpoint / pencil), each
|
|
// brush showing its own remembered color.
|
|
BrushPickerButton(
|
|
selected: _penBrush,
|
|
active: _tool == EditorToolKind.brush,
|
|
tooltip: 'Brush',
|
|
labelFor: brushLabelEn,
|
|
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
|
onSelected: (b) => setState(() {
|
|
_penBrush = b;
|
|
_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),
|
|
PaletteDivider(cs: cs),
|
|
// Page-background template picker (rnote-style: blank / dots / ruled
|
|
// / grid / cornell). Persists per-notebook in the sidecar.
|
|
PopupMenuButton<NoteBackground>(
|
|
tooltip: 'Page background',
|
|
initialValue: _background,
|
|
onSelected: (b) {
|
|
setState(() {
|
|
_background = b;
|
|
_dirty = true;
|
|
});
|
|
},
|
|
itemBuilder: (context) => [
|
|
for (final b in NoteBackground.values)
|
|
PopupMenuItem<NoteBackground>(
|
|
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 brush's remembered color. A color tap updates
|
|
// only that brush's entry (rnote per-brush color memory).
|
|
final selected = _color.toARGB32() == c.toARGB32() &&
|
|
_tool != EditorToolKind.eraser &&
|
|
_tool != EditorToolKind.select;
|
|
return GestureDetector(
|
|
onTap: () => setState(() {
|
|
if (_tool == EditorToolKind.eraser ||
|
|
_tool == EditorToolKind.select) {
|
|
_tool = EditorToolKind.brush;
|
|
}
|
|
_brushColors[_activeColorBrush] = 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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|