diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 06fcf7e6..a3a2e77d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,6 +17,7 @@ on: jobs: validate: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 460544d0..b576d54e 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -21,6 +21,7 @@ jobs: build_and_deploy: runs-on: ubuntu-latest + timeout-minutes: 20 needs: test steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 476533f6..436635ba 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -21,6 +21,7 @@ jobs: build_and_preview: if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22b0a5bf..e86c14d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 name: Test steps: - uses: actions/checkout@v2 @@ -58,6 +59,7 @@ jobs: integration-test: runs-on: ubuntu-latest + timeout-minutes: 20 name: Integration Tests steps: - uses: actions/checkout@v2 @@ -112,6 +114,7 @@ jobs: web-smoke: runs-on: ubuntu-latest + timeout-minutes: 20 name: Web Smoke (Playwright) steps: - uses: actions/checkout@v4 diff --git a/packages/core/build.yaml b/packages/core/build.yaml new file mode 100644 index 00000000..3adb6803 --- /dev/null +++ b/packages/core/build.yaml @@ -0,0 +1,10 @@ +global_options: + dart_mappable_builder: + options: + caseStyle: none + enumCaseStyle: none + generateMethods: + - decode + - copy + - stringify + - equals diff --git a/packages/core/lib/src/deck_configuration.dart b/packages/core/lib/src/deck_configuration.dart index 384a0cc5..2a3e47dd 100644 --- a/packages/core/lib/src/deck_configuration.dart +++ b/packages/core/lib/src/deck_configuration.dart @@ -2,12 +2,15 @@ import 'dart:io'; import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; +import 'package:dart_mappable/dart_mappable.dart'; import 'package:path/path.dart' as p; part 'deck_configuration.g.dart'; +part 'deck_configuration.mapper.dart'; @AckModel() -final class DeckConfiguration { +@MappableClass() +final class DeckConfiguration with DeckConfigurationMappable { final String? projectDir; final String? slidesPath; final String? outputDir; @@ -84,20 +87,6 @@ final class DeckConfiguration { File get pubspecFile => File(p.join(_baseDir, 'pubspec.yaml')); - DeckConfiguration copyWith({ - String? projectDir, - String? slidesPath, - String? outputDir, - String? assetsPath, - }) { - return DeckConfiguration( - projectDir: projectDir ?? this.projectDir, - slidesPath: slidesPath ?? this.slidesPath, - outputDir: outputDir ?? this.outputDir, - assetsPath: assetsPath ?? this.assetsPath, - ); - } - Map toMap() { return { if (projectDir != null) 'projectDir': projectDir, @@ -129,18 +118,4 @@ final class DeckConfiguration { }).passthrough(); static File get defaultFile => File('superdeck.yaml'); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is DeckConfiguration && - runtimeType == other.runtimeType && - projectDir == other.projectDir && - slidesPath == other.slidesPath && - outputDir == other.outputDir && - assetsPath == other.assetsPath; - - @override - int get hashCode => - Object.hash(projectDir, slidesPath, outputDir, assetsPath); } diff --git a/packages/core/lib/src/deck_configuration.g.dart b/packages/core/lib/src/deck_configuration.g.dart index e9db7997..6089f14e 100644 --- a/packages/core/lib/src/deck_configuration.g.dart +++ b/packages/core/lib/src/deck_configuration.g.dart @@ -5,8 +5,6 @@ // AckSchemaGenerator // ************************************************************************** -// // GENERATED CODE - DO NOT MODIFY BY HAND - part of 'deck_configuration.dart'; /// Generated schema for DeckConfiguration diff --git a/packages/core/lib/src/deck_configuration.mapper.dart b/packages/core/lib/src/deck_configuration.mapper.dart new file mode 100644 index 00000000..5ca00f47 --- /dev/null +++ b/packages/core/lib/src/deck_configuration.mapper.dart @@ -0,0 +1,174 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'deck_configuration.dart'; + +class DeckConfigurationMapper extends ClassMapperBase { + DeckConfigurationMapper._(); + + static DeckConfigurationMapper? _instance; + static DeckConfigurationMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = DeckConfigurationMapper._()); + } + return _instance!; + } + + @override + final String id = 'DeckConfiguration'; + + static String? _$projectDir(DeckConfiguration v) => v.projectDir; + static const Field _f$projectDir = Field( + 'projectDir', + _$projectDir, + opt: true, + ); + static String? _$slidesPath(DeckConfiguration v) => v.slidesPath; + static const Field _f$slidesPath = Field( + 'slidesPath', + _$slidesPath, + opt: true, + ); + static String? _$outputDir(DeckConfiguration v) => v.outputDir; + static const Field _f$outputDir = Field( + 'outputDir', + _$outputDir, + opt: true, + ); + static String? _$assetsPath(DeckConfiguration v) => v.assetsPath; + static const Field _f$assetsPath = Field( + 'assetsPath', + _$assetsPath, + opt: true, + ); + + @override + final MappableFields fields = const { + #projectDir: _f$projectDir, + #slidesPath: _f$slidesPath, + #outputDir: _f$outputDir, + #assetsPath: _f$assetsPath, + }; + + static DeckConfiguration _instantiate(DecodingData data) { + return DeckConfiguration( + projectDir: data.dec(_f$projectDir), + slidesPath: data.dec(_f$slidesPath), + outputDir: data.dec(_f$outputDir), + assetsPath: data.dec(_f$assetsPath), + ); + } + + @override + final Function instantiate = _instantiate; + + static DeckConfiguration fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static DeckConfiguration fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin DeckConfigurationMappable { + DeckConfigurationCopyWith< + DeckConfiguration, + DeckConfiguration, + DeckConfiguration + > + get copyWith => + _DeckConfigurationCopyWithImpl( + this as DeckConfiguration, + $identity, + $identity, + ); + @override + String toString() { + return DeckConfigurationMapper.ensureInitialized().stringifyValue( + this as DeckConfiguration, + ); + } + + @override + bool operator ==(Object other) { + return DeckConfigurationMapper.ensureInitialized().equalsValue( + this as DeckConfiguration, + other, + ); + } + + @override + int get hashCode { + return DeckConfigurationMapper.ensureInitialized().hashValue( + this as DeckConfiguration, + ); + } +} + +extension DeckConfigurationValueCopy<$R, $Out> + on ObjectCopyWith<$R, DeckConfiguration, $Out> { + DeckConfigurationCopyWith<$R, DeckConfiguration, $Out> + get $asDeckConfiguration => $base.as( + (v, t, t2) => _DeckConfigurationCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +abstract class DeckConfigurationCopyWith< + $R, + $In extends DeckConfiguration, + $Out +> + implements ClassCopyWith<$R, $In, $Out> { + $R call({ + String? projectDir, + String? slidesPath, + String? outputDir, + String? assetsPath, + }); + DeckConfigurationCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +class _DeckConfigurationCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, DeckConfiguration, $Out> + implements DeckConfigurationCopyWith<$R, DeckConfiguration, $Out> { + _DeckConfigurationCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + DeckConfigurationMapper.ensureInitialized(); + @override + $R call({ + Object? projectDir = $none, + Object? slidesPath = $none, + Object? outputDir = $none, + Object? assetsPath = $none, + }) => $apply( + FieldCopyWithData({ + if (projectDir != $none) #projectDir: projectDir, + if (slidesPath != $none) #slidesPath: slidesPath, + if (outputDir != $none) #outputDir: outputDir, + if (assetsPath != $none) #assetsPath: assetsPath, + }), + ); + @override + DeckConfiguration $make(CopyWithData data) => DeckConfiguration( + projectDir: data.get(#projectDir, or: $value.projectDir), + slidesPath: data.get(#slidesPath, or: $value.slidesPath), + outputDir: data.get(#outputDir, or: $value.outputDir), + assetsPath: data.get(#assetsPath, or: $value.assetsPath), + ); + + @override + DeckConfigurationCopyWith<$R2, DeckConfiguration, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _DeckConfigurationCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/packages/core/lib/src/deck_service.dart b/packages/core/lib/src/deck_service.dart index 3bfe4e55..e108afad 100644 --- a/packages/core/lib/src/deck_service.dart +++ b/packages/core/lib/src/deck_service.dart @@ -37,7 +37,7 @@ class DeckService { } final content = await file.readAsString(); final data = jsonDecode(content) as Map; - return Deck.fromMap(data); + return Deck.parse(data); } on Exception catch (e) { return _createErrorDeck( 'Superdeck reference error', diff --git a/packages/core/lib/src/models/asset_model.dart b/packages/core/lib/src/models/asset_model.dart index 87c648a1..f79408dd 100644 --- a/packages/core/lib/src/models/asset_model.dart +++ b/packages/core/lib/src/models/asset_model.dart @@ -1,6 +1,11 @@ import 'package:collection/collection.dart'; +import 'package:dart_mappable/dart_mappable.dart'; import 'package:superdeck_core/superdeck_core.dart'; + +part 'asset_model.mapper.dart'; + +@MappableEnum() enum AssetExtension { png, jpeg, @@ -32,7 +37,8 @@ enum AssetExtension { } } -class GeneratedAsset { +@MappableClass() +class GeneratedAsset with GeneratedAssetMappable { final String name; final AssetExtension extension; final String type; @@ -47,18 +53,6 @@ class GeneratedAsset { static String buildKey(String valueToHash) => generateValueHash(valueToHash); - GeneratedAsset copyWith({ - String? name, - AssetExtension? extension, - String? type, - }) { - return GeneratedAsset( - name: name ?? this.name, - extension: extension ?? this.extension, - type: type ?? this.type, - ); - } - Map toMap() { return {'name': name, 'extension': extension.name, 'type': type}; } @@ -77,6 +71,9 @@ class GeneratedAsset { "type": Ack.string(), }); + static GeneratedAsset parse(Map map) => + fromMap(schema.parse(map)!); + static GeneratedAsset thumbnail(String slideKey) { return GeneratedAsset( name: slideKey, @@ -100,35 +97,18 @@ class GeneratedAsset { type: 'image', ); } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is GeneratedAsset && - runtimeType == other.runtimeType && - name == other.name && - extension == other.extension && - type == other.type; - - @override - int get hashCode => Object.hash(name, extension, type); } -class GeneratedAssetsReference { +@MappableClass() +class GeneratedAssetsReference with GeneratedAssetsReferenceMappable { + @MappableField(key: 'last_modified') final DateTime lastModified; final List files; - GeneratedAssetsReference({required this.lastModified, required this.files}); - - GeneratedAssetsReference copyWith({ - DateTime? lastModified, - List? files, - }) { - return GeneratedAssetsReference( - lastModified: lastModified ?? this.lastModified, - files: files ?? this.files, - ); - } + GeneratedAssetsReference({ + required this.lastModified, + required List files, + }) : files = List.unmodifiable(files); Map toMap() { return {'last_modified': lastModified.toIso8601String(), 'files': files}; @@ -143,15 +123,11 @@ class GeneratedAssetsReference { ); } - @override - bool operator ==(Object other) => - identical(this, other) || - other is GeneratedAssetsReference && - runtimeType == other.runtimeType && - lastModified == other.lastModified && - const ListEquality().equals(files, other.files); - - @override - int get hashCode => - Object.hash(lastModified, const ListEquality().hash(files)); + static final schema = Ack.object({ + 'last_modified': Ack.string(), + 'files': Ack.list(Ack.string()), + }); + + static GeneratedAssetsReference parse(Map map) => + fromMap(schema.parse(map)!); } diff --git a/packages/core/lib/src/models/asset_model.mapper.dart b/packages/core/lib/src/models/asset_model.mapper.dart new file mode 100644 index 00000000..c64d5cad --- /dev/null +++ b/packages/core/lib/src/models/asset_model.mapper.dart @@ -0,0 +1,334 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'asset_model.dart'; + +class AssetExtensionMapper extends EnumMapper { + AssetExtensionMapper._(); + + static AssetExtensionMapper? _instance; + static AssetExtensionMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = AssetExtensionMapper._()); + } + return _instance!; + } + + static AssetExtension fromValue(dynamic value) { + ensureInitialized(); + return MapperContainer.globals.fromValue(value); + } + + @override + AssetExtension decode(dynamic value) { + switch (value) { + case r'png': + return AssetExtension.png; + case r'jpeg': + return AssetExtension.jpeg; + case r'gif': + return AssetExtension.gif; + case r'webp': + return AssetExtension.webp; + case r'svg': + return AssetExtension.svg; + default: + throw MapperException.unknownEnumValue(value); + } + } + + @override + dynamic encode(AssetExtension self) { + switch (self) { + case AssetExtension.png: + return r'png'; + case AssetExtension.jpeg: + return r'jpeg'; + case AssetExtension.gif: + return r'gif'; + case AssetExtension.webp: + return r'webp'; + case AssetExtension.svg: + return r'svg'; + } + } +} + +extension AssetExtensionMapperExtension on AssetExtension { + String toValue() { + AssetExtensionMapper.ensureInitialized(); + return MapperContainer.globals.toValue(this) as String; + } +} + +class GeneratedAssetMapper extends ClassMapperBase { + GeneratedAssetMapper._(); + + static GeneratedAssetMapper? _instance; + static GeneratedAssetMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = GeneratedAssetMapper._()); + AssetExtensionMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'GeneratedAsset'; + + static String _$name(GeneratedAsset v) => v.name; + static const Field _f$name = Field('name', _$name); + static AssetExtension _$extension(GeneratedAsset v) => v.extension; + static const Field _f$extension = Field( + 'extension', + _$extension, + ); + static String _$type(GeneratedAsset v) => v.type; + static const Field _f$type = Field('type', _$type); + + @override + final MappableFields fields = const { + #name: _f$name, + #extension: _f$extension, + #type: _f$type, + }; + + static GeneratedAsset _instantiate(DecodingData data) { + return GeneratedAsset( + name: data.dec(_f$name), + extension: data.dec(_f$extension), + type: data.dec(_f$type), + ); + } + + @override + final Function instantiate = _instantiate; + + static GeneratedAsset fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static GeneratedAsset fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin GeneratedAssetMappable { + GeneratedAssetCopyWith + get copyWith => _GeneratedAssetCopyWithImpl( + this as GeneratedAsset, + $identity, + $identity, + ); + @override + String toString() { + return GeneratedAssetMapper.ensureInitialized().stringifyValue( + this as GeneratedAsset, + ); + } + + @override + bool operator ==(Object other) { + return GeneratedAssetMapper.ensureInitialized().equalsValue( + this as GeneratedAsset, + other, + ); + } + + @override + int get hashCode { + return GeneratedAssetMapper.ensureInitialized().hashValue( + this as GeneratedAsset, + ); + } +} + +extension GeneratedAssetValueCopy<$R, $Out> + on ObjectCopyWith<$R, GeneratedAsset, $Out> { + GeneratedAssetCopyWith<$R, GeneratedAsset, $Out> get $asGeneratedAsset => + $base.as((v, t, t2) => _GeneratedAssetCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class GeneratedAssetCopyWith<$R, $In extends GeneratedAsset, $Out> + implements ClassCopyWith<$R, $In, $Out> { + $R call({String? name, AssetExtension? extension, String? type}); + GeneratedAssetCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +class _GeneratedAssetCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, GeneratedAsset, $Out> + implements GeneratedAssetCopyWith<$R, GeneratedAsset, $Out> { + _GeneratedAssetCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + GeneratedAssetMapper.ensureInitialized(); + @override + $R call({String? name, AssetExtension? extension, String? type}) => $apply( + FieldCopyWithData({ + if (name != null) #name: name, + if (extension != null) #extension: extension, + if (type != null) #type: type, + }), + ); + @override + GeneratedAsset $make(CopyWithData data) => GeneratedAsset( + name: data.get(#name, or: $value.name), + extension: data.get(#extension, or: $value.extension), + type: data.get(#type, or: $value.type), + ); + + @override + GeneratedAssetCopyWith<$R2, GeneratedAsset, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _GeneratedAssetCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +class GeneratedAssetsReferenceMapper + extends ClassMapperBase { + GeneratedAssetsReferenceMapper._(); + + static GeneratedAssetsReferenceMapper? _instance; + static GeneratedAssetsReferenceMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use( + _instance = GeneratedAssetsReferenceMapper._(), + ); + } + return _instance!; + } + + @override + final String id = 'GeneratedAssetsReference'; + + static DateTime _$lastModified(GeneratedAssetsReference v) => v.lastModified; + static const Field _f$lastModified = + Field('lastModified', _$lastModified, key: r'last_modified'); + static List _$files(GeneratedAssetsReference v) => v.files; + static const Field> _f$files = Field( + 'files', + _$files, + ); + + @override + final MappableFields fields = const { + #lastModified: _f$lastModified, + #files: _f$files, + }; + + static GeneratedAssetsReference _instantiate(DecodingData data) { + return GeneratedAssetsReference( + lastModified: data.dec(_f$lastModified), + files: data.dec(_f$files), + ); + } + + @override + final Function instantiate = _instantiate; + + static GeneratedAssetsReference fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static GeneratedAssetsReference fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin GeneratedAssetsReferenceMappable { + GeneratedAssetsReferenceCopyWith< + GeneratedAssetsReference, + GeneratedAssetsReference, + GeneratedAssetsReference + > + get copyWith => + _GeneratedAssetsReferenceCopyWithImpl< + GeneratedAssetsReference, + GeneratedAssetsReference + >(this as GeneratedAssetsReference, $identity, $identity); + @override + String toString() { + return GeneratedAssetsReferenceMapper.ensureInitialized().stringifyValue( + this as GeneratedAssetsReference, + ); + } + + @override + bool operator ==(Object other) { + return GeneratedAssetsReferenceMapper.ensureInitialized().equalsValue( + this as GeneratedAssetsReference, + other, + ); + } + + @override + int get hashCode { + return GeneratedAssetsReferenceMapper.ensureInitialized().hashValue( + this as GeneratedAssetsReference, + ); + } +} + +extension GeneratedAssetsReferenceValueCopy<$R, $Out> + on ObjectCopyWith<$R, GeneratedAssetsReference, $Out> { + GeneratedAssetsReferenceCopyWith<$R, GeneratedAssetsReference, $Out> + get $asGeneratedAssetsReference => $base.as( + (v, t, t2) => _GeneratedAssetsReferenceCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +abstract class GeneratedAssetsReferenceCopyWith< + $R, + $In extends GeneratedAssetsReference, + $Out +> + implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>> get files; + $R call({DateTime? lastModified, List? files}); + GeneratedAssetsReferenceCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +class _GeneratedAssetsReferenceCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, GeneratedAssetsReference, $Out> + implements + GeneratedAssetsReferenceCopyWith<$R, GeneratedAssetsReference, $Out> { + _GeneratedAssetsReferenceCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + GeneratedAssetsReferenceMapper.ensureInitialized(); + @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>> get files => + ListCopyWith( + $value.files, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(files: v), + ); + @override + $R call({DateTime? lastModified, List? files}) => $apply( + FieldCopyWithData({ + if (lastModified != null) #lastModified: lastModified, + if (files != null) #files: files, + }), + ); + @override + GeneratedAssetsReference $make(CopyWithData data) => GeneratedAssetsReference( + lastModified: data.get(#lastModified, or: $value.lastModified), + files: data.get(#files, or: $value.files), + ); + + @override + GeneratedAssetsReferenceCopyWith<$R2, GeneratedAssetsReference, $Out2> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _GeneratedAssetsReferenceCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/packages/core/lib/src/models/block_model.dart b/packages/core/lib/src/models/block_model.dart index 03ccd36a..774aafd2 100644 --- a/packages/core/lib/src/models/block_model.dart +++ b/packages/core/lib/src/models/block_model.dart @@ -1,11 +1,16 @@ -import 'package:collection/collection.dart'; +import 'package:dart_mappable/dart_mappable.dart'; import 'package:superdeck_core/superdeck_core.dart'; +import 'mappers.dart'; + +part 'block_model.mapper.dart'; + /// Base class for all content blocks in a slide. /// /// Blocks are the fundamental building units of slide content. They can be /// arranged in sections and support alignment, flexible sizing, and scrolling. -sealed class Block { +@MappableClass(discriminatorKey: 'type', hook: BlockDiscriminatorHook()) +sealed class Block with BlockMappable { /// The type identifier for this block. final String type; @@ -36,10 +41,8 @@ sealed class Block { /// Parses a block from a JSON map. /// /// Automatically determines the block type from the discriminator key. - static Block parse(Map map) { - discriminatedSchema.parse(map); - return fromMap(map); - } + static Block parse(Map map) => + fromMap(discriminatedSchema.parse(map)!); /// Schema for discriminated union of block types. /// @@ -55,7 +58,6 @@ sealed class Block { ); Map toMap(); - Block copyWith({ContentAlignment? align, int? flex, bool? scrollable}); static Block fromMap(Map map) { final type = map['type'] as String; @@ -66,18 +68,13 @@ sealed class Block { _ => throw ArgumentError('Unknown block type: $type'), }; } - - @override - bool operator ==(Object other); - - @override - int get hashCode; } /// A block that contains multiple child blocks arranged horizontally. /// /// Sections are used to create multi-column layouts within a slide. -class SectionBlock extends Block { +@MappableClass(discriminatorValue: SectionBlock.key) +class SectionBlock extends Block with SectionBlockMappable { /// The child blocks contained in this section. final List blocks; @@ -85,7 +82,7 @@ class SectionBlock extends Block { static const key = 'section'; SectionBlock(List? blocks, {super.align, super.flex, super.scrollable}) - : blocks = blocks ?? [], + : blocks = List.unmodifiable(blocks ?? const []), super(type: key); /// The total flex value of all child blocks. @@ -93,21 +90,6 @@ class SectionBlock extends Block { return blocks.fold(0, (total, block) => total + block.flex); } - @override - SectionBlock copyWith({ - List? blocks, - ContentAlignment? align, - int? flex, - bool? scrollable, - }) { - return SectionBlock( - blocks ?? this.blocks, - align: align ?? this.align, - flex: flex ?? this.flex, - scrollable: scrollable ?? this.scrollable, - ); - } - @override Map toMap() { return { @@ -122,7 +104,7 @@ class SectionBlock extends Block { static SectionBlock fromMap(Map map) { return SectionBlock( (map['blocks'] as List?) - ?.map((e) => Block.fromMap(e as Map)) + ?.map((e) => Block.fromMap(Map.from(e as Map))) .toList(), align: map['align'] != null ? ContentAlignment.fromJson(map['align']!) @@ -133,10 +115,8 @@ class SectionBlock extends Block { } /// Parses a section block from a JSON map. - static SectionBlock parse(Map map) { - schema.parse(map); - return fromMap(map); - } + static SectionBlock parse(Map map) => + fromMap(schema.parse(map)!); /// Creates a section block with a single text column. static SectionBlock text(String content) { @@ -150,26 +130,6 @@ class SectionBlock extends Block { 'scrollable': Ack.boolean().optional(), 'blocks': Ack.list(Block.discriminatedSchema).optional(), }, additionalProperties: true); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SectionBlock && - runtimeType == other.runtimeType && - type == other.type && - align == other.align && - flex == other.flex && - scrollable == other.scrollable && - const DeepCollectionEquality().equals(blocks, other.blocks); - - @override - int get hashCode => Object.hash( - type, - align, - flex, - scrollable, - const DeepCollectionEquality().hash(blocks), - ); } /// Alias used by generated Ack model schemas for [SectionBlock] references. @@ -178,7 +138,8 @@ final sectionBlockSchema = SectionBlock.schema; /// A block that displays markdown content. /// /// This is the most common block type, used for text and markdown content. -class ContentBlock extends Block { +@MappableClass(discriminatorValue: ContentBlock.key) +class ContentBlock extends Block with ContentBlockMappable { /// The type identifier for content blocks. static const key = 'block'; @@ -192,21 +153,6 @@ class ContentBlock extends Block { : content = content ?? '', super(type: key); - @override - ContentBlock copyWith({ - String? content, - ContentAlignment? align, - int? flex, - bool? scrollable, - }) { - return ContentBlock( - content ?? this.content, - align: align ?? this.align, - flex: flex ?? this.flex, - scrollable: scrollable ?? this.scrollable, - ); - } - @override Map toMap() { return { @@ -219,18 +165,14 @@ class ContentBlock extends Block { } static ContentBlock fromMap(Map map) { - try { - return ContentBlock( - map['content'] as String?, - align: map['align'] != null - ? ContentAlignment.fromJson(map['align']!) - : null, - flex: (map['flex'] as num?)?.toInt() ?? 1, - scrollable: map['scrollable'] as bool? ?? false, - ); - } catch (e) { - throw Exception('Failed to parse ContentBlock: $e'); - } + return ContentBlock( + map['content'] as String?, + align: map['align'] != null + ? ContentAlignment.fromJson(map['align']!) + : null, + flex: (map['flex'] as num?)?.toInt() ?? 1, + scrollable: map['scrollable'] as bool? ?? false, + ); } /// Validation schema for content blocks. @@ -241,21 +183,12 @@ class ContentBlock extends Block { 'content': Ack.string().optional(), }, additionalProperties: true); - @override - bool operator ==(Object other) => - identical(this, other) || - other is ContentBlock && - runtimeType == other.runtimeType && - type == other.type && - align == other.align && - flex == other.flex && - scrollable == other.scrollable && - content == other.content; - - @override - int get hashCode => Object.hash(type, align, flex, scrollable, content); + /// Parses a content block from a JSON map with schema validation. + static ContentBlock parse(Map map) => + fromMap(schema.parse(map)!); } +@MappableEnum() enum DartPadTheme { dark, light; @@ -277,6 +210,7 @@ enum DartPadTheme { } } +@MappableEnum() enum ImageFit { fill, contain, @@ -303,8 +237,14 @@ enum ImageFit { } } -class WidgetBlock extends Block { +@MappableClass( + discriminatorValue: WidgetBlock.key, + hook: UnmappedPropertiesHook('args'), +) +class WidgetBlock extends Block with WidgetBlockMappable { static const key = 'widget'; + static const _reservedKeys = {'type', 'name', 'align', 'flex', 'scrollable'}; + final Map args; final String name; @@ -315,53 +255,40 @@ class WidgetBlock extends Block { super.flex, super.scrollable, }) : args = args == null ? const {} : Map.unmodifiable(args), - super(type: key); - - @override - WidgetBlock copyWith({ - String? name, - Map? args, - ContentAlignment? align, - int? flex, - bool? scrollable, - }) { - return WidgetBlock( - name: name ?? this.name, - args: args ?? this.args, - align: align ?? this.align, - flex: flex ?? this.flex, - scrollable: scrollable ?? this.scrollable, - ); + super(type: key) { + final collision = this.args.keys.where(_reservedKeys.contains).toList(); + if (collision.isNotEmpty) { + throw ArgumentError( + 'WidgetBlock args must not contain reserved keys: $collision', + ); + } } @override Map toMap() { return { + ...args, 'type': type, if (align != null) 'align': align!.name, 'flex': flex, 'scrollable': scrollable, 'name': name, - ...args, // Unmapped properties hook: spread args into the map }; } static WidgetBlock fromMap(Map map) { - // Extract known fields final name = map['name'] as String; final align = map['align'] != null ? ContentAlignment.fromJson(map['align']!) : null; final flex = (map['flex'] as num?)?.toInt() ?? 1; final scrollable = map['scrollable'] as bool? ?? false; - - // Everything else goes into args (implementing UnmappedPropertiesHook behavior) - final args = Map.from(map); - args.remove('type'); - args.remove('align'); - args.remove('flex'); - args.remove('scrollable'); - args.remove('name'); + final args = Map.from(map) + ..remove('type') + ..remove('align') + ..remove('flex') + ..remove('scrollable') + ..remove('name'); return WidgetBlock( name: name, @@ -379,29 +306,12 @@ class WidgetBlock extends Block { 'name': Ack.string(), }, additionalProperties: true); - @override - bool operator ==(Object other) => - identical(this, other) || - other is WidgetBlock && - runtimeType == other.runtimeType && - type == other.type && - align == other.align && - flex == other.flex && - scrollable == other.scrollable && - name == other.name && - const MapEquality().equals(args, other.args); - - @override - int get hashCode => Object.hash( - type, - align, - flex, - scrollable, - name, - const MapEquality().hash(args), - ); + /// Parses a widget block from a JSON map with schema validation. + static WidgetBlock parse(Map map) => + fromMap(schema.parse(map)!); } +@MappableEnum() enum ContentAlignment { topLeft, topCenter, @@ -430,12 +340,3 @@ enum ContentAlignment { } } -extension StringContentX on String { - ContentBlock toBlock() => ContentBlock(this); -} - -extension BlockX on Block { - Block flex(int flex) => copyWith(flex: flex); - Block scrollable([bool scrollable = true]) => - copyWith(scrollable: scrollable); -} diff --git a/packages/core/lib/src/models/block_model.mapper.dart b/packages/core/lib/src/models/block_model.mapper.dart new file mode 100644 index 00000000..518edd06 --- /dev/null +++ b/packages/core/lib/src/models/block_model.mapper.dart @@ -0,0 +1,820 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'block_model.dart'; + +class DartPadThemeMapper extends EnumMapper { + DartPadThemeMapper._(); + + static DartPadThemeMapper? _instance; + static DartPadThemeMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = DartPadThemeMapper._()); + } + return _instance!; + } + + static DartPadTheme fromValue(dynamic value) { + ensureInitialized(); + return MapperContainer.globals.fromValue(value); + } + + @override + DartPadTheme decode(dynamic value) { + switch (value) { + case r'dark': + return DartPadTheme.dark; + case r'light': + return DartPadTheme.light; + default: + throw MapperException.unknownEnumValue(value); + } + } + + @override + dynamic encode(DartPadTheme self) { + switch (self) { + case DartPadTheme.dark: + return r'dark'; + case DartPadTheme.light: + return r'light'; + } + } +} + +extension DartPadThemeMapperExtension on DartPadTheme { + String toValue() { + DartPadThemeMapper.ensureInitialized(); + return MapperContainer.globals.toValue(this) as String; + } +} + +class ImageFitMapper extends EnumMapper { + ImageFitMapper._(); + + static ImageFitMapper? _instance; + static ImageFitMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = ImageFitMapper._()); + } + return _instance!; + } + + static ImageFit fromValue(dynamic value) { + ensureInitialized(); + return MapperContainer.globals.fromValue(value); + } + + @override + ImageFit decode(dynamic value) { + switch (value) { + case r'fill': + return ImageFit.fill; + case r'contain': + return ImageFit.contain; + case r'cover': + return ImageFit.cover; + case r'fitWidth': + return ImageFit.fitWidth; + case r'fitHeight': + return ImageFit.fitHeight; + case r'none': + return ImageFit.none; + case r'scaleDown': + return ImageFit.scaleDown; + default: + throw MapperException.unknownEnumValue(value); + } + } + + @override + dynamic encode(ImageFit self) { + switch (self) { + case ImageFit.fill: + return r'fill'; + case ImageFit.contain: + return r'contain'; + case ImageFit.cover: + return r'cover'; + case ImageFit.fitWidth: + return r'fitWidth'; + case ImageFit.fitHeight: + return r'fitHeight'; + case ImageFit.none: + return r'none'; + case ImageFit.scaleDown: + return r'scaleDown'; + } + } +} + +extension ImageFitMapperExtension on ImageFit { + String toValue() { + ImageFitMapper.ensureInitialized(); + return MapperContainer.globals.toValue(this) as String; + } +} + +class ContentAlignmentMapper extends EnumMapper { + ContentAlignmentMapper._(); + + static ContentAlignmentMapper? _instance; + static ContentAlignmentMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = ContentAlignmentMapper._()); + } + return _instance!; + } + + static ContentAlignment fromValue(dynamic value) { + ensureInitialized(); + return MapperContainer.globals.fromValue(value); + } + + @override + ContentAlignment decode(dynamic value) { + switch (value) { + case r'topLeft': + return ContentAlignment.topLeft; + case r'topCenter': + return ContentAlignment.topCenter; + case r'topRight': + return ContentAlignment.topRight; + case r'centerLeft': + return ContentAlignment.centerLeft; + case r'center': + return ContentAlignment.center; + case r'centerRight': + return ContentAlignment.centerRight; + case r'bottomLeft': + return ContentAlignment.bottomLeft; + case r'bottomCenter': + return ContentAlignment.bottomCenter; + case r'bottomRight': + return ContentAlignment.bottomRight; + default: + throw MapperException.unknownEnumValue(value); + } + } + + @override + dynamic encode(ContentAlignment self) { + switch (self) { + case ContentAlignment.topLeft: + return r'topLeft'; + case ContentAlignment.topCenter: + return r'topCenter'; + case ContentAlignment.topRight: + return r'topRight'; + case ContentAlignment.centerLeft: + return r'centerLeft'; + case ContentAlignment.center: + return r'center'; + case ContentAlignment.centerRight: + return r'centerRight'; + case ContentAlignment.bottomLeft: + return r'bottomLeft'; + case ContentAlignment.bottomCenter: + return r'bottomCenter'; + case ContentAlignment.bottomRight: + return r'bottomRight'; + } + } +} + +extension ContentAlignmentMapperExtension on ContentAlignment { + String toValue() { + ContentAlignmentMapper.ensureInitialized(); + return MapperContainer.globals.toValue(this) as String; + } +} + +class BlockMapper extends ClassMapperBase { + BlockMapper._(); + + static BlockMapper? _instance; + static BlockMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = BlockMapper._()); + SectionBlockMapper.ensureInitialized(); + ContentBlockMapper.ensureInitialized(); + WidgetBlockMapper.ensureInitialized(); + ContentAlignmentMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'Block'; + + static String _$type(Block v) => v.type; + static const Field _f$type = Field('type', _$type); + static ContentAlignment? _$align(Block v) => v.align; + static const Field _f$align = Field( + 'align', + _$align, + opt: true, + ); + static int _$flex(Block v) => v.flex; + static const Field _f$flex = Field( + 'flex', + _$flex, + opt: true, + def: 1, + ); + static bool _$scrollable(Block v) => v.scrollable; + static const Field _f$scrollable = Field( + 'scrollable', + _$scrollable, + opt: true, + def: false, + ); + + @override + final MappableFields fields = const { + #type: _f$type, + #align: _f$align, + #flex: _f$flex, + #scrollable: _f$scrollable, + }; + + @override + final MappingHook hook = const BlockDiscriminatorHook(); + static Block _instantiate(DecodingData data) { + throw MapperException.missingSubclass( + 'Block', + 'type', + '${data.value['type']}', + ); + } + + @override + final Function instantiate = _instantiate; + + static Block fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static Block fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin BlockMappable { + BlockCopyWith get copyWith; +} + +abstract class BlockCopyWith<$R, $In extends Block, $Out> + implements ClassCopyWith<$R, $In, $Out> { + $R call({ContentAlignment? align, int? flex, bool? scrollable}); + BlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class SectionBlockMapper extends SubClassMapperBase { + SectionBlockMapper._(); + + static SectionBlockMapper? _instance; + static SectionBlockMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = SectionBlockMapper._()); + BlockMapper.ensureInitialized().addSubMapper(_instance!); + BlockMapper.ensureInitialized(); + ContentAlignmentMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'SectionBlock'; + + static List _$blocks(SectionBlock v) => v.blocks; + static const Field> _f$blocks = Field( + 'blocks', + _$blocks, + ); + static ContentAlignment? _$align(SectionBlock v) => v.align; + static const Field _f$align = Field( + 'align', + _$align, + opt: true, + ); + static int _$flex(SectionBlock v) => v.flex; + static const Field _f$flex = Field( + 'flex', + _$flex, + opt: true, + def: 1, + ); + static bool _$scrollable(SectionBlock v) => v.scrollable; + static const Field _f$scrollable = Field( + 'scrollable', + _$scrollable, + opt: true, + def: false, + ); + static String _$type(SectionBlock v) => v.type; + static const Field _f$type = Field( + 'type', + _$type, + mode: FieldMode.member, + ); + + @override + final MappableFields fields = const { + #blocks: _f$blocks, + #align: _f$align, + #flex: _f$flex, + #scrollable: _f$scrollable, + #type: _f$type, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = SectionBlock.key; + @override + late final ClassMapperBase superMapper = BlockMapper.ensureInitialized(); + + @override + final MappingHook superHook = const BlockDiscriminatorHook(); + + static SectionBlock _instantiate(DecodingData data) { + return SectionBlock( + data.dec(_f$blocks), + align: data.dec(_f$align), + flex: data.dec(_f$flex), + scrollable: data.dec(_f$scrollable), + ); + } + + @override + final Function instantiate = _instantiate; + + static SectionBlock fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static SectionBlock fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin SectionBlockMappable { + SectionBlockCopyWith get copyWith => + _SectionBlockCopyWithImpl( + this as SectionBlock, + $identity, + $identity, + ); + @override + String toString() { + return SectionBlockMapper.ensureInitialized().stringifyValue( + this as SectionBlock, + ); + } + + @override + bool operator ==(Object other) { + return SectionBlockMapper.ensureInitialized().equalsValue( + this as SectionBlock, + other, + ); + } + + @override + int get hashCode { + return SectionBlockMapper.ensureInitialized().hashValue( + this as SectionBlock, + ); + } +} + +extension SectionBlockValueCopy<$R, $Out> + on ObjectCopyWith<$R, SectionBlock, $Out> { + SectionBlockCopyWith<$R, SectionBlock, $Out> get $asSectionBlock => + $base.as((v, t, t2) => _SectionBlockCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class SectionBlockCopyWith<$R, $In extends SectionBlock, $Out> + implements BlockCopyWith<$R, $In, $Out> { + ListCopyWith<$R, Block, BlockCopyWith<$R, Block, Block>> get blocks; + @override + $R call({ + List? blocks, + ContentAlignment? align, + int? flex, + bool? scrollable, + }); + SectionBlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _SectionBlockCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, SectionBlock, $Out> + implements SectionBlockCopyWith<$R, SectionBlock, $Out> { + _SectionBlockCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + SectionBlockMapper.ensureInitialized(); + @override + ListCopyWith<$R, Block, BlockCopyWith<$R, Block, Block>> get blocks => + ListCopyWith( + $value.blocks, + (v, t) => v.copyWith.$chain(t), + (v) => call(blocks: v), + ); + @override + $R call({ + Object? blocks = $none, + Object? align = $none, + int? flex, + bool? scrollable, + }) => $apply( + FieldCopyWithData({ + if (blocks != $none) #blocks: blocks, + if (align != $none) #align: align, + if (flex != null) #flex: flex, + if (scrollable != null) #scrollable: scrollable, + }), + ); + @override + SectionBlock $make(CopyWithData data) => SectionBlock( + data.get(#blocks, or: $value.blocks), + align: data.get(#align, or: $value.align), + flex: data.get(#flex, or: $value.flex), + scrollable: data.get(#scrollable, or: $value.scrollable), + ); + + @override + SectionBlockCopyWith<$R2, SectionBlock, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _SectionBlockCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +class ContentBlockMapper extends SubClassMapperBase { + ContentBlockMapper._(); + + static ContentBlockMapper? _instance; + static ContentBlockMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = ContentBlockMapper._()); + BlockMapper.ensureInitialized().addSubMapper(_instance!); + ContentAlignmentMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'ContentBlock'; + + static String _$content(ContentBlock v) => v.content; + static const Field _f$content = Field( + 'content', + _$content, + ); + static ContentAlignment? _$align(ContentBlock v) => v.align; + static const Field _f$align = Field( + 'align', + _$align, + opt: true, + ); + static int _$flex(ContentBlock v) => v.flex; + static const Field _f$flex = Field( + 'flex', + _$flex, + opt: true, + def: 1, + ); + static bool _$scrollable(ContentBlock v) => v.scrollable; + static const Field _f$scrollable = Field( + 'scrollable', + _$scrollable, + opt: true, + def: false, + ); + static String _$type(ContentBlock v) => v.type; + static const Field _f$type = Field( + 'type', + _$type, + mode: FieldMode.member, + ); + + @override + final MappableFields fields = const { + #content: _f$content, + #align: _f$align, + #flex: _f$flex, + #scrollable: _f$scrollable, + #type: _f$type, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = ContentBlock.key; + @override + late final ClassMapperBase superMapper = BlockMapper.ensureInitialized(); + + @override + final MappingHook superHook = const BlockDiscriminatorHook(); + + static ContentBlock _instantiate(DecodingData data) { + return ContentBlock( + data.dec(_f$content), + align: data.dec(_f$align), + flex: data.dec(_f$flex), + scrollable: data.dec(_f$scrollable), + ); + } + + @override + final Function instantiate = _instantiate; + + static ContentBlock fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static ContentBlock fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin ContentBlockMappable { + ContentBlockCopyWith get copyWith => + _ContentBlockCopyWithImpl( + this as ContentBlock, + $identity, + $identity, + ); + @override + String toString() { + return ContentBlockMapper.ensureInitialized().stringifyValue( + this as ContentBlock, + ); + } + + @override + bool operator ==(Object other) { + return ContentBlockMapper.ensureInitialized().equalsValue( + this as ContentBlock, + other, + ); + } + + @override + int get hashCode { + return ContentBlockMapper.ensureInitialized().hashValue( + this as ContentBlock, + ); + } +} + +extension ContentBlockValueCopy<$R, $Out> + on ObjectCopyWith<$R, ContentBlock, $Out> { + ContentBlockCopyWith<$R, ContentBlock, $Out> get $asContentBlock => + $base.as((v, t, t2) => _ContentBlockCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class ContentBlockCopyWith<$R, $In extends ContentBlock, $Out> + implements BlockCopyWith<$R, $In, $Out> { + @override + $R call({ + String? content, + ContentAlignment? align, + int? flex, + bool? scrollable, + }); + ContentBlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _ContentBlockCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, ContentBlock, $Out> + implements ContentBlockCopyWith<$R, ContentBlock, $Out> { + _ContentBlockCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + ContentBlockMapper.ensureInitialized(); + @override + $R call({ + Object? content = $none, + Object? align = $none, + int? flex, + bool? scrollable, + }) => $apply( + FieldCopyWithData({ + if (content != $none) #content: content, + if (align != $none) #align: align, + if (flex != null) #flex: flex, + if (scrollable != null) #scrollable: scrollable, + }), + ); + @override + ContentBlock $make(CopyWithData data) => ContentBlock( + data.get(#content, or: $value.content), + align: data.get(#align, or: $value.align), + flex: data.get(#flex, or: $value.flex), + scrollable: data.get(#scrollable, or: $value.scrollable), + ); + + @override + ContentBlockCopyWith<$R2, ContentBlock, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _ContentBlockCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +class WidgetBlockMapper extends SubClassMapperBase { + WidgetBlockMapper._(); + + static WidgetBlockMapper? _instance; + static WidgetBlockMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = WidgetBlockMapper._()); + BlockMapper.ensureInitialized().addSubMapper(_instance!); + ContentAlignmentMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'WidgetBlock'; + + static String _$name(WidgetBlock v) => v.name; + static const Field _f$name = Field('name', _$name); + static Map _$args(WidgetBlock v) => v.args; + static const Field> _f$args = Field( + 'args', + _$args, + opt: true, + ); + static ContentAlignment? _$align(WidgetBlock v) => v.align; + static const Field _f$align = Field( + 'align', + _$align, + opt: true, + ); + static int _$flex(WidgetBlock v) => v.flex; + static const Field _f$flex = Field( + 'flex', + _$flex, + opt: true, + def: 1, + ); + static bool _$scrollable(WidgetBlock v) => v.scrollable; + static const Field _f$scrollable = Field( + 'scrollable', + _$scrollable, + opt: true, + def: false, + ); + static String _$type(WidgetBlock v) => v.type; + static const Field _f$type = Field( + 'type', + _$type, + mode: FieldMode.member, + ); + + @override + final MappableFields fields = const { + #name: _f$name, + #args: _f$args, + #align: _f$align, + #flex: _f$flex, + #scrollable: _f$scrollable, + #type: _f$type, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = WidgetBlock.key; + @override + late final ClassMapperBase superMapper = BlockMapper.ensureInitialized(); + + @override + final MappingHook hook = const UnmappedPropertiesHook('args'); + @override + final MappingHook superHook = const BlockDiscriminatorHook(); + + static WidgetBlock _instantiate(DecodingData data) { + return WidgetBlock( + name: data.dec(_f$name), + args: data.dec(_f$args), + align: data.dec(_f$align), + flex: data.dec(_f$flex), + scrollable: data.dec(_f$scrollable), + ); + } + + @override + final Function instantiate = _instantiate; + + static WidgetBlock fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static WidgetBlock fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin WidgetBlockMappable { + WidgetBlockCopyWith get copyWith => + _WidgetBlockCopyWithImpl( + this as WidgetBlock, + $identity, + $identity, + ); + @override + String toString() { + return WidgetBlockMapper.ensureInitialized().stringifyValue( + this as WidgetBlock, + ); + } + + @override + bool operator ==(Object other) { + return WidgetBlockMapper.ensureInitialized().equalsValue( + this as WidgetBlock, + other, + ); + } + + @override + int get hashCode { + return WidgetBlockMapper.ensureInitialized().hashValue(this as WidgetBlock); + } +} + +extension WidgetBlockValueCopy<$R, $Out> + on ObjectCopyWith<$R, WidgetBlock, $Out> { + WidgetBlockCopyWith<$R, WidgetBlock, $Out> get $asWidgetBlock => + $base.as((v, t, t2) => _WidgetBlockCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class WidgetBlockCopyWith<$R, $In extends WidgetBlock, $Out> + implements BlockCopyWith<$R, $In, $Out> { + MapCopyWith<$R, String, Object?, ObjectCopyWith<$R, Object?, Object?>?> + get args; + @override + $R call({ + String? name, + Map? args, + ContentAlignment? align, + int? flex, + bool? scrollable, + }); + WidgetBlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _WidgetBlockCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, WidgetBlock, $Out> + implements WidgetBlockCopyWith<$R, WidgetBlock, $Out> { + _WidgetBlockCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + WidgetBlockMapper.ensureInitialized(); + @override + MapCopyWith<$R, String, Object?, ObjectCopyWith<$R, Object?, Object?>?> + get args => MapCopyWith( + $value.args, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(args: v), + ); + @override + $R call({ + String? name, + Object? args = $none, + Object? align = $none, + int? flex, + bool? scrollable, + }) => $apply( + FieldCopyWithData({ + if (name != null) #name: name, + if (args != $none) #args: args, + if (align != $none) #align: align, + if (flex != null) #flex: flex, + if (scrollable != null) #scrollable: scrollable, + }), + ); + @override + WidgetBlock $make(CopyWithData data) => WidgetBlock( + name: data.get(#name, or: $value.name), + args: data.get(#args, or: $value.args), + align: data.get(#align, or: $value.align), + flex: data.get(#flex, or: $value.flex), + scrollable: data.get(#scrollable, or: $value.scrollable), + ); + + @override + WidgetBlockCopyWith<$R2, WidgetBlock, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _WidgetBlockCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/packages/core/lib/src/models/deck_model.dart b/packages/core/lib/src/models/deck_model.dart index ba4c1b9c..35156c4d 100644 --- a/packages/core/lib/src/models/deck_model.dart +++ b/packages/core/lib/src/models/deck_model.dart @@ -1,21 +1,18 @@ import 'package:ack/ack.dart'; -import 'package:collection/collection.dart'; +import 'package:dart_mappable/dart_mappable.dart'; import '../deck_configuration.dart'; import 'slide_model.dart'; -class Deck { +part 'deck_model.mapper.dart'; + +@MappableClass() +class Deck with DeckMappable { final List slides; final DeckConfiguration configuration; - const Deck({required this.slides, required this.configuration}); - - Deck copyWith({List? slides, DeckConfiguration? configuration}) { - return Deck( - slides: slides ?? this.slides, - configuration: configuration ?? this.configuration, - ); - } + Deck({required List slides, required this.configuration}) + : slides = List.unmodifiable(slides); Map toMap() { return { @@ -25,46 +22,28 @@ class Deck { } static Deck fromMap(Map map) { - final payload = schema.parse(map) as Map; + final configurationValue = map['configuration']; - return _fromPayload(payload); - } - - /// Ack schema for validating complete deck/presentation JSON. - static final schema = Ack.object({ - 'slides': Ack.list(Slide.schema), - 'configuration': DeckConfiguration.schema.optional(), - }); - - /// Alias for [fromMap]. - static Deck parse(Map map) => fromMap(map); - - static Deck _fromPayload(Map payload) { - final configurationValue = payload['configuration']; return Deck( - slides: (payload['slides'] as List) + slides: (map['slides'] as List) .map( (slide) => - Slide.fromValidatedMap(Map.from(slide as Map)), + Slide.fromMap(Map.from(slide as Map)), ) .toList(), configuration: configurationValue == null ? DeckConfiguration() - : DeckConfiguration.parse( + : DeckConfiguration.fromMap( Map.from(configurationValue as Map), ), ); } - @override - bool operator ==(Object other) => - identical(this, other) || - other is Deck && - runtimeType == other.runtimeType && - const DeepCollectionEquality().equals(slides, other.slides) && - configuration == other.configuration; + /// Ack schema for validating complete deck/presentation JSON. + static final schema = Ack.object({ + 'slides': Ack.list(Slide.schema), + 'configuration': DeckConfiguration.schema.optional(), + }); - @override - int get hashCode => - Object.hash(const DeepCollectionEquality().hash(slides), configuration); + static Deck parse(Map map) => fromMap(schema.parse(map)!); } diff --git a/packages/core/lib/src/models/deck_model.mapper.dart b/packages/core/lib/src/models/deck_model.mapper.dart new file mode 100644 index 00000000..b5253694 --- /dev/null +++ b/packages/core/lib/src/models/deck_model.mapper.dart @@ -0,0 +1,127 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'deck_model.dart'; + +class DeckMapper extends ClassMapperBase { + DeckMapper._(); + + static DeckMapper? _instance; + static DeckMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = DeckMapper._()); + SlideMapper.ensureInitialized(); + DeckConfigurationMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'Deck'; + + static List _$slides(Deck v) => v.slides; + static const Field> _f$slides = Field('slides', _$slides); + static DeckConfiguration _$configuration(Deck v) => v.configuration; + static const Field _f$configuration = Field( + 'configuration', + _$configuration, + ); + + @override + final MappableFields fields = const { + #slides: _f$slides, + #configuration: _f$configuration, + }; + + static Deck _instantiate(DecodingData data) { + return Deck( + slides: data.dec(_f$slides), + configuration: data.dec(_f$configuration), + ); + } + + @override + final Function instantiate = _instantiate; + + static Deck fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static Deck fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin DeckMappable { + DeckCopyWith get copyWith => + _DeckCopyWithImpl(this as Deck, $identity, $identity); + @override + String toString() { + return DeckMapper.ensureInitialized().stringifyValue(this as Deck); + } + + @override + bool operator ==(Object other) { + return DeckMapper.ensureInitialized().equalsValue(this as Deck, other); + } + + @override + int get hashCode { + return DeckMapper.ensureInitialized().hashValue(this as Deck); + } +} + +extension DeckValueCopy<$R, $Out> on ObjectCopyWith<$R, Deck, $Out> { + DeckCopyWith<$R, Deck, $Out> get $asDeck => + $base.as((v, t, t2) => _DeckCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class DeckCopyWith<$R, $In extends Deck, $Out> + implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith<$R, Slide, SlideCopyWith<$R, Slide, Slide>> get slides; + DeckConfigurationCopyWith<$R, DeckConfiguration, DeckConfiguration> + get configuration; + $R call({List? slides, DeckConfiguration? configuration}); + DeckCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _DeckCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Deck, $Out> + implements DeckCopyWith<$R, Deck, $Out> { + _DeckCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = DeckMapper.ensureInitialized(); + @override + ListCopyWith<$R, Slide, SlideCopyWith<$R, Slide, Slide>> get slides => + ListCopyWith( + $value.slides, + (v, t) => v.copyWith.$chain(t), + (v) => call(slides: v), + ); + @override + DeckConfigurationCopyWith<$R, DeckConfiguration, DeckConfiguration> + get configuration => + $value.configuration.copyWith.$chain((v) => call(configuration: v)); + @override + $R call({List? slides, DeckConfiguration? configuration}) => $apply( + FieldCopyWithData({ + if (slides != null) #slides: slides, + if (configuration != null) #configuration: configuration, + }), + ); + @override + Deck $make(CopyWithData data) => Deck( + slides: data.get(#slides, or: $value.slides), + configuration: data.get(#configuration, or: $value.configuration), + ); + + @override + DeckCopyWith<$R2, Deck, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _DeckCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/packages/core/lib/src/models/mappers.dart b/packages/core/lib/src/models/mappers.dart new file mode 100644 index 00000000..64159326 --- /dev/null +++ b/packages/core/lib/src/models/mappers.dart @@ -0,0 +1,15 @@ +import 'package:dart_mappable/dart_mappable.dart'; + +/// Normalizes the legacy `column` discriminator to `block`. +class BlockDiscriminatorHook extends MappingHook { + const BlockDiscriminatorHook(); + + @override + Object? beforeDecode(Object? value) { + if (value case {'type': 'column'}) { + return {...value, 'type': 'block'}; + } + + return value; + } +} diff --git a/packages/core/lib/src/models/slide_model.dart b/packages/core/lib/src/models/slide_model.dart index 8351831b..e2f6a2dd 100644 --- a/packages/core/lib/src/models/slide_model.dart +++ b/packages/core/lib/src/models/slide_model.dart @@ -1,11 +1,11 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; -import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; +import 'package:dart_mappable/dart_mappable.dart'; import 'block_model.dart'; part 'slide_model.g.dart'; +part 'slide_model.mapper.dart'; const _knownSlideOptionFields = {'title', 'style', 'template'}; @@ -14,7 +14,8 @@ const _knownSlideOptionFields = {'title', 'style', 'template'}; /// A slide contains sections of content blocks, optional configuration options, /// and any speaker notes or comments. Each slide is uniquely identified by a key. @AckModel(additionalProperties: true) -class Slide { +@MappableClass() +class Slide with SlideMappable { /// Unique identifier for this slide, typically generated from content hash. final String key; @@ -27,26 +28,13 @@ class Slide { /// Speaker notes or comments associated with this slide. final List comments; - const Slide({ + Slide({ required this.key, this.options, - this.sections = const [], - this.comments = const [], - }); - - Slide copyWith({ - String? key, - SlideOptions? options, - List? sections, - List? comments, - }) { - return Slide( - key: key ?? this.key, - options: options ?? this.options, - sections: sections ?? this.sections, - comments: comments ?? this.comments, - ); - } + List sections = const [], + List comments = const [], + }) : sections = List.unmodifiable(sections), + comments = List.unmodifiable(comments); Map toMap() { return { @@ -58,30 +46,20 @@ class Slide { } static Slide fromMap(Map map) { - final payload = schema.parse(map) as Map; - return _fromPayload(payload); - } - - @internal - static Slide fromValidatedMap(Map payload) { - return _fromPayload(payload); - } - - static Slide _fromPayload(Map payload) { - final optionsPayload = payload['options'] as Map?; + final optionsPayload = map['options'] as Map?; return Slide( - key: payload['key'] as String, + key: map['key'] as String, options: optionsPayload == null ? null : SlideOptions.fromMap(optionsPayload), - sections: (payload['sections'] as List? ?? const []) + sections: (map['sections'] as List? ?? const []) .map( (section) => SectionBlock.fromMap(Map.from(section as Map)), ) .toList(), - comments: (payload['comments'] as List? ?? const []) + comments: (map['comments'] as List? ?? const []) .cast(), ); } @@ -93,8 +71,8 @@ class Slide { 'comments': Ack.list(Ack.string()).optional(), }); - /// Alias for [fromMap]. - static Slide parse(Map map) => fromMap(map); + /// Validates [map] against the schema and constructs a [Slide]. + static Slide parse(Map map) => fromMap(schema.parse(map)!); /// Creates an error slide to display errors in the presentation. /// @@ -124,31 +102,14 @@ ${error.toString()} ], ); } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Slide && - runtimeType == other.runtimeType && - key == other.key && - options == other.options && - const DeepCollectionEquality().equals(sections, other.sections) && - const ListEquality().equals(comments, other.comments); - - @override - int get hashCode => Object.hash( - key, - options, - const DeepCollectionEquality().hash(sections), - const ListEquality().hash(comments), - ); } /// Configuration options for a slide. /// /// Provides metadata and styling information for individual slides. @AckModel(additionalProperties: true, additionalPropertiesField: 'args') -class SlideOptions { +@MappableClass(hook: UnmappedPropertiesHook('args')) +class SlideOptions with SlideOptionsMappable { /// The title of the slide, if any. final String? title; @@ -164,52 +125,41 @@ class SlideOptions { /// Additional arguments passed to the slide. final Map args; - const SlideOptions({ + SlideOptions({ this.title, this.style, this.template, - this.args = const {}, - }); - - SlideOptions copyWith({ - String? title, - String? style, - String? template, - Map? args, - }) { - return SlideOptions( - title: title ?? this.title, - style: style ?? this.style, - template: template ?? this.template, - args: args ?? this.args, - ); + Map args = const {}, + }) : args = Map.unmodifiable(args) { + final collision = + this.args.keys.where(_knownSlideOptionFields.contains).toList(); + if (collision.isNotEmpty) { + throw ArgumentError( + 'SlideOptions args must not contain reserved keys: $collision', + ); + } } Map toMap() { return { + ...args, if (title != null) 'title': title, if (style != null) 'style': style, if (template != null) 'template': template, - ...args, }; } static SlideOptions fromMap(Map map) { - final payload = schema.parse(map) as Map; - return _fromPayload(payload); - } - - static SlideOptions _fromPayload(Map payload) { final args = Map.fromEntries( - payload.entries.where( + map.entries.where( (entry) => !_knownSlideOptionFields.contains(entry.key), ), ); return SlideOptions( - title: payload['title'] as String?, - style: payload['style'] as String?, - template: payload['template'] as String?, + title: map['title'] as String?, + style: map['style'] as String?, + template: map['template'] as String?, args: args, ); } @@ -221,20 +171,7 @@ class SlideOptions { 'template': Ack.string().optional(), }); - /// Alias for [fromMap]. - static SlideOptions parse(Map map) => fromMap(map); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SlideOptions && - runtimeType == other.runtimeType && - title == other.title && - style == other.style && - template == other.template && - const MapEquality().equals(args, other.args); - - @override - int get hashCode => - Object.hash(title, style, template, const MapEquality().hash(args)); + /// Validates [map] against the schema and constructs [SlideOptions]. + static SlideOptions parse(Map map) => + fromMap(schema.parse(map)!); } diff --git a/packages/core/lib/src/models/slide_model.g.dart b/packages/core/lib/src/models/slide_model.g.dart index 0a39cc2f..4d1ad432 100644 --- a/packages/core/lib/src/models/slide_model.g.dart +++ b/packages/core/lib/src/models/slide_model.g.dart @@ -5,8 +5,6 @@ // AckSchemaGenerator // ************************************************************************** -// // GENERATED CODE - DO NOT MODIFY BY HAND - part of 'slide_model.dart'; /// Generated schema for Slide diff --git a/packages/core/lib/src/models/slide_model.mapper.dart b/packages/core/lib/src/models/slide_model.mapper.dart new file mode 100644 index 00000000..92e1d437 --- /dev/null +++ b/packages/core/lib/src/models/slide_model.mapper.dart @@ -0,0 +1,338 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'slide_model.dart'; + +class SlideMapper extends ClassMapperBase { + SlideMapper._(); + + static SlideMapper? _instance; + static SlideMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = SlideMapper._()); + SlideOptionsMapper.ensureInitialized(); + SectionBlockMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'Slide'; + + static String _$key(Slide v) => v.key; + static const Field _f$key = Field('key', _$key); + static SlideOptions? _$options(Slide v) => v.options; + static const Field _f$options = Field( + 'options', + _$options, + opt: true, + ); + static List _$sections(Slide v) => v.sections; + static const Field> _f$sections = Field( + 'sections', + _$sections, + opt: true, + def: const [], + ); + static List _$comments(Slide v) => v.comments; + static const Field> _f$comments = Field( + 'comments', + _$comments, + opt: true, + def: const [], + ); + + @override + final MappableFields fields = const { + #key: _f$key, + #options: _f$options, + #sections: _f$sections, + #comments: _f$comments, + }; + + static Slide _instantiate(DecodingData data) { + return Slide( + key: data.dec(_f$key), + options: data.dec(_f$options), + sections: data.dec(_f$sections), + comments: data.dec(_f$comments), + ); + } + + @override + final Function instantiate = _instantiate; + + static Slide fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static Slide fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin SlideMappable { + SlideCopyWith get copyWith => + _SlideCopyWithImpl(this as Slide, $identity, $identity); + @override + String toString() { + return SlideMapper.ensureInitialized().stringifyValue(this as Slide); + } + + @override + bool operator ==(Object other) { + return SlideMapper.ensureInitialized().equalsValue(this as Slide, other); + } + + @override + int get hashCode { + return SlideMapper.ensureInitialized().hashValue(this as Slide); + } +} + +extension SlideValueCopy<$R, $Out> on ObjectCopyWith<$R, Slide, $Out> { + SlideCopyWith<$R, Slide, $Out> get $asSlide => + $base.as((v, t, t2) => _SlideCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class SlideCopyWith<$R, $In extends Slide, $Out> + implements ClassCopyWith<$R, $In, $Out> { + SlideOptionsCopyWith<$R, SlideOptions, SlideOptions>? get options; + ListCopyWith< + $R, + SectionBlock, + SectionBlockCopyWith<$R, SectionBlock, SectionBlock> + > + get sections; + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>> get comments; + $R call({ + String? key, + SlideOptions? options, + List? sections, + List? comments, + }); + SlideCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _SlideCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Slide, $Out> + implements SlideCopyWith<$R, Slide, $Out> { + _SlideCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = SlideMapper.ensureInitialized(); + @override + SlideOptionsCopyWith<$R, SlideOptions, SlideOptions>? get options => + $value.options?.copyWith.$chain((v) => call(options: v)); + @override + ListCopyWith< + $R, + SectionBlock, + SectionBlockCopyWith<$R, SectionBlock, SectionBlock> + > + get sections => ListCopyWith( + $value.sections, + (v, t) => v.copyWith.$chain(t), + (v) => call(sections: v), + ); + @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>> get comments => + ListCopyWith( + $value.comments, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(comments: v), + ); + @override + $R call({ + String? key, + Object? options = $none, + List? sections, + List? comments, + }) => $apply( + FieldCopyWithData({ + if (key != null) #key: key, + if (options != $none) #options: options, + if (sections != null) #sections: sections, + if (comments != null) #comments: comments, + }), + ); + @override + Slide $make(CopyWithData data) => Slide( + key: data.get(#key, or: $value.key), + options: data.get(#options, or: $value.options), + sections: data.get(#sections, or: $value.sections), + comments: data.get(#comments, or: $value.comments), + ); + + @override + SlideCopyWith<$R2, Slide, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _SlideCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +class SlideOptionsMapper extends ClassMapperBase { + SlideOptionsMapper._(); + + static SlideOptionsMapper? _instance; + static SlideOptionsMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = SlideOptionsMapper._()); + } + return _instance!; + } + + @override + final String id = 'SlideOptions'; + + static String? _$title(SlideOptions v) => v.title; + static const Field _f$title = Field( + 'title', + _$title, + opt: true, + ); + static String? _$style(SlideOptions v) => v.style; + static const Field _f$style = Field( + 'style', + _$style, + opt: true, + ); + static String? _$template(SlideOptions v) => v.template; + static const Field _f$template = Field( + 'template', + _$template, + opt: true, + ); + static Map _$args(SlideOptions v) => v.args; + static const Field> _f$args = Field( + 'args', + _$args, + opt: true, + def: const {}, + ); + + @override + final MappableFields fields = const { + #title: _f$title, + #style: _f$style, + #template: _f$template, + #args: _f$args, + }; + + @override + final MappingHook hook = const UnmappedPropertiesHook('args'); + static SlideOptions _instantiate(DecodingData data) { + return SlideOptions( + title: data.dec(_f$title), + style: data.dec(_f$style), + template: data.dec(_f$template), + args: data.dec(_f$args), + ); + } + + @override + final Function instantiate = _instantiate; + + static SlideOptions fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static SlideOptions fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin SlideOptionsMappable { + SlideOptionsCopyWith get copyWith => + _SlideOptionsCopyWithImpl( + this as SlideOptions, + $identity, + $identity, + ); + @override + String toString() { + return SlideOptionsMapper.ensureInitialized().stringifyValue( + this as SlideOptions, + ); + } + + @override + bool operator ==(Object other) { + return SlideOptionsMapper.ensureInitialized().equalsValue( + this as SlideOptions, + other, + ); + } + + @override + int get hashCode { + return SlideOptionsMapper.ensureInitialized().hashValue( + this as SlideOptions, + ); + } +} + +extension SlideOptionsValueCopy<$R, $Out> + on ObjectCopyWith<$R, SlideOptions, $Out> { + SlideOptionsCopyWith<$R, SlideOptions, $Out> get $asSlideOptions => + $base.as((v, t, t2) => _SlideOptionsCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class SlideOptionsCopyWith<$R, $In extends SlideOptions, $Out> + implements ClassCopyWith<$R, $In, $Out> { + MapCopyWith<$R, String, Object?, ObjectCopyWith<$R, Object?, Object?>?> + get args; + $R call({ + String? title, + String? style, + String? template, + Map? args, + }); + SlideOptionsCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _SlideOptionsCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, SlideOptions, $Out> + implements SlideOptionsCopyWith<$R, SlideOptions, $Out> { + _SlideOptionsCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + SlideOptionsMapper.ensureInitialized(); + @override + MapCopyWith<$R, String, Object?, ObjectCopyWith<$R, Object?, Object?>?> + get args => MapCopyWith( + $value.args, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(args: v), + ); + @override + $R call({ + Object? title = $none, + Object? style = $none, + Object? template = $none, + Map? args, + }) => $apply( + FieldCopyWithData({ + if (title != $none) #title: title, + if (style != $none) #style: style, + if (template != $none) #template: template, + if (args != null) #args: args, + }), + ); + @override + SlideOptions $make(CopyWithData data) => SlideOptions( + title: data.get(#title, or: $value.title), + style: data.get(#style, or: $value.style), + template: data.get(#template, or: $value.template), + args: data.get(#args, or: $value.args), + ); + + @override + SlideOptionsCopyWith<$R2, SlideOptions, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _SlideOptionsCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 16721749..2f34e2fe 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: meta: ^1.15.0 ack: ^1.0.0-beta.7 ack_annotations: ^1.0.0-beta.7 + dart_mappable: ^4.7.0 markdown: ^7.3.0 logging: ^1.3.0 crypto: ^3.0.6 @@ -24,3 +25,6 @@ dev_dependencies: lints: ^5.0.0 test: ^1.24.0 dart_code_metrics_presets: ^2.19.0 + build_runner: ^2.5.4 + ack_generator: ^1.0.0-beta.7 + dart_mappable_builder: ^4.7.0 diff --git a/packages/core/test/src/deck_service_test.dart b/packages/core/test/src/deck_service_test.dart index 9c73385d..b878a173 100644 --- a/packages/core/test/src/deck_service_test.dart +++ b/packages/core/test/src/deck_service_test.dart @@ -144,7 +144,7 @@ void main() { 'saveReferences retains last_modified when asset files are unchanged', () async { final deck = Deck( - slides: [const Slide(key: 'intro')], + slides: [Slide(key: 'intro')], configuration: config, ); diff --git a/packages/core/test/src/models/asset_model_test.dart b/packages/core/test/src/models/asset_model_test.dart index 76a1cb3d..2527a4f5 100644 --- a/packages/core/test/src/models/asset_model_test.dart +++ b/packages/core/test/src/models/asset_model_test.dart @@ -415,6 +415,27 @@ void main() { expect(GeneratedAsset.schema.safeParse(invalid).isOk, isFalse); }); }); + + group('parse', () { + test('parses valid input', () { + final asset = GeneratedAsset.parse({ + 'name': 'test', + 'extension': 'png', + 'type': 'thumbnail', + }); + + expect(asset.name, 'test'); + expect(asset.extension, AssetExtension.png); + expect(asset.type, 'thumbnail'); + }); + + test('throws AckException when required fields are missing', () { + expect( + () => GeneratedAsset.parse({'extension': 'png', 'type': 'thumbnail'}), + throwsA(isA()), + ); + }); + }); }); group('GeneratedAssetsReference', () { @@ -431,6 +452,18 @@ void main() { expect(ref.lastModified, lastModified); expect(ref.files, files); }); + + test('files is unmodifiable', () { + final ref = GeneratedAssetsReference( + lastModified: DateTime(2024, 1, 1), + files: ['file1.png'], + ); + + expect( + () => (ref.files as List).add('file2.png'), + throwsUnsupportedError, + ); + }); }); group('copyWith', () { @@ -498,6 +531,24 @@ void main() { expect(ref.files, ['file1.png', 'file2.jpeg']); }); + test('fromMap throws when last_modified is missing', () { + expect( + () => GeneratedAssetsReference.fromMap({ + 'files': ['file.png'], + }), + throwsA(isA()), + ); + }); + + test('fromMap throws when files is missing', () { + expect( + () => GeneratedAssetsReference.fromMap({ + 'last_modified': '2024-06-20T08:15:30.000Z', + }), + throwsA(isA()), + ); + }); + test('round-trip serialization preserves data', () { final original = GeneratedAssetsReference( lastModified: DateTime.utc(2024, 12, 25, 23, 59, 59), @@ -523,6 +574,66 @@ void main() { }); }); + group('schema', () { + test('validates correct structure', () { + expect( + GeneratedAssetsReference.schema.safeParse({ + 'last_modified': '2024-06-20T08:15:30.000Z', + 'files': ['file1.png', 'file2.jpeg'], + }).isOk, + isTrue, + ); + }); + + test('rejects missing last_modified', () { + expect( + GeneratedAssetsReference.schema.safeParse({ + 'files': ['file1.png'], + }).isOk, + isFalse, + ); + }); + + test('rejects missing files', () { + expect( + GeneratedAssetsReference.schema.safeParse({ + 'last_modified': '2024-06-20T08:15:30.000Z', + }).isOk, + isFalse, + ); + }); + }); + + group('parse', () { + test('parses valid input', () { + final ref = GeneratedAssetsReference.parse({ + 'last_modified': '2024-06-20T08:15:30.000Z', + 'files': ['file1.png', 'file2.jpeg'], + }); + + expect(ref.lastModified, DateTime.utc(2024, 6, 20, 8, 15, 30)); + expect(ref.files, ['file1.png', 'file2.jpeg']); + }); + + test('throws AckException when last_modified is missing', () { + expect( + () => GeneratedAssetsReference.parse({ + 'files': ['file1.png'], + }), + throwsA(isA()), + ); + }); + + test('throws AckException when files is missing', () { + expect( + () => GeneratedAssetsReference.parse({ + 'last_modified': '2024-06-20T08:15:30.000Z', + }), + throwsA(isA()), + ); + }); + }); + group('equality and hashCode', () { test('equal references are equal', () { final ref1 = GeneratedAssetsReference( diff --git a/packages/core/test/src/models/block_model_test.dart b/packages/core/test/src/models/block_model_test.dart index 62cc79b4..d36e1bc2 100644 --- a/packages/core/test/src/models/block_model_test.dart +++ b/packages/core/test/src/models/block_model_test.dart @@ -407,7 +407,7 @@ void main() { test('throws on invalid alignment', () { final map = {'type': 'column', 'align': 'invalid'}; - expect(() => ContentBlock.fromMap(map), throwsException); + expect(() => ContentBlock.fromMap(map), throwsArgumentError); }); }); @@ -491,6 +491,15 @@ void main() { expect((section.blocks[0] as ContentBlock).content, 'A'); }); + test('blocks is unmodifiable', () { + final section = SectionBlock([ContentBlock('A')]); + + expect( + () => (section.blocks as List).add(ContentBlock('B')), + throwsUnsupportedError, + ); + }); + test('creates with all parameters', () { final section = SectionBlock( [ContentBlock('Test')], @@ -641,6 +650,23 @@ void main() { expect(() => widget.args['newKey'] = 'fail', throwsUnsupportedError); }); + group('constructor validation', () { + test('throws ArgumentError when args contain reserved keys', () { + for (final reservedKey in const [ + 'type', + 'name', + 'align', + 'flex', + 'scrollable', + ]) { + expect( + () => WidgetBlock(name: 'Test', args: {reservedKey: 'invalid'}), + throwsArgumentError, + ); + } + }); + }); + group('copyWith', () { test('copies with new name', () { final original = WidgetBlock(name: 'Original'); @@ -695,6 +721,24 @@ void main() { expect(map['customKey'], 'customValue'); expect(map['count'], 5); }); + + test('serializes reserved fields and custom args together', () { + final widget = WidgetBlock( + name: 'ReservedName', + align: ContentAlignment.center, + flex: 2, + scrollable: true, + args: {'custom': 'value'}, + ); + final map = widget.toMap(); + + expect(map['type'], 'widget'); + expect(map['name'], 'ReservedName'); + expect(map['align'], 'center'); + expect(map['flex'], 2); + expect(map['scrollable'], true); + expect(map['custom'], 'value'); + }); }); group('fromMap', () { @@ -728,6 +772,24 @@ void main() { expect(widget.args.containsKey('type'), isFalse); expect(widget.args.containsKey('name'), isFalse); }); + + test('strips reserved fields from args', () { + final widget = WidgetBlock.fromMap({ + 'type': 'widget', + 'name': 'MyWidget', + 'align': 'center', + 'flex': 3, + 'scrollable': true, + 'custom': 'value', + }); + + expect(widget.args.containsKey('type'), isFalse); + expect(widget.args.containsKey('name'), isFalse); + expect(widget.args.containsKey('align'), isFalse); + expect(widget.args.containsKey('flex'), isFalse); + expect(widget.args.containsKey('scrollable'), isFalse); + expect(widget.args['custom'], 'value'); + }); }); group('round-trip serialization', () { @@ -837,28 +899,7 @@ void main() { }); }); - group('StringContentX extension', () { - test('converts string to ContentBlock', () { - final block = 'Hello World'.toBlock(); - - expect(block, isA()); - expect(block.content, 'Hello World'); - }); - - test('preserves content exactly', () { - const content = 'Line 1\nLine 2\n\nLine 4'; - final block = content.toBlock(); - - expect(block.content, content); - }); - }); - - // Note: BlockX extension methods (flex(), scrollable()) cannot be tested - // directly because they share names with Block properties. Instance members - // take precedence over extension methods in Dart. The extension is designed - // for use in builder patterns where the type is explicitly Block, not a - // subclass. Testing the underlying copyWith functionality instead. - group('Block copyWith via extensions pattern', () { + group('Block copyWith', () { test('copyWith can set flex value', () { final original = ContentBlock('Test'); final modified = original.copyWith(flex: 5); diff --git a/packages/core/test/src/models/deck_model_test.dart b/packages/core/test/src/models/deck_model_test.dart index e83c4bc9..801b9e9e 100644 --- a/packages/core/test/src/models/deck_model_test.dart +++ b/packages/core/test/src/models/deck_model_test.dart @@ -37,10 +37,7 @@ void main() { }); test('creates with slides', () { - final slides = [ - const Slide(key: 'slide-1'), - const Slide(key: 'slide-2'), - ]; + final slides = [Slide(key: 'slide-1'), Slide(key: 'slide-2')]; final deck = Deck(slides: slides, configuration: DeckConfiguration()); expect(deck.slides.length, 2); @@ -48,13 +45,25 @@ void main() { expect(deck.slides[1].key, 'slide-2'); }); + test('slides is unmodifiable', () { + final deck = Deck( + slides: [Slide(key: 'slide-1')], + configuration: DeckConfiguration(), + ); + + expect( + () => (deck.slides as List).add(Slide(key: 'slide-2')), + throwsUnsupportedError, + ); + }); + group('copyWith', () { test('copies with new slides', () { final original = Deck( - slides: const [Slide(key: 'original')], + slides: [Slide(key: 'original')], configuration: DeckConfiguration(), ); - final copy = original.copyWith(slides: const [Slide(key: 'new')]); + final copy = original.copyWith(slides: [Slide(key: 'new')]); expect(copy.slides[0].key, 'new'); }); @@ -73,7 +82,7 @@ void main() { test('preserves values when not specified', () { final original = Deck( - slides: const [Slide(key: 'keep')], + slides: [Slide(key: 'keep')], configuration: DeckConfiguration(projectDir: '/keep'), ); final copy = original.copyWith(); @@ -239,12 +248,12 @@ void main() { 'slides': [], }; - expect(() => Deck.fromMap(map), throwsA(isA())); + expect(() => Deck.parse(map), throwsA(isA())); }); test('throws when slides is missing', () { expect( - () => Deck.fromMap({}), + () => Deck.parse({}), throwsA( isA().having( (error) => error.toJson(), @@ -265,7 +274,7 @@ void main() { 'assetsPath', ]) { expect( - () => Deck.fromMap({ + () => Deck.parse({ 'slides': [], 'configuration': {field: null}, }), @@ -304,7 +313,7 @@ void main() { slides: [ Slide( key: 'rt-slide', - options: const SlideOptions(title: 'RT Title'), + options: SlideOptions(title: 'RT Title'), sections: [ SectionBlock([ContentBlock('Content')]), ], @@ -532,11 +541,11 @@ void main() { group('equality', () { test('equal decks are equal', () { final deck1 = Deck( - slides: const [Slide(key: 'same')], + slides: [Slide(key: 'same')], configuration: DeckConfiguration(projectDir: '/same'), ); final deck2 = Deck( - slides: const [Slide(key: 'same')], + slides: [Slide(key: 'same')], configuration: DeckConfiguration(projectDir: '/same'), ); @@ -546,11 +555,11 @@ void main() { test('different slides make decks unequal', () { final deck1 = Deck( - slides: const [Slide(key: 'a')], + slides: [Slide(key: 'a')], configuration: DeckConfiguration(), ); final deck2 = Deck( - slides: const [Slide(key: 'b')], + slides: [Slide(key: 'b')], configuration: DeckConfiguration(), ); diff --git a/packages/core/test/src/models/slide_model_test.dart b/packages/core/test/src/models/slide_model_test.dart index e896db1f..9d0c2aac 100644 --- a/packages/core/test/src/models/slide_model_test.dart +++ b/packages/core/test/src/models/slide_model_test.dart @@ -7,7 +7,7 @@ void main() { group('Slide Model', () { group('Slide', () { test('creates with required key only', () { - const slide = Slide(key: 'test-key'); + final slide = Slide(key: 'test-key'); expect(slide.key, 'test-key'); expect(slide.options, isNull); @@ -20,7 +20,7 @@ void main() { SectionBlock([ContentBlock('Content')]), ]; final comments = ['Speaker note 1', 'Speaker note 2']; - final options = const SlideOptions(title: 'Title', style: 'custom'); + final options = SlideOptions(title: 'Title', style: 'custom'); final slide = Slide( key: 'full-key', @@ -35,25 +35,48 @@ void main() { expect(slide.comments.length, 2); }); + test('sections is unmodifiable', () { + final slide = Slide( + key: 'immutable-sections', + sections: [ + SectionBlock([ContentBlock('A')]), + ], + ); + + expect( + () => (slide.sections as List).add(SectionBlock([])), + throwsUnsupportedError, + ); + }); + + test('comments is unmodifiable', () { + final slide = Slide(key: 'immutable-comments', comments: ['note']); + + expect( + () => (slide.comments as List).add('another'), + throwsUnsupportedError, + ); + }); + group('copyWith', () { test('copies with new key', () { - const original = Slide(key: 'original'); + final original = Slide(key: 'original'); final copy = original.copyWith(key: 'new-key'); expect(copy.key, 'new-key'); }); test('copies with new options', () { - const original = Slide(key: 'key'); + final original = Slide(key: 'key'); final copy = original.copyWith( - options: const SlideOptions(title: 'New Title'), + options: SlideOptions(title: 'New Title'), ); expect(copy.options?.title, 'New Title'); }); test('copies with new sections', () { - const original = Slide(key: 'key'); + final original = Slide(key: 'key'); final newSections = [ SectionBlock([ContentBlock('New')]), ]; @@ -63,7 +86,7 @@ void main() { }); test('copies with new comments', () { - const original = Slide(key: 'key'); + final original = Slide(key: 'key'); final copy = original.copyWith(comments: ['Note 1', 'Note 2']); expect(copy.comments, ['Note 1', 'Note 2']); @@ -72,7 +95,7 @@ void main() { test('preserves values when not specified', () { final original = Slide( key: 'key', - options: const SlideOptions(title: 'Title'), + options: SlideOptions(title: 'Title'), sections: [ SectionBlock([ContentBlock('Content')]), ], @@ -89,7 +112,7 @@ void main() { group('toMap', () { test('serializes minimal slide', () { - const slide = Slide(key: 'minimal'); + final slide = Slide(key: 'minimal'); final map = slide.toMap(); expect(map['key'], 'minimal'); @@ -101,7 +124,7 @@ void main() { test('serializes slide with options', () { final slide = Slide( key: 'with-opts', - options: const SlideOptions(title: 'My Title', style: 'dark'), + options: SlideOptions(title: 'My Title', style: 'dark'), ); final map = slide.toMap(); @@ -125,7 +148,7 @@ void main() { }); test('serializes slide with comments', () { - const slide = Slide(key: 'with-comments', comments: ['Note 1']); + final slide = Slide(key: 'with-comments', comments: ['Note 1']); final map = slide.toMap(); expect(map['comments'], ['Note 1']); @@ -187,7 +210,7 @@ void main() { () { for (final field in ['title', 'style', 'template']) { expect( - () => Slide.fromMap({ + () => Slide.parse({ 'key': 'invalid-options', 'options': {field: null}, }), @@ -202,11 +225,9 @@ void main() { test('preserves data through toMap/fromMap', () { final original = Slide( key: 'roundtrip', - options: const SlideOptions(title: 'RT Title', style: 'rt-style'), + options: SlideOptions(title: 'RT Title', style: 'rt-style'), sections: [ - SectionBlock([ - ContentBlock('Section content'), - ]), + SectionBlock([ContentBlock('Section content')]), ], comments: ['RT Comment'], ); @@ -294,16 +315,16 @@ void main() { group('equality', () { test('equal slides are equal', () { - const slide1 = Slide(key: 'same', comments: ['note']); - const slide2 = Slide(key: 'same', comments: ['note']); + final slide1 = Slide(key: 'same', comments: ['note']); + final slide2 = Slide(key: 'same', comments: ['note']); expect(slide1, slide2); expect(slide1.hashCode, slide2.hashCode); }); test('different keys make slides unequal', () { - const slide1 = Slide(key: 'key1'); - const slide2 = Slide(key: 'key2'); + final slide1 = Slide(key: 'key1'); + final slide2 = Slide(key: 'key2'); expect(slide1, isNot(slide2)); }); @@ -311,11 +332,11 @@ void main() { test('different options make slides unequal', () { final slide1 = Slide( key: 'key', - options: const SlideOptions(title: 'A'), + options: SlideOptions(title: 'A'), ); final slide2 = Slide( key: 'key', - options: const SlideOptions(title: 'B'), + options: SlideOptions(title: 'B'), ); expect(slide1, isNot(slide2)); @@ -339,8 +360,8 @@ void main() { }); test('different comments make slides unequal', () { - const slide1 = Slide(key: 'key', comments: ['A']); - const slide2 = Slide(key: 'key', comments: ['B']); + final slide1 = Slide(key: 'key', comments: ['A']); + final slide2 = Slide(key: 'key', comments: ['B']); expect(slide1, isNot(slide2)); }); @@ -385,7 +406,7 @@ void main() { group('SlideOptions', () { test('creates with default values', () { - const options = SlideOptions(); + final options = SlideOptions(); expect(options.title, isNull); expect(options.style, isNull); @@ -393,7 +414,7 @@ void main() { }); test('creates with all parameters', () { - const options = SlideOptions( + final options = SlideOptions( title: 'Title', style: 'dark', args: {'custom': 'value'}, @@ -404,8 +425,26 @@ void main() { expect(options.args['custom'], 'value'); }); + test('args is unmodifiable', () { + final options = SlideOptions(args: {'custom': 'value'}); + + expect( + () => options.args['newKey'] = 'newValue', + throwsUnsupportedError, + ); + }); + + test('throws ArgumentError when args contain reserved keys', () { + for (final reservedKey in const ['title', 'style', 'template']) { + expect( + () => SlideOptions(args: {reservedKey: 'invalid'}), + throwsArgumentError, + ); + } + }); + test('creates with template parameter', () { - const options = SlideOptions(template: 'my-template'); + final options = SlideOptions(template: 'my-template'); expect(options.template, 'my-template'); expect(options.title, isNull); @@ -413,42 +452,42 @@ void main() { }); test('template defaults to null', () { - const options = SlideOptions(); + final options = SlideOptions(); expect(options.template, isNull); }); group('copyWith', () { test('copies with new title', () { - const original = SlideOptions(title: 'Original'); + final original = SlideOptions(title: 'Original'); final copy = original.copyWith(title: 'New'); expect(copy.title, 'New'); }); test('copies with new style', () { - const original = SlideOptions(style: 'light'); + final original = SlideOptions(style: 'light'); final copy = original.copyWith(style: 'dark'); expect(copy.style, 'dark'); }); test('copies with new args', () { - const original = SlideOptions(args: {'a': 1}); + final original = SlideOptions(args: {'a': 1}); final copy = original.copyWith(args: {'b': 2}); expect(copy.args, {'b': 2}); }); test('copies with new template', () { - const original = SlideOptions(template: 'original-template'); + final original = SlideOptions(template: 'original-template'); final copy = original.copyWith(template: 'new-template'); expect(copy.template, 'new-template'); }); test('preserves values when not specified', () { - const original = SlideOptions( + final original = SlideOptions( title: 'T', style: 'S', template: 'tmpl', @@ -465,7 +504,7 @@ void main() { group('toMap', () { test('serializes empty options', () { - const options = SlideOptions(); + final options = SlideOptions(); final map = options.toMap(); expect(map.containsKey('title'), isFalse); @@ -473,7 +512,7 @@ void main() { }); test('serializes title and style', () { - const options = SlideOptions(title: 'T', style: 'S'); + final options = SlideOptions(title: 'T', style: 'S'); final map = options.toMap(); expect(map['title'], 'T'); @@ -481,21 +520,21 @@ void main() { }); test('serializes template when present', () { - const options = SlideOptions(template: 'my-template'); + final options = SlideOptions(template: 'my-template'); final map = options.toMap(); expect(map['template'], 'my-template'); }); test('omits template when null', () { - const options = SlideOptions(title: 'T'); + final options = SlideOptions(title: 'T'); final map = options.toMap(); expect(map.containsKey('template'), isFalse); }); test('spreads args into map', () { - const options = SlideOptions( + final options = SlideOptions( title: 'T', args: {'custom1': 'val1', 'custom2': 42}, ); @@ -505,6 +544,16 @@ void main() { expect(map['custom1'], 'val1'); expect(map['custom2'], 42); }); + + test('rejects args that contain reserved keys', () { + expect( + () => SlideOptions( + title: 'Reserved title', + args: {'title': 'arg-title', 'custom': 'value'}, + ), + throwsArgumentError, + ); + }); }); group('fromMap', () { @@ -553,11 +602,25 @@ void main() { expect(options.args['anotherKey'], 123); expect(options.args.containsKey('title'), isFalse); }); + + test('strips all reserved keys from args', () { + final options = SlideOptions.fromMap({ + 'title': 'T', + 'style': 'S', + 'template': 'tmpl', + 'extra': 'value', + }); + + expect(options.args.containsKey('title'), isFalse); + expect(options.args.containsKey('style'), isFalse); + expect(options.args.containsKey('template'), isFalse); + expect(options.args['extra'], 'value'); + }); }); group('round-trip serialization', () { test('preserves data through toMap/fromMap', () { - const original = SlideOptions( + final original = SlideOptions( title: 'RT', style: 'rt-style', args: {'k': 'v'}, @@ -571,7 +634,7 @@ void main() { }); test('preserves template through toMap/fromMap', () { - const original = SlideOptions(title: 'RT', template: 'rt-template'); + final original = SlideOptions(title: 'RT', template: 'rt-template'); final restored = SlideOptions.fromMap(original.toMap()); @@ -608,44 +671,44 @@ void main() { group('equality', () { test('equal options are equal', () { - const opt1 = SlideOptions(title: 'T', args: {'a': 1}); - const opt2 = SlideOptions(title: 'T', args: {'a': 1}); + final opt1 = SlideOptions(title: 'T', args: {'a': 1}); + final opt2 = SlideOptions(title: 'T', args: {'a': 1}); expect(opt1, opt2); expect(opt1.hashCode, opt2.hashCode); }); test('different title makes options unequal', () { - const opt1 = SlideOptions(title: 'A'); - const opt2 = SlideOptions(title: 'B'); + final opt1 = SlideOptions(title: 'A'); + final opt2 = SlideOptions(title: 'B'); expect(opt1, isNot(opt2)); }); test('different style makes options unequal', () { - const opt1 = SlideOptions(style: 'light'); - const opt2 = SlideOptions(style: 'dark'); + final opt1 = SlideOptions(style: 'light'); + final opt2 = SlideOptions(style: 'dark'); expect(opt1, isNot(opt2)); }); test('different args make options unequal', () { - const opt1 = SlideOptions(args: {'a': 1}); - const opt2 = SlideOptions(args: {'a': 2}); + final opt1 = SlideOptions(args: {'a': 1}); + final opt2 = SlideOptions(args: {'a': 2}); expect(opt1, isNot(opt2)); }); test('different template makes options unequal', () { - const opt1 = SlideOptions(template: 'template-a'); - const opt2 = SlideOptions(template: 'template-b'); + final opt1 = SlideOptions(template: 'template-a'); + final opt2 = SlideOptions(template: 'template-b'); expect(opt1, isNot(opt2)); }); test('template present vs absent makes options unequal', () { - const opt1 = SlideOptions(template: 'some-template'); - const opt2 = SlideOptions(); + final opt1 = SlideOptions(template: 'some-template'); + final opt2 = SlideOptions(); expect(opt1, isNot(opt2)); }); diff --git a/packages/genui/test/presentation/thumbnail_preview_service_test.dart b/packages/genui/test/presentation/thumbnail_preview_service_test.dart index 00132ff5..b7b8700a 100644 --- a/packages/genui/test/presentation/thumbnail_preview_service_test.dart +++ b/packages/genui/test/presentation/thumbnail_preview_service_test.dart @@ -54,9 +54,9 @@ void main() { final context = tester.element(find.byType(SizedBox)); final slides = [ - const Slide(key: 'slide_0'), - const Slide(key: 'slide_1'), - const Slide(key: 'slide_2'), + Slide(key: 'slide_0'), + Slide(key: 'slide_1'), + Slide(key: 'slide_2'), ]; final result = await service.generatePreviews( @@ -85,7 +85,7 @@ void main() { ); final context = tester.element(find.byType(SizedBox)); - final slides = [const Slide(key: 'slide_0'), const Slide(key: 'slide_1')]; + final slides = [Slide(key: 'slide_0'), Slide(key: 'slide_1')]; final captured = <(int, Uint8List)>[]; await service.generatePreviews( @@ -112,9 +112,9 @@ void main() { final context = tester.element(find.byType(SizedBox)); final slides = [ - const Slide(key: 'slide_0'), - const Slide(key: 'slide_1'), - const Slide(key: 'slide_2'), + Slide(key: 'slide_0'), + Slide(key: 'slide_1'), + Slide(key: 'slide_2'), ]; var cancelAfter = 1; @@ -140,9 +140,9 @@ void main() { final context = tester.element(find.byType(SizedBox)); final slides = [ - const Slide(key: 'slide_0'), - const Slide(key: 'slide_1'), - const Slide(key: 'slide_2'), + Slide(key: 'slide_0'), + Slide(key: 'slide_1'), + Slide(key: 'slide_2'), ]; var callCount = 0; @@ -182,7 +182,7 @@ void main() { ); final context = tester.element(find.byType(SizedBox)); - final slides = [const Slide(key: 'slide_0'), const Slide(key: 'slide_1')]; + final slides = [Slide(key: 'slide_0'), Slide(key: 'slide_1')]; final captured = <(int, Uint8List)>[]; final failingService = ThumbnailPreviewService( diff --git a/packages/superdeck/lib/src/deck/bundled_deck_service.dart b/packages/superdeck/lib/src/deck/bundled_deck_service.dart index 95bdbb95..e10f317f 100644 --- a/packages/superdeck/lib/src/deck/bundled_deck_service.dart +++ b/packages/superdeck/lib/src/deck/bundled_deck_service.dart @@ -27,7 +27,7 @@ class BundledDeckService extends DeckService { try { final content = await rootBundle.loadString(deckAssetPath); final data = jsonDecode(content) as Map; - return Deck.fromMap(data); + return Deck.parse(data); } on Object catch (error) { return Deck( slides: [ diff --git a/packages/superdeck/lib/src/deck/slide_configuration.dart b/packages/superdeck/lib/src/deck/slide_configuration.dart index 4cf9deeb..34f600ef 100644 --- a/packages/superdeck/lib/src/deck/slide_configuration.dart +++ b/packages/superdeck/lib/src/deck/slide_configuration.dart @@ -30,7 +30,7 @@ class SlideConfiguration { }) : _slide = slide, _widgets = widgets; - SlideOptions get options => _slide.options ?? const SlideOptions(); + SlideOptions get options => _slide.options ?? SlideOptions(); String get key => _slide.key; diff --git a/packages/superdeck/test/deck/slide_configuration_builder_test.dart b/packages/superdeck/test/deck/slide_configuration_builder_test.dart index a326c131..3f6b40cb 100644 --- a/packages/superdeck/test/deck/slide_configuration_builder_test.dart +++ b/packages/superdeck/test/deck/slide_configuration_builder_test.dart @@ -15,7 +15,7 @@ void main() { test('backward compatibility — no templates, applies deck styles', () { final baseStyle = SlideStyle(); final options = DeckOptions(baseStyle: baseStyle); - final slides = [const Slide(key: 'slide-1')]; + final slides = [Slide(key: 'slide-1')]; final configs = builder.buildConfigurations(slides, options); @@ -28,7 +28,7 @@ void main() { final namedStyle = SlideStyle(); final options = DeckOptions(styles: {'dark': namedStyle}); final slides = [ - const Slide( + Slide( key: 'styled', options: SlideOptions(style: 'dark'), ), @@ -48,7 +48,7 @@ void main() { ); final options = DeckOptions(templates: {'corporate': template}); final slides = [ - const Slide( + Slide( key: 'tmpl-slide', options: SlideOptions(template: 'corporate'), ), @@ -68,7 +68,7 @@ void main() { final template = SlideTemplate(styles: {'highlight': variant}); final options = DeckOptions(templates: {'t': template}); final slides = [ - const Slide( + Slide( key: 'variant-slide', options: SlideOptions(template: 't', style: 'highlight'), ), @@ -82,7 +82,7 @@ void main() { test('defaultTemplate applies to slides without explicit template', () { final defaultTemplate = SlideTemplate(baseStyle: SlideStyle()); final options = DeckOptions(defaultTemplate: defaultTemplate); - final slides = [const Slide(key: 'default-tmpl')]; + final slides = [Slide(key: 'default-tmpl')]; final configs = builder.buildConfigurations(slides, options); @@ -97,7 +97,7 @@ void main() { baseStyle: deckBase, ); final slides = [ - const Slide( + Slide( key: 'no-tmpl', options: SlideOptions(template: 'none'), ), @@ -112,7 +112,7 @@ void main() { test('unknown template throws TemplateException', () { final options = DeckOptions(templates: {'real': SlideTemplate()}); final slides = [ - const Slide( + Slide( key: 'bad', options: SlideOptions(template: 'fake'), ), @@ -132,8 +132,8 @@ void main() { templates: {'t': template}, ); final slides = [ - const Slide(key: 'plain'), - const Slide( + Slide(key: 'plain'), + Slide( key: 'templated', options: SlideOptions(template: 't'), ), @@ -147,11 +147,7 @@ void main() { test('slideIndex is correctly assigned', () { final options = DeckOptions(); - final slides = [ - const Slide(key: 'a'), - const Slide(key: 'b'), - const Slide(key: 'c'), - ]; + final slides = [Slide(key: 'a'), Slide(key: 'b'), Slide(key: 'c')]; final configs = builder.buildConfigurations(slides, options); @@ -162,7 +158,7 @@ void main() { test('thumbnailFile stores generated asset key only', () { final options = DeckOptions(); - final slides = [const Slide(key: 'cover')]; + final slides = [Slide(key: 'cover')]; final configs = builder.buildConfigurations(slides, options); diff --git a/packages/superdeck/test/deck/template_resolver_test.dart b/packages/superdeck/test/deck/template_resolver_test.dart index a2bc3b05..67daf71e 100644 --- a/packages/superdeck/test/deck/template_resolver_test.dart +++ b/packages/superdeck/test/deck/template_resolver_test.dart @@ -40,7 +40,7 @@ void main() { final namedStyle = SlideStyle(); final options = DeckOptions(styles: {'dark': namedStyle}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(style: 'dark'); + final slideOptions = SlideOptions(style: 'dark'); final result = resolver.resolve(slideOptions); @@ -52,7 +52,7 @@ void main() { test('no template, unknown style — throws TemplateException', () { final options = DeckOptions(styles: {'light': SlideStyle()}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(style: 'nonexistent'); + final slideOptions = SlideOptions(style: 'nonexistent'); expect( () => resolver.resolve(slideOptions), @@ -67,7 +67,7 @@ void main() { styles: {'light': SlideStyle(), 'dark': SlideStyle()}, ); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(style: 'missing'); + final slideOptions = SlideOptions(style: 'missing'); expect( () => resolver.resolve(slideOptions), @@ -89,7 +89,7 @@ void main() { final template = SlideTemplate(baseStyle: templateBaseStyle); final options = DeckOptions(templates: {'hero': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'hero'); + final slideOptions = SlideOptions(template: 'hero'); final result = resolver.resolve(slideOptions); @@ -105,7 +105,7 @@ void main() { final template = SlideTemplate(); final options = DeckOptions(templates: {'blank': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'blank'); + final slideOptions = SlideOptions(template: 'blank'); final result = resolver.resolve(slideOptions); @@ -118,7 +118,7 @@ void main() { final template = SlideTemplate(styles: {'accent': templateStyle}); final options = DeckOptions(templates: {'branded': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'branded', style: 'accent'); + final slideOptions = SlideOptions(template: 'branded', style: 'accent'); final result = resolver.resolve(slideOptions); @@ -133,7 +133,7 @@ void main() { final template = SlideTemplate(styles: {'known': SlideStyle()}); final options = DeckOptions(templates: {'myTemplate': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions( + final slideOptions = SlideOptions( template: 'myTemplate', style: 'unknown', ); @@ -150,7 +150,7 @@ void main() { final template = SlideTemplate(styles: {'valid': SlideStyle()}); final options = DeckOptions(templates: {'corporate': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions( + final slideOptions = SlideOptions( template: 'corporate', style: 'bogus', ); @@ -171,7 +171,7 @@ void main() { test('unknown template name — throws TemplateException', () { final options = DeckOptions(templates: {'existing': SlideTemplate()}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'doesNotExist'); + final slideOptions = SlideOptions(template: 'doesNotExist'); expect( () => resolver.resolve(slideOptions), @@ -184,7 +184,7 @@ void main() { () { final options = DeckOptions(templates: {'real': SlideTemplate()}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'phantom'); + final slideOptions = SlideOptions(template: 'phantom'); expect( () => resolver.resolve(slideOptions), @@ -204,7 +204,7 @@ void main() { () { final options = DeckOptions(); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'phantom'); + final slideOptions = SlideOptions(template: 'phantom'); expect( () => resolver.resolve(slideOptions), @@ -254,7 +254,7 @@ void main() { templates: {'explicit': explicitTemplate}, ); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'explicit'); + final slideOptions = SlideOptions(template: 'explicit'); final result = resolver.resolve(slideOptions); @@ -270,7 +270,7 @@ void main() { final defaultTemplate = SlideTemplate(); final options = DeckOptions(defaultTemplate: defaultTemplate); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(title: 'No template'); + final slideOptions = SlideOptions(title: 'No template'); final result = resolver.resolve(slideOptions); @@ -286,7 +286,7 @@ void main() { baseStyle: baseStyle, ); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'none'); + final slideOptions = SlideOptions(template: 'none'); final result = resolver.resolve(slideOptions); @@ -303,7 +303,7 @@ void main() { styles: {'accent': deckStyle}, ); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'none', style: 'accent'); + final slideOptions = SlideOptions(template: 'none', style: 'accent'); final result = resolver.resolve(slideOptions); @@ -319,7 +319,7 @@ void main() { ); final options = DeckOptions(defaultTemplate: defaultTemplate); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(style: 'unknown'); + final slideOptions = SlideOptions(style: 'unknown'); expect( () => resolver.resolve(slideOptions), @@ -359,7 +359,7 @@ void main() { styles: {'variant': namedStyle}, ); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(style: 'variant'); + final slideOptions = SlideOptions(style: 'variant'); final result = resolver.resolve(slideOptions); @@ -380,7 +380,7 @@ void main() { ); final options = DeckOptions(templates: {'themed': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions( + final slideOptions = SlideOptions( template: 'themed', style: 'highlight', ); @@ -402,7 +402,7 @@ void main() { final template = SlideTemplate(baseStyle: templateBase); final options = DeckOptions(templates: {'simple': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 'simple'); + final slideOptions = SlideOptions(template: 'simple'); final result = resolver.resolve(slideOptions); @@ -421,7 +421,7 @@ void main() { templates: {'t': template}, ); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 't'); + final slideOptions = SlideOptions(template: 't'); final result = resolver.resolve(slideOptions); @@ -451,7 +451,7 @@ void main() { test('is true when explicit template is resolved', () { final options = DeckOptions(templates: {'t': SlideTemplate()}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 't'); + final slideOptions = SlideOptions(template: 't'); final result = resolver.resolve(slideOptions); @@ -482,7 +482,7 @@ void main() { final template = SlideTemplate(); final options = DeckOptions(templates: {'t': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 't'); + final slideOptions = SlideOptions(template: 't'); final result = resolver.resolve(slideOptions); @@ -493,7 +493,7 @@ void main() { final template = SlideTemplate(); final options = DeckOptions(templates: {'t': template}); final resolver = TemplateResolver(options); - const slideOptions = SlideOptions(template: 't'); + final slideOptions = SlideOptions(template: 't'); final withTemplate = resolver.resolve(slideOptions); final withoutTemplate = resolver.resolve(null); diff --git a/packages/superdeck/test/markdown/builders/text_element_builder_widget_test.dart b/packages/superdeck/test/markdown/builders/text_element_builder_widget_test.dart index 2b7246c3..fc74f795 100644 --- a/packages/superdeck/test/markdown/builders/text_element_builder_widget_test.dart +++ b/packages/superdeck/test/markdown/builders/text_element_builder_widget_test.dart @@ -196,7 +196,7 @@ class _MarkdownHarness extends StatelessWidget { final slideConfiguration = SlideConfiguration( slideIndex: 0, style: SlideStyle(), - slide: const Slide(key: 'test-slide'), + slide: Slide(key: 'test-slide'), thumbnailFile: 'thumb.png', ); diff --git a/packages/superdeck/test/markdown/image_element_rendering_test.dart b/packages/superdeck/test/markdown/image_element_rendering_test.dart index 9582fc4f..64b03a1c 100644 --- a/packages/superdeck/test/markdown/image_element_rendering_test.dart +++ b/packages/superdeck/test/markdown/image_element_rendering_test.dart @@ -201,7 +201,7 @@ class _MarkdownHarness extends StatelessWidget { final slideConfiguration = SlideConfiguration( slideIndex: 0, style: SlideStyle(), - slide: const Slide(key: 'slide'), + slide: Slide(key: 'slide'), thumbnailFile: 'thumb.png', ); diff --git a/packages/superdeck/test/markdown/markdown_builders_test.dart b/packages/superdeck/test/markdown/markdown_builders_test.dart index a1573952..a72cbf6e 100644 --- a/packages/superdeck/test/markdown/markdown_builders_test.dart +++ b/packages/superdeck/test/markdown/markdown_builders_test.dart @@ -238,7 +238,7 @@ class _MarkdownHarness extends StatelessWidget { final slideConfiguration = SlideConfiguration( slideIndex: 0, style: SlideStyle(), - slide: const Slide(key: 'slide'), + slide: Slide(key: 'slide'), thumbnailFile: 'thumb.png', ); diff --git a/packages/superdeck/test/slide_template_test.dart b/packages/superdeck/test/slide_template_test.dart index 657a6aec..8dd9ea44 100644 --- a/packages/superdeck/test/slide_template_test.dart +++ b/packages/superdeck/test/slide_template_test.dart @@ -8,7 +8,7 @@ import 'test_helpers.dart'; void main() { group('SimpleTemplate', () { - const slide = Slide(key: 'simple-slide'); + final slide = Slide(key: 'simple-slide'); final config = SlideConfiguration( slide: slide, slideIndex: 0,