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. 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/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/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..71c56c49 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,49 @@ 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, - }, + title: result.plan?.topic, ); + 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 +214,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 +232,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 +267,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..b11a49a1 --- /dev/null +++ b/packages/playground/lib/features/export/data/deck_export_saver.dart @@ -0,0 +1,26 @@ +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.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); + +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: await compute(_encodeZip, export), + fileExtension: 'zip', + mimeType: MimeType.zip, + ); + + return path != null; +} 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..82cda0db --- /dev/null +++ b/packages/playground/lib/features/export/domain/deck_export.dart @@ -0,0 +1,87 @@ +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. + /// + /// 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 = >{}; + 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) => + 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+'), ' ') + .trim() + .replaceAll(RegExp(r'^\.+'), '') + .trim(); + + return stem.isEmpty ? null : 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/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 05dd4ab6..cdec00cf 100644 --- a/packages/playground/pubspec.yaml +++ b/packages/playground/pubspec.yaml @@ -44,8 +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: @@ -55,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/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); +} 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..e239e327 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, 'Urban gardens', reason: 'named after the deck topic'); + 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 "Urban gardens".'), 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 "Urban gardens".'), 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/export/domain/deck_export_test.dart b/packages/playground/test/features/export/domain/deck_export_test.dart new file mode 100644 index 00000000..3cfb941f --- /dev/null +++ b/packages/playground/test/features/export/domain/deck_export_test.dart @@ -0,0 +1,128 @@ +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 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'); + }); + + 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']!), + ); + }); +} 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; - } -}