feat(slide): rebuild PPT annotator on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 14m34s
All checks were successful
CI / Windows build (push) Successful in 14m34s
PPT slides now annotate with the single performant inking engine (PenCanvas) instead of the old ink_canvas, per "all note features on the pen-first canvas". - PenSlideScreen: per-slide normalized strokes over each slide image, prev/next + slider nav, undo/redo, shared M3 palette, and the pressure curve / eraser size+mode / palm rejection from the shared canvas. - slide_export: pure, tested export geometry. Because strokes are now normalized to the page rect, the PDF exporter maps them straight into each slide's draw rect — fixing the old exporter's known ink misalignment (it guessed live-widget size). - Route PPT import + open -> PenSlideScreen; delete the dead old ppt_annotator_screen. (ink_canvas/annotation_toolbar remain for split_view, the last old-canvas screen.) Tests: slide_export geometry (4). flutter analyze: 0 issues. Suite: 269/269.
This commit is contained in:
544
lib/editor/canvas/pen_slide_screen.dart
Normal file
544
lib/editor/canvas/pen_slide_screen.dart
Normal file
@@ -0,0 +1,544 @@
|
||||
// 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 '../input/pen_config.dart';
|
||||
import '../input/pen_input_service.dart';
|
||||
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||
import '../layout/viewport_fit.dart';
|
||||
import '../pdf/slide_export.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;
|
||||
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
Color _color = Colors.black;
|
||||
bool _allowFingerDrawing = false;
|
||||
bool _needsCenter = true;
|
||||
bool _showSlider = false;
|
||||
double? _scrub;
|
||||
|
||||
PenConfigController? _penConfig;
|
||||
final TransformationController _transform = TransformationController();
|
||||
|
||||
static const double _penWidthFraction = 0.006;
|
||||
static const double _highlighterWidthFraction = 0.02;
|
||||
static const Size _fallbackSlide = Size(1600, 900);
|
||||
|
||||
static const List<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
|
||||
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 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() {
|
||||
_penConfig?.removeListener(_onPenConfigChanged);
|
||||
_penConfig?.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;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 == CanvasTool.highlighter
|
||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return 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: _tool,
|
||||
color: _color,
|
||||
strokeWidth: _strokeWidth,
|
||||
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: [
|
||||
ToolButton(
|
||||
icon: Icons.edit_outlined,
|
||||
selected: _tool == CanvasTool.pen,
|
||||
tooltip: 'Pen',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == CanvasTool.highlighter,
|
||||
tooltip: 'Highlighter',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
selected: _tool == CanvasTool.eraser,
|
||||
tooltip: 'Eraser',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||
),
|
||||
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),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorDot(Color c, ColorScheme cs) {
|
||||
final selected =
|
||||
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_color = c;
|
||||
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||
}),
|
||||
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) {
|
||||
setState(() => _scrub = null);
|
||||
_goToSlide(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 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/editor/pdf/slide_export.dart
Normal file
36
lib/editor/pdf/slide_export.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
// lib/editor/pdf/slide_export.dart
|
||||
//
|
||||
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
|
||||
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
|
||||
// just maps each normalized point into the slide image's draw rectangle on the
|
||||
// PDF page — no live-widget-size guessing, which is what made the old PPT
|
||||
// exporter misalign ink (see the removed ppt_annotator_screen comment).
|
||||
|
||||
import 'dart:ui' show Offset, Rect, Size;
|
||||
|
||||
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
|
||||
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
|
||||
/// exported ink lands exactly where it was drawn.
|
||||
Rect slideDrawRect(Size page, Size image) {
|
||||
final iw = image.width <= 0 ? 1.0 : image.width;
|
||||
final ih = image.height <= 0 ? 1.0 : image.height;
|
||||
final scale = (page.width / iw) < (page.height / ih)
|
||||
? page.width / iw
|
||||
: page.height / ih;
|
||||
final drawW = iw * scale;
|
||||
final drawH = ih * scale;
|
||||
final offX = (page.width - drawW) / 2;
|
||||
final offY = (page.height - drawH) / 2;
|
||||
return Rect.fromLTWH(offX, offY, drawW, drawH);
|
||||
}
|
||||
|
||||
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
|
||||
/// inside the slide's [drawRect] on the PDF page.
|
||||
Offset normToSlide(double nx, double ny, Rect drawRect) =>
|
||||
Offset(drawRect.left + nx * drawRect.width,
|
||||
drawRect.top + ny * drawRect.height);
|
||||
|
||||
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
|
||||
/// page width, scaled into [drawRect].
|
||||
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
|
||||
normalizedWidth * drawRect.width;
|
||||
@@ -10,7 +10,7 @@ import '../editor/canvas/pen_editor_screen.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import '../services/pptx_service.dart';
|
||||
import '../editor/canvas/pen_note_screen.dart';
|
||||
import 'ppt_annotator_screen.dart';
|
||||
import '../editor/canvas/pen_slide_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'split_view_screen.dart';
|
||||
@@ -222,7 +222,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PptAnnotatorScreen(
|
||||
builder: (_) => PenSlideScreen(
|
||||
filePath: filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
@@ -527,7 +527,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
);
|
||||
}
|
||||
|
||||
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PptAnnotatorScreen
|
||||
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen
|
||||
Future<void> _openDocument(BuildContext context) async {
|
||||
final document = widget.document;
|
||||
final isPdf = document.docType == 'pdf';
|
||||
@@ -539,7 +539,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// PPT/PPTX: convert to images then push PptAnnotatorScreen
|
||||
// PPT/PPTX: convert to images then push PenSlideScreen
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text('Processing presentation...')),
|
||||
@@ -557,7 +557,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
}
|
||||
Navigator.of(this.context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PptAnnotatorScreen(
|
||||
builder: (_) => PenSlideScreen(
|
||||
filePath: document.filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
/// Per-slide annotation state. The [UndoManager] is the single source of
|
||||
/// truth for a slide's strokes; [strokes] reflects its current contents so
|
||||
/// the live canvas and the PDF export always render what was actually drawn.
|
||||
class _SlideAnnotations {
|
||||
final UndoManager undoManager = UndoManager();
|
||||
List<InkStroke> get strokes => undoManager.currentStrokes;
|
||||
}
|
||||
|
||||
/// Screen that displays PPTX slides with an ink annotation overlay.
|
||||
///
|
||||
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
|
||||
/// sits on top of each slide so the user can annotate freely. Annotations are
|
||||
/// stored per-slide and can be exported as a PDF.
|
||||
class PptAnnotatorScreen extends StatefulWidget {
|
||||
final String filePath;
|
||||
final List<String> slideImagePaths;
|
||||
final String? extractedText;
|
||||
|
||||
const PptAnnotatorScreen({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.slideImagePaths,
|
||||
this.extractedText,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
|
||||
}
|
||||
|
||||
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
|
||||
late final PageController _pageController;
|
||||
late final Map<int, _SlideAnnotations> _annotations;
|
||||
int _currentPage = 0;
|
||||
bool _isDrawing = false;
|
||||
bool _showTextPanel = false;
|
||||
// Set to true once the unsaved-annotations warning SnackBar has been shown.
|
||||
bool _hasShownUnsavedWarning = false;
|
||||
|
||||
// Toolbar state
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
|
||||
// Derived
|
||||
late final String _fileName;
|
||||
late final int _slideCount;
|
||||
late final String _extractedText;
|
||||
|
||||
PressureCurve get _pressureCurve {
|
||||
switch (_pressureCurveType) {
|
||||
case PressureCurveType.linear:
|
||||
return PressureCurve.linear;
|
||||
case PressureCurveType.soft:
|
||||
return PressureCurve.soft;
|
||||
case PressureCurveType.hard:
|
||||
return PressureCurve.hard;
|
||||
case PressureCurveType.custom:
|
||||
return const PressureCurve(type: PressureCurveType.custom);
|
||||
}
|
||||
}
|
||||
|
||||
UndoManager get _currentUndoManager =>
|
||||
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fileName = p.basename(widget.filePath);
|
||||
_slideCount = widget.slideImagePaths.length;
|
||||
_extractedText = widget.extractedText ?? '';
|
||||
|
||||
_pageController = PageController();
|
||||
_annotations = {};
|
||||
for (var i = 0; i < _slideCount; i++) {
|
||||
_annotations[i] = _SlideAnnotations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -- Drawing callbacks --
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
setState(() {
|
||||
_currentUndoManager.addStroke(stroke);
|
||||
});
|
||||
// Warn once per session that PPT annotations are not auto-saved.
|
||||
if (!_hasShownUnsavedWarning) {
|
||||
_hasShownUnsavedWarning = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
setState(() {
|
||||
final original = _currentUndoManager.currentStrokes
|
||||
.where((s) => s.id == strokeId)
|
||||
.firstOrNull;
|
||||
if (original != null) {
|
||||
_currentUndoManager.removeStroke(original, replacements: replacements);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Export --
|
||||
|
||||
Future<void> _exportPdf() async {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
|
||||
|
||||
try {
|
||||
final bytes = await _buildPdfBytes();
|
||||
if (!mounted) return;
|
||||
|
||||
final dir = await _getExportDir();
|
||||
final baseName = p.basenameWithoutExtension(_fileName);
|
||||
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
|
||||
await File(outPath).writeAsBytes(bytes);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _getExportDir() 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;
|
||||
|
||||
for (var i = 0; i < _slideCount; i++) {
|
||||
final page = doc.pages.add();
|
||||
final pageSize = page.getClientSize();
|
||||
|
||||
// Draw slide image
|
||||
final imgPath = widget.slideImagePaths[i];
|
||||
try {
|
||||
final imgBytes = await File(imgPath).readAsBytes();
|
||||
final bitmap = PdfBitmap(imgBytes);
|
||||
|
||||
final imgW = bitmap.width.toDouble();
|
||||
final imgH = bitmap.height.toDouble();
|
||||
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
|
||||
final drawW = imgW * scale;
|
||||
final drawH = imgH * scale;
|
||||
final offX = (pageSize.width - drawW) / 2;
|
||||
final offY = (pageSize.height - drawH) / 2;
|
||||
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
|
||||
|
||||
page.graphics.drawImage(bitmap, imgRect);
|
||||
|
||||
// Draw ink strokes
|
||||
final annots = _annotations[i];
|
||||
if (annots != null && annots.strokes.isNotEmpty) {
|
||||
// KNOWN LIMITATION: strokes are captured in the live viewer's
|
||||
// full-fill pixel space (the InkCanvas is Positioned.fill over the
|
||||
// whole slide area, while the slide image is BoxFit.contain inside
|
||||
// it). The scale below is derived from the PDF page layout, not the
|
||||
// live widget size, so exported ink can be misaligned/scaled wrong.
|
||||
// A correct fix normalizes strokes to [0,1] of the *rendered image
|
||||
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
|
||||
// to the PDF draw rect here. Requires on-device visual verification.
|
||||
final imgAspect = imgW / imgH;
|
||||
final pageAspect = pageSize.width / pageSize.height;
|
||||
double widgetW, widgetH;
|
||||
if (imgAspect > pageAspect) {
|
||||
widgetW = pageSize.width;
|
||||
widgetH = pageSize.width / imgAspect;
|
||||
} else {
|
||||
widgetH = pageSize.height;
|
||||
widgetW = pageSize.height * imgAspect;
|
||||
}
|
||||
final scaleX = drawW / widgetW;
|
||||
final scaleY = drawH / widgetH;
|
||||
|
||||
for (final stroke in annots.strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
if (stroke.points.length < 2) continue;
|
||||
|
||||
final r = (stroke.color >> 16) & 0xFF;
|
||||
final g = (stroke.color >> 8) & 0xFF;
|
||||
final b = stroke.color & 0xFF;
|
||||
final pdfColor = PdfColor(r, g, b);
|
||||
|
||||
final path = PdfPath();
|
||||
path.startFigure();
|
||||
for (var j = 0; j < stroke.points.length - 1; j++) {
|
||||
final pt1 = stroke.points[j];
|
||||
final pt2 = stroke.points[j + 1];
|
||||
path.addLine(
|
||||
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
|
||||
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
|
||||
);
|
||||
}
|
||||
|
||||
page.graphics.drawPath(
|
||||
path,
|
||||
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
// -- UI --
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// No slides to annotate: show an empty state and skip the toolbar, which
|
||||
// would otherwise dereference a non-existent slide's annotation state.
|
||||
if (_slideCount == 0) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
body: const Center(child: Text('No slides to display')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||||
actions: [
|
||||
if (_extractedText.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showTextPanel
|
||||
? Icons.text_snippet
|
||||
: Icons.text_snippet_outlined,
|
||||
),
|
||||
tooltip: 'Toggle extracted text',
|
||||
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
tooltip: 'Export as PDF',
|
||||
onPressed: _exportPdf,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: _currentUndoManager.canUndo,
|
||||
canRedo: _currentUndoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) => setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildSlideViewer()),
|
||||
if (_showTextPanel) _buildTextPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildPageIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSlideViewer() {
|
||||
if (_slideCount == 0) {
|
||||
return const Center(child: Text('No slides to display'));
|
||||
}
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (_) => setState(() => _isDrawing = true),
|
||||
onPointerUp: (_) => setState(() => _isDrawing = false),
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
|
||||
itemCount: _slideCount,
|
||||
onPageChanged: (page) => setState(() => _currentPage = page),
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Slide image (background)
|
||||
Positioned.fill(
|
||||
child: Image.file(
|
||||
File(widget.slideImagePaths[index]),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Slide ${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Ink annotation overlay (foreground)
|
||||
Positioned.fill(
|
||||
child: InkCanvas(
|
||||
strokes: _annotations[index]?.strokes ?? [],
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
pressureCurve: _pressureCurve,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
filled: _filled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageIndicator() {
|
||||
if (_slideCount == 0) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Previous button — always present for both modes.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: _currentPage > 0
|
||||
? () => _pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// Dot row for small decks; compact text counter for large decks.
|
||||
if (_slideCount <= 12)
|
||||
...List.generate(_slideCount, (i) {
|
||||
final isActive = i == _currentPage;
|
||||
return GestureDetector(
|
||||
onTap: () => _pageController.animateToPage(
|
||||
i,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
child: Container(
|
||||
width: isActive ? 12 : 8,
|
||||
height: isActive ? 12 : 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
else
|
||||
Text(
|
||||
'${_currentPage + 1} / $_slideCount',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
// Next button — always present for both modes.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: _currentPage < _slideCount - 1
|
||||
? () => _pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const Spacer(),
|
||||
// Slide counter is always shown at the trailing end for dot mode;
|
||||
// the compact text above already serves this role for large decks.
|
||||
if (_slideCount <= 12)
|
||||
Text(
|
||||
'${_currentPage + 1} / $_slideCount',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextPanel() {
|
||||
return SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.text_fields, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Extracted Text',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () => setState(() => _showTextPanel = false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
_extractedText,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Dialogs --
|
||||
|
||||
void _undo() {
|
||||
setState(() => _currentUndoManager.undo());
|
||||
}
|
||||
|
||||
void _redo() {
|
||||
setState(() => _currentUndoManager.redo());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user