Flutter desktop app for building Marp presentations via structured slide editors, with live preview, fullscreen presenter, and PDF/PPTX export. Includes Makefile quality gate, CI workflow, and full test suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
472 lines
14 KiB
Dart
472 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import '../../services/caption_service.dart';
|
||
import '../../services/description_service.dart';
|
||
import '../../services/image_service.dart';
|
||
import '../../state/tabs_provider.dart';
|
||
import '../dialogs/image_carousel_picker.dart';
|
||
|
||
/// Shared layout helpers for slide editors.
|
||
|
||
class EditorField extends StatelessWidget {
|
||
final String label;
|
||
final TextEditingController controller;
|
||
final String hint;
|
||
final int maxLines;
|
||
|
||
const EditorField({
|
||
super.key,
|
||
required this.label,
|
||
required this.controller,
|
||
this.hint = '',
|
||
this.maxLines = 1,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF64748B),
|
||
),
|
||
),
|
||
const SizedBox(height: 5),
|
||
TextField(
|
||
controller: controller,
|
||
maxLines: maxLines,
|
||
minLines: 1,
|
||
decoration: InputDecoration(hintText: hint),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class EditorFieldList extends StatelessWidget {
|
||
final List<Widget> children;
|
||
const EditorFieldList({super.key, required this.children});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListView.separated(
|
||
padding: const EdgeInsets.all(16),
|
||
itemCount: children.length,
|
||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
||
itemBuilder: (_, i) => children[i],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Zoom-bediening voor afbeeldingen in slides.
|
||
/// De afbeelding vult ALTIJD de slide; de zoom bepaalt welk deel zichtbaar is.
|
||
/// 0 of 100 = normaal (cover, Marp-standaard)
|
||
/// 150 = ingezoomd: ziet alleen het midden van de foto
|
||
/// 300 = flink ingezoomd: ziet 1/3 van de foto
|
||
class ImageZoomControl extends StatelessWidget {
|
||
final int value; // 0 = auto/normaal, anders minValue–maxValue
|
||
final ValueChanged<int> onChanged;
|
||
final int step;
|
||
final int minValue;
|
||
final int maxValue;
|
||
|
||
const ImageZoomControl({
|
||
super.key,
|
||
required this.value,
|
||
required this.onChanged,
|
||
this.step = 10,
|
||
this.minValue = 20,
|
||
this.maxValue = 300,
|
||
});
|
||
|
||
// Effectieve sliderwaarde: 0 behandelen als 100
|
||
int get _effective => value == 0 ? 100 : value.clamp(minValue, maxValue);
|
||
|
||
String get _label {
|
||
final v = _effective;
|
||
if (maxValue <= 100) return '$v%'; // paneelbreedte-modus
|
||
if (v == 100) return 'Volledig zichtbaar (100%)';
|
||
if (v > 100) {
|
||
return 'Ingezoomd $v% — ${((1 / (v / 100)) * 100).round()}% van de foto zichtbaar';
|
||
}
|
||
return 'Uitgezoomd $v%';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final zoomed = _effective != 100;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Tooltip(
|
||
message: 'Uitzoomen (meer van de foto zichtbaar)',
|
||
child: Icon(Icons.zoom_out, size: 16, color: Color(0xFF94A3B8)),
|
||
),
|
||
Expanded(
|
||
child: Slider(
|
||
value: _effective.toDouble(),
|
||
min: minValue.toDouble(),
|
||
max: maxValue.toDouble(),
|
||
divisions: (maxValue - minValue) ~/ step,
|
||
label: _label,
|
||
onChanged: (v) {
|
||
final snapped = ((v.round() / step).round() * step).clamp(
|
||
minValue,
|
||
maxValue,
|
||
);
|
||
onChanged(snapped);
|
||
},
|
||
),
|
||
),
|
||
const Tooltip(
|
||
message: 'Inzoomen (minder van de foto zichtbaar)',
|
||
child: Icon(Icons.zoom_in, size: 16, color: Color(0xFF94A3B8)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 52,
|
||
child: Text(
|
||
'$_effective%',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: zoomed
|
||
? const Color(0xFF2563EB)
|
||
: const Color(0xFF94A3B8),
|
||
fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal,
|
||
),
|
||
textAlign: TextAlign.right,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Tooltip(
|
||
message: 'Terugzetten (volledige afbeelding zichtbaar)',
|
||
child: IconButton(
|
||
icon: const Icon(Icons.refresh, size: 16),
|
||
onPressed: zoomed ? () => onChanged(100) : null,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||
color: const Color(0xFF94A3B8),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||
child: Text(
|
||
_label,
|
||
style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Callback met pad én caption.
|
||
typedef ImagePickedCallback = void Function(String path, String caption);
|
||
|
||
/// Afbeeldingskiezer-balk met carousel-knop + optionele caption.
|
||
class ImagePickerBar extends ConsumerWidget {
|
||
final String imagePath;
|
||
final String imageCaption;
|
||
final String? captionBasePath;
|
||
final List<String> searchPaths;
|
||
final ImagePickedCallback onPicked;
|
||
final VoidCallback? onBrowse;
|
||
final VoidCallback? onPaste;
|
||
final VoidCallback? onClear;
|
||
final ValueChanged<String>? onCaptionChanged;
|
||
final String label;
|
||
|
||
const ImagePickerBar({
|
||
super.key,
|
||
required this.imagePath,
|
||
required this.searchPaths,
|
||
required this.onPicked,
|
||
this.imageCaption = '',
|
||
this.captionBasePath,
|
||
this.onBrowse,
|
||
this.onPaste,
|
||
this.onClear,
|
||
this.onCaptionChanged,
|
||
this.label = 'Geen afbeelding gekozen',
|
||
});
|
||
|
||
Future<void> _openCarousel(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
CaptionService captions,
|
||
) async {
|
||
final result = await ImageCarouselPicker.show(
|
||
context,
|
||
searchPaths: searchPaths,
|
||
initialPath: imagePath.isNotEmpty ? _resolveImagePath(imagePath) : null,
|
||
captionService: captions,
|
||
descriptionService: ref.read(descriptionServiceProvider),
|
||
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
|
||
);
|
||
if (result != null) onPicked(result.path, result.caption);
|
||
}
|
||
|
||
/// Find every open-deck slide that references [absolutePath], so we can warn
|
||
/// before deleting an image that is still in use.
|
||
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
||
final target = p.normalize(absolutePath);
|
||
final usages = <String>[];
|
||
for (final tab in ref.read(tabsProvider).tabs) {
|
||
final deck = tab.deckNotifier.currentState.deck;
|
||
if (deck == null) continue;
|
||
for (var i = 0; i < deck.slides.length; i++) {
|
||
final slide = deck.slides[i];
|
||
for (final candidate in [slide.imagePath, slide.imagePath2]) {
|
||
if (candidate.isEmpty) continue;
|
||
final resolved = p.normalize(
|
||
p.isAbsolute(candidate)
|
||
? candidate
|
||
: p.join(deck.projectPath ?? '', candidate),
|
||
);
|
||
if (resolved == target) {
|
||
usages.add('${tab.label} · slide ${i + 1}');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return usages;
|
||
}
|
||
|
||
String _resolveImagePath(String path) {
|
||
if (p.isAbsolute(path) || captionBasePath == null) return path;
|
||
return p.join(captionBasePath!, path);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final captions = ref.read(captionServiceProvider);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Pad-display
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: const Color(0xFFCBD5E1)),
|
||
borderRadius: BorderRadius.circular(6),
|
||
color: Colors.white,
|
||
),
|
||
child: Text(
|
||
imagePath.isEmpty ? label : imagePath,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: imagePath.isEmpty
|
||
? const Color(0xFF94A3B8)
|
||
: const Color(0xFF334155),
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
// Knoppen
|
||
Wrap(
|
||
spacing: 6,
|
||
runSpacing: 6,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
ElevatedButton.icon(
|
||
onPressed: () => _openCarousel(context, ref, captions),
|
||
icon: const Icon(Icons.photo_library_outlined, size: 16),
|
||
label: const Text('Uit bibliotheek…'),
|
||
),
|
||
if (onBrowse != null)
|
||
OutlinedButton.icon(
|
||
onPressed: onBrowse,
|
||
icon: const Icon(Icons.folder_open_outlined, size: 16),
|
||
label: const Text('Van computer…'),
|
||
),
|
||
if (onPaste != null)
|
||
Tooltip(
|
||
message: 'Afbeelding plakken uit klembord',
|
||
child: IconButton(
|
||
onPressed: onPaste,
|
||
icon: const Icon(Icons.content_paste, size: 18),
|
||
color: const Color(0xFF64748B),
|
||
),
|
||
),
|
||
if (imagePath.isNotEmpty)
|
||
Tooltip(
|
||
message: 'Kopieer afbeelding naar klembord',
|
||
child: IconButton(
|
||
onPressed: () async {
|
||
final ok = await ImageService().copyImageToClipboard(
|
||
_resolveImagePath(imagePath),
|
||
);
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
ok
|
||
? 'Afbeelding gekopieerd naar klembord.'
|
||
: 'Kopiëren naar klembord mislukt.',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
icon: const Icon(Icons.content_copy_outlined, size: 18),
|
||
color: const Color(0xFF64748B),
|
||
),
|
||
),
|
||
if (onClear != null && imagePath.isNotEmpty)
|
||
Tooltip(
|
||
message: 'Verwijder afbeelding',
|
||
child: IconButton(
|
||
onPressed: onClear,
|
||
icon: const Icon(Icons.clear, size: 18),
|
||
color: const Color(0xFF94A3B8),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
// Caption-veld
|
||
if (imagePath.isNotEmpty && onCaptionChanged != null) ...[
|
||
const SizedBox(height: 8),
|
||
_CaptionField(
|
||
caption: imageCaption,
|
||
imagePath: imagePath,
|
||
captionBasePath: captionBasePath,
|
||
captionService: captions,
|
||
onChanged: onCaptionChanged!,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Captionveld met auto-save naar sidecar.
|
||
class _CaptionField extends StatefulWidget {
|
||
final String caption;
|
||
final String imagePath;
|
||
final String? captionBasePath;
|
||
final CaptionService captionService;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _CaptionField({
|
||
required this.caption,
|
||
required this.imagePath,
|
||
this.captionBasePath,
|
||
required this.captionService,
|
||
required this.onChanged,
|
||
});
|
||
|
||
@override
|
||
State<_CaptionField> createState() => _CaptionFieldState();
|
||
}
|
||
|
||
class _CaptionFieldState extends State<_CaptionField> {
|
||
late final TextEditingController _ctrl;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = TextEditingController(text: widget.caption);
|
||
_ctrl.addListener(_onChanged);
|
||
_loadStoredCaption();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_CaptionField old) {
|
||
super.didUpdateWidget(old);
|
||
if (old.imagePath != widget.imagePath) {
|
||
_ctrl.removeListener(_onChanged);
|
||
_ctrl.text = widget.caption;
|
||
_ctrl.addListener(_onChanged);
|
||
_loadStoredCaption();
|
||
}
|
||
}
|
||
|
||
void _onChanged() {
|
||
widget.onChanged(_ctrl.text);
|
||
widget.captionService.saveCaption(
|
||
widget.imagePath,
|
||
_ctrl.text,
|
||
basePath: widget.captionBasePath,
|
||
);
|
||
}
|
||
|
||
Future<void> _loadStoredCaption() async {
|
||
if (widget.caption.isNotEmpty) return;
|
||
final stored = await widget.captionService.getCaption(
|
||
widget.imagePath,
|
||
basePath: widget.captionBasePath,
|
||
);
|
||
if (!mounted || stored == null || stored == _ctrl.text) return;
|
||
_ctrl.removeListener(_onChanged);
|
||
_ctrl.text = stored;
|
||
_ctrl.addListener(_onChanged);
|
||
widget.onChanged(stored);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return TextField(
|
||
controller: _ctrl,
|
||
decoration: InputDecoration(
|
||
hintText: 'Caption / bronvermelding (bijv. © Naam Fotograaf)',
|
||
hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)),
|
||
prefixIcon: const Icon(
|
||
Icons.copyright_outlined,
|
||
size: 16,
|
||
color: Color(0xFF94A3B8),
|
||
),
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
||
filled: true,
|
||
fillColor: const Color(0xFFF8FAFC),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
||
),
|
||
),
|
||
style: const TextStyle(fontSize: 12),
|
||
);
|
||
}
|
||
}
|
||
|
||
class SectionLabel extends StatelessWidget {
|
||
final String text;
|
||
const SectionLabel(this.text, {super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Text(
|
||
text,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF64748B),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|