2026-06-02 23:28:39 +02:00
|
|
|
|
import 'dart:io';
|
2026-06-08 12:18:35 +02:00
|
|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
|
import 'package:flutter/material.dart';
|
2026-06-07 11:42:44 +02:00
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
2026-06-04 00:59:14 +02:00
|
|
|
|
import 'package:flutter_highlight/flutter_highlight.dart';
|
|
|
|
|
|
import 'package:flutter_highlight/themes/github.dart';
|
2026-06-06 20:41:24 +02:00
|
|
|
|
import 'package:flutter_highlight/themes/atom-one-dark.dart';
|
2026-06-04 00:59:14 +02:00
|
|
|
|
import 'package:flutter_math_fork/flutter_math.dart';
|
|
|
|
|
|
import 'package:highlight/highlight.dart' show highlight;
|
|
|
|
|
|
import 'package:highlight/languages/all.dart' show allLanguages;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
import 'package:video_player/video_player.dart';
|
2026-06-05 19:14:54 +02:00
|
|
|
|
import '../../l10n/app_localizations.dart';
|
2026-06-07 11:42:44 +02:00
|
|
|
|
import '../../models/chart.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
import '../../models/deck.dart';
|
|
|
|
|
|
import '../../models/settings.dart';
|
|
|
|
|
|
import '../../models/slide.dart';
|
|
|
|
|
|
import '../../theme/app_theme.dart';
|
|
|
|
|
|
import 'inline_markdown.dart';
|
|
|
|
|
|
|
2026-06-03 15:03:27 +02:00
|
|
|
|
/// Returns a TextStyle with the correct font. 'EB Garamond' is bundled with the
|
|
|
|
|
|
/// app (see pubspec.yaml); all other fonts resolve to system families.
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-08 12:18:35 +02:00
|
|
|
|
/// Vergroot grafieklabels voor weergave op afstand in presentatiemodus.
|
|
|
|
|
|
final bool presentationMode;
|
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
|
/// 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,
|
2026-06-08 12:18:35 +02:00
|
|
|
|
this.presentationMode = false,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
this.onAudioComplete,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-05 19:14:54 +02:00
|
|
|
|
final hasBottomRightTlp =
|
|
|
|
|
|
tlp != TlpLevel.none &&
|
|
|
|
|
|
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
|
|
|
|
|
|
themeProfile.logoPosition == 'bottom-right');
|
2026-06-02 23:28:39 +02:00
|
|
|
|
// 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,
|
2026-06-05 19:14:54 +02:00
|
|
|
|
hasBottomTlp: hasBottomRightTlp,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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)
|
2026-06-05 19:14:54 +02:00
|
|
|
|
_TlpOverlay(
|
|
|
|
|
|
tlp: tlp,
|
|
|
|
|
|
w: w,
|
|
|
|
|
|
profile: themeProfile,
|
|
|
|
|
|
hasLogo:
|
|
|
|
|
|
themeProfile.logoPath?.isNotEmpty == true &&
|
|
|
|
|
|
slide.showLogo,
|
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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,
|
|
|
|
|
|
);
|
2026-06-06 20:41:24 +02:00
|
|
|
|
case SlideType.code:
|
|
|
|
|
|
return _CodePreview(
|
|
|
|
|
|
slide: slide,
|
|
|
|
|
|
w: w,
|
|
|
|
|
|
font: fontFamily,
|
|
|
|
|
|
profile: themeProfile,
|
|
|
|
|
|
);
|
2026-06-07 11:42:44 +02:00
|
|
|
|
case SlideType.chart:
|
|
|
|
|
|
return _ChartPreview(
|
|
|
|
|
|
slide: slide,
|
|
|
|
|
|
w: w,
|
|
|
|
|
|
font: fontFamily,
|
|
|
|
|
|
profile: themeProfile,
|
2026-06-08 12:18:35 +02:00
|
|
|
|
presentationMode: presentationMode,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
);
|
2026-06-02 23:28:39 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
context,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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: [
|
2026-06-05 19:14:54 +02:00
|
|
|
|
_resolvedImage(context, slide.imagePath, projectPath),
|
|
|
|
|
|
_captionOverlay(context, slide.imageCaption, w),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
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: [
|
2026-06-05 19:14:54 +02:00
|
|
|
|
_resolvedImage(context, slide.imagePath, projectPath),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
_captionOverlay(context, slide.imageCaption, w),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
SizedBox(
|
|
|
|
|
|
width: rightW,
|
|
|
|
|
|
child: Stack(
|
|
|
|
|
|
fit: StackFit.expand,
|
|
|
|
|
|
children: [
|
2026-06-05 19:14:54 +02:00
|
|
|
|
_resolvedImage(context, slide.imagePath2, projectPath),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
_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(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
context,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
context,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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,
|
2026-06-05 19:14:54 +02:00
|
|
|
|
child: _resolvedImage(
|
|
|
|
|
|
context,
|
|
|
|
|
|
logoPath,
|
|
|
|
|
|
projectPath,
|
|
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-04 00:59:14 +02:00
|
|
|
|
children: _buildBlocks(context),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-04 00:59:14 +02:00
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 20:41:24 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
@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);
|
|
|
|
|
|
|
2026-06-08 13:51:29 +02:00
|
|
|
|
final codeBg = _hexColor(profile.codeBackgroundColor);
|
|
|
|
|
|
final codeFg = _hexColor(profile.codeTextColor);
|
|
|
|
|
|
|
2026-06-06 20:41:24 +02:00
|
|
|
|
final mono = TextStyle(
|
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
|
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
|
|
|
|
|
fontSize: w * 0.024,
|
|
|
|
|
|
height: 1.4,
|
2026-06-08 13:51:29 +02:00
|
|
|
|
color: codeFg,
|
2026-06-06 20:41:24 +02:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-08 13:51:29 +02:00
|
|
|
|
// 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 Widget codeContent = (known && profile.codeHighlightSyntax)
|
2026-06-06 20:41:24 +02:00
|
|
|
|
? HighlightView(
|
|
|
|
|
|
code,
|
|
|
|
|
|
language: lang,
|
2026-06-08 13:51:29 +02:00
|
|
|
|
// Keep atom-one-dark's per-token colours but drop its own
|
|
|
|
|
|
// background so our themed [codeBg] shows through unchanged.
|
|
|
|
|
|
theme: {
|
|
|
|
|
|
...atomOneDarkTheme,
|
|
|
|
|
|
'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith(
|
|
|
|
|
|
backgroundColor: codeBg,
|
|
|
|
|
|
color: codeFg,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2026-06-06 20:41:24 +02:00
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
textStyle: mono,
|
|
|
|
|
|
)
|
|
|
|
|
|
: Text(code, style: mono);
|
|
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
color: _hexColor(profile.slideBackgroundColor),
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: EdgeInsets.fromLTRB(
|
|
|
|
|
|
pad,
|
|
|
|
|
|
pad + safe.top,
|
|
|
|
|
|
pad,
|
|
|
|
|
|
pad + safe.bottom,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
|
decoration: BoxDecoration(
|
2026-06-08 13:51:29 +02:00
|
|
|
|
color: codeBg,
|
2026-06-06 20:41:24 +02:00
|
|
|
|
borderRadius: BorderRadius.circular(w * 0.012),
|
2026-06-08 13:51:29 +02:00
|
|
|
|
border: Border.all(color: codeFg.withValues(alpha: 0.22)),
|
2026-06-06 20:41:24 +02:00
|
|
|
|
),
|
|
|
|
|
|
padding: EdgeInsets.all(w * 0.03),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
if (slide.title.isNotEmpty) ...[
|
|
|
|
|
|
_md(
|
|
|
|
|
|
context,
|
|
|
|
|
|
slide.title,
|
|
|
|
|
|
_applyFont(
|
|
|
|
|
|
font,
|
|
|
|
|
|
TextStyle(
|
|
|
|
|
|
fontSize: w * 0.03,
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
2026-06-08 13:51:29 +02:00
|
|
|
|
color: codeFg,
|
2026-06-06 20:41:24 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
linkColor: _hexColor(profile.accentColor),
|
|
|
|
|
|
),
|
|
|
|
|
|
SizedBox(height: w * 0.02),
|
|
|
|
|
|
],
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: FittedBox(
|
|
|
|
|
|
fit: BoxFit.scaleDown,
|
|
|
|
|
|
alignment: Alignment.topLeft,
|
|
|
|
|
|
// Een onbegrensde breedte laat code-regels op hun natuurlijke
|
|
|
|
|
|
// lengte staan (geen woordafbreking), waarna de FittedBox het
|
|
|
|
|
|
// geheel verkleint tot het past.
|
|
|
|
|
|
child: codeContent,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 11:42:44 +02:00
|
|
|
|
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
|
2026-06-08 12:18:35 +02:00
|
|
|
|
class _ChartPreview extends StatefulWidget {
|
2026-06-07 11:42:44 +02:00
|
|
|
|
final Slide slide;
|
|
|
|
|
|
final double w;
|
|
|
|
|
|
final String font;
|
|
|
|
|
|
final ThemeProfile profile;
|
2026-06-08 12:18:35 +02:00
|
|
|
|
final bool presentationMode;
|
2026-06-07 11:42:44 +02:00
|
|
|
|
|
|
|
|
|
|
const _ChartPreview({
|
|
|
|
|
|
required this.slide,
|
|
|
|
|
|
required this.w,
|
|
|
|
|
|
required this.font,
|
|
|
|
|
|
required this.profile,
|
2026-06-08 12:18:35 +02:00
|
|
|
|
required this.presentationMode,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-08 12:18:35 +02:00
|
|
|
|
@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;
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-06-07 11:42:44 +02:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final spec = ChartSpec.parse(slide.customMarkdown);
|
2026-06-08 12:18:35 +02:00
|
|
|
|
final horizontalPad = w * 0.05;
|
|
|
|
|
|
final verticalPad = w * 0.018;
|
2026-06-07 11:42:44 +02:00
|
|
|
|
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(
|
2026-06-08 12:18:35 +02:00
|
|
|
|
horizontalPad,
|
|
|
|
|
|
verticalPad + safe.top,
|
|
|
|
|
|
horizontalPad,
|
|
|
|
|
|
verticalPad + safe.bottom,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
2026-06-08 12:18:35 +02:00
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
children: [
|
|
|
|
|
|
if (spec.title.isNotEmpty) ...[
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
linkColor: _hexColor(profile.accentColor),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
SizedBox(height: w * 0.012),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
],
|
|
|
|
|
|
Expanded(
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _legend(ChartSpec spec, Color textColor) {
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-06-07 11:42:44 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-08 13:51:29 +02:00
|
|
|
|
case ChartType.radar:
|
|
|
|
|
|
return _radarChart(spec, textColor);
|
2026-06-07 11:42:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-08 12:18:35 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-07 11:42:44 +02:00
|
|
|
|
return m <= 0 ? 1 : m * 1.15;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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) {
|
2026-06-08 13:51:29 +02:00
|
|
|
|
if (!spec.supportsBoundLines) return const ExtraLinesData();
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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'),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 11:42:44 +02:00
|
|
|
|
FlTitlesData _titles(ChartSpec spec, Color textColor) {
|
|
|
|
|
|
final style = _applyFont(
|
|
|
|
|
|
font,
|
2026-06-08 12:18:35 +02:00
|
|
|
|
TextStyle(
|
|
|
|
|
|
fontSize: w * 0.0115 * _labelScale,
|
|
|
|
|
|
color: textColor.withValues(alpha: 0.88),
|
|
|
|
|
|
fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal,
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
);
|
|
|
|
|
|
return FlTitlesData(
|
|
|
|
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
|
|
leftTitles: AxisTitles(
|
|
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
|
|
showTitles: true,
|
2026-06-08 12:18:35 +02:00
|
|
|
|
reservedSize: w * 0.05 * _labelScale,
|
|
|
|
|
|
getTitlesWidget: (value, meta) => Text(
|
|
|
|
|
|
_fmtNum(value),
|
|
|
|
|
|
style: style.copyWith(fontSize: w * 0.0105 * _labelScale),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
bottomTitles: AxisTitles(
|
|
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
|
|
showTitles: true,
|
2026-06-08 12:18:35 +02:00
|
|
|
|
interval: 1,
|
|
|
|
|
|
reservedSize: w * 0.044 * _labelScale,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
getTitlesWidget: (value, meta) {
|
|
|
|
|
|
final i = value.round();
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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);
|
2026-06-07 11:42:44 +02:00
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: EdgeInsets.only(top: w * 0.008),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
child: SizedBox(
|
|
|
|
|
|
width: slot,
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
spec.x[i],
|
|
|
|
|
|
style: style,
|
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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],
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return BarChart(
|
|
|
|
|
|
BarChartData(
|
2026-06-08 12:18:35 +02:00
|
|
|
|
minY: _minY(spec),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
maxY: _maxY(spec),
|
|
|
|
|
|
barGroups: groups,
|
|
|
|
|
|
titlesData: _titles(spec, textColor),
|
|
|
|
|
|
gridData: _grid(textColor),
|
|
|
|
|
|
borderData: FlBorderData(show: false),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
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]),
|
|
|
|
|
|
],
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return LineChart(
|
|
|
|
|
|
LineChartData(
|
2026-06-08 12:18:35 +02:00
|
|
|
|
minY: _minY(spec),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
maxY: _maxY(spec),
|
|
|
|
|
|
lineBarsData: bars,
|
|
|
|
|
|
titlesData: _titles(spec, textColor),
|
|
|
|
|
|
gridData: _grid(textColor),
|
|
|
|
|
|
borderData: FlBorderData(show: false),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
extraLinesData: _boundLines(spec),
|
|
|
|
|
|
lineTouchData: LineTouchData(
|
|
|
|
|
|
enabled: true,
|
2026-06-08 13:51:29 +02:00
|
|
|
|
// 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(),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
mouseCursorResolver: (event, response) =>
|
|
|
|
|
|
response?.lineBarSpots?.isEmpty ?? true
|
|
|
|
|
|
? SystemMouseCursors.basic
|
|
|
|
|
|
: SystemMouseCursors.click,
|
|
|
|
|
|
touchTooltipData: LineTouchTooltipData(
|
|
|
|
|
|
fitInsideHorizontally: true,
|
|
|
|
|
|
fitInsideVertically: true,
|
|
|
|
|
|
getTooltipColor: (_) => const Color(0xFF0F172A),
|
2026-06-08 13:51:29 +02:00
|
|
|
|
// 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.
|
2026-06-08 12:18:35 +02:00
|
|
|
|
getTooltipItems: (spots) {
|
2026-06-08 13:51:29 +02:00
|
|
|
|
final style = _lineTooltipStyle(spots.length);
|
2026-06-08 12:18:35 +02:00
|
|
|
|
return [
|
2026-06-08 13:51:29 +02:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
];
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
duration: Duration.zero,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _pieChart(ChartSpec spec, Color textColor) {
|
2026-06-08 12:18:35 +02:00
|
|
|
|
if (spec.series.isEmpty || spec.x.isEmpty) {
|
|
|
|
|
|
return _placeholderText('—');
|
2026-06-07 11:42:44 +02:00
|
|
|
|
}
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
)
|
|
|
|
|
|
: 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(),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
),
|
|
|
|
|
|
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,
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
),
|
2026-06-07 11:42:44 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-06-07 11:42:44 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 13:51:29 +02:00
|
|
|
|
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);
|
|
|
|
|
|
final bg = _hexColor(profile.slideBackgroundColor);
|
|
|
|
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: w * 0.06, vertical: w * 0.012),
|
|
|
|
|
|
child: LayoutBuilder(
|
|
|
|
|
|
builder: (context, constraints) {
|
|
|
|
|
|
// A square keeps fl_chart's centre/radius predictable so the tick
|
|
|
|
|
|
// labels we overlay line up exactly with its grid rings.
|
|
|
|
|
|
final side = math.min(constraints.maxWidth, constraints.maxHeight);
|
|
|
|
|
|
final centerOffset = side / 2;
|
|
|
|
|
|
final radius = centerOffset * 0.8; // matches RadarChartPainter
|
|
|
|
|
|
final tickStyle = _applyFont(
|
|
|
|
|
|
font,
|
|
|
|
|
|
TextStyle(
|
|
|
|
|
|
fontSize: w * 0.012 * _labelScale,
|
|
|
|
|
|
color: textColor.withValues(alpha: 0.6),
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return 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 — and our labels — 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,
|
|
|
|
|
|
// Hide fl_chart's own ring numbers; we draw labelled
|
|
|
|
|
|
// ticks ourselves so any min/max scale reads correctly.
|
|
|
|
|
|
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: false),
|
|
|
|
|
|
),
|
|
|
|
|
|
duration: Duration.zero,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Evenly spaced scale labels up the top spoke: lo at centre,
|
|
|
|
|
|
// hi at the outer ring, with equal steps between.
|
|
|
|
|
|
for (var k = 0; k <= scale.ticks; k++)
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
left: centerOffset + w * 0.006,
|
|
|
|
|
|
top:
|
|
|
|
|
|
centerOffset -
|
|
|
|
|
|
radius * k / scale.ticks -
|
|
|
|
|
|
w * 0.01 * _labelScale,
|
|
|
|
|
|
child: IgnorePointer(
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding: EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: w * 0.004,
|
|
|
|
|
|
),
|
|
|
|
|
|
color: bg.withValues(alpha: 0.7),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
_fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks),
|
|
|
|
|
|
style: tickStyle,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 12:18:35 +02:00
|
|
|
|
TextStyle _tooltipStyle() => _applyFont(
|
|
|
|
|
|
font,
|
|
|
|
|
|
TextStyle(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
fontSize: (w * 0.013 * _labelScale).clamp(11, 18),
|
|
|
|
|
|
height: 1.25,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-08 13:51:29 +02:00
|
|
|
|
/// 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,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 11:42:44 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-06-04 00:59:14 +02:00
|
|
|
|
/// 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;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
BuildContext context,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
String imagePath,
|
|
|
|
|
|
String? projectPath,
|
|
|
|
|
|
int imageSize, {
|
|
|
|
|
|
Color bgColor = Colors.black,
|
|
|
|
|
|
Alignment alignment = Alignment.center,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (imageSize == 0) {
|
2026-06-05 19:14:54 +02:00
|
|
|
|
return _resolvedImage(
|
|
|
|
|
|
context,
|
|
|
|
|
|
imagePath,
|
|
|
|
|
|
projectPath,
|
|
|
|
|
|
); // BoxFit.cover standaard
|
2026-06-02 23:28:39 +02:00
|
|
|
|
}
|
|
|
|
|
|
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(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
context,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
imagePath,
|
|
|
|
|
|
projectPath,
|
|
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _resolvedImage(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
BuildContext context,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
String imagePath,
|
|
|
|
|
|
String? projectPath, {
|
|
|
|
|
|
BoxFit fit = BoxFit.cover,
|
|
|
|
|
|
}) {
|
2026-06-05 19:14:54 +02:00
|
|
|
|
if (imagePath.isEmpty) return _imagePlaceholder(context);
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-06 20:41:24 +02:00
|
|
|
|
// 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,
|
2026-06-05 19:14:54 +02:00
|
|
|
|
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
right: right ?? w * _kTlpEdge,
|
|
|
|
|
|
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 20:41:24 +02:00
|
|
|
|
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) {
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-05 19:14:54 +02:00
|
|
|
|
double _tlpBottomInset(double w) => w * 0.022;
|
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
|
/// 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) =>
|
2026-06-05 19:14:54 +02:00
|
|
|
|
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
|
|
/// 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;
|
2026-06-05 19:14:54 +02:00
|
|
|
|
final bool hasLogo;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
|
|
const _TlpOverlay({
|
|
|
|
|
|
required this.tlp,
|
|
|
|
|
|
required this.w,
|
|
|
|
|
|
required this.profile,
|
2026-06-05 19:14:54 +02:00
|
|
|
|
required this.hasLogo,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-05 19:14:54 +02:00
|
|
|
|
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
return Positioned(
|
2026-06-05 19:14:54 +02:00
|
|
|
|
bottom: _tlpBottomInset(w),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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;
|
2026-06-06 20:41:24 +02:00
|
|
|
|
case SlideType.code:
|
|
|
|
|
|
return w * 0.05;
|
2026-06-07 11:42:44 +02:00
|
|
|
|
case SlideType.chart:
|
|
|
|
|
|
return w * 0.06;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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;
|
2026-06-05 19:14:54 +02:00
|
|
|
|
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
|
2026-06-02 23:28:39 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 19:14:54 +02:00
|
|
|
|
Widget _imagePlaceholder(BuildContext context) {
|
2026-06-08 12:18:35 +02:00
|
|
|
|
return ColoredBox(
|
2026-06-02 23:28:39 +02:00
|
|
|
|
color: const Color(0xFFE2E8F0),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
2026-06-08 12:18:35 +02:00
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|