Ocideck/lib/widgets/editors/_editor_field.dart
Brenno de Winter dd2e91d61b Initial commit: OciDeck Marp presentation builder
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>
2026-06-02 23:28:39 +02:00

472 lines
14 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_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 minValuemaxValue
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),
),
),
);
}
}