Ocideck/lib/widgets/dialogs/add_slide_dialog.dart
Brenno de Winter 68725341a7 Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
  file per group (preferring the most-used, then the oldest), merges
  the tags/descriptions and captions of the copies, repoints slides in
  open decks and in .md presentations on disk, and deletes the copies
  after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
  easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
  reference the image (marked "not open"), next to the open decks.

Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
  keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
  the whole grid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00

309 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/slide.dart';
import '../../theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
class AddSlideDialog extends StatelessWidget {
const AddSlideDialog({super.key});
static Future<SlideType?> show(BuildContext context) {
return showDialog<SlideType>(
context: context,
builder: (_) => const AddSlideDialog(),
);
}
static const _types = [
(SlideType.title, 'Titelpagina'),
(SlideType.section, 'Tussentitel'),
(SlideType.bullets, 'Alleen Bullets'),
(SlideType.twoBullets, 'Twee Bulletkolommen'),
(SlideType.bulletsImage, 'Bullets + Afbeelding'),
(SlideType.twoImages, 'Twee Afbeeldingen'),
(SlideType.image, 'Grote Afbeelding'),
(SlideType.video, 'Video'),
(SlideType.quote, 'Quote'),
(SlideType.table, 'Tabel'),
(SlideType.chart, 'Grafiek'),
(SlideType.code, 'Broncode'),
(SlideType.freeMarkdown, 'Vrije Markdown'),
];
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
Navigator.pop(context),
},
child: AlertDialog(
title: Text(l10n.d('Slide type kiezen')),
content: SizedBox(
width: 440,
// Reading-order tabbing through the cards; the first one takes
// focus so the dialog is fully keyboard-operable right away.
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (var i = 0; i < _types.length; i++)
_TypeCard(
type: _types[i].$1,
label: l10n.d(_types[i].$2),
autofocus: i == 0,
onTap: () => Navigator.pop(context, _types[i].$1),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.t('cancel')),
),
],
),
);
}
}
class _TypeCard extends StatelessWidget {
final SlideType type;
final String label;
final VoidCallback onTap;
final bool autofocus;
const _TypeCard({
required this.type,
required this.label,
required this.onTap,
this.autofocus = false,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: InkWell(
onTap: onTap,
autofocus: autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: AppTheme.accent.withValues(alpha: 0.14),
hoverColor: AppTheme.accent.withValues(alpha: 0.06),
child: Container(
width: 100,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFCBD5E1)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// A stylised wireframe of the layout, so the card shows what
// the slide will look like instead of an abstract icon.
ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CustomPaint(
painter: SlideTypePreviewPainter(type: type),
),
),
),
),
const SizedBox(height: 6),
Text(
label,
textAlign: TextAlign.center,
maxLines: 2,
style: const TextStyle(fontSize: 11, height: 1.15),
),
],
),
),
),
);
}
}
/// Paints a miniature 16:9 wireframe of a slide layout, in the spirit of the
/// layout pickers in other presentation tools: title bars, text lines, image
/// placeholders. All geometry lives on a 160×90 design canvas and is scaled
/// to whatever size the card provides.
@visibleForTesting
class SlideTypePreviewPainter extends CustomPainter {
final SlideType type;
/// Wireframe palette: dark bars for titles, soft bars for body text.
static const _canvas = Color(0xFFF8FAFC);
static const _ink = Color(0xFF334155);
static const _soft = Color(0xFFB6C2D2);
static const _fill = Color(0xFFE2E8F0);
static const _accent = AppTheme.accent;
const SlideTypePreviewPainter({required this.type});
@override
void paint(Canvas canvas, Size size) {
final u = size.width / 160;
canvas.scale(u);
canvas.drawRect(const Rect.fromLTWH(0, 0, 160, 90), _paint(_canvas));
switch (type) {
case SlideType.title:
_bar(canvas, 30, 34, 100, 12, _ink);
_bar(canvas, 45, 53, 70, 7, _accent);
case SlideType.section:
_bar(canvas, 16, 36, 5, 24, _accent);
_bar(canvas, 30, 38, 86, 11, _ink);
_bar(canvas, 30, 54, 52, 6, _soft);
case SlideType.bullets:
_bar(canvas, 14, 12, 84, 9, _ink);
_bullets(canvas, 14, 34, 110, 4);
case SlideType.twoBullets:
_bar(canvas, 14, 12, 84, 9, _ink);
_bullets(canvas, 14, 32, 56, 3);
_bullets(canvas, 90, 32, 56, 3);
case SlideType.bulletsImage:
_bar(canvas, 14, 12, 66, 9, _ink);
_bullets(canvas, 14, 32, 60, 3);
_imageBox(canvas, 90, 26, 56, 50);
case SlideType.twoImages:
_imageBox(canvas, 12, 16, 64, 46);
_imageBox(canvas, 84, 16, 64, 46);
_bar(canvas, 20, 68, 48, 5, _soft);
_bar(canvas, 92, 68, 48, 5, _soft);
case SlideType.image:
_imageBox(canvas, 10, 10, 140, 70);
case SlideType.video:
_imageBox(canvas, 10, 10, 140, 70, pictogram: false);
canvas.drawCircle(const Offset(80, 45), 14, _paint(Colors.white));
final play = Path()
..moveTo(75, 37)
..lineTo(89, 45)
..lineTo(75, 53)
..close();
canvas.drawPath(play, _paint(_ink));
case SlideType.quote:
_quoteMark(canvas, 16, 16);
_bar(canvas, 42, 30, 96, 7, _ink);
_bar(canvas, 42, 43, 78, 7, _ink);
_bar(canvas, 42, 60, 42, 5, _accent);
case SlideType.table:
_bar(canvas, 14, 16, 132, 14, _soft, radius: 2);
final line = _paint(_ink.withValues(alpha: 0.45))..strokeWidth = 1.5;
for (var r = 1; r <= 4; r++) {
canvas.drawLine(
Offset(14, 16 + r * 14),
Offset(146, 16 + r * 14),
line,
);
}
for (var c = 0; c <= 3; c++) {
canvas.drawLine(
Offset(14 + c * 44, 16),
Offset(14 + c * 44, 72),
line,
);
}
case SlideType.chart:
final axis = _paint(_soft)..strokeWidth = 2;
canvas.drawLine(const Offset(20, 14), const Offset(20, 74), axis);
canvas.drawLine(const Offset(20, 74), const Offset(148, 74), axis);
_bar(canvas, 34, 50, 18, 24, _soft, radius: 2);
_bar(canvas, 64, 36, 18, 38, _accent, radius: 2);
_bar(canvas, 94, 44, 18, 30, _soft, radius: 2);
_bar(canvas, 124, 24, 18, 50, _accent, radius: 2);
case SlideType.code:
_bar(canvas, 10, 10, 140, 70, const Color(0xFF1E293B), radius: 4);
_bar(canvas, 20, 22, 44, 6, const Color(0xFF7DD3A7), radius: 3);
_bar(canvas, 30, 34, 64, 6, const Color(0xFF93B8F8), radius: 3);
_bar(canvas, 30, 46, 50, 6, const Color(0xFFE2C08D), radius: 3);
_bar(canvas, 20, 58, 32, 6, const Color(0xFF7DD3A7), radius: 3);
case SlideType.freeMarkdown:
_bar(canvas, 14, 12, 10, 9, _accent, radius: 2);
_bar(canvas, 28, 12, 62, 9, _ink);
_bar(canvas, 14, 32, 120, 6, _soft);
_bar(canvas, 14, 44, 132, 6, _soft);
_bar(canvas, 14, 56, 92, 6, _soft);
_bar(canvas, 14, 68, 110, 6, _soft);
}
}
void _bar(
Canvas canvas,
double x,
double y,
double w,
double h,
Color color, {
double? radius,
}) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, w, h),
Radius.circular(radius ?? h / 2),
),
_paint(color),
);
}
/// A column of bullet points: accent dot plus a soft text line.
void _bullets(Canvas canvas, double x, double y, double w, int count) {
for (var i = 0; i < count; i++) {
final dy = y + i * 13.0;
canvas.drawCircle(Offset(x + 3, dy + 3), 3, _paint(_accent));
_bar(canvas, x + 11, dy, w * (i.isEven ? 1.0 : 0.74), 6, _soft);
}
}
/// Image placeholder: filled box with a sun and mountains pictogram.
void _imageBox(
Canvas canvas,
double x,
double y,
double w,
double h, {
bool pictogram = true,
}) {
_bar(canvas, x, y, w, h, _fill, radius: 4);
if (!pictogram) return;
final dark = _paint(_soft);
canvas.drawCircle(Offset(x + w * 0.28, y + h * 0.30), h * 0.11, dark);
final hills = Path()
..moveTo(x + w * 0.08, y + h * 0.88)
..lineTo(x + w * 0.38, y + h * 0.45)
..lineTo(x + w * 0.58, y + h * 0.72)
..lineTo(x + w * 0.74, y + h * 0.52)
..lineTo(x + w * 0.94, y + h * 0.88)
..close();
canvas.drawPath(hills, dark);
}
/// A stylised double quotation mark.
void _quoteMark(Canvas canvas, double x, double y) {
final paint = _paint(_accent);
for (final dx in [0.0, 11.0]) {
canvas.drawCircle(Offset(x + 4 + dx, y + 8), 4, paint);
final tail = Path()
..moveTo(x + dx, y + 8)
..quadraticBezierTo(x + dx, y + 17, x + 7 + dx, y + 18)
..lineTo(x + 7 + dx, y + 14)
..quadraticBezierTo(x + 4 + dx, y + 13, x + 4 + dx, y + 8)
..close();
canvas.drawPath(tail, paint);
}
}
@override
bool shouldRepaint(SlideTypePreviewPainter old) => old.type != type;
}
Paint _paint(Color color) => Paint()..color = color;