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>
246 lines
7.7 KiB
Dart
246 lines
7.7 KiB
Dart
// 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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|