Ocideck/lib/widgets/slides/previews/overlays.dart
Brenno de Winter 6b2ba4df89 Split slide_preview.dart and app_shell.dart into part files (#1)
Break the two largest widget files into part/part-of libraries grouped by
concern, with no public API or behaviour change (private widgets keep working
because parts share the library namespace; all imports stay in the main file).

  slide_preview.dart  4748 -> 426 lines + slides/previews/{text,bullets,
                      checklist,table,media,code,chart,overlays}.dart
  app_shell.dart      1930 -> 996 lines + shell/{shell_actions,tab_bar,
                      welcome_screen,status_bar,shell_overlays}.dart

fullscreen_presenter.dart is intentionally left as-is: ~1.6k of its lines are a
single interactive _FullscreenPresenterState (38 setState calls), which a
mechanical split cannot reduce and extensions can't host (protected setState).
Shrinking it needs a behaviour-affecting sub-widget extraction, tracked
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:49 +02:00

246 lines
7.7 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Part of the slide_preview library — see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _LogoOverlay extends StatelessWidget {
final String logoPath;
final String? projectPath;
final String position;
final double size;
const _LogoOverlay({
required this.logoPath,
required this.projectPath,
required this.position,
required this.size,
});
@override
Widget build(BuildContext context) {
final horizontalInset = size * 0.28;
final topInset = size * 0.42;
final bottomInset = size * 0.12;
return Positioned(
top: position.startsWith('top') ? topInset : null,
bottom: position.startsWith('bottom') ? bottomInset : null,
left: position.endsWith('left') ? horizontalInset : null,
right: position.endsWith('right') ? horizontalInset : null,
child: SizedBox(
width: size,
height: size,
child: _resolvedImage(
context,
logoPath,
projectPath,
fit: BoxFit.contain,
),
),
);
}
}
// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ──────
const double _kTlpFont = 0.018; // × slidebreedte
const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
const double _kTlpHPad = 0.011;
const double _kTlpVPad = 0.005;
double _tlpBottomInset(double w) => w * 0.022;
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
double _tlpVerticalReserve(double w) =>
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
class _TlpOverlay extends StatelessWidget {
final TlpLevel tlp;
final double w;
final ThemeProfile profile;
final bool hasLogo;
const _TlpOverlay({
required this.tlp,
required this.w,
required this.profile,
required this.hasLogo,
});
@override
Widget build(BuildContext context) {
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
return Positioned(
bottom: _tlpBottomInset(w),
left: toLeft ? w * _kTlpEdge : null,
right: toLeft ? null : w * _kTlpEdge,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: w * _kTlpHPad,
vertical: w * _kTlpVPad,
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(w * 0.005),
),
child: Text(
tlp.label,
style: TextStyle(
color: Color(tlp.foreground),
fontSize: w * _kTlpFont,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
height: 1.0,
),
),
),
);
}
}
class _FooterOverlay extends StatelessWidget {
final Slide slide;
final double w;
final ThemeProfile profile;
final int? slideNumber;
final int? slideCount;
final TlpLevel tlp;
const _FooterOverlay({
required this.slide,
required this.w,
required this.profile,
this.slideNumber,
this.slideCount,
this.tlp = TlpLevel.none,
});
String _applyTokens(String s) {
final now = DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
final date = '${two(now.day)}-${two(now.month)}-${now.year}';
return s
.replaceAll('{page}', slideNumber?.toString() ?? '')
.replaceAll('{total}', slideCount?.toString() ?? '')
.replaceAll('{date}', date)
.replaceAll('{title}', slide.title);
}
@override
Widget build(BuildContext context) {
if (!slide.showFooter) return const SizedBox.shrink();
if (slide.type == SlideType.title || slide.type == SlideType.section) {
return const SizedBox.shrink();
}
final footerText = _applyTokens(profile.footerText).trim();
final showPages = profile.footerShowPageNumbers && slideNumber != null;
if (footerText.isEmpty && !showPages) return const SizedBox.shrink();
// Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt.
final fontSize = w * 0.0145;
final style = TextStyle(
color: _hexColor(profile.textColor).withValues(alpha: 0.7),
fontSize: fontSize,
// Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft.
shadows: [
Shadow(
color: Colors.white.withValues(alpha: 0.5),
blurRadius: w * 0.003,
),
],
);
// Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en
// de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen.
double mx(double a, double b) => a > b ? a : b;
final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo;
final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom');
final logoOnLeft = profile.logoPosition.endsWith('left');
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
final tlpSpan = tlp == TlpLevel.none
? 0.0
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
final footerLeftAligned = profile.footerPosition == 'left';
// Links uitgelijnd begint de footer waar het logo of de bullets beginnen,
// voor een consistente linkermarge. Anders de standaardmarge.
var left = footerLeftAligned
? (logoBottom && logoOnLeft
? logoLeftEdge
: _contentLeftInset(slide, w))
: w * 0.04;
var right = w * 0.04;
if (logoBottom) {
if (logoOnLeft) {
// Een links-uitgelijnde footer mag bewust met de logo-linkerkant
// uitlijnen; anders schuift 'ie rechts van het logo om overlap te
// voorkomen.
if (!footerLeftAligned) left = mx(left, logoSpan);
} else {
right = mx(right, logoSpan);
}
}
if (tlp != TlpLevel.none) {
if (tlpOnRight) {
right = mx(right, tlpSpan);
} else {
left = mx(left, tlpSpan);
}
}
final alignment = switch (profile.footerPosition) {
'left' => Alignment.centerLeft,
'center' => Alignment.center,
_ => Alignment.centerRight,
};
final textAlign = switch (profile.footerPosition) {
'left' => TextAlign.left,
'center' => TextAlign.center,
_ => TextAlign.right,
};
return Positioned(
left: left,
right: right,
bottom: w * 0.02,
child: Align(
alignment: alignment,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: w - left - right),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (footerText.isNotEmpty)
Flexible(
child: Text(
footerText,
style: style,
textAlign: textAlign,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02),
if (showPages)
Text(
'$slideNumber / ${slideCount ?? slideNumber}',
style: style,
),
],
),
),
),
);
}
}