All checks were successful
CI / Windows build (push) Successful in 9m55s
Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text. Co-authored-by: Cursor <cursoragent@cursor.com>
682 lines
23 KiB
Dart
682 lines
23 KiB
Dart
// lib/editor/canvas/pen_slide_screen.dart
|
|
//
|
|
// Pen-first slide (PPT) annotator. Reuses the single performant inking engine
|
|
// (PenCanvas) over each slide image, with per-slide normalized strokes and
|
|
// prev/next navigation. Export to PDF maps the normalized strokes into each
|
|
// slide's draw rect (slide_export.dart) — which also fixes the old exporter's
|
|
// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export
|
|
// to PDF is how annotations are kept), matching the previous behavior.
|
|
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
|
|
|
import '../engine/brush.dart';
|
|
import '../engine/shape_geometry.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 '../pdf/slide_export.dart';
|
|
import '../ui/page_nav_shortcuts.dart';
|
|
import '../ui/pen_settings_page.dart';
|
|
import 'editor_tool.dart';
|
|
import 'pen_canvas.dart';
|
|
import 'pen_palette_widgets.dart';
|
|
import 'pen_stroke.dart';
|
|
|
|
class PenSlideScreen extends StatefulWidget {
|
|
const PenSlideScreen({
|
|
super.key,
|
|
required this.filePath,
|
|
required this.slideImagePaths,
|
|
this.extractedText,
|
|
});
|
|
|
|
final String filePath;
|
|
final List<String> slideImagePaths;
|
|
final String? extractedText;
|
|
|
|
@override
|
|
State<PenSlideScreen> createState() => _PenSlideScreenState();
|
|
}
|
|
|
|
class _PenSlideScreenState extends State<PenSlideScreen> {
|
|
int _slideIndex = 0;
|
|
final Map<int, List<PenStroke>> _strokesBySlide = {};
|
|
final Map<int, List<List<PenStroke>>> _undo = {};
|
|
final Map<int, List<List<PenStroke>>> _redo = {};
|
|
|
|
/// Intrinsic pixel size of each slide image, loaded async so the page rect
|
|
/// keeps the slide's aspect (no distortion). Null until loaded.
|
|
Map<int, Size>? _slideSizes;
|
|
|
|
/// 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;
|
|
|
|
BrushKind get _penBrush =>
|
|
_penSlots?.active.brush ?? BrushKind.fountainPen;
|
|
|
|
Color get _color => _tool == EditorToolKind.highlighter
|
|
? _highlighterColor
|
|
: (_penSlots?.active.color ?? Colors.black);
|
|
|
|
bool _allowFingerDrawing = false;
|
|
bool _needsCenter = true;
|
|
bool _showSlider = false;
|
|
double? _scrub;
|
|
|
|
PenConfigController? _penConfig;
|
|
PenSlotsController? _penSlots;
|
|
final TransformationController _transform = TransformationController();
|
|
|
|
static const double _highlighterWidthFraction = 0.02;
|
|
static const Size _fallbackSlide = Size(1600, 900);
|
|
|
|
static const List<Color> _palette = kInkPalette;
|
|
|
|
int get _slideCount => widget.slideImagePaths.length;
|
|
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
PenInputService.instance.start();
|
|
_loadSlideSizes();
|
|
_initPenConfig();
|
|
}
|
|
|
|
Future<void> _loadSlideSizes() async {
|
|
final sizes = <int, Size>{};
|
|
for (var i = 0; i < _slideCount; i++) {
|
|
try {
|
|
final bytes = await File(widget.slideImagePaths[i]).readAsBytes();
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
final frame = await codec.getNextFrame();
|
|
sizes[i] = Size(
|
|
frame.image.width.toDouble(), frame.image.height.toDouble());
|
|
frame.image.dispose();
|
|
} catch (_) {
|
|
sizes[i] = _fallbackSlide;
|
|
}
|
|
}
|
|
if (mounted) setState(() => _slideSizes = sizes);
|
|
}
|
|
|
|
Future<void> _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() {
|
|
_penConfig?.removeListener(_onPenConfigChanged);
|
|
_penConfig?.dispose();
|
|
_penSlots?.removeListener(_onPenSlotsChanged);
|
|
_penSlots?.dispose();
|
|
_transform.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
|
|
|
void _pushUndo() {
|
|
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
|
_redo[_slideIndex]?.clear();
|
|
}
|
|
|
|
void _commitStroke(PenStroke stroke) {
|
|
setState(() {
|
|
_pushUndo();
|
|
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
|
|
});
|
|
}
|
|
|
|
void _eraseStroke(int index, List<PenStroke> replacements) {
|
|
final strokes = _currentStrokes;
|
|
if (index < 0 || index >= strokes.length) return;
|
|
setState(() {
|
|
_pushUndo();
|
|
_strokesBySlide[_slideIndex] = [
|
|
...strokes.sublist(0, index),
|
|
...replacements,
|
|
...strokes.sublist(index + 1),
|
|
];
|
|
});
|
|
}
|
|
|
|
void _performUndo() {
|
|
final stack = _undo[_slideIndex];
|
|
if (stack == null || stack.isEmpty) return;
|
|
setState(() {
|
|
(_redo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
|
_strokesBySlide[_slideIndex] = stack.removeLast();
|
|
});
|
|
}
|
|
|
|
void _performRedo() {
|
|
final stack = _redo[_slideIndex];
|
|
if (stack == null || stack.isEmpty) return;
|
|
setState(() {
|
|
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
|
_strokesBySlide[_slideIndex] = stack.removeLast();
|
|
});
|
|
}
|
|
|
|
void _toggleFingerDrawing() {
|
|
final next = !_allowFingerDrawing;
|
|
setState(() => _allowFingerDrawing = next);
|
|
_penConfig?.setFingerDrawing(next);
|
|
}
|
|
|
|
void _goToSlide(int i) {
|
|
final clamped = i.clamp(0, _slideCount - 1);
|
|
if (clamped == _slideIndex) return;
|
|
setState(() {
|
|
_slideIndex = clamped;
|
|
_needsCenter = true;
|
|
_selectedStroke = null; // selection is per-slide
|
|
});
|
|
}
|
|
|
|
// ── Export ──────────────────────────────────────────────────────────────────
|
|
|
|
Future<void> _exportPdf() async {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.showSnackBar(
|
|
const SnackBar(content: Text('Exporting PDF...')));
|
|
try {
|
|
final bytes = await _buildPdfBytes();
|
|
final dir = await _exportDir();
|
|
final base = p.basenameWithoutExtension(widget.filePath);
|
|
final outPath = p.join(dir.path, '${base}_annotated.pdf');
|
|
await File(outPath).writeAsBytes(bytes);
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
|
}
|
|
}
|
|
|
|
Future<Directory> _exportDir() async {
|
|
try {
|
|
final home = Platform.environment['HOME'];
|
|
if (home != null) {
|
|
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
|
|
if (!await dir.exists()) await dir.create(recursive: true);
|
|
return dir;
|
|
}
|
|
} catch (_) {}
|
|
return Directory.current;
|
|
}
|
|
|
|
Future<Uint8List> _buildPdfBytes() async {
|
|
final doc = PdfDocument();
|
|
doc.pageSettings.margins.all = 0;
|
|
final sizes = _slideSizes ?? const {};
|
|
for (var i = 0; i < _slideCount; i++) {
|
|
final page = doc.pages.add();
|
|
final pageSize = page.getClientSize();
|
|
try {
|
|
final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes();
|
|
final bitmap = PdfBitmap(imgBytes);
|
|
final imageSize = sizes[i] ??
|
|
Size(bitmap.width.toDouble(), bitmap.height.toDouble());
|
|
final draw = slideDrawRect(
|
|
Size(pageSize.width, pageSize.height), imageSize);
|
|
page.graphics.drawImage(bitmap, draw);
|
|
|
|
for (final stroke in _strokesBySlide[i] ?? const <PenStroke>[]) {
|
|
if (stroke.points.length < 2) continue;
|
|
final r = (stroke.color >> 16) & 0xFF;
|
|
final g = (stroke.color >> 8) & 0xFF;
|
|
final b = stroke.color & 0xFF;
|
|
final path = PdfPath();
|
|
path.startFigure();
|
|
for (var j = 0; j < stroke.points.length - 1; j++) {
|
|
final p1 = stroke.points[j];
|
|
final p2 = stroke.points[j + 1];
|
|
path.addLine(
|
|
normToSlide(p1.x, p1.y, draw),
|
|
normToSlide(p2.x, p2.y, draw),
|
|
);
|
|
}
|
|
page.graphics.drawPath(
|
|
path,
|
|
pen: PdfPen(PdfColor(r, g, b),
|
|
width: slideStrokeWidth(stroke.width, draw)),
|
|
);
|
|
}
|
|
} catch (_) {
|
|
page.graphics.drawRectangle(
|
|
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
|
|
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
|
|
);
|
|
}
|
|
}
|
|
final bytes = await doc.save();
|
|
doc.dispose();
|
|
return Uint8List.fromList(bytes);
|
|
}
|
|
|
|
// ── Layout ────────────────────────────────────────────────────────────────
|
|
|
|
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 (per-slide, reuses the undo stacks) ──
|
|
|
|
void _selectStroke(int? index) {
|
|
setState(() => _selectedStroke = index);
|
|
}
|
|
|
|
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
|
|
final strokes = _currentStrokes;
|
|
if (index < 0 || index >= strokes.length) return;
|
|
setState(() {
|
|
if (isDragStart) _pushUndo();
|
|
final next = List<PenStroke>.from(strokes);
|
|
next[index] = translateStroke(next[index], dx, dy);
|
|
_strokesBySlide[_slideIndex] = next;
|
|
});
|
|
}
|
|
|
|
void _deleteSelected() {
|
|
final idx = _selectedStroke;
|
|
final strokes = _currentStrokes;
|
|
if (idx == null || idx < 0 || idx >= strokes.length) return;
|
|
setState(() {
|
|
_pushUndo();
|
|
_strokesBySlide[_slideIndex] = [
|
|
...strokes.sublist(0, idx),
|
|
...strokes.sublist(idx + 1),
|
|
];
|
|
_selectedStroke = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return pageNavShortcuts(
|
|
onPrevious:
|
|
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
|
|
onNext: _slideIndex < _slideCount - 1
|
|
? () => _goToSlide(_slideIndex + 1)
|
|
: null,
|
|
onFirst: _slideCount > 0 ? () => _goToSlide(0) : null,
|
|
onLast: _slideCount > 0 ? () => _goToSlide(_slideCount - 1) : null,
|
|
child: Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
Positioned.fill(child: _buildCanvas()),
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: _buildToolPalette(cs),
|
|
),
|
|
),
|
|
),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: RoundIconButton(
|
|
icon: Icons.arrow_back,
|
|
tooltip: 'Back',
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
),
|
|
),
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.topRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: RoundIconButton(
|
|
icon: Icons.picture_as_pdf_outlined,
|
|
tooltip: 'Export to PDF',
|
|
onPressed: _exportPdf,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_slideCount > 0)
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: _buildSlidePill(cs),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCanvas() {
|
|
final sizes = _slideSizes;
|
|
if (sizes == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (_slideCount == 0) {
|
|
return const Center(child: Text('No slides.'));
|
|
}
|
|
final slide = sizes[_slideIndex] ?? _fallbackSlide;
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final fitW = constraints.maxWidth / slide.width;
|
|
final fitH = constraints.maxHeight / slide.height;
|
|
final scale = fitW < fitH ? fitW : fitH;
|
|
final pageSize = Size(slide.width * scale, slide.height * scale);
|
|
|
|
if (_needsCenter) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_centerPage(
|
|
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
|
|
setState(() => _needsCenter = false);
|
|
});
|
|
}
|
|
|
|
return PenCanvas(
|
|
key: ValueKey(_slideIndex),
|
|
pageSize: pageSize,
|
|
strokes: _currentStrokes,
|
|
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,
|
|
pageWidget: Image.file(
|
|
File(widget.slideImagePaths[_slideIndex]),
|
|
fit: BoxFit.fill,
|
|
gaplessPlayback: true,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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: [
|
|
// 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),
|
|
),
|
|
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[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null,
|
|
),
|
|
ToolButton(
|
|
icon: Icons.redo,
|
|
selected: false,
|
|
tooltip: 'Redo',
|
|
onPressed:
|
|
(_redo[_slideIndex]?.isNotEmpty ?? false) ? _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),
|
|
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) {
|
|
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 _buildSlidePill(ColorScheme cs) {
|
|
final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round();
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_showSlider && _slideCount > 1)
|
|
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: _slideCount.toDouble(),
|
|
value: (_scrub ?? (_slideIndex + 1).toDouble())
|
|
.clamp(1, _slideCount.toDouble()),
|
|
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
|
onChanged: (v) => setState(() => _scrub = v),
|
|
onChangeEnd: (v) {
|
|
final target = v.round() - 1;
|
|
setState(() {
|
|
_scrub = v;
|
|
_slideIndex = target;
|
|
});
|
|
_goToSlide(target);
|
|
setState(() {
|
|
_scrub = null;
|
|
_showSlider = false;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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 slide',
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed:
|
|
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
|
|
),
|
|
TextButton(
|
|
onPressed: _slideCount > 1
|
|
? () => setState(() => _showSlider = !_showSlider)
|
|
: null,
|
|
child: Text(
|
|
'$shown / $_slideCount',
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Next slide',
|
|
icon: const Icon(Icons.chevron_right),
|
|
onPressed: _slideIndex < _slideCount - 1
|
|
? () => _goToSlide(_slideIndex + 1)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|