Ocideck/lib/widgets/slides/slide_preview.dart

427 lines
14 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart';
import '../../l10n/app_localizations.dart';
import '../../models/chart.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../theme/app_theme.dart';
import '../../utils/log.dart';
import 'inline_markdown.dart';
// Slide preview widgets, split into part files by slide type for
// navigability. These parts share this library's imports and private scope.
part 'previews/text_previews.dart';
part 'previews/bullets_previews.dart';
part 'previews/checklist_previews.dart';
part 'previews/table_preview.dart';
part 'previews/media_previews.dart';
part 'previews/code_preview.dart';
part 'previews/chart_preview.dart';
part 'previews/overlays.dart';
/// Returns a TextStyle with the correct font. 'EB Garamond' is bundled with the
/// app (see pubspec.yaml); all other fonts resolve to system families.
TextStyle _applyFont(String font, TextStyle base) {
return base.copyWith(fontFamily: font);
}
/// Geeft de link-tap-handler door aan alle tekst in een slide, zonder die door
/// elke sub-widget heen te hoeven sleuren. Draagt ook of er een TLP-markering
/// rechtsonder staat, zodat bijschriften daarboven uitwijken.
class _SlideLinkScope extends InheritedWidget {
final void Function(String url)? onTapLink;
final bool hasBottomTlp;
const _SlideLinkScope({
required this.onTapLink,
this.hasBottomTlp = false,
required super.child,
});
static void Function(String url)? of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_SlideLinkScope>()
?.onTapLink;
}
static bool hasBottomTlpOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_SlideLinkScope>()
?.hasBottomTlp ??
false;
}
@override
bool updateShouldNotify(_SlideLinkScope oldWidget) =>
oldWidget.onTapLink != onTapLink ||
oldWidget.hasBottomTlp != hasBottomTlp;
}
/// Tekst met inline-markdown (**vet**, *cursief*, `code`, ~~door~~, [link](url)).
/// Vervangt platte [Text] op alle inhoudsplekken van een slide.
Widget _md(
BuildContext context,
String text,
TextStyle style, {
required Color linkColor,
int? maxLines,
TextAlign textAlign = TextAlign.start,
TextOverflow overflow = TextOverflow.clip,
bool softWrap = true,
}) {
return InlineMarkdownText(
text,
style: style,
linkColor: linkColor,
onTapLink: _SlideLinkScope.of(context),
maxLines: maxLines,
textAlign: textAlign,
overflow: overflow,
softWrap: softWrap,
);
}
Color _hexColor(String hex) {
final cleaned = hex.replaceFirst('#', '');
final value = int.tryParse(
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
radix: 16,
);
return Color(value ?? 0xFFFFFFFF);
}
EdgeInsets _logoSafeInsets(double w, ThemeProfile profile) {
if (profile.logoPath?.isEmpty ?? true) return EdgeInsets.zero;
// Reserve just enough to clear the logo plus a small margin (matching the
// split-layout reserve). A larger margin needlessly shrinks the text area.
final reserved = w * ((profile.logoSize + 24) / 1280);
if (profile.logoPosition.startsWith('top')) {
return EdgeInsets.only(top: reserved);
}
return EdgeInsets.only(bottom: reserved);
}
EdgeInsets _splitTextLogoSafeInsets(double w, ThemeProfile profile) {
if (profile.logoPath?.isEmpty ?? true) return EdgeInsets.zero;
if (profile.logoPosition.endsWith('right')) return EdgeInsets.zero;
final reserved = w * ((profile.logoSize + 24) / 1280);
if (profile.logoPosition.startsWith('top')) {
return EdgeInsets.only(top: reserved);
}
return EdgeInsets.only(bottom: reserved);
}
/// Renders a visual approximation of a Marp slide inside a 16:9 container.
/// All font sizes and paddings are proportional to the widget width so the
/// same widget works both as the full preview pane and as a tiny thumbnail.
/// Content that exceeds the slide height is scaled down proportionally via
/// FittedBox rather than clipped.
class SlidePreviewWidget extends StatelessWidget {
final Slide slide;
final String? projectPath;
final ThemeProfile themeProfile;
/// Het lettertype hoort bij de stijl (themeProfile), niet bij de app.
String get fontFamily => themeProfile.fontFamily;
/// Optioneel: maakt links in de tekst klikbaar (preview/presenter). In
/// thumbnails en bij export blijft dit null → links zijn alleen gestyled.
final void Function(String url)? onLinkTap;
/// 1-gebaseerd slidenummer en totaal, voor footer-paginanummers en de
/// {page}/{total}-tokens. Null → geen paginanummers.
final int? slideNumber;
final int? slideCount;
/// TLP-classificatie van de presentatie; getoond als markering op de slide.
final TlpLevel tlp;
/// Of audio/video op deze slide afgespeeld kan worden (de audioknop verschijnt
/// en video kan starten). Standaard uit — thumbnails en export spelen niets.
final bool enableMedia;
/// Of media automatisch start (audio-/video-autoplay). In de editor-preview
/// staat dit uit (handmatig starten); in de presenter aan.
final bool autoplayMedia;
/// Vergroot grafieklabels voor weergave op afstand in presentatiemodus.
final bool presentationMode;
/// Wijzigt tijdens het presenteren een checklistitem. [column] is 0 voor de
/// eerste/enkele lijst en 1 voor de rechterkolom.
final void Function(int column, int itemIndex)? onChecklistItemToggle;
/// Wordt aangeroepen wanneer de audio van deze slide klaar is (voor de
/// automatische modus van de presenter).
final VoidCallback? onAudioComplete;
/// Wordt aangeroepen wanneer de video van deze slide klaar is.
final VoidCallback? onVideoComplete;
const SlidePreviewWidget({
super.key,
required this.slide,
this.projectPath,
this.themeProfile = const ThemeProfile(),
this.onLinkTap,
this.slideNumber,
this.slideCount,
this.tlp = TlpLevel.none,
this.enableMedia = false,
this.autoplayMedia = false,
this.presentationMode = false,
this.onChecklistItemToggle,
this.onAudioComplete,
this.onVideoComplete,
});
@override
Widget build(BuildContext context) {
final hasBottomRightTlp =
tlp != TlpLevel.none &&
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
themeProfile.logoPosition == 'bottom-right');
// Make the widget self-sufficient for text rendering. On screen it sits
// inside a Material (which supplies a clean DefaultTextStyle), but the
// export rasterizer mounts it in a bare Overlay subtree. Without an
// explicit DefaultTextStyle there, any Text that doesn't set its own color
// falls back to Flutter's broken default — red letters with a yellow
// underline — which is exactly what showed up in exports. Wrapping here
// guarantees identical results in the preview and the export.
// The slide is a fixed 16:9 design surface whose sizes all derive from
// its width; interface text scaling must not reflow it (the auto-fit
// measuring assumes unscaled text), so the canvas opts out.
return MediaQuery.withNoTextScaling(
child: _ChecklistInteractionHost(
enabled: presentationMode && onChecklistItemToggle != null,
onToggle: onChecklistItemToggle,
child: Directionality(
textDirection: TextDirection.ltr,
child: DefaultTextStyle(
style: TextStyle(
color: _hexColor(themeProfile.textColor),
decoration: TextDecoration.none,
fontWeight: FontWeight.normal,
fontStyle: FontStyle.normal,
),
child: _SlideLinkScope(
onTapLink: onLinkTap,
hasBottomTlp: hasBottomRightTlp,
child: _buildSlide(),
),
),
),
),
);
}
Widget _buildSlide() {
return LayoutBuilder(
builder: (_, constraints) {
final w = constraints.maxWidth;
return AspectRatio(
aspectRatio: 16 / 9,
child: ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
_buildContent(w),
_FooterOverlay(
slide: slide,
w: w,
profile: themeProfile,
slideNumber: slideNumber,
slideCount: slideCount,
tlp: tlp,
),
if (tlp != TlpLevel.none)
_TlpOverlay(
tlp: tlp,
w: w,
profile: themeProfile,
hasLogo:
themeProfile.logoPath?.isNotEmpty == true &&
slide.showLogo,
),
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
_LogoOverlay(
logoPath: themeProfile.logoPath!,
projectPath: projectPath,
position: themeProfile.logoPosition,
size: w * (themeProfile.logoSize / 1280),
),
if (enableMedia && slide.audioPath.isNotEmpty)
_AudioPlayback(
audioPath: slide.audioPath,
projectPath: projectPath,
autoplay: autoplayMedia && slide.audioAutoplay,
onComplete: onAudioComplete,
w: w,
),
],
),
),
);
},
);
}
Widget _buildContent(double w) {
switch (slide.type) {
case SlideType.title:
return _TitlePreview(
slide: slide,
w: w,
projectPath: projectPath,
font: fontFamily,
profile: themeProfile,
);
case SlideType.section:
return _SectionPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.bullets:
return _BulletsPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.twoBullets:
return _TwoBulletsPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.bulletsImage:
return _BulletsImagePreview(
slide: slide,
w: w,
projectPath: projectPath,
font: fontFamily,
profile: themeProfile,
);
case SlideType.twoImages:
return _TwoImagesPreview(
slide: slide,
w: w,
projectPath: projectPath,
font: fontFamily,
profile: themeProfile,
);
case SlideType.image:
return _ImagePreview(
slide: slide,
w: w,
projectPath: projectPath,
font: fontFamily,
profile: themeProfile,
);
case SlideType.video:
return _VideoPreview(
slide: slide,
w: w,
projectPath: projectPath,
font: fontFamily,
profile: themeProfile,
autoplay: autoplayMedia && slide.videoAutoplay,
onComplete: onVideoComplete,
);
case SlideType.quote:
return _QuotePreview(
slide: slide,
w: w,
font: fontFamily,
projectPath: projectPath,
profile: themeProfile,
);
case SlideType.table:
return _TablePreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.freeMarkdown:
return _MarkdownPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.code:
return _CodePreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.chart:
return _ChartPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
presentationMode: presentationMode,
);
}
}
}
String? _resolvePath(String path, String? projectPath) =>
resolveSlideAssetPath(path, projectPath);
/// Resolves an image/media path the way the slide renderer does, so callers
/// (e.g. the presenter, to precache) can point at the exact file that will be
/// displayed. Returns null for an empty path.
String? resolveSlideAssetPath(String path, String? projectPath) {
if (path.isEmpty) return null;
if (path.startsWith('/') || path.contains(':\\')) return path;
if (projectPath != null) return '$projectPath/$path';
return path;
}
/// Footer onderaan een slide: vrije tekst (links) + paginanummers (rechts),
/// op basis van het stijlprofiel. Verborgen op titel-/sectieslides (daar is
/// een footer ongebruikelijk en valt 'ie weg tegen de donkere achtergrond).
/// Linkermarge waar de inhoud (bullets/tekst) van een slide begint. Wordt
/// gebruikt om een links-uitgelijnde footer ermee te laten uitlijnen, zodat het
/// geheel consistenter oogt. Moet overeenkomen met de `pad`-waarden van de
/// afzonderlijke slide-renderers hierboven.
double _contentLeftInset(Slide slide, double w) {
switch (slide.type) {
case SlideType.bullets:
case SlideType.freeMarkdown:
return w * 0.07;
case SlideType.code:
return w * 0.05;
case SlideType.chart:
return w * 0.06;
case SlideType.twoBullets:
return w * 0.065;
case SlideType.table:
return w * 0.06;
case SlideType.bulletsImage:
return w * 0.038;
case SlideType.quote:
return w * 0.08;
default:
// Beeld/video: geen tekstmarge om mee uit te lijnen.
return w * 0.04;
}
}