2026-06-02 23:28:39 +02:00
|
|
|
import 'package:desktop_drop/desktop_drop.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
import 'package:window_manager/window_manager.dart';
|
|
|
|
|
import '../models/deck.dart';
|
|
|
|
|
import '../models/slide.dart';
|
|
|
|
|
import '../services/caption_service.dart';
|
|
|
|
|
import '../services/description_service.dart';
|
|
|
|
|
import '../services/export_service.dart';
|
|
|
|
|
import '../services/recovery_service.dart';
|
|
|
|
|
import '../state/deck_provider.dart';
|
|
|
|
|
import '../state/editor_provider.dart';
|
|
|
|
|
import '../state/settings_provider.dart';
|
|
|
|
|
import '../state/tabs_provider.dart';
|
|
|
|
|
import '../theme/app_theme.dart';
|
2026-06-04 02:30:03 +02:00
|
|
|
import '../l10n/app_localizations.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
import 'dialogs/export_dialog.dart';
|
|
|
|
|
import 'dialogs/find_replace_dialog.dart';
|
|
|
|
|
import 'dialogs/image_carousel_picker.dart';
|
|
|
|
|
import 'dialogs/new_deck_dialog.dart';
|
|
|
|
|
import 'dialogs/open_presentation_dialog.dart';
|
|
|
|
|
import 'dialogs/presentation_info_dialog.dart';
|
|
|
|
|
import 'dialogs/settings_dialog.dart';
|
|
|
|
|
import 'panels/editor_panel.dart';
|
|
|
|
|
import 'panels/preview_panel.dart';
|
|
|
|
|
import 'panels/slide_list_panel.dart';
|
|
|
|
|
import 'presentation/fullscreen_presenter.dart';
|
|
|
|
|
|
|
|
|
|
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
Split slide_preview.dart and app_shell.dart into part files (#1)
Break the two largest widget files into part/part-of libraries grouped by
concern, with no public API or behaviour change (private widgets keep working
because parts share the library namespace; all imports stay in the main file).
slide_preview.dart 4748 -> 426 lines + slides/previews/{text,bullets,
checklist,table,media,code,chart,overlays}.dart
app_shell.dart 1930 -> 996 lines + shell/{shell_actions,tab_bar,
welcome_screen,status_bar,shell_overlays}.dart
fullscreen_presenter.dart is intentionally left as-is: ~1.6k of its lines are a
single interactive _FullscreenPresenterState (38 setState calls), which a
mechanical split cannot reduce and extensions can't host (protected setState).
Shrinking it needs a behaviour-affecting sub-widget extraction, tracked
separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:49 +02:00
|
|
|
// Shell sub-widgets and helpers, split into part files for navigability.
|
|
|
|
|
// These parts share this library's imports and private scope.
|
|
|
|
|
part 'shell/shell_actions.dart';
|
|
|
|
|
part 'shell/tab_bar.dart';
|
|
|
|
|
part 'shell/welcome_screen.dart';
|
|
|
|
|
part 'shell/status_bar.dart';
|
|
|
|
|
part 'shell/shell_overlays.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
class AppShell extends ConsumerStatefulWidget {
|
|
|
|
|
const AppShell({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<AppShell> createState() => _AppShellState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AppShellState extends ConsumerState<AppShell> with WindowListener {
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
windowManager.addListener(this);
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeRestore());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bij opstart: zijn er herstelbestanden van een vorige (gecrashte) sessie?
|
|
|
|
|
Future<void> _maybeRestore() async {
|
|
|
|
|
final recovery = ref.read(recoveryServiceProvider);
|
|
|
|
|
final snapshots = await recovery.loadAll();
|
|
|
|
|
if (snapshots.isEmpty || !mounted) return;
|
|
|
|
|
|
|
|
|
|
final restore = await showDialog<bool>(
|
|
|
|
|
context: context,
|
|
|
|
|
barrierDismissible: false,
|
2026-06-04 02:30:03 +02:00
|
|
|
builder: (ctx) {
|
|
|
|
|
final l10n = ctx.l10n;
|
|
|
|
|
return AlertDialog(
|
|
|
|
|
title: Text(l10n.d('Niet-opgeslagen werk herstellen?')),
|
|
|
|
|
content: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
snapshots.length == 1
|
|
|
|
|
? l10n.d(
|
|
|
|
|
'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:',
|
|
|
|
|
)
|
|
|
|
|
: '${l10n.d('Er zijn')} ${snapshots.length} ${l10n.d('presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:')}',
|
|
|
|
|
style: const TextStyle(fontSize: 13),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
for (final s in snapshots)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 3),
|
|
|
|
|
child: Text(
|
|
|
|
|
'• ${s.label} · ${_formatWhen(s.savedAt)}',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 12.5,
|
|
|
|
|
color: Color(0xFF475569),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
],
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
|
|
|
child: Text(l10n.d('Verwijderen')),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
|
|
|
child: Text(l10n.d('Herstellen')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (restore == true) {
|
|
|
|
|
ref.read(tabsProvider.notifier).restoreRecovered(snapshots);
|
|
|
|
|
} else {
|
|
|
|
|
await recovery.clearAll();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _formatWhen(DateTime t) {
|
|
|
|
|
String two(int v) => v.toString().padLeft(2, '0');
|
|
|
|
|
return '${two(t.day)}-${two(t.month)} ${two(t.hour)}:${two(t.minute)}';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
windowManager.removeListener(this);
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void onWindowClose() async {
|
|
|
|
|
if (ref.read(tabsProvider).anyDirty) {
|
|
|
|
|
final shouldSave = await _confirmSaveBeforeClose(
|
2026-06-04 02:30:03 +02:00
|
|
|
context.l10n.d(
|
|
|
|
|
'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.',
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
if (!shouldSave) return;
|
|
|
|
|
final saved = await _saveAllDirtyTabs();
|
|
|
|
|
if (saved) await _destroy();
|
|
|
|
|
} else {
|
|
|
|
|
await _destroy();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Nette afsluiting: herstelbestanden opruimen (alles is opgeslagen) en sluiten.
|
|
|
|
|
Future<void> _destroy() async {
|
|
|
|
|
await ref.read(recoveryServiceProvider).clearAll();
|
|
|
|
|
await windowManager.destroy();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> _confirmSaveBeforeClose(String message) async {
|
|
|
|
|
if (!mounted) return false;
|
|
|
|
|
return await showDialog<bool>(
|
|
|
|
|
context: context,
|
|
|
|
|
barrierDismissible: false,
|
2026-06-04 02:30:03 +02:00
|
|
|
builder: (ctx) {
|
|
|
|
|
final l10n = ctx.l10n;
|
|
|
|
|
return AlertDialog(
|
|
|
|
|
title: Text(l10n.d('Niet-opgeslagen wijzigingen')),
|
|
|
|
|
content: Text(message),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
|
|
|
child: Text(l10n.t('cancel')),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
|
|
|
child: Text(l10n.d('Opslaan en sluiten')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-06-02 23:28:39 +02:00
|
|
|
) ??
|
|
|
|
|
false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> _saveAllDirtyTabs() async {
|
|
|
|
|
final homeDir = ref.read(settingsProvider).homeDirectory;
|
|
|
|
|
for (final tab in ref.read(tabsProvider).tabs) {
|
|
|
|
|
if (!tab.isDirty) continue;
|
|
|
|
|
final saved = await tab.deckNotifier.save(initialDirectory: homeDir);
|
|
|
|
|
if (!saved) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _onCloseTab(int index) async {
|
|
|
|
|
final tab = ref.read(tabsProvider).tabs[index];
|
|
|
|
|
if (tab.isDirty) {
|
|
|
|
|
final shouldSave = await _confirmSaveBeforeClose(
|
2026-06-04 02:30:03 +02:00
|
|
|
context.l10n.d(
|
|
|
|
|
'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.',
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
if (!shouldSave) return;
|
|
|
|
|
final saved = await tab.deckNotifier.save(
|
|
|
|
|
initialDirectory: ref.read(settingsProvider).homeDirectory,
|
|
|
|
|
);
|
|
|
|
|
if (!saved) return;
|
|
|
|
|
}
|
|
|
|
|
ref.read(tabsProvider.notifier).closeTab(index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Sla het actieve tabblad op. App-breed zodat Ctrl/Cmd+S altijd werkt,
|
|
|
|
|
/// ongeacht waar de focus zit.
|
|
|
|
|
void _saveActive() {
|
|
|
|
|
final tab = ref.read(tabsProvider).current;
|
|
|
|
|
tab?.deckNotifier.save(
|
|
|
|
|
initialDirectory: ref.read(settingsProvider).homeDirectory,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:56:51 +02:00
|
|
|
/// Open een presentatie via de zoek-/kies-dialoog. App-breed zodat Ctrl/Cmd+O
|
|
|
|
|
/// altijd werkt, ongeacht waar de focus zit.
|
|
|
|
|
void _openActive() {
|
|
|
|
|
_openWithSearch(context, ref, ref.read(settingsProvider).homeDirectory);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
bool _dragging = false;
|
|
|
|
|
|
|
|
|
|
static const _imageExtensions = {
|
|
|
|
|
'.jpg',
|
|
|
|
|
'.jpeg',
|
|
|
|
|
'.png',
|
|
|
|
|
'.gif',
|
|
|
|
|
'.webp',
|
|
|
|
|
'.bmp',
|
|
|
|
|
'.heic',
|
|
|
|
|
'.tiff',
|
|
|
|
|
'.tif',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Verwerk gesleepte bestanden: presentaties/pakketten openen, afbeeldingen
|
|
|
|
|
/// als nieuwe slide(s) toevoegen aan het actieve deck.
|
|
|
|
|
Future<void> _onFilesDropped(List<String> paths) async {
|
|
|
|
|
final homeDir = ref.read(settingsProvider).homeDirectory;
|
|
|
|
|
final tabs = ref.read(tabsProvider.notifier);
|
|
|
|
|
final images = <String>[];
|
|
|
|
|
for (final path in paths) {
|
|
|
|
|
final ext = p.extension(path).toLowerCase();
|
|
|
|
|
if (ext == '.md') {
|
|
|
|
|
await tabs.openFileByPath(path);
|
|
|
|
|
} else if (ext == '.ocideck' || ext == '.zip') {
|
|
|
|
|
await tabs.importPackageFile(path, homeDir: homeDir);
|
|
|
|
|
} else if (_imageExtensions.contains(ext)) {
|
|
|
|
|
images.add(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (images.isNotEmpty) _addImagesToActiveDeck(images);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _addImagesToActiveDeck(List<String> paths) {
|
|
|
|
|
final tab = ref.read(tabsProvider).current;
|
|
|
|
|
if (tab == null || !tab.isOpen) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(
|
2026-06-02 23:28:39 +02:00
|
|
|
content: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
context.l10n.d(
|
|
|
|
|
'Open eerst een presentatie om afbeeldingen toe te voegen.',
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final deckN = tab.deckNotifier;
|
|
|
|
|
final editorN = tab.editorNotifier;
|
|
|
|
|
var idx = editorN.currentState.selectedIndex;
|
|
|
|
|
for (final path in paths) {
|
|
|
|
|
deckN.addSlide(SlideType.image, afterIndex: idx);
|
|
|
|
|
idx += 1;
|
|
|
|
|
deckN.updateSlide(
|
|
|
|
|
idx,
|
|
|
|
|
Slide.create(SlideType.image).copyWith(imagePath: path),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
editorN.select(idx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final tabsState = ref.watch(tabsProvider);
|
|
|
|
|
|
|
|
|
|
return CallbackShortcuts(
|
|
|
|
|
bindings: {
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
|
|
|
|
|
_saveActive,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyS, meta: true): _saveActive,
|
2026-06-03 21:56:51 +02:00
|
|
|
const SingleActivator(LogicalKeyboardKey.keyO, control: true):
|
|
|
|
|
_openActive,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyO, meta: true): _openActive,
|
2026-06-02 23:28:39 +02:00
|
|
|
},
|
|
|
|
|
child: FocusScope(
|
|
|
|
|
autofocus: true,
|
|
|
|
|
child: DropTarget(
|
|
|
|
|
onDragEntered: (_) => setState(() => _dragging = true),
|
|
|
|
|
onDragExited: (_) => setState(() => _dragging = false),
|
|
|
|
|
onDragDone: (detail) {
|
|
|
|
|
setState(() => _dragging = false);
|
|
|
|
|
_onFilesDropped(detail.files.map((f) => f.path).toList());
|
|
|
|
|
},
|
|
|
|
|
child: Material(
|
|
|
|
|
child: Stack(
|
|
|
|
|
children: [
|
|
|
|
|
Column(
|
|
|
|
|
children: [
|
|
|
|
|
_AppTabBar(
|
|
|
|
|
tabsState: tabsState,
|
|
|
|
|
onSelect: (i) =>
|
|
|
|
|
ref.read(tabsProvider.notifier).selectTab(i),
|
|
|
|
|
onClose: _onCloseTab,
|
|
|
|
|
onAdd: () =>
|
|
|
|
|
ref.read(tabsProvider.notifier).newEmptyTab(),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: IndexedStack(
|
|
|
|
|
index: tabsState.clampedIndex,
|
|
|
|
|
children: [
|
|
|
|
|
for (final tab in tabsState.tabs)
|
|
|
|
|
ProviderScope(
|
|
|
|
|
key: ValueKey(tab.id),
|
|
|
|
|
overrides: [
|
|
|
|
|
deckProvider.overrideWith(
|
|
|
|
|
(ref) => tab.deckNotifier,
|
|
|
|
|
),
|
|
|
|
|
editorProvider.overrideWith(
|
|
|
|
|
(ref) => tab.editorNotifier,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
child: const _TabContent(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
if (_dragging) const _DropOverlay(),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _MainLayout extends ConsumerStatefulWidget {
|
|
|
|
|
final ExportService exportService;
|
|
|
|
|
|
|
|
|
|
const _MainLayout({required this.exportService});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<_MainLayout> createState() => _MainLayoutState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|
|
|
|
static const _minSlideRailWidth = 210.0;
|
|
|
|
|
static const _defaultSlideRailWidth = 320.0;
|
|
|
|
|
static const _minEditorWidth = 420.0;
|
|
|
|
|
|
|
|
|
|
double _slideRailWidth = _defaultSlideRailWidth;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final deckState = ref.watch(deckProvider);
|
|
|
|
|
final deck = deckState.deck!;
|
|
|
|
|
final editor = ref.watch(editorProvider);
|
|
|
|
|
final settings = ref.watch(settingsProvider);
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
final deckNotifier = ref.read(deckProvider.notifier);
|
|
|
|
|
final editorNotifier = ref.read(editorProvider.notifier);
|
|
|
|
|
|
|
|
|
|
final isMarkdownMode = editor.mode == EditorMode.markdown;
|
|
|
|
|
|
|
|
|
|
Future<void> saveDeck() async {
|
|
|
|
|
await deckNotifier.save(initialDirectory: settings.homeDirectory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void openFindReplace() {
|
|
|
|
|
FindReplaceDialog.show(
|
|
|
|
|
context,
|
|
|
|
|
countMatches: (q, cs) =>
|
|
|
|
|
deckNotifier.countMatches(q, caseSensitive: cs),
|
|
|
|
|
replaceAll: (q, r, cs) =>
|
|
|
|
|
deckNotifier.replaceAll(q, r, caseSensitive: cs),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:43:46 +02:00
|
|
|
Future<void> clearAllChecklists() async {
|
|
|
|
|
final count = deckNotifier.checkedChecklistCount;
|
|
|
|
|
if (count == 0) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(
|
|
|
|
|
l10n.d('Er zijn geen aangevinkte checklist-items om te legen.'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (ctx) {
|
|
|
|
|
final l10n = ctx.l10n;
|
|
|
|
|
return AlertDialog(
|
|
|
|
|
title: Text(l10n.d('Alle checkboxen legen?')),
|
|
|
|
|
content: Text(
|
|
|
|
|
'${l10n.d('Hiermee worden alle')} $count '
|
|
|
|
|
'${l10n.d('aangevinkte checklist-items in de hele presentatie uitgevinkt. Dit kun je ongedaan maken met Ctrl/Cmd+Z.')}',
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
|
|
|
child: Text(l10n.t('cancel')),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
|
|
|
child: Text(l10n.d('Alles legen')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
if (confirmed != true) return;
|
|
|
|
|
deckNotifier.clearAllChecklists();
|
|
|
|
|
if (!context.mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
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
|
|
|
content: Text('$count ${l10n.d('checklist-items uitgevinkt.')}'),
|
2026-06-09 15:43:46 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
Future<void> openImageCarousel() async {
|
|
|
|
|
final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1);
|
|
|
|
|
final slide = deck.slides[idx];
|
|
|
|
|
final initialPath = _resolveImagePath(slide.imagePath, deck.projectPath);
|
|
|
|
|
final result = await ImageCarouselPicker.show(
|
|
|
|
|
context,
|
|
|
|
|
searchPaths: _imageSearchPaths(
|
|
|
|
|
deck.projectPath,
|
|
|
|
|
settings.homeDirectory,
|
|
|
|
|
),
|
|
|
|
|
initialPath: initialPath,
|
|
|
|
|
captionService: ref.read(captionServiceProvider),
|
|
|
|
|
descriptionService: ref.read(descriptionServiceProvider),
|
|
|
|
|
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
|
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
|
|
|
onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to),
|
|
|
|
|
openDeckFiles: [
|
|
|
|
|
for (final tab in ref.read(tabsProvider).tabs)
|
|
|
|
|
?tab.deckNotifier.currentState.filePath,
|
|
|
|
|
],
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
if (result == null) return;
|
|
|
|
|
|
|
|
|
|
final updated = switch (slide.type) {
|
|
|
|
|
SlideType.title ||
|
|
|
|
|
SlideType.image ||
|
|
|
|
|
SlideType.quote ||
|
|
|
|
|
SlideType.bulletsImage => slide.copyWith(
|
|
|
|
|
imagePath: result.path,
|
|
|
|
|
imageCaption: result.caption,
|
|
|
|
|
),
|
|
|
|
|
SlideType.twoImages => slide.copyWith(
|
|
|
|
|
imagePath: slide.imagePath.isEmpty ? result.path : slide.imagePath,
|
|
|
|
|
imagePath2: slide.imagePath.isEmpty ? slide.imagePath2 : result.path,
|
|
|
|
|
imageCaption: slide.imagePath.isEmpty
|
|
|
|
|
? result.caption
|
|
|
|
|
: slide.imageCaption,
|
|
|
|
|
imageCaption2: slide.imagePath.isEmpty
|
|
|
|
|
? slide.imageCaption2
|
|
|
|
|
: result.caption,
|
|
|
|
|
),
|
|
|
|
|
SlideType.bullets => slide.copyWith(
|
|
|
|
|
type: SlideType.bulletsImage,
|
|
|
|
|
imagePath: result.path,
|
|
|
|
|
imageCaption: result.caption,
|
|
|
|
|
imageSize: slide.imageSize > 0 ? slide.imageSize : 40,
|
|
|
|
|
),
|
|
|
|
|
_ => null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (updated == null) {
|
|
|
|
|
if (!context.mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(
|
2026-06-02 23:28:39 +02:00
|
|
|
content: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d(
|
|
|
|
|
'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.',
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deckNotifier.updateSlide(idx, updated);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void presentDeck() {
|
|
|
|
|
// Overgeslagen slides weglaten en de selectie naar de eerstvolgende
|
|
|
|
|
// zichtbare slide vertalen.
|
|
|
|
|
final visible = <int>[
|
|
|
|
|
for (var i = 0; i < deck.slides.length; i++)
|
2026-06-06 22:34:42 +02:00
|
|
|
if (!deck.slides[i].skipped &&
|
|
|
|
|
slideVisibleAtTlp(deck.slides[i], deck.tlp))
|
|
|
|
|
i,
|
2026-06-02 23:28:39 +02:00
|
|
|
];
|
2026-06-05 00:02:51 +02:00
|
|
|
final slides = _slidesForPresentationOrExport(deck);
|
|
|
|
|
if (slides.isEmpty) {
|
2026-06-02 23:28:39 +02:00
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(
|
|
|
|
|
content: Text(
|
|
|
|
|
l10n.d('Alle slides zijn overgeslagen — niets om te tonen.'),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
|
|
|
|
|
if (initial < 0) initial = visible.length - 1;
|
2026-06-05 00:02:51 +02:00
|
|
|
if (initial < 0) initial = 0;
|
Add dual-screen presenter mode (slide on beamer, notes on laptop)
When a second display is connected (macOS), presenting now opens a
borderless audience window on the beamer showing the slide, while the
main window shows the presenter view (current/next slide, speaker notes,
clock, controls) on the laptop. The two windows stay in sync over method
channels: navigation, blank screen, audio-complete and beamer clicks are
forwarded between them, and media plays only on the beamer to avoid
double audio. Falls back to the existing single-window presenter when
there is one display or the second window can't be created.
- Vendors a fork of desktop_multi_window in third_party/ that re-adds the
native macOS window geometry/fullscreen calls (coverScreen, setFrame,
close) the published 0.3.0 dropped; wired via a path dependency.
- Registers the app's plugins for sub-windows in MainFlutterWindow so
video/image rendering works on the beamer.
- Routes the multi_window dart entrypoint to a minimal AudienceWindowApp.
Compiles (flutter analyze + macOS debug build) and all tests pass;
runtime two-screen behaviour still needs verification on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:25:34 +02:00
|
|
|
FullscreenPresenter.present(
|
2026-06-02 23:28:39 +02:00
|
|
|
context,
|
2026-06-05 00:02:51 +02:00
|
|
|
slides: slides,
|
2026-06-02 23:28:39 +02:00
|
|
|
projectPath: deck.projectPath,
|
|
|
|
|
themeProfile: deck.themeProfile,
|
|
|
|
|
initialIndex: initial,
|
|
|
|
|
tlp: deck.tlp,
|
Add annotation layer (laser, pen, highlighter) over slides
Draw on slides while presenting, kept as a layer fully separate from the
Marp content so the deck stays pure, portable Marp.
Presenter tools (D pen, T highlighter, E eraser, X laser, C clear, Esc
puts the tool away) with a small floating colour/tool bar shown only when
a tool is active. Strokes use coordinates normalized to the 16:9 slide so
they render identically on the laptop and the beamer; in dual-screen mode
the ink and the laser are mirrored live to the audience window.
Persistence (decoupled from the .md):
- In memory the layer is keyed by Slide.id (stable within a session).
- On disk it lives in a sidecar <name>.ink.json next to the deck and as a
separate entry inside the .ocideck package; the markdown is untouched.
- Because slide ids are regenerated on load, the sidecar anchors strokes
by order + a content fingerprint, re-attaching them after reordering and
dropping them when a slide's content changed.
- Deck.annotations carries the layer in memory but is never serialized to
markdown; deckProvider.setAnnotations keeps it out of undo/redo.
flutter analyze is clean, all tests pass (incl. new stroke/codec tests),
and the macOS debug build compiles. Drawing and live beamer sync still
need verification on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:14:51 +02:00
|
|
|
annotations: deck.annotations,
|
|
|
|
|
onAnnotationsChanged: deckNotifier.setAnnotations,
|
2026-06-09 13:28:23 +02:00
|
|
|
onSlideChanged: (updated) {
|
|
|
|
|
final index = deckNotifier.currentState.deck?.slides.indexWhere(
|
|
|
|
|
(slide) => slide.id == updated.id,
|
|
|
|
|
);
|
|
|
|
|
if (index != null && index >= 0) {
|
|
|
|
|
deckNotifier.updateSlide(index, updated);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void exportDeck() {
|
2026-06-05 00:02:51 +02:00
|
|
|
final slides = _slidesForPresentationOrExport(deck);
|
2026-06-02 23:28:39 +02:00
|
|
|
if (slides.isEmpty) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(
|
2026-06-02 23:28:39 +02:00
|
|
|
content: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Alle slides zijn overgeslagen — niets om te exporteren.'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ExportDialog.show(
|
|
|
|
|
context,
|
|
|
|
|
deckPath: deckState.filePath!,
|
|
|
|
|
slides: slides,
|
|
|
|
|
themeProfile: deck.themeProfile,
|
|
|
|
|
projectPath: deck.projectPath,
|
|
|
|
|
exportService: widget.exportService,
|
|
|
|
|
tlp: deck.tlp,
|
2026-06-03 15:03:27 +02:00
|
|
|
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
2026-06-07 11:42:44 +02:00
|
|
|
// Inline chart data so the HTML export can render charts standalone,
|
|
|
|
|
// even when a chart links an external CSV.
|
2026-06-05 00:02:51 +02:00
|
|
|
markdown: ref
|
|
|
|
|
.read(markdownServiceProvider)
|
2026-06-07 11:42:44 +02:00
|
|
|
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 02:30:03 +02:00
|
|
|
final canExport = deckState.filePath != null && !deckState.isDirty;
|
|
|
|
|
final exportTooltip = deckState.filePath == null
|
|
|
|
|
? l10n.t('exportNeedsSave')
|
|
|
|
|
: deckState.isDirty
|
|
|
|
|
? l10n.t('exportNeedsClean')
|
|
|
|
|
: l10n.t('exportReady');
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
void toggleMarkdownMode() {
|
|
|
|
|
if (isMarkdownMode) {
|
|
|
|
|
editorNotifier.setMode(EditorMode.visual);
|
|
|
|
|
} else {
|
|
|
|
|
editorNotifier.setMode(
|
|
|
|
|
EditorMode.markdown,
|
|
|
|
|
initialMarkdown: deckNotifier.generateMarkdown(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void openFullDeckPreview() {
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (_) =>
|
|
|
|
|
FullDeckPreview(deck: deck, themeProfile: deck.themeProfile),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> newInTab() async {
|
|
|
|
|
final title = await NewDeckDialog.show(context);
|
|
|
|
|
if (title != null) {
|
|
|
|
|
ref.read(tabsProvider.notifier).newDeckInNewTab(title);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> openProperties() async {
|
|
|
|
|
final info = await PresentationInfoDialog.show(context, deck);
|
|
|
|
|
if (info == null) return;
|
|
|
|
|
deckNotifier.updateInfo(
|
2026-06-05 19:14:54 +02:00
|
|
|
title: info.title,
|
2026-06-02 23:28:39 +02:00
|
|
|
author: info.author,
|
|
|
|
|
organization: info.organization,
|
|
|
|
|
version: info.version,
|
|
|
|
|
date: info.date,
|
|
|
|
|
description: info.description,
|
|
|
|
|
keywords: info.keywords,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> exportPackage() async {
|
|
|
|
|
final fileService = ref.read(fileServiceProvider);
|
|
|
|
|
final dest = await fileService.pickPackageDestination(deck);
|
|
|
|
|
if (dest == null) return;
|
|
|
|
|
try {
|
|
|
|
|
await fileService.exportPackage(deck, dest);
|
|
|
|
|
if (!context.mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(
|
|
|
|
|
content: Text('${l10n.d('Pakket geëxporteerd naar:')}\n$dest'),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!context.mounted) return;
|
2026-06-04 02:30:03 +02:00
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(content: Text('${l10n.d('Export mislukt:')} $e')),
|
|
|
|
|
);
|
2026-06-02 23:28:39 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> importPackage() async {
|
|
|
|
|
final fileService = ref.read(fileServiceProvider);
|
|
|
|
|
final path = await fileService.pickPackageFile(
|
|
|
|
|
initialDirectory: settings.homeDirectory,
|
|
|
|
|
);
|
|
|
|
|
if (path == null) return;
|
|
|
|
|
final ok = await ref
|
|
|
|
|
.read(tabsProvider.notifier)
|
|
|
|
|
.importPackageFile(path, homeDir: settings.homeDirectory);
|
|
|
|
|
if (!ok && context.mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(content: Text(l10n.d('Kon dit pakket niet importeren.'))),
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> importUrl() async {
|
|
|
|
|
final url = await _showUrlDialog(context);
|
|
|
|
|
if (url == null || url.trim().isEmpty) return;
|
|
|
|
|
final ok = await ref
|
|
|
|
|
.read(tabsProvider.notifier)
|
|
|
|
|
.importFromUrl(url, homeDir: settings.homeDirectory);
|
|
|
|
|
if (!ok && context.mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-06-04 02:30:03 +02:00
|
|
|
SnackBar(
|
|
|
|
|
content: Text(l10n.d('Kon van deze URL geen presentatie ophalen.')),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PopupMenuItem<String> menuItem(String value, IconData icon, String label) {
|
|
|
|
|
return PopupMenuItem<String>(
|
|
|
|
|
value: value,
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, size: 16, color: const Color(0xFF475569)),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Focus(
|
|
|
|
|
canRequestFocus: false,
|
|
|
|
|
skipTraversal: true,
|
|
|
|
|
onKeyEvent: (node, event) {
|
|
|
|
|
if (event is KeyDownEvent &&
|
|
|
|
|
event.logicalKey == LogicalKeyboardKey.keyS &&
|
|
|
|
|
(HardwareKeyboard.instance.isControlPressed ||
|
|
|
|
|
HardwareKeyboard.instance.isMetaPressed)) {
|
|
|
|
|
saveDeck();
|
|
|
|
|
return KeyEventResult.handled;
|
|
|
|
|
}
|
|
|
|
|
return KeyEventResult.ignored;
|
|
|
|
|
},
|
|
|
|
|
child: CallbackShortcuts(
|
|
|
|
|
bindings: {
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
|
|
|
|
|
saveDeck,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyS, meta: true): saveDeck,
|
|
|
|
|
// Ongedaan maken / opnieuw. Vuren alleen wanneer de focus niet in een
|
|
|
|
|
// tekstveld zit (dat handelt z'n eigen undo af), dus geen conflict.
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyZ, control: true):
|
|
|
|
|
deckNotifier.undo,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyZ, meta: true):
|
|
|
|
|
deckNotifier.undo,
|
|
|
|
|
const SingleActivator(
|
|
|
|
|
LogicalKeyboardKey.keyZ,
|
|
|
|
|
control: true,
|
|
|
|
|
shift: true,
|
|
|
|
|
): deckNotifier.redo,
|
|
|
|
|
const SingleActivator(
|
|
|
|
|
LogicalKeyboardKey.keyZ,
|
|
|
|
|
meta: true,
|
|
|
|
|
shift: true,
|
|
|
|
|
): deckNotifier.redo,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyY, control: true):
|
|
|
|
|
deckNotifier.redo,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyH, control: true):
|
|
|
|
|
openFindReplace,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyH, meta: true):
|
|
|
|
|
openFindReplace,
|
|
|
|
|
},
|
|
|
|
|
child: Scaffold(
|
|
|
|
|
appBar: AppBar(
|
|
|
|
|
title: Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(Icons.slideshow_outlined, size: 22),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Flexible(
|
|
|
|
|
child: Text(deck.title, overflow: TextOverflow.ellipsis),
|
|
|
|
|
),
|
|
|
|
|
if (deckState.isDirty) ...[
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Container(
|
|
|
|
|
width: 6,
|
|
|
|
|
height: 6,
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: Colors.orangeAccent,
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
|
_TlpChip(
|
|
|
|
|
tlp: deck.tlp,
|
|
|
|
|
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
|
|
|
|
),
|
2026-06-05 19:14:54 +02:00
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Tooltip(
|
|
|
|
|
message: l10n.t('presentationProperties'),
|
|
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.info_outline, size: 18),
|
|
|
|
|
onPressed: openProperties,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
// ── Bewerken ────────────────────────────────────────────────
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
message: l10n.t('undo'),
|
2026-06-02 23:28:39 +02:00
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.undo, size: 18),
|
|
|
|
|
onPressed: deckState.canUndo ? deckNotifier.undo : null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
message: l10n.t('redo'),
|
2026-06-02 23:28:39 +02:00
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.redo, size: 18),
|
|
|
|
|
onPressed: deckState.canRedo ? deckNotifier.redo : null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const _ActionsDivider(),
|
|
|
|
|
// ── Inhoud ──────────────────────────────────────────────────
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
message: l10n.t('imageLibrary'),
|
2026-06-02 23:28:39 +02:00
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.photo_library_outlined, size: 18),
|
|
|
|
|
onPressed: openImageCarousel,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const _ActionsDivider(),
|
|
|
|
|
// ── Presenteren & uitvoer ───────────────────────────────────
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
message: l10n.t('presentFullscreen'),
|
2026-06-02 23:28:39 +02:00
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.play_circle_outline, size: 20),
|
|
|
|
|
onPressed: presentDeck,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
message: isMarkdownMode
|
|
|
|
|
? l10n.t('visualMode')
|
|
|
|
|
: l10n.t('markdownMode'),
|
2026-06-02 23:28:39 +02:00
|
|
|
child: IconButton(
|
|
|
|
|
icon: Icon(
|
|
|
|
|
isMarkdownMode ? Icons.view_quilt : Icons.code,
|
|
|
|
|
size: 18,
|
|
|
|
|
),
|
|
|
|
|
onPressed: toggleMarkdownMode,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
message: l10n.t('saveShortcut'),
|
2026-06-02 23:28:39 +02:00
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.save_outlined, size: 18),
|
|
|
|
|
onPressed: saveDeck,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const _ActionsDivider(),
|
|
|
|
|
// ── Overig (minder vaak gebruikt) ───────────────────────────
|
|
|
|
|
PopupMenuButton<String>(
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.t('more'),
|
2026-06-02 23:28:39 +02:00
|
|
|
icon: const Icon(Icons.more_vert, size: 20),
|
|
|
|
|
position: PopupMenuPosition.under,
|
|
|
|
|
onSelected: (v) {
|
|
|
|
|
switch (v) {
|
|
|
|
|
case 'new_tab':
|
|
|
|
|
newInTab();
|
|
|
|
|
case 'open':
|
|
|
|
|
_openWithSearch(context, ref, settings.homeDirectory);
|
|
|
|
|
case 'export_package':
|
|
|
|
|
exportPackage();
|
|
|
|
|
case 'import_package':
|
|
|
|
|
importPackage();
|
|
|
|
|
case 'import_url':
|
|
|
|
|
importUrl();
|
|
|
|
|
case 'find':
|
|
|
|
|
openFindReplace();
|
2026-06-09 15:43:46 +02:00
|
|
|
case 'clear_checklists':
|
|
|
|
|
clearAllChecklists();
|
2026-06-02 23:28:39 +02:00
|
|
|
case 'full_preview':
|
|
|
|
|
openFullDeckPreview();
|
|
|
|
|
case 'properties':
|
|
|
|
|
openProperties();
|
|
|
|
|
case 'settings':
|
|
|
|
|
SettingsDialog.show(context);
|
|
|
|
|
default:
|
|
|
|
|
if (v.startsWith('style:')) {
|
|
|
|
|
final name = v.substring(6);
|
|
|
|
|
final profile = settings.themeProfiles.firstWhere(
|
|
|
|
|
(p) => p.name == name,
|
|
|
|
|
orElse: () => settings.themeProfile,
|
|
|
|
|
);
|
|
|
|
|
deckNotifier.updateThemeProfile(profile);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
itemBuilder: (_) => [
|
|
|
|
|
menuItem(
|
|
|
|
|
'new_tab',
|
|
|
|
|
Icons.add_circle_outline,
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.t('newPresentationTab'),
|
|
|
|
|
),
|
|
|
|
|
menuItem(
|
|
|
|
|
'open',
|
|
|
|
|
Icons.folder_open_outlined,
|
|
|
|
|
l10n.t('openEllipsis'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
const PopupMenuDivider(),
|
|
|
|
|
menuItem(
|
|
|
|
|
'export_package',
|
|
|
|
|
Icons.inventory_2_outlined,
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.t('exportPackage'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
menuItem(
|
|
|
|
|
'import_package',
|
|
|
|
|
Icons.unarchive_outlined,
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.t('importPackage'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
menuItem('import_url', Icons.link, l10n.t('importUrl')),
|
2026-06-02 23:28:39 +02:00
|
|
|
const PopupMenuDivider(),
|
2026-06-04 02:30:03 +02:00
|
|
|
menuItem('find', Icons.find_replace, l10n.t('findReplace')),
|
2026-06-09 15:43:46 +02:00
|
|
|
menuItem(
|
|
|
|
|
'clear_checklists',
|
|
|
|
|
Icons.check_box_outline_blank,
|
|
|
|
|
l10n.d('Alle checkboxen legen'),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
menuItem(
|
|
|
|
|
'full_preview',
|
|
|
|
|
Icons.preview_outlined,
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.t('fullDeckPreview'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
const PopupMenuDivider(),
|
|
|
|
|
for (final profile in settings.themeProfiles)
|
|
|
|
|
PopupMenuItem<String>(
|
|
|
|
|
value: 'style:${profile.name}',
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
profile.name == deck.themeProfile.name
|
|
|
|
|
? Icons.check
|
|
|
|
|
: Icons.palette_outlined,
|
|
|
|
|
size: 16,
|
|
|
|
|
color: const Color(0xFF475569),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Flexible(
|
|
|
|
|
child: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
'${l10n.t('styleProfile')}: ${profile.name}',
|
2026-06-02 23:28:39 +02:00
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const PopupMenuDivider(),
|
2026-06-04 02:30:03 +02:00
|
|
|
menuItem(
|
|
|
|
|
'settings',
|
|
|
|
|
Icons.settings_outlined,
|
|
|
|
|
l10n.t('settings'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
bottomNavigationBar: _DeckStatusBar(
|
|
|
|
|
deck: deck,
|
|
|
|
|
deckState: deckState,
|
|
|
|
|
exportDirectory: settings.exportDirectory,
|
|
|
|
|
onSave: saveDeck,
|
|
|
|
|
onExport: canExport ? exportDeck : null,
|
|
|
|
|
exportTooltip: exportTooltip,
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
body: Builder(
|
|
|
|
|
builder: (ctx) {
|
|
|
|
|
if (deckState.error != null) {
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(deckState.error!),
|
|
|
|
|
backgroundColor: Colors.red[700],
|
|
|
|
|
action: SnackBarAction(
|
|
|
|
|
label: 'OK',
|
|
|
|
|
textColor: Colors.white,
|
|
|
|
|
onPressed: () =>
|
|
|
|
|
ref.read(deckProvider.notifier).clearError(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
ref.read(deckProvider.notifier).clearError();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// The available width comes from MediaQuery, NOT a
|
|
|
|
|
// LayoutBuilder: a LayoutBuilder rebuilds this subtree during
|
|
|
|
|
// the layout phase, and when the slide list's keyed
|
|
|
|
|
// ReorderableListView items get reparented in that pass their
|
|
|
|
|
// overlay children are activated outside an active layout —
|
|
|
|
|
// "A _RenderLayoutBuilder was mutated in performLayout". The
|
|
|
|
|
// body row spans the window, so the window width is equivalent.
|
|
|
|
|
final bodyWidth = MediaQuery.sizeOf(ctx).width;
|
|
|
|
|
final maxRailWidth = (bodyWidth - _minEditorWidth)
|
|
|
|
|
.clamp(_minSlideRailWidth, bodyWidth)
|
|
|
|
|
.toDouble();
|
|
|
|
|
final railWidth = _slideRailWidth
|
|
|
|
|
.clamp(_minSlideRailWidth, maxRailWidth)
|
|
|
|
|
.toDouble();
|
|
|
|
|
if (railWidth != _slideRailWidth) {
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
if (mounted) setState(() => _slideRailWidth = railWidth);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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
|
|
|
return Row(
|
|
|
|
|
children: [
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: railWidth,
|
|
|
|
|
child: SlideListPanel(railWidth: railWidth),
|
|
|
|
|
),
|
|
|
|
|
_ResizableDivider(
|
|
|
|
|
onDrag: (delta) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_slideRailWidth = (_slideRailWidth + delta)
|
|
|
|
|
.clamp(_minSlideRailWidth, maxRailWidth)
|
|
|
|
|
.toDouble();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const Expanded(child: EditorPanel()),
|
|
|
|
|
],
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── AppBar helpers ────────────────────────────────────────────────────────────
|