- The slide title now renders above the code panel (styled like other slide types) instead of inside the dark code window — it is the slide's title. - Code is sized to fill the panel: scaled up to use spare space (capped) and down so long fragments still fit, instead of a small block in a big box. - Add a per-profile monospace font for code slides (e.g. Courier), applied in the preview and the HTML export. - Settings: a banner on the Colours and Logo tabs makes clear they edit the loaded style profile, and colour pickers now accept a custom hex value. - Update docs and translations for the new strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3850 lines
123 KiB
Dart
3850 lines
123 KiB
Dart
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||
import 'package:flutter_highlight/themes/github.dart';
|
||
import 'package:flutter_highlight/themes/atom-one-dark.dart';
|
||
import 'package:flutter_math_fork/flutter_math.dart';
|
||
import 'package:highlight/highlight.dart' show highlight;
|
||
import 'package:highlight/languages/all.dart' show allLanguages;
|
||
import 'package:video_player/video_player.dart';
|
||
import '../../l10n/app_localizations.dart';
|
||
import '../../models/chart.dart';
|
||
import '../../models/deck.dart';
|
||
import '../../models/settings.dart';
|
||
import '../../models/slide.dart';
|
||
import '../../theme/app_theme.dart';
|
||
import 'inline_markdown.dart';
|
||
|
||
/// Returns a TextStyle with the correct font. 'EB Garamond' is bundled with the
|
||
/// app (see pubspec.yaml); all other fonts resolve to system families.
|
||
TextStyle _applyFont(String font, TextStyle base) {
|
||
return base.copyWith(fontFamily: font);
|
||
}
|
||
|
||
/// Geeft de link-tap-handler door aan alle tekst in een slide, zonder die door
|
||
/// elke sub-widget heen te hoeven sleuren. Draagt ook of er een TLP-markering
|
||
/// rechtsonder staat, zodat bijschriften daarboven uitwijken.
|
||
class _SlideLinkScope extends InheritedWidget {
|
||
final void Function(String url)? onTapLink;
|
||
final bool hasBottomTlp;
|
||
const _SlideLinkScope({
|
||
required this.onTapLink,
|
||
this.hasBottomTlp = false,
|
||
required super.child,
|
||
});
|
||
|
||
static void Function(String url)? of(BuildContext context) {
|
||
return context
|
||
.dependOnInheritedWidgetOfExactType<_SlideLinkScope>()
|
||
?.onTapLink;
|
||
}
|
||
|
||
static bool hasBottomTlpOf(BuildContext context) {
|
||
return context
|
||
.dependOnInheritedWidgetOfExactType<_SlideLinkScope>()
|
||
?.hasBottomTlp ??
|
||
false;
|
||
}
|
||
|
||
@override
|
||
bool updateShouldNotify(_SlideLinkScope oldWidget) =>
|
||
oldWidget.onTapLink != onTapLink ||
|
||
oldWidget.hasBottomTlp != hasBottomTlp;
|
||
}
|
||
|
||
/// Tekst met inline-markdown (**vet**, *cursief*, `code`, ~~door~~, [link](url)).
|
||
/// Vervangt platte [Text] op alle inhoudsplekken van een slide.
|
||
Widget _md(
|
||
BuildContext context,
|
||
String text,
|
||
TextStyle style, {
|
||
required Color linkColor,
|
||
int? maxLines,
|
||
TextAlign textAlign = TextAlign.start,
|
||
TextOverflow overflow = TextOverflow.clip,
|
||
bool softWrap = true,
|
||
}) {
|
||
return InlineMarkdownText(
|
||
text,
|
||
style: style,
|
||
linkColor: linkColor,
|
||
onTapLink: _SlideLinkScope.of(context),
|
||
maxLines: maxLines,
|
||
textAlign: textAlign,
|
||
overflow: overflow,
|
||
softWrap: softWrap,
|
||
);
|
||
}
|
||
|
||
Color _hexColor(String hex) {
|
||
final cleaned = hex.replaceFirst('#', '');
|
||
final value = int.tryParse(
|
||
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
|
||
radix: 16,
|
||
);
|
||
return Color(value ?? 0xFFFFFFFF);
|
||
}
|
||
|
||
EdgeInsets _logoSafeInsets(double w, ThemeProfile profile) {
|
||
if (profile.logoPath?.isEmpty ?? true) return EdgeInsets.zero;
|
||
final reserved = w * ((profile.logoSize + 64) / 1280);
|
||
if (profile.logoPosition.startsWith('top')) {
|
||
return EdgeInsets.only(top: reserved);
|
||
}
|
||
return EdgeInsets.only(bottom: reserved);
|
||
}
|
||
|
||
EdgeInsets _splitTextLogoSafeInsets(double w, ThemeProfile profile) {
|
||
if (profile.logoPath?.isEmpty ?? true) return EdgeInsets.zero;
|
||
if (profile.logoPosition.endsWith('right')) return EdgeInsets.zero;
|
||
final reserved = w * ((profile.logoSize + 24) / 1280);
|
||
if (profile.logoPosition.startsWith('top')) {
|
||
return EdgeInsets.only(top: reserved);
|
||
}
|
||
return EdgeInsets.only(bottom: reserved);
|
||
}
|
||
|
||
/// Renders a visual approximation of a Marp slide inside a 16:9 container.
|
||
/// All font sizes and paddings are proportional to the widget width so the
|
||
/// same widget works both as the full preview pane and as a tiny thumbnail.
|
||
/// Content that exceeds the slide height is scaled down proportionally via
|
||
/// FittedBox rather than clipped.
|
||
class SlidePreviewWidget extends StatelessWidget {
|
||
final Slide slide;
|
||
final String? projectPath;
|
||
final ThemeProfile themeProfile;
|
||
|
||
/// Het lettertype hoort bij de stijl (themeProfile), niet bij de app.
|
||
String get fontFamily => themeProfile.fontFamily;
|
||
|
||
/// Optioneel: maakt links in de tekst klikbaar (preview/presenter). In
|
||
/// thumbnails en bij export blijft dit null → links zijn alleen gestyled.
|
||
final void Function(String url)? onLinkTap;
|
||
|
||
/// 1-gebaseerd slidenummer en totaal, voor footer-paginanummers en de
|
||
/// {page}/{total}-tokens. Null → geen paginanummers.
|
||
final int? slideNumber;
|
||
final int? slideCount;
|
||
|
||
/// TLP-classificatie van de presentatie; getoond als markering op de slide.
|
||
final TlpLevel tlp;
|
||
|
||
/// Of audio/video op deze slide afgespeeld kan worden (de audioknop verschijnt
|
||
/// en video kan starten). Standaard uit — thumbnails en export spelen niets.
|
||
final bool enableMedia;
|
||
|
||
/// Of media automatisch start (audio-/video-autoplay). In de editor-preview
|
||
/// staat dit uit (handmatig starten); in de presenter aan.
|
||
final bool autoplayMedia;
|
||
|
||
/// Vergroot grafieklabels voor weergave op afstand in presentatiemodus.
|
||
final bool presentationMode;
|
||
|
||
/// Wordt aangeroepen wanneer de audio van deze slide klaar is (voor de
|
||
/// automatische modus van de presenter).
|
||
final VoidCallback? onAudioComplete;
|
||
|
||
const SlidePreviewWidget({
|
||
super.key,
|
||
required this.slide,
|
||
this.projectPath,
|
||
this.themeProfile = const ThemeProfile(),
|
||
this.onLinkTap,
|
||
this.slideNumber,
|
||
this.slideCount,
|
||
this.tlp = TlpLevel.none,
|
||
this.enableMedia = false,
|
||
this.autoplayMedia = false,
|
||
this.presentationMode = false,
|
||
this.onAudioComplete,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final hasBottomRightTlp =
|
||
tlp != TlpLevel.none &&
|
||
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
|
||
themeProfile.logoPosition == 'bottom-right');
|
||
// Make the widget self-sufficient for text rendering. On screen it sits
|
||
// inside a Material (which supplies a clean DefaultTextStyle), but the
|
||
// export rasterizer mounts it in a bare Overlay subtree. Without an
|
||
// explicit DefaultTextStyle there, any Text that doesn't set its own color
|
||
// falls back to Flutter's broken default — red letters with a yellow
|
||
// underline — which is exactly what showed up in exports. Wrapping here
|
||
// guarantees identical results in the preview and the export.
|
||
return Directionality(
|
||
textDirection: TextDirection.ltr,
|
||
child: DefaultTextStyle(
|
||
style: TextStyle(
|
||
color: _hexColor(themeProfile.textColor),
|
||
decoration: TextDecoration.none,
|
||
fontWeight: FontWeight.normal,
|
||
fontStyle: FontStyle.normal,
|
||
),
|
||
child: _SlideLinkScope(
|
||
onTapLink: onLinkTap,
|
||
hasBottomTlp: hasBottomRightTlp,
|
||
child: _buildSlide(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSlide() {
|
||
return LayoutBuilder(
|
||
builder: (_, constraints) {
|
||
final w = constraints.maxWidth;
|
||
return AspectRatio(
|
||
aspectRatio: 16 / 9,
|
||
child: ClipRect(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
_buildContent(w),
|
||
_FooterOverlay(
|
||
slide: slide,
|
||
w: w,
|
||
profile: themeProfile,
|
||
slideNumber: slideNumber,
|
||
slideCount: slideCount,
|
||
tlp: tlp,
|
||
),
|
||
if (tlp != TlpLevel.none)
|
||
_TlpOverlay(
|
||
tlp: tlp,
|
||
w: w,
|
||
profile: themeProfile,
|
||
hasLogo:
|
||
themeProfile.logoPath?.isNotEmpty == true &&
|
||
slide.showLogo,
|
||
),
|
||
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
|
||
_LogoOverlay(
|
||
logoPath: themeProfile.logoPath!,
|
||
projectPath: projectPath,
|
||
position: themeProfile.logoPosition,
|
||
size: w * (themeProfile.logoSize / 1280),
|
||
),
|
||
if (enableMedia && slide.audioPath.isNotEmpty)
|
||
_AudioPlayback(
|
||
audioPath: slide.audioPath,
|
||
projectPath: projectPath,
|
||
autoplay: autoplayMedia && slide.audioAutoplay,
|
||
onComplete: onAudioComplete,
|
||
w: w,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(double w) {
|
||
switch (slide.type) {
|
||
case SlideType.title:
|
||
return _TitlePreview(
|
||
slide: slide,
|
||
w: w,
|
||
projectPath: projectPath,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.section:
|
||
return _SectionPreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.bullets:
|
||
return _BulletsPreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.twoBullets:
|
||
return _TwoBulletsPreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.bulletsImage:
|
||
return _BulletsImagePreview(
|
||
slide: slide,
|
||
w: w,
|
||
projectPath: projectPath,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.twoImages:
|
||
return _TwoImagesPreview(
|
||
slide: slide,
|
||
w: w,
|
||
projectPath: projectPath,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.image:
|
||
return _ImagePreview(
|
||
slide: slide,
|
||
w: w,
|
||
projectPath: projectPath,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.video:
|
||
return _VideoPreview(
|
||
slide: slide,
|
||
w: w,
|
||
projectPath: projectPath,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
autoplay: autoplayMedia && slide.videoAutoplay,
|
||
);
|
||
case SlideType.quote:
|
||
return _QuotePreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
projectPath: projectPath,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.table:
|
||
return _TablePreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.freeMarkdown:
|
||
return _MarkdownPreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.code:
|
||
return _CodePreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
);
|
||
case SlideType.chart:
|
||
return _ChartPreview(
|
||
slide: slide,
|
||
w: w,
|
||
font: fontFamily,
|
||
profile: themeProfile,
|
||
presentationMode: presentationMode,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (_) {}
|
||
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 _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 _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;
|
||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||
final titleSize = w * 0.042;
|
||
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 slideHeight = w * 9 / 16;
|
||
final availW = (w - pad * 2).clamp(w * 0.12, w);
|
||
final availH = slideHeight - (pad + safe.top) - (pad + 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: availW,
|
||
availH: availH,
|
||
hasTitle: hasTitle,
|
||
title: slide.title,
|
||
bullets: bullets,
|
||
titleSize: titleSize,
|
||
bulletSize: bulletSize,
|
||
spacing: spacing,
|
||
bulletGap: bulletGap,
|
||
maxScale: _kSplitBulletsMaxScale,
|
||
);
|
||
|
||
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,
|
||
pad + safe.top,
|
||
pad,
|
||
pad + 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 (hasTitle && bullets.isNotEmpty)
|
||
SizedBox(height: spacing * scale),
|
||
...bullets.map((b) {
|
||
int level = 0;
|
||
while (level < b.length && b[level] == '\t') {
|
||
level++;
|
||
}
|
||
final text = b.substring(level);
|
||
final fontSize =
|
||
bulletSize * _bulletLevelScale(level) * scale;
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: level * bulletSize * 1.05 * scale,
|
||
top: bulletGap * scale,
|
||
bottom: bulletGap * scale,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${_bulletMarkerForLevel(level)} ',
|
||
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),
|
||
),
|
||
),
|
||
linkColor: _hexColor(profile.accentColor),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pad = w * 0.065;
|
||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||
final titleSize = w * 0.04;
|
||
final bulletSize = w * 0.024;
|
||
final spacing = pad * 0.38;
|
||
final bulletGap = w * 0.0055;
|
||
final columnGap = w * 0.055;
|
||
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;
|
||
|
||
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 - (pad + safe.top) - (pad + safe.bottom);
|
||
if (hasTitle) {
|
||
availH -= _measureTextHeight(
|
||
slide.title,
|
||
titleSize,
|
||
contentW,
|
||
bold: true,
|
||
);
|
||
availH -= spacing;
|
||
}
|
||
final leftScale = _bulletsFitScale(
|
||
availW: columnW,
|
||
availH: availH,
|
||
hasTitle: false,
|
||
title: '',
|
||
bullets: leftBullets,
|
||
titleSize: titleSize,
|
||
bulletSize: bulletSize,
|
||
spacing: spacing,
|
||
bulletGap: bulletGap,
|
||
maxScale: _kBulletsMaxScale,
|
||
);
|
||
final rightScale = _bulletsFitScale(
|
||
availW: columnW,
|
||
availH: availH,
|
||
hasTitle: false,
|
||
title: '',
|
||
bullets: rightBullets,
|
||
titleSize: titleSize,
|
||
bulletSize: bulletSize,
|
||
spacing: spacing,
|
||
bulletGap: bulletGap,
|
||
maxScale: _kBulletsMaxScale,
|
||
);
|
||
final scale = leftScale < rightScale ? leftScale : rightScale;
|
||
|
||
return Container(
|
||
color: _hexColor(profile.slideBackgroundColor),
|
||
child: SizedBox.expand(
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
pad,
|
||
pad + safe.top,
|
||
pad,
|
||
pad + 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),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: columnW,
|
||
child: _BulletListColumn(
|
||
bullets: leftBullets,
|
||
font: font,
|
||
profile: profile,
|
||
bulletSize: bulletSize,
|
||
bulletGap: bulletGap,
|
||
scale: scale,
|
||
),
|
||
),
|
||
SizedBox(width: columnGap),
|
||
SizedBox(
|
||
width: columnW,
|
||
child: _BulletListColumn(
|
||
bullets: rightBullets,
|
||
font: font,
|
||
profile: profile,
|
||
bulletSize: bulletSize,
|
||
bulletGap: bulletGap,
|
||
scale: scale,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
maxScale: _kBulletsMaxScale,
|
||
);
|
||
|
||
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),
|
||
...bullets.map((b) {
|
||
int level = 0;
|
||
while (level < b.length && b[level] == '\t') {
|
||
level++;
|
||
}
|
||
final text = b.substring(level);
|
||
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: level * bulletSize * 1.05 * scale,
|
||
top: bulletGap * scale,
|
||
bottom: bulletGap * scale,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${_bulletMarkerForLevel(level)} ',
|
||
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),
|
||
),
|
||
),
|
||
linkColor: _hexColor(profile.accentColor),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BulletListColumn extends StatelessWidget {
|
||
final List<String> bullets;
|
||
final String font;
|
||
final ThemeProfile profile;
|
||
final double bulletSize;
|
||
final double bulletGap;
|
||
final double scale;
|
||
|
||
const _BulletListColumn({
|
||
required this.bullets,
|
||
required this.font,
|
||
required this.profile,
|
||
required this.bulletSize,
|
||
required this.bulletGap,
|
||
required this.scale,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
...bullets.map((b) {
|
||
int level = 0;
|
||
while (level < b.length && b[level] == '\t') {
|
||
level++;
|
||
}
|
||
final text = b.substring(level);
|
||
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: level * bulletSize * 1.05 * scale,
|
||
top: bulletGap * scale,
|
||
bottom: bulletGap * scale,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${_bulletMarkerForLevel(level)} ',
|
||
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),
|
||
),
|
||
),
|
||
linkColor: _hexColor(profile.accentColor),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// 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)];
|
||
}
|
||
|
||
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,
|
||
double minScale = 0.2,
|
||
double maxScale = 1.0,
|
||
}) {
|
||
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,
|
||
hasTitle: hasTitle,
|
||
title: title,
|
||
bullets: bullets,
|
||
titleSize: titleSize,
|
||
bulletSize: bulletSize,
|
||
spacing: spacing,
|
||
bulletGap: bulletGap,
|
||
);
|
||
|
||
// 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,
|
||
}) {
|
||
var height = 0.0;
|
||
if (hasTitle) {
|
||
height += _measureTextHeight(title, titleSize * scale, availW, bold: true);
|
||
}
|
||
if (hasTitle && bullets.isNotEmpty) height += spacing * scale;
|
||
for (final b in bullets) {
|
||
int level = 0;
|
||
while (level < b.length && b[level] == '\t') {
|
||
level++;
|
||
}
|
||
final text = b.substring(level);
|
||
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||
final indent = level * bulletSize * 1.05 * scale;
|
||
final marker = '${_bulletMarkerForLevel(level)} ';
|
||
final markerW = _measureTextWidth(marker, fontSize, bold: true);
|
||
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
|
||
final textH = _measureTextHeight(
|
||
text,
|
||
fontSize,
|
||
wrapW,
|
||
lineHeight: _kBulletLineHeight,
|
||
);
|
||
final markerH = _measureTextHeight(marker, fontSize, double.infinity);
|
||
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
|
||
}
|
||
return height;
|
||
}
|
||
|
||
double _measureTextHeight(
|
||
String text,
|
||
double fontSize,
|
||
double maxWidth, {
|
||
double? lineHeight,
|
||
bool bold = false,
|
||
}) {
|
||
final painter = TextPainter(
|
||
text: TextSpan(
|
||
text: stripInlineMarkdown(text),
|
||
style: TextStyle(
|
||
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}) {
|
||
final painter = TextPainter(
|
||
text: TextSpan(
|
||
text: stripInlineMarkdown(text),
|
||
style: TextStyle(
|
||
fontSize: fontSize,
|
||
fontWeight: bold ? FontWeight.bold : null,
|
||
),
|
||
),
|
||
textDirection: TextDirection.ltr,
|
||
)..layout();
|
||
return painter.width;
|
||
}
|
||
|
||
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;
|
||
|
||
const _VideoPreview({
|
||
required this.slide,
|
||
required this.w,
|
||
this.projectPath,
|
||
required this.font,
|
||
required this.profile,
|
||
this.autoplay = false,
|
||
});
|
||
|
||
@override
|
||
State<_VideoPreview> createState() => _VideoPreviewState();
|
||
}
|
||
|
||
class _VideoPreviewState extends State<_VideoPreview> {
|
||
VideoPlayerController? _controller;
|
||
String? _path;
|
||
|
||
@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 {
|
||
await _controller?.dispose();
|
||
_controller = null;
|
||
_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();
|
||
await controller.setLooping(widget.autoplay);
|
||
if (widget.autoplay) await controller.play();
|
||
} catch (_) {
|
||
// Keep the placeholder visible when the platform cannot open the file.
|
||
}
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 _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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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)),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
|
||
class _ChartPreview extends StatefulWidget {
|
||
final Slide slide;
|
||
final double w;
|
||
final String font;
|
||
final ThemeProfile profile;
|
||
final bool presentationMode;
|
||
|
||
const _ChartPreview({
|
||
required this.slide,
|
||
required this.w,
|
||
required this.font,
|
||
required this.profile,
|
||
required this.presentationMode,
|
||
});
|
||
|
||
@override
|
||
State<_ChartPreview> createState() => _ChartPreviewState();
|
||
}
|
||
|
||
class _ChartPreviewState extends State<_ChartPreview> {
|
||
Slide get slide => widget.slide;
|
||
double get w => widget.w;
|
||
String get font => widget.font;
|
||
ThemeProfile get profile => widget.profile;
|
||
bool get presentationMode => widget.presentationMode;
|
||
|
||
/// Legend entry the pointer is over: a series index for bar/line charts, or a
|
||
/// slice (category) index for pie charts. Null when nothing is hovered.
|
||
int? _hovered;
|
||
|
||
/// The radar vertex under the pointer, used to draw its tooltip. Null when not
|
||
/// hovering a point.
|
||
({int series, int entry, double value, Offset offset})? _radarTouch;
|
||
|
||
void _setHover(int? index) {
|
||
if (_hovered != index) setState(() => _hovered = index);
|
||
}
|
||
|
||
/// True when another legend entry is hovered, so [index] should fade back.
|
||
bool _dimmed(int index) => _hovered != null && _hovered != index;
|
||
|
||
/// Series colour with legend-hover feedback: non-hovered series fade out so
|
||
/// the hovered one stands out in the plot.
|
||
Color _seriesDisplayColor(ChartSeries series, int i) {
|
||
final base = _seriesColor(series, i);
|
||
return _dimmed(i) ? base.withValues(alpha: 0.2) : base;
|
||
}
|
||
|
||
double get _labelScale => presentationMode ? 1.12 : 1;
|
||
|
||
Color _seriesColor(ChartSeries series, int i) {
|
||
if (series.color == null && i == 0) {
|
||
return _hexColor(profile.accentColor);
|
||
}
|
||
return _hexColor(chartSeriesColor(series, i));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||
final horizontalPad = w * 0.05;
|
||
final verticalPad = w * 0.018;
|
||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||
final textColor = _hexColor(profile.textColor);
|
||
|
||
return Container(
|
||
color: _hexColor(profile.slideBackgroundColor),
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
horizontalPad,
|
||
verticalPad + safe.top,
|
||
horizontalPad,
|
||
verticalPad + safe.bottom,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (spec.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,
|
||
spec.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.012),
|
||
],
|
||
Expanded(
|
||
child: Container(
|
||
key: const ValueKey('chart-surface'),
|
||
padding: EdgeInsets.fromLTRB(
|
||
w * 0.02,
|
||
w * 0.01,
|
||
w * 0.025,
|
||
w * 0.01,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: textColor.withValues(alpha: 0.035),
|
||
borderRadius: BorderRadius.circular(w * 0.014),
|
||
border: Border.all(color: textColor.withValues(alpha: 0.09)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
child: spec.hasInlineData
|
||
? _chart(spec, textColor)
|
||
: _placeholder(context),
|
||
),
|
||
if (spec.hasInlineData && spec.series.isNotEmpty) ...[
|
||
SizedBox(height: w * 0.006),
|
||
spec.type == ChartType.pie
|
||
? _pieLegend(spec, textColor)
|
||
: _legend(spec, textColor),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _legend(ChartSpec spec, Color textColor) {
|
||
return SizedBox(
|
||
height: w * 0.03,
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
for (var i = 0; i < spec.series.length; i++) ...[
|
||
if (i > 0) SizedBox(width: w * 0.01),
|
||
MouseRegion(
|
||
onEnter: (_) => _setHover(i),
|
||
onExit: (_) => _setHover(null),
|
||
child: AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 120),
|
||
opacity: _dimmed(i) ? 0.4 : 1,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: w * 0.01,
|
||
vertical: w * 0.004,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: _hovered == i
|
||
? _seriesColor(spec.series[i], i).withValues(alpha: 0.18)
|
||
: textColor.withValues(alpha: 0.045),
|
||
borderRadius: BorderRadius.circular(w),
|
||
border: Border.all(
|
||
color: _hovered == i
|
||
? _seriesColor(spec.series[i], i)
|
||
: Colors.transparent,
|
||
width: w * 0.0015,
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: w * 0.012,
|
||
height: w * 0.012,
|
||
decoration: BoxDecoration(
|
||
color: _seriesColor(spec.series[i], i),
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
SizedBox(width: w * 0.006),
|
||
ConstrainedBox(
|
||
constraints: BoxConstraints(maxWidth: w * 0.16),
|
||
child: Text(
|
||
spec.series[i].name.isEmpty
|
||
? 'Reeks ${i + 1}'
|
||
: spec.series[i].name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.013,
|
||
fontWeight: FontWeight.w600,
|
||
color: textColor.withValues(alpha: 0.82),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _pieLegend(ChartSpec spec, Color textColor) {
|
||
final itemCount = math.min(spec.x.length, 18);
|
||
final columns = math.min(itemCount, presentationMode ? 4 : 6);
|
||
final rows = (itemCount / columns).ceil();
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final gap = w * 0.006;
|
||
final itemWidth =
|
||
(constraints.maxWidth - gap * (columns - 1)) / columns;
|
||
return SizedBox(
|
||
height: rows * w * 0.03 * _labelScale + (rows - 1) * gap,
|
||
child: Wrap(
|
||
spacing: gap,
|
||
runSpacing: gap,
|
||
children: [
|
||
for (var i = 0; i < itemCount; i++)
|
||
MouseRegion(
|
||
onEnter: (_) => _setHover(i),
|
||
onExit: (_) => _setHover(null),
|
||
child: AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 120),
|
||
opacity: _dimmed(i) ? 0.4 : 1,
|
||
child: Container(
|
||
width: itemWidth,
|
||
height: w * 0.03 * _labelScale,
|
||
padding: EdgeInsets.symmetric(horizontal: w * 0.008),
|
||
decoration: BoxDecoration(
|
||
color: _hovered == i
|
||
? _hexColor(
|
||
chartRowColor(spec, i),
|
||
).withValues(alpha: 0.18)
|
||
: textColor.withValues(alpha: 0.045),
|
||
borderRadius: BorderRadius.circular(w),
|
||
border: Border.all(
|
||
color: _hovered == i
|
||
? _hexColor(chartRowColor(spec, i))
|
||
: Colors.transparent,
|
||
width: w * 0.0015,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: w * 0.012,
|
||
height: w * 0.012,
|
||
decoration: BoxDecoration(
|
||
color: _hexColor(chartRowColor(spec, i)),
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
SizedBox(width: w * 0.006),
|
||
Expanded(
|
||
child: Text(
|
||
spec.x[i],
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.013 * _labelScale,
|
||
fontWeight: FontWeight.w600,
|
||
color: textColor.withValues(alpha: 0.82),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _chart(ChartSpec spec, Color textColor) {
|
||
switch (spec.type) {
|
||
case ChartType.bar:
|
||
return _barChart(spec, textColor);
|
||
case ChartType.line:
|
||
return _lineChart(spec, textColor);
|
||
case ChartType.pie:
|
||
return _pieChart(spec, textColor);
|
||
case ChartType.radar:
|
||
return _radarChart(spec, textColor);
|
||
}
|
||
}
|
||
|
||
double _maxY(ChartSpec spec) {
|
||
var m = 0.0;
|
||
for (final s in spec.series) {
|
||
for (final v in s.data) {
|
||
if (v > m) m = v;
|
||
}
|
||
}
|
||
// Keep any bound line comfortably inside the plot so its label is visible.
|
||
if (spec.supportsBounds) {
|
||
for (final b in [spec.minBound, spec.maxBound]) {
|
||
if (b != null && b > m) m = b;
|
||
}
|
||
}
|
||
return m <= 0 ? 1 : m * 1.15;
|
||
}
|
||
|
||
double _minY(ChartSpec spec) {
|
||
var m = 0.0;
|
||
for (final s in spec.series) {
|
||
for (final v in s.data) {
|
||
if (v < m) m = v;
|
||
}
|
||
}
|
||
if (spec.supportsBounds) {
|
||
for (final b in [spec.minBound, spec.maxBound]) {
|
||
if (b != null && b < m) m = b;
|
||
}
|
||
}
|
||
return m >= 0 ? 0 : m * 1.15;
|
||
}
|
||
|
||
/// Optional min/max threshold lines drawn across the plot (bar/line only).
|
||
ExtraLinesData _boundLines(ChartSpec spec) {
|
||
if (!spec.supportsBoundLines) return const ExtraLinesData();
|
||
final dash = [
|
||
(w * 0.018).round().clamp(4, 14),
|
||
(w * 0.01).round().clamp(3, 9),
|
||
];
|
||
HorizontalLine line(double value, Color color, String prefix) =>
|
||
HorizontalLine(
|
||
y: value,
|
||
color: color,
|
||
strokeWidth: w * 0.0035,
|
||
dashArray: dash,
|
||
label: HorizontalLineLabel(
|
||
show: true,
|
||
alignment: Alignment.topRight,
|
||
padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002),
|
||
style: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.0115 * _labelScale,
|
||
color: color,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
labelResolver: (_) => '$prefix ${_fmtNum(value)}',
|
||
),
|
||
);
|
||
return ExtraLinesData(
|
||
horizontalLines: [
|
||
if (spec.minBound != null)
|
||
line(spec.minBound!, const Color(0xFFF59E0B), 'min'),
|
||
if (spec.maxBound != null)
|
||
line(spec.maxBound!, const Color(0xFFEF4444), 'max'),
|
||
],
|
||
);
|
||
}
|
||
|
||
FlTitlesData _titles(ChartSpec spec, Color textColor) {
|
||
final style = _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.0115 * _labelScale,
|
||
color: textColor.withValues(alpha: 0.88),
|
||
fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal,
|
||
),
|
||
);
|
||
return FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: w * 0.05 * _labelScale,
|
||
getTitlesWidget: (value, meta) => Text(
|
||
_fmtNum(value),
|
||
style: style.copyWith(fontSize: w * 0.0105 * _labelScale),
|
||
),
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
interval: 1,
|
||
reservedSize: w * 0.044 * _labelScale,
|
||
getTitlesWidget: (value, meta) {
|
||
final i = value.round();
|
||
final n = spec.x.length;
|
||
if (i < 0 || i >= n) return const SizedBox.shrink();
|
||
// Show as many labels as fit without colliding: keep at least
|
||
// [minSlot] of horizontal room per label, then thin them out
|
||
// evenly based on the actual pixel spacing between points.
|
||
final spacing = n > 1
|
||
? meta.parentAxisSize / (n - 1)
|
||
: meta.parentAxisSize;
|
||
final minSlot = w * 0.085 * _labelScale;
|
||
final step = math.max(1, (minSlot / spacing).ceil());
|
||
final lastMultiple = ((n - 1) ~/ step) * step;
|
||
final showLast = i == n - 1 && (n - 1 - lastMultiple) > step / 2;
|
||
if (i % step != 0 && !showLast) return const SizedBox.shrink();
|
||
final slot = (step * spacing - w * 0.012).clamp(w * 0.04, w * 0.16);
|
||
return Padding(
|
||
padding: EdgeInsets.only(top: w * 0.008),
|
||
child: SizedBox(
|
||
width: slot,
|
||
child: Text(
|
||
spec.x[i],
|
||
style: style,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _fmtNum(double v) {
|
||
if (v == v.roundToDouble()) return v.toInt().toString();
|
||
return v.toStringAsFixed(1);
|
||
}
|
||
|
||
FlGridData _grid(Color textColor) => FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
getDrawingHorizontalLine: (v) =>
|
||
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
|
||
);
|
||
|
||
Widget _barChart(ChartSpec spec, Color textColor) {
|
||
final groups = <BarChartGroupData>[];
|
||
for (var xi = 0; xi < spec.x.length; xi++) {
|
||
groups.add(
|
||
BarChartGroupData(
|
||
x: xi,
|
||
barRods: [
|
||
for (var si = 0; si < spec.series.length; si++)
|
||
if (xi < spec.series[si].data.length)
|
||
BarChartRodData(
|
||
toY: spec.series[si].data[xi],
|
||
color: _seriesDisplayColor(spec.series[si], si),
|
||
width: (w * 0.032 / spec.series.length).clamp(
|
||
w * 0.008,
|
||
w * 0.022,
|
||
),
|
||
borderRadius: BorderRadius.vertical(
|
||
top: Radius.circular(w * 0.006),
|
||
),
|
||
backDrawRodData: BackgroundBarChartRodData(
|
||
show: true,
|
||
toY: _maxY(spec),
|
||
color: textColor.withValues(alpha: 0.025),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return BarChart(
|
||
BarChartData(
|
||
minY: _minY(spec),
|
||
maxY: _maxY(spec),
|
||
barGroups: groups,
|
||
titlesData: _titles(spec, textColor),
|
||
gridData: _grid(textColor),
|
||
borderData: FlBorderData(show: false),
|
||
extraLinesData: _boundLines(spec),
|
||
barTouchData: BarTouchData(
|
||
enabled: true,
|
||
mouseCursorResolver: (event, response) => response?.spot == null
|
||
? SystemMouseCursors.basic
|
||
: SystemMouseCursors.click,
|
||
touchTooltipData: BarTouchTooltipData(
|
||
fitInsideHorizontally: true,
|
||
fitInsideVertically: true,
|
||
getTooltipColor: (_) => const Color(0xFF0F172A),
|
||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||
final label = group.x >= 0 && group.x < spec.x.length
|
||
? spec.x[group.x]
|
||
: '';
|
||
final series = rodIndex < spec.series.length
|
||
? spec.series[rodIndex].name
|
||
: '';
|
||
return BarTooltipItem(
|
||
'$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}',
|
||
_tooltipStyle(),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
duration: Duration.zero,
|
||
);
|
||
}
|
||
|
||
Widget _lineChart(ChartSpec spec, Color textColor) {
|
||
final bars = <LineChartBarData>[];
|
||
for (var si = 0; si < spec.series.length; si++) {
|
||
bars.add(
|
||
LineChartBarData(
|
||
spots: [
|
||
for (var xi = 0; xi < spec.series[si].data.length; xi++)
|
||
FlSpot(xi.toDouble(), spec.series[si].data[xi]),
|
||
],
|
||
color: _seriesDisplayColor(spec.series[si], si),
|
||
barWidth: w * (_hovered == si ? 0.0065 : 0.0045),
|
||
isCurved: true,
|
||
curveSmoothness: 0.22,
|
||
dotData: FlDotData(
|
||
show: true,
|
||
getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(
|
||
radius: w * 0.005,
|
||
color: _seriesDisplayColor(spec.series[si], si),
|
||
strokeWidth: w * 0.0025,
|
||
strokeColor: _hexColor(profile.slideBackgroundColor),
|
||
),
|
||
),
|
||
belowBarData: BarAreaData(
|
||
show: true,
|
||
color: _seriesDisplayColor(spec.series[si], si).withValues(
|
||
alpha: spec.series.length == 1 ? 0.14 : 0.05,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return LineChart(
|
||
LineChartData(
|
||
minY: _minY(spec),
|
||
maxY: _maxY(spec),
|
||
lineBarsData: bars,
|
||
titlesData: _titles(spec, textColor),
|
||
gridData: _grid(textColor),
|
||
borderData: FlBorderData(show: false),
|
||
extraLinesData: _boundLines(spec),
|
||
lineTouchData: LineTouchData(
|
||
enabled: true,
|
||
// Measure proximity to the actual dot (x *and* y), not just the
|
||
// column, so the tooltip belongs to the point under the cursor.
|
||
distanceCalculator: (touch, spot) => (touch - spot).distance,
|
||
touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(),
|
||
mouseCursorResolver: (event, response) =>
|
||
response?.lineBarSpots?.isEmpty ?? true
|
||
? SystemMouseCursors.basic
|
||
: SystemMouseCursors.click,
|
||
touchTooltipData: LineTouchTooltipData(
|
||
fitInsideHorizontally: true,
|
||
fitInsideVertically: true,
|
||
getTooltipColor: (_) => const Color(0xFF0F172A),
|
||
// Show every dot near the cursor. When several dots sit on (almost)
|
||
// the same spot they all appear; the font shrinks to keep them
|
||
// readable when stacked.
|
||
getTooltipItems: (spots) {
|
||
final style = _lineTooltipStyle(spots.length);
|
||
return [
|
||
for (final spot in spots)
|
||
LineTooltipItem(
|
||
'${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n'
|
||
'${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}',
|
||
style,
|
||
),
|
||
];
|
||
},
|
||
),
|
||
),
|
||
),
|
||
duration: Duration.zero,
|
||
);
|
||
}
|
||
|
||
Widget _pieChart(ChartSpec spec, Color textColor) {
|
||
if (spec.series.isEmpty || spec.x.isEmpty) {
|
||
return _placeholderText('—');
|
||
}
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final visibleSeries = math.min(spec.series.length, 2);
|
||
final columns = visibleSeries;
|
||
const rows = 1;
|
||
final tileHeight = constraints.maxHeight / rows;
|
||
final tileWidth = constraints.maxWidth / columns;
|
||
return GridView.builder(
|
||
padding: EdgeInsets.zero,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: columns,
|
||
childAspectRatio: tileWidth / tileHeight,
|
||
crossAxisSpacing: w * 0.012,
|
||
mainAxisSpacing: w * 0.008,
|
||
),
|
||
itemCount: visibleSeries,
|
||
itemBuilder: (context, si) {
|
||
final series = spec.series[si];
|
||
final values = [
|
||
for (var xi = 0; xi < spec.x.length; xi++)
|
||
xi < series.data.length && series.data[xi] > 0
|
||
? series.data[xi]
|
||
: 0.0,
|
||
];
|
||
final total = values.fold<double>(0, (a, b) => a + b);
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
flex: 4,
|
||
child: total <= 0
|
||
? Center(
|
||
child: Text(
|
||
'0',
|
||
style: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.025,
|
||
color: textColor.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
: LayoutBuilder(
|
||
builder: (context, pieConstraints) {
|
||
final available =
|
||
pieConstraints.biggest.shortestSide;
|
||
final radius = (available * 0.42).clamp(
|
||
w * 0.018,
|
||
w * 0.075,
|
||
);
|
||
return ClipRect(
|
||
child: _HoverPieChart(
|
||
externalHover: _hovered,
|
||
values: values,
|
||
labels: spec.x,
|
||
colors: [
|
||
for (var xi = 0; xi < values.length; xi++)
|
||
_hexColor(chartRowColor(spec, xi)),
|
||
],
|
||
radius: radius,
|
||
centerSpaceRadius: radius * 0.42,
|
||
sectionSpace: w * 0.002,
|
||
titleStyle: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: (radius * 0.18).clamp(
|
||
w * 0.009,
|
||
w * 0.013,
|
||
),
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
tooltipStyle: _tooltipStyle(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
SizedBox(width: w * 0.008),
|
||
Expanded(
|
||
flex: 2,
|
||
child: Text(
|
||
series.name.isEmpty ? 'Reeks ${si + 1}' : series.name,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.015,
|
||
height: 1.1,
|
||
fontWeight: FontWeight.w700,
|
||
color: textColor,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _radarChart(ChartSpec spec, Color textColor) {
|
||
if (spec.x.length < 3 || spec.series.isEmpty) {
|
||
return _placeholderText(
|
||
context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'),
|
||
);
|
||
}
|
||
final grid = textColor.withValues(alpha: 0.18);
|
||
final scale = radarScale(spec);
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: w * 0.03, vertical: w * 0.012),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// Reserve a slim column on the right for the scale legend, then keep
|
||
// the chart square so fl_chart's centre/radius stay predictable.
|
||
final legendWidth = w * 0.075;
|
||
final available = constraints.maxWidth - legendWidth - w * 0.02;
|
||
final side = math.max(
|
||
0.0,
|
||
math.min(available, constraints.maxHeight),
|
||
);
|
||
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Expanded(
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: side,
|
||
height: side,
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: RadarChart(
|
||
RadarChartData(
|
||
dataSets: [
|
||
for (var si = 0; si < spec.series.length; si++)
|
||
RadarDataSet(
|
||
dataEntries: [
|
||
for (var xi = 0; xi < spec.x.length; xi++)
|
||
RadarEntry(
|
||
value:
|
||
xi < spec.series[si].data.length
|
||
? spec.series[si].data[xi]
|
||
: 0,
|
||
),
|
||
],
|
||
fillColor: _seriesDisplayColor(
|
||
spec.series[si],
|
||
si,
|
||
).withValues(alpha: _dimmed(si) ? 0.04 : 0.16),
|
||
borderColor: _seriesDisplayColor(
|
||
spec.series[si],
|
||
si,
|
||
),
|
||
borderWidth:
|
||
w * (_hovered == si ? 0.0055 : 0.0035),
|
||
entryRadius:
|
||
w * (_hovered == si ? 0.006 : 0.004),
|
||
),
|
||
// Invisible anchor pinning the scale to [lo, hi]
|
||
// so the rings represent a fixed scale.
|
||
RadarDataSet(
|
||
dataEntries: [
|
||
for (var xi = 0; xi < spec.x.length; xi++)
|
||
RadarEntry(
|
||
value: xi == 0 ? scale.hi : scale.lo,
|
||
),
|
||
],
|
||
fillColor: Colors.transparent,
|
||
borderColor: Colors.transparent,
|
||
borderWidth: 0,
|
||
entryRadius: 0,
|
||
),
|
||
],
|
||
radarShape: RadarShape.polygon,
|
||
radarBackgroundColor: Colors.transparent,
|
||
radarBorderData: BorderSide(color: grid, width: 1),
|
||
gridBorderData: BorderSide(color: grid, width: 1),
|
||
tickBorderData: BorderSide(color: grid, width: 1),
|
||
tickCount: scale.ticks,
|
||
isMinValueAtCenter: true,
|
||
// The scale now lives in a side legend, so hide
|
||
// fl_chart's in-chart ring numbers.
|
||
ticksTextStyle: const TextStyle(
|
||
color: Colors.transparent,
|
||
fontSize: 0.001,
|
||
),
|
||
titlePositionPercentageOffset: 0.14,
|
||
getTitle: (index, angle) => RadarChartTitle(
|
||
text: index < spec.x.length ? spec.x[index] : '',
|
||
),
|
||
titleTextStyle: _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.0135 * _labelScale,
|
||
color: textColor.withValues(alpha: 0.88),
|
||
fontWeight: presentationMode
|
||
? FontWeight.w600
|
||
: FontWeight.w500,
|
||
),
|
||
),
|
||
radarTouchData: RadarTouchData(
|
||
enabled: true,
|
||
touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0)
|
||
.toDouble(),
|
||
mouseCursorResolver: (event, response) =>
|
||
_radarSpotFrom(response, spec) == null
|
||
? SystemMouseCursors.basic
|
||
: SystemMouseCursors.click,
|
||
touchCallback: (event, response) {
|
||
final next =
|
||
event.isInterestedForInteractions
|
||
? _radarSpotFrom(response, spec)
|
||
: null;
|
||
if (next != _radarTouch) {
|
||
setState(() => _radarTouch = next);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
duration: Duration.zero,
|
||
),
|
||
),
|
||
if (_radarTouch != null)
|
||
_radarTooltip(spec, side, _radarTouch!),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: legendWidth,
|
||
child: _radarScaleLegend(scale, textColor),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Extract the touched real-series vertex from a radar touch response,
|
||
/// ignoring the invisible scale anchor dataset.
|
||
({int series, int entry, double value, Offset offset})? _radarSpotFrom(
|
||
RadarTouchResponse? response,
|
||
ChartSpec spec,
|
||
) {
|
||
final spot = response?.touchedSpot;
|
||
if (spot == null) return null;
|
||
if (spot.touchedDataSetIndex < 0 ||
|
||
spot.touchedDataSetIndex >= spec.series.length) {
|
||
return null; // the anchor dataset, or out of range
|
||
}
|
||
return (
|
||
series: spot.touchedDataSetIndex,
|
||
entry: spot.touchedRadarEntryIndex,
|
||
value: spot.touchedRadarEntry.value,
|
||
offset: spot.offset,
|
||
);
|
||
}
|
||
|
||
/// A small floating tooltip for the hovered radar vertex, like the other
|
||
/// charts: the axis label, the series name and the value.
|
||
Widget _radarTooltip(
|
||
ChartSpec spec,
|
||
double side,
|
||
({int series, int entry, double value, Offset offset}) touch,
|
||
) {
|
||
final axis = touch.entry >= 0 && touch.entry < spec.x.length
|
||
? spec.x[touch.entry]
|
||
: '';
|
||
final series = touch.series < spec.series.length
|
||
? spec.series[touch.series].name
|
||
: '';
|
||
final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series;
|
||
final onLeftHalf = touch.offset.dx <= side / 2;
|
||
return Positioned(
|
||
left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null,
|
||
right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012),
|
||
top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)),
|
||
child: IgnorePointer(
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(maxWidth: side * 0.6),
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: w * 0.012,
|
||
vertical: w * 0.006,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF0F172A),
|
||
borderRadius: BorderRadius.circular(w * 0.008),
|
||
boxShadow: const [
|
||
BoxShadow(color: Color(0x33000000), blurRadius: 6),
|
||
],
|
||
),
|
||
child: Text(
|
||
'${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}',
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: _tooltipStyle(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Vertical scale legend shown to the right of a radar chart: the tick values
|
||
/// from the outer ring (top) down to the centre (bottom), in a small font.
|
||
Widget _radarScaleLegend(
|
||
({double lo, double hi, int ticks}) scale,
|
||
Color textColor,
|
||
) {
|
||
final style = _applyFont(
|
||
font,
|
||
TextStyle(
|
||
fontSize: w * 0.012 * _labelScale,
|
||
color: textColor.withValues(alpha: 0.62),
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
);
|
||
final tickColor = textColor.withValues(alpha: 0.3);
|
||
return Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
for (var k = scale.ticks; k >= 0; k--) ...[
|
||
if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(width: w * 0.012, height: 1, color: tickColor),
|
||
SizedBox(width: w * 0.006),
|
||
Flexible(
|
||
child: Text(
|
||
_fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks),
|
||
style: style,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Resolves the radar scale: a low/high pair plus an even tick count. Honours
|
||
/// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data
|
||
/// range to a tidy scale so the rings read as round numbers.
|
||
({double lo, double hi, int ticks}) radarScale(ChartSpec spec) {
|
||
var dataMin = 0.0;
|
||
var dataMax = 0.0;
|
||
var seen = false;
|
||
for (final s in spec.series) {
|
||
for (final v in s.data) {
|
||
if (!seen) {
|
||
dataMin = v;
|
||
dataMax = v;
|
||
seen = true;
|
||
} else {
|
||
if (v < dataMin) dataMin = v;
|
||
if (v > dataMax) dataMax = v;
|
||
}
|
||
}
|
||
}
|
||
if (!seen) {
|
||
dataMin = 0;
|
||
dataMax = 1;
|
||
}
|
||
final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0);
|
||
final rawHi = spec.maxBound ?? dataMax;
|
||
final nice = _niceScale(rawLo, rawHi);
|
||
final lo = spec.minBound ?? nice.lo;
|
||
var hi = spec.maxBound ?? nice.hi;
|
||
if (hi <= lo) hi = lo + nice.step;
|
||
final ticks = math.max(2, ((hi - lo) / nice.step).round());
|
||
return (lo: lo, hi: hi, ticks: ticks);
|
||
}
|
||
|
||
({double lo, double hi, double step}) _niceScale(double lo, double hi) {
|
||
final range = (hi - lo).abs();
|
||
final r = range <= 0 ? 1.0 : range;
|
||
final rawStep = r / 4;
|
||
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
|
||
final norm = rawStep / mag;
|
||
final niceNorm = norm < 1.5
|
||
? 1.0
|
||
: norm < 3
|
||
? 2.0
|
||
: norm < 7
|
||
? 5.0
|
||
: 10.0;
|
||
final step = niceNorm * mag;
|
||
return (
|
||
lo: (lo / step).floor() * step,
|
||
hi: (hi / step).ceil() * step,
|
||
step: step,
|
||
);
|
||
}
|
||
|
||
TextStyle _tooltipStyle() => _applyFont(
|
||
font,
|
||
TextStyle(
|
||
color: Colors.white,
|
||
fontSize: (w * 0.013 * _labelScale).clamp(11, 18),
|
||
height: 1.25,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
);
|
||
|
||
/// Tooltip style for line charts. Each touched dot adds two lines, so when
|
||
/// several dots overlap the font shrinks a step to keep the stack readable.
|
||
TextStyle _lineTooltipStyle(int count) {
|
||
final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0);
|
||
final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0);
|
||
return _applyFont(
|
||
font,
|
||
TextStyle(
|
||
color: Colors.white,
|
||
fontSize: (base * shrink).clamp(8.0, 18.0),
|
||
height: 1.2,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _placeholder(BuildContext context) =>
|
||
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
||
|
||
Widget _placeholderText(String text) => Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.bar_chart_outlined,
|
||
size: w * 0.08,
|
||
color: const Color(0xFF94A3B8),
|
||
),
|
||
SizedBox(height: w * 0.01),
|
||
Text(
|
||
text,
|
||
style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _HoverPieChart extends StatefulWidget {
|
||
final List<double> values;
|
||
final List<String> labels;
|
||
final List<Color> colors;
|
||
final double radius;
|
||
final double centerSpaceRadius;
|
||
final double sectionSpace;
|
||
final TextStyle titleStyle;
|
||
final TextStyle tooltipStyle;
|
||
|
||
/// Slice index highlighted from outside (e.g. hovering the legend), combined
|
||
/// with this chart's own touch hover.
|
||
final int? externalHover;
|
||
|
||
const _HoverPieChart({
|
||
required this.values,
|
||
required this.labels,
|
||
required this.colors,
|
||
required this.radius,
|
||
required this.centerSpaceRadius,
|
||
required this.sectionSpace,
|
||
required this.titleStyle,
|
||
required this.tooltipStyle,
|
||
this.externalHover,
|
||
});
|
||
|
||
@override
|
||
State<_HoverPieChart> createState() => _HoverPieChartState();
|
||
}
|
||
|
||
class _HoverPieChartState extends State<_HoverPieChart> {
|
||
int? _hovered;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final total = widget.values.fold<double>(0, (a, b) => a + b);
|
||
final external = widget.externalHover;
|
||
final hovered = _hovered ?? (external != null && external >= 0 && external < widget.values.length ? external : null);
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Positioned.fill(
|
||
child: PieChart(
|
||
PieChartData(
|
||
sections: [
|
||
for (var i = 0; i < widget.values.length; i++)
|
||
PieChartSectionData(
|
||
value: widget.values[i],
|
||
color: widget.colors[i],
|
||
title: widget.values[i] / total >= 0.08
|
||
? '${(widget.values[i] / total * 100).round()}%'
|
||
: '',
|
||
radius: widget.radius * (hovered == i ? 1.08 : 1),
|
||
titleStyle: widget.titleStyle,
|
||
),
|
||
],
|
||
sectionsSpace: widget.sectionSpace,
|
||
centerSpaceRadius: widget.centerSpaceRadius,
|
||
pieTouchData: PieTouchData(
|
||
enabled: true,
|
||
mouseCursorResolver: (event, response) =>
|
||
response?.touchedSection == null
|
||
? SystemMouseCursors.basic
|
||
: SystemMouseCursors.click,
|
||
touchCallback: (event, response) {
|
||
final next = event.isInterestedForInteractions
|
||
? response?.touchedSection?.touchedSectionIndex
|
||
: null;
|
||
if (next != _hovered) setState(() => _hovered = next);
|
||
},
|
||
),
|
||
),
|
||
duration: Duration.zero,
|
||
),
|
||
),
|
||
if (hovered != null && hovered >= 0 && hovered < widget.values.length)
|
||
Positioned(
|
||
top: 4,
|
||
left: 4,
|
||
right: 4,
|
||
child: IgnorePointer(
|
||
child: Center(
|
||
child: Container(
|
||
key: const ValueKey('pie-hover-tooltip'),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF0F172A),
|
||
borderRadius: BorderRadius.circular(8),
|
||
boxShadow: const [
|
||
BoxShadow(color: Color(0x33000000), blurRadius: 6),
|
||
],
|
||
),
|
||
child: Text(
|
||
'${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}',
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style: widget.tooltipStyle,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
String _formatChartValue(double value) => value == value.roundToDouble()
|
||
? value.toInt().toString()
|
||
: value.toStringAsFixed(1);
|
||
|
||
/// 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 ─────────────────────────────────────────────────────────────
|
||
|
||
/// 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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String? _resolvePath(String path, String? projectPath) =>
|
||
resolveSlideAssetPath(path, projectPath);
|
||
|
||
/// Resolves an image/media path the way the slide renderer does, so callers
|
||
/// (e.g. the presenter, to precache) can point at the exact file that will be
|
||
/// displayed. Returns null for an empty path.
|
||
String? resolveSlideAssetPath(String path, String? projectPath) {
|
||
if (path.isEmpty) return null;
|
||
if (path.startsWith('/') || path.contains(':\\')) return path;
|
||
if (projectPath != null) return '$projectPath/$path';
|
||
return path;
|
||
}
|
||
|
||
// ── 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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Footer onderaan een slide: vrije tekst (links) + paginanummers (rechts),
|
||
/// op basis van het stijlprofiel. Verborgen op titel-/sectieslides (daar is
|
||
/// een footer ongebruikelijk en valt 'ie weg tegen de donkere achtergrond).
|
||
/// Linkermarge waar de inhoud (bullets/tekst) van een slide begint. Wordt
|
||
/// gebruikt om een links-uitgelijnde footer ermee te laten uitlijnen, zodat het
|
||
/// geheel consistenter oogt. Moet overeenkomen met de `pad`-waarden van de
|
||
/// afzonderlijke slide-renderers hierboven.
|
||
double _contentLeftInset(Slide slide, double w) {
|
||
switch (slide.type) {
|
||
case SlideType.bullets:
|
||
case SlideType.freeMarkdown:
|
||
return w * 0.07;
|
||
case SlideType.code:
|
||
return w * 0.05;
|
||
case SlideType.chart:
|
||
return w * 0.06;
|
||
case SlideType.twoBullets:
|
||
return w * 0.065;
|
||
case SlideType.table:
|
||
return w * 0.06;
|
||
case SlideType.bulletsImage:
|
||
return w * 0.038;
|
||
case SlideType.quote:
|
||
return w * 0.08;
|
||
default:
|
||
// Beeld/video: geen tekstmarge om mee uit te lijnen.
|
||
return w * 0.04;
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|