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>
309 lines
10 KiB
Dart
309 lines
10 KiB
Dart
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;
|