From f5eb72ccf6c3a1d0667bd445eb5dcd1684d19fe1 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Mon, 1 Jun 2026 23:21:20 -0300 Subject: [PATCH] feat: add framework hooks for embedded SuperDeck hosts Framework changes that let SuperDeck slides be hosted inside a custom app (e.g. an external editor) instead of going through the CLI build pipeline. superdeck: - SlideParts.header/footer are now nullable so embedded hosts can omit chrome - SlideRenderView uses MixScope.inherit and SlideCaptureService re-applies SD color tokens so captures resolve theme tokens identically to live slides - DeckPresentationState/ThumbnailService expose deleteAllThumbnails for editor flows that need to invalidate the cache - Hero shuttle now re-applies the source slide's DefaultTextStyle so text doesn't resize at handoff builder: re-export comment/markdown/section parsers for downstream apps. demo/superdeck: bump google_fonts to ^8.1.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- demo/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 - demo/macos/Podfile.lock | 9 +- demo/pubspec.yaml | 2 +- demo/windows/flutter/generated_plugins.cmake | 1 + packages/builder/lib/superdeck_builder.dart | 4 + .../src/capture/slide_capture_service.dart | 11 ++- .../lib/src/deck/deck_presentation_state.dart | 16 ++++ .../lib/src/rendering/slides/slide_parts.dart | 10 +- .../rendering/slides/slide_render_view.dart | 2 +- .../lib/src/thumbnails/thumbnail_service.dart | 25 +++++ .../lib/src/ui/widgets/hero_element.dart | 16 +++- packages/superdeck/pubspec.yaml | 2 +- .../deck/deck_presentation_state_test.dart | 60 ++++++++++++ .../thumbnails/thumbnail_service_test.dart | 64 +++++++++++++ .../src/ui/widgets/hero_element_test.dart | 91 +++++++++++++++++++ 16 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 packages/superdeck/test/src/ui/widgets/hero_element_test.dart diff --git a/demo/linux/flutter/generated_plugins.cmake b/demo/linux/flutter/generated_plugins.cmake index 00303ac7..4ce8cfcf 100644 --- a/demo/linux/flutter/generated_plugins.cmake +++ b/demo/linux/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/demo/macos/Flutter/GeneratedPluginRegistrant.swift b/demo/macos/Flutter/GeneratedPluginRegistrant.swift index d452446b..cc954336 100644 --- a/demo/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/demo/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,12 @@ import FlutterMacOS import Foundation -import path_provider_foundation import screen_retriever_macos import sqflite_darwin import webview_flutter_wkwebview import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) diff --git a/demo/macos/Podfile.lock b/demo/macos/Podfile.lock index 81e22e5c..61cc4ff1 100644 --- a/demo/macos/Podfile.lock +++ b/demo/macos/Podfile.lock @@ -1,8 +1,5 @@ PODS: - FlutterMacOS (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS - sqflite_darwin (0.0.4): @@ -16,7 +13,6 @@ PODS: DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) @@ -25,8 +21,6 @@ DEPENDENCIES: EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos sqflite_darwin: @@ -38,10 +32,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 diff --git a/demo/pubspec.yaml b/demo/pubspec.yaml index 76725bcd..fd9f8960 100644 --- a/demo/pubspec.yaml +++ b/demo/pubspec.yaml @@ -8,7 +8,7 @@ environment: dependencies: flutter: sdk: flutter - google_fonts: ^6.3.2 + google_fonts: ^8.1.0 mesh: ^0.4.3 mix: ^2.0.3 superdeck_core: ^1.0.0 diff --git a/demo/windows/flutter/generated_plugins.cmake b/demo/windows/flutter/generated_plugins.cmake index 5e3bc3d8..e1de4890 100644 --- a/demo/windows/flutter/generated_plugins.cmake +++ b/demo/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/builder/lib/superdeck_builder.dart b/packages/builder/lib/superdeck_builder.dart index adb35a4c..04bf60bb 100644 --- a/packages/builder/lib/superdeck_builder.dart +++ b/packages/builder/lib/superdeck_builder.dart @@ -5,3 +5,7 @@ export 'package:superdeck_core/superdeck_core.dart' show DeckFormatException; export 'src/build/build_event.dart'; export 'src/build/deck_builder.dart'; + +export 'src/parsers/comment_parser.dart'; +export 'src/parsers/markdown_parser.dart'; +export 'src/parsers/section_parser.dart'; diff --git a/packages/superdeck/lib/src/capture/slide_capture_service.dart b/packages/superdeck/lib/src/capture/slide_capture_service.dart index 51d27fa4..27de83b3 100644 --- a/packages/superdeck/lib/src/capture/slide_capture_service.dart +++ b/packages/superdeck/lib/src/capture/slide_capture_service.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show MaterialApp, Scaffold, Theme; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; +import 'package:mix/mix.dart'; +import '../ui/tokens/colors.dart'; import '../ui/widgets/provider.dart'; import '../rendering/slides/slide_view.dart'; @@ -117,6 +119,7 @@ class SlideCaptureService { RenderConfig config, ) async { try { + final mixScope = MixScope.maybeOf(config.context); final child = InheritedTheme.captureAll( config.context, MediaQuery( @@ -124,7 +127,13 @@ class SlideCaptureService { child: MaterialApp( theme: Theme.of(config.context), debugShowCheckedModeBanner: false, - home: Scaffold(body: widget), + + home: Scaffold( + body: MixScope( + tokens: {...?mixScope?.tokens, ...SDColors.colorMap}, + child: widget, + ), + ), ), ), ); diff --git a/packages/superdeck/lib/src/deck/deck_presentation_state.dart b/packages/superdeck/lib/src/deck/deck_presentation_state.dart index 015ba304..616717e0 100644 --- a/packages/superdeck/lib/src/deck/deck_presentation_state.dart +++ b/packages/superdeck/lib/src/deck/deck_presentation_state.dart @@ -155,6 +155,22 @@ final class DeckPresentationState { return _thumbnails.value[slideKey]; } + /// Deletes every cached thumbnail (in-memory and persistent). + /// + /// After this completes, [getThumbnail] returns `null` for every slide + /// until [generateThumbnails] is called again. + Future deleteAllThumbnails() async { + if (_disposed) return; + await _thumbnailService.deleteAllThumbnails( + slides: _slides.value, + cache: _thumbnails.value, + onCacheUpdate: (updated) { + if (_disposed) return; + _thumbnails.value = updated; + }, + ); + } + void dispose() { _disposed = true; _indexClampEffect?.call(); diff --git a/packages/superdeck/lib/src/rendering/slides/slide_parts.dart b/packages/superdeck/lib/src/rendering/slides/slide_parts.dart index a8266ac7..163398fe 100644 --- a/packages/superdeck/lib/src/rendering/slides/slide_parts.dart +++ b/packages/superdeck/lib/src/rendering/slides/slide_parts.dart @@ -1,16 +1,14 @@ import 'package:flutter/widgets.dart'; import 'background.dart'; -import 'footer.dart'; -import 'header.dart'; class SlideParts { const SlideParts({ - this.header = const HeaderPart(), - this.footer = const FooterPart(), + this.header, + this.footer, this.background = const BackgroundPart(), }); - final PreferredSizeWidget header; - final PreferredSizeWidget footer; + final PreferredSizeWidget? header; + final PreferredSizeWidget? footer; final Widget background; } diff --git a/packages/superdeck/lib/src/rendering/slides/slide_render_view.dart b/packages/superdeck/lib/src/rendering/slides/slide_render_view.dart index cd98d38c..b6b4cac2 100644 --- a/packages/superdeck/lib/src/rendering/slides/slide_render_view.dart +++ b/packages/superdeck/lib/src/rendering/slides/slide_render_view.dart @@ -14,7 +14,7 @@ class SlideRenderView extends StatelessWidget { @override Widget build(BuildContext context) { - return MixScope( + return MixScope.inherit( colors: SDColors.colorMap, child: InheritedData( data: configuration, diff --git a/packages/superdeck/lib/src/thumbnails/thumbnail_service.dart b/packages/superdeck/lib/src/thumbnails/thumbnail_service.dart index 4372dd61..8cca7155 100644 --- a/packages/superdeck/lib/src/thumbnails/thumbnail_service.dart +++ b/packages/superdeck/lib/src/thumbnails/thumbnail_service.dart @@ -72,6 +72,31 @@ class ThumbnailService { onCacheUpdate(updatedCache); } + /// Deletes every cached thumbnail and clears the in-memory cache. + /// + /// Disposes every [AsyncThumbnail] in [cache] and removes the corresponding + /// entry from the asset cache store. Keys to delete are taken from both the + /// disposed [AsyncThumbnail]s (so orphan entries for removed slides are + /// still cleaned) and the current [slides] list. After completion, + /// [onCacheUpdate] is invoked with an empty map. + Future deleteAllThumbnails({ + required List slides, + required Map cache, + required void Function(Map) onCacheUpdate, + }) async { + final keysToDelete = { + for (final thumbnail in cache.values) thumbnail.thumbnailKey, + for (final slide in slides) slide.thumbnailKey, + }; + + for (final thumbnail in cache.values) { + thumbnail.dispose(); + } + onCacheUpdate({}); + + await Future.wait(keysToDelete.map(_cacheStore.delete)); + } + /// Generates a single thumbnail for a slide. /// /// Resolve order: diff --git a/packages/superdeck/lib/src/ui/widgets/hero_element.dart b/packages/superdeck/lib/src/ui/widgets/hero_element.dart index 74f3d323..a0be50fa 100644 --- a/packages/superdeck/lib/src/ui/widgets/hero_element.dart +++ b/packages/superdeck/lib/src/ui/widgets/hero_element.dart @@ -127,10 +127,22 @@ Widget buildElementHero({ final to = HeroElement.of(toHeroContext); final from = HeroElement.maybeOf(fromHeroContext) ?? to; + // The shuttle is built inside the Navigator's Overlay, which sits + // outside the route's Material and therefore exposes a different + // DefaultTextStyle than the slide. Text properties an element spec + // leaves unset (letterSpacing, leadingDistribution, ...) would + // otherwise resolve against the Overlay's bare default and make the + // text change size the instant the Hero hands off to/from the real + // widget. Re-apply the source slide's DefaultTextStyle so the + // shuttle resolves identically to the widget it stands in for. + final slideTextStyle = DefaultTextStyle.of(fromHeroContext).style; + return AnimatedBuilder( animation: animation, - builder: (context, _) => - buildFlight(context, from, to, animation.value), + builder: (context, _) => DefaultTextStyle.merge( + style: slideTextStyle, + child: buildFlight(context, from, to, animation.value), + ), ); }, ); diff --git a/packages/superdeck/pubspec.yaml b/packages/superdeck/pubspec.yaml index 1b9fd352..dd6fd046 100644 --- a/packages/superdeck/pubspec.yaml +++ b/packages/superdeck/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: web: ^1.1.0 webview_flutter: ^4.10.0 webview_flutter_web: ^0.2.3 - google_fonts: ^6.3.2 + google_fonts: ^8.1.0 meta: ^1.16.0 qr_flutter: ^4.1.0 signals: ^6.2.0 diff --git a/packages/superdeck/test/src/deck/deck_presentation_state_test.dart b/packages/superdeck/test/src/deck/deck_presentation_state_test.dart index a6a7a1a6..d79b407a 100644 --- a/packages/superdeck/test/src/deck/deck_presentation_state_test.dart +++ b/packages/superdeck/test/src/deck/deck_presentation_state_test.dart @@ -28,6 +28,7 @@ class _RecordingThumbnailService extends ThumbnailService { _RecordingThumbnailService() : super(cacheStore: NoopAssetCacheStore()); int callCount = 0; + int deleteAllCallCount = 0; final List> receivedCacheKeys = >[]; final Map trackedThumbnails = {}; @@ -61,6 +62,19 @@ class _RecordingThumbnailService extends ThumbnailService { onCacheUpdate(updatedCache); } + + @override + Future deleteAllThumbnails({ + required List slides, + required Map cache, + required void Function(Map) onCacheUpdate, + }) async { + deleteAllCallCount++; + for (final thumbnail in cache.values) { + thumbnail.dispose(); + } + onCacheUpdate({}); + } } Future _pumpContext(WidgetTester tester) async { @@ -100,6 +114,52 @@ void main() { expect(service.receivedCacheKeys.last, equals({'slide-0'})); }); + testWidgets('deleteAllThumbnails disposes cache and clears getThumbnail', ( + tester, + ) async { + final slides = signal>(createTestSlides(2)); + addTearDown(slides.dispose); + + final service = _RecordingThumbnailService(); + final state = DeckPresentationState( + thumbnailService: service, + slides: slides, + transitionDuration: Duration.zero, + ); + addTearDown(state.dispose); + + final context = await _pumpContext(tester); + state.generateThumbnails(context, slides.value); + final slide0 = service.trackedThumbnails['slide-0']!; + final slide1 = service.trackedThumbnails['slide-1']!; + + await state.deleteAllThumbnails(); + + expect(service.deleteAllCallCount, 1); + expect(state.getThumbnail('slide-0'), isNull); + expect(state.getThumbnail('slide-1'), isNull); + expect(slide0.disposed, isTrue); + expect(slide1.disposed, isTrue); + }); + + testWidgets('deleteAllThumbnails is a no-op after dispose', (tester) async { + final slides = signal>(createTestSlides(1)); + addTearDown(slides.dispose); + + final service = _RecordingThumbnailService(); + final state = DeckPresentationState( + thumbnailService: service, + slides: slides, + transitionDuration: Duration.zero, + ); + + state.dispose(); + await state.deleteAllThumbnails(); + + expect(service.deleteAllCallCount, 0); + expect(tester.takeException(), isNull); + }); + testWidgets('dispose tears down thumbnail cache and blocks later updates', ( tester, ) async { diff --git a/packages/superdeck/test/src/thumbnails/thumbnail_service_test.dart b/packages/superdeck/test/src/thumbnails/thumbnail_service_test.dart index 15cca3ea..4444f77d 100644 --- a/packages/superdeck/test/src/thumbnails/thumbnail_service_test.dart +++ b/packages/superdeck/test/src/thumbnails/thumbnail_service_test.dart @@ -176,6 +176,70 @@ void main() { ); }); + test('deleteAllThumbnails clears cache and store entries', () async { + final store = _FakeAssetCacheStore(); + final capture = FakeSlideCaptureService(Uint8List.fromList([1, 2, 3])); + final service = ThumbnailService( + cacheStore: store, + slideCaptureService: capture, + ); + final introThumbnail = AsyncThumbnail( + thumbnailKey: _thumbnailKey('intro'), + generator: (ctx, {required force}) async => null, + ); + final agendaThumbnail = AsyncThumbnail( + thumbnailKey: _thumbnailKey('agenda'), + generator: (ctx, {required force}) async => null, + ); + + Map? updatedCache; + await service.deleteAllThumbnails( + slides: [_createSlide('intro'), _createSlide('agenda')], + cache: {'intro': introThumbnail, 'agenda': agendaThumbnail}, + onCacheUpdate: (cache) { + updatedCache = cache; + }, + ); + + expect(updatedCache, isNotNull); + expect(updatedCache, isEmpty); + expect( + store.callOrder, + containsAll([ + 'delete:${_thumbnailKey('intro')}', + 'delete:${_thumbnailKey('agenda')}', + ]), + ); + }); + + test('deleteAllThumbnails also removes orphan keys from cache', () async { + final store = _FakeAssetCacheStore(); + final capture = FakeSlideCaptureService(Uint8List.fromList([1, 2, 3])); + final service = ThumbnailService( + cacheStore: store, + slideCaptureService: capture, + ); + // 'orphan' is in the cache but no longer in the slides list. + final orphan = AsyncThumbnail( + thumbnailKey: _thumbnailKey('orphan'), + generator: (ctx, {required force}) async => null, + ); + + await service.deleteAllThumbnails( + slides: [_createSlide('intro')], + cache: {'orphan': orphan}, + onCacheUpdate: (_) {}, + ); + + expect( + store.callOrder, + containsAll([ + 'delete:${_thumbnailKey('orphan')}', + 'delete:${_thumbnailKey('intro')}', + ]), + ); + }); + testWidgets('replaces cached async thumbnail when thumbnail key changes', ( tester, ) async { diff --git a/packages/superdeck/test/src/ui/widgets/hero_element_test.dart b/packages/superdeck/test/src/ui/widgets/hero_element_test.dart new file mode 100644 index 00000000..cbcc4f1b --- /dev/null +++ b/packages/superdeck/test/src/ui/widgets/hero_element_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/src/ui/widgets/hero_element.dart'; + +/// Regression coverage for the "text size pops at the start/end of a hero +/// transition" bug. +/// +/// `buildElementHero` renders the flight shuttle inside the Navigator's +/// `Overlay`, which sits outside the route's `Material` and so exposes a +/// different `DefaultTextStyle` than the slide. Text properties an element +/// spec leaves unset (`letterSpacing`, `leadingDistribution`, ...) resolve +/// against that ambient style, so the shuttle used to render the text at a +/// different size than the real widget — a visible pop at the flight handoff. +void main() { + // A heading-like style (mirrors default_style.dart): sets the obvious + // properties but leaves letterSpacing / leadingDistribution unset so they + // are inherited from the ambient DefaultTextStyle. + const headingStyle = TextStyle( + fontSize: 64, + fontWeight: FontWeight.bold, + height: 1.2, + ); + const text = 'Heading Sample'; + const routeKey = ValueKey('route-text'); + const shuttleKey = ValueKey('shuttle-text'); + + Widget heroWidget() => HeroElement( + data: text, + child: buildElementHero( + tag: 'h', + // Mirrors StyledText -> Text(text, style: spec.style). + child: const Text(text, style: headingStyle, key: routeKey), + buildFlight: (context, from, to, t) { + // Mirrors TextElementBuilder._buildStableFlight at a flight endpoint: + // a bare Text.rich with no `style:` argument. + return const Text.rich( + TextSpan(style: headingStyle, children: [TextSpan(text: text)]), + key: shuttleKey, + ); + }, + ), + ); + + testWidgets( + 'flight shuttle text renders at the same size as the route text', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Center(child: heroWidget())), + routes: { + '/next': (_) => Scaffold(body: Center(child: heroWidget())), + }, + ), + ); + + final routePara = tester.renderObject( + find.descendant( + of: find.byKey(routeKey), + matching: find.byType(RichText), + ), + ); + final routeWidth = routePara.getMaxIntrinsicWidth(double.infinity); + final routeHeight = routePara.getMinIntrinsicHeight(double.infinity); + + tester.state(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); // build the flight + await tester.pump(const Duration(milliseconds: 1)); // first flight frame + + final shuttlePara = tester.renderObject( + find.descendant( + of: find.byKey(shuttleKey), + matching: find.byType(RichText), + ), + ); + final shuttleWidth = shuttlePara.getMaxIntrinsicWidth(double.infinity); + final shuttleHeight = shuttlePara.getMinIntrinsicHeight(double.infinity); + + expect( + shuttleWidth, + routeWidth, + reason: 'shuttle text width must match the route text (no handoff pop)', + ); + expect( + shuttleHeight, + routeHeight, + reason: 'shuttle text height must match the route text', + ); + }, + ); +}