From ea20c71b23ca3723b6795d1331a2d0db453550ef Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 22 Sep 2026 20:44:46 -0500 Subject: [PATCH 1/5] fix(playground): make MemoryDeckLoader.reload re-emit the last result SuperDeck's retry screen calls reload(), and the in-memory loader ignored it, so a session waiting on the reload could stay loading. Reload now repeats the last decoded slides or error, or reports that no markdown was supplied. --- .../data/data_sources/memory_deck_loader.dart | 49 +++++-- .../data_sources/memory_deck_loader_test.dart | 120 ++++++++++++++++++ 2 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 packages/playground/test/core/data/data_sources/memory_deck_loader_test.dart diff --git a/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart b/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart index 0a556d5e..fabd849d 100644 --- a/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart +++ b/packages/playground/lib/core/data/data_sources/memory_deck_loader.dart @@ -11,27 +11,60 @@ class MemoryDeckLoader extends DeckLoader { final _controller = StreamController.broadcast(); bool _disposed = false; + bool _hasMarkdown = false; String? _lastLoadedMarkdown; + List? _loadedSlides; + Object? _loadError; + + void _emitDecoded(String markdown) { + try { + final slides = _codec.decode(markdown); + _loadedSlides = slides; + _loadError = null; + _controller.add(SlidesLoadedEvent(slides)); + } catch (error) { + _loadedSlides = null; + _loadError = error; + _controller.add(SlidesErrorEvent('$error', error: error)); + } + } /// Parses the given markdown and emits a [SlidesLoadedEvent]. void updateMarkdown(String markdown) { if (_disposed || markdown == _lastLoadedMarkdown) return; _lastLoadedMarkdown = markdown; + _hasMarkdown = true; + _emitDecoded(markdown); + } - try { - final slides = _codec.decode(markdown); - _controller.add(SlidesLoadedEvent(slides)); - } catch (e) { - _controller.add(SlidesErrorEvent('$e', error: e)); + /// Re-emits the last terminal preview result. + /// + /// Identical text on [updateMarkdown] stays quiet; a reload repeats the + /// result so a session waiting on it can leave loading. + @override + Future reload() async { + if (_disposed) return; + if (!_hasMarkdown) { + _controller.add(SlidesErrorEvent('No markdown has been supplied.')); + await Future.delayed(Duration.zero); + + return; } + final error = _loadError; + if (error != null) { + _controller.add(SlidesErrorEvent('$error', error: error)); + } else { + _controller.add(SlidesLoadedEvent(_loadedSlides ?? const [])); + } + // Broadcast delivery is a later microtask. Stay incomplete until that + // event has reached listeners, including a session waiting to leave + // loading. + await Future.delayed(Duration.zero); } @override Stream load() => _controller.stream; - @override - Future reload() async {} - @override Future dispose() async { _disposed = true; diff --git a/packages/playground/test/core/data/data_sources/memory_deck_loader_test.dart b/packages/playground/test/core/data/data_sources/memory_deck_loader_test.dart new file mode 100644 index 00000000..9b74d273 --- /dev/null +++ b/packages/playground/test/core/data/data_sources/memory_deck_loader_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart'; +import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +const _markdown = ''' +--- +title: Loaded +--- + +A deck that already decoded. +'''; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + test('reload re-emits unchanged slides before it completes', () async { + final harness = _harness(); + harness.loader.updateMarkdown(_markdown); + await Future.delayed(Duration.zero); + expect(harness.deck.session.isLoading.value, isFalse); + final title = + harness.deck.session.loadedSlides.value?.single.options?.title; + expect(title, 'Loaded'); + + final order = await _reload(harness.loader, harness.deck); + + expect(order.eventBeforeComplete, isTrue); + expect(order.event, isA()); + expect(harness.deck.session.isLoading.value, isFalse); + expect( + harness.deck.session.loadedSlides.value?.single.options?.title, + title, + ); + expect(harness.deck.session.error.value, isNull); + }); + + test( + 'reload re-emits a terminal error for markdown that failed to decode', + () async { + final harness = _harness(); + harness.loader.updateMarkdown('@column\n'); + await Future.delayed(Duration.zero); + expect(harness.deck.session.loadedSlides.value, isNull); + expect(harness.deck.session.isLoading.value, isFalse); + expect(harness.deck.session.error.value, isNotNull); + + final order = await _reload(harness.loader, harness.deck); + + expect(order.eventBeforeComplete, isTrue); + expect(order.event, isA()); + expect(harness.deck.session.isLoading.value, isFalse); + expect(harness.deck.session.loadedSlides.value, isNull); + expect(harness.deck.session.error.value, isNotNull); + }, + ); + + test( + 'reload without markdown emits a terminal error and clears loading', + () async { + final harness = _harness(); + expect(harness.deck.session.loadedSlides.value, isNull); + + final order = await _reload(harness.loader, harness.deck); + + expect(order.eventBeforeComplete, isTrue); + expect(order.event, isA()); + expect(harness.deck.session.isLoading.value, isFalse); + expect(harness.deck.session.loadedSlides.value, isNull); + expect(harness.deck.session.error.value, isNotNull); + }, + ); + + test('an ordinary identical update emits no new preview event', () async { + final harness = _harness(); + harness.loader.updateMarkdown(_markdown); + await Future.delayed(Duration.zero); + final events = []; + final subscription = harness.loader.load().listen(events.add); + addTearDown(subscription.cancel); + + harness.loader.updateMarkdown(_markdown); + await Future.delayed(Duration.zero); + + expect(events, isEmpty); + }); +} + +({MemoryDeckLoader loader, DeckController deck}) _harness() { + final loader = MemoryDeckLoader(); + final deck = DeckController( + deckLoader: loader, + options: DeckOptions(), + assetCacheStore: MemoryAssetCacheStore(), + ); + addTearDown(deck.dispose); + + return (loader: loader, deck: deck); +} + +Future<({bool eventBeforeComplete, SlidesEvent? event})> _reload( + MemoryDeckLoader loader, + DeckController deck, +) async { + var completed = false; + SlidesEvent? event; + var eventBeforeComplete = false; + final subscription = loader.load().listen((next) { + event = next; + eventBeforeComplete = !completed; + }); + addTearDown(subscription.cancel); + + await deck.session.reload().whenComplete(() => completed = true); + + return (eventBeforeComplete: eventBeforeComplete, event: event); +} From 9a432ad0d950dcd6738857925b2dca5d1c9c7320 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 22 Sep 2026 20:46:24 -0500 Subject: [PATCH 2/5] feat(playground): build a generated deck as a SuperDeck project export DeckExport lays out slides.md and its generated artwork under assets/, rewriting each bare asset key to a relative path any SuperDeck project can load, and encodes the result as a zip. --- .../features/export/domain/deck_export.dart | 79 ++++++++++++ packages/playground/pubspec.yaml | 1 + .../export/domain/deck_export_test.dart | 114 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 packages/playground/lib/features/export/domain/deck_export.dart create mode 100644 packages/playground/test/features/export/domain/deck_export_test.dart diff --git a/packages/playground/lib/features/export/domain/deck_export.dart b/packages/playground/lib/features/export/domain/deck_export.dart new file mode 100644 index 00000000..7804cc32 --- /dev/null +++ b/packages/playground/lib/features/export/domain/deck_export.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:superdeck_builder/superdeck_builder.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +import '../../../core/domain/generated_image_asset.dart'; + +/// A generated deck laid out as a SuperDeck project: `slides.md` and its +/// artwork under `assets/`. +/// +/// Generated slides name their images by bare asset key, which only this +/// app's in-memory store resolves. The export rewrites each one to +/// `assets/`, a relative path any SuperDeck project loads. +final class DeckExport { + static const _markdownPath = 'slides.md'; + static const _assetsFolder = 'assets'; + static const _fallbackName = 'SuperDeck deck'; + + /// Suggested file name for the export, without an extension. + final String name; + + /// File contents by path relative to the project root. + final Map> files; + + const DeckExport._({required this.name, required this.files}); + + /// Builds the export for [slides] and the [images] generated for them. + /// + /// Images without bytes are left out; their slides no longer reference + /// them. + factory DeckExport.fromDeck({ + required List slides, + required List images, + }) { + var markdown = const SlideSerializer().serialize(slides); + final assets = >{}; + for (final image in images) { + final bytes = image.bytes; + if (bytes == null || bytes.isEmpty) continue; + final key = AssetCacheStore.validateAssetKey(image.assetKey); + final path = '$_assetsFolder/$key'; + markdown = markdown.replaceAll( + RegExp('(? slides) { + final title = slides.isEmpty ? null : slides.first.options?.title; + final stem = (title ?? '') + .replaceAll(RegExp(r'[\x00-\x1f/\\:]'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim() + .replaceAll(RegExp(r'^\.+'), '') + .trim(); + + return stem.isEmpty ? _fallbackName : stem; + } + + /// Encodes [files] as a zip archive. + Uint8List toZip() { + final archive = Archive(); + for (final MapEntry(key: path, value: bytes) in files.entries) { + archive.addFile(ArchiveFile.bytes(path, bytes)); + } + + return ZipEncoder().encodeBytes(archive); + } +} diff --git a/packages/playground/pubspec.yaml b/packages/playground/pubspec.yaml index 05dd4ab6..e5192877 100644 --- a/packages/playground/pubspec.yaml +++ b/packages/playground/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: json_schema_builder: ^0.1.5 # Filesystem persistence for saved decks (desktop-only). path_provider: ^2.1.4 + archive: ^4.0.9 dev_dependencies: flutter_test: diff --git a/packages/playground/test/features/export/domain/deck_export_test.dart b/packages/playground/test/features/export/domain/deck_export_test.dart new file mode 100644 index 00000000..7819b0fa --- /dev/null +++ b/packages/playground/test/features/export/domain/deck_export_test.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/data/mappers/deck_markdown_codec.dart'; +import 'package:playground/core/domain/generated_image_asset.dart'; +import 'package:playground/features/export/domain/deck_export.dart'; + +const _heroKey = 'wizard-run-slide-01-hero.png'; +const _chartKey = 'wizard-run-slide-02-chart.png'; + +const _markdown = + ''' +--- +title: Urban gardens +--- + +# Urban gardens + +![Hero]($_heroKey) + +--- + +@image { + src: $_chartKey +} +'''; + +final _heroBytes = [1, 2, 3]; +final _chartBytes = [4, 5, 6]; + +void main() { + final slides = const DeckMarkdownCodec().decode(_markdown); + + group('DeckExport.fromDeck', () { + test('writes slides.md with every image under assets/', () { + final export = DeckExport.fromDeck( + slides: slides, + images: [ + GeneratedImageAsset.success(assetKey: _heroKey, bytes: _heroBytes), + GeneratedImageAsset.success(assetKey: _chartKey, bytes: _chartBytes), + ], + ); + + final markdown = utf8.decode(export.files['slides.md']!); + expect(markdown, contains('](assets/$_heroKey)')); + expect(markdown, contains('src: assets/$_chartKey')); + expect(markdown, isNot(contains(']($_heroKey)'))); + expect(export.files['assets/$_heroKey'], _heroBytes); + expect(export.files['assets/$_chartKey'], _chartBytes); + expect(export.files.keys, hasLength(3)); + }); + + test('leaves out images that have no bytes', () { + final export = DeckExport.fromDeck( + slides: slides, + images: [ + GeneratedImageAsset.success(assetKey: _heroKey, bytes: _heroBytes), + const GeneratedImageAsset.failure( + assetKey: _chartKey, + error: 'timeout', + ), + ], + ); + + expect(export.files.keys, ['slides.md', 'assets/$_heroKey']); + final markdown = utf8.decode(export.files['slides.md']!); + expect(markdown, isNot(contains('assets/$_chartKey'))); + }); + + test('names the export after the first slide title', () { + final export = DeckExport.fromDeck(slides: slides, images: const []); + + expect(export.name, 'Urban gardens'); + }); + + test('falls back to a generic name when no title is usable', () { + final untitled = const DeckMarkdownCodec().decode('# Only a heading\n'); + final slashed = const DeckMarkdownCodec().decode( + '---\ntitle: "../ops/plan: v2"\n---\n\nBody\n', + ); + + expect( + DeckExport.fromDeck(slides: untitled, images: const []).name, + 'SuperDeck deck', + ); + expect( + DeckExport.fromDeck(slides: slashed, images: const []).name, + 'ops plan v2', + ); + }); + }); + + test('toZip holds exactly the export files', () { + final export = DeckExport.fromDeck( + slides: slides, + images: [ + GeneratedImageAsset.success(assetKey: _heroKey, bytes: _heroBytes), + ], + ); + + final archive = ZipDecoder().decodeBytes(export.toZip()); + + expect(archive.files.map((file) => file.name), [ + 'slides.md', + 'assets/$_heroKey', + ]); + expect(archive.findFile('assets/$_heroKey')!.content, _heroBytes); + expect( + utf8.decode(archive.findFile('slides.md')!.content), + utf8.decode(export.files['slides.md']!), + ); + }); +} From 48c2306f470d42d912719d62e2c7c0b8b5092b5e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 22 Sep 2026 20:51:48 -0500 Subject: [PATCH 3/5] refactor(playground)!: replace the saved-deck library with deck export The Wizard now exports the deck it generated as a zip (slides.md plus assets/) through the platform save dialog, instead of saving into a remembered SuperDeck folder that the app lists and reopens. Removed with the library: the saved-decks page and route, DeckLibraryController, MacOsDeckLibrary and its manifest, the saved-deck artwork binding, security-scoped bookmarks on the Dart and Swift sides with their entitlement, Result, and DeckDocumentStore, whose only reader was the library save. Generated artwork now resolves from the run's in-memory store alone. --- packages/playground/dcm_baseline.json | 27 -- packages/playground/lib/app/providers.dart | 38 +- packages/playground/lib/app/router.dart | 7 +- .../lib/core/data/data_sources/deck_file.dart | 54 --- .../deck_library_asset_store.dart | 56 --- .../security_scoped_file_access.dart | 96 ---- .../domain/stores/deck_document_store.dart | 22 - packages/playground/lib/core/result.dart | 42 -- .../domain/generated_deck_result_applier.dart | 10 +- .../wizard_generation_status.dart | 35 +- .../ai/wizard/presentation/wizard_page.dart | 161 +++--- .../export/data/deck_export_saver.dart | 20 + .../library/data/mac_os_deck_library.dart | 459 ------------------ .../features/library/domain/deck_library.dart | 70 --- .../domain/deck_library_controller.dart | 180 ------- .../features/library/domain/saved_deck.dart | 166 ------- .../presentation/save_deck_dialog.dart | 91 ---- .../presentation/saved_decks_page.dart | 148 ------ .../lib/features/library/routes/routes.dart | 8 - .../presentation/pages/presentation_page.dart | 2 +- .../features/presentation/routes/routes.dart | 2 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../macos/Runner.xcodeproj/project.pbxproj | 4 - .../macos/Runner/DebugProfile.entitlements | 2 - .../macos/Runner/MainFlutterWindow.swift | 12 - .../macos/Runner/Release.entitlements | 2 - .../SecurityScopedBookmarkManager.swift | 123 ----- packages/playground/pubspec.yaml | 8 +- .../security_scoped_file_access_test.dart | 73 --- .../stores/deck_document_store_test.dart | 34 -- .../generated_deck_result_applier_test.dart | 85 ++-- ...test.dart => wizard_export_flow_test.dart} | 184 +++---- .../wizard/presentation/wizard_page_test.dart | 36 -- .../data/mac_os_deck_library_test.dart | 298 ------------ .../library/deck_library_durability_test.dart | 219 --------- .../domain/deck_library_controller_test.dart | 225 --------- .../presentation/saved_decks_page_test.dart | 112 ----- .../test/helpers/fake_deck_library.dart | 112 ----- .../fake_security_scoped_file_access.dart | 53 -- 39 files changed, 199 insertions(+), 3079 deletions(-) delete mode 100644 packages/playground/lib/core/data/data_sources/deck_file.dart delete mode 100644 packages/playground/lib/core/data/data_sources/deck_library_asset_store.dart delete mode 100644 packages/playground/lib/core/data/data_sources/security_scoped_file_access.dart delete mode 100644 packages/playground/lib/core/domain/stores/deck_document_store.dart delete mode 100644 packages/playground/lib/core/result.dart create mode 100644 packages/playground/lib/features/export/data/deck_export_saver.dart delete mode 100644 packages/playground/lib/features/library/data/mac_os_deck_library.dart delete mode 100644 packages/playground/lib/features/library/domain/deck_library.dart delete mode 100644 packages/playground/lib/features/library/domain/deck_library_controller.dart delete mode 100644 packages/playground/lib/features/library/domain/saved_deck.dart delete mode 100644 packages/playground/lib/features/library/presentation/save_deck_dialog.dart delete mode 100644 packages/playground/lib/features/library/presentation/saved_decks_page.dart delete mode 100644 packages/playground/lib/features/library/routes/routes.dart delete mode 100644 packages/playground/macos/Runner/SecurityScopedBookmarkManager.swift delete mode 100644 packages/playground/test/core/data/data_sources/security_scoped_file_access_test.dart delete mode 100644 packages/playground/test/core/domain/stores/deck_document_store_test.dart rename packages/playground/test/features/ai/wizard/presentation/{wizard_save_flow_test.dart => wizard_export_flow_test.dart} (53%) delete mode 100644 packages/playground/test/features/library/data/mac_os_deck_library_test.dart delete mode 100644 packages/playground/test/features/library/deck_library_durability_test.dart delete mode 100644 packages/playground/test/features/library/domain/deck_library_controller_test.dart delete mode 100644 packages/playground/test/features/library/presentation/saved_decks_page_test.dart delete mode 100644 packages/playground/test/helpers/fake_deck_library.dart delete mode 100644 packages/playground/test/helpers/fake_security_scoped_file_access.dart diff --git a/packages/playground/dcm_baseline.json b/packages/playground/dcm_baseline.json index 10befd58..d76ecbc8 100644 --- a/packages/playground/dcm_baseline.json +++ b/packages/playground/dcm_baseline.json @@ -33,22 +33,6 @@ "99914b932bd37a50b983c5e7c90ae93b" ] }, - "lib/core/data/data_sources/security_scoped_file_access.dart": { - "member-ordering": [ - "be8c26dcc636a143b78001de86957301", - "4d906b08741be42f24bff407f0b17938", - "1430c4571c14f58ecac407876fdacbe2", - "74d2d4912fc7084d7933cf07e59a738d", - "ce91d284738a04ce3ae951f7c61efa8d" - ], - "newline-before-return": [ - "a7b69a9b65d6bb7620e797ff51b1f2a2", - "c9db1f09a26ca1c96a59cea77cf29a8e", - "004d60a501680fe4a801c1f382091a6c", - "9d1bed3be3f428ef16a7bc10fb4f04d1", - "eefb853f7fffca0c5b6d150b8eaa69df" - ] - }, "lib/core/domain/design/generated_deck_theme_factory.dart": { "prefer-shorthands-with-enums": [ "f3d70c6aa77da74309195ab874fff66d", @@ -121,12 +105,6 @@ "959b7175f88493905e3c324c2bf6f97d" ] }, - "lib/core/result.dart": { - "member-ordering": [ - "85eca8e6b45775b517ebcc6aab8021d0", - "00c8c3a7775b4bd83b4c7627c1029a39" - ] - }, "lib/features/ai/generation/core/debug_logger.dart": { "member-ordering": [ "c9e8d09211e0b5eaac505b8ea7fb89e6" @@ -845,11 +823,6 @@ "7619ac479ac9f2834cfe7d877347194d" ] }, - "lib/features/ai/wizard/core/ui/widgets/header.dart": { - "arguments-ordering": [ - "801979137471c1e5fb1750d69c099bcf" - ] - }, "lib/features/ai/wizard/core/utils/color_utils.dart": { "newline-before-return": [ "ac84b6615d6391fc8cb6a100c0b2aad0", diff --git a/packages/playground/lib/app/providers.dart b/packages/playground/lib/app/providers.dart index d33f95cb..3a41e1dd 100644 --- a/packages/playground/lib/app/providers.dart +++ b/packages/playground/lib/app/providers.dart @@ -3,14 +3,9 @@ import 'package:hero_ui/hero_ui.dart'; import 'package:provider/provider.dart'; import 'package:superdeck/superdeck.dart'; -import '../core/data/data_sources/deck_library_asset_store.dart'; import '../core/data/data_sources/memory_asset_cache_store.dart'; import '../core/data/data_sources/memory_deck_loader.dart'; import '../core/domain/stores/deck_customization_store.dart'; -import '../core/domain/stores/deck_document_store.dart'; -import '../features/library/data/mac_os_deck_library.dart'; -import '../features/library/domain/deck_library.dart'; -import '../features/library/domain/deck_library_controller.dart'; const _debugDeckLayout = bool.fromEnvironment('SUPERDECK_DEBUG_LAYOUT'); @@ -25,13 +20,10 @@ const _debugDeckLayout = bool.fromEnvironment('SUPERDECK_DEBUG_LAYOUT'); /// `PresentationPage` hands `DeckController` straight to SuperDeck's own /// `DeckPresenter`, which reads slides internally — no bridge store. class AppProviders extends StatelessWidget { - const AppProviders({required this.child, this.deckLibrary, super.key}); + const AppProviders({required this.child, super.key}); final Widget child; - /// Overrides the macOS deck library in tests and on other platforms. - final DeckLibrary? deckLibrary; - @override Widget build(BuildContext context) { // Resolve theme tokens here (listening is allowed in build) rather than @@ -46,29 +38,16 @@ class AppProviders extends StatelessWidget { dispose: (_, loader) => loader.dispose(), ), Provider(create: (_) => MemoryAssetCacheStore()), - ChangeNotifierProvider(create: (_) => DeckDocumentStore(markdown: '')), - Provider( - create: (_) => deckLibrary ?? MacOsDeckLibrary(), - dispose: (_, library) => library.dispose(), - ), - Provider( - create: (ctx) => DeckLibraryAssetStore( - library: ctx.read(), - // Typed explicitly: generated artwork has to land in the store the - // thumbnails read, not in any store that happens to be provided. - fallback: ctx.read(), - ), - ), Provider( create: (ctx) => DeckController( deckLoader: ctx.read(), options: DeckOptions(debug: _debugDeckLayout), - // Slide artwork follows the open deck; thumbnails are derived - // from it and stay in memory. + // Generated artwork and the thumbnails derived from it both live + // in memory for the run. thumbnailService: ThumbnailService( cacheStore: ctx.read(), ), - assetCacheStore: ctx.read(), + assetCacheStore: ctx.read(), ), dispose: (_, controller) => controller.dispose(), lazy: false, @@ -81,15 +60,6 @@ class AppProviders extends StatelessWidget { foreground: foreground, ), ), - ChangeNotifierProvider( - create: (ctx) => DeckLibraryController( - library: ctx.read(), - documentStore: ctx.read(), - deckLoader: ctx.read(), - customizationStore: ctx.read(), - assetStore: ctx.read(), - ), - ), ], child: child, ); diff --git a/packages/playground/lib/app/router.dart b/packages/playground/lib/app/router.dart index f473d981..8917cb73 100644 --- a/packages/playground/lib/app/router.dart +++ b/packages/playground/lib/app/router.dart @@ -1,17 +1,12 @@ import 'package:go_router/go_router.dart'; import '../features/ai/wizard/routes/routes.dart'; -import '../features/library/routes/routes.dart'; import '../features/presentation/routes/routes.dart'; /// The app's [GoRouter], composing each feature's routes. GoRouter createRouter({String initialLocation = '/'}) { return GoRouter( - routes: [ - ...wizardRoutes(), - ...libraryRoutes(), - ...presentationRoutes(), - ], + routes: [...wizardRoutes(), ...presentationRoutes()], initialLocation: initialLocation, ); } diff --git a/packages/playground/lib/core/data/data_sources/deck_file.dart b/packages/playground/lib/core/data/data_sources/deck_file.dart deleted file mode 100644 index 96183065..00000000 --- a/packages/playground/lib/core/data/data_sources/deck_file.dart +++ /dev/null @@ -1,54 +0,0 @@ -/// A persistent reference to a deck file. -/// -/// Files created in the selected SuperDeck directory inherit its active -/// access scope, so this only needs to track the deck's resolved path. -final class DeckFileReference { - /// Last resolved absolute path of the deck. - final String path; - - const DeckFileReference({required this.path}); - - @override - bool operator ==(Object other) { - return other is DeckFileReference && other.path == path; - } - - @override - String toString() => 'DeckFileReference(path: $path)'; - - @override - int get hashCode => path.hashCode; -} - -/// Thrown when a deck cannot be read. -final class DeckFileReadException implements Exception { - final String path; - final Object cause; - - const DeckFileReadException(this.path, this.cause); - - @override - String toString() => 'Could not read deck file "$path": $cause'; -} - -/// Thrown when a deck cannot be written. -final class DeckFileWriteException implements Exception { - final String path; - final Object cause; - - const DeckFileWriteException(this.path, this.cause); - - @override - String toString() => 'Could not write deck file "$path": $cause'; -} - -/// Thrown when persistent security-scoped access cannot be created or used. -final class DeckFileAccessException implements Exception { - final String path; - final Object cause; - - const DeckFileAccessException(this.path, this.cause); - - @override - String toString() => 'Could not access deck file "$path": $cause'; -} diff --git a/packages/playground/lib/core/data/data_sources/deck_library_asset_store.dart b/packages/playground/lib/core/data/data_sources/deck_library_asset_store.dart deleted file mode 100644 index 5e3af1f5..00000000 --- a/packages/playground/lib/core/data/data_sources/deck_library_asset_store.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:superdeck_core/superdeck_core.dart' show AssetCacheStore; - -import '../../result.dart'; -import '../../../features/library/domain/deck_library.dart'; -import '../../../features/library/domain/saved_deck.dart'; - -/// Resolves artwork for whichever deck the app is showing. -/// -/// Two lifetimes meet here and stay separate. Artwork the Wizard just -/// generated lives in [fallback] for the rest of the run, because a generated -/// deck is not saved until the reader says so. Artwork of a deck opened from -/// the library lives beside that deck on disk and outlives the run. -/// -/// The store object itself is stable — `DeckController` takes its store once — -/// while what it resolves follows the open deck. -class DeckLibraryAssetStore implements AssetCacheStore { - final DeckLibrary _library; - final AssetCacheStore _fallback; - - SavedDeckRef? _openDeck; - - DeckLibraryAssetStore({ - required DeckLibrary library, - required AssetCacheStore fallback, - }) : _library = library, - _fallback = fallback; - - /// Follows [ref] for later reads, or nothing when a generated deck is shown. - void bindTo(SavedDeckRef? ref) { - _openDeck = ref; - } - - @override - Future resolve(String assetKey) async { - final key = AssetCacheStore.validateAssetKey(assetKey); - final deck = _openDeck; - if (deck != null) { - final resolved = await _library.resolveAsset(deck, key); - if (resolved case Ok(value: final uri?)) return uri; - } - - return _fallback.resolve(key); - } - - /// Keeps generated artwork in memory for this run. - /// - /// Writing a deck to disk is an explicit save through [DeckLibrary], never a - /// side effect of rendering one. - @override - Future write(String assetKey, List bytes) => - _fallback.write(AssetCacheStore.validateAssetKey(assetKey), bytes); - - @override - Future delete(String assetKey) => - _fallback.delete(AssetCacheStore.validateAssetKey(assetKey)); -} diff --git a/packages/playground/lib/core/data/data_sources/security_scoped_file_access.dart b/packages/playground/lib/core/data/data_sources/security_scoped_file_access.dart deleted file mode 100644 index 923dc45f..00000000 --- a/packages/playground/lib/core/data/data_sources/security_scoped_file_access.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/services.dart'; - -/// A user-selected directory that remains accessible across app launches. -final class SecurityScopedDirectoryReference { - const SecurityScopedDirectoryReference({ - required this.path, - required this.bookmark, - }); - - final String path; - final String bookmark; - - @override - int get hashCode => Object.hash(path, bookmark); - - @override - bool operator ==(Object other) { - return other is SecurityScopedDirectoryReference && - other.path == path && - other.bookmark == bookmark; - } -} - -/// Bridges opaque macOS security-scoped bookmarks to the native runner. -/// -/// Files covered by the active decks-directory scope have no individual -/// bookmark and are intentional no-ops. -class SecurityScopedFileAccess { - const SecurityScopedFileAccess({ - MethodChannel channel = const MethodChannel(channelName), - }) : _channel = channel; - - /// Channel shared with `MainFlutterWindow.swift`. - static const channelName = - 'dev.superdeck.playground/security_scoped_bookmarks'; - - final MethodChannel _channel; - - /// Prompts for the parent directory where the `SuperDeck` folder will live. - Future pickDecksDirectory() async { - final result = await _channel.invokeMapMethod( - 'pickDecksDirectory', - ); - if (result == null) return null; - final reference = _referenceValues( - result, - operation: 'directory selection', - ); - return SecurityScopedDirectoryReference( - path: reference.path, - bookmark: reference.bookmark, - ); - } - - /// Starts persistent access to a previously selected decks directory. - Future startAccessingDirectory( - SecurityScopedDirectoryReference reference, - ) async { - final result = await _channel.invokeMapMethod( - 'startAccessing', - reference.bookmark, - ); - if (result == null) { - throw StateError( - 'Native directory bookmark resolution returned no data.', - ); - } - final active = _referenceValues(result, operation: 'directory resolution'); - return SecurityScopedDirectoryReference( - path: active.path, - bookmark: active.bookmark, - ); - } - - ({String path, String bookmark}) _referenceValues( - Map result, { - required String operation, - }) { - final path = result['path']; - final refreshedBookmark = result['bookmark']; - if (path is! String || - path.isEmpty || - refreshedBookmark is! String || - refreshedBookmark.isEmpty) { - throw StateError('Native bookmark $operation returned invalid data.'); - } - return (path: path, bookmark: refreshedBookmark); - } - - /// Stops access to a directory activated by [startAccessingDirectory]. - Future stopAccessingDirectory( - SecurityScopedDirectoryReference reference, - ) async { - await _channel.invokeMethod('stopAccessing', reference.bookmark); - } -} diff --git a/packages/playground/lib/core/domain/stores/deck_document_store.dart b/packages/playground/lib/core/domain/stores/deck_document_store.dart deleted file mode 100644 index e4364185..00000000 --- a/packages/playground/lib/core/domain/stores/deck_document_store.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/foundation.dart'; - -/// The playground's sole logical Markdown document. -/// -/// Generation publishes whole documents into it, and the preview and the saved -/// deck both read from it. Identical content is intentionally ignored so a -/// republished but unchanged document does not trigger redundant work. -class DeckDocumentStore extends ChangeNotifier { - String _markdown; - - DeckDocumentStore({required String markdown}) : _markdown = markdown; - - /// The current full Markdown document. - String get markdown => _markdown; - - /// Replaces the document and notifies listeners when its text changed. - void replaceMarkdown(String markdown) { - if (markdown == _markdown) return; - _markdown = markdown; - notifyListeners(); - } -} diff --git a/packages/playground/lib/core/result.dart b/packages/playground/lib/core/result.dart deleted file mode 100644 index bd1a7e52..00000000 --- a/packages/playground/lib/core/result.dart +++ /dev/null @@ -1,42 +0,0 @@ -/// Utility type that wraps the outcome of an operation that can fail. -/// -/// Evaluate a result with a switch: -/// ```dart -/// switch (result) { -/// case Ok(:final value): -/// print(value); -/// case Failure(:final error): -/// print(error); -/// } -/// ``` -sealed class Result { - const Result(); - - /// Creates a successful [Result] holding [value]. - const factory Result.ok(T value) = Ok._; - - /// Creates a failed [Result] holding [error]. - const factory Result.error(Exception error) = Failure._; -} - -/// A successful [Result] carrying a [value]. -final class Ok extends Result { - const Ok._(this.value); - - /// The successful value. - final T value; - - @override - String toString() => 'Result<$T>.ok($value)'; -} - -/// A failed [Result] carrying an [error]. -final class Failure extends Result { - const Failure._(this.error); - - /// The failure error. - final Exception error; - - @override - String toString() => 'Result<$T>.error($error)'; -} diff --git a/packages/playground/lib/features/ai/generation/domain/generated_deck_result_applier.dart b/packages/playground/lib/features/ai/generation/domain/generated_deck_result_applier.dart index af634b8a..0b0efa59 100644 --- a/packages/playground/lib/features/ai/generation/domain/generated_deck_result_applier.dart +++ b/packages/playground/lib/features/ai/generation/domain/generated_deck_result_applier.dart @@ -5,7 +5,6 @@ import 'package:superdeck_core/superdeck_core.dart'; import '../../../../core/data/data_sources/memory_deck_loader.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; -import '../../../../core/domain/stores/deck_document_store.dart'; import '../core/engine/services/deck_generator_service.dart'; import '../../../../core/domain/design/generated_deck_style_mapper.dart'; @@ -33,23 +32,19 @@ final class GeneratedDeckApplication { /// Applies generated decks for one host and evicts artwork from the deck it /// replaces only after the replacement has been published successfully. final class GeneratedDeckResultApplier { - final DeckDocumentStore _documentStore; - final MemoryDeckLoader _deckLoader; final AssetCacheStore _assetCacheStore; final DeckCustomizationStore _customizationStore; Set _appliedAssetKeys = const {}; /// Serializes application, so two results for one host cannot interleave - /// their artwork writes and their document publication. + /// their artwork writes and their publication. Future _queue = Future.value(); GeneratedDeckResultApplier({ - required DeckDocumentStore documentStore, required MemoryDeckLoader deckLoader, required AssetCacheStore assetCacheStore, required DeckCustomizationStore customizationStore, - }) : _documentStore = documentStore, - _deckLoader = deckLoader, + }) : _deckLoader = deckLoader, _assetCacheStore = assetCacheStore, _customizationStore = customizationStore; @@ -101,7 +96,6 @@ final class GeneratedDeckResultApplier { } final markdown = const SlideSerializer().serialize(result.slides); - _documentStore.replaceMarkdown(markdown); _deckLoader.updateMarkdown(markdown); if (result.theme case final theme?) { _customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle()); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart index 0b16d97d..1a7b7dd5 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart @@ -30,10 +30,8 @@ class WizardGenerationStatus extends StatelessWidget { this.onRetryFailed, this.onEditOutline, this.onStartOver, - this.onSave, - this.onOpenSavedDecks, - this.saveLabel = 'Save deck', - this.isSaving = false, + this.onExport, + this.isExporting = false, }); final WizardGenerationStatusKind kind; @@ -55,18 +53,10 @@ class WizardGenerationStatus extends StatelessWidget { final VoidCallback? onEditOutline; final VoidCallback? onStartOver; - /// Writes this deck to the SuperDeck folder. Absent when the platform has - /// nowhere to write it. - final VoidCallback? onSave; + /// Writes this deck out as a SuperDeck project. + final VoidCallback? onExport; - /// Opens the list of decks already saved. - final VoidCallback? onOpenSavedDecks; - - /// Reflects whether this deck has been saved yet, so a second save reads as - /// another copy rather than an undo. - final String saveLabel; - - final bool isSaving; + final bool isExporting; @override Widget build(BuildContext context) { @@ -225,18 +215,11 @@ class WizardGenerationStatus extends StatelessWidget { icon: LucideIcons.refreshCw, variant: .outline, ), - if (onOpenSavedDecks != null) - SdButton( - label: 'Saved decks', - onPressed: onOpenSavedDecks, - icon: LucideIcons.folderOpen, - variant: .ghost, - ), - if (onSave != null) + if (onExport != null) SdButton( - label: isSaving ? 'Saving…' : saveLabel, - onPressed: isSaving ? null : onSave, - icon: LucideIcons.save, + label: isExporting ? 'Exporting…' : 'Export deck', + onPressed: isExporting ? null : onExport, + icon: LucideIcons.download, variant: .outline, ), SdButton( diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart index b79619c9..36961992 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart @@ -5,14 +5,11 @@ import 'package:go_router/go_router.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:provider/provider.dart'; +import '../../../../core/data/data_sources/memory_asset_cache_store.dart'; import '../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; -import '../../../../core/data/data_sources/deck_library_asset_store.dart'; -import '../../../../core/domain/stores/deck_document_store.dart'; -import '../../../library/domain/deck_library.dart'; -import '../../../library/domain/deck_library_controller.dart'; -import '../../../library/domain/saved_deck.dart'; -import '../../../library/presentation/save_deck_dialog.dart'; +import '../../../export/data/deck_export_saver.dart'; +import '../../../export/domain/deck_export.dart'; import '../../generation/core/engine/services/deck_generator_service.dart'; import '../../generation/core/env_config.dart'; import '../../generation/domain/generated_deck_result_applier.dart'; @@ -28,15 +25,15 @@ import 'wizard_view.dart'; /// Host for the conversational Wizard, the app's only authoring flow. /// -/// Generated Markdown is kept in memory: results are applied straight to the -/// in-memory document and preview stores, and a deck-storage folder is only -/// requested later, when the reader chooses to save. +/// Generated decks stay in memory; exporting writes one out as a SuperDeck +/// project. class WizardPage extends StatelessWidget { const WizardPage({ this.isConfigured, this.generationService, this.imageGenerator, this.imageGenerationEnabled, + this.exportSaver = saveDeckExportAsZip, super.key, }); @@ -52,6 +49,9 @@ class WizardPage extends StatelessWidget { /// Overrides the debug-on, release-opt-in image rollout policy. final bool? imageGenerationEnabled; + /// Writes an exported deck. Tests replace the platform save dialog. + final DeckExportSaver exportSaver; + @override Widget build(BuildContext context) { final hasApiKey = isConfigured ?? EnvConfig.hasGeminiApiKey; @@ -89,12 +89,8 @@ class WizardPage extends StatelessWidget { ChangeNotifierProvider( create: (context) { final resultApplier = GeneratedDeckResultApplier( - documentStore: Provider.of( - context, - listen: false, - ), deckLoader: Provider.of(context, listen: false), - assetCacheStore: Provider.of( + assetCacheStore: Provider.of( context, listen: false, ), @@ -104,11 +100,6 @@ class WizardPage extends StatelessWidget { ), ); - final library = Provider.of( - context, - listen: false, - ); - return WizardGenerationController( service: generationService ?? @@ -116,14 +107,7 @@ class WizardPage extends StatelessWidget { apiKey: EnvConfig.geminiApiKey, imageGenerator: finalImageGenerator, ), - applyResult: (result, {required isValid}) { - // A generated deck owns the runtime from here. The saved deck - // that was open must stop answering for artwork, or a new - // image could resolve to the old deck's file of the same name. - library.releaseOpenDeck(); - - return resultApplier.apply(result, isValid: isValid); - }, + applyResult: resultApplier.apply, ); }, ), @@ -139,7 +123,7 @@ class WizardPage extends StatelessWidget { imageStyleEnabled: imagesEnabled, ); }, - child: const _WizardExperience(), + child: _WizardExperience(exportSaver: exportSaver), ), ), ); @@ -147,45 +131,48 @@ class WizardPage extends StatelessWidget { } class _WizardExperience extends StatefulWidget { - const _WizardExperience(); + const _WizardExperience({required this.exportSaver}); + + final DeckExportSaver exportSaver; @override State<_WizardExperience> createState() => _WizardExperienceState(); } class _WizardExperienceState extends State<_WizardExperience> { - /// Saves the deck the Wizard just produced, under a name the reader picks. - /// - /// Nothing is written until the dialog is confirmed, and a deck that is - /// saved twice becomes two decks rather than one overwritten one. - Future _save(WizardGenerationController controller) async { - final result = controller.result; - if (result == null) return; - final library = context.read(); - final name = await SaveDeckDialog.show( - context, - name: controller.plan?.topic ?? 'Untitled deck', - ); - if (name == null || !mounted) return; + bool _isExporting = false; + + /// What the last export did, for the result it exported. + ({DeckGenerationResult result, String message})? _exportNotice; - await library.save( - name: name, + /// Writes the deck the Wizard just produced to wherever the reader picks. + Future _export(DeckGenerationResult result) async { + final export = DeckExport.fromDeck( + slides: result.slides, images: result.generatedImages, - theme: switch (result.theme) { - final theme? => SavedDeckTheme( - id: theme.descriptor.id, - version: theme.descriptor.version, - density: theme.density, - ), - _ => null, - }, ); + setState(() => _isExporting = true); + String? message; + try { + final saved = await widget.exportSaver(export); + if (saved) message = 'Exported "${export.name}".'; + } catch (error) { + message = 'The deck could not be exported: $error'; + } + if (!mounted) return; + setState(() { + _isExporting = false; + if (message != null) _exportNotice = (result: result, message: message); + }); } @override Widget build(BuildContext context) { final controller = context.watch(); - final library = context.watch(); + final result = controller.result; + final exportNotice = identical(_exportNotice?.result, result) + ? _exportNotice?.message + : null; final overlay = switch (controller.stage) { .setup => null, .planning || .composing => _CenteredScrollable( @@ -226,8 +213,7 @@ class _WizardExperienceState extends State<_WizardExperience> { child: WizardGenerationStatus( kind: .completed, noticeMessage: - library.errorMessage ?? - _saveNotice(library.lastSave) ?? + exportNotice ?? _completionNotice(controller.result, controller.applyNotice), slideCount: controller.result?.slides.length, failedSlideCount: controller.result?.slideFailures.length ?? 0, @@ -245,51 +231,34 @@ class _WizardExperienceState extends State<_WizardExperience> { context, ).restartConversation(); }, - onSave: library.canSave ? () => unawaited(_save(controller)) : null, - onOpenSavedDecks: () => context.push('/decks'), - saveLabel: library.lastSave == null - ? 'Save deck' - : 'Save another copy', - isSaving: library.isBusy, + onExport: result == null ? null : () => unawaited(_export(result)), + isExporting: _isExporting, ), ), }; return Scaffold( body: SafeArea( - child: Stack( - children: [ - LayoutBuilder( - builder: (context, constraints) => Center( - child: SizedBox( - width: constraints.constrainWidth(1080), - height: constraints.maxHeight, - child: Padding( - padding: const EdgeInsets.all(32), - child: Stack( - fit: .expand, - children: [ - Offstage( - offstage: controller.stage != .setup, - child: const WizardView(), - ), - ?overlay, - ], + child: LayoutBuilder( + builder: (context, constraints) => Center( + child: SizedBox( + width: constraints.constrainWidth(1080), + height: constraints.maxHeight, + child: Padding( + padding: const EdgeInsets.all(32), + child: Stack( + fit: .expand, + children: [ + Offstage( + offstage: controller.stage != .setup, + child: const WizardView(), ), - ), + ?overlay, + ], ), ), ), - Positioned( - top: 8, - right: 8, - child: IconButton( - onPressed: () => context.push('/decks'), - tooltip: 'Saved decks', - icon: const Icon(Icons.folder_outlined), - ), - ), - ], + ), ), ), backgroundColor: $background.resolve(context), @@ -297,18 +266,6 @@ class _WizardExperienceState extends State<_WizardExperience> { } } -/// Names what a save wrote, including artwork that could not come along. -String? _saveNotice(DeckSaveOutcome? outcome) { - if (outcome == null) return null; - if (outcome.isComplete) { - return 'Saved "${outcome.ref.name}" to your SuperDeck folder.'; - } - final missing = outcome.missingAssetKeys.length; - - return 'Saved "${outcome.ref.name}", without $missing ' - '${missing == 1 ? 'image' : 'images'} that had no artwork to write.'; -} - String? _completionNotice(DeckGenerationResult? result, String? applyNotice) { if (result == null) return null; final messages = [?applyNotice]; diff --git a/packages/playground/lib/features/export/data/deck_export_saver.dart b/packages/playground/lib/features/export/data/deck_export_saver.dart new file mode 100644 index 00000000..1a45d0e8 --- /dev/null +++ b/packages/playground/lib/features/export/data/deck_export_saver.dart @@ -0,0 +1,20 @@ +import 'package:file_saver/file_saver.dart'; + +import '../domain/deck_export.dart'; + +/// Writes an export somewhere the reader chooses. +/// +/// Completes with `false` when the reader cancels. +typedef DeckExportSaver = Future Function(DeckExport export); + +/// Saves [export] as a zip through the platform's save dialog. +Future saveDeckExportAsZip(DeckExport export) async { + final path = await FileSaver.instance.saveAs( + name: export.name, + bytes: export.toZip(), + fileExtension: 'zip', + mimeType: MimeType.zip, + ); + + return path != null; +} diff --git a/packages/playground/lib/features/library/data/mac_os_deck_library.dart b/packages/playground/lib/features/library/data/mac_os_deck_library.dart deleted file mode 100644 index bec744a5..00000000 --- a/packages/playground/lib/features/library/data/mac_os_deck_library.dart +++ /dev/null @@ -1,459 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:superdeck_core/superdeck_core.dart' show AssetCacheStore; - -import '../../../core/data/data_sources/deck_file.dart'; -import '../../../core/data/data_sources/security_scoped_file_access.dart'; -import '../../../core/domain/generated_image_asset.dart'; -import '../../../core/result.dart'; -import '../domain/deck_library.dart'; -import '../domain/saved_deck.dart'; - -/// macOS [DeckLibrary], backed by a user-selected SuperDeck folder. -/// -/// The folder is chosen once and remembered as a security-scoped bookmark, so -/// every deck saved into it is reachable on the next launch without asking -/// again. Decks created inside it inherit that access and need no bookmark of -/// their own. -class MacOsDeckLibrary implements DeckLibrary { - static const _decksFolderName = 'SuperDeck'; - static const _settingsFolder = 'superdeck_playground'; - static const _settingsFileName = 'settings.json'; - static const _decksDirectoryKey = 'decksDirectory'; - static const _temporarySuffix = '.superdeck-partial'; - - final SecurityScopedFileAccess _fileAccess; - - SecurityScopedDirectoryReference? _activeDecksDirectory; - Future? _decksDirectoryRequest; - bool _disposed = false; - - MacOsDeckLibrary({ - SecurityScopedFileAccess fileAccess = const SecurityScopedFileAccess(), - }) : _fileAccess = fileAccess; - - /// Splits `.md` into the stem its siblings are named after. - static String deckStem(String markdownPath) { - final name = p.basename(markdownPath); - final extension = p.extension(name); - - return extension.isEmpty - ? name - : name.substring(0, name.length - extension.length); - } - - /// The directory holding the artwork of the deck at [markdownPath]. - static String assetsDirectoryPath(String markdownPath) => p.join( - p.dirname(markdownPath), - '${deckStem(markdownPath)}$kDeckAssetsSuffix', - ); - - /// The manifest beside the deck at [markdownPath]. - static String manifestPath(String markdownPath) => p.join( - p.dirname(markdownPath), - '${deckStem(markdownPath)}$kDeckManifestSuffix', - ); - - /// Turns a deck title into a filename stem that survives a filesystem. - /// - /// Path separators, leading dots and control characters are removed; - /// everything else the user typed, including spaces and non-ASCII letters, - /// is kept so the file is recognisable in Finder. - static String toFileStem(String name) { - final collapsed = name - .replaceAll(RegExp(r'[\x00-\x1f/\\:]'), ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - final trimmed = collapsed.replaceAll(RegExp(r'^\.+'), '').trim(); - - return trimmed.isEmpty ? 'Untitled deck' : trimmed; - } - - Future> _readSettings() async { - try { - final file = await _settingsFile(); - if (!await file.exists()) return {}; - final decoded = jsonDecode(await file.readAsString()); - - return decoded is Map ? decoded.cast() : {}; - } catch (_) { - // Corrupt settings cost the remembered folder, not the app. - return {}; - } - } - - /// Appends a counter until nothing in [directory] answers to the stem. - /// - /// Saving twice keeps both decks: "My talk", then "My talk 2". - Future _uniqueStem(Directory directory, String stem) async { - var candidate = stem; - var suffix = 1; - while (await _stemTaken(directory, candidate)) { - suffix++; - candidate = '$stem $suffix'; - } - - return candidate; - } - - Future _stemTaken(Directory directory, String stem) async { - final markdown = File(p.join(directory.path, '$stem.md')); - final assets = Directory(p.join(directory.path, '$stem$kDeckAssetsSuffix')); - final manifest = File( - p.join(directory.path, '$stem$kDeckManifestSuffix'), - ); - - return await markdown.exists() || - await assets.exists() || - await manifest.exists(); - } - - Future _readManifest(String markdownPath) async { - try { - final file = File(manifestPath(markdownPath)); - if (!await file.exists()) return null; - final decoded = jsonDecode(await file.readAsString()); - if (decoded is! Map) return null; - - return SavedDeckManifest.fromJson(decoded.cast()); - } catch (_) { - // A deck with an unreadable manifest still presents; it just opens - // without its theme. - return null; - } - } - - /// Writes through a sibling temporary file, so an interrupted write cannot - /// leave a half-written deck behind. - Future _writeString(File file, String contents) async { - final temporary = File('${file.path}$_temporarySuffix'); - try { - await temporary.writeAsString(contents, flush: true); - await temporary.rename(file.path); - } catch (_) { - await _deleteQuietly(temporary); - rethrow; - } - } - - Future _writeBytes(File file, List bytes) async { - final temporary = File('${file.path}$_temporarySuffix'); - try { - await temporary.writeAsBytes(bytes, flush: true); - await temporary.rename(file.path); - } catch (_) { - await _deleteQuietly(temporary); - rethrow; - } - } - - Future _deleteQuietly(File? file) async { - if (file == null) return; - try { - if (await file.exists()) await file.delete(); - } catch (_) { - // Cleanup must not replace the failure that caused it. - } - } - - Future _deleteDirectoryQuietly(Directory? directory) async { - if (directory == null) return; - try { - if (await directory.exists()) await directory.delete(recursive: true); - } catch (_) { - // Cleanup must not replace the failure that caused it. - } - } - - Future _decksDirectory() async { - final existingRequest = _decksDirectoryRequest; - if (existingRequest != null) return existingRequest; - - final request = _resolveDecksDirectory(); - _decksDirectoryRequest = request; - try { - return await request; - } catch (_) { - if (identical(_decksDirectoryRequest, request)) { - _decksDirectoryRequest = null; - } - rethrow; - } - } - - Future _resolveDecksDirectory() async { - final remembered = await _storedDecksDirectory(); - if (remembered != null) { - final restored = await _tryActivateDecksDirectory(remembered); - if (restored != null) return restored; - } - - SecurityScopedDirectoryReference? selected; - try { - selected = await _fileAccess.pickDecksDirectory(); - } catch (error, stackTrace) { - Error.throwWithStackTrace( - DeckFileAccessException('', error), - stackTrace, - ); - } - if (selected == null) { - throw DeckFileAccessException( - '', - StateError('Choosing a folder for saved decks was cancelled.'), - ); - } - - try { - return await _activateDecksDirectory(selected); - } catch (error, stackTrace) { - Error.throwWithStackTrace( - DeckFileAccessException(selected.path, error), - stackTrace, - ); - } - } - - Future _tryActivateDecksDirectory( - SecurityScopedDirectoryReference reference, - ) async { - try { - return await _activateDecksDirectory(reference); - } catch (_) { - return null; - } - } - - Future _activateDecksDirectory( - SecurityScopedDirectoryReference reference, - ) async { - SecurityScopedDirectoryReference? active; - try { - active = await _fileAccess.startAccessingDirectory(reference); - if (_disposed) throw StateError('The deck library has been disposed.'); - - final root = Directory(active.path); - if (!await root.exists()) { - throw StateError('The chosen folder no longer exists.'); - } - final directory = Directory(p.join(root.path, _decksFolderName)); - await directory.create(recursive: true); - if (_disposed) throw StateError('The deck library has been disposed.'); - - _activeDecksDirectory = active; - await _rememberDecksDirectory(active); - - return directory; - } catch (_) { - if (active != null) { - try { - await _fileAccess.stopAccessingDirectory(active); - } catch (_) { - // A failed activation must not leak an access scope. - } - } - rethrow; - } - } - - Future _storedDecksDirectory() async { - final stored = (await _readSettings())[_decksDirectoryKey]; - if (stored is! Map) return null; - final path = stored['path']; - final bookmark = stored['bookmark']; - if (path is! String || - path.isEmpty || - bookmark is! String || - bookmark.isEmpty) { - return null; - } - - return SecurityScopedDirectoryReference(path: path, bookmark: bookmark); - } - - Future _rememberDecksDirectory( - SecurityScopedDirectoryReference reference, - ) async { - try { - final settings = await _readSettings(); - settings[_decksDirectoryKey] = { - 'path': reference.path, - 'bookmark': reference.bookmark, - }; - await (await _settingsFile()).writeAsString(jsonEncode(settings)); - } catch (_) { - // Remembering the folder is a convenience, not a save guarantee. - } - } - - Future _settingsFile() async { - final support = await getApplicationSupportDirectory(); - final directory = Directory(p.join(support.path, _settingsFolder)); - await directory.create(recursive: true); - - return File(p.join(directory.path, _settingsFileName)); - } - - @override - Future> save({ - required String name, - required String markdown, - List images = const [], - SavedDeckTheme? theme, - }) async { - Directory? assetsDirectory; - File? markdownFile; - File? manifestFile; - try { - final directory = await _decksDirectory(); - final stem = await _uniqueStem(directory, toFileStem(name)); - markdownFile = File(p.join(directory.path, '$stem.md')); - manifestFile = File( - p.join(directory.path, '$stem$kDeckManifestSuffix'), - ); - assetsDirectory = Directory( - p.join(directory.path, '$stem$kDeckAssetsSuffix'), - ); - - final saved = []; - final missing = []; - for (final image in images) { - final key = AssetCacheStore.validateAssetKey(image.assetKey); - final bytes = image.bytes; - if (bytes == null || bytes.isEmpty) { - missing.add(key); - continue; - } - await assetsDirectory.create(recursive: true); - await _writeBytes(File(p.join(assetsDirectory.path, key)), bytes); - saved.add(key); - } - - final savedAt = DateTime.now().toUtc(); - final manifest = SavedDeckManifest( - formatVersion: SavedDeckManifest.currentFormatVersion, - name: name.trim().isEmpty ? stem : name.trim(), - savedAt: savedAt, - markdownFileName: p.basename(markdownFile.path), - assetsDirectoryName: p.basename(assetsDirectory.path), - assetKeys: saved, - theme: theme, - ); - - // Markdown first, then the manifest: a deck without a manifest still - // opens, a manifest without a deck is meaningless. - await _writeString(markdownFile, markdown); - await _writeString( - manifestFile, - const JsonEncoder.withIndent(' ').convert(manifest.toJson()), - ); - - return Result.ok( - DeckSaveOutcome( - ref: SavedDeckRef( - reference: DeckFileReference(path: markdownFile.path), - name: manifest.name, - savedAt: savedAt, - ), - savedAssetKeys: saved, - missingAssetKeys: missing, - ), - ); - } on DeckFileAccessException catch (error) { - return Result.error(error); - } catch (error) { - // This save created every path it touched, so removing them cannot take - // a deck saved earlier with it. - await _deleteQuietly(markdownFile); - await _deleteQuietly(manifestFile); - await _deleteDirectoryQuietly(assetsDirectory); - - return Result.error( - DeckFileWriteException(markdownFile?.path ?? name, error), - ); - } - } - - @override - Future>> list() async { - try { - final directory = await _decksDirectory(); - final refs = []; - await for (final entity in directory.list()) { - if (entity is! File || p.extension(entity.path) != '.md') continue; - final manifest = await _readManifest(entity.path); - refs.add( - SavedDeckRef( - reference: DeckFileReference(path: entity.path), - name: manifest?.name ?? deckStem(entity.path), - savedAt: manifest?.savedAt ?? (await entity.stat()).modified, - ), - ); - } - refs.sort((a, b) => b.savedAt.compareTo(a.savedAt)); - - return Result.ok(refs); - } on DeckFileAccessException catch (error) { - return Result.error(error); - } catch (error) { - return Result.error( - DeckFileReadException('', error), - ); - } - } - - @override - Future> open(SavedDeckRef ref) async { - try { - final file = File(ref.reference.path); - if (!await file.exists()) { - throw StateError('The deck is no longer in the SuperDeck folder.'); - } - - return Result.ok( - SavedDeck( - ref: ref, - markdown: await file.readAsString(), - manifest: await _readManifest(ref.reference.path), - ), - ); - } catch (error) { - return Result.error( - DeckFileReadException(ref.reference.path, error), - ); - } - } - - @override - Future> resolveAsset(SavedDeckRef ref, String assetKey) async { - try { - final key = AssetCacheStore.validateAssetKey(assetKey); - final file = File( - p.join(assetsDirectoryPath(ref.reference.path), key), - ); - - return Result.ok(await file.exists() ? file.uri : null); - } catch (error) { - return Result.error( - DeckFileReadException(ref.reference.path, error), - ); - } - } - - @override - void dispose() { - _disposed = true; - _decksDirectoryRequest = null; - final directory = _activeDecksDirectory; - _activeDecksDirectory = null; - if (directory != null) { - _fileAccess.stopAccessingDirectory(directory).ignore(); - } - } - - @override - bool get canSave => Platform.isMacOS; -} diff --git a/packages/playground/lib/features/library/domain/deck_library.dart b/packages/playground/lib/features/library/domain/deck_library.dart deleted file mode 100644 index 57dd8dfe..00000000 --- a/packages/playground/lib/features/library/domain/deck_library.dart +++ /dev/null @@ -1,70 +0,0 @@ -import '../../../core/domain/generated_image_asset.dart'; -import '../../../core/result.dart'; -import 'saved_deck.dart'; - -/// Reads and writes complete decks in the SuperDeck folder. -/// -/// A saved deck is three things that travel together: `.md`, -/// `.assets/` holding the artwork the Markdown refers to by bare -/// filename, and `.deck.json` describing both. Saving never replaces an -/// earlier deck: each save creates a new, uniquely named copy. -/// -/// Decks open read-only. The playground presents saved decks; it does not -/// edit them. -abstract interface class DeckLibrary { - /// Whether this platform can store decks at all. - /// - /// Callers ask before offering to save, rather than inferring support from - /// the platform. A library that answers `false` still presents generated - /// decks for the rest of the run. - bool get canSave; - - /// Writes [markdown], the readable images among [images], and a manifest as - /// a new deck named after [name]. - /// - /// The name is normalised and made unique, so two saves of "My talk" become - /// two decks. Artwork whose bytes are missing is skipped and named in the - /// result rather than silently dropped. A failure removes what this save - /// created; decks saved earlier are never touched. - Future> save({ - required String name, - required String markdown, - List images, - SavedDeckTheme? theme, - }); - - /// Saved decks, newest first. - Future>> list(); - - /// Reads [ref] back for presentation. - Future> open(SavedDeckRef ref); - - /// The URI of [assetKey] beside the deck at [ref], or `null` when the deck - /// does not have it. - Future> resolveAsset(SavedDeckRef ref, String assetKey); - - void dispose(); -} - -/// What one save wrote. -final class DeckSaveOutcome { - final SavedDeckRef ref; - - /// Artwork written beside the deck. - final List savedAssetKeys; - - /// Artwork the deck refers to that had no bytes to write. - /// - /// The deck is still saved and still presentable; these images will not - /// render. Never reported as a complete save. - final List missingAssetKeys; - - const DeckSaveOutcome({ - required this.ref, - required this.savedAssetKeys, - required this.missingAssetKeys, - }); - - /// Whether every referenced image came along. - bool get isComplete => missingAssetKeys.isEmpty; -} diff --git a/packages/playground/lib/features/library/domain/deck_library_controller.dart b/packages/playground/lib/features/library/domain/deck_library_controller.dart deleted file mode 100644 index 096474f6..00000000 --- a/packages/playground/lib/features/library/domain/deck_library_controller.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import '../../../core/data/data_sources/deck_library_asset_store.dart'; -import '../../../core/data/data_sources/memory_deck_loader.dart'; -import '../../../core/domain/design/generated_deck_style_mapper.dart'; -import '../../../core/domain/design/presentation_theme_catalog.dart'; -import '../../../core/domain/design/presentation_typography_catalog.dart'; -import '../../../core/domain/generated_image_asset.dart'; -import '../../../core/domain/stores/deck_customization_store.dart'; -import '../../../core/domain/stores/deck_document_store.dart'; -import '../../../core/result.dart'; -import 'deck_library.dart'; -import 'saved_deck.dart'; - -/// Saves generated decks and opens saved ones for presentation. -/// -/// One object owns both directions because they share the runtime a deck is -/// published into: the document, the preview loader, the theme, and which -/// deck's artwork the renderer resolves. -class DeckLibraryController extends ChangeNotifier { - final DeckLibrary _library; - final DeckDocumentStore _documentStore; - final MemoryDeckLoader _deckLoader; - final DeckCustomizationStore _customizationStore; - final DeckLibraryAssetStore _assetStore; - final PresentationThemeCatalog _themeCatalog; - final PresentationTypographyCatalog _typographyCatalog; - - List _decks = const []; - DeckSaveOutcome? _lastSave; - String? _errorMessage; - bool _isBusy = false; - - DeckLibraryController({ - required DeckLibrary library, - required DeckDocumentStore documentStore, - required MemoryDeckLoader deckLoader, - required DeckCustomizationStore customizationStore, - required DeckLibraryAssetStore assetStore, - PresentationThemeCatalog? themeCatalog, - PresentationTypographyCatalog? typographyCatalog, - }) : _library = library, - _documentStore = documentStore, - _deckLoader = deckLoader, - _customizationStore = customizationStore, - _assetStore = assetStore, - _themeCatalog = themeCatalog ?? PresentationThemeCatalog.withDefaults(), - _typographyCatalog = - typographyCatalog ?? PresentationTypographyCatalog.withDefaults(); - - void _setBusy({required bool busy}) { - _isBusy = busy; - notifyListeners(); - } - - /// Restores the appearance a deck was saved with. - /// - /// A selection the catalog no longer offers leaves the current theme alone - /// and is reported, rather than resolving to a different-looking deck. - String? _applyTheme(SavedDeckTheme? theme) { - if (theme == null) return null; - try { - final resolved = _themeCatalog.resolve( - id: theme.id, - version: theme.version, - typographyCatalog: _typographyCatalog, - density: theme.density, - ); - _customizationStore.applyGeneratedStyle(resolved.toGeneratedDeckStyle()); - - return null; - } catch (_) { - return 'This deck was saved with the "${theme.id}" theme, version ' - '${theme.version}, which this version of SuperDeck no longer has. ' - 'The deck opens with the current theme.'; - } - } - - /// Saved decks, newest first. - List get decks => _decks; - - /// What the last save wrote, for the notice the Wizard shows. - DeckSaveOutcome? get lastSave => _lastSave; - - /// A user-facing reason the last save or open did not finish. - String? get errorMessage => _errorMessage; - - /// Whether a save or open is running. - bool get isBusy => _isBusy; - - /// Whether decks can be stored at all on this platform. - bool get canSave => _library.canSave; - - /// Writes the current document as a new deck named [name]. - /// - /// [images] carries the artwork of the generation that produced the - /// document, and [theme] the selection it was generated with. - Future save({ - required String name, - List images = const [], - SavedDeckTheme? theme, - }) async { - if (_isBusy) return false; - _errorMessage = null; - _setBusy(busy: true); - try { - final result = await _library.save( - name: name, - markdown: _documentStore.markdown, - images: images, - theme: theme, - ); - switch (result) { - case Ok(:final value): - _lastSave = value; - // The saved copy, not the run's memory, now owns this artwork. - _assetStore.bindTo(value.ref); - _decks = [value.ref, ..._decks.where((ref) => ref != value.ref)]; - - return true; - case Failure(:final error): - _errorMessage = '$error'; - - return false; - } - } finally { - _setBusy(busy: false); - } - } - - /// Reloads the list of saved decks. - Future refresh() async { - _errorMessage = null; - _setBusy(busy: true); - try { - switch (await _library.list()) { - case Ok(:final value): - _decks = value; - case Failure(:final error): - _errorMessage = '$error'; - } - } finally { - _setBusy(busy: false); - } - } - - /// Publishes the saved deck at [ref] for presentation. - /// - /// The deck is read-only: it is loaded into the preview and the renderer, - /// with its own artwork and the theme it was saved with. - Future open(SavedDeckRef ref) async { - if (_isBusy) return false; - _errorMessage = null; - _setBusy(busy: true); - try { - switch (await _library.open(ref)) { - case Ok(:final value): - _assetStore.bindTo(value.ref); - _documentStore.replaceMarkdown(value.markdown); - _deckLoader.updateMarkdown(value.markdown); - _errorMessage = _applyTheme(value.theme); - - return true; - case Failure(:final error): - _errorMessage = '$error'; - - return false; - } - } finally { - _setBusy(busy: false); - } - } - - /// Forgets the open saved deck, so a new generation owns the runtime again. - void releaseOpenDeck() { - _lastSave = null; - _assetStore.bindTo(null); - notifyListeners(); - } -} diff --git a/packages/playground/lib/features/library/domain/saved_deck.dart b/packages/playground/lib/features/library/domain/saved_deck.dart deleted file mode 100644 index b6f04ffd..00000000 --- a/packages/playground/lib/features/library/domain/saved_deck.dart +++ /dev/null @@ -1,166 +0,0 @@ -import '../../../core/data/data_sources/deck_file.dart'; - -/// Directory suffix that pairs a saved deck with its artwork. -const kDeckAssetsSuffix = '.assets'; - -/// Filename suffix of the metadata written beside a saved deck. -const kDeckManifestSuffix = '.deck.json'; - -/// The theme a deck was generated with, in the catalog's own terms. -/// -/// Only the selection is stored. Resolved colours and fonts are derived from -/// the catalog at open time, so a deck saved against catalog version 1 can be -/// recognised — and refused — by a catalog that moved on. -final class SavedDeckTheme { - final String id; - final int version; - final String density; - - const SavedDeckTheme({ - required this.id, - required this.version, - required this.density, - }); - - factory SavedDeckTheme.fromJson(Map json) => SavedDeckTheme( - id: json['id']! as String, - version: (json['version']! as num).toInt(), - density: json['density']! as String, - ); - - Map toJson() => { - 'id': id, - 'version': version, - 'density': density, - }; - - @override - bool operator ==(Object other) => - other is SavedDeckTheme && - other.id == id && - other.version == version && - other.density == density; - - @override - String toString() => 'SavedDeckTheme($id v$version, $density)'; - - @override - int get hashCode => Object.hash(id, version, density); -} - -/// What a saved deck carries beside its Markdown. -/// -/// The manifest is the deck's canonical description: the format version that -/// wrote it, the display name, the artwork the Markdown refers to, and the -/// theme selection. A deck whose manifest cannot be read still opens; it just -/// opens without a theme. -final class SavedDeckManifest { - /// Current manifest format. Bump when a field changes meaning. - static const currentFormatVersion = 1; - - final int formatVersion; - final String name; - final DateTime savedAt; - final String markdownFileName; - final String assetsDirectoryName; - final List assetKeys; - final SavedDeckTheme? theme; - - const SavedDeckManifest({ - required this.formatVersion, - required this.name, - required this.savedAt, - required this.markdownFileName, - required this.assetsDirectoryName, - required this.assetKeys, - this.theme, - }); - - factory SavedDeckManifest.fromJson(Map json) { - final formatVersion = (json['formatVersion'] as num?)?.toInt(); - if (formatVersion == null) { - throw const FormatException('Deck manifest has no formatVersion.'); - } - if (formatVersion > currentFormatVersion) { - throw FormatException( - 'Deck manifest format $formatVersion is newer than this app ' - 'understands ($currentFormatVersion).', - ); - } - final savedAt = switch (json['savedAt']) { - final String value => DateTime.tryParse(value), - _ => null, - }; - if (savedAt == null) { - throw const FormatException('Deck manifest has no savedAt timestamp.'); - } - - return SavedDeckManifest( - formatVersion: formatVersion, - name: json['name']! as String, - savedAt: savedAt, - markdownFileName: json['markdownFileName']! as String, - assetsDirectoryName: json['assetsDirectoryName']! as String, - assetKeys: [ - for (final key in (json['assetKeys'] as List? ?? const [])) - key as String, - ], - theme: switch (json['theme']) { - final Map theme => SavedDeckTheme.fromJson(theme), - _ => null, - }, - ); - } - - Map toJson() => { - 'formatVersion': formatVersion, - 'name': name, - 'savedAt': savedAt.toUtc().toIso8601String(), - 'markdownFileName': markdownFileName, - 'assetsDirectoryName': assetsDirectoryName, - 'assetKeys': assetKeys, - 'theme': ?theme?.toJson(), - }; -} - -/// A deck on disk, named well enough to list and reopen it. -final class SavedDeckRef { - final DeckFileReference reference; - final String name; - final DateTime savedAt; - - const SavedDeckRef({ - required this.reference, - required this.name, - required this.savedAt, - }); - - @override - bool operator ==(Object other) => - other is SavedDeckRef && - other.reference == reference && - other.name == name && - other.savedAt == savedAt; - - @override - String toString() => 'SavedDeckRef($name, ${reference.path})'; - - @override - int get hashCode => Object.hash(reference, name, savedAt); -} - -/// A saved deck read back for presentation. -final class SavedDeck { - final SavedDeckRef ref; - final String markdown; - final SavedDeckManifest? manifest; - - const SavedDeck({ - required this.ref, - required this.markdown, - this.manifest, - }); - - /// The theme the deck was saved with, when its manifest carried one. - SavedDeckTheme? get theme => manifest?.theme; -} diff --git a/packages/playground/lib/features/library/presentation/save_deck_dialog.dart b/packages/playground/lib/features/library/presentation/save_deck_dialog.dart deleted file mode 100644 index 71a9b459..00000000 --- a/packages/playground/lib/features/library/presentation/save_deck_dialog.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; - -import '../../ai/wizard/core/ui/ui.dart'; - -/// Asks for the name a generated deck is saved under. -/// -/// Saving is explicit: the Wizard never writes a deck the reader did not ask -/// for, and the name is theirs to change. -class SaveDeckDialog extends StatefulWidget { - const SaveDeckDialog({super.key, required this.initialName}); - - /// Returns the chosen name, or `null` when the reader cancelled. - static Future show(BuildContext context, {required String name}) { - return showDialog( - context: context, - builder: (context) => SaveDeckDialog(initialName: name), - ); - } - - final String initialName; - - @override - State createState() => _SaveDeckDialogState(); -} - -class _SaveDeckDialogState extends State { - late final TextEditingController _controller = TextEditingController( - text: widget.initialName, - ); - - void _submit() { - final name = _controller.text.trim(); - if (name.isEmpty) return; - Navigator.of(context).pop(name); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Dialog( - child: ConstrainedBox( - constraints: const .tightFor(width: 420), - child: Padding( - padding: const .all(24), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - spacing: 16, - children: [ - const SdTitle('Save this deck'), - const SdCaption( - 'The deck, its artwork and its theme are written to your ' - 'SuperDeck folder. Saving again keeps both copies.', - ), - SdTextField( - controller: _controller, - label: 'Deck name', - onSubmitted: (_) => _submit(), - textInputAction: .done, - autofocus: true, - semanticLabel: 'Deck name', - ), - Row( - mainAxisAlignment: .end, - spacing: 10, - children: [ - SdButton( - label: 'Cancel', - onPressed: () => Navigator.of(context).pop(), - variant: .ghost, - ), - SdButton( - label: 'Save deck', - onPressed: _submit, - icon: LucideIcons.save, - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/packages/playground/lib/features/library/presentation/saved_decks_page.dart b/packages/playground/lib/features/library/presentation/saved_decks_page.dart deleted file mode 100644 index d193ce30..00000000 --- a/packages/playground/lib/features/library/presentation/saved_decks_page.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hero_ui/hero_ui.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:provider/provider.dart'; - -import '../../ai/wizard/core/ui/ui.dart'; -import '../domain/deck_library_controller.dart'; -import '../domain/saved_deck.dart'; - -/// The decks already saved to the SuperDeck folder, opened for presenting. -/// -/// Decks are read-only here. Opening one publishes its Markdown, artwork and -/// theme into the runtime and presents it. -class SavedDecksPage extends StatefulWidget { - const SavedDecksPage({super.key}); - - @override - State createState() => _SavedDecksPageState(); -} - -class _SavedDecksPageState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(context.read().refresh()); - }); - } - - Future _open(SavedDeckRef ref) async { - final controller = context.read(); - final opened = await controller.open(ref); - if (!mounted || !opened) return; - context.push('/present/0'); - } - - @override - Widget build(BuildContext context) { - final controller = context.watch(); - final decks = controller.decks; - - return Scaffold( - body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const .tightFor(width: 720), - child: Padding( - padding: const .all(32), - child: Column( - crossAxisAlignment: .start, - spacing: 20, - children: [ - Row( - mainAxisAlignment: .spaceBetween, - children: [ - const SdHeadline('Saved decks'), - SdButton( - label: 'New deck', - onPressed: () => context.go('/'), - icon: LucideIcons.sparkles, - variant: .outline, - ), - ], - ), - if (controller.errorMessage case final message?) - SdCallout(text: message, icon: LucideIcons.triangleAlert), - if (controller.isBusy && decks.isEmpty) - const SdSpinner() - else if (decks.isEmpty) - const SdBody( - 'No decks yet. Generate one with the Wizard and save it ' - 'to see it here.', - ) - else - Expanded( - child: ListView.separated( - itemBuilder: (context, index) => - _SavedDeckTile(ref: decks[index], onOpen: _open), - separatorBuilder: (context, index) => - const SizedBox(height: 10), - itemCount: decks.length, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _SavedDeckTile extends StatelessWidget { - const _SavedDeckTile({required this.ref, required this.onOpen}); - - final SavedDeckRef ref; - final Future Function(SavedDeckRef ref) onOpen; - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: 'Present ${ref.name}', - child: Container( - padding: const .all(16), - decoration: BoxDecoration( - color: $surfaceSecondary.resolve(context), - border: .all(color: $border.resolve(context)), - borderRadius: .circular(14), - ), - child: Row( - spacing: 16, - children: [ - Expanded( - child: Column( - crossAxisAlignment: .start, - spacing: 4, - children: [ - SdTitle(ref.name), - SdCaption(_savedAtLabel(ref.savedAt)), - ], - ), - ), - SdButton( - label: 'Present', - onPressed: () => unawaited(onOpen(ref)), - icon: LucideIcons.play, - ), - ], - ), - ), - ); - } -} - -String _savedAtLabel(DateTime savedAt) { - final local = savedAt.toLocal(); - final month = local.month.toString().padLeft(2, '0'); - final day = local.day.toString().padLeft(2, '0'); - final hour = local.hour.toString().padLeft(2, '0'); - final minute = local.minute.toString().padLeft(2, '0'); - - return 'Saved ${local.year}-$month-$day at $hour:$minute'; -} diff --git a/packages/playground/lib/features/library/routes/routes.dart b/packages/playground/lib/features/library/routes/routes.dart deleted file mode 100644 index 00b7138f..00000000 --- a/packages/playground/lib/features/library/routes/routes.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:go_router/go_router.dart'; - -import '../presentation/saved_decks_page.dart'; - -/// Decks already saved to the SuperDeck folder. -List libraryRoutes() => [ - GoRoute(path: '/decks', builder: (context, state) => const SavedDecksPage()), -]; diff --git a/packages/playground/lib/features/presentation/presentation/pages/presentation_page.dart b/packages/playground/lib/features/presentation/presentation/pages/presentation_page.dart index 9deac5c4..e60e48ce 100644 --- a/packages/playground/lib/features/presentation/presentation/pages/presentation_page.dart +++ b/packages/playground/lib/features/presentation/presentation/pages/presentation_page.dart @@ -11,7 +11,7 @@ import 'package:superdeck/superdeck.dart'; /// action bar, menu, and scaled slide — all driven off the shared /// [DeckController]. Navigation, the menu, and thumbnails are the shell's own. /// -/// The playground pushes this over the Wizard or saved-deck library (unlike +/// The playground pushes this over the Wizard (unlike /// standalone SuperDeck, which is the whole app), so a close button is /// overlaid to return. [initialIndex], from the route, seeds the slide the /// reader chose to present. diff --git a/packages/playground/lib/features/presentation/routes/routes.dart b/packages/playground/lib/features/presentation/routes/routes.dart index 15f51779..13ac3bf2 100644 --- a/packages/playground/lib/features/presentation/routes/routes.dart +++ b/packages/playground/lib/features/presentation/routes/routes.dart @@ -6,7 +6,7 @@ import '../presentation/pages/presentation_page.dart'; const _transitionDuration = Duration(milliseconds: 250); /// The presentation feature's routes. `/present/:index` is pushed over the -/// Wizard or saved-deck library, while deck globals at the app root remain +/// Wizard, while deck globals at the app root remain /// available to the SuperDeck shell rendered here. /// /// The route fades in and out via a [CustomTransitionPage] rather than the diff --git a/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift index 1292404f..e302d480 100644 --- a/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import audioplayers_darwin import device_info_plus +import file_saver import irondash_engine_context import screen_retriever_macos import sqflite_darwin @@ -19,6 +20,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/packages/playground/macos/Runner.xcodeproj/project.pbxproj b/packages/playground/macos/Runner.xcodeproj/project.pbxproj index 457743e6..d51cb7ab 100644 --- a/packages/playground/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/playground/macos/Runner.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 607761A9960048984A5A4E6F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B2CD240576EF27BE303B514 /* Pods_Runner.framework */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - A51F9B022F12ABCD00000001 /* SecurityScopedBookmarkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51F9B012F12ABCD00000001 /* SecurityScopedBookmarkManager.swift */; }; B495E5BD97984C6EF0C5B96A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA381DA986524D8EF0AA2DEF /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -89,7 +88,6 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 7E9A6C0725AE267ECBE50B9D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - A51F9B012F12ABCD00000001 /* SecurityScopedBookmarkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopedBookmarkManager.swift; sourceTree = ""; }; DA381DA986524D8EF0AA2DEF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DBDEAEBB3B165959B92AA66F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -183,7 +181,6 @@ children = ( 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - A51F9B012F12ABCD00000001 /* SecurityScopedBookmarkManager.swift */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, 33E51914231749380026EE4D /* Release.entitlements */, 33CC11242044D66E0003C045 /* Resources */, @@ -451,7 +448,6 @@ buildActionMask = 2147483647; files = ( 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - A51F9B022F12ABCD00000001 /* SecurityScopedBookmarkManager.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, ); diff --git a/packages/playground/macos/Runner/DebugProfile.entitlements b/packages/playground/macos/Runner/DebugProfile.entitlements index 714d930f..cff5a4b3 100644 --- a/packages/playground/macos/Runner/DebugProfile.entitlements +++ b/packages/playground/macos/Runner/DebugProfile.entitlements @@ -12,7 +12,5 @@ com.apple.security.files.user-selected.read-write - com.apple.security.files.bookmarks.app-scope - diff --git a/packages/playground/macos/Runner/MainFlutterWindow.swift b/packages/playground/macos/Runner/MainFlutterWindow.swift index 6cc1e8f9..c9edb3b3 100644 --- a/packages/playground/macos/Runner/MainFlutterWindow.swift +++ b/packages/playground/macos/Runner/MainFlutterWindow.swift @@ -5,8 +5,6 @@ class MainFlutterWindow: NSWindow { private static let initialContentSize = NSSize(width: 1440, height: 900) private static let minimumContentSize = NSSize(width: 1200, height: 700) - private let bookmarkManager = SecurityScopedBookmarkManager() - override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -31,16 +29,6 @@ class MainFlutterWindow: NSWindow { self.center() } - let registrar = flutterViewController.registrar( - forPlugin: "SecurityScopedBookmarks") - let bookmarkChannel = FlutterMethodChannel( - name: "dev.superdeck.playground/security_scoped_bookmarks", - binaryMessenger: registrar.messenger) - let bookmarkManager = self.bookmarkManager - bookmarkChannel.setMethodCallHandler { call, result in - bookmarkManager.handle(call, result: result) - } - RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() diff --git a/packages/playground/macos/Runner/Release.entitlements b/packages/playground/macos/Runner/Release.entitlements index f2b984c2..38da9a97 100644 --- a/packages/playground/macos/Runner/Release.entitlements +++ b/packages/playground/macos/Runner/Release.entitlements @@ -8,7 +8,5 @@ com.apple.security.files.user-selected.read-write - com.apple.security.files.bookmarks.app-scope - diff --git a/packages/playground/macos/Runner/SecurityScopedBookmarkManager.swift b/packages/playground/macos/Runner/SecurityScopedBookmarkManager.swift deleted file mode 100644 index 89e4cefb..00000000 --- a/packages/playground/macos/Runner/SecurityScopedBookmarkManager.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Cocoa -import FlutterMacOS - -private enum SecurityScopedBookmarkError: LocalizedError { - case invalidArgument(String) - case invalidBookmark - case accessDenied(String) - - var errorDescription: String? { - switch self { - case .invalidArgument(let method): - return "Invalid argument for \(method)." - case .invalidBookmark: - return "The stored security-scoped bookmark is invalid." - case .accessDenied(let path): - return "Could not start security-scoped access to \(path)." - } - } -} - -final class SecurityScopedBookmarkManager { - private var activeURLs: [String: URL] = [:] - - deinit { - for url in activeURLs.values { - url.stopAccessingSecurityScopedResource() - } - } - - func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - do { - switch call.method { - case "pickDecksDirectory": - result(try pickDecksDirectory()) - case "startAccessing": - guard let bookmark = call.arguments as? String, !bookmark.isEmpty else { - throw SecurityScopedBookmarkError.invalidArgument(call.method) - } - result(try startAccessing(bookmark: bookmark)) - case "stopAccessing": - guard let bookmark = call.arguments as? String, !bookmark.isEmpty else { - throw SecurityScopedBookmarkError.invalidArgument(call.method) - } - stopAccessing(bookmark: bookmark) - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } catch { - result( - FlutterError( - code: "SECURITY_SCOPED_BOOKMARK_ERROR", - message: error.localizedDescription, - details: nil)) - } - } - - private func pickDecksDirectory() throws -> [String: String]? { - let panel = NSOpenPanel() - panel.title = "Choose where SuperDeck saves decks" - panel.message = "Choose a folder. SuperDeck will create its SuperDeck folder inside it." - panel.prompt = "Choose" - panel.allowsMultipleSelection = false - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.canCreateDirectories = true - panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Documents", isDirectory: true) - - return try selectURL(with: panel) - } - - private func selectURL(with panel: NSOpenPanel) throws -> [String: String]? { - guard panel.runModal() == .OK, let url = panel.url else { - return nil - } - - // NSOpenPanel adds the selected URL to the sandbox. Persist that access; - // Dart resolves and activates the bookmark before later file operations. - let bookmark = try makeBookmark(for: url) - return ["path": url.path, "bookmark": bookmark] - } - - private func startAccessing(bookmark: String) throws -> [String: String] { - if let url = activeURLs[bookmark] { - return ["path": url.path, "bookmark": bookmark] - } - guard let data = Data(base64Encoded: bookmark) else { - throw SecurityScopedBookmarkError.invalidBookmark - } - - var isStale = false - let url = try URL( - resolvingBookmarkData: data, - options: [.withSecurityScope], - relativeTo: nil, - bookmarkDataIsStale: &isStale) - guard url.startAccessingSecurityScopedResource() else { - throw SecurityScopedBookmarkError.accessDenied(url.path) - } - - do { - let activeBookmark = isStale ? try makeBookmark(for: url) : bookmark - activeURLs[activeBookmark] = url - return ["path": url.path, "bookmark": activeBookmark] - } catch { - url.stopAccessingSecurityScopedResource() - throw error - } - } - - private func stopAccessing(bookmark: String) { - activeURLs.removeValue(forKey: bookmark)?.stopAccessingSecurityScopedResource() - } - - private func makeBookmark(for url: URL) throws -> String { - let data = try url.bookmarkData( - options: [.withSecurityScope], - includingResourceValuesForKeys: nil, - relativeTo: nil) - return data.base64EncodedString() - } -} diff --git a/packages/playground/pubspec.yaml b/packages/playground/pubspec.yaml index e5192877..cdec00cf 100644 --- a/packages/playground/pubspec.yaml +++ b/packages/playground/pubspec.yaml @@ -44,9 +44,9 @@ dependencies: ack_json_schema_builder: ^1.2.0 dartantic_ai: ^3.1.0 json_schema_builder: ^0.1.5 - # Filesystem persistence for saved decks (desktop-only). - path_provider: ^2.1.4 + # Deck export: a zip written through the platform save dialog. archive: ^4.0.9 + file_saver: ^0.4.0 dev_dependencies: flutter_test: @@ -56,10 +56,6 @@ dev_dependencies: build_runner: ^2.5.4 ack_generator: ^1.2.0 http: ^1.5.0 - # Test-only: mock path_provider so the native deck file store can be exercised - # against a temp directory. - path_provider_platform_interface: ^2.1.2 - plugin_platform_interface: ^2.1.8 image: ^4.8.0 flutter: diff --git a/packages/playground/test/core/data/data_sources/security_scoped_file_access_test.dart b/packages/playground/test/core/data/data_sources/security_scoped_file_access_test.dart deleted file mode 100644 index 8944d5b5..00000000 --- a/packages/playground/test/core/data/data_sources/security_scoped_file_access_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -@TestOn('vm') -library; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:playground/core/data/data_sources/security_scoped_file_access.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel(SecurityScopedFileAccess.channelName); - final calls = []; - - setUp(() { - debugDefaultTargetPlatformOverride = TargetPlatform.macOS; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return switch (call.method) { - 'pickDecksDirectory' => { - 'path': '/Users/test/Documents', - 'bookmark': 'created-directory-bookmark', - }, - 'startAccessing' => { - 'path': '/Users/test/Documents', - 'bookmark': 'refreshed-directory-bookmark', - }, - 'stopAccessing' => null, - _ => throw MissingPluginException(call.method), - }; - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - debugDefaultTargetPlatformOverride = null; - calls.clear(); - }); - - test('selects, activates, refreshes, and releases a directory', () async { - const access = SecurityScopedFileAccess(); - final picked = await access.pickDecksDirectory(); - final activated = await access.startAccessingDirectory(picked!); - await access.stopAccessingDirectory(activated); - - expect( - picked, - const SecurityScopedDirectoryReference( - path: '/Users/test/Documents', - bookmark: 'created-directory-bookmark', - ), - ); - expect( - activated, - const SecurityScopedDirectoryReference( - path: '/Users/test/Documents', - bookmark: 'refreshed-directory-bookmark', - ), - ); - expect(calls.map((call) => call.method), [ - 'pickDecksDirectory', - 'startAccessing', - 'stopAccessing', - ]); - expect(calls.map((call) => call.arguments), [ - null, - 'created-directory-bookmark', - 'refreshed-directory-bookmark', - ]); - }); -} diff --git a/packages/playground/test/core/domain/stores/deck_document_store_test.dart b/packages/playground/test/core/domain/stores/deck_document_store_test.dart deleted file mode 100644 index 6cde5fd5..00000000 --- a/packages/playground/test/core/domain/stores/deck_document_store_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:playground/core/domain/stores/deck_document_store.dart'; - -void main() { - test('exposes its initial Markdown', () { - final store = DeckDocumentStore(markdown: '# Initial'); - addTearDown(store.dispose); - - expect(store.markdown, '# Initial'); - }); - - test('notifies once for each real replacement', () { - final store = DeckDocumentStore(markdown: '# Initial'); - addTearDown(store.dispose); - var notifications = 0; - store.addListener(() => notifications++); - - store.replaceMarkdown('# Updated'); - - expect(store.markdown, '# Updated'); - expect(notifications, 1); - }); - - test('does not notify when replacement text is identical', () { - final store = DeckDocumentStore(markdown: '# Initial'); - addTearDown(store.dispose); - var notifications = 0; - store.addListener(() => notifications++); - - store.replaceMarkdown('# Initial'); - - expect(notifications, 0); - }); -} diff --git a/packages/playground/test/features/ai/generation/domain/generated_deck_result_applier_test.dart b/packages/playground/test/features/ai/generation/domain/generated_deck_result_applier_test.dart index d1e6310b..7893486b 100644 --- a/packages/playground/test/features/ai/generation/domain/generated_deck_result_applier_test.dart +++ b/packages/playground/test/features/ai/generation/domain/generated_deck_result_applier_test.dart @@ -12,7 +12,6 @@ import 'package:playground/core/domain/stores/deck_customization_store.dart'; import 'package:playground/features/ai/generation/core/engine/schemas/outline_schema.dart'; import 'package:playground/features/ai/generation/core/engine/services/deck_generator_service.dart'; import 'package:playground/features/ai/generation/domain/generated_deck_result_applier.dart'; -import 'package:playground/core/domain/stores/deck_document_store.dart'; import 'package:superdeck/superdeck.dart'; import 'package:superdeck_builder/superdeck_builder.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -27,7 +26,6 @@ void main() { const assetKey = 'wizard-test-slide-01-opening.png'; final cache = MemoryAssetCacheStore(); final loader = MemoryDeckLoader(); - final documentStore = DeckDocumentStore(markdown: ''); final deckController = DeckController( deckLoader: loader, options: DeckOptions(), @@ -37,13 +35,11 @@ void main() { addTearDown(customizationStore.dispose); addTearDown(deckController.dispose); addTearDown(loader.dispose); - addTearDown(documentStore.dispose); final published = loader.load().first.then( (_) => cache.resolve(assetKey), ); final applier = GeneratedDeckResultApplier( - documentStore: documentStore, deckLoader: loader, assetCacheStore: cache, customizationStore: customizationStore, @@ -63,7 +59,12 @@ void main() { expect(application.published, isTrue); expect(application.cleanupError, isNull); expect(await published, isNotNull); - expect(documentStore.markdown, contains(assetKey)); + expect( + const SlideSerializer().serialize( + deckController.session.loadedSlides.value!, + ), + contains(assetKey), + ); }, ); @@ -72,7 +73,6 @@ void main() { const nextAssetKey = 'wizard-next-slide-01-opening.png'; final cache = MemoryAssetCacheStore(); final loader = MemoryDeckLoader(); - final documentStore = DeckDocumentStore(markdown: ''); final deckController = DeckController( deckLoader: loader, options: DeckOptions(), @@ -80,7 +80,6 @@ void main() { ); final customizationStore = DeckCustomizationStore(deckController); final applier = GeneratedDeckResultApplier( - documentStore: documentStore, deckLoader: loader, assetCacheStore: cache, customizationStore: customizationStore, @@ -88,7 +87,6 @@ void main() { addTearDown(customizationStore.dispose); addTearDown(deckController.dispose); addTearDown(loader.dispose); - addTearDown(documentStore.dispose); await applier.apply(_result(oldAssetKey), isValid: _always); expect(await cache.resolve(oldAssetKey), isNotNull); @@ -108,7 +106,7 @@ void main() { await host.applier.apply(_result(committedAssetKey), isValid: _always); expect(await cache.resolve(committedAssetKey), isNotNull); - final publishedMarkdown = host.documentStore.markdown; + final publishedMarkdown = await host.publishedMarkdown; final publishedTheme = host.appliedTheme; final previewEvents = []; final subscription = host._loader.load().listen(previewEvents.add); @@ -120,14 +118,14 @@ void main() { isValid: () => valid, ); await cache.writeStarted.future; - // The document moves on while the artwork write is still in flight. + // The run moves on while the artwork write is still in flight. valid = false; cache.releaseWrite(); expect((await application).published, isFalse); expect(await cache.resolve(stagedAssetKey), isNull); expect(await cache.resolve(committedAssetKey), isNotNull); - expect(host.documentStore.markdown, publishedMarkdown); + expect(await host.publishedMarkdown, publishedMarkdown); expect(host.appliedTheme, publishedTheme); expect(previewEvents, isEmpty); }); @@ -144,7 +142,7 @@ void main() { addTearDown(host.dispose); await host.applier.apply(_result(committedKey), isValid: _always); - final publishedMarkdown = host.documentStore.markdown; + final publishedMarkdown = await host.publishedMarkdown; final publishedTheme = host.appliedTheme; final previewEvents = []; final subscription = host._loader.load().listen(previewEvents.add); @@ -174,7 +172,7 @@ void main() { ), ); - expect(host.documentStore.markdown, publishedMarkdown); + expect(await host.publishedMarkdown, publishedMarkdown); expect(host.appliedTheme, publishedTheme); expect(previewEvents, isEmpty); expect(await cache.resolve(committedKey), isNotNull); @@ -187,7 +185,7 @@ void main() { ); expect(application.published, isTrue); - expect(host.documentStore.markdown, contains(nextKey)); + expect(await host.publishedMarkdown, contains(nextKey)); expect(host.appliedTheme, isNot(publishedTheme)); expect(previewEvents.whereType(), hasLength(1)); expect(await cache.resolve(nextKey), isNotNull); @@ -234,7 +232,7 @@ void main() { expect(application.published, isTrue); expect(application.cleanupError, isNotNull); - expect(host.documentStore.markdown, contains(nextAssetKey)); + expect(await host.publishedMarkdown, contains(nextAssetKey)); expect(await cache.resolve(nextAssetKey), isNotNull); }); @@ -261,34 +259,31 @@ void main() { expect((await first).published, isTrue); expect((await second).published, isTrue); expect(cache.writes, [firstAssetKey, secondAssetKey]); - expect(host.documentStore.markdown, contains(secondAssetKey)); + expect(await host.publishedMarkdown, contains(secondAssetKey)); expect(await cache.resolve(firstAssetKey), isNull); }); - test( - 'publishes the document, preview, theme, and artwork together', - () async { - const assetKey = 'wizard-published-slide-01-opening.png'; - final cache = _BlockingAssetCacheStore(); - final host = _ApplierHost(cache); - addTearDown(host.dispose); + test('publishes the preview, theme, and artwork together', () async { + const assetKey = 'wizard-published-slide-01-opening.png'; + final cache = _BlockingAssetCacheStore(); + final host = _ApplierHost(cache); + addTearDown(host.dispose); - final previewMarkdown = host.previewMarkdown; - final themeBefore = host.appliedTheme; + final previewMarkdown = host.previewMarkdown; + final themeBefore = host.appliedTheme; - final application = await host.applier.apply( - _result(assetKey), - isValid: _always, - ); + final application = await host.applier.apply( + _result(assetKey), + isValid: _always, + ); - expect(application.published, isTrue); - expect(host.documentStore.markdown, contains(assetKey)); - expect(await previewMarkdown, contains(assetKey)); - expect(host.appliedTheme, isNot(themeBefore)); - expect(host.appliedTheme.headlineFamily, 'Space Grotesk'); - expect(await cache.resolve(assetKey), isNotNull); - }, - ); + expect(application.published, isTrue); + expect(await host.publishedMarkdown, contains(assetKey)); + expect(await previewMarkdown, contains(assetKey)); + expect(host.appliedTheme, isNot(themeBefore)); + expect(host.appliedTheme.headlineFamily, 'Space Grotesk'); + expect(await cache.resolve(assetKey), isNotNull); + }); } bool _always() => true; @@ -296,8 +291,7 @@ bool _always() => true; /// Owns the stores one applier writes into. final class _ApplierHost { _ApplierHost(AssetCacheStore cache) - : documentStore = DeckDocumentStore(markdown: ''), - _loader = MemoryDeckLoader(), + : _loader = MemoryDeckLoader(), _cache = cache { _deckController = DeckController( deckLoader: _loader, @@ -306,14 +300,12 @@ final class _ApplierHost { ); customizationStore = DeckCustomizationStore(_deckController); applier = GeneratedDeckResultApplier( - documentStore: documentStore, deckLoader: _loader, assetCacheStore: _cache, customizationStore: customizationStore, ); } - final DeckDocumentStore documentStore; final MemoryDeckLoader _loader; final AssetCacheStore _cache; late final DeckController _deckController; @@ -327,6 +319,16 @@ final class _ApplierHost { headlineFamily: customizationStore.level(TextLevel.h1).family, ); + /// The deck the renderer shows now, as markdown. + Future get publishedMarkdown async { + // Loader events reach the session in a later microtask. + await Future.delayed(Duration.zero); + + return const SlideSerializer().serialize( + _deckController.session.loadedSlides.value ?? const [], + ); + } + /// The markdown the preview loader receives next. Future get previewMarkdown => _loader .load() @@ -339,7 +341,6 @@ final class _ApplierHost { customizationStore.dispose(); _deckController.dispose(); unawaited(_loader.dispose()); - documentStore.dispose(); } } diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_save_flow_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart similarity index 53% rename from packages/playground/test/features/ai/wizard/presentation/wizard_save_flow_test.dart rename to packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart index a7d7efb0..f5c68a32 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_save_flow_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart @@ -1,27 +1,27 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:playground/app/providers.dart'; -import 'package:playground/core/data/data_sources/deck_library_asset_store.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; import 'package:playground/core/domain/generated_image_asset.dart'; import 'package:playground/features/ai/generation/core/engine/schemas/outline_schema.dart'; import 'package:playground/features/ai/generation/core/engine/services/deck_generation_request.dart'; import 'package:playground/features/ai/generation/core/engine/services/deck_generator_service.dart'; import 'package:playground/features/ai/generation/core/engine/services/deck_theme_resolution.dart'; -import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; -import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_generation_controller.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_page.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_view.dart'; -import 'package:playground/features/library/domain/deck_library_controller.dart'; +import 'package:playground/features/export/data/deck_export_saver.dart'; +import 'package:playground/features/export/domain/deck_export.dart'; import 'package:provider/provider.dart'; import 'package:superdeck_core/superdeck_core.dart'; -import '../../../../helpers/fake_deck_library.dart'; - -/// A 1x1 PNG, so the artwork a save carries is real bytes. +/// A 1x1 PNG, so the artwork an export carries is real bytes. final _png = [ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, // 0, 0, 0, 1, 0, 0, 0, 1, 8, 4, 0, 0, 0, 181, 28, 12, 2, @@ -37,31 +37,24 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); - Future< - ({ - WizardGenerationController wizard, - DeckLibraryController library, - DeckLibraryAssetStore assets, - }) - > - pumpCompletedDeck(WidgetTester tester, FakeDeckLibrary library) async { + Future pumpCompletedDeck( + WidgetTester tester, + DeckExportSaver exportSaver, + ) async { final router = GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => WizardPage( isConfigured: true, - generationService: _SaveFlowGenerationService(), + generationService: _ExportFlowGenerationService(), + exportSaver: exportSaver, ), ), GoRoute( path: '/present/:index', builder: (context, state) => const Scaffold(body: Text('presenting')), ), - GoRoute( - path: '/decks', - builder: (context, state) => const Scaffold(body: Text('decks')), - ), ], ); addTearDown(router.dispose); @@ -71,15 +64,14 @@ void main() { routerConfig: router, builder: (context, child) => HeroTheme( data: HeroThemeData.light(), - child: AppProviders(deckLibrary: library, child: child!), + child: AppProviders(child: child!), ), ), ); await tester.pump(); - final wizardContext = tester.element(find.byType(WizardView)); final wizard = Provider.of( - wizardContext, + tester.element(find.byType(WizardView)), listen: false, ); await wizard.createOutline(_request); @@ -89,145 +81,79 @@ void main() { expect(find.text('Your presentation is ready'), findsOneWidget); - return ( - wizard: wizard, - library: Provider.of(wizardContext, listen: false), - assets: Provider.of(wizardContext, listen: false), - ); + return wizard; } - testWidgets('saving a finished deck writes its markdown, artwork and theme', ( + testWidgets('exporting a finished deck writes its markdown and artwork', ( tester, ) async { - final library = FakeDeckLibrary(); - - final scope = await pumpCompletedDeck(tester, library); - - await tester.tap(find.text('Save deck')); - await tester.pumpAndSettle(); + final exports = []; + await pumpCompletedDeck(tester, (export) async { + exports.add(export); - // The dialog offers the deck's own topic and saves nothing until confirmed. - expect(find.text('Save this deck'), findsOneWidget); - expect( - find.text('Urban gardens'), - findsWidgets, - reason: 'the dialog offers the deck topic as its name', - ); - expect(library.saveCount, 0); + return true; + }); - await tester.enterText( - find.byType(EditableText).last, - 'Urban gardens 2026', - ); - await tester.tap(find.widgetWithText(GestureDetector, 'Save deck').last); + await tester.tap(find.text('Export deck')); await tester.pumpAndSettle(); - expect(library.saveCount, 1); - final path = library.saved.single.reference.path; - expect( - library.markdown[path], - contains('Urban gardens strengthen cities.'), - reason: 'the saved deck carries the generated Markdown', - ); + final export = exports.single; + expect(export.name, 'Opening'); + final markdown = utf8.decode(export.files['slides.md']!); + expect(markdown, contains('Urban gardens strengthen cities.')); expect( - library.assets[path]?['hero.png'], - _png, - reason: 'the saved deck carries the generated artwork', - ); - expect( - library.themes[path]?.toJson(), - {'id': 'technical-paper', 'version': 1, 'density': 'balanced'}, - reason: 'the saved deck carries canonical, versioned theme metadata', - ); - expect( - (await scope.assets.resolve('hero.png'))?.scheme, - 'file', - reason: 'the saved deck now answers for artwork', - ); - expect( - find.textContaining('Saved "Urban gardens 2026"'), - findsOneWidget, + markdown, + contains('src: assets/hero.png'), + reason: 'the slides point at artwork shipped beside them', ); + expect(export.files['assets/hero.png'], _png); + expect(find.text('Exported "Opening".'), findsOneWidget); }); - testWidgets('cancelling the dialog writes nothing', (tester) async { - final library = FakeDeckLibrary(); - await pumpCompletedDeck(tester, library); + testWidgets('cancelling the save dialog reports nothing', (tester) async { + await pumpCompletedDeck(tester, (_) async => false); - await tester.tap(find.text('Save deck')); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(GestureDetector, 'Cancel').last); + await tester.tap(find.text('Export deck')); await tester.pumpAndSettle(); - expect(library.saveCount, 0); - expect(library.saved, isEmpty); + expect(find.textContaining('Exported'), findsNothing); + expect(find.text('Export deck'), findsOneWidget); }); - testWidgets('a second save offers another copy rather than an overwrite', ( - tester, - ) async { - final library = FakeDeckLibrary(); - await pumpCompletedDeck(tester, library); - - await tester.tap(find.text('Save deck')); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(GestureDetector, 'Save deck').last); - await tester.pumpAndSettle(); - - expect(find.text('Save another copy'), findsOneWidget); + testWidgets('a failed export says why', (tester) async { + await pumpCompletedDeck(tester, (_) async => throw StateError('disk full')); - await tester.tap(find.text('Save another copy')); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(GestureDetector, 'Save deck').last); + await tester.tap(find.text('Export deck')); await tester.pumpAndSettle(); - expect(library.saveCount, 2); - expect(library.saved.map((ref) => ref.name).toSet(), hasLength(2)); + expect( + find.textContaining('The deck could not be exported'), + findsOneWidget, + ); + expect(find.textContaining('disk full'), findsOneWidget); }); - testWidgets('a new generation releases the saved deck it was showing', ( + testWidgets('a new generation drops the previous export notice', ( tester, ) async { - final library = FakeDeckLibrary(); - final scope = await pumpCompletedDeck(tester, library); - - await tester.tap(find.text('Save deck')); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(GestureDetector, 'Save deck').last); + final wizard = await pumpCompletedDeck(tester, (_) async => true); + await tester.tap(find.text('Export deck')); await tester.pumpAndSettle(); - expect((await scope.assets.resolve('hero.png'))?.scheme, 'file'); + expect(find.text('Exported "Opening".'), findsOneWidget); - // Generating again publishes a new deck into the same runtime. - await scope.wizard.createOutline(_request); + await wizard.createOutline(_request); await tester.pump(); await tester.tap(find.text('Approve & build')); await tester.pumpAndSettle(); - expect( - (await scope.assets.resolve('hero.png'))?.scheme, - 'data', - reason: 'the new deck owns the runtime, so the saved deck must not ' - 'answer for artwork any more', - ); - expect(find.text('Save deck'), findsOneWidget); - }); - - testWidgets('a library that cannot save offers no save action', ( - tester, - ) async { - final library = FakeDeckLibrary()..canSave = false; - - await pumpCompletedDeck(tester, library); - - expect(find.text('Save deck'), findsNothing); - expect(find.text('Present deck'), findsOneWidget); + expect(find.textContaining('Exported'), findsNothing); }); } -final class _SaveFlowGenerationService extends DeckGeneratorService { - _SaveFlowGenerationService() : super(apiKey: 'test-key'); +final class _ExportFlowGenerationService extends DeckGeneratorService { + _ExportFlowGenerationService() : super(apiKey: 'test-key'); - final DeckPlan _plan = _saveFlowPlan(); + final DeckPlan _plan = _exportFlowPlan(); @override Future plan( @@ -279,7 +205,7 @@ final class _SaveFlowGenerationService extends DeckGeneratorService { ); } -DeckPlan _saveFlowPlan() { +DeckPlan _exportFlowPlan() { final themes = PresentationThemeCatalog.withDefaults(); final typography = PresentationTypographyCatalog.withDefaults(); final descriptor = themes.current('technical-paper')!; diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart index c663454f..a984f89f 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart @@ -62,42 +62,6 @@ void main() { expect(find.byType(WizardView), findsOneWidget); expect(find.text('What is the presentation about?'), findsOneWidget); expect(find.text('Describe your presentation topic…'), findsOneWidget); - expect(find.byTooltip('Saved decks'), findsOneWidget); - }); - - testWidgets('the header action opens the saved decks', (tester) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => WizardPage( - isConfigured: true, - generationService: DeckGeneratorService(apiKey: 'test-key'), - ), - ), - GoRoute( - path: '/decks', - builder: (context, state) => - const Scaffold(body: Text('Saved decks route')), - ), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - MaterialApp.router( - routerConfig: router, - builder: (context, child) => HeroTheme( - data: HeroThemeData.light(), - child: AppProviders(child: child!), - ), - ), - ); - await tester.pump(); - - await tester.tap(find.byTooltip('Saved decks')); - await tester.pumpAndSettle(); - - expect(find.text('Saved decks route'), findsOneWidget); }); testWidgets('approved outline loads the deck before Present navigation', ( diff --git a/packages/playground/test/features/library/data/mac_os_deck_library_test.dart b/packages/playground/test/features/library/data/mac_os_deck_library_test.dart deleted file mode 100644 index 90089348..00000000 --- a/packages/playground/test/features/library/data/mac_os_deck_library_test.dart +++ /dev/null @@ -1,298 +0,0 @@ -@TestOn('mac-os') -library; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:playground/core/data/data_sources/security_scoped_file_access.dart'; -import 'package:playground/core/domain/generated_image_asset.dart'; -import 'package:playground/core/result.dart'; -import 'package:playground/features/library/data/mac_os_deck_library.dart'; -import 'package:playground/features/library/domain/deck_library.dart'; -import 'package:playground/features/library/domain/saved_deck.dart'; - -import '../../../helpers/fake_security_scoped_file_access.dart'; - -/// A 1x1 PNG. Deterministic image bytes, no provider call. -final _png = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', -); -final _otherPng = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=', -); - -const _theme = SavedDeckTheme( - id: 'technical-paper', - version: 1, - density: 'balanced', -); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late Directory temp; - late FakeSecurityScopedFileAccess access; - late MacOsDeckLibrary library; - - setUp(() async { - temp = await Directory.systemTemp.createTemp('deck_library_test'); - PathProviderPlatform.instance = FakePathProvider(temp.path); - access = FakeSecurityScopedFileAccess() - ..pickedDirectory = SecurityScopedDirectoryReference( - path: temp.path, - bookmark: 'directory-bookmark', - ); - library = MacOsDeckLibrary(fileAccess: access); - }); - - tearDown(() async { - library.dispose(); - if (await temp.exists()) await temp.delete(recursive: true); - }); - - String decks(String name) => p.join(temp.path, 'SuperDeck', name); - - Future saveOk({ - String name = 'My talk', - String markdown = '# Slide\n', - List images = const [], - SavedDeckTheme? theme, - }) async { - final result = await library.save( - name: name, - markdown: markdown, - images: images, - theme: theme, - ); - switch (result) { - case Failure(:final error): - fail('$error'); - case Ok(:final value): - return value; - } - } - - group('save', () { - test('writes the deck, its artwork and its manifest', () async { - final outcome = await saveOk( - markdown: '# Slide\n\n![Hero](hero.png)\n', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _png), - ], - theme: _theme, - ); - - expect(outcome.isComplete, isTrue); - expect(outcome.savedAssetKeys, ['hero.png']); - expect(outcome.ref.name, 'My talk'); - expect(outcome.ref.reference.path, decks('My talk.md')); - expect( - await File(decks('My talk.md')).readAsString(), - '# Slide\n\n![Hero](hero.png)\n', - ); - expect( - await File(p.join(decks('My talk.assets'), 'hero.png')).readAsBytes(), - _png, - ); - - final manifest = - jsonDecode(await File(decks('My talk.deck.json')).readAsString()) - as Map; - expect(manifest['formatVersion'], SavedDeckManifest.currentFormatVersion); - expect(manifest['name'], 'My talk'); - expect(manifest['markdownFileName'], 'My talk.md'); - expect(manifest['assetsDirectoryName'], 'My talk.assets'); - expect(manifest['assetKeys'], ['hero.png']); - expect(manifest['theme'], { - 'id': 'technical-paper', - 'version': 1, - 'density': 'balanced', - }); - expect(DateTime.tryParse('${manifest['savedAt']}'), isNotNull); - }); - - test('a second save keeps the first deck', () async { - await saveOk( - markdown: '# First\n', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _png), - ], - ); - - final second = await saveOk( - markdown: '# Second\n', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _otherPng), - ], - ); - - expect(second.ref.reference.path, decks('My talk 2.md')); - expect(await File(decks('My talk.md')).readAsString(), '# First\n'); - expect(await File(decks('My talk 2.md')).readAsString(), '# Second\n'); - // Each deck keeps its own artwork under the same key. - expect( - await File(p.join(decks('My talk.assets'), 'hero.png')).readAsBytes(), - _png, - ); - expect( - await File(p.join(decks('My talk 2.assets'), 'hero.png')).readAsBytes(), - _otherPng, - ); - }); - - test('names artwork that had no bytes instead of dropping it', () async { - final outcome = await saveOk( - images: const [ - GeneratedImageAsset.failure(assetKey: 'hero.png', error: 'timeout'), - ], - ); - - expect(outcome.isComplete, isFalse); - expect(outcome.missingAssetKeys, ['hero.png']); - expect(outcome.savedAssetKeys, isEmpty); - expect(await File(decks('My talk.md')).exists(), isTrue); - expect(await Directory(decks('My talk.assets')).exists(), isFalse); - }); - - test('keeps a name a filesystem can hold', () async { - final outcome = await saveOk(name: ' ../Café talk: v2/final '); - - expect( - p.basename(outcome.ref.reference.path), - 'Café talk v2 final.md', - ); - expect(await File(outcome.ref.reference.path).exists(), isTrue); - }); - - test('an unnamed deck still gets a file', () async { - final outcome = await saveOk(name: ' '); - - expect(p.basename(outcome.ref.reference.path), 'Untitled deck.md'); - }); - - test('a failed save leaves nothing behind', () async { - final result = await library.save( - name: 'Broken', - markdown: '# Slide\n', - images: [ - GeneratedImageAsset.success( - assetKey: '../escape.png', - bytes: _png, - ), - ], - ); - - expect(result, isA>()); - expect(await File(decks('Broken.md')).exists(), isFalse); - expect(await File(decks('Broken.deck.json')).exists(), isFalse); - expect(await Directory(decks('Broken.assets')).exists(), isFalse); - expect(await File(p.join(temp.path, 'SuperDeck', 'escape.png')).exists(), - isFalse); - }); - - test('reports a cancelled folder choice instead of guessing', () async { - access.pickedDirectory = null; - final fresh = MacOsDeckLibrary(fileAccess: access); - addTearDown(fresh.dispose); - - final result = await fresh.save(name: 'Talk', markdown: '# Slide\n'); - - expect(result, isA>()); - }); - }); - - group('list and open', () { - test('lists saved decks newest first', () async { - await saveOk(name: 'Older', markdown: '# Older\n'); - await Future.delayed(const Duration(milliseconds: 5)); - await saveOk(name: 'Newer', markdown: '# Newer\n'); - - final listed = await library.list(); - - expect(listed, isA>>()); - final refs = (listed as Ok>).value; - expect(refs.map((ref) => ref.name), ['Newer', 'Older']); - }); - - test('opens a saved deck with its theme', () async { - final saved = await saveOk( - markdown: '# Slide\n', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _png), - ], - theme: _theme, - ); - - final opened = await library.open(saved.ref); - - expect(opened, isA>()); - final deck = (opened as Ok).value; - expect(deck.markdown, '# Slide\n'); - expect(deck.theme, _theme); - expect(deck.manifest?.assetKeys, ['hero.png']); - }); - - test('a deck whose manifest is unreadable still opens', () async { - final saved = await saveOk(markdown: '# Slide\n', theme: _theme); - await File(decks('My talk.deck.json')).writeAsString('{not json'); - - final opened = await library.open(saved.ref); - final listed = await library.list(); - - expect((opened as Ok).value.markdown, '# Slide\n'); - expect(opened.value.theme, isNull); - expect( - (listed as Ok>).value.single.name, - 'My talk', - reason: 'the filename names a deck whose manifest is gone', - ); - }); - - test('a manifest from a newer format is not guessed at', () async { - final saved = await saveOk(markdown: '# Slide\n', theme: _theme); - await File(decks('My talk.deck.json')).writeAsString( - jsonEncode({ - 'formatVersion': SavedDeckManifest.currentFormatVersion + 1, - 'name': 'My talk', - 'savedAt': DateTime.now().toUtc().toIso8601String(), - 'markdownFileName': 'My talk.md', - 'assetsDirectoryName': 'My talk.assets', - 'assetKeys': [], - }), - ); - - final opened = await library.open(saved.ref); - - expect((opened as Ok).value.theme, isNull); - }); - - test('a deck that is gone reports a read failure', () async { - final saved = await saveOk(); - await File(saved.ref.reference.path).delete(); - - expect(await library.open(saved.ref), isA>()); - }); - - test('resolves artwork beside the deck and refuses to escape', () async { - final saved = await saveOk( - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _png), - ], - ); - - final resolved = await library.resolveAsset(saved.ref, 'hero.png'); - final missing = await library.resolveAsset(saved.ref, 'absent.png'); - final escape = await library.resolveAsset(saved.ref, '../escape.png'); - - expect( - (resolved as Ok).value, - File(p.join(decks('My talk.assets'), 'hero.png')).uri, - ); - expect((missing as Ok).value, isNull); - expect(escape, isA>()); - }); - }); -} diff --git a/packages/playground/test/features/library/deck_library_durability_test.dart b/packages/playground/test/features/library/deck_library_durability_test.dart deleted file mode 100644 index 202f4f22..00000000 --- a/packages/playground/test/features/library/deck_library_durability_test.dart +++ /dev/null @@ -1,219 +0,0 @@ -@TestOn('mac-os') -library; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart' show Colors; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:playground/core/data/data_sources/deck_library_asset_store.dart'; -import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart'; -import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; -import 'package:playground/core/data/data_sources/security_scoped_file_access.dart'; -import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; -import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; -import 'package:playground/core/domain/generated_image_asset.dart'; -import 'package:playground/core/domain/stores/deck_customization_store.dart'; -import 'package:playground/core/domain/stores/deck_document_store.dart'; -import 'package:playground/features/library/data/mac_os_deck_library.dart'; -import 'package:playground/features/library/domain/deck_library_controller.dart'; -import 'package:playground/features/library/domain/saved_deck.dart'; -import 'package:superdeck/superdeck.dart'; - -import '../../helpers/fake_security_scoped_file_access.dart'; - -/// Deterministic 1x1 PNGs, so a resolution can be attributed to the deck it -/// came from rather than to the key alone. -final _pngA = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', -); -final _pngB = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=', -); - -const _theme = SavedDeckTheme( - id: 'technical-paper', - version: 1, - density: 'balanced', -); - -/// The objects one application run owns. -/// -/// Building a second one over the same folder is what a relaunch looks like: -/// nothing carries over except what is on disk. -final class _Run { - final MacOsDeckLibrary library; - final MemoryAssetCacheStore memory; - late final DeckLibraryAssetStore assets; - late final DeckDocumentStore document; - late final MemoryDeckLoader loader; - late final DeckController deckController; - late final DeckCustomizationStore customization; - late final DeckLibraryController controller; - - _Run(FakeSecurityScopedFileAccess access) - : library = MacOsDeckLibrary(fileAccess: access), - memory = MemoryAssetCacheStore() { - assets = DeckLibraryAssetStore(library: library, fallback: memory); - document = DeckDocumentStore(markdown: ''); - loader = MemoryDeckLoader(); - deckController = DeckController( - deckLoader: loader, - options: DeckOptions(), - assetCacheStore: assets, - ); - customization = DeckCustomizationStore( - deckController, - background: Colors.black, - foreground: Colors.white, - ); - controller = DeckLibraryController( - library: library, - documentStore: document, - deckLoader: loader, - customizationStore: customization, - assetStore: assets, - ); - } - - Future close() async { - controller.dispose(); - customization.dispose(); - deckController.dispose(); - await loader.dispose(); - document.dispose(); - library.dispose(); - } -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); - - late Directory temp; - late FakeSecurityScopedFileAccess access; - - setUp(() async { - temp = await Directory.systemTemp.createTemp('deck_library_durability'); - PathProviderPlatform.instance = FakePathProvider(temp.path); - access = FakeSecurityScopedFileAccess() - ..pickedDirectory = SecurityScopedDirectoryReference( - path: temp.path, - bookmark: 'directory-bookmark', - ); - }); - - tearDown(() async { - if (await temp.exists()) await temp.delete(recursive: true); - }); - - test('a saved deck reopens with its artwork and theme in a new run', () async { - final first = _Run(access); - first.document.replaceMarkdown('# Generated\n\n![Hero](hero.png)\n'); - await first.memory.write('hero.png', _pngA); - - final saved = await first.controller.save( - name: 'Urban gardens', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _pngA), - ], - theme: _theme, - ); - expect(saved, isTrue); - final defaultHeadline = first.customization.level(TextLevel.h1).family; - await first.close(); - - // A second run: new library, new caches, nothing in memory. - final second = _Run(access); - addTearDown(second.close); - await second.controller.refresh(); - - expect(second.controller.decks.single.name, 'Urban gardens'); - expect(await second.memory.resolve('hero.png'), isNull); - - final opened = await second.controller.open(second.controller.decks.single); - - expect(opened, isTrue); - expect(second.controller.errorMessage, isNull); - expect(second.document.markdown, contains('![Hero](hero.png)')); - final resolved = await second.assets.resolve('hero.png'); - expect(resolved, isNotNull, reason: 'the reopened deck lost its artwork'); - expect(resolved!.scheme, 'file'); - expect(await File.fromUri(resolved).readAsBytes(), _pngA); - // The theme is restored from the manifest's selection, resolved through - // the catalog, rather than left at the session default. - final expected = PresentationThemeCatalog.withDefaults().resolve( - id: _theme.id, - version: _theme.version, - density: _theme.density, - typographyCatalog: PresentationTypographyCatalog.withDefaults(), - ); - expect(second.customization.level(TextLevel.h1).family, expected.headlineFamily); - expect(expected.headlineFamily, isNot(defaultHeadline)); - }); - - test('each saved deck keeps its own artwork under the same key', () async { - final run = _Run(access); - addTearDown(run.close); - - run.document.replaceMarkdown('# A\n\n![Hero](hero.png)\n'); - await run.controller.save( - name: 'Deck A', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _pngA), - ], - theme: _theme, - ); - // A new generation owns the runtime again, then saves its own artwork. - run.controller.releaseOpenDeck(); - run.document.replaceMarkdown('# B\n\n![Hero](hero.png)\n'); - await run.controller.save( - name: 'Deck B', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _pngB), - ], - theme: _theme, - ); - - await run.controller.refresh(); - final decks = {for (final ref in run.controller.decks) ref.name: ref}; - - await run.controller.open(decks['Deck A']!); - final fromA = await run.assets.resolve('hero.png'); - expect(await File.fromUri(fromA!).readAsBytes(), _pngA); - expect(run.document.markdown, contains('# A')); - - await run.controller.open(decks['Deck B']!); - final fromB = await run.assets.resolve('hero.png'); - expect(await File.fromUri(fromB!).readAsBytes(), _pngB); - expect(run.document.markdown, contains('# B')); - - await run.controller.open(decks['Deck A']!); - expect( - await File.fromUri((await run.assets.resolve('hero.png'))!).readAsBytes(), - _pngA, - reason: 'A to B to A must return A its own artwork', - ); - }); - - test('a released deck stops answering for artwork', () async { - final run = _Run(access); - addTearDown(run.close); - run.document.replaceMarkdown('# A\n\n![Hero](hero.png)\n'); - await run.controller.save( - name: 'Deck A', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: _pngA), - ], - ); - - // The next generation's artwork lives in memory until it is saved. - run.controller.releaseOpenDeck(); - await run.memory.write('hero.png', _pngB); - final resolved = await run.assets.resolve('hero.png'); - - expect(resolved?.scheme, 'data'); - }); -} diff --git a/packages/playground/test/features/library/domain/deck_library_controller_test.dart b/packages/playground/test/features/library/domain/deck_library_controller_test.dart deleted file mode 100644 index e2d071e2..00000000 --- a/packages/playground/test/features/library/domain/deck_library_controller_test.dart +++ /dev/null @@ -1,225 +0,0 @@ -import 'package:flutter/material.dart' show Colors; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:playground/core/data/data_sources/deck_library_asset_store.dart'; -import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart'; -import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; -import 'package:playground/core/domain/generated_image_asset.dart'; -import 'package:playground/core/domain/stores/deck_customization_store.dart'; -import 'package:playground/core/domain/stores/deck_document_store.dart'; -import 'package:playground/features/library/domain/deck_library_controller.dart'; -import 'package:playground/features/library/domain/saved_deck.dart'; -import 'package:superdeck/superdeck.dart'; - -import '../../../helpers/fake_deck_library.dart'; - -const _theme = SavedDeckTheme( - id: 'technical-paper', - version: 1, - density: 'balanced', -); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); - - ({ - DeckLibraryController controller, - FakeDeckLibrary library, - DeckDocumentStore document, - MemoryDeckLoader loader, - DeckCustomizationStore customization, - DeckLibraryAssetStore assets, - MemoryAssetCacheStore memory, - }) - newScope() { - final library = FakeDeckLibrary(); - final memory = MemoryAssetCacheStore(); - final assets = DeckLibraryAssetStore(library: library, fallback: memory); - final document = DeckDocumentStore(markdown: '# Generated\n'); - final loader = MemoryDeckLoader(); - final deckController = DeckController( - deckLoader: loader, - options: DeckOptions(), - assetCacheStore: assets, - ); - final customization = DeckCustomizationStore( - deckController, - background: Colors.black, - foreground: Colors.white, - ); - final controller = DeckLibraryController( - library: library, - documentStore: document, - deckLoader: loader, - customizationStore: customization, - assetStore: assets, - ); - addTearDown(() { - controller.dispose(); - customization.dispose(); - deckController.dispose(); - loader.dispose(); - document.dispose(); - }); - - return ( - controller: controller, - library: library, - document: document, - loader: loader, - customization: customization, - assets: assets, - memory: memory, - ); - } - - group('save', () { - test('writes the current document and keeps its artwork reachable', () async { - final scope = newScope(); - await scope.memory.write('hero.png', [1, 2, 3]); - - final saved = await scope.controller.save( - name: 'My talk', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: [1, 2, 3]), - ], - theme: _theme, - ); - - expect(saved, isTrue); - expect(scope.library.markdown['/decks/My talk.md'], '# Generated\n'); - expect(scope.controller.lastSave?.isComplete, isTrue); - expect(scope.controller.decks.single.name, 'My talk'); - // The saved copy now answers for the artwork, not the run's memory. - expect(await scope.assets.resolve('hero.png'), isNotNull); - }); - - test('names artwork that had no bytes', () async { - final scope = newScope(); - - await scope.controller.save( - name: 'My talk', - images: const [ - GeneratedImageAsset.failure(assetKey: 'hero.png', error: 'timeout'), - ], - ); - - expect(scope.controller.lastSave?.isComplete, isFalse); - expect(scope.controller.lastSave?.missingAssetKeys, ['hero.png']); - }); - - test('a failed save reports why and opens nothing', () async { - final scope = newScope()..library.saveError = Exception('disk full'); - - final saved = await scope.controller.save(name: 'My talk'); - - expect(saved, isFalse); - expect(scope.controller.errorMessage, contains('disk full')); - expect(scope.controller.decks, isEmpty); - expect(scope.controller.lastSave, isNull); - }); - - test('saving twice keeps both decks', () async { - final scope = newScope(); - - await scope.controller.save(name: 'My talk'); - await scope.controller.save(name: 'My talk'); - - expect(scope.library.saveCount, 2); - expect( - scope.controller.decks.map((ref) => ref.name), - containsAll(['My talk', 'My talk 2']), - ); - }); - }); - - group('open', () { - test('publishes a saved deck and its theme', () async { - final scope = newScope(); - await scope.controller.save( - name: 'My talk', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: [4]), - ], - theme: _theme, - ); - scope.document.replaceMarkdown('# Something else\n'); - scope.controller.releaseOpenDeck(); - final ref = scope.controller.decks.single; - - final opened = await scope.controller.open(ref); - - expect(opened, isTrue); - expect(scope.document.markdown, '# Generated\n'); - expect( - await scope.assets.resolve('hero.png'), - isNotNull, - reason: 'the reopened deck answers for its own artwork', - ); - expect(scope.controller.errorMessage, isNull); - expect(scope.customization.level(TextLevel.h1).family, isNotEmpty); - }); - - test('a theme the catalog no longer has is reported, not guessed', () async { - final scope = newScope(); - await scope.controller.save( - name: 'My talk', - theme: const SavedDeckTheme( - id: 'technical-paper', - version: 99, - density: 'balanced', - ), - ); - final familyBefore = scope.customization.level(TextLevel.h1).family; - - await scope.controller.open(scope.controller.decks.single); - - expect(scope.controller.errorMessage, contains('no longer has')); - expect(scope.customization.level(TextLevel.h1).family, familyBefore); - }); - - test('a deck that cannot be read reports and keeps the current one', () async { - final scope = newScope()..library.openError = Exception('gone'); - await scope.controller.save(name: 'My talk'); - final ref = scope.controller.decks.single; - scope.controller.releaseOpenDeck(); - - final opened = await scope.controller.open(ref); - - expect(opened, isFalse); - expect(scope.controller.errorMessage, contains('gone')); - expect(scope.document.markdown, '# Generated\n'); - }); - - test('releasing the open deck returns artwork to the run', () async { - final scope = newScope(); - await scope.memory.write('hero.png', [9]); - await scope.controller.save( - name: 'My talk', - images: [ - GeneratedImageAsset.success(assetKey: 'hero.png', bytes: [1]), - ], - ); - - scope.controller.releaseOpenDeck(); - - final resolved = await scope.assets.resolve('hero.png'); - expect(resolved, isNotNull); - expect( - resolved!.scheme, - 'data', - reason: 'the run\'s own artwork answers again, not the saved deck\'s', - ); - }); - }); - - test('refresh reports a library it cannot read', () async { - final scope = newScope()..library.listError = Exception('no folder'); - - await scope.controller.refresh(); - - expect(scope.controller.decks, isEmpty); - expect(scope.controller.errorMessage, contains('no folder')); - }); -} diff --git a/packages/playground/test/features/library/presentation/saved_decks_page_test.dart b/packages/playground/test/features/library/presentation/saved_decks_page_test.dart deleted file mode 100644 index 160dd905..00000000 --- a/packages/playground/test/features/library/presentation/saved_decks_page_test.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:hero_ui/hero_ui.dart'; -import 'package:playground/core/data/data_sources/deck_library_asset_store.dart'; -import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart'; -import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; -import 'package:playground/core/domain/stores/deck_customization_store.dart'; -import 'package:playground/core/domain/stores/deck_document_store.dart'; -import 'package:playground/features/library/domain/deck_library_controller.dart'; -import 'package:playground/features/library/presentation/saved_decks_page.dart'; -import 'package:provider/provider.dart'; -import 'package:superdeck/superdeck.dart'; - -import '../../../helpers/fake_deck_library.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); - - Future<({DeckLibraryController controller, DeckDocumentStore document})> - pumpPage(WidgetTester tester, FakeDeckLibrary library) async { - final memory = MemoryAssetCacheStore(); - final assets = DeckLibraryAssetStore(library: library, fallback: memory); - final document = DeckDocumentStore(markdown: ''); - final loader = MemoryDeckLoader(); - final deckController = DeckController( - deckLoader: loader, - options: DeckOptions(), - assetCacheStore: assets, - ); - final customization = DeckCustomizationStore( - deckController, - background: Colors.black, - foreground: Colors.white, - ); - final controller = DeckLibraryController( - library: library, - documentStore: document, - deckLoader: loader, - customizationStore: customization, - assetStore: assets, - ); - addTearDown(() { - controller.dispose(); - customization.dispose(); - deckController.dispose(); - loader.dispose(); - document.dispose(); - }); - - await tester.pumpWidget( - MaterialApp.router( - routerConfig: GoRouter( - routes: [ - GoRoute( - path: '/decks', - builder: (context, state) => const SavedDecksPage(), - ), - GoRoute( - path: '/present/:index', - builder: (context, state) => const Text('presenting'), - ), - ], - initialLocation: '/decks', - ), - builder: (context, child) => HeroTheme( - data: HeroThemeData.dark(), - child: ChangeNotifierProvider.value(value: controller, child: child!), - ), - ), - ); - await tester.pumpAndSettle(); - - return (controller: controller, document: document); - } - - testWidgets('offers nothing to present before a deck is saved', ( - tester, - ) async { - await pumpPage(tester, FakeDeckLibrary()); - - expect(find.textContaining('No decks yet'), findsOneWidget); - expect(find.text('Present'), findsNothing); - }); - - testWidgets('lists saved decks and presents the one chosen', (tester) async { - final library = FakeDeckLibrary(); - await library.save(name: 'Quarterly review', markdown: '# Q3\n'); - final scope = await pumpPage(tester, library); - - expect(find.text('Quarterly review'), findsOneWidget); - expect(find.textContaining(RegExp(r'Saved \d{4}-')), findsOneWidget); - - await tester.tap(find.text('Present')); - await tester.pumpAndSettle(); - - expect(find.text('presenting'), findsOneWidget); - expect(scope.document.markdown, '# Q3\n'); - }); - - testWidgets('a library it cannot read says so instead of looking empty', ( - tester, - ) async { - final library = FakeDeckLibrary()..listError = Exception('no folder'); - - await pumpPage(tester, library); - - expect(find.textContaining('no folder'), findsOneWidget); - }); -} diff --git a/packages/playground/test/helpers/fake_deck_library.dart b/packages/playground/test/helpers/fake_deck_library.dart deleted file mode 100644 index 73100a70..00000000 --- a/packages/playground/test/helpers/fake_deck_library.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:playground/core/data/data_sources/deck_file.dart'; -import 'package:playground/core/domain/generated_image_asset.dart'; -import 'package:playground/core/result.dart'; -import 'package:playground/features/library/domain/deck_library.dart'; -import 'package:playground/features/library/domain/saved_deck.dart'; - -/// In-memory [DeckLibrary] for tests that need saved decks without a disk. -class FakeDeckLibrary implements DeckLibrary { - final Map markdown = {}; - final Map>> assets = {}; - final Map themes = {}; - final List saved = []; - - @override - bool canSave = true; - - Exception? saveError; - Exception? openError; - Exception? listError; - int saveCount = 0; - - @override - Future> save({ - required String name, - required String markdown, - List images = const [], - SavedDeckTheme? theme, - }) async { - saveCount++; - final error = saveError; - if (error != null) return Result.error(error); - - final stem = saved.any((ref) => ref.name == name) - ? '$name ${saved.where((ref) => ref.name.startsWith(name)).length + 1}' - : name; - final path = '/decks/$stem.md'; - final ref = SavedDeckRef( - reference: DeckFileReference(path: path), - name: stem, - savedAt: DateTime.now(), - ); - this.markdown[path] = markdown; - themes[path] = theme; - final written = []; - final missing = []; - for (final image in images) { - final bytes = image.bytes; - if (bytes == null || bytes.isEmpty) { - missing.add(image.assetKey); - continue; - } - (assets[path] ??= {})[image.assetKey] = bytes; - written.add(image.assetKey); - } - saved.insert(0, ref); - - return Result.ok( - DeckSaveOutcome( - ref: ref, - savedAssetKeys: written, - missingAssetKeys: missing, - ), - ); - } - - @override - Future>> list() async { - final error = listError; - - return error != null ? Result.error(error) : Result.ok(List.of(saved)); - } - - @override - Future> open(SavedDeckRef ref) async { - final error = openError; - if (error != null) return Result.error(error); - final content = markdown[ref.reference.path]; - if (content == null) { - return Result.error( - DeckFileReadException(ref.reference.path, StateError('missing')), - ); - } - - return Result.ok( - SavedDeck( - ref: ref, - markdown: content, - manifest: SavedDeckManifest( - formatVersion: SavedDeckManifest.currentFormatVersion, - name: ref.name, - savedAt: ref.savedAt, - markdownFileName: '${ref.name}.md', - assetsDirectoryName: '${ref.name}.assets', - assetKeys: assets[ref.reference.path]?.keys.toList() ?? const [], - theme: themes[ref.reference.path], - ), - ), - ); - } - - @override - Future> resolveAsset(SavedDeckRef ref, String assetKey) async { - final bytes = assets[ref.reference.path]?[assetKey]; - - return Result.ok( - bytes == null ? null : Uri.file('${ref.reference.path}.assets/$assetKey'), - ); - } - - @override - void dispose() {} -} diff --git a/packages/playground/test/helpers/fake_security_scoped_file_access.dart b/packages/playground/test/helpers/fake_security_scoped_file_access.dart deleted file mode 100644 index 9458217a..00000000 --- a/packages/playground/test/helpers/fake_security_scoped_file_access.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:playground/core/data/data_sources/security_scoped_file_access.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -/// Points the library's settings and default deck folder at a temp root. -class FakePathProvider extends PathProviderPlatform - with MockPlatformInterfaceMixin { - FakePathProvider(this.root); - - final String root; - - @override - Future getApplicationDocumentsPath() async => root; - - @override - Future getApplicationSupportPath() async => root; -} - -/// Stands in for the macOS picker and bookmark APIs, which need a real user -/// and a real sandbox. Everything below it is ordinary file I/O. -class FakeSecurityScopedFileAccess extends SecurityScopedFileAccess { - SecurityScopedDirectoryReference? pickedDirectory; - final Map - restoredDirectories = {}; - final List startedDirectories = []; - final List stoppedDirectories = []; - Object? startError; - Object? stopError; - - @override - Future pickDecksDirectory() async { - return pickedDirectory; - } - - @override - Future startAccessingDirectory( - SecurityScopedDirectoryReference reference, - ) async { - startedDirectories.add(reference); - final error = startError; - if (error != null) throw error; - return restoredDirectories[reference] ?? reference; - } - - @override - Future stopAccessingDirectory( - SecurityScopedDirectoryReference reference, - ) async { - stoppedDirectories.add(reference); - final error = stopError; - if (error != null) throw error; - } -} From 0043596cb073cea4c96664a7b1dcfab7755e808c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 22 Sep 2026 20:51:48 -0500 Subject: [PATCH 4/5] docs: describe deck export instead of the saved-deck library --- AGENTS.md | 4 ++-- packages/playground/README.md | 44 +++++++++++------------------------ 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02f3edf6..9331afdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ packages/ superdeck/ # Flutter widgets and presentation components cli/ # superdeck CLI tool (setup, build/watch, version) builder/ # Deck parsing, serialization, and build/watch pipeline (Dart-only) - playground/ # SuperDeck Wizard: AI deck generation, presenting, saving + playground/ # SuperDeck Wizard: AI deck generation, presenting, exporting plugins/pdf/ # PDF export plugin demo/ # Sample presentation app docs/ # User-facing documentation (MDX format) @@ -33,7 +33,7 @@ docs/ # User-facing documentation (MDX format) - **superdeck**: Flutter widgets, DeckController, navigation, thumbnail/capture services, theme system - **cli**: CLI commands for project setup and building slides - **builder**: Markdown deck parsing, serialization, build/watch orchestration, and build plugins -- **playground**: AI deck generation (the Wizard), present mode, and the saved-deck library +- **playground**: AI deck generation (the Wizard), present mode, and deck export - **plugins/pdf**: PDF capture and export UI ## Environment Setup diff --git a/packages/playground/README.md b/packages/playground/README.md index 05252f5d..13ca8d59 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -1,34 +1,26 @@ # playground The SuperDeck Wizard. One flow: describe a presentation, review the outline it -plans, approve it, present the deck, and save it. Package name kept as +plans, approve it, present the deck, and export it. Package name kept as `playground` for now. -Generated decks live in memory. The app does not open arbitrary Markdown, -watch files, or auto-save; a deck is written only when the reader asks for it. +The deck lives in memory for the run. The app does not open Markdown files or +keep a library of decks; a deck leaves the app only when the reader exports it. -## What a saved deck is +## Exporting a deck -Three files that travel together in your SuperDeck folder: +**Export deck** saves a zip through the platform's save dialog: ``` -My talk.md the Markdown the generation produced -My talk.assets/ the artwork it refers to by bare filename -My talk.deck.json name, timestamp, asset list, and the theme selection +slides.md the Markdown the generation produced +assets/ its generated artwork ``` -The manifest stores the theme's catalog id, version and density, not resolved -colours, so a deck reopened later is recognised rather than guessed at. A deck -whose manifest is missing or unreadable still opens; it opens without its -theme. - -**Moving, copying or sharing a deck has to include its asset directory**, or -its images stop resolving. Saving the same deck twice keeps both copies -("My talk", then "My talk 2") — a save never replaces a deck you already have. - -Saving is macOS-only for now: it needs a folder you pick once, which the app -remembers. Generating and presenting work anywhere; the save action is simply -absent where decks cannot be stored. +`slides.md` refers to each image as `assets/`, so the unzipped folder is +a SuperDeck project: build it with the CLI and present it from any SuperDeck +app. Declare `assets/` in that app's `pubspec.yaml` to bundle the images for +release and web builds. The export carries the Markdown and artwork, not the +generated theme. ## Layers (folders, single package) @@ -37,12 +29,11 @@ lib/ main.dart app/ # router.dart + providers.dart (app-root DI) core/ # shared cross-feature domain + data - result.dart # Result - domain/ # stores (document, customization), design catalogs + domain/ # customization store, design catalogs data/ # data_sources (asset stores, deck loader), mappers features/ ai/ # the Wizard, the generation engine, image generation - library/ # saved decks: domain / data / presentation / routes + export/ # the deck as a SuperDeck project zip presentation/ # present mode ``` @@ -159,10 +150,3 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ Add `--dart-define=LIVE_DEBUG_LAYOUT=true` to include the section, block, margin, and padding guides in the recaptured slide PNGs and contact sheet. Normal captures remain clean by default. - -## Deck files - -On first launch, choose a parent directory for deck storage. The app creates a -`SuperDeck` folder inside it and remembers access with a macOS security-scoped -bookmark. The saved-deck library lists the decks in that folder and opens them -read-only for presenting. The Wizard does not open arbitrary Markdown files. From e2152d46f9c30764baceeda97ee04b6d60bce8cb Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 22 Sep 2026 21:14:44 -0500 Subject: [PATCH 5/5] fix(playground): name exports after the deck topic and zip off the UI isolate The save dialog now suggests the deck's topic, as the old save dialog did, falling back to the first slide title. Encoding the zip compresses every generated image, so it runs through compute instead of blocking frames. --- .../ai/wizard/presentation/wizard_page.dart | 1 + .../export/data/deck_export_saver.dart | 8 ++++++- .../features/export/domain/deck_export.dart | 22 +++++++++++++------ .../presentation/wizard_export_flow_test.dart | 6 ++--- .../export/domain/deck_export_test.dart | 18 +++++++++++++-- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart index 36961992..71c56c49 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart @@ -150,6 +150,7 @@ class _WizardExperienceState extends State<_WizardExperience> { final export = DeckExport.fromDeck( slides: result.slides, images: result.generatedImages, + title: result.plan?.topic, ); setState(() => _isExporting = true); String? message; diff --git a/packages/playground/lib/features/export/data/deck_export_saver.dart b/packages/playground/lib/features/export/data/deck_export_saver.dart index 1a45d0e8..b11a49a1 100644 --- a/packages/playground/lib/features/export/data/deck_export_saver.dart +++ b/packages/playground/lib/features/export/data/deck_export_saver.dart @@ -1,4 +1,5 @@ import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.dart'; import '../domain/deck_export.dart'; @@ -7,11 +8,16 @@ import '../domain/deck_export.dart'; /// Completes with `false` when the reader cancels. typedef DeckExportSaver = Future Function(DeckExport export); +Uint8List _encodeZip(DeckExport export) => export.toZip(); + /// Saves [export] as a zip through the platform's save dialog. +/// +/// The zip is encoded off the UI isolate, since compressing generated +/// artwork can take long enough to drop frames. Future saveDeckExportAsZip(DeckExport export) async { final path = await FileSaver.instance.saveAs( name: export.name, - bytes: export.toZip(), + bytes: await compute(_encodeZip, export), fileExtension: 'zip', mimeType: MimeType.zip, ); diff --git a/packages/playground/lib/features/export/domain/deck_export.dart b/packages/playground/lib/features/export/domain/deck_export.dart index 7804cc32..82cda0db 100644 --- a/packages/playground/lib/features/export/domain/deck_export.dart +++ b/packages/playground/lib/features/export/domain/deck_export.dart @@ -28,11 +28,13 @@ final class DeckExport { /// Builds the export for [slides] and the [images] generated for them. /// - /// Images without bytes are left out; their slides no longer reference - /// them. + /// The export is named after [title], or the first slide's title without + /// one. Images without bytes are left out; their slides no longer + /// reference them. factory DeckExport.fromDeck({ required List slides, required List images, + String? title, }) { var markdown = const SlideSerializer().serialize(slides); final assets = >{}; @@ -49,14 +51,20 @@ final class DeckExport { } return DeckExport._( - name: _nameFor(slides), + name: + _fileStem(title) ?? + _fileStem(_firstSlideTitle(slides)) ?? + _fallbackName, files: {_markdownPath: utf8.encode(markdown), ...assets}, ); } - /// The first slide's title, made safe to use as a file name. - static String _nameFor(List slides) { - final title = slides.isEmpty ? null : slides.first.options?.title; + static String? _firstSlideTitle(List slides) => + slides.isEmpty ? null : slides.first.options?.title; + + /// [title] made safe to use as a file name, or `null` when nothing usable + /// is left. + static String? _fileStem(String? title) { final stem = (title ?? '') .replaceAll(RegExp(r'[\x00-\x1f/\\:]'), ' ') .replaceAll(RegExp(r'\s+'), ' ') @@ -64,7 +72,7 @@ final class DeckExport { .replaceAll(RegExp(r'^\.+'), '') .trim(); - return stem.isEmpty ? _fallbackName : stem; + return stem.isEmpty ? null : stem; } /// Encodes [files] as a zip archive. diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart index f5c68a32..e239e327 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_export_flow_test.dart @@ -98,7 +98,7 @@ void main() { await tester.pumpAndSettle(); final export = exports.single; - expect(export.name, 'Opening'); + expect(export.name, 'Urban gardens', reason: 'named after the deck topic'); final markdown = utf8.decode(export.files['slides.md']!); expect(markdown, contains('Urban gardens strengthen cities.')); expect( @@ -107,7 +107,7 @@ void main() { reason: 'the slides point at artwork shipped beside them', ); expect(export.files['assets/hero.png'], _png); - expect(find.text('Exported "Opening".'), findsOneWidget); + expect(find.text('Exported "Urban gardens".'), findsOneWidget); }); testWidgets('cancelling the save dialog reports nothing', (tester) async { @@ -139,7 +139,7 @@ void main() { final wizard = await pumpCompletedDeck(tester, (_) async => true); await tester.tap(find.text('Export deck')); await tester.pumpAndSettle(); - expect(find.text('Exported "Opening".'), findsOneWidget); + expect(find.text('Exported "Urban gardens".'), findsOneWidget); await wizard.createOutline(_request); await tester.pump(); diff --git a/packages/playground/test/features/export/domain/deck_export_test.dart b/packages/playground/test/features/export/domain/deck_export_test.dart index 7819b0fa..3cfb941f 100644 --- a/packages/playground/test/features/export/domain/deck_export_test.dart +++ b/packages/playground/test/features/export/domain/deck_export_test.dart @@ -68,8 +68,22 @@ void main() { expect(markdown, isNot(contains('assets/$_chartKey'))); }); - test('names the export after the first slide title', () { - final export = DeckExport.fromDeck(slides: slides, images: const []); + test('names the export after the given title', () { + final export = DeckExport.fromDeck( + slides: slides, + images: const [], + title: 'City food: a primer', + ); + + expect(export.name, 'City food a primer'); + }); + + test('falls back to the first slide title', () { + final export = DeckExport.fromDeck( + slides: slides, + images: const [], + title: ' ', + ); expect(export.name, 'Urban gardens'); });