diff --git a/packages/clean_framework/CHANGELOG.md b/packages/clean_framework/CHANGELOG.md index d2181b50..cb33ff6d 100644 --- a/packages/clean_framework/CHANGELOG.md +++ b/packages/clean_framework/CHANGELOG.md @@ -17,6 +17,7 @@ Sub-packages: - Introduced `transformers` to **UseCase**; use cases are now lazily instantiated - Simplified provider creation and usage - Added `UseCaseProvider.autoDispose` +- Added `UseCaseProviderBridge` For migration guide, please refer to [the docs](https://docs.page/MattHamburger/clean_framework/codelabs/clean-framework/migration-guide). diff --git a/packages/clean_framework/example/lib/features/home/domain/home_entity.dart b/packages/clean_framework/example/lib/features/home/domain/home_entity.dart index 7306eb06..3b0f4a8d 100644 --- a/packages/clean_framework/example/lib/features/home/domain/home_entity.dart +++ b/packages/clean_framework/example/lib/features/home/domain/home_entity.dart @@ -9,16 +9,18 @@ class HomeEntity extends Entity { this.pokemonNameQuery = '', this.status = HomeStatus.initial, this.isRefresh = false, + this.lastViewedPokemon = '', }); final List pokemons; final String pokemonNameQuery; final HomeStatus status; final bool isRefresh; + final String lastViewedPokemon; @override List get props { - return [pokemons, pokemonNameQuery, status, isRefresh]; + return [pokemons, pokemonNameQuery, status, isRefresh, lastViewedPokemon]; } @override @@ -27,12 +29,14 @@ class HomeEntity extends Entity { String? pokemonNameQuery, HomeStatus? status, bool? isRefresh, + String? lastViewedPokemon, }) { return HomeEntity( pokemons: pokemons ?? this.pokemons, pokemonNameQuery: pokemonNameQuery ?? this.pokemonNameQuery, status: status ?? this.status, isRefresh: isRefresh ?? this.isRefresh, + lastViewedPokemon: lastViewedPokemon ?? this.lastViewedPokemon, ); } } diff --git a/packages/clean_framework/example/lib/features/home/domain/home_ui_output.dart b/packages/clean_framework/example/lib/features/home/domain/home_ui_output.dart index 224d530e..b13609ef 100644 --- a/packages/clean_framework/example/lib/features/home/domain/home_ui_output.dart +++ b/packages/clean_framework/example/lib/features/home/domain/home_ui_output.dart @@ -7,12 +7,14 @@ class HomeUIOutput extends Output { required this.pokemons, required this.status, required this.isRefresh, + required this.lastViewedPokemon, }); final List pokemons; final HomeStatus status; final bool isRefresh; + final String lastViewedPokemon; @override - List get props => [pokemons, status, isRefresh]; + List get props => [pokemons, status, isRefresh, lastViewedPokemon]; } diff --git a/packages/clean_framework/example/lib/features/home/domain/home_use_case.dart b/packages/clean_framework/example/lib/features/home/domain/home_use_case.dart index e051c094..77795d0b 100644 --- a/packages/clean_framework/example/lib/features/home/domain/home_use_case.dart +++ b/packages/clean_framework/example/lib/features/home/domain/home_use_case.dart @@ -14,6 +14,7 @@ class HomeUseCase extends UseCase { transformers: [ HomeUIOutputTransformer(), PokemonSearchInputTransformer(), + LastViewedPokemonInputTransformer(), ], ); @@ -76,6 +77,7 @@ class HomeUIOutputTransformer pokemons: filteredPokemons.toList(growable: false), status: entity.status, isRefresh: entity.isRefresh, + lastViewedPokemon: entity.lastViewedPokemon, ); } } @@ -87,3 +89,17 @@ class PokemonSearchInputTransformer return entity.copyWith(pokemonNameQuery: input.name); } } + +class LastViewedPokemonInput extends Input { + LastViewedPokemonInput({required this.name}); + + final String name; +} + +class LastViewedPokemonInputTransformer + extends InputTransformer { + @override + HomeEntity transform(HomeEntity entity, LastViewedPokemonInput input) { + return entity.copyWith(lastViewedPokemon: input.name); + } +} diff --git a/packages/clean_framework/example/lib/features/home/presentation/home_presenter.dart b/packages/clean_framework/example/lib/features/home/presentation/home_presenter.dart index 9c04d040..738167e4 100644 --- a/packages/clean_framework/example/lib/features/home/presentation/home_presenter.dart +++ b/packages/clean_framework/example/lib/features/home/presentation/home_presenter.dart @@ -26,6 +26,7 @@ class HomePresenter onRetry: useCase.fetchPokemons, isLoading: output.status == HomeStatus.loading, hasFailedLoading: output.status == HomeStatus.failed, + lastViewedPokemon: output.lastViewedPokemon, ); } diff --git a/packages/clean_framework/example/lib/features/home/presentation/home_ui.dart b/packages/clean_framework/example/lib/features/home/presentation/home_ui.dart index 4f64aa35..368d13fc 100644 --- a/packages/clean_framework/example/lib/features/home/presentation/home_ui.dart +++ b/packages/clean_framework/example/lib/features/home/presentation/home_ui.dart @@ -60,6 +60,22 @@ class HomeUI extends UI { bottom: viewModel.isLoading || viewModel.hasFailedLoading ? null : PokemonSearchField(onChanged: viewModel.onSearch), + actions: [ + if (viewModel.lastViewedPokemon.isNotEmpty) + Text.rich( + TextSpan( + text: 'Last Viewed: ', + children: [ + TextSpan( + text: viewModel.lastViewedPokemon, + style: textTheme.labelSmall, + ), + ], + style: textTheme.bodySmall, + ), + ), + const SizedBox(width: 16), + ], ), body: child, ); diff --git a/packages/clean_framework/example/lib/features/home/presentation/home_view_model.dart b/packages/clean_framework/example/lib/features/home/presentation/home_view_model.dart index b3bbdbf4..7a3c5352 100644 --- a/packages/clean_framework/example/lib/features/home/presentation/home_view_model.dart +++ b/packages/clean_framework/example/lib/features/home/presentation/home_view_model.dart @@ -7,6 +7,7 @@ class HomeViewModel extends ViewModel { required this.pokemons, required this.isLoading, required this.hasFailedLoading, + required this.lastViewedPokemon, required this.onRetry, required this.onRefresh, required this.onSearch, @@ -15,11 +16,14 @@ class HomeViewModel extends ViewModel { final List pokemons; final bool isLoading; final bool hasFailedLoading; + final String lastViewedPokemon; final VoidCallback onRetry; final AsyncCallback onRefresh; final ValueChanged onSearch; @override - List get props => [pokemons, isLoading, hasFailedLoading]; + List get props { + return [pokemons, isLoading, hasFailedLoading, lastViewedPokemon]; + } } diff --git a/packages/clean_framework/example/lib/features/profile/domain/profile_entity.dart b/packages/clean_framework/example/lib/features/profile/domain/profile_entity.dart index 33dfdbcd..924190d5 100644 --- a/packages/clean_framework/example/lib/features/profile/domain/profile_entity.dart +++ b/packages/clean_framework/example/lib/features/profile/domain/profile_entity.dart @@ -3,6 +3,7 @@ import 'package:clean_framework_example/features/profile/models/pokemon_profile_ class ProfileEntity extends Entity { ProfileEntity({ + this.name = '', this.types = const [], this.description = '', this.height = 0, @@ -10,6 +11,7 @@ class ProfileEntity extends Entity { this.stats = const [], }); + final String name; final List types; final String description; final int height; @@ -17,10 +19,11 @@ class ProfileEntity extends Entity { final List stats; @override - List get props => [types, description, height, weight, stats]; + List get props => [name, types, description, height, weight, stats]; @override ProfileEntity copyWith({ + String? name, List? types, String? description, int? height, @@ -28,6 +31,7 @@ class ProfileEntity extends Entity { List? stats, }) { return ProfileEntity( + name: name ?? this.name, types: types ?? this.types, description: description ?? this.description, height: height ?? this.height, diff --git a/packages/clean_framework/example/lib/features/profile/domain/profile_use_case.dart b/packages/clean_framework/example/lib/features/profile/domain/profile_use_case.dart index c8080012..3bdd843f 100644 --- a/packages/clean_framework/example/lib/features/profile/domain/profile_use_case.dart +++ b/packages/clean_framework/example/lib/features/profile/domain/profile_use_case.dart @@ -38,6 +38,7 @@ class ProfileUseCase extends UseCase { final profile = success.profile; return entity.copyWith( + name: name, types: profile.types, height: profile.height, weight: profile.weight, diff --git a/packages/clean_framework/example/lib/providers.dart b/packages/clean_framework/example/lib/providers.dart index 2631bbd0..40cb4215 100644 --- a/packages/clean_framework/example/lib/providers.dart +++ b/packages/clean_framework/example/lib/providers.dart @@ -2,13 +2,29 @@ import 'package:clean_framework/clean_framework.dart'; import 'package:clean_framework_example/core/pokemon/pokemon_external_interface.dart'; import 'package:clean_framework_example/features/home/domain/home_use_case.dart'; import 'package:clean_framework_example/features/home/external_interface/pokemon_collection_gateway.dart'; +import 'package:clean_framework_example/features/profile/domain/profile_entity.dart'; import 'package:clean_framework_example/features/profile/domain/profile_use_case.dart'; import 'package:clean_framework_example/features/profile/external_interface/pokemon_profile_gateway.dart'; import 'package:clean_framework_example/features/profile/external_interface/pokemon_species_gateway.dart'; -final homeUseCaseProvider = UseCaseProvider.autoDispose(HomeUseCase.new); +final homeUseCaseProvider = UseCaseProvider.autoDispose( + HomeUseCase.new, + (bridge) { + bridge.connect( + profileUseCaseProvider, + selector: (e) => e.name, + (oldPokeName, pokeName) { + if (oldPokeName != pokeName) { + bridge.useCase.setInput(LastViewedPokemonInput(name: pokeName)); + } + }, + ); + }, +); -final profileUseCaseProvider = UseCaseProvider(ProfileUseCase.new); +final profileUseCaseProvider = UseCaseProvider( + ProfileUseCase.new, +); final pokemonCollectionGateway = GatewayProvider( PokemonCollectionGateway.new, diff --git a/packages/clean_framework/lib/src/core/gateway/gateway.dart b/packages/clean_framework/lib/src/core/gateway/gateway.dart index aef097a5..61024aba 100644 --- a/packages/clean_framework/lib/src/core/gateway/gateway.dart +++ b/packages/clean_framework/lib/src/core/gateway/gateway.dart @@ -19,7 +19,7 @@ abstract class Gateway = Result Function(dynamic); extension RequestSubscriptionMapExtension on RequestSubscriptionMap { void add(RequestSubscription subscription) { - if (this[O] != null) { - throw StateError('A subscription for $O already exists.'); - } - this[O] = subscription; } diff --git a/packages/clean_framework/lib/src/core/use_case/use_case_provider.dart b/packages/clean_framework/lib/src/core/use_case/use_case_provider.dart index 791ce2b9..2daa00db 100644 --- a/packages/clean_framework/lib/src/core/use_case/use_case_provider.dart +++ b/packages/clean_framework/lib/src/core/use_case/use_case_provider.dart @@ -1,8 +1,7 @@ import 'dart:async'; +import 'package:clean_framework/clean_framework.dart'; import 'package:clean_framework/src/core/clean_framework_provider.dart'; -import 'package:clean_framework/src/core/use_case/entity.dart'; -import 'package:clean_framework/src/core/use_case/use_case.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:meta/meta.dart'; @@ -10,19 +9,18 @@ abstract class UseCaseProviderBase, N extends ProviderBase> extends CleanFrameworkProvider { UseCaseProviderBase({required super.provider}); - Future> get notifier => _initCompleter.future; + final StreamController> _notifierController = + StreamController.broadcast(); + + Stream> get notifier => _notifierController.stream; @visibleForOverriding Refreshable buildNotifier(); void init() { - if (!_initCompleter.isCompleted) { - _initCompleter.complete(buildNotifier()); - } + _notifierController.add(buildNotifier()); } - final Completer> _initCompleter = Completer(); - O subscribe(WidgetRef ref) { return ref.watch(_listenForOutputChange(ref)); } @@ -46,8 +44,20 @@ abstract class UseCaseProviderBase, class UseCaseProvider> extends UseCaseProviderBase> { - UseCaseProvider(U Function() create) - : super(provider: StateNotifierProvider((_) => create())); + UseCaseProvider( + U Function() create, [ + UseCaseProviderConnector? connector, + ]) : super( + provider: StateNotifierProvider( + (ref) { + final useCase = create(); + connector?.call( + UseCaseProviderBridge._(useCase, ref), + ); + return useCase; + }, + ), + ); static const autoDispose = AutoDisposeUseCaseProviderBuilder(); @@ -57,8 +67,20 @@ class UseCaseProvider> class AutoDisposeUseCaseProvider> extends UseCaseProviderBase> { - AutoDisposeUseCaseProvider(U Function() create) - : super(provider: StateNotifierProvider.autoDispose((_) => create())); + AutoDisposeUseCaseProvider( + U Function() create, [ + UseCaseProviderConnector? connector, + ]) : super( + provider: StateNotifierProvider.autoDispose( + (ref) { + final useCase = create(); + connector?.call( + UseCaseProviderBridge._(useCase, ref), + ); + return useCase; + }, + ), + ); @override Refreshable buildNotifier() => call().notifier; @@ -68,8 +90,32 @@ class AutoDisposeUseCaseProviderBuilder { const AutoDisposeUseCaseProviderBuilder(); AutoDisposeUseCaseProvider call>( - U Function() create, - ) { - return AutoDisposeUseCaseProvider(create); + U Function() create, [ + UseCaseProviderConnector? connector, + ]) { + return AutoDisposeUseCaseProvider(create, connector); + } +} + +typedef UseCaseProviderConnector> = void + Function(UseCaseProviderBridge bridge); + +class UseCaseProviderBridge> { + UseCaseProviderBridge._(this.useCase, Ref ref) : _ref = ref; + + final BU useCase; + final Ref _ref; + + void connect, T>( + UseCaseProvider provider, + void Function(T? previous, T next) connector, { + required T Function(E entity) selector, + bool fireImmediately = false, + }) { + _ref.listen( + provider().select(selector), + connector, + fireImmediately: fireImmediately, + ); } } diff --git a/packages/clean_framework/lib/src/presentation/presenter/presenter.dart b/packages/clean_framework/lib/src/presentation/presenter/presenter.dart index a4e89e6c..6a832ee2 100644 --- a/packages/clean_framework/lib/src/presentation/presenter/presenter.dart +++ b/packages/clean_framework/lib/src/presentation/presenter/presenter.dart @@ -55,8 +55,10 @@ class _PresenterState void initState() { super.initState(); widget._provider - ..init() - ..notifier.then((_) => widget.onLayoutReady(context, _useCase!)); + ..notifier.first.then((_) { + widget.onLayoutReady(context, _useCase!); + }) + ..init(); } @override diff --git a/packages/clean_framework/test/core/use_case/use_case_test.dart b/packages/clean_framework/test/core/use_case/use_case_test.dart index effd5c12..1d384fa5 100644 --- a/packages/clean_framework/test/core/use_case/use_case_test.dart +++ b/packages/clean_framework/test/core/use_case/use_case_test.dart @@ -99,29 +99,6 @@ void main() { }, ); - test('throws error on duplicate subscription for same output', () { - useCase.subscribe( - (output) async { - final out = output as TestGatewayOutput; - return Either.right( - TestSuccessInput(message: 'Hello ${out.name}!'), - ); - }, - ); - - expect( - () => useCase.subscribe( - (output) async { - final out = output as TestGatewayOutput; - return Either.right( - TestSuccessInput(message: 'Hello ${out.name}!'), - ); - }, - ), - throwsStateError, - ); - }); - group('debounce', () { test( 'performs action immediately first ' diff --git a/packages/clean_framework/test/providers/gateway_unit_test.dart b/packages/clean_framework/test/providers/gateway_unit_test.dart index 28a490b8..020dfbed 100644 --- a/packages/clean_framework/test/providers/gateway_unit_test.dart +++ b/packages/clean_framework/test/providers/gateway_unit_test.dart @@ -14,7 +14,7 @@ void main() { await useCase.doFakeRequest(const TestDirectOutput('123')); - expect(useCase.entity, EntityFake(value: 'success')); + expect(useCase.entity, const EntityFake(value: 'success')); }); test('Gateway unit test for failure on direct output', () async { @@ -26,7 +26,7 @@ void main() { await useCase.doFakeRequest(const TestDirectOutput('123')); - expect(useCase.entity, EntityFake(value: 'failure')); + expect(useCase.entity, const EntityFake(value: 'failure')); }); test('Gateway unit test for success on yield output', () async { @@ -39,11 +39,11 @@ void main() { await useCase.doFakeRequest(const TestSubscriptionOutput('123')); - expect(useCase.entity, EntityFake(value: 'success')); + expect(useCase.entity, const EntityFake(value: 'success')); gateway.yieldResponse(const TestResponse('with yield')); - expect(useCase.entity, EntityFake(value: 'success with input')); + expect(useCase.entity, const EntityFake(value: 'success with input')); }); test('Gateway unit test for failure on yield output', () async { @@ -55,7 +55,7 @@ void main() { await useCase.doFakeRequest(const TestSubscriptionOutput('123')); - expect(useCase.entity, EntityFake(value: 'failure')); + expect(useCase.entity, const EntityFake(value: 'failure')); }); test('props', () { diff --git a/packages/clean_framework/test/providers/presenter_widget_test.dart b/packages/clean_framework/test/providers/presenter_widget_test.dart index 606dd40d..46b81c35 100644 --- a/packages/clean_framework/test/providers/presenter_widget_test.dart +++ b/packages/clean_framework/test/providers/presenter_widget_test.dart @@ -104,18 +104,18 @@ class TestPresenter extends Presenter { class TestUseCase extends UseCase { TestUseCase() : super( - entity: EntityFake(), + entity: const EntityFake(), transformers: [ OutputTransformer.from((entity) => TestOutput(entity.value)), ], ); Future fetch() async { - entity = EntityFake(value: 'a'); + entity = const EntityFake(value: 'a'); await Future.delayed(const Duration(milliseconds: 100)); - entity = EntityFake(value: 'b'); + entity = const EntityFake(value: 'b'); } } diff --git a/packages/clean_framework_firestore/lib/src/firebase_gateway.dart b/packages/clean_framework_firestore/lib/src/firebase_gateway.dart index 4e4bc219..da48ce5f 100644 --- a/packages/clean_framework_firestore/lib/src/firebase_gateway.dart +++ b/packages/clean_framework_firestore/lib/src/firebase_gateway.dart @@ -11,5 +11,7 @@ abstract class FirebaseGateway FailureInput(); + FailureInput onFailure(FailureResponse failureResponse) { + return FailureInput(message: failureResponse.message); + } } diff --git a/packages/clean_framework_firestore/test/firebase_gateway_test.dart b/packages/clean_framework_firestore/test/firebase_gateway_test.dart index 20bbe1fa..9fad0b1b 100644 --- a/packages/clean_framework_firestore/test/firebase_gateway_test.dart +++ b/packages/clean_framework_firestore/test/firebase_gateway_test.dart @@ -14,9 +14,9 @@ void main() { ); }; - await useCase.doFakeRequest(TestOutput('123')); + await useCase.doFakeRequest(const TestOutput('123')); - expect(useCase.entity, EntityFake(value: 'success')); + expect(useCase.entity, const EntityFake(value: 'success')); }); test('FirebaseGateway transport failure', () async { @@ -28,9 +28,9 @@ void main() { ); }; - await useCase.doFakeRequest(TestOutput('123')); + await useCase.doFakeRequest(const TestOutput('123')); - expect(useCase.entity, EntityFake(value: 'failure')); + expect(useCase.entity, const EntityFake(value: 'failure')); }); } @@ -55,7 +55,8 @@ class TestGateway extends FirebaseGateway { @override SuccessInput onSuccess(RestSuccessResponse response) { - return SuccessInput(); + return const SuccessInput(); } } diff --git a/packages/clean_framework_test/lib/src/use_case_fake.dart b/packages/clean_framework_test/lib/src/use_case_fake.dart index 6c63bbfe..76b6ee51 100644 --- a/packages/clean_framework_test/lib/src/use_case_fake.dart +++ b/packages/clean_framework_test/lib/src/use_case_fake.dart @@ -11,7 +11,7 @@ class UseCaseFake extends Fake implements UseCase { UseCaseFake({this.output}); - EntityFake _entity = EntityFake(); + EntityFake _entity = const EntityFake(); late RequestSubscription subscription; S? successInput; final Output? output; @@ -54,7 +54,8 @@ class UseCaseFake extends Fake } class EntityFake extends Entity { - EntityFake({this.value = 'initial'}); + const EntityFake({this.value = 'initial'}); + final String value; @override