feature/meldingen-hardening #6
15 changed files with 5337 additions and 5271 deletions
|
|
@ -30,173 +30,13 @@ import 'presentation/fullscreen_presenter.dart';
|
||||||
|
|
||||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Open the search-based presentation picker and load the chosen file
|
// Shell sub-widgets and helpers, split into part files for navigability.
|
||||||
/// (optionally jumping to a matched slide).
|
// These parts share this library's imports and private scope.
|
||||||
Future<void> _openWithSearch(
|
part 'shell/shell_actions.dart';
|
||||||
BuildContext context,
|
part 'shell/tab_bar.dart';
|
||||||
WidgetRef ref,
|
part 'shell/welcome_screen.dart';
|
||||||
String? initialDirectory,
|
part 'shell/status_bar.dart';
|
||||||
) async {
|
part 'shell/shell_overlays.dart';
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
final result = await OpenPresentationDialog.show(
|
|
||||||
context,
|
|
||||||
fileService: ref.read(fileServiceProvider),
|
|
||||||
initialDirectory: initialDirectory ?? settings.homeDirectory,
|
|
||||||
);
|
|
||||||
if (result == null) return;
|
|
||||||
await ref
|
|
||||||
.read(tabsProvider.notifier)
|
|
||||||
.openFileByPath(result.path, selectIndex: result.slideIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vraag een URL op om een presentatie (pakket of markdown) op te halen.
|
|
||||||
Future<String?> _showUrlDialog(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final controller = TextEditingController();
|
|
||||||
return showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text(l10n.d('Importeren via URL')),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 460,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.d(
|
|
||||||
'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.',
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: controller,
|
|
||||||
autofocus: true,
|
|
||||||
keyboardType: TextInputType.url,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'https://…',
|
|
||||||
prefixIcon: Icon(Icons.link, size: 18),
|
|
||||||
isDense: true,
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onSubmitted: (v) => Navigator.pop(ctx, v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text(l10n.t('cancel')),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
|
||||||
icon: const Icon(Icons.download, size: 16),
|
|
||||||
label: Text(l10n.d('Ophalen')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> _imageSearchPaths(String? projectPath, String? homeDirectory) {
|
|
||||||
final projectImagesPath = projectPath == null
|
|
||||||
? null
|
|
||||||
: p.join(projectPath, 'images');
|
|
||||||
return [?projectImagesPath, ?projectPath, ?homeDirectory];
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _resolveImagePath(String path, String? projectPath) {
|
|
||||||
if (path.isEmpty) return null;
|
|
||||||
if (p.isAbsolute(path) || projectPath == null) return path;
|
|
||||||
return p.join(projectPath, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
|
|
||||||
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
|
|
||||||
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
|
|
||||||
Future<void> _replaceImageUsages(
|
|
||||||
WidgetRef ref,
|
|
||||||
String fromAbsolute,
|
|
||||||
String toAbsolute,
|
|
||||||
) async {
|
|
||||||
final target = p.normalize(fromAbsolute);
|
|
||||||
for (final tab in ref.read(tabsProvider).tabs) {
|
|
||||||
final notifier = tab.deckNotifier;
|
|
||||||
final deck = notifier.currentState.deck;
|
|
||||||
if (deck == null) continue;
|
|
||||||
final projectPath = deck.projectPath ?? '';
|
|
||||||
|
|
||||||
String resolve(String candidate) => p.normalize(
|
|
||||||
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
|
|
||||||
);
|
|
||||||
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
|
|
||||||
// binnen het project ligt; anders absoluut.
|
|
||||||
String replacement(String candidate) {
|
|
||||||
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
|
|
||||||
return p.isWithin(projectPath, toAbsolute)
|
|
||||||
? p.relative(toAbsolute, from: projectPath)
|
|
||||||
: toAbsolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < deck.slides.length; i++) {
|
|
||||||
final slide = deck.slides[i];
|
|
||||||
var updated = slide;
|
|
||||||
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
|
|
||||||
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
|
|
||||||
}
|
|
||||||
if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) {
|
|
||||||
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
|
|
||||||
}
|
|
||||||
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Slide> _slidesForPresentationOrExport(Deck deck) {
|
|
||||||
// Drop skipped slides and slides whose TLP classification is stricter than
|
|
||||||
// the level chosen for this presentation/export.
|
|
||||||
final slides = deck.slides
|
|
||||||
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
|
|
||||||
.toList();
|
|
||||||
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
|
|
||||||
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
|
||||||
slides.add(
|
|
||||||
Slide.create(
|
|
||||||
SlideType.freeMarkdown,
|
|
||||||
).copyWith(customMarkdown: closingMarkdown),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return slides;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App shell ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
const AppShell({super.key});
|
const AppShell({super.key});
|
||||||
|
|
@ -504,387 +344,6 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visuele hint terwijl bestanden boven het venster zweven.
|
|
||||||
class _DropOverlay extends StatelessWidget {
|
|
||||||
const _DropOverlay();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Positioned.fill(
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Container(
|
|
||||||
color: const Color(0xFF1C2B47).withValues(alpha: 0.55),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.file_download_outlined,
|
|
||||||
size: 40,
|
|
||||||
color: Color(0xFF2563EB),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
context.l10n.d('Laat los om toe te voegen'),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF1E293B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
context.l10n.d(
|
|
||||||
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color(0xFF64748B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tab bar ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _AppTabBar extends StatelessWidget {
|
|
||||||
final TabsState tabsState;
|
|
||||||
final ValueChanged<int> onSelect;
|
|
||||||
final ValueChanged<int> onClose;
|
|
||||||
final VoidCallback onAdd;
|
|
||||||
|
|
||||||
const _AppTabBar({
|
|
||||||
required this.tabsState,
|
|
||||||
required this.onSelect,
|
|
||||||
required this.onClose,
|
|
||||||
required this.onAdd,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final palette = Theme.of(context).extension<AppPalette>()!;
|
|
||||||
return Container(
|
|
||||||
height: 36,
|
|
||||||
color: palette.panel,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < tabsState.tabs.length; i++)
|
|
||||||
_TabChip(
|
|
||||||
tab: tabsState.tabs[i],
|
|
||||||
isActive: i == tabsState.clampedIndex,
|
|
||||||
showClose:
|
|
||||||
tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen,
|
|
||||||
panelText: palette.panelText,
|
|
||||||
accent: Theme.of(context).colorScheme.secondary,
|
|
||||||
onTap: () => onSelect(i),
|
|
||||||
onClose: () => onClose(i),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Tooltip(
|
|
||||||
message: l10n.t('newTab'),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onAdd,
|
|
||||||
child: SizedBox(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
child: Icon(
|
|
||||||
Icons.add,
|
|
||||||
size: 16,
|
|
||||||
color: palette.panelText.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TabChip extends StatelessWidget {
|
|
||||||
final TabInfo tab;
|
|
||||||
final bool isActive;
|
|
||||||
final bool showClose;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback onClose;
|
|
||||||
final Color panelText;
|
|
||||||
final Color accent;
|
|
||||||
|
|
||||||
const _TabChip({
|
|
||||||
required this.tab,
|
|
||||||
required this.isActive,
|
|
||||||
required this.showClose,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onClose,
|
|
||||||
required this.panelText,
|
|
||||||
required this.accent,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isActive
|
|
||||||
? panelText.withValues(alpha: 0.12)
|
|
||||||
: Colors.transparent,
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: isActive ? accent : Colors.transparent,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (tab.isDirty)
|
|
||||||
Container(
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
margin: const EdgeInsets.only(right: 5),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.orangeAccent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
tab.label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: isActive
|
|
||||||
? panelText
|
|
||||||
: panelText.withValues(alpha: 0.72),
|
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showClose) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
InkWell(
|
|
||||||
onTap: onClose,
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
child: Icon(
|
|
||||||
Icons.close,
|
|
||||||
size: 12,
|
|
||||||
color: panelText.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-tab content ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _TabContent extends ConsumerWidget {
|
|
||||||
const _TabContent();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isOpen = ref.watch(deckProvider.select((s) => s.isOpen));
|
|
||||||
if (!isOpen) return const _WelcomeScreen();
|
|
||||||
return _MainLayout(exportService: ExportService());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Welcome screen ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _WelcomeScreen extends ConsumerWidget {
|
|
||||||
const _WelcomeScreen();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final palette = theme.extension<AppPalette>()!;
|
|
||||||
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
|
||||||
final recentFiles = ref.watch(
|
|
||||||
settingsProvider.select((s) => s.recentFiles),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
body: Row(
|
|
||||||
children: [
|
|
||||||
// ── Midden: logo + knoppen ─────────────────────────────────────
|
|
||||||
Expanded(
|
|
||||||
child: Align(
|
|
||||||
alignment: const Alignment(-0.15, 0.12),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Semantics(
|
|
||||||
label: 'De Winter Information Solutions',
|
|
||||||
image: true,
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/images/de-winter-wittegeheel.png',
|
|
||||||
width: 320,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
filterQuality: FilterQuality.high,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 36),
|
|
||||||
SizedBox(
|
|
||||||
width: 220,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () => _newDeck(context, ref),
|
|
||||||
icon: const Icon(Icons.add, size: 18),
|
|
||||||
label: Text(l10n.t('newPresentation')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SizedBox(
|
|
||||||
width: 220,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => _openWithSearch(context, ref, homeDir),
|
|
||||||
icon: const Icon(Icons.folder_open_outlined, size: 18),
|
|
||||||
label: Text(l10n.t('open')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => SettingsDialog.show(context),
|
|
||||||
icon: const Icon(Icons.settings_outlined, size: 17),
|
|
||||||
label: Text(l10n.t('settings')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// ── Rechts: recente bestanden ──────────────────────────────────
|
|
||||||
if (recentFiles.isNotEmpty)
|
|
||||||
Container(
|
|
||||||
width: 280,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
border: Border(
|
|
||||||
left: BorderSide(color: theme.colorScheme.outlineVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
|
||||||
child: Text(
|
|
||||||
l10n.t('recentPresentations'),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: palette.mutedText,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
itemCount: recentFiles.length,
|
|
||||||
itemBuilder: (_, i) {
|
|
||||||
final path = recentFiles[i];
|
|
||||||
final name = path.split('/').last.replaceAll('.md', '');
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => ref
|
|
||||||
.read(tabsProvider.notifier)
|
|
||||||
.openFileByPath(path),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 10,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.slideshow_outlined,
|
|
||||||
size: 16,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
path,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: palette.mutedText,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _newDeck(BuildContext context, WidgetRef ref) async {
|
|
||||||
final title = await NewDeckDialog.show(context);
|
|
||||||
if (title != null) {
|
|
||||||
ref.read(tabsProvider.notifier).newDeckInCurrentTab(title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main 2-panel layout ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _MainLayout extends ConsumerStatefulWidget {
|
class _MainLayout extends ConsumerStatefulWidget {
|
||||||
final ExportService exportService;
|
final ExportService exportService;
|
||||||
|
|
||||||
|
|
@ -1535,396 +994,3 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AppBar helpers ────────────────────────────────────────────────────────────
|
// ── AppBar helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _DeckStatusBar extends StatelessWidget {
|
|
||||||
final Deck deck;
|
|
||||||
final DeckState deckState;
|
|
||||||
final String? exportDirectory;
|
|
||||||
final Future<void> Function() onSave;
|
|
||||||
final VoidCallback? onExport;
|
|
||||||
final String exportTooltip;
|
|
||||||
|
|
||||||
const _DeckStatusBar({
|
|
||||||
required this.deck,
|
|
||||||
required this.deckState,
|
|
||||||
required this.exportDirectory,
|
|
||||||
required this.onSave,
|
|
||||||
required this.onExport,
|
|
||||||
required this.exportTooltip,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final skipped = deck.slides.where((s) => s.skipped).length;
|
|
||||||
final fileLabel = deckState.filePath == null
|
|
||||||
? l10n.t('notSavedYet')
|
|
||||||
: p.basename(deckState.filePath!);
|
|
||||||
final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved');
|
|
||||||
final exportLabel = exportDirectory == null
|
|
||||||
? l10n.t('exportNextToDeck')
|
|
||||||
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Material(
|
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
child: Container(
|
|
||||||
height: 30,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_StatusAction(
|
|
||||||
icon: deckState.isDirty
|
|
||||||
? Icons.radio_button_checked
|
|
||||||
: Icons.check_circle_outline,
|
|
||||||
label: saveLabel,
|
|
||||||
tooltip: deckState.isDirty
|
|
||||||
? l10n.t('unsavedChanges')
|
|
||||||
: l10n.t('noUnsavedChanges'),
|
|
||||||
color: deckState.isDirty
|
|
||||||
? const Color(0xFFD97706)
|
|
||||||
: const Color(0xFF15803D),
|
|
||||||
onTap: () => onSave(),
|
|
||||||
),
|
|
||||||
const _StatusDivider(),
|
|
||||||
_StatusItem(
|
|
||||||
icon: Icons.description_outlined,
|
|
||||||
label: fileLabel,
|
|
||||||
tooltip: deckState.filePath ?? l10n.t('noFileYet'),
|
|
||||||
),
|
|
||||||
const _StatusDivider(),
|
|
||||||
_StatusItem(
|
|
||||||
icon: Icons.slideshow_outlined,
|
|
||||||
label: skipped == 0
|
|
||||||
? '${deck.slides.length} ${l10n.t('slides')}'
|
|
||||||
: '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}',
|
|
||||||
tooltip: skipped == 0
|
|
||||||
? l10n.t('allSlidesIncluded')
|
|
||||||
: '$skipped ${l10n.t('skippedSlidesExcluded')}',
|
|
||||||
color: skipped == 0 ? null : const Color(0xFF8A6D3B),
|
|
||||||
),
|
|
||||||
const _StatusDivider(),
|
|
||||||
_StatusItem(
|
|
||||||
icon: Icons.palette_outlined,
|
|
||||||
label: deck.themeProfile.name,
|
|
||||||
tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}',
|
|
||||||
),
|
|
||||||
if (deck.tlp != TlpLevel.none) ...[
|
|
||||||
const _StatusDivider(),
|
|
||||||
_StatusItem(
|
|
||||||
icon: Icons.shield_outlined,
|
|
||||||
label: deck.tlp.label,
|
|
||||||
tooltip: '${l10n.t('classification')}: ${deck.tlp.label}',
|
|
||||||
color: Color(deck.tlp.foreground),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const Spacer(),
|
|
||||||
_StatusItem(
|
|
||||||
icon: Icons.folder_outlined,
|
|
||||||
label: exportLabel,
|
|
||||||
tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_StatusAction(
|
|
||||||
icon: Icons.upload_file_outlined,
|
|
||||||
label: l10n.t('export'),
|
|
||||||
tooltip: exportTooltip,
|
|
||||||
onTap: onExport,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusItem extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final String tooltip;
|
|
||||||
final Color? color;
|
|
||||||
|
|
||||||
const _StatusItem({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.tooltip,
|
|
||||||
this.color,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
return Tooltip(
|
|
||||||
message: tooltip,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 13, color: fg),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 210),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: fg,
|
|
||||||
fontWeight: color == null ? FontWeight.normal : FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusAction extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final String tooltip;
|
|
||||||
final Color? color;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _StatusAction({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.tooltip,
|
|
||||||
this.color,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final enabled = onTap != null;
|
|
||||||
final fg = enabled
|
|
||||||
? (color ?? Theme.of(context).colorScheme.secondary)
|
|
||||||
: Theme.of(context).disabledColor;
|
|
||||||
return Tooltip(
|
|
||||||
message: tooltip,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 13, color: fg),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: fg,
|
|
||||||
fontWeight: enabled ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusDivider extends StatelessWidget {
|
|
||||||
const _StatusDivider();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: 1,
|
|
||||||
height: 14,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
color: Theme.of(context).colorScheme.outlineVariant,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dunne verticale scheiding tussen groepen AppBar-knoppen.
|
|
||||||
class _ActionsDivider extends StatelessWidget {
|
|
||||||
const _ActionsDivider();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: 1,
|
|
||||||
height: 20,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
|
||||||
color: Colors.white24,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ResizableDivider extends StatefulWidget {
|
|
||||||
final ValueChanged<double> onDrag;
|
|
||||||
|
|
||||||
const _ResizableDivider({required this.onDrag});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ResizableDivider> createState() => _ResizableDividerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ResizableDividerState extends State<_ResizableDivider> {
|
|
||||||
static const double _keyboardStep = 24;
|
|
||||||
|
|
||||||
bool _hovered = false;
|
|
||||||
bool _dragging = false;
|
|
||||||
bool _focused = false;
|
|
||||||
|
|
||||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
||||||
if (event is KeyUpEvent) return KeyEventResult.ignored;
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
|
||||||
widget.onDrag(-_keyboardStep);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
||||||
widget.onDrag(_keyboardStep);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final active = _hovered || _dragging || _focused;
|
|
||||||
// Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys
|
|
||||||
// move it, and focus is shown with the same highlight as hovering
|
|
||||||
// (WCAG 2.4.7). Screen readers see it as an adjustable element.
|
|
||||||
return Focus(
|
|
||||||
onKeyEvent: _onKeyEvent,
|
|
||||||
onFocusChange: (focused) => setState(() => _focused = focused),
|
|
||||||
child: Semantics(
|
|
||||||
slider: true,
|
|
||||||
label: l10n.d('Breedte van het slidepaneel'),
|
|
||||||
hint: l10n.d('Pijltjestoetsen passen de breedte aan'),
|
|
||||||
onIncrease: () => widget.onDrag(_keyboardStep),
|
|
||||||
onDecrease: () => widget.onDrag(-_keyboardStep),
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.resizeColumn,
|
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onHorizontalDragStart: (_) => setState(() => _dragging = true),
|
|
||||||
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
|
|
||||||
onHorizontalDragCancel: () => setState(() => _dragging = false),
|
|
||||||
onHorizontalDragUpdate: (details) =>
|
|
||||||
widget.onDrag(details.delta.dx),
|
|
||||||
child: Tooltip(
|
|
||||||
message: l10n.d(
|
|
||||||
'Sleep om de slide-preview breder of smaller te maken',
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 9,
|
|
||||||
child: Center(
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 90),
|
|
||||||
width: active ? 3 : 1,
|
|
||||||
color: active
|
|
||||||
? Theme.of(context).colorScheme.secondary
|
|
||||||
: Theme.of(context).colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de
|
|
||||||
/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent
|
|
||||||
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
|
|
||||||
class _TlpChip extends StatelessWidget {
|
|
||||||
final TlpLevel tlp;
|
|
||||||
final ValueChanged<TlpLevel> onSelected;
|
|
||||||
|
|
||||||
const _TlpChip({required this.tlp, required this.onSelected});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final isSet = tlp != TlpLevel.none;
|
|
||||||
final fg = Color(tlp.foreground);
|
|
||||||
|
|
||||||
final child = Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSet ? Colors.black : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (!isSet)
|
|
||||||
const Icon(Icons.shield_outlined, size: 14, color: Colors.white70),
|
|
||||||
if (!isSet) const SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
isSet ? tlp.label : 'TLP',
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSet ? fg : Colors.white70,
|
|
||||||
fontSize: 11.5,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 16,
|
|
||||||
color: isSet ? fg : Colors.white54,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return PopupMenuButton<TlpLevel>(
|
|
||||||
tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
|
|
||||||
position: PopupMenuPosition.under,
|
|
||||||
onSelected: onSelected,
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
for (final level in TlpLevel.values)
|
|
||||||
PopupMenuItem<TlpLevel>(
|
|
||||||
value: level,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: level == TlpLevel.none
|
|
||||||
? Colors.transparent
|
|
||||||
: Color(level.foreground),
|
|
||||||
border: Border.all(color: const Color(0xFF94A3B8)),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(level == TlpLevel.none ? l10n.d('Geen') : level.label),
|
|
||||||
if (level == tlp) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Spacer(),
|
|
||||||
const Icon(Icons.check, size: 16, color: Color(0xFF475569)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
171
lib/widgets/shell/shell_actions.dart
Normal file
171
lib/widgets/shell/shell_actions.dart
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
// Part of the app_shell library — see ../app_shell.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../app_shell.dart';
|
||||||
|
|
||||||
|
/// Open the search-based presentation picker and load the chosen file
|
||||||
|
/// (optionally jumping to a matched slide).
|
||||||
|
Future<void> _openWithSearch(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String? initialDirectory,
|
||||||
|
) async {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
final result = await OpenPresentationDialog.show(
|
||||||
|
context,
|
||||||
|
fileService: ref.read(fileServiceProvider),
|
||||||
|
initialDirectory: initialDirectory ?? settings.homeDirectory,
|
||||||
|
);
|
||||||
|
if (result == null) return;
|
||||||
|
await ref
|
||||||
|
.read(tabsProvider.notifier)
|
||||||
|
.openFileByPath(result.path, selectIndex: result.slideIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vraag een URL op om een presentatie (pakket of markdown) op te halen.
|
||||||
|
Future<String?> _showUrlDialog(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l10n.d('Importeren via URL')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 460,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.d(
|
||||||
|
'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.',
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'https://…',
|
||||||
|
prefixIcon: Icon(Icons.link, size: 18),
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (v) => Navigator.pop(ctx, v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text(l10n.t('cancel')),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||||
|
icon: const Icon(Icons.download, size: 16),
|
||||||
|
label: Text(l10n.d('Ophalen')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _imageSearchPaths(String? projectPath, String? homeDirectory) {
|
||||||
|
final projectImagesPath = projectPath == null
|
||||||
|
? null
|
||||||
|
: p.join(projectPath, 'images');
|
||||||
|
return [?projectImagesPath, ?projectPath, ?homeDirectory];
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolveImagePath(String path, String? projectPath) {
|
||||||
|
if (path.isEmpty) return null;
|
||||||
|
if (p.isAbsolute(path) || projectPath == null) return path;
|
||||||
|
return p.join(projectPath, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
|
||||||
|
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
|
||||||
|
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
|
||||||
|
Future<void> _replaceImageUsages(
|
||||||
|
WidgetRef ref,
|
||||||
|
String fromAbsolute,
|
||||||
|
String toAbsolute,
|
||||||
|
) async {
|
||||||
|
final target = p.normalize(fromAbsolute);
|
||||||
|
for (final tab in ref.read(tabsProvider).tabs) {
|
||||||
|
final notifier = tab.deckNotifier;
|
||||||
|
final deck = notifier.currentState.deck;
|
||||||
|
if (deck == null) continue;
|
||||||
|
final projectPath = deck.projectPath ?? '';
|
||||||
|
|
||||||
|
String resolve(String candidate) => p.normalize(
|
||||||
|
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
|
||||||
|
);
|
||||||
|
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
|
||||||
|
// binnen het project ligt; anders absoluut.
|
||||||
|
String replacement(String candidate) {
|
||||||
|
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
|
||||||
|
return p.isWithin(projectPath, toAbsolute)
|
||||||
|
? p.relative(toAbsolute, from: projectPath)
|
||||||
|
: toAbsolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < deck.slides.length; i++) {
|
||||||
|
final slide = deck.slides[i];
|
||||||
|
var updated = slide;
|
||||||
|
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
|
||||||
|
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
|
||||||
|
}
|
||||||
|
if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) {
|
||||||
|
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
|
||||||
|
}
|
||||||
|
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Slide> _slidesForPresentationOrExport(Deck deck) {
|
||||||
|
// Drop skipped slides and slides whose TLP classification is stricter than
|
||||||
|
// the level chosen for this presentation/export.
|
||||||
|
final slides = deck.slides
|
||||||
|
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
|
||||||
|
.toList();
|
||||||
|
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
|
||||||
|
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
||||||
|
slides.add(
|
||||||
|
Slide.create(
|
||||||
|
SlideType.freeMarkdown,
|
||||||
|
).copyWith(customMarkdown: closingMarkdown),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return slides;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App shell ─────────────────────────────────────────────────────────────────
|
||||||
139
lib/widgets/shell/shell_overlays.dart
Normal file
139
lib/widgets/shell/shell_overlays.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// Part of the app_shell library — see ../app_shell.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../app_shell.dart';
|
||||||
|
|
||||||
|
/// Visuele hint terwijl bestanden boven het venster zweven.
|
||||||
|
class _DropOverlay extends StatelessWidget {
|
||||||
|
const _DropOverlay();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
color: const Color(0xFF1C2B47).withValues(alpha: 0.55),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.file_download_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Color(0xFF2563EB),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
context.l10n.d('Laat los om toe te voegen'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1E293B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.l10n.d(
|
||||||
|
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab bar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ResizableDivider extends StatefulWidget {
|
||||||
|
final ValueChanged<double> onDrag;
|
||||||
|
|
||||||
|
const _ResizableDivider({required this.onDrag});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ResizableDivider> createState() => _ResizableDividerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResizableDividerState extends State<_ResizableDivider> {
|
||||||
|
static const double _keyboardStep = 24;
|
||||||
|
|
||||||
|
bool _hovered = false;
|
||||||
|
bool _dragging = false;
|
||||||
|
bool _focused = false;
|
||||||
|
|
||||||
|
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||||
|
if (event is KeyUpEvent) return KeyEventResult.ignored;
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||||
|
widget.onDrag(-_keyboardStep);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||||
|
widget.onDrag(_keyboardStep);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final active = _hovered || _dragging || _focused;
|
||||||
|
// Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys
|
||||||
|
// move it, and focus is shown with the same highlight as hovering
|
||||||
|
// (WCAG 2.4.7). Screen readers see it as an adjustable element.
|
||||||
|
return Focus(
|
||||||
|
onKeyEvent: _onKeyEvent,
|
||||||
|
onFocusChange: (focused) => setState(() => _focused = focused),
|
||||||
|
child: Semantics(
|
||||||
|
slider: true,
|
||||||
|
label: l10n.d('Breedte van het slidepaneel'),
|
||||||
|
hint: l10n.d('Pijltjestoetsen passen de breedte aan'),
|
||||||
|
onIncrease: () => widget.onDrag(_keyboardStep),
|
||||||
|
onDecrease: () => widget.onDrag(-_keyboardStep),
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeColumn,
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onHorizontalDragStart: (_) => setState(() => _dragging = true),
|
||||||
|
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
|
||||||
|
onHorizontalDragCancel: () => setState(() => _dragging = false),
|
||||||
|
onHorizontalDragUpdate: (details) =>
|
||||||
|
widget.onDrag(details.delta.dx),
|
||||||
|
child: Tooltip(
|
||||||
|
message: l10n.d(
|
||||||
|
'Sleep om de slide-preview breder of smaller te maken',
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 9,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 90),
|
||||||
|
width: active ? 3 : 1,
|
||||||
|
color: active
|
||||||
|
? Theme.of(context).colorScheme.secondary
|
||||||
|
: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
316
lib/widgets/shell/status_bar.dart
Normal file
316
lib/widgets/shell/status_bar.dart
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
// Part of the app_shell library — see ../app_shell.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../app_shell.dart';
|
||||||
|
|
||||||
|
class _DeckStatusBar extends StatelessWidget {
|
||||||
|
final Deck deck;
|
||||||
|
final DeckState deckState;
|
||||||
|
final String? exportDirectory;
|
||||||
|
final Future<void> Function() onSave;
|
||||||
|
final VoidCallback? onExport;
|
||||||
|
final String exportTooltip;
|
||||||
|
|
||||||
|
const _DeckStatusBar({
|
||||||
|
required this.deck,
|
||||||
|
required this.deckState,
|
||||||
|
required this.exportDirectory,
|
||||||
|
required this.onSave,
|
||||||
|
required this.onExport,
|
||||||
|
required this.exportTooltip,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final skipped = deck.slides.where((s) => s.skipped).length;
|
||||||
|
final fileLabel = deckState.filePath == null
|
||||||
|
? l10n.t('notSavedYet')
|
||||||
|
: p.basename(deckState.filePath!);
|
||||||
|
final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved');
|
||||||
|
final exportLabel = exportDirectory == null
|
||||||
|
? l10n.t('exportNextToDeck')
|
||||||
|
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: Container(
|
||||||
|
height: 30,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_StatusAction(
|
||||||
|
icon: deckState.isDirty
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.check_circle_outline,
|
||||||
|
label: saveLabel,
|
||||||
|
tooltip: deckState.isDirty
|
||||||
|
? l10n.t('unsavedChanges')
|
||||||
|
: l10n.t('noUnsavedChanges'),
|
||||||
|
color: deckState.isDirty
|
||||||
|
? const Color(0xFFD97706)
|
||||||
|
: const Color(0xFF15803D),
|
||||||
|
onTap: () => onSave(),
|
||||||
|
),
|
||||||
|
const _StatusDivider(),
|
||||||
|
_StatusItem(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
label: fileLabel,
|
||||||
|
tooltip: deckState.filePath ?? l10n.t('noFileYet'),
|
||||||
|
),
|
||||||
|
const _StatusDivider(),
|
||||||
|
_StatusItem(
|
||||||
|
icon: Icons.slideshow_outlined,
|
||||||
|
label: skipped == 0
|
||||||
|
? '${deck.slides.length} ${l10n.t('slides')}'
|
||||||
|
: '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}',
|
||||||
|
tooltip: skipped == 0
|
||||||
|
? l10n.t('allSlidesIncluded')
|
||||||
|
: '$skipped ${l10n.t('skippedSlidesExcluded')}',
|
||||||
|
color: skipped == 0 ? null : const Color(0xFF8A6D3B),
|
||||||
|
),
|
||||||
|
const _StatusDivider(),
|
||||||
|
_StatusItem(
|
||||||
|
icon: Icons.palette_outlined,
|
||||||
|
label: deck.themeProfile.name,
|
||||||
|
tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}',
|
||||||
|
),
|
||||||
|
if (deck.tlp != TlpLevel.none) ...[
|
||||||
|
const _StatusDivider(),
|
||||||
|
_StatusItem(
|
||||||
|
icon: Icons.shield_outlined,
|
||||||
|
label: deck.tlp.label,
|
||||||
|
tooltip: '${l10n.t('classification')}: ${deck.tlp.label}',
|
||||||
|
color: Color(deck.tlp.foreground),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
_StatusItem(
|
||||||
|
icon: Icons.folder_outlined,
|
||||||
|
label: exportLabel,
|
||||||
|
tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_StatusAction(
|
||||||
|
icon: Icons.upload_file_outlined,
|
||||||
|
label: l10n.t('export'),
|
||||||
|
tooltip: exportTooltip,
|
||||||
|
onTap: onExport,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String tooltip;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const _StatusItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.tooltip,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 13, color: fg),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 210),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: fg,
|
||||||
|
fontWeight: color == null ? FontWeight.normal : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusAction extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String tooltip;
|
||||||
|
final Color? color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const _StatusAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.tooltip,
|
||||||
|
this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = onTap != null;
|
||||||
|
final fg = enabled
|
||||||
|
? (color ?? Theme.of(context).colorScheme.secondary)
|
||||||
|
: Theme.of(context).disabledColor;
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 13, color: fg),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: fg,
|
||||||
|
fontWeight: enabled ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusDivider extends StatelessWidget {
|
||||||
|
const _StatusDivider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 1,
|
||||||
|
height: 14,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dunne verticale scheiding tussen groepen AppBar-knoppen.
|
||||||
|
class _ActionsDivider extends StatelessWidget {
|
||||||
|
const _ActionsDivider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
color: Colors.white24,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de
|
||||||
|
/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent
|
||||||
|
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
|
||||||
|
class _TlpChip extends StatelessWidget {
|
||||||
|
final TlpLevel tlp;
|
||||||
|
final ValueChanged<TlpLevel> onSelected;
|
||||||
|
|
||||||
|
const _TlpChip({required this.tlp, required this.onSelected});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final isSet = tlp != TlpLevel.none;
|
||||||
|
final fg = Color(tlp.foreground);
|
||||||
|
|
||||||
|
final child = Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSet ? Colors.black : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!isSet)
|
||||||
|
const Icon(Icons.shield_outlined, size: 14, color: Colors.white70),
|
||||||
|
if (!isSet) const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
isSet ? tlp.label : 'TLP',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSet ? fg : Colors.white70,
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
size: 16,
|
||||||
|
color: isSet ? fg : Colors.white54,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return PopupMenuButton<TlpLevel>(
|
||||||
|
tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
for (final level in TlpLevel.values)
|
||||||
|
PopupMenuItem<TlpLevel>(
|
||||||
|
value: level,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: level == TlpLevel.none
|
||||||
|
? Colors.transparent
|
||||||
|
: Color(level.foreground),
|
||||||
|
border: Border.all(color: const Color(0xFF94A3B8)),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(level == TlpLevel.none ? l10n.d('Geen') : level.label),
|
||||||
|
if (level == tlp) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Icons.check, size: 16, color: Color(0xFF475569)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
lib/widgets/shell/tab_bar.dart
Normal file
167
lib/widgets/shell/tab_bar.dart
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
// Part of the app_shell library — see ../app_shell.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../app_shell.dart';
|
||||||
|
|
||||||
|
class _AppTabBar extends StatelessWidget {
|
||||||
|
final TabsState tabsState;
|
||||||
|
final ValueChanged<int> onSelect;
|
||||||
|
final ValueChanged<int> onClose;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
|
||||||
|
const _AppTabBar({
|
||||||
|
required this.tabsState,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final palette = Theme.of(context).extension<AppPalette>()!;
|
||||||
|
return Container(
|
||||||
|
height: 36,
|
||||||
|
color: palette.panel,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < tabsState.tabs.length; i++)
|
||||||
|
_TabChip(
|
||||||
|
tab: tabsState.tabs[i],
|
||||||
|
isActive: i == tabsState.clampedIndex,
|
||||||
|
showClose:
|
||||||
|
tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen,
|
||||||
|
panelText: palette.panelText,
|
||||||
|
accent: Theme.of(context).colorScheme.secondary,
|
||||||
|
onTap: () => onSelect(i),
|
||||||
|
onClose: () => onClose(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: l10n.t('newTab'),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onAdd,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: Icon(
|
||||||
|
Icons.add,
|
||||||
|
size: 16,
|
||||||
|
color: palette.panelText.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabChip extends StatelessWidget {
|
||||||
|
final TabInfo tab;
|
||||||
|
final bool isActive;
|
||||||
|
final bool showClose;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final Color panelText;
|
||||||
|
final Color accent;
|
||||||
|
|
||||||
|
const _TabChip({
|
||||||
|
required this.tab,
|
||||||
|
required this.isActive,
|
||||||
|
required this.showClose,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onClose,
|
||||||
|
required this.panelText,
|
||||||
|
required this.accent,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive
|
||||||
|
? panelText.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: isActive ? accent : Colors.transparent,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (tab.isDirty)
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
margin: const EdgeInsets.only(right: 5),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.orangeAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
tab.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isActive
|
||||||
|
? panelText
|
||||||
|
: panelText.withValues(alpha: 0.72),
|
||||||
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showClose) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
InkWell(
|
||||||
|
onTap: onClose,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 12,
|
||||||
|
color: panelText.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-tab content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TabContent extends ConsumerWidget {
|
||||||
|
const _TabContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isOpen = ref.watch(deckProvider.select((s) => s.isOpen));
|
||||||
|
if (!isOpen) return const _WelcomeScreen();
|
||||||
|
return _MainLayout(exportService: ExportService());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Welcome screen ────────────────────────────────────────────────────────────
|
||||||
164
lib/widgets/shell/welcome_screen.dart
Normal file
164
lib/widgets/shell/welcome_screen.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// Part of the app_shell library — see ../app_shell.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../app_shell.dart';
|
||||||
|
|
||||||
|
class _WelcomeScreen extends ConsumerWidget {
|
||||||
|
const _WelcomeScreen();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final palette = theme.extension<AppPalette>()!;
|
||||||
|
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
||||||
|
final recentFiles = ref.watch(
|
||||||
|
settingsProvider.select((s) => s.recentFiles),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
// ── Midden: logo + knoppen ─────────────────────────────────────
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: const Alignment(-0.15, 0.12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Semantics(
|
||||||
|
label: 'De Winter Information Solutions',
|
||||||
|
image: true,
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/de-winter-wittegeheel.png',
|
||||||
|
width: 320,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _newDeck(context, ref),
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: Text(l10n.t('newPresentation')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _openWithSearch(context, ref, homeDir),
|
||||||
|
icon: const Icon(Icons.folder_open_outlined, size: 18),
|
||||||
|
label: Text(l10n.t('open')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => SettingsDialog.show(context),
|
||||||
|
icon: const Icon(Icons.settings_outlined, size: 17),
|
||||||
|
label: Text(l10n.t('settings')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── Rechts: recente bestanden ──────────────────────────────────
|
||||||
|
if (recentFiles.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: 280,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||||
|
child: Text(
|
||||||
|
l10n.t('recentPresentations'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: palette.mutedText,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
itemCount: recentFiles.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final path = recentFiles[i];
|
||||||
|
final name = path.split('/').last.replaceAll('.md', '');
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => ref
|
||||||
|
.read(tabsProvider.notifier)
|
||||||
|
.openFileByPath(path),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.slideshow_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
path,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: palette.mutedText,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _newDeck(BuildContext context, WidgetRef ref) async {
|
||||||
|
final title = await NewDeckDialog.show(context);
|
||||||
|
if (title != null) {
|
||||||
|
ref.read(tabsProvider.notifier).newDeckInCurrentTab(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main 2-panel layout ───────────────────────────────────────────────────────
|
||||||
916
lib/widgets/slides/previews/bullets_previews.dart
Normal file
916
lib/widgets/slides/previews/bullets_previews.dart
Normal file
|
|
@ -0,0 +1,916 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
class _BulletsPreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _BulletsPreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pad = w * 0.07;
|
||||||
|
// Slightly tighter top/bottom margin than the side margin so short
|
||||||
|
// checklists can grow into more of the slide height instead of leaving a
|
||||||
|
// wide empty band below the text.
|
||||||
|
final vPad = w * 0.05;
|
||||||
|
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||||
|
final titleSize = w * 0.042;
|
||||||
|
final subtitleSize = w * 0.030;
|
||||||
|
final bulletSize = w * 0.026;
|
||||||
|
final spacing = pad * 0.5;
|
||||||
|
final bulletGap = w * 0.006;
|
||||||
|
final bullets = slide.bullets
|
||||||
|
.where((b) => b.trimLeft().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final hasTitle = slide.title.isNotEmpty;
|
||||||
|
final subtitle = slide.subtitle;
|
||||||
|
final hasSubtitle = subtitle.isNotEmpty;
|
||||||
|
final showProgress =
|
||||||
|
slide.listStyle == ListStyle.checklist &&
|
||||||
|
slide.showChecklistProgress &&
|
||||||
|
bullets.isNotEmpty;
|
||||||
|
|
||||||
|
final slideHeight = w * 9 / 16;
|
||||||
|
final availW = (w - pad * 2).clamp(w * 0.12, w);
|
||||||
|
// The progress chart only needs a modest, fixed slot; give all remaining
|
||||||
|
// width to the bullets so the text can grow as large (and readable) as
|
||||||
|
// possible, especially on slides with many checklist items.
|
||||||
|
final progressGap = w * 0.025;
|
||||||
|
final progressW = w * 0.34;
|
||||||
|
final textAvailW = showProgress
|
||||||
|
? (availW - progressGap - progressW).clamp(w * 0.12, availW)
|
||||||
|
: availW;
|
||||||
|
final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
|
||||||
|
// Grow (or, when needed, shrink) the text so it uses the full vertical
|
||||||
|
// space instead of leaving a large empty area below a few short bullets.
|
||||||
|
final scale = _bulletsFitScale(
|
||||||
|
availW: textAvailW,
|
||||||
|
availH: availH,
|
||||||
|
hasTitle: hasTitle,
|
||||||
|
title: slide.title,
|
||||||
|
bullets: bullets,
|
||||||
|
titleSize: titleSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
spacing: spacing,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
font: font,
|
||||||
|
subtitle: subtitle,
|
||||||
|
subtitleSize: subtitleSize,
|
||||||
|
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
pad,
|
||||||
|
vPad + safe.top,
|
||||||
|
pad,
|
||||||
|
vPad + safe.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (hasTitle)
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: titleSize * scale,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _hexColor(profile.textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
if (hasSubtitle) ...[
|
||||||
|
SizedBox(height: spacing * scale * 0.4),
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
subtitle,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: subtitleSize * scale,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if ((hasTitle || hasSubtitle) && bullets.isNotEmpty)
|
||||||
|
SizedBox(height: spacing * scale),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _BulletListColumn(
|
||||||
|
bullets: bullets,
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
scale: scale,
|
||||||
|
column: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showProgress) ...[
|
||||||
|
SizedBox(width: progressGap),
|
||||||
|
SizedBox(
|
||||||
|
width: progressW,
|
||||||
|
child: Center(
|
||||||
|
child: _ChecklistProgress(
|
||||||
|
bullets: bullets,
|
||||||
|
w: w,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TwoBulletsPreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _TwoBulletsPreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// One bullet column with an optional heading above it. When any column has a
|
||||||
|
/// heading, an equal-height slot is reserved in both so the bullet lists line
|
||||||
|
/// up.
|
||||||
|
Widget _bulletColumn(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required List<String> bullets,
|
||||||
|
required double columnW,
|
||||||
|
required double headingSize,
|
||||||
|
required double headingSlotH,
|
||||||
|
required double headingGap,
|
||||||
|
required double bulletSize,
|
||||||
|
required double bulletGap,
|
||||||
|
required double scale,
|
||||||
|
required int column,
|
||||||
|
}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: columnW,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (headingSlotH > 0) ...[
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: headingSlotH,
|
||||||
|
child: title.isEmpty
|
||||||
|
? null
|
||||||
|
: _md(
|
||||||
|
context,
|
||||||
|
title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: headingSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: headingGap),
|
||||||
|
],
|
||||||
|
_BulletListColumn(
|
||||||
|
bullets: bullets,
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
scale: scale,
|
||||||
|
column: column,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pad = w * 0.065;
|
||||||
|
// Tighter top/bottom margin than the side margin so dense columns (e.g. a
|
||||||
|
// 19-item list) can use more of the slide height and stay readable.
|
||||||
|
final vPad = w * 0.045;
|
||||||
|
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||||
|
final leftBullets = slide.bullets
|
||||||
|
.where((b) => b.trimLeft().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final rightBullets = slide.bullets2
|
||||||
|
.where((b) => b.trimLeft().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final hasTitle = slide.title.isNotEmpty;
|
||||||
|
|
||||||
|
// On dense slides (a long column drives the shared text size down) spend
|
||||||
|
// less of the height on the title, headings and inter-item gaps so the
|
||||||
|
// list items themselves can render larger and stay readable.
|
||||||
|
final dense = math.max(leftBullets.length, rightBullets.length) > 12;
|
||||||
|
final titleSize = w * (dense ? 0.034 : 0.04);
|
||||||
|
final bulletSize = w * 0.024;
|
||||||
|
final spacing = pad * (dense ? 0.28 : 0.38);
|
||||||
|
final bulletGap = w * (dense ? 0.0036 : 0.0055);
|
||||||
|
final columnGap = w * 0.055;
|
||||||
|
|
||||||
|
final col1Title = slide.columnTitle1.trim();
|
||||||
|
final col2Title = slide.columnTitle2.trim();
|
||||||
|
final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty;
|
||||||
|
final headingSize = w * (dense ? 0.023 : 0.03);
|
||||||
|
final headingGap = w * (dense ? 0.007 : 0.012);
|
||||||
|
|
||||||
|
final slideHeight = w * 9 / 16;
|
||||||
|
final contentW = (w - pad * 2).clamp(w * 0.12, w);
|
||||||
|
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
|
||||||
|
var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
|
||||||
|
if (hasTitle) {
|
||||||
|
availH -= _measureTextHeight(
|
||||||
|
slide.title,
|
||||||
|
titleSize,
|
||||||
|
contentW,
|
||||||
|
bold: true,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
availH -= spacing;
|
||||||
|
}
|
||||||
|
// Reserve room for the (optional) column headings so the bullets still fit.
|
||||||
|
double headingHeight(String t) => t.isEmpty
|
||||||
|
? 0
|
||||||
|
: _measureTextHeight(
|
||||||
|
t,
|
||||||
|
headingSize,
|
||||||
|
columnW,
|
||||||
|
bold: true,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
final maxHeadingH = math.max(
|
||||||
|
headingHeight(col1Title),
|
||||||
|
headingHeight(col2Title),
|
||||||
|
);
|
||||||
|
if (hasColumnTitles) availH -= maxHeadingH + headingGap;
|
||||||
|
final leftScale = _bulletsFitScale(
|
||||||
|
availW: columnW,
|
||||||
|
availH: availH,
|
||||||
|
hasTitle: false,
|
||||||
|
title: '',
|
||||||
|
bullets: leftBullets,
|
||||||
|
titleSize: titleSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
spacing: spacing,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
font: font,
|
||||||
|
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
);
|
||||||
|
final rightScale = _bulletsFitScale(
|
||||||
|
availW: columnW,
|
||||||
|
availH: availH,
|
||||||
|
hasTitle: false,
|
||||||
|
title: '',
|
||||||
|
bullets: rightBullets,
|
||||||
|
titleSize: titleSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
spacing: spacing,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
font: font,
|
||||||
|
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
);
|
||||||
|
// Treat both columns as one composition: the busiest column determines
|
||||||
|
// the shared text size, so left and right never look typographically
|
||||||
|
// unrelated.
|
||||||
|
final columnScale = math.min(leftScale, rightScale);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
pad,
|
||||||
|
vPad + safe.top,
|
||||||
|
pad,
|
||||||
|
vPad + safe.bottom,
|
||||||
|
),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: contentW,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (hasTitle)
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: titleSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _hexColor(profile.textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
if (hasTitle) SizedBox(height: spacing),
|
||||||
|
if (slide.listStyle == ListStyle.checklist &&
|
||||||
|
slide.showChecklistProgress &&
|
||||||
|
(leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: contentW * 0.5,
|
||||||
|
child: _ChecklistProgress(
|
||||||
|
bullets: [...leftBullets, ...rightBullets],
|
||||||
|
w: w,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: spacing),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_bulletColumn(
|
||||||
|
context,
|
||||||
|
title: col1Title,
|
||||||
|
bullets: leftBullets,
|
||||||
|
columnW: columnW,
|
||||||
|
headingSize: headingSize,
|
||||||
|
headingSlotH: hasColumnTitles ? maxHeadingH : 0,
|
||||||
|
headingGap: headingGap,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
scale: columnScale,
|
||||||
|
column: 0,
|
||||||
|
),
|
||||||
|
SizedBox(width: columnGap),
|
||||||
|
_bulletColumn(
|
||||||
|
context,
|
||||||
|
title: col2Title,
|
||||||
|
bullets: rightBullets,
|
||||||
|
columnW: columnW,
|
||||||
|
headingSize: headingSize,
|
||||||
|
headingSlotH: hasColumnTitles ? maxHeadingH : 0,
|
||||||
|
headingGap: headingGap,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
scale: columnScale,
|
||||||
|
column: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulletsImagePreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String? projectPath;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _BulletsImagePreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
this.projectPath,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final leftPad = w * 0.038;
|
||||||
|
final verticalPad = w * 0.042;
|
||||||
|
// Keep the gap between the text column and the image equal to the slide's
|
||||||
|
// left margin so the layout stays symmetric.
|
||||||
|
final gap = leftPad;
|
||||||
|
final safe = slide.showLogo
|
||||||
|
? _splitTextLogoSafeInsets(w, profile)
|
||||||
|
: EdgeInsets.zero;
|
||||||
|
final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40)
|
||||||
|
.clamp(0.1, 0.70);
|
||||||
|
final imgWidth = w * imgFraction;
|
||||||
|
final bulletSize = w * 0.031;
|
||||||
|
final titleSize = w * 0.042;
|
||||||
|
final spacing = verticalPad * 0.32;
|
||||||
|
final bulletGap = w * 0.005;
|
||||||
|
final bullets = slide.bullets
|
||||||
|
.where((b) => b.trimLeft().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final hasTitle = slide.title.isNotEmpty;
|
||||||
|
|
||||||
|
// The slide is always rendered 16:9, so the available area for the text
|
||||||
|
// column is fully determined by the width. Computing it directly (instead
|
||||||
|
// of via a LayoutBuilder) keeps the widget tree identical to the image
|
||||||
|
// side and avoids any layout-timing surprises.
|
||||||
|
final slideHeight = w * 9 / 16;
|
||||||
|
final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w);
|
||||||
|
final availH =
|
||||||
|
slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom);
|
||||||
|
// Pick the largest font scale (capped at the design size) whose content
|
||||||
|
// still fits the available height at the full column width. This keeps the
|
||||||
|
// text as large as possible and lets it span the full width toward the
|
||||||
|
// image, instead of uniformly shrinking and leaving a wide gap.
|
||||||
|
final scale = _bulletsFitScale(
|
||||||
|
availW: availW,
|
||||||
|
availH: availH,
|
||||||
|
hasTitle: hasTitle,
|
||||||
|
title: slide.title,
|
||||||
|
bullets: bullets,
|
||||||
|
titleSize: titleSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
spacing: spacing,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
font: font,
|
||||||
|
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: imgWidth,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_resolvedImage(context, slide.imagePath, projectPath),
|
||||||
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: imgWidth + gap,
|
||||||
|
bottom: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
leftPad,
|
||||||
|
verticalPad + safe.top,
|
||||||
|
0,
|
||||||
|
verticalPad + safe.bottom,
|
||||||
|
),
|
||||||
|
// FittedBox stays as a safety net for measurement rounding; with
|
||||||
|
// an accurate scale it renders at scale 1 (full width).
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: availW,
|
||||||
|
child: _contentColumn(
|
||||||
|
context: context,
|
||||||
|
scale: scale,
|
||||||
|
bullets: bullets,
|
||||||
|
hasTitle: hasTitle,
|
||||||
|
titleSize: titleSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
spacing: spacing,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contentColumn({
|
||||||
|
required BuildContext context,
|
||||||
|
required double scale,
|
||||||
|
required List<String> bullets,
|
||||||
|
required bool hasTitle,
|
||||||
|
required double titleSize,
|
||||||
|
required double bulletSize,
|
||||||
|
required double spacing,
|
||||||
|
required double bulletGap,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (hasTitle)
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: titleSize * scale,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _hexColor(profile.textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale),
|
||||||
|
if (slide.listStyle == ListStyle.checklist &&
|
||||||
|
slide.showChecklistProgress &&
|
||||||
|
bullets.isNotEmpty) ...[
|
||||||
|
_ChecklistProgress(
|
||||||
|
bullets: bullets,
|
||||||
|
w: w,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
),
|
||||||
|
SizedBox(height: spacing * scale),
|
||||||
|
],
|
||||||
|
...bullets.asMap().entries.map((entry) {
|
||||||
|
final b = entry.value;
|
||||||
|
int level = 0;
|
||||||
|
while (level < b.length && b[level] == '\t') {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
final text = slide.listStyle == ListStyle.checklist
|
||||||
|
? checklistItemText(b)
|
||||||
|
: b.substring(level);
|
||||||
|
final checked =
|
||||||
|
slide.listStyle == ListStyle.checklist && checklistItemChecked(b);
|
||||||
|
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||||||
|
return _ChecklistBulletRow(
|
||||||
|
bullets: bullets,
|
||||||
|
itemIndex: entry.key,
|
||||||
|
column: 0,
|
||||||
|
listStyle: slide.listStyle,
|
||||||
|
checked: checked,
|
||||||
|
text: text,
|
||||||
|
level: level,
|
||||||
|
fontSize: fontSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
scale: scale,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulletListColumn extends StatelessWidget {
|
||||||
|
final List<String> bullets;
|
||||||
|
final ListStyle listStyle;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
final double bulletSize;
|
||||||
|
final double bulletGap;
|
||||||
|
final double scale;
|
||||||
|
final int column;
|
||||||
|
|
||||||
|
const _BulletListColumn({
|
||||||
|
required this.bullets,
|
||||||
|
required this.listStyle,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
required this.bulletSize,
|
||||||
|
required this.bulletGap,
|
||||||
|
required this.scale,
|
||||||
|
this.column = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
...bullets.asMap().entries.map((entry) {
|
||||||
|
final b = entry.value;
|
||||||
|
int level = 0;
|
||||||
|
while (level < b.length && b[level] == '\t') {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
final text = listStyle == ListStyle.checklist
|
||||||
|
? checklistItemText(b)
|
||||||
|
: b.substring(level);
|
||||||
|
final checked =
|
||||||
|
listStyle == ListStyle.checklist && checklistItemChecked(b);
|
||||||
|
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||||||
|
return _ChecklistBulletRow(
|
||||||
|
bullets: bullets,
|
||||||
|
itemIndex: entry.key,
|
||||||
|
column: column,
|
||||||
|
listStyle: listStyle,
|
||||||
|
checked: checked,
|
||||||
|
text: text,
|
||||||
|
level: level,
|
||||||
|
fontSize: fontSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
scale: scale,
|
||||||
|
font: font,
|
||||||
|
profile: profile,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upper bound for growing bullet text to fill otherwise empty vertical space.
|
||||||
|
const double _kBulletsMaxScale = 3.2;
|
||||||
|
|
||||||
|
/// Split slides have a much narrower column, so short bullet lists can stay
|
||||||
|
/// visually timid unless they are allowed to grow a little further.
|
||||||
|
const double _kSplitBulletsMaxScale = 4.35;
|
||||||
|
|
||||||
|
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
|
||||||
|
/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's
|
||||||
|
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
|
||||||
|
/// text at 24–32pt — beyond that it stops aiding readability and starts
|
||||||
|
/// competing with the title. The fit scale multiplies title and bullets
|
||||||
|
/// alike, so capping the bullet size also keeps the hierarchy intact.
|
||||||
|
const double _kBulletMaxFontFraction = 0.0335;
|
||||||
|
|
||||||
|
/// The largest auto-fit scale that keeps bullets at or under
|
||||||
|
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
|
||||||
|
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
|
||||||
|
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
|
||||||
|
|
||||||
|
/// Line height used for bullet body text, shared by rendering and measuring.
|
||||||
|
const double _kBulletLineHeight = 1.16;
|
||||||
|
|
||||||
|
String _bulletMarkerForLevel(int level) {
|
||||||
|
const markers = ['•', '◦', '▪', '▫', '–'];
|
||||||
|
return markers[level.clamp(0, markers.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _listMarker(List<String> items, int index, ListStyle style) {
|
||||||
|
int levelOf(String item) {
|
||||||
|
var level = 0;
|
||||||
|
while (level < item.length && item[level] == '\t') {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
final level = levelOf(items[index]);
|
||||||
|
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
|
||||||
|
if (style == ListStyle.checklist) {
|
||||||
|
return checklistItemChecked(items[index]) ? '☑' : '☐';
|
||||||
|
}
|
||||||
|
var number = 0;
|
||||||
|
for (var i = 0; i <= index; i++) {
|
||||||
|
final itemLevel = levelOf(items[i]);
|
||||||
|
if (itemLevel == level) number++;
|
||||||
|
if (itemLevel < level) number = 0;
|
||||||
|
}
|
||||||
|
return '$number.';
|
||||||
|
}
|
||||||
|
|
||||||
|
double _bulletLevelScale(int level) {
|
||||||
|
if (level <= 0) return 1.0;
|
||||||
|
if (level == 1) return 0.86;
|
||||||
|
if (level == 2) return 0.80;
|
||||||
|
return 0.76;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largest scale in [minScale, maxScale] for which the bullet block fits
|
||||||
|
/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this
|
||||||
|
/// also grows the text *above* its design size when there is spare vertical
|
||||||
|
/// room, so short slides use the full height instead of clustering at the top.
|
||||||
|
double _bulletsFitScale({
|
||||||
|
required double availW,
|
||||||
|
required double availH,
|
||||||
|
required bool hasTitle,
|
||||||
|
required String title,
|
||||||
|
required List<String> bullets,
|
||||||
|
required double titleSize,
|
||||||
|
required double bulletSize,
|
||||||
|
required double spacing,
|
||||||
|
required double bulletGap,
|
||||||
|
required String font,
|
||||||
|
String subtitle = '',
|
||||||
|
double subtitleSize = 0,
|
||||||
|
double minScale = 0.2,
|
||||||
|
double maxScale = 1.0,
|
||||||
|
ListStyle listStyle = ListStyle.bullets,
|
||||||
|
}) {
|
||||||
|
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
|
||||||
|
// 2% safety margin so minor measurement differences never overflow.
|
||||||
|
final budget = availH * 0.98;
|
||||||
|
double measure(double scale) => _bulletsBlockHeight(
|
||||||
|
scale: scale,
|
||||||
|
availW: availW,
|
||||||
|
listStyle: listStyle,
|
||||||
|
hasTitle: hasTitle,
|
||||||
|
title: title,
|
||||||
|
bullets: bullets,
|
||||||
|
titleSize: titleSize,
|
||||||
|
bulletSize: bulletSize,
|
||||||
|
spacing: spacing,
|
||||||
|
bulletGap: bulletGap,
|
||||||
|
font: font,
|
||||||
|
subtitle: subtitle,
|
||||||
|
subtitleSize: subtitleSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Everything already fits at the largest allowed size → use it.
|
||||||
|
if (measure(maxScale) <= budget) return maxScale;
|
||||||
|
|
||||||
|
// Otherwise binary-search the largest scale that fits. Search upward from the
|
||||||
|
// design size when it fits, downward when even the design size overflows.
|
||||||
|
double lo, hi;
|
||||||
|
if (maxScale > 1.0 && measure(1.0) <= budget) {
|
||||||
|
lo = 1.0;
|
||||||
|
hi = maxScale;
|
||||||
|
} else {
|
||||||
|
lo = minScale;
|
||||||
|
hi = maxScale > 1.0 ? 1.0 : maxScale;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < 24; i++) {
|
||||||
|
final mid = (lo + hi) / 2;
|
||||||
|
if (measure(mid) <= budget) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _bulletsBlockHeight({
|
||||||
|
required double scale,
|
||||||
|
required double availW,
|
||||||
|
required bool hasTitle,
|
||||||
|
required String title,
|
||||||
|
required List<String> bullets,
|
||||||
|
required double titleSize,
|
||||||
|
required double bulletSize,
|
||||||
|
required double spacing,
|
||||||
|
required double bulletGap,
|
||||||
|
required String font,
|
||||||
|
String subtitle = '',
|
||||||
|
double subtitleSize = 0,
|
||||||
|
ListStyle listStyle = ListStyle.bullets,
|
||||||
|
}) {
|
||||||
|
var height = 0.0;
|
||||||
|
if (hasTitle) {
|
||||||
|
height += _measureTextHeight(
|
||||||
|
title,
|
||||||
|
titleSize * scale,
|
||||||
|
availW,
|
||||||
|
bold: true,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (subtitle.isNotEmpty) {
|
||||||
|
height += spacing * scale * 0.4;
|
||||||
|
height += _measureTextHeight(
|
||||||
|
subtitle,
|
||||||
|
subtitleSize * scale,
|
||||||
|
availW,
|
||||||
|
bold: true,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
|
||||||
|
height += spacing * scale;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < bullets.length; i++) {
|
||||||
|
final b = bullets[i];
|
||||||
|
int level = 0;
|
||||||
|
while (level < b.length && b[level] == '\t') {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
// Measure exactly what gets rendered: checklists strip the `[x] ` prefix
|
||||||
|
// and use a checkbox marker, numbered lists use `N.`. Measuring the raw
|
||||||
|
// string with a bullet marker over-counts the height and would shrink the
|
||||||
|
// text below the space it actually needs.
|
||||||
|
final text = listStyle == ListStyle.checklist
|
||||||
|
? checklistItemText(b)
|
||||||
|
: b.substring(level);
|
||||||
|
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||||||
|
final indent = level * bulletSize * 1.05 * scale;
|
||||||
|
final marker = '${_listMarker(bullets, i, listStyle)} ';
|
||||||
|
final markerW = _measureTextWidth(
|
||||||
|
marker,
|
||||||
|
fontSize,
|
||||||
|
bold: true,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
|
||||||
|
final textH = _measureTextHeight(
|
||||||
|
text,
|
||||||
|
fontSize,
|
||||||
|
wrapW,
|
||||||
|
lineHeight: _kBulletLineHeight,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
final markerH = _measureTextHeight(
|
||||||
|
marker,
|
||||||
|
fontSize,
|
||||||
|
double.infinity,
|
||||||
|
fontFamily: font,
|
||||||
|
);
|
||||||
|
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
|
||||||
|
}
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _measureTextHeight(
|
||||||
|
String text,
|
||||||
|
double fontSize,
|
||||||
|
double maxWidth, {
|
||||||
|
double? lineHeight,
|
||||||
|
bool bold = false,
|
||||||
|
String? fontFamily,
|
||||||
|
}) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: stripInlineMarkdown(text),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: fontSize,
|
||||||
|
height: lineHeight,
|
||||||
|
fontWeight: bold ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
|
||||||
|
return painter.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _measureTextWidth(
|
||||||
|
String text,
|
||||||
|
double fontSize, {
|
||||||
|
bool bold = false,
|
||||||
|
String? fontFamily,
|
||||||
|
}) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: stripInlineMarkdown(text),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: bold ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
return painter.width;
|
||||||
|
}
|
||||||
1478
lib/widgets/slides/previews/chart_preview.dart
Normal file
1478
lib/widgets/slides/previews/chart_preview.dart
Normal file
File diff suppressed because it is too large
Load diff
333
lib/widgets/slides/previews/checklist_previews.dart
Normal file
333
lib/widgets/slides/previews/checklist_previews.dart
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
class _ChecklistProgress extends StatelessWidget {
|
||||||
|
final List<String> bullets;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _ChecklistProgress({
|
||||||
|
required this.bullets,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final items = bullets
|
||||||
|
.where((bullet) => checklistItemText(bullet).trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final checked = items.where(checklistItemChecked).length;
|
||||||
|
final total = items.length;
|
||||||
|
final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round();
|
||||||
|
final openPercent = total == 0 ? 0 : 100 - checkedPercent;
|
||||||
|
final textColor = _hexColor(profile.textColor);
|
||||||
|
final checkedColor = _hexColor(profile.checklistCheckedColor);
|
||||||
|
final openColor = _hexColor(profile.checklistUncheckedColor);
|
||||||
|
final labelStyle = _applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.0125,
|
||||||
|
height: 1.2,
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final interaction = _ChecklistInteractionScope.maybeOf(context);
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Grow the pie to fill the width it is handed instead of staying at a
|
||||||
|
// fixed, tiny size. Every caller gives this widget a bounded column
|
||||||
|
// width, so the chart now scales with the space that is actually
|
||||||
|
// available next to (or above) the bullets.
|
||||||
|
final maxW = constraints.maxWidth.isFinite
|
||||||
|
? constraints.maxWidth
|
||||||
|
: w * 0.4;
|
||||||
|
// Cap the pie so it stays a balanced companion to the bullet column
|
||||||
|
// rather than dominating it: a smaller chart keeps the visual split
|
||||||
|
// closer to 50/50 and, crucially, never forces the surrounding text to
|
||||||
|
// shrink to fit the chart's height when a slide has many bullets.
|
||||||
|
final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble();
|
||||||
|
final baseRadius = diameter * 0.44;
|
||||||
|
final hoverRadius = diameter * 0.48;
|
||||||
|
final pieTitleStyle = _applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: diameter * 0.085,
|
||||||
|
height: 1.1,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget pie(bool? hovered) => PieChart(
|
||||||
|
key: const ValueKey('checklist-progress-pie'),
|
||||||
|
PieChartData(
|
||||||
|
sectionsSpace: w * 0.002,
|
||||||
|
centerSpaceRadius: 0,
|
||||||
|
startDegreeOffset: -90,
|
||||||
|
sections: [
|
||||||
|
if (checkedPercent > 0)
|
||||||
|
PieChartSectionData(
|
||||||
|
value: checkedPercent.toDouble(),
|
||||||
|
color: checkedColor,
|
||||||
|
radius: hovered == true ? hoverRadius : baseRadius,
|
||||||
|
title: '$checkedPercent%',
|
||||||
|
titleStyle: pieTitleStyle.copyWith(color: Colors.white),
|
||||||
|
),
|
||||||
|
if (openPercent > 0)
|
||||||
|
PieChartSectionData(
|
||||||
|
value: openPercent.toDouble(),
|
||||||
|
color: openColor,
|
||||||
|
radius: hovered == false ? hoverRadius : baseRadius,
|
||||||
|
title: '$openPercent%',
|
||||||
|
titleStyle: pieTitleStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
pieTouchData: PieTouchData(
|
||||||
|
enabled: interaction?.enabled == true,
|
||||||
|
touchCallback: (event, response) {
|
||||||
|
if (interaction?.enabled != true) return;
|
||||||
|
final index = event.isInterestedForInteractions
|
||||||
|
? response?.touchedSection?.touchedSectionIndex
|
||||||
|
: null;
|
||||||
|
if (index == null) {
|
||||||
|
interaction!.hovered.value = null;
|
||||||
|
} else if (checkedPercent == 0) {
|
||||||
|
interaction!.hovered.value = false;
|
||||||
|
} else {
|
||||||
|
interaction!.hovered.value = index == 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
label:
|
||||||
|
'${context.l10n.d('Afgevinkt')} $checkedPercent%, '
|
||||||
|
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: diameter,
|
||||||
|
height: diameter,
|
||||||
|
child: interaction == null
|
||||||
|
? pie(null)
|
||||||
|
: ValueListenableBuilder<bool?>(
|
||||||
|
valueListenable: interaction.hovered,
|
||||||
|
builder: (_, hovered, _) => pie(hovered),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: w * 0.008),
|
||||||
|
MouseRegion(
|
||||||
|
key: const ValueKey('checklist-progress-checked'),
|
||||||
|
onEnter: interaction?.enabled != true
|
||||||
|
? null
|
||||||
|
: (_) => interaction!.hovered.value = true,
|
||||||
|
onExit: interaction?.enabled != true
|
||||||
|
? null
|
||||||
|
: (_) => interaction!.hovered.value = null,
|
||||||
|
child: Text(
|
||||||
|
'${context.l10n.d('Afgevinkt')} $checkedPercent%',
|
||||||
|
style: labelStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MouseRegion(
|
||||||
|
key: const ValueKey('checklist-progress-unchecked'),
|
||||||
|
onEnter: interaction?.enabled != true
|
||||||
|
? null
|
||||||
|
: (_) => interaction!.hovered.value = false,
|
||||||
|
onExit: interaction?.enabled != true
|
||||||
|
? null
|
||||||
|
: (_) => interaction!.hovered.value = null,
|
||||||
|
child: Text(
|
||||||
|
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
|
||||||
|
style: labelStyle.copyWith(
|
||||||
|
color: textColor.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistBulletRow extends StatelessWidget {
|
||||||
|
final List<String> bullets;
|
||||||
|
final int itemIndex;
|
||||||
|
final int column;
|
||||||
|
final ListStyle listStyle;
|
||||||
|
final bool checked;
|
||||||
|
final String text;
|
||||||
|
final int level;
|
||||||
|
final double fontSize;
|
||||||
|
final double bulletSize;
|
||||||
|
final double bulletGap;
|
||||||
|
final double scale;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _ChecklistBulletRow({
|
||||||
|
required this.bullets,
|
||||||
|
required this.itemIndex,
|
||||||
|
required this.column,
|
||||||
|
required this.listStyle,
|
||||||
|
required this.checked,
|
||||||
|
required this.text,
|
||||||
|
required this.level,
|
||||||
|
required this.fontSize,
|
||||||
|
required this.bulletSize,
|
||||||
|
required this.bulletGap,
|
||||||
|
required this.scale,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final interaction = _ChecklistInteractionScope.maybeOf(context);
|
||||||
|
Widget row(bool highlighted) => AnimatedContainer(
|
||||||
|
key: ValueKey('checklist-preview-item-$column-$itemIndex'),
|
||||||
|
duration: const Duration(milliseconds: 140),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: highlighted
|
||||||
|
? _hexColor(profile.accentColor).withValues(alpha: 0.16)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(wScale(5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
key: ValueKey('checklist-preview-toggle-$column-$itemIndex'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap:
|
||||||
|
listStyle == ListStyle.checklist && interaction?.enabled == true
|
||||||
|
? () => interaction!.onToggle?.call(column, itemIndex)
|
||||||
|
: null,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor:
|
||||||
|
listStyle == ListStyle.checklist &&
|
||||||
|
interaction?.enabled == true
|
||||||
|
? SystemMouseCursors.click
|
||||||
|
: MouseCursor.defer,
|
||||||
|
child: Text(
|
||||||
|
'${_listMarker(bullets, itemIndex, listStyle)} ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
color: _hexColor(profile.accentColor),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
text,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
height: _kBulletLineHeight,
|
||||||
|
color: _hexColor(profile.textColor),
|
||||||
|
decoration: checked && profile.checklistStrikeThrough
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
decorationColor: _hexColor(profile.textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final padded = Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: level * bulletSize * 1.05 * scale,
|
||||||
|
top: bulletGap * scale,
|
||||||
|
bottom: bulletGap * scale,
|
||||||
|
),
|
||||||
|
child: interaction == null || listStyle != ListStyle.checklist
|
||||||
|
? row(false)
|
||||||
|
: ValueListenableBuilder<bool?>(
|
||||||
|
valueListenable: interaction.hovered,
|
||||||
|
builder: (_, hovered, _) => row(hovered == checked),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return padded;
|
||||||
|
}
|
||||||
|
|
||||||
|
double wScale(double value) => value * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistInteractionHost extends StatefulWidget {
|
||||||
|
final bool enabled;
|
||||||
|
final void Function(int column, int itemIndex)? onToggle;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _ChecklistInteractionHost({
|
||||||
|
required this.enabled,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ChecklistInteractionHost> createState() =>
|
||||||
|
_ChecklistInteractionHostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> {
|
||||||
|
final ValueNotifier<bool?> hovered = ValueNotifier(null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
hovered.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _ChecklistInteractionScope(
|
||||||
|
enabled: widget.enabled,
|
||||||
|
hovered: hovered,
|
||||||
|
onToggle: widget.onToggle,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistInteractionScope extends InheritedWidget {
|
||||||
|
final bool enabled;
|
||||||
|
final ValueNotifier<bool?> hovered;
|
||||||
|
final void Function(int column, int itemIndex)? onToggle;
|
||||||
|
|
||||||
|
const _ChecklistInteractionScope({
|
||||||
|
required this.enabled,
|
||||||
|
required this.hovered,
|
||||||
|
required this.onToggle,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static _ChecklistInteractionScope? maybeOf(BuildContext context) =>
|
||||||
|
context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_ChecklistInteractionScope oldWidget) =>
|
||||||
|
enabled != oldWidget.enabled || onToggle != oldWidget.onToggle;
|
||||||
|
}
|
||||||
180
lib/widgets/slides/previews/code_preview.dart
Normal file
180
lib/widgets/slides/previews/code_preview.dart
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
/// Een 'broncode-sheet': de code op een donker editor-vlak, met
|
||||||
|
/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar
|
||||||
|
/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de
|
||||||
|
/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen.
|
||||||
|
class _CodePreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _CodePreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Natural (unwrapped) size of [text] in [style]: width is the longest line,
|
||||||
|
/// height the full block. Used to scale code to the available space.
|
||||||
|
static Size _measureMono(String text, TextStyle style) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: TextSpan(text: text.isEmpty ? ' ' : text, style: style),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
return painter.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_ensureHighlightLanguages();
|
||||||
|
final pad = w * 0.05;
|
||||||
|
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||||
|
final code = slide.customMarkdown;
|
||||||
|
final lang = slide.codeLanguage.trim();
|
||||||
|
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
|
||||||
|
|
||||||
|
final codeBg = _hexColor(profile.codeBackgroundColor);
|
||||||
|
final codeFg = _hexColor(profile.codeTextColor);
|
||||||
|
|
||||||
|
// The chosen monospace family, always backed by a generic monospace fallback
|
||||||
|
// so an uninstalled face still renders fixed-width.
|
||||||
|
final fallback = <String>['Menlo', 'Consolas', 'Courier New', 'monospace']
|
||||||
|
..removeWhere((f) => f == profile.codeFontFamily);
|
||||||
|
final baseFont = w * 0.024;
|
||||||
|
final maxFont = w * 0.040; // grow to fill, but never huge
|
||||||
|
TextStyle monoAt(double size) => TextStyle(
|
||||||
|
fontFamily: profile.codeFontFamily,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontSize: size,
|
||||||
|
height: 1.4,
|
||||||
|
color: codeFg,
|
||||||
|
);
|
||||||
|
|
||||||
|
// HighlightView throws on an unknown language, so fall back to plain (but
|
||||||
|
// monospace) text. When syntax highlighting is off we always render plain
|
||||||
|
// text so the whole block is one colour — needed for a CRT-green screen.
|
||||||
|
final useHighlight = known && profile.codeHighlightSyntax;
|
||||||
|
final highlightTheme = {
|
||||||
|
...atomOneDarkTheme,
|
||||||
|
// Keep atom-one-dark's per-token colours but drop its own background so
|
||||||
|
// our themed [codeBg] shows through unchanged.
|
||||||
|
'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith(
|
||||||
|
backgroundColor: codeBg,
|
||||||
|
color: codeFg,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Widget buildCode(TextStyle style) => useHighlight
|
||||||
|
? HighlightView(
|
||||||
|
code,
|
||||||
|
language: lang,
|
||||||
|
theme: highlightTheme,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
textStyle: style,
|
||||||
|
)
|
||||||
|
: Text(code, style: style);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
pad,
|
||||||
|
pad + safe.top,
|
||||||
|
pad,
|
||||||
|
pad + safe.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// The slide title belongs to the slide, not inside the code window,
|
||||||
|
// so it sits above the panel like other slide types.
|
||||||
|
if (slide.title.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: w * 0.025,
|
||||||
|
vertical: w * 0.01,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hexColor(profile.titleBackgroundColor),
|
||||||
|
borderRadius: BorderRadius.circular(w * 0.012),
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: _hexColor(profile.accentColor),
|
||||||
|
width: w * 0.006,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.032,
|
||||||
|
height: 1.1,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _hexColor(profile.titleTextColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: w * 0.018),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: codeBg,
|
||||||
|
borderRadius: BorderRadius.circular(w * 0.012),
|
||||||
|
border: Border.all(color: codeFg.withValues(alpha: 0.22)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.all(w * 0.03),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Size the code to fill the panel: scale up to use spare
|
||||||
|
// space (capped at [maxFont]) and down so long fragments
|
||||||
|
// still fit, rather than leaving a small block in a big box.
|
||||||
|
final measured = useHighlight
|
||||||
|
? code.replaceAll('\t', ' ')
|
||||||
|
: code;
|
||||||
|
final natural = _measureMono(measured, monoAt(baseFont));
|
||||||
|
final availW = math.max(1.0, constraints.maxWidth - 1);
|
||||||
|
final availH = math.max(1.0, constraints.maxHeight - 1);
|
||||||
|
var scale = math.min(
|
||||||
|
availW / natural.width,
|
||||||
|
availH / natural.height,
|
||||||
|
);
|
||||||
|
if (!scale.isFinite || scale <= 0) scale = 1;
|
||||||
|
final size = math.min(baseFont * scale, maxFont);
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: buildCode(monoAt(size)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||||
|
/// colour any common language without throwing.
|
||||||
|
bool _highlightReady = false;
|
||||||
|
|
||||||
|
void _ensureHighlightLanguages() {
|
||||||
|
if (_highlightReady) return;
|
||||||
|
allLanguages.forEach(highlight.registerLanguage);
|
||||||
|
_highlightReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helper ─────────────────────────────────────────────────────────────
|
||||||
601
lib/widgets/slides/previews/media_previews.dart
Normal file
601
lib/widgets/slides/previews/media_previews.dart
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
class _AudioPlayback extends StatefulWidget {
|
||||||
|
final String audioPath;
|
||||||
|
final String? projectPath;
|
||||||
|
final bool autoplay;
|
||||||
|
final double w;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
|
||||||
|
const _AudioPlayback({
|
||||||
|
required this.audioPath,
|
||||||
|
required this.projectPath,
|
||||||
|
required this.autoplay,
|
||||||
|
required this.w,
|
||||||
|
this.onComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AudioPlayback> createState() => _AudioPlaybackState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioPlaybackState extends State<_AudioPlayback> {
|
||||||
|
VideoPlayerController? _controller;
|
||||||
|
bool _completed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_AudioPlayback oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.audioPath != widget.audioPath ||
|
||||||
|
oldWidget.autoplay != widget.autoplay) {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
_controller?.removeListener(_onTick);
|
||||||
|
await _controller?.dispose();
|
||||||
|
_completed = false;
|
||||||
|
final path = _resolvePath(widget.audioPath, widget.projectPath);
|
||||||
|
if (path == null) return;
|
||||||
|
final controller = VideoPlayerController.file(File(path));
|
||||||
|
_controller = controller;
|
||||||
|
try {
|
||||||
|
await controller.initialize();
|
||||||
|
controller.addListener(_onTick);
|
||||||
|
if (widget.autoplay) await controller.play();
|
||||||
|
} catch (e) {
|
||||||
|
logWarning('_AudioPlaybackState._init: audio controller init failed', e);
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detecteer het einde van de audio en meld dat één keer (voor auto-advance).
|
||||||
|
void _onTick() {
|
||||||
|
final c = _controller;
|
||||||
|
if (c == null || !c.value.isInitialized || _completed) return;
|
||||||
|
final pos = c.value.position;
|
||||||
|
final dur = c.value.duration;
|
||||||
|
if (dur > Duration.zero &&
|
||||||
|
pos.inMilliseconds >= dur.inMilliseconds - 200 &&
|
||||||
|
!c.value.isPlaying) {
|
||||||
|
_completed = true;
|
||||||
|
widget.onComplete?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller?.removeListener(_onTick);
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = _controller;
|
||||||
|
return Positioned(
|
||||||
|
right: widget.w * 0.035,
|
||||||
|
bottom: widget.w * 0.035,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Audio',
|
||||||
|
onPressed: controller == null || !controller.value.isInitialized
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
controller.value.isPlaying
|
||||||
|
? controller.pause()
|
||||||
|
: controller.play();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
controller?.value.isPlaying == true
|
||||||
|
? Icons.volume_up
|
||||||
|
: Icons.volume_up_outlined,
|
||||||
|
),
|
||||||
|
iconSize: widget.w * 0.032,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Individual slide-type renderers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TwoImagesPreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String? projectPath;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _TwoImagesPreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
this.projectPath,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5)
|
||||||
|
.clamp(0.1, 0.9);
|
||||||
|
final leftW = w * splitFraction;
|
||||||
|
final rightW = w * (1 - splitFraction);
|
||||||
|
final titleSize = w * 0.032;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Twee afbeeldingen naast elkaar
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: leftW,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_resolvedImage(context, slide.imagePath, projectPath),
|
||||||
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: rightW,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_resolvedImage(context, slide.imagePath2, projectPath),
|
||||||
|
_captionOverlay(context, slide.imageCaption2, w),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Optionele ondertitel
|
||||||
|
if (slide.title.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: w * 0.04,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: w * 0.04,
|
||||||
|
vertical: w * 0.015,
|
||||||
|
),
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: titleSize,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: const Color(0xFF8BB8FF),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImagePreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String? projectPath;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _ImagePreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
this.projectPath,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasTitle = slide.title.isNotEmpty;
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_zoomedImage(
|
||||||
|
context,
|
||||||
|
slide.imagePath,
|
||||||
|
projectPath,
|
||||||
|
slide.imageSize,
|
||||||
|
bgColor: _hexColor(profile.slideBackgroundColor),
|
||||||
|
// When zoomed out, anchor the image to the top so the bottom title
|
||||||
|
// banner sits in the freed-up space instead of over the picture.
|
||||||
|
alignment: hasTitle ? Alignment.topCenter : Alignment.center,
|
||||||
|
),
|
||||||
|
if (slide.title.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: w * 0.06,
|
||||||
|
right: w * 0.06,
|
||||||
|
bottom: w * 0.06,
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: w * 0.04,
|
||||||
|
vertical: w * 0.02,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: w * 0.038,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: const Color(0xFF8BB8FF),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPreview extends StatefulWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String? projectPath;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
final bool autoplay;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
|
||||||
|
const _VideoPreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
this.projectPath,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
this.autoplay = false,
|
||||||
|
this.onComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_VideoPreview> createState() => _VideoPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPreviewState extends State<_VideoPreview> {
|
||||||
|
VideoPlayerController? _controller;
|
||||||
|
String? _path;
|
||||||
|
bool _completed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_VideoPreview oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.slide.videoPath != widget.slide.videoPath ||
|
||||||
|
oldWidget.autoplay != widget.autoplay) {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
_controller?.removeListener(_onTick);
|
||||||
|
await _controller?.dispose();
|
||||||
|
_controller = null;
|
||||||
|
_completed = false;
|
||||||
|
_path = _resolvePath(widget.slide.videoPath, widget.projectPath);
|
||||||
|
if (_path == null) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final controller = VideoPlayerController.file(File(_path!));
|
||||||
|
_controller = controller;
|
||||||
|
try {
|
||||||
|
await controller.initialize();
|
||||||
|
controller.addListener(_onTick);
|
||||||
|
await controller.setLooping(false);
|
||||||
|
if (widget.autoplay) await controller.play();
|
||||||
|
} catch (e) {
|
||||||
|
logWarning('_VideoPreviewState._init: video controller init failed', e);
|
||||||
|
// Keep the placeholder visible when the platform cannot open the file.
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTick() {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null ||
|
||||||
|
!controller.value.isInitialized ||
|
||||||
|
_completed ||
|
||||||
|
!widget.autoplay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final duration = controller.value.duration;
|
||||||
|
final position = controller.value.position;
|
||||||
|
if (duration > Duration.zero &&
|
||||||
|
position.inMilliseconds >= duration.inMilliseconds - 200 &&
|
||||||
|
!controller.value.isPlaying) {
|
||||||
|
_completed = true;
|
||||||
|
widget.onComplete?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller?.removeListener(_onTick);
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = _controller;
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(widget.profile.slideBackgroundColor),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (controller != null && controller.value.isInitialized)
|
||||||
|
Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: controller.value.aspectRatio,
|
||||||
|
child: VideoPlayer(controller),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_mediaPlaceholder(Icons.movie_outlined, 'Video'),
|
||||||
|
if (widget.slide.title.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: widget.w * 0.06,
|
||||||
|
right: widget.w * 0.06,
|
||||||
|
top: widget.w * 0.04,
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
widget.slide.title,
|
||||||
|
_applyFont(
|
||||||
|
widget.font,
|
||||||
|
TextStyle(
|
||||||
|
color: _hexColor(widget.profile.textColor),
|
||||||
|
fontSize: widget.w * 0.038,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(widget.profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: widget.w * 0.04,
|
||||||
|
bottom: widget.w * 0.035,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: controller == null || !controller.value.isInitialized
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
controller.value.isPlaying
|
||||||
|
? controller.pause()
|
||||||
|
: controller.play();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
controller?.value.isPlaying == true
|
||||||
|
? Icons.pause_circle
|
||||||
|
: Icons.play_circle,
|
||||||
|
),
|
||||||
|
iconSize: widget.w * 0.045,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain.
|
||||||
|
/// imageSize = 0 → cover (Marp-standaard, vult frame, snijdt bij)
|
||||||
|
/// imageSize = 100 → volledige afbeelding zichtbaar (contain, evt. randen)
|
||||||
|
/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect
|
||||||
|
/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain
|
||||||
|
Widget _zoomedImage(
|
||||||
|
BuildContext context,
|
||||||
|
String imagePath,
|
||||||
|
String? projectPath,
|
||||||
|
int imageSize, {
|
||||||
|
Color bgColor = Colors.black,
|
||||||
|
Alignment alignment = Alignment.center,
|
||||||
|
}) {
|
||||||
|
if (imageSize == 0) {
|
||||||
|
return _resolvedImage(
|
||||||
|
context,
|
||||||
|
imagePath,
|
||||||
|
projectPath,
|
||||||
|
); // BoxFit.cover standaard
|
||||||
|
}
|
||||||
|
final scale = imageSize / 100.0;
|
||||||
|
// Size the image box to `scale` × the available area and let BoxFit.contain
|
||||||
|
// fit the picture inside it. This produces the same visual result as a
|
||||||
|
// Transform.scale but without a transform layer, which `RepaintBoundary
|
||||||
|
// .toImage` (used for exports) captures far more reliably — a scaled
|
||||||
|
// transform layer would frequently render blank in the exported PNG.
|
||||||
|
return ClipRect(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: bgColor,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final boxW = constraints.maxWidth * scale;
|
||||||
|
final boxH = constraints.maxHeight * scale;
|
||||||
|
return Align(
|
||||||
|
alignment: alignment,
|
||||||
|
child: SizedBox(
|
||||||
|
width: boxW,
|
||||||
|
height: boxH,
|
||||||
|
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
|
||||||
|
child: _resolvedImage(
|
||||||
|
context,
|
||||||
|
imagePath,
|
||||||
|
projectPath,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _resolvedImage(
|
||||||
|
BuildContext context,
|
||||||
|
String imagePath,
|
||||||
|
String? projectPath, {
|
||||||
|
BoxFit fit = BoxFit.cover,
|
||||||
|
}) {
|
||||||
|
if (imagePath.isEmpty) return _imagePlaceholder(context);
|
||||||
|
|
||||||
|
final String resolved;
|
||||||
|
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
|
||||||
|
resolved = imagePath;
|
||||||
|
} else if (projectPath != null) {
|
||||||
|
resolved = '$projectPath/$imagePath';
|
||||||
|
} else {
|
||||||
|
resolved = imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Image.file(
|
||||||
|
File(resolved),
|
||||||
|
fit: fit,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
// Keep showing the previous frame while the next image decodes. Without
|
||||||
|
// this the widget paints nothing for a frame on a source change, which
|
||||||
|
// shows up as a black flash between slides — fatal when recording video.
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _captionOverlay(
|
||||||
|
BuildContext context,
|
||||||
|
String caption,
|
||||||
|
double w, {
|
||||||
|
double? right,
|
||||||
|
double? bottom,
|
||||||
|
}) {
|
||||||
|
final text = caption.trim();
|
||||||
|
if (text.isEmpty) return const SizedBox.shrink();
|
||||||
|
// Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering
|
||||||
|
// staat, schuift het bijschrift erboven zodat het niet wordt overschreven.
|
||||||
|
final lift = _SlideLinkScope.hasBottomTlpOf(context)
|
||||||
|
? _tlpVerticalReserve(w)
|
||||||
|
: 0.0;
|
||||||
|
return Positioned(
|
||||||
|
right: right ?? w * _kTlpEdge,
|
||||||
|
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(maxWidth: w * 0.5),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.58),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: w * 0.011,
|
||||||
|
height: 1.25,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _mediaPlaceholder(IconData icon, String label) {
|
||||||
|
return Container(
|
||||||
|
color: const Color(0xFFE2E8F0),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: const Color(0xFF94A3B8), size: 32),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _imagePlaceholder(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: const Color(0xFFE2E8F0),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final shortestSide = constraints.biggest.shortestSide;
|
||||||
|
if (shortestSide < 48) {
|
||||||
|
return Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.image_outlined,
|
||||||
|
color: const Color(0xFF94A3B8),
|
||||||
|
size: shortestSide * 0.65,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.image_outlined,
|
||||||
|
color: Color(0xFF94A3B8),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.l10n.d('Afbeelding'),
|
||||||
|
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
246
lib/widgets/slides/previews/overlays.dart
Normal file
246
lib/widgets/slides/previews/overlays.dart
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
class _LogoOverlay extends StatelessWidget {
|
||||||
|
final String logoPath;
|
||||||
|
final String? projectPath;
|
||||||
|
final String position;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const _LogoOverlay({
|
||||||
|
required this.logoPath,
|
||||||
|
required this.projectPath,
|
||||||
|
required this.position,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final horizontalInset = size * 0.28;
|
||||||
|
final topInset = size * 0.42;
|
||||||
|
final bottomInset = size * 0.12;
|
||||||
|
return Positioned(
|
||||||
|
top: position.startsWith('top') ? topInset : null,
|
||||||
|
bottom: position.startsWith('bottom') ? bottomInset : null,
|
||||||
|
left: position.endsWith('left') ? horizontalInset : null,
|
||||||
|
right: position.endsWith('right') ? horizontalInset : null,
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: _resolvedImage(
|
||||||
|
context,
|
||||||
|
logoPath,
|
||||||
|
projectPath,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ──────
|
||||||
|
const double _kTlpFont = 0.018; // × slidebreedte
|
||||||
|
|
||||||
|
const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
|
||||||
|
|
||||||
|
const double _kTlpHPad = 0.011;
|
||||||
|
|
||||||
|
const double _kTlpVPad = 0.005;
|
||||||
|
|
||||||
|
double _tlpBottomInset(double w) => w * 0.022;
|
||||||
|
|
||||||
|
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
|
||||||
|
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
||||||
|
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
|
||||||
|
|
||||||
|
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
|
||||||
|
double _tlpVerticalReserve(double w) =>
|
||||||
|
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
|
||||||
|
|
||||||
|
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
||||||
|
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
||||||
|
class _TlpOverlay extends StatelessWidget {
|
||||||
|
final TlpLevel tlp;
|
||||||
|
final double w;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
final bool hasLogo;
|
||||||
|
|
||||||
|
const _TlpOverlay({
|
||||||
|
required this.tlp,
|
||||||
|
required this.w,
|
||||||
|
required this.profile,
|
||||||
|
required this.hasLogo,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
|
||||||
|
return Positioned(
|
||||||
|
bottom: _tlpBottomInset(w),
|
||||||
|
left: toLeft ? w * _kTlpEdge : null,
|
||||||
|
right: toLeft ? null : w * _kTlpEdge,
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: w * _kTlpHPad,
|
||||||
|
vertical: w * _kTlpVPad,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
borderRadius: BorderRadius.circular(w * 0.005),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tlp.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(tlp.foreground),
|
||||||
|
fontSize: w * _kTlpFont,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FooterOverlay extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
final int? slideNumber;
|
||||||
|
final int? slideCount;
|
||||||
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
const _FooterOverlay({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.profile,
|
||||||
|
this.slideNumber,
|
||||||
|
this.slideCount,
|
||||||
|
this.tlp = TlpLevel.none,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _applyTokens(String s) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
final date = '${two(now.day)}-${two(now.month)}-${now.year}';
|
||||||
|
return s
|
||||||
|
.replaceAll('{page}', slideNumber?.toString() ?? '')
|
||||||
|
.replaceAll('{total}', slideCount?.toString() ?? '')
|
||||||
|
.replaceAll('{date}', date)
|
||||||
|
.replaceAll('{title}', slide.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!slide.showFooter) return const SizedBox.shrink();
|
||||||
|
if (slide.type == SlideType.title || slide.type == SlideType.section) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final footerText = _applyTokens(profile.footerText).trim();
|
||||||
|
final showPages = profile.footerShowPageNumbers && slideNumber != null;
|
||||||
|
if (footerText.isEmpty && !showPages) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt.
|
||||||
|
final fontSize = w * 0.0145;
|
||||||
|
final style = TextStyle(
|
||||||
|
color: _hexColor(profile.textColor).withValues(alpha: 0.7),
|
||||||
|
fontSize: fontSize,
|
||||||
|
// Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft.
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
blurRadius: w * 0.003,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en
|
||||||
|
// de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen.
|
||||||
|
double mx(double a, double b) => a > b ? a : b;
|
||||||
|
final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo;
|
||||||
|
final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom');
|
||||||
|
final logoOnLeft = profile.logoPosition.endsWith('left');
|
||||||
|
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
|
||||||
|
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
|
||||||
|
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
|
||||||
|
final tlpSpan = tlp == TlpLevel.none
|
||||||
|
? 0.0
|
||||||
|
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
|
||||||
|
final footerLeftAligned = profile.footerPosition == 'left';
|
||||||
|
|
||||||
|
// Links uitgelijnd begint de footer waar het logo of de bullets beginnen,
|
||||||
|
// voor een consistente linkermarge. Anders de standaardmarge.
|
||||||
|
var left = footerLeftAligned
|
||||||
|
? (logoBottom && logoOnLeft
|
||||||
|
? logoLeftEdge
|
||||||
|
: _contentLeftInset(slide, w))
|
||||||
|
: w * 0.04;
|
||||||
|
var right = w * 0.04;
|
||||||
|
if (logoBottom) {
|
||||||
|
if (logoOnLeft) {
|
||||||
|
// Een links-uitgelijnde footer mag bewust met de logo-linkerkant
|
||||||
|
// uitlijnen; anders schuift 'ie rechts van het logo om overlap te
|
||||||
|
// voorkomen.
|
||||||
|
if (!footerLeftAligned) left = mx(left, logoSpan);
|
||||||
|
} else {
|
||||||
|
right = mx(right, logoSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tlp != TlpLevel.none) {
|
||||||
|
if (tlpOnRight) {
|
||||||
|
right = mx(right, tlpSpan);
|
||||||
|
} else {
|
||||||
|
left = mx(left, tlpSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final alignment = switch (profile.footerPosition) {
|
||||||
|
'left' => Alignment.centerLeft,
|
||||||
|
'center' => Alignment.center,
|
||||||
|
_ => Alignment.centerRight,
|
||||||
|
};
|
||||||
|
final textAlign = switch (profile.footerPosition) {
|
||||||
|
'left' => TextAlign.left,
|
||||||
|
'center' => TextAlign.center,
|
||||||
|
_ => TextAlign.right,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: left,
|
||||||
|
right: right,
|
||||||
|
bottom: w * 0.02,
|
||||||
|
child: Align(
|
||||||
|
alignment: alignment,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: w - left - right),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (footerText.isNotEmpty)
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
footerText,
|
||||||
|
style: style,
|
||||||
|
textAlign: textAlign,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02),
|
||||||
|
if (showPages)
|
||||||
|
Text(
|
||||||
|
'$slideNumber / ${slideCount ?? slideNumber}',
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
lib/widgets/slides/previews/table_preview.dart
Normal file
129
lib/widgets/slides/previews/table_preview.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
class _TablePreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _TablePreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pad = w * 0.06;
|
||||||
|
final safe = slide.showLogo
|
||||||
|
? _splitTextLogoSafeInsets(w, profile)
|
||||||
|
: EdgeInsets.zero;
|
||||||
|
final titleSize = w * 0.038;
|
||||||
|
final rows = slide.tableRows.where((r) => r.isNotEmpty).toList();
|
||||||
|
final colCount = rows.fold<int>(0, (m, r) => r.length > m ? r.length : m);
|
||||||
|
|
||||||
|
// Scale cell text down as the table grows so it keeps fitting nicely.
|
||||||
|
final density = (rows.length + colCount).clamp(2, 24);
|
||||||
|
final cellSize = (w * 0.025 * (10 / (density + 6))).clamp(
|
||||||
|
w * 0.010,
|
||||||
|
w * 0.021,
|
||||||
|
);
|
||||||
|
|
||||||
|
final accent = _hexColor(profile.accentColor);
|
||||||
|
final textColor = _hexColor(profile.tableTextColor);
|
||||||
|
final headerTextColor = _hexColor(profile.tableHeaderTextColor);
|
||||||
|
final borderColor = accent.withValues(alpha: 0.35);
|
||||||
|
|
||||||
|
Widget cell(String value, {required bool header}) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: cellSize * 0.55,
|
||||||
|
vertical: cellSize * 0.36,
|
||||||
|
),
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
value,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: cellSize,
|
||||||
|
color: header ? headerTextColor : textColor,
|
||||||
|
fontWeight: header ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: header ? headerTextColor : accent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TableRow buildRow(List<String> row, {required bool header}) {
|
||||||
|
return TableRow(
|
||||||
|
decoration: BoxDecoration(color: header ? accent : null),
|
||||||
|
children: List.generate(colCount, (c) {
|
||||||
|
final value = c < row.length ? row[c] : '';
|
||||||
|
return TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: cell(value, header: header),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
pad,
|
||||||
|
pad + safe.top,
|
||||||
|
pad,
|
||||||
|
pad + safe.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (slide.title.isNotEmpty) ...[
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: titleSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _hexColor(profile.textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
SizedBox(height: pad * 0.35),
|
||||||
|
],
|
||||||
|
if (rows.isNotEmpty && colCount > 0)
|
||||||
|
Table(
|
||||||
|
border: TableBorder.all(
|
||||||
|
color: borderColor,
|
||||||
|
width: w * 0.0012,
|
||||||
|
),
|
||||||
|
defaultColumnWidth: const FlexColumnWidth(),
|
||||||
|
children: [
|
||||||
|
buildRow(rows.first, header: true),
|
||||||
|
for (var i = 1; i < rows.length; i++)
|
||||||
|
buildRow(rows[i], header: false),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
478
lib/widgets/slides/previews/text_previews.dart
Normal file
478
lib/widgets/slides/previews/text_previews.dart
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
// Part of the slide_preview library — see ../slide_preview.dart.
|
||||||
|
// Split out for navigability; all imports live in the main library file.
|
||||||
|
part of '../slide_preview.dart';
|
||||||
|
|
||||||
|
class _TitlePreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String? projectPath;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _TitlePreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
this.projectPath,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _content(BuildContext context) {
|
||||||
|
final pad = w * 0.08;
|
||||||
|
final link = _hexColor(profile.accentColor);
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(pad),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (slide.title.isNotEmpty)
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: _hexColor(profile.titleTextColor),
|
||||||
|
fontSize: w * 0.055,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: link,
|
||||||
|
),
|
||||||
|
if (slide.subtitle.isNotEmpty) ...[
|
||||||
|
SizedBox(height: w * 0.02),
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.subtitle,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: _hexColor(
|
||||||
|
profile.titleTextColor,
|
||||||
|
).withValues(alpha: 0.72),
|
||||||
|
fontSize: w * 0.03,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: link,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasBg = slide.imagePath.isNotEmpty;
|
||||||
|
|
||||||
|
if (!hasBg) {
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.titleBackgroundColor),
|
||||||
|
child: SizedBox.expand(child: _content(context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_zoomedImage(
|
||||||
|
context,
|
||||||
|
slide.imagePath,
|
||||||
|
projectPath,
|
||||||
|
slide.imageSize,
|
||||||
|
bgColor: _hexColor(profile.titleBackgroundColor),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
color: _hexColor(
|
||||||
|
profile.titleBackgroundColor,
|
||||||
|
).withValues(alpha: 0.62),
|
||||||
|
),
|
||||||
|
_content(context),
|
||||||
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionPreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _SectionPreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pad = w * 0.08;
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.sectionBackgroundColor),
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(pad),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (slide.title.isNotEmpty)
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.title,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: _hexColor(profile.titleTextColor),
|
||||||
|
fontSize: w * 0.05,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
if (slide.subtitle.isNotEmpty) ...[
|
||||||
|
SizedBox(height: w * 0.015),
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
slide.subtitle,
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: _hexColor(
|
||||||
|
profile.titleTextColor,
|
||||||
|
).withValues(alpha: 0.72),
|
||||||
|
fontSize: w * 0.025,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuotePreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final String? projectPath;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _QuotePreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
this.projectPath,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pad = w * 0.08;
|
||||||
|
final hasBg = slide.imagePath.isNotEmpty;
|
||||||
|
final textColor = hasBg ? Colors.white : _hexColor(profile.textColor);
|
||||||
|
final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!;
|
||||||
|
final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor);
|
||||||
|
|
||||||
|
final content = FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(pad),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: w * 0.008,
|
||||||
|
height: w * 0.12,
|
||||||
|
color: accentColor,
|
||||||
|
margin: EdgeInsets.only(right: pad * 0.4),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _md(
|
||||||
|
context,
|
||||||
|
slide.quote.isEmpty ? '' : '"${slide.quote}"',
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.033,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: textColor,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (slide.quoteAuthor.isNotEmpty) ...[
|
||||||
|
SizedBox(height: pad * 0.6),
|
||||||
|
_md(
|
||||||
|
context,
|
||||||
|
'— ${slide.quoteAuthor}',
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.026,
|
||||||
|
color: authorColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: accentColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasBg) {
|
||||||
|
return Container(
|
||||||
|
color: _hexColor(profile.slideBackgroundColor),
|
||||||
|
child: SizedBox.expand(child: content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_zoomedImage(
|
||||||
|
context,
|
||||||
|
slide.imagePath,
|
||||||
|
projectPath,
|
||||||
|
slide.imageSize,
|
||||||
|
bgColor: _hexColor(profile.slideBackgroundColor),
|
||||||
|
),
|
||||||
|
Container(color: Colors.black.withValues(alpha: 0.52)),
|
||||||
|
content,
|
||||||
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarkdownPreview extends StatelessWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final double w;
|
||||||
|
final String font;
|
||||||
|
final ThemeProfile profile;
|
||||||
|
|
||||||
|
const _MarkdownPreview({
|
||||||
|
required this.slide,
|
||||||
|
required this.w,
|
||||||
|
required this.font,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pad = w * 0.07;
|
||||||
|
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
pad,
|
||||||
|
pad + safe.top,
|
||||||
|
pad,
|
||||||
|
pad + safe.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: _buildBlocks(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the free Markdown into block widgets: fenced ```code``` (syntax
|
||||||
|
/// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines.
|
||||||
|
List<Widget> _buildBlocks(BuildContext context) {
|
||||||
|
final link = _hexColor(profile.accentColor);
|
||||||
|
final lines = slide.customMarkdown.split('\n');
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
var i = 0;
|
||||||
|
// Cap rendered blocks so a huge slide can't blow up layout (the preview is a
|
||||||
|
// thumbnail; FittedBox scales the rest down).
|
||||||
|
while (i < lines.length && widgets.length < 24) {
|
||||||
|
final line = lines[i];
|
||||||
|
|
||||||
|
// Fenced code block: ``` or ```language … ```
|
||||||
|
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
|
||||||
|
if (fence != null) {
|
||||||
|
final language = fence.group(1)!.trim();
|
||||||
|
final code = <String>[];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) {
|
||||||
|
code.add(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < lines.length) i++; // consume the closing fence
|
||||||
|
widgets.add(_codeBlock(code.join('\n'), language));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display math fenced by lines containing only `$$`.
|
||||||
|
if (line.trim() == r'$$') {
|
||||||
|
final tex = <String>[];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && lines[i].trim() != r'$$') {
|
||||||
|
tex.add(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < lines.length) i++; // consume the closing $$
|
||||||
|
widgets.add(_mathBlock(tex.join('\n')));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Single-line display math: $$ … $$
|
||||||
|
final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line);
|
||||||
|
if (oneLine != null) {
|
||||||
|
widgets.add(_mathBlock(oneLine.group(1)!.trim()));
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.add(_textLine(context, line, link));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _textLine(BuildContext context, String line, Color link) {
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
return _md(
|
||||||
|
context,
|
||||||
|
line.substring(2),
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.04,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
linkColor: link,
|
||||||
|
);
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
return _md(
|
||||||
|
context,
|
||||||
|
line.substring(3),
|
||||||
|
_applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
linkColor: link,
|
||||||
|
);
|
||||||
|
} else if (line.startsWith('- ')) {
|
||||||
|
return _md(
|
||||||
|
context,
|
||||||
|
'• ${line.substring(2)}',
|
||||||
|
_applyFont(font, TextStyle(fontSize: w * 0.024)),
|
||||||
|
linkColor: link,
|
||||||
|
);
|
||||||
|
} else if (line.isEmpty) {
|
||||||
|
return SizedBox(height: w * 0.01);
|
||||||
|
}
|
||||||
|
return _md(
|
||||||
|
context,
|
||||||
|
line,
|
||||||
|
_applyFont(font, TextStyle(fontSize: w * 0.024)),
|
||||||
|
linkColor: link,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _codeBlock(String code, String language) {
|
||||||
|
_ensureHighlightLanguages();
|
||||||
|
final mono = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: w * 0.02,
|
||||||
|
height: 1.3,
|
||||||
|
color: const Color(0xFF24292E),
|
||||||
|
);
|
||||||
|
// HighlightView throws on an unregistered language, so only use it for ones
|
||||||
|
// we actually know; otherwise fall back to plain monospace.
|
||||||
|
final known = language.isNotEmpty && allLanguages.containsKey(language);
|
||||||
|
final Widget content = known
|
||||||
|
? HighlightView(
|
||||||
|
code,
|
||||||
|
language: language,
|
||||||
|
theme: githubTheme,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
textStyle: mono,
|
||||||
|
)
|
||||||
|
: Text(code, style: mono);
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: w * 0.008),
|
||||||
|
padding: EdgeInsets.all(w * 0.018),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF6F8FA),
|
||||||
|
borderRadius: BorderRadius.circular(w * 0.008),
|
||||||
|
border: Border.all(color: const Color(0xFFE1E4E8)),
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _mathBlock(String tex) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: w * 0.012),
|
||||||
|
child: Math.tex(
|
||||||
|
tex,
|
||||||
|
textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)),
|
||||||
|
onErrorFallback: (err) => Text(
|
||||||
|
'\$\$$tex\$\$',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: w * 0.022,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue