From fe34bb4c53819a3ce71d23d1fb3a69e4c97c5c79 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 08:10:22 +0200 Subject: [PATCH 01/17] Introduce `registryProvider` --- analysis_options.yaml | 2 + lib/main.dart | 27 ++-- lib/presentation.dart | 1 + lib/presentation/app.dart | 1 + lib/presentation/rebloc/store_factory.dart | 2 +- lib/presentation/registry.dart | 37 +---- .../screens/contacts/contacts_create.dart | 1 + .../contacts/widgets/contact_appbar.dart | 1 + .../contacts/widgets/contact_form.dart | 1 + .../homepage/widgets/create_button.dart | 1 + .../homepage/widgets/top_button_bar.dart | 1 + lib/presentation/screens/jobs/job.dart | 1 + .../screens/jobs/jobs_create_view_model.dart | 1 + .../screens/jobs/widgets/gallery_grids.dart | 1 + .../screens/jobs/widgets/payment_grids.dart | 1 + .../screens/measures/measures_create.dart | 1 + .../widgets/measures_slide_block.dart | 1 + .../widgets/measures_slide_block_item.dart | 1 + lib/presentation/state.dart | 1 + lib/presentation/state/registry_provider.dart | 9 ++ pubspec.lock | 133 +++++++++++++++++- pubspec.yaml | 13 +- test/presentation/app_test.dart | 1 + test/utils.dart | 1 + 24 files changed, 186 insertions(+), 54 deletions(-) create mode 100644 lib/presentation/state.dart create mode 100644 lib/presentation/state/registry_provider.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index a52d1ad..acae79e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -20,6 +20,8 @@ analyzer: unused_result: error unused_shown_name: error invalid_annotation_target: ignore + plugins: + - custom_lint language: strict-casts: true strict-raw-types: true diff --git a/lib/main.dart b/lib/main.dart index 6a9986d..94e4d7a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,8 @@ import 'dart:async' as async; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:registry/registry.dart'; import 'package:universal_io/io.dart' as io; import 'core.dart'; @@ -81,16 +83,21 @@ void main(List args) async { ..set(TasksCoordinator(navigatorKey)); runApp( - ErrorBoundary( - isReleaseMode: !environment.isDebugging, - errorViewBuilder: (_) => const AppCrashErrorView(), - onException: AppLog.e, - onCrash: errorReporter.reportCrash, - child: App( - registry: registry, - navigatorKey: navigatorKey, - store: storeFactory(registry), - navigatorObservers: [navigationObserver], + ProviderScope( + overrides: [ + registryProvider.overrideWithValue(registry), + ], + child: ErrorBoundary( + isReleaseMode: !environment.isDebugging, + errorViewBuilder: (_) => const AppCrashErrorView(), + onException: AppLog.e, + onCrash: errorReporter.reportCrash, + child: App( + registry: registry, + navigatorKey: navigatorKey, + store: storeFactory(registry), + navigatorObservers: [navigationObserver], + ), ), ), ); diff --git a/lib/presentation.dart b/lib/presentation.dart index c4fcfc6..6e6f3f0 100644 --- a/lib/presentation.dart +++ b/lib/presentation.dart @@ -4,6 +4,7 @@ export 'presentation/coordinator.dart'; export 'presentation/mixins.dart'; export 'presentation/rebloc.dart'; export 'presentation/registry.dart'; +export 'presentation/state.dart'; export 'presentation/theme.dart'; export 'presentation/utils.dart'; export 'presentation/widgets.dart'; diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index 40e6af3..a408143 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:rebloc/rebloc.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'constants.dart'; diff --git a/lib/presentation/rebloc/store_factory.dart b/lib/presentation/rebloc/store_factory.dart index edddedd..aa2c3fa 100644 --- a/lib/presentation/rebloc/store_factory.dart +++ b/lib/presentation/rebloc/store_factory.dart @@ -1,6 +1,6 @@ import 'package:rebloc/rebloc.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; -import 'package:tailor_made/presentation/registry.dart'; import 'accounts/bloc.dart'; import 'app_state.dart'; diff --git a/lib/presentation/registry.dart b/lib/presentation/registry.dart index 2d3deb9..d4f1b74 100644 --- a/lib/presentation/registry.dart +++ b/lib/presentation/registry.dart @@ -1,40 +1,5 @@ import 'package:flutter/widgets.dart'; - -typedef RegistryFactory = U Function(); - -class Registry { - final Expando _instances = Expando('Registry'); - - void set(T instance) => _instances.set(instance); - - T get() { - final Object instance = _instances.get(); - if (instance is T Function()) { - return instance.call(); - } - return instance as T; - } - - void factory(T Function(RegistryFactory) fn) => _instances.set(() => fn(() => get())); - - @visibleForTesting - void replace(T instance) => _instances.set(instance, true); -} - -extension on Expando { - void set(Object? instance, [bool replace = false]) { - assert(!(this[T] != null && !replace), 'Instance of type $T is already added to the Registry'); - this[T] = instance; - } - - Object get() { - final Object? instance = this[T]; - if (instance == null) { - throw ArgumentError('Instance of type $T was not added to the Registry'); - } - return instance; - } -} +import 'package:registry/registry.dart'; class RegistryProvider extends InheritedWidget { const RegistryProvider({ diff --git a/lib/presentation/screens/contacts/contacts_create.dart b/lib/presentation/screens/contacts/contacts_create.dart index 64048d7..ff8ab1a 100644 --- a/lib/presentation/screens/contacts/contacts_create.dart +++ b/lib/presentation/screens/contacts/contacts_create.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_native_contact_picker/flutter_native_contact_picker.dart'; import 'package:rebloc/rebloc.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/contacts/widgets/contact_appbar.dart b/lib/presentation/screens/contacts/widgets/contact_appbar.dart index 672e9b7..92bf6b0 100644 --- a/lib/presentation/screens/contacts/widgets/contact_appbar.dart +++ b/lib/presentation/screens/contacts/widgets/contact_appbar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/contacts/widgets/contact_form.dart b/lib/presentation/screens/contacts/widgets/contact_form.dart index d179bea..02721c1 100644 --- a/lib/presentation/screens/contacts/widgets/contact_form.dart +++ b/lib/presentation/screens/contacts/widgets/contact_form.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/homepage/widgets/create_button.dart b/lib/presentation/screens/homepage/widgets/create_button.dart index ba5b401..b05f950 100644 --- a/lib/presentation/screens/homepage/widgets/create_button.dart +++ b/lib/presentation/screens/homepage/widgets/create_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/homepage/widgets/top_button_bar.dart b/lib/presentation/screens/homepage/widgets/top_button_bar.dart index a905a67..6b04e77 100644 --- a/lib/presentation/screens/homepage/widgets/top_button_bar.dart +++ b/lib/presentation/screens/homepage/widgets/top_button_bar.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:rebloc/rebloc.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/jobs/job.dart b/lib/presentation/screens/jobs/job.dart index 4b29141..c6bdf69 100644 --- a/lib/presentation/screens/jobs/job.dart +++ b/lib/presentation/screens/jobs/job.dart @@ -1,6 +1,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:rebloc/rebloc.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/jobs/jobs_create_view_model.dart b/lib/presentation/screens/jobs/jobs_create_view_model.dart index 204ca4a..516c2a1 100644 --- a/lib/presentation/screens/jobs/jobs_create_view_model.dart +++ b/lib/presentation/screens/jobs/jobs_create_view_model.dart @@ -1,6 +1,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/jobs/widgets/gallery_grids.dart b/lib/presentation/screens/jobs/widgets/gallery_grids.dart index fc60aed..d070068 100644 --- a/lib/presentation/screens/jobs/widgets/gallery_grids.dart +++ b/lib/presentation/screens/jobs/widgets/gallery_grids.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/jobs/widgets/payment_grids.dart b/lib/presentation/screens/jobs/widgets/payment_grids.dart index bd7f139..38ee3d3 100644 --- a/lib/presentation/screens/jobs/widgets/payment_grids.dart +++ b/lib/presentation/screens/jobs/widgets/payment_grids.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/measures/measures_create.dart b/lib/presentation/screens/measures/measures_create.dart index 41b751d..08b465b 100644 --- a/lib/presentation/screens/measures/measures_create.dart +++ b/lib/presentation/screens/measures/measures_create.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:rebloc/rebloc.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/measures/widgets/measures_slide_block.dart b/lib/presentation/screens/measures/widgets/measures_slide_block.dart index 3c6a715..73fd517 100644 --- a/lib/presentation/screens/measures/widgets/measures_slide_block.dart +++ b/lib/presentation/screens/measures/widgets/measures_slide_block.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/screens/measures/widgets/measures_slide_block_item.dart b/lib/presentation/screens/measures/widgets/measures_slide_block_item.dart index 480365a..ed45442 100644 --- a/lib/presentation/screens/measures/widgets/measures_slide_block_item.dart +++ b/lib/presentation/screens/measures/widgets/measures_slide_block_item.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart new file mode 100644 index 0000000..461d19a --- /dev/null +++ b/lib/presentation/state.dart @@ -0,0 +1 @@ +export 'state/registry_provider.dart'; diff --git a/lib/presentation/state/registry_provider.dart b/lib/presentation/state/registry_provider.dart new file mode 100644 index 0000000..dab4c2b --- /dev/null +++ b/lib/presentation/state/registry_provider.dart @@ -0,0 +1,9 @@ +import 'package:registry/registry.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'registry_provider.g.dart'; + +/// Container for Registry/Service locator +/// Should be overridden per [ProviderScope] +@Riverpod(dependencies: []) +Registry registry(RegistryRef ref) => throw UnimplementedError(); diff --git a/pubspec.lock b/pubspec.lock index 40a6f26..8095040 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "405666cd3cf0ee0a48d21ec67e65406aad2c726d9fa58840d3375e7bdcd32a07" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "60.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,18 @@ packages: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "1952250bd005bacb895a01bf1b4dc00e3ba1c526cf47dca54dfe24979c65f5b3" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "5.12.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" args: dependency: transitive description: @@ -153,6 +161,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" clock: dependency: "direct main" description: @@ -233,6 +257,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + custom_lint: + dependency: transitive + description: + name: custom_lint + sha256: "3ce36c04d30c60cde295588c6185b3f9800e6c18f6670a7ffdb3d5eab39bb942" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "73d09c9848e9f6d5c3e0a1809eac841a8d7ea123d0849feefa040e1ad60b6d06" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "9170d9db2daf774aa2251a3bc98e4ba903c7702ab07aa438bc83bd3c9a0de57f" + url: "https://pub.dev" + source: hosted + version: "0.4.0" dart_style: dependency: transitive description: @@ -492,6 +540,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.15" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: b83ac5827baadefd331ea1d85110f34645827ea234ccabf53a655f41901a9bf4 + url: "https://pub.dev" + source: hosted + version: "2.3.6" flutter_spinkit: dependency: "direct main" description: @@ -599,6 +655,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "728c0613556c1d153f7e7f4a367cffacc3f5a677d7f6497a1c2b35add4e6dacf" + url: "https://pub.dev" + source: hosted + version: "3.0.6" http: dependency: transitive description: @@ -763,10 +827,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.8.0" mfsao: dependency: "direct dev" description: @@ -959,6 +1023,55 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.0" + registry: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "5dd4e7fdfb9b0d6848602a86fd8bef3b072b8d2f" + url: "https://github.com/jogboms/registry.dart.git" + source: git + version: "0.1.0" + riverpod: + dependency: "direct main" + description: + name: riverpod + sha256: "80e48bebc83010d5e67a11c9514af6b44bbac1ec77b4333c8ea65dbc79e2d8ef" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "1b2632a6fc0b659c923a4dcc7cd5da42476f5b3294c70c86c971e63bdd443384" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: cedd6a54b6f5764ffd5c05df57b6676bfc8c01978e14ee60a2c16891038820fe + url: "https://pub.dev" + source: hosted + version: "2.1.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: cd0595de57ccf5d944ff4b0f68289e11ac6a2eff1e3dfd1d884a43f6f3bcee5e + url: "https://pub.dev" + source: hosted + version: "2.2.3" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "043ff016011be4c5887b3239bfbca05d284bdb68db0a5363cee0242b7567e250" + url: "https://pub.dev" + source: hosted + version: "1.3.2" rxdart: dependency: "direct main" description: @@ -1124,6 +1237,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + url: "https://pub.dev" + source: hosted + version: "0.7.2+1" stream_channel: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 05818cf..b0dbeb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: flutter_native_image: git: url: https://github.com/btastic/flutter_native_image.git + flutter_riverpod: ^2.3.6 flutter_spinkit: ^5.2.0 freezed_annotation: ^2.2.0 get_version: @@ -41,7 +42,11 @@ dependencies: photo_view: ^0.14.0 platform: ^3.1.0 rebloc: ^0.4.0 - rxdart: ^0.26.0 + registry: + git: https://github.com/jogboms/registry.dart.git + riverpod: ^2.3.6 + riverpod_annotation: ^2.1.1 + rxdart: ^0.27.7 shared_preferences: ^2.1.2 timeago: ^3.4.0 universal_io: ^2.2.2 @@ -49,6 +54,10 @@ dependencies: uuid: ^3.0.7 version: ^3.0.2 +dependency_overrides: + meta: 1.8.0 # required by registry + rxdart: ^0.26.0 # required by rebloc + dev_dependencies: build_runner: flutter_test: @@ -57,6 +66,8 @@ dev_dependencies: json_serializable: ^6.7.0 mfsao: ^3.0.0 mocktail: ^0.3.0 + riverpod_generator: ^2.2.3 + riverpod_lint: ^1.3.2 flutter: uses-material-design: true diff --git a/test/presentation/app_test.dart b/test/presentation/app_test.dart index 70f8f13..6eeb940 100644 --- a/test/presentation/app_test.dart +++ b/test/presentation/app_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:registry/registry.dart'; import 'package:tailor_made/data.dart'; import 'package:tailor_made/presentation.dart'; import 'package:tailor_made/presentation/screens/homepage/homepage.dart'; diff --git a/test/utils.dart b/test/utils.dart index 26a3dd0..f29c147 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart' as mt; +import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; From 09a2468a186cfa5becbf8fe53d8c253873e43fe6 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 08:40:45 +0200 Subject: [PATCH 02/17] Introduce `accountProvider`, `paymentsProvider` & `galleryProvider` --- .../repositories/accounts/accounts_impl.dart | 15 +++++ .../accounts/accounts_mock_impl.dart | 3 + lib/domain.dart | 1 + lib/domain/entities.dart | 1 + lib/domain/entities/auth_exception.dart | 65 +++++++++++++++++++ lib/domain/repositories/accounts.dart | 2 + .../use_cases/fetch_account_use_case.dart | 10 +++ lib/main.dart | 1 + lib/presentation/screens/gallery/gallery.dart | 22 ++++--- .../gallery/providers/gallery_provider.dart | 13 ++++ .../screens/payments/payments.dart | 22 ++++--- .../payments/providers/payments_provider.dart | 13 ++++ lib/presentation/state.dart | 1 + lib/presentation/state/account_provider.dart | 9 +++ lib/presentation/widgets.dart | 1 + lib/presentation/widgets/error_view.dart | 16 +++++ 16 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 lib/domain/entities/auth_exception.dart create mode 100644 lib/domain/use_cases/fetch_account_use_case.dart create mode 100644 lib/presentation/screens/gallery/providers/gallery_provider.dart create mode 100644 lib/presentation/screens/payments/providers/payments_provider.dart create mode 100644 lib/presentation/state/account_provider.dart create mode 100644 lib/presentation/widgets/error_view.dart diff --git a/lib/data/repositories/accounts/accounts_impl.dart b/lib/data/repositories/accounts/accounts_impl.dart index e607e7d..767df5b 100644 --- a/lib/data/repositories/accounts/accounts_impl.dart +++ b/lib/data/repositories/accounts/accounts_impl.dart @@ -44,6 +44,21 @@ class AccountsImpl extends Accounts { return account.uid; } + @override + Future fetch() async { + final String? id = firebase.auth.getUser; + if (id == null) { + throw const AuthException.userNotFound(); + } + + final AccountEntity? account = await getAccount(id); + if (account == null) { + throw const AuthException.userNotFound(); + } + + return account; + } + @override Future getAccount(String userId) async { final MapDocumentSnapshot doc = await collection.fetchOne(userId).get(); diff --git a/lib/data/repositories/accounts/accounts_mock_impl.dart b/lib/data/repositories/accounts/accounts_mock_impl.dart index 2f248aa..20c92e7 100644 --- a/lib/data/repositories/accounts/accounts_mock_impl.dart +++ b/lib/data/repositories/accounts/accounts_mock_impl.dart @@ -21,6 +21,9 @@ class AccountsMockImpl extends Accounts { return; } + @override + Future fetch() async => (await getAccount('1'))!; + @override Future getAccount(String userId) async { return const AccountEntity( diff --git a/lib/domain.dart b/lib/domain.dart index 7d9ae63..f6b4995 100644 --- a/lib/domain.dart +++ b/lib/domain.dart @@ -8,3 +8,4 @@ export 'domain/repositories/measures.dart'; export 'domain/repositories/payments.dart'; export 'domain/repositories/settings.dart'; export 'domain/repositories/stats.dart'; +export 'domain/use_cases/fetch_account_use_case.dart'; diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 8891bc4..bca595e 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -1,4 +1,5 @@ export 'entities/account_entity.dart'; +export 'entities/auth_exception.dart'; export 'entities/contact_entity.dart'; export 'entities/create_contact_data.dart'; export 'entities/create_image_data.dart'; diff --git a/lib/domain/entities/auth_exception.dart b/lib/domain/entities/auth_exception.dart new file mode 100644 index 0000000..36e6c3f --- /dev/null +++ b/lib/domain/entities/auth_exception.dart @@ -0,0 +1,65 @@ +abstract class AuthException { + const factory AuthException.unknown(Exception exception) = AuthExceptionUnknown; + + const factory AuthException.canceled() = AuthExceptionCanceled; + + const factory AuthException.failed() = AuthExceptionFailed; + + const factory AuthException.networkUnavailable() = AuthExceptionNetworkUnavailable; + + const factory AuthException.popupBlockedByBrowser() = AuthExceptionPopupBlockedByBrowser; + + const factory AuthException.invalidEmail({String? email}) = AuthExceptionInvalidEmail; + + const factory AuthException.userDisabled({String? email}) = AuthExceptionUserDisabled; + + const factory AuthException.userNotFound({String? email}) = AuthExceptionUserNotFound; + + const factory AuthException.tooManyRequests({String? email}) = AuthExceptionTooManyRequests; +} + +class AuthExceptionUnknown implements AuthException { + const AuthExceptionUnknown(this.exception); + + final Exception exception; +} + +class AuthExceptionCanceled implements AuthException { + const AuthExceptionCanceled(); +} + +class AuthExceptionFailed implements AuthException { + const AuthExceptionFailed(); +} + +class AuthExceptionNetworkUnavailable implements AuthException { + const AuthExceptionNetworkUnavailable(); +} + +class AuthExceptionPopupBlockedByBrowser implements AuthException { + const AuthExceptionPopupBlockedByBrowser(); +} + +class AuthExceptionInvalidEmail implements AuthException { + const AuthExceptionInvalidEmail({this.email}); + + final String? email; +} + +class AuthExceptionUserDisabled implements AuthException { + const AuthExceptionUserDisabled({this.email}); + + final String? email; +} + +class AuthExceptionUserNotFound implements AuthException { + const AuthExceptionUserNotFound({this.email}); + + final String? email; +} + +class AuthExceptionTooManyRequests implements AuthException { + const AuthExceptionTooManyRequests({this.email}); + + final String? email; +} diff --git a/lib/domain/repositories/accounts.dart b/lib/domain/repositories/accounts.dart index 7c59939..2b677ae 100644 --- a/lib/domain/repositories/accounts.dart +++ b/lib/domain/repositories/accounts.dart @@ -9,6 +9,8 @@ abstract class Accounts { Future signUp(AccountEntity account); + Future fetch(); + Future getAccount(String userId); Future updateAccount( diff --git a/lib/domain/use_cases/fetch_account_use_case.dart b/lib/domain/use_cases/fetch_account_use_case.dart new file mode 100644 index 0000000..592a661 --- /dev/null +++ b/lib/domain/use_cases/fetch_account_use_case.dart @@ -0,0 +1,10 @@ +import '../entities/account_entity.dart'; +import '../repositories/accounts.dart'; + +class FetchAccountUseCase { + const FetchAccountUseCase({required Accounts accounts}) : _accounts = accounts; + + final Accounts _accounts; + + Future call() => _accounts.fetch(); +} diff --git a/lib/main.dart b/lib/main.dart index 94e4d7a..3fb6467 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,6 +74,7 @@ void main(List args) async { ..set(repository.payments) ..set(repository.measures) ..set(repository.stats) + ..factory((RegistryFactory di) => FetchAccountUseCase(accounts: di())) ..set(ContactsCoordinator(navigatorKey)) ..set(GalleryCoordinator(navigatorKey)) ..set(SharedCoordinator(navigatorKey)) diff --git a/lib/presentation/screens/gallery/gallery.dart b/lib/presentation/screens/gallery/gallery.dart index 5963517..1bf88b1 100644 --- a/lib/presentation/screens/gallery/gallery.dart +++ b/lib/presentation/screens/gallery/gallery.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; +import 'package:tailor_made/presentation/screens/gallery/providers/gallery_provider.dart'; import 'widgets/gallery_grid.dart'; @@ -41,16 +43,16 @@ class GalleryPage extends StatelessWidget { return _Content(images: images); } - return StreamBuilder>( - // TODO(Jogboms): move this out of here - stream: context.registry.get().fetchAll(userId), - builder: (_, AsyncSnapshot> snapshot) { - final List? data = snapshot.data; - if (data == null) { - return const SliverFillRemaining(child: LoadingSpinner()); - } - return _Content(images: data); - }, + return Consumer( + builder: (_, WidgetRef ref, Widget? child) => ref.watch(galleryProvider).when( + skipLoadingOnReload: true, + data: (List data) => _Content(images: data), + error: (Object error, StackTrace stackTrace) => SliverFillRemaining( + child: ErrorView(error, stackTrace), + ), + loading: () => child!, + ), + child: const SliverFillRemaining(child: LoadingSpinner()), ); }, ), diff --git a/lib/presentation/screens/gallery/providers/gallery_provider.dart b/lib/presentation/screens/gallery/providers/gallery_provider.dart new file mode 100644 index 0000000..debdb1f --- /dev/null +++ b/lib/presentation/screens/gallery/providers/gallery_provider.dart @@ -0,0 +1,13 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; + +part 'gallery_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream> gallery(GalleryRef ref) async* { + final AccountEntity account = await ref.watch(accountProvider.future); + + yield* ref.read(registryProvider).get().fetchAll(account.uid); +} diff --git a/lib/presentation/screens/payments/payments.dart b/lib/presentation/screens/payments/payments.dart index d55559f..8dc7444 100644 --- a/lib/presentation/screens/payments/payments.dart +++ b/lib/presentation/screens/payments/payments.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; +import 'providers/payments_provider.dart'; import 'widgets/payments_list.dart'; class PaymentsPage extends StatelessWidget { @@ -39,16 +41,16 @@ class PaymentsPage extends StatelessWidget { return _Content(payments: payments); } - return StreamBuilder>( - // TODO(Jogboms): move this out of here - stream: context.registry.get().fetchAll(userId), - builder: (_, AsyncSnapshot> snapshot) { - final List? data = snapshot.data; - if (data == null) { - return const SliverFillRemaining(child: LoadingSpinner()); - } - return _Content(payments: data); - }, + return Consumer( + builder: (_, WidgetRef ref, Widget? child) => ref.watch(paymentsProvider).when( + skipLoadingOnReload: true, + data: (List data) => _Content(payments: data), + error: (Object error, StackTrace stackTrace) => SliverFillRemaining( + child: ErrorView(error, stackTrace), + ), + loading: () => child!, + ), + child: const SliverFillRemaining(child: LoadingSpinner()), ); }, ) diff --git a/lib/presentation/screens/payments/providers/payments_provider.dart b/lib/presentation/screens/payments/providers/payments_provider.dart new file mode 100644 index 0000000..85d0251 --- /dev/null +++ b/lib/presentation/screens/payments/providers/payments_provider.dart @@ -0,0 +1,13 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; + +part 'payments_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream> payments(PaymentsRef ref) async* { + final AccountEntity account = await ref.watch(accountProvider.future); + + yield* ref.read(registryProvider).get().fetchAll(account.uid); +} diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index 461d19a..0da5f2d 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -1 +1,2 @@ +export 'state/account_provider.dart'; export 'state/registry_provider.dart'; diff --git a/lib/presentation/state/account_provider.dart b/lib/presentation/state/account_provider.dart new file mode 100644 index 0000000..a8dbaf8 --- /dev/null +++ b/lib/presentation/state/account_provider.dart @@ -0,0 +1,9 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'registry_provider.dart'; + +part 'account_provider.g.dart'; + +@Riverpod(dependencies: [registry]) +Future account(AccountRef ref) async => ref.read(registryProvider).get().call(); diff --git a/lib/presentation/widgets.dart b/lib/presentation/widgets.dart index 7ae1078..f1b2f2d 100644 --- a/lib/presentation/widgets.dart +++ b/lib/presentation/widgets.dart @@ -7,6 +7,7 @@ export 'widgets/app_icon.dart'; export 'widgets/custom_app_bar.dart'; export 'widgets/dots.dart'; export 'widgets/empty_result_view.dart'; +export 'widgets/error_view.dart'; export 'widgets/form_section_header.dart'; export 'widgets/loading_spinner.dart'; export 'widgets/primary_button.dart'; diff --git a/lib/presentation/widgets/error_view.dart b/lib/presentation/widgets/error_view.dart new file mode 100644 index 0000000..2a343b2 --- /dev/null +++ b/lib/presentation/widgets/error_view.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +class ErrorView extends StatelessWidget { + const ErrorView(this.error, this.stackTrace, {super.key}); + + static const Key errorViewKey = Key('errorViewKey'); + + final Object error; + final StackTrace? stackTrace; + + @override + Widget build(BuildContext context) => Center( + key: errorViewKey, + child: Text(error.toString()), + ); +} From a9bd4b30d2d12421bcf61bbb4f4e3b2f5447db19 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 10:19:02 +0200 Subject: [PATCH 03/17] Introduce `filteredContactsProvider` --- lib/presentation/rebloc.dart | 2 +- .../screens/contacts/contacts.dart | 77 +++++++++++-------- .../filtered_contacts_state_provider.dart | 74 ++++++++++++++++++ .../widgets/contacts_filter_button.dart | 20 ++--- lib/presentation/state.dart | 2 + lib/presentation/state/contacts_provider.dart | 14 ++++ .../state/state_notifier_mixin.dart | 10 +++ lib/presentation/theme/app_theme.dart | 1 + lib/presentation/utils.dart | 1 + .../{rebloc/contacts => utils}/sort_type.dart | 0 10 files changed, 159 insertions(+), 42 deletions(-) create mode 100644 lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart create mode 100644 lib/presentation/state/contacts_provider.dart create mode 100644 lib/presentation/state/state_notifier_mixin.dart rename lib/presentation/{rebloc/contacts => utils}/sort_type.dart (100%) diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index 034cf48..b14d9bb 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -9,7 +9,6 @@ export 'rebloc/common/home_view_model.dart'; export 'rebloc/common/middleware.dart'; export 'rebloc/common/state_status.dart'; export 'rebloc/contacts/bloc.dart'; -export 'rebloc/contacts/sort_type.dart'; export 'rebloc/contacts/view_model.dart'; export 'rebloc/extensions.dart'; export 'rebloc/jobs/bloc.dart'; @@ -22,3 +21,4 @@ export 'rebloc/settings/view_model.dart'; export 'rebloc/stats/bloc.dart'; export 'rebloc/stats/view_model.dart'; export 'rebloc/store_factory.dart'; +export 'utils/sort_type.dart'; diff --git a/lib/presentation/screens/contacts/contacts.dart b/lib/presentation/screens/contacts/contacts.dart index 1dc9626..0c79ab5 100644 --- a/lib/presentation/screens/contacts/contacts.dart +++ b/lib/presentation/screens/contacts/contacts.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/presentation.dart'; +import 'providers/filtered_contacts_state_provider.dart'; import 'widgets/contacts_filter_button.dart'; import 'widgets/contacts_list_item.dart'; @@ -12,71 +13,86 @@ class ContactsPage extends StatefulWidget { State createState() => _ContactsPageState(); } -class _ContactsPageState extends State with StoreDispatchMixin { +class _ContactsPageState extends State { @override Widget build(BuildContext context) { - return ViewModelSubscriber( - converter: ContactsViewModel.new, - builder: (BuildContext context, DispatchFunction dispatch, ContactsViewModel vm) { + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) { + final AsyncValue filteredContacts = ref.watch(filteredContactsProvider); + return WillPopScope( child: Scaffold( - appBar: _AppBar(vm: vm), - body: Builder( - builder: (BuildContext context) { - if (vm.isLoading && !vm.isSearching) { - return const LoadingSpinner(); - } - - if (vm.contacts.isEmpty) { + appBar: _AppBar(loading: filteredContacts.isLoading), + body: filteredContacts.when( + skipLoadingOnReload: true, + data: (FilteredContactsState state) { + if (state.contacts.isEmpty) { return const Center( child: EmptyResultView(message: 'No contacts available'), ); } return ListView.separated( - itemCount: vm.contacts.length, - shrinkWrap: true, + itemCount: state.contacts.length, padding: const EdgeInsets.only(bottom: 96.0), - itemBuilder: (_, int index) => ContactsListItem(contact: vm.contacts[index]), + itemBuilder: (_, int index) => ContactsListItem(contact: state.contacts[index]), separatorBuilder: (_, __) => const Divider(height: 0), ); }, + error: ErrorView.new, + loading: () => child!, ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.person_add), - onPressed: () => context.registry.get().toCreateContact(vm.userId), + onPressed: () => context.registry.get().toCreateContact( + ref.read(filteredContactsProvider).requireValue.userId, //todo: remove + ), ), ), onWillPop: () async { - if (vm.isSearching) { - dispatchAction(const ContactsAction.searchCancel()); + final SearchContactQueryState queryState = ref.read(searchContactQueryStateProvider.notifier); + if (queryState.isSearching) { + queryState.setState(''); return false; } return true; }, ); }, + child: const LoadingSpinner(), ); } } -class _AppBar extends StatefulWidget implements PreferredSizeWidget { - const _AppBar({required this.vm}); +class _AppBar extends ConsumerStatefulWidget implements PreferredSizeWidget { + const _AppBar({required this.loading}); - final ContactsViewModel vm; + final bool loading; @override - State<_AppBar> createState() => _AppBarState(); + ConsumerState<_AppBar> createState() => _AppBarState(); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -class _AppBarState extends State<_AppBar> with StoreDispatchMixin { +class _AppBarState extends ConsumerState<_AppBar> { + late final SearchContactQueryState _queryProvider = ref.read(searchContactQueryStateProvider.notifier); + late final SearchContactSortState _sortProvider = ref.read(searchContactSortStateProvider.notifier); + late final TextEditingController _controller = TextEditingController(text: _queryProvider.currentState); bool _isSearching = false; @override Widget build(BuildContext context) { + ref.listen(searchContactQueryStateProvider, (_, String next) { + if (_controller.text != next) { + _controller.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length, affinity: TextAffinity.upstream), + ); + } + }); + if (!_isSearching) { return CustomAppBar( title: const Text('Contacts'), @@ -86,27 +102,26 @@ class _AppBarState extends State<_AppBar> with StoreDispatchMixin { onPressed: _onTapSearch, ), ContactsFilterButton( - vm: widget.vm, - onTapSort: (ContactsSortType type) => dispatchAction(ContactsAction.sort(type)), + sortType: ref.watch(searchContactSortStateProvider), + onTapSort: _sortProvider.setState, ), ], ); } return AppBar( - centerTitle: false, - elevation: 1.0, leading: AppCloseButton(onPop: _handleSearchEnd), title: TextField( + controller: _controller, autofocus: true, decoration: const InputDecoration(hintText: 'Search...'), - onChanged: (String term) => dispatchAction(ContactsAction.search(term)), + onChanged: _queryProvider.setState, ), bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), child: SizedBox( height: 1.0, - child: widget.vm.isLoading ? const LinearProgressIndicator() : null, + child: widget.loading ? const LinearProgressIndicator() : null, ), ), ); @@ -115,7 +130,7 @@ class _AppBarState extends State<_AppBar> with StoreDispatchMixin { void _onTapSearch() => setState(() => _isSearching = true); void _handleSearchEnd() { - dispatchAction(const ContactsAction.searchCancel()); + _queryProvider.setState(''); setState(() => _isSearching = false); } } diff --git a/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart b/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart new file mode 100644 index 0000000..64aa3d6 --- /dev/null +++ b/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; +import '../../../utils.dart'; + +part 'filtered_contacts_state_provider.g.dart'; + +@Riverpod(dependencies: [account, contacts, SearchContactQueryState, SearchContactSortState]) +Future filteredContacts(FilteredContactsRef ref) async { + final AccountEntity account = await ref.watch(accountProvider.future); + final List items = await ref.watch(contactsProvider.future); + final String query = ref.watch(searchContactQueryStateProvider).trim().toLowerCase(); + final ContactsSortType sortType = ref.watch(searchContactSortStateProvider); + + return FilteredContactsState( + userId: account.uid, + contacts: items + .where((ContactEntity element) { + if (query.length > 1) { + return element.fullname.toLowerCase().contains(query); + } + + return true; + }) + .sorted(_sort(sortType)) + .toList(growable: false), + ); +} + +class FilteredContactsState with EquatableMixin { + const FilteredContactsState({required this.userId, required this.contacts}); + + final String userId; + final List contacts; + + @override + List get props => [userId, contacts]; +} + +@riverpod +class SearchContactQueryState extends _$SearchContactQueryState with StateNotifierMixin { + @override + String build() => ''; + + bool get isSearching => state.length > 1; +} + +@riverpod +class SearchContactSortState extends _$SearchContactSortState with StateNotifierMixin { + @override + ContactsSortType build() => ContactsSortType.names; +} + +Comparator _sort(ContactsSortType sortType) { + switch (sortType) { + case ContactsSortType.jobs: + return (ContactEntity a, ContactEntity b) => b.totalJobs.compareTo(a.totalJobs); + case ContactsSortType.names: + return (ContactEntity a, ContactEntity b) => a.fullname.compareTo(b.fullname); + case ContactsSortType.completed: + return (ContactEntity a, ContactEntity b) => (b.totalJobs - b.pendingJobs).compareTo(a.totalJobs - a.pendingJobs); + case ContactsSortType.pending: + return (ContactEntity a, ContactEntity b) => b.pendingJobs.compareTo(a.pendingJobs); + case ContactsSortType.recent: + return (ContactEntity a, ContactEntity b) => b.createdAt.compareTo(a.createdAt); + case ContactsSortType.reset: + return (ContactEntity a, ContactEntity b) => a.id.compareTo(b.id); + } +} diff --git a/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart b/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart index fe25436..a1579ec 100644 --- a/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart +++ b/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart @@ -3,9 +3,9 @@ import 'package:tailor_made/presentation/rebloc.dart'; import 'package:tailor_made/presentation/widgets.dart'; class ContactsFilterButton extends StatelessWidget { - const ContactsFilterButton({super.key, required this.vm, required this.onTapSort}); + const ContactsFilterButton({super.key, required this.sortType, required this.onTapSort}); - final ContactsViewModel vm; + final ContactsSortType sortType; final ValueSetter onTapSort; @override @@ -24,37 +24,37 @@ class ContactsFilterButton extends StatelessWidget { itemBuilder: (BuildContext context) { return <_Option>[ _Option( - enabled: vm.sortFn != ContactsSortType.jobs, + enabled: sortType != ContactsSortType.jobs, style: optionTheme.copyWith(color: _colorTestFn(ContactsSortType.jobs, colorScheme)), text: 'Sort by Jobs', type: ContactsSortType.jobs, ), _Option( - enabled: vm.sortFn != ContactsSortType.names, + enabled: sortType != ContactsSortType.names, style: optionTheme.copyWith(color: _colorTestFn(ContactsSortType.names, colorScheme)), text: 'Sort by Name', type: ContactsSortType.names, ), _Option( - enabled: vm.sortFn != ContactsSortType.completed, + enabled: sortType != ContactsSortType.completed, style: optionTheme.copyWith(color: _colorTestFn(ContactsSortType.completed, colorScheme)), text: 'Sort by Completed', type: ContactsSortType.completed, ), _Option( - enabled: vm.sortFn != ContactsSortType.pending, + enabled: sortType != ContactsSortType.pending, style: optionTheme.copyWith(color: _colorTestFn(ContactsSortType.pending, colorScheme)), text: 'Sort by Pending', type: ContactsSortType.pending, ), _Option( - enabled: vm.sortFn != ContactsSortType.recent, + enabled: sortType != ContactsSortType.recent, style: optionTheme.copyWith(color: _colorTestFn(ContactsSortType.recent, colorScheme)), text: 'Sort by Recent', type: ContactsSortType.recent, ), _Option( - enabled: vm.sortFn != ContactsSortType.reset, + enabled: sortType != ContactsSortType.reset, style: optionTheme.copyWith(color: _colorTestFn(ContactsSortType.reset, colorScheme)), text: 'No Sort', type: ContactsSortType.reset, @@ -65,7 +65,7 @@ class ContactsFilterButton extends StatelessWidget { ), Align( alignment: const Alignment(0.75, -0.5), - child: vm.hasSortFn ? Dots(color: colorScheme.secondary) : null, + child: sortType != ContactsSortType.reset ? Dots(color: colorScheme.secondary) : null, ), ], ), @@ -73,7 +73,7 @@ class ContactsFilterButton extends StatelessWidget { } Color? _colorTestFn(ContactsSortType type, ColorScheme colorScheme) => - vm.sortFn == type ? colorScheme.secondary : null; + sortType == type ? colorScheme.secondary : null; } class _Option extends PopupMenuItem { diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index 0da5f2d..1776c00 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -1,2 +1,4 @@ export 'state/account_provider.dart'; +export 'state/contacts_provider.dart'; export 'state/registry_provider.dart'; +export 'state/state_notifier_mixin.dart'; diff --git a/lib/presentation/state/contacts_provider.dart b/lib/presentation/state/contacts_provider.dart new file mode 100644 index 0000000..03cc2f2 --- /dev/null +++ b/lib/presentation/state/contacts_provider.dart @@ -0,0 +1,14 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; + +part 'contacts_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream> contacts(ContactsRef ref) async* { + final AccountEntity account = await ref.watch(accountProvider.future); + + yield* ref.read(registryProvider).get().fetchAll(account.uid); +} diff --git a/lib/presentation/state/state_notifier_mixin.dart b/lib/presentation/state/state_notifier_mixin.dart new file mode 100644 index 0000000..8138c4f --- /dev/null +++ b/lib/presentation/state/state_notifier_mixin.dart @@ -0,0 +1,10 @@ +import 'package:meta/meta.dart'; +import 'package:riverpod/riverpod.dart'; + +@optionalTypeArgs +mixin StateNotifierMixin on AutoDisposeNotifier { + T get currentState => state; + + // ignore: use_setters_to_change_properties + void setState(T value) => state = value; +} diff --git a/lib/presentation/theme/app_theme.dart b/lib/presentation/theme/app_theme.dart index f4c00b3..8d3f2df 100644 --- a/lib/presentation/theme/app_theme.dart +++ b/lib/presentation/theme/app_theme.dart @@ -71,6 +71,7 @@ ThemeData themeBuilder( appBarTheme: defaultTheme.appBarTheme.copyWith( backgroundColor: colorScheme.surface, foregroundColor: colorScheme.onSurface, + elevation: 1.0, ), textButtonTheme: TextButtonThemeData(style: buttonStyle), filledButtonTheme: FilledButtonThemeData(style: buttonStyle), diff --git a/lib/presentation/utils.dart b/lib/presentation/utils.dart index af18ce4..c70007c 100644 --- a/lib/presentation/utils.dart +++ b/lib/presentation/utils.dart @@ -11,3 +11,4 @@ export 'utils/route_transitions.dart'; export 'utils/show_child_dialog.dart'; export 'utils/show_choice_dialog.dart'; export 'utils/show_image_choice_dialog.dart'; +export 'utils/sort_type.dart'; diff --git a/lib/presentation/rebloc/contacts/sort_type.dart b/lib/presentation/utils/sort_type.dart similarity index 100% rename from lib/presentation/rebloc/contacts/sort_type.dart rename to lib/presentation/utils/sort_type.dart From a751146c82cc80cab887f2ad6312606350b8d013 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 20:14:32 +0200 Subject: [PATCH 04/17] Introduce `filteredJobsProvider` & 'jobsProvider' --- lib/presentation/rebloc.dart | 4 +- .../screens/contacts/contacts.dart | 2 +- .../filtered_contacts_state_provider.dart | 9 +- lib/presentation/screens/jobs/jobs.dart | 85 +++++++++++++------ .../filtered_jobs_state_provider.dart | 79 +++++++++++++++++ .../jobs/widgets/jobs_filter_button.dart | 22 ++--- lib/presentation/state.dart | 1 + lib/presentation/state/jobs_provider.dart | 14 +++ lib/presentation/utils.dart | 3 +- ...sort_type.dart => contacts_sort_type.dart} | 0 .../jobs_sort_type.dart} | 0 11 files changed, 170 insertions(+), 49 deletions(-) create mode 100644 lib/presentation/screens/jobs/providers/filtered_jobs_state_provider.dart create mode 100644 lib/presentation/state/jobs_provider.dart rename lib/presentation/utils/{sort_type.dart => contacts_sort_type.dart} (100%) rename lib/presentation/{rebloc/jobs/sort_type.dart => utils/jobs_sort_type.dart} (100%) diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index b14d9bb..74f336a 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -12,7 +12,6 @@ export 'rebloc/contacts/bloc.dart'; export 'rebloc/contacts/view_model.dart'; export 'rebloc/extensions.dart'; export 'rebloc/jobs/bloc.dart'; -export 'rebloc/jobs/sort_type.dart'; export 'rebloc/jobs/view_model.dart'; export 'rebloc/measures/bloc.dart'; export 'rebloc/measures/view_model.dart'; @@ -21,4 +20,5 @@ export 'rebloc/settings/view_model.dart'; export 'rebloc/stats/bloc.dart'; export 'rebloc/stats/view_model.dart'; export 'rebloc/store_factory.dart'; -export 'utils/sort_type.dart'; +export 'utils/contacts_sort_type.dart'; +export 'utils/jobs_sort_type.dart'; diff --git a/lib/presentation/screens/contacts/contacts.dart b/lib/presentation/screens/contacts/contacts.dart index 0c79ab5..f552ab1 100644 --- a/lib/presentation/screens/contacts/contacts.dart +++ b/lib/presentation/screens/contacts/contacts.dart @@ -45,7 +45,7 @@ class _ContactsPageState extends State { floatingActionButton: FloatingActionButton( child: const Icon(Icons.person_add), onPressed: () => context.registry.get().toCreateContact( - ref.read(filteredContactsProvider).requireValue.userId, //todo: remove + ref.read(accountProvider).requireValue.uid, //todo: remove ), ), ), diff --git a/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart b/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart index 64aa3d6..002e946 100644 --- a/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart +++ b/lib/presentation/screens/contacts/providers/filtered_contacts_state_provider.dart @@ -12,17 +12,15 @@ part 'filtered_contacts_state_provider.g.dart'; @Riverpod(dependencies: [account, contacts, SearchContactQueryState, SearchContactSortState]) Future filteredContacts(FilteredContactsRef ref) async { - final AccountEntity account = await ref.watch(accountProvider.future); final List items = await ref.watch(contactsProvider.future); final String query = ref.watch(searchContactQueryStateProvider).trim().toLowerCase(); final ContactsSortType sortType = ref.watch(searchContactSortStateProvider); return FilteredContactsState( - userId: account.uid, contacts: items .where((ContactEntity element) { if (query.length > 1) { - return element.fullname.toLowerCase().contains(query); + return element.fullname.contains(RegExp(query, caseSensitive: false)); } return true; @@ -33,13 +31,12 @@ Future filteredContacts(FilteredContactsRef ref) async { } class FilteredContactsState with EquatableMixin { - const FilteredContactsState({required this.userId, required this.contacts}); + const FilteredContactsState({required this.contacts}); - final String userId; final List contacts; @override - List get props => [userId, contacts]; + List get props => [contacts]; } @riverpod diff --git a/lib/presentation/screens/jobs/jobs.dart b/lib/presentation/screens/jobs/jobs.dart index ea5313d..c246a1b 100644 --- a/lib/presentation/screens/jobs/jobs.dart +++ b/lib/presentation/screens/jobs/jobs.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/presentation.dart'; +import 'providers/filtered_jobs_state_provider.dart'; import 'widgets/jobs_filter_button.dart'; import 'widgets/jobs_list.dart'; @@ -10,61 +11,90 @@ class JobsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return ViewModelSubscriber( - converter: JobsViewModel.new, - builder: (BuildContext context, DispatchFunction dispatcher, JobsViewModel vm) { + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) { + final AsyncValue filteredJobs = ref.watch(filteredJobsProvider); + return WillPopScope( child: Scaffold( - appBar: _AppBar(vm: vm), + appBar: _AppBar(loading: filteredJobs.isLoading), body: Builder( builder: (BuildContext context) { - if (vm.isLoading && !vm.isSearching) { - return const LoadingSpinner(); - } + return filteredJobs.when( + skipLoadingOnReload: true, + data: (FilteredJobsState state) { + if (state.jobs.isEmpty) { + return const Center( + child: EmptyResultView(message: 'No jobs available'), + ); + } - return SafeArea( - top: false, - child: CustomScrollView( - slivers: [JobList(jobs: vm.jobs)], - ), + return SafeArea( + top: false, + child: CustomScrollView( + slivers: [ + JobList(jobs: state.jobs), + ], + ), + ); + }, + error: ErrorView.new, + loading: () => child!, ); }, ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.library_add), - onPressed: () => context.registry.get().toCreateJob(vm.userId, vm.contacts), + onPressed: () => context.registry.get().toCreateJob( + ref.read(accountProvider).requireValue.uid, //todo: remove + ref.read(contactsProvider).requireValue, //todo: remove + ), ), ), onWillPop: () async { - if (vm.isSearching) { - dispatcher(const JobsAction.searchCancel()); + final SearchJobQueryState queryState = ref.read(searchJobQueryStateProvider.notifier); + if (queryState.isSearching) { + queryState.setState(''); return false; } return true; }, ); }, + child: const LoadingSpinner(), ); } } -class _AppBar extends StatefulWidget implements PreferredSizeWidget { - const _AppBar({required this.vm}); +class _AppBar extends ConsumerStatefulWidget implements PreferredSizeWidget { + const _AppBar({required this.loading}); - final JobsViewModel vm; + final bool loading; @override - State<_AppBar> createState() => _AppBarState(); + ConsumerState<_AppBar> createState() => _AppBarState(); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -class _AppBarState extends State<_AppBar> with StoreDispatchMixin { +class _AppBarState extends ConsumerState<_AppBar> { + late final SearchJobQueryState _queryProvider = ref.read(searchJobQueryStateProvider.notifier); + late final SearchJobSortState _sortProvider = ref.read(searchJobSortStateProvider.notifier); + late final TextEditingController _controller = TextEditingController(text: _queryProvider.currentState); bool _isSearching = false; @override Widget build(BuildContext context) { + ref.listen(searchJobQueryStateProvider, (_, String next) { + if (_controller.text != next) { + _controller.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length, affinity: TextAffinity.upstream), + ); + } + }); + if (!_isSearching) { return CustomAppBar( title: const Text('Jobs'), @@ -74,27 +104,26 @@ class _AppBarState extends State<_AppBar> with StoreDispatchMixin { onPressed: _onTapSearch, ), JobsFilterButton( - vm: widget.vm, - onTapSort: (JobsSortType type) => dispatchAction(JobsAction.sort(type)), + sortType: ref.watch(searchJobSortStateProvider), + onTapSort: _sortProvider.setState, ), ], ); } return AppBar( - centerTitle: false, - elevation: 1.0, leading: AppCloseButton(onPop: _handleSearchEnd), title: TextField( + controller: _controller, autofocus: true, decoration: const InputDecoration(hintText: 'Search...'), - onChanged: (String term) => dispatchAction(JobsAction.search(term)), + onChanged: _queryProvider.setState, ), bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), child: SizedBox( height: 1.0, - child: widget.vm.isLoading ? const LinearProgressIndicator() : null, + child: widget.loading ? const LinearProgressIndicator() : null, ), ), ); @@ -103,7 +132,7 @@ class _AppBarState extends State<_AppBar> with StoreDispatchMixin { void _onTapSearch() => setState(() => _isSearching = true); void _handleSearchEnd() { - dispatchAction(const JobsAction.searchCancel()); + _queryProvider.setState(''); setState(() => _isSearching = false); } } diff --git a/lib/presentation/screens/jobs/providers/filtered_jobs_state_provider.dart b/lib/presentation/screens/jobs/providers/filtered_jobs_state_provider.dart new file mode 100644 index 0000000..deb9ef5 --- /dev/null +++ b/lib/presentation/screens/jobs/providers/filtered_jobs_state_provider.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; +import '../../../utils.dart'; + +part 'filtered_jobs_state_provider.g.dart'; + +@Riverpod(dependencies: [account, jobs, SearchJobQueryState, SearchJobSortState]) +Future filteredJobs(FilteredJobsRef ref) async { + final List items = await ref.watch(jobsProvider.future); + final String query = ref.watch(searchJobQueryStateProvider).trim().toLowerCase(); + final JobsSortType sortType = ref.watch(searchJobSortStateProvider); + + return FilteredJobsState( + jobs: items + .where((JobEntity element) { + if (query.length > 1) { + return element.name.contains(RegExp(query, caseSensitive: false)); + } + + return true; + }) + .sorted(_sort(sortType)) + .toList(growable: false), + ); +} + +class FilteredJobsState with EquatableMixin { + const FilteredJobsState({required this.jobs}); + + final List jobs; + + @override + List get props => [jobs]; +} + +@riverpod +class SearchJobQueryState extends _$SearchJobQueryState with StateNotifierMixin { + @override + String build() => ''; + + bool get isSearching => state.length > 1; +} + +@riverpod +class SearchJobSortState extends _$SearchJobSortState with StateNotifierMixin { + @override + JobsSortType build() => JobsSortType.names; +} + +Comparator _sort(JobsSortType sortType) { + switch (sortType) { + case JobsSortType.active: + return (JobEntity a, JobEntity b) => (a.isComplete == b.isComplete) + ? 0 + : a.isComplete + ? 1 + : -1; + case JobsSortType.names: + return (JobEntity a, JobEntity b) => a.name.compareTo(b.name); + case JobsSortType.payments: + double foldPrice(double acc, PaymentEntity model) => acc + model.price; + return (JobEntity a, JobEntity b) => + b.payments.fold(0.0, foldPrice).compareTo(a.payments.fold(0.0, foldPrice)); + case JobsSortType.owed: + return (JobEntity a, JobEntity b) => b.pendingPayment.compareTo(a.pendingPayment); + case JobsSortType.price: + return (JobEntity a, JobEntity b) => b.price.compareTo(a.price); + case JobsSortType.recent: + return (JobEntity a, JobEntity b) => b.createdAt.compareTo(a.createdAt); + case JobsSortType.reset: + return (JobEntity a, JobEntity b) => a.id.compareTo(b.id); + } +} diff --git a/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart b/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart index 7e93237..7fd3fcc 100644 --- a/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart +++ b/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart @@ -3,9 +3,9 @@ import 'package:tailor_made/presentation/rebloc.dart'; import 'package:tailor_made/presentation/widgets.dart'; class JobsFilterButton extends StatelessWidget { - const JobsFilterButton({super.key, required this.vm, required this.onTapSort}); + const JobsFilterButton({super.key, required this.sortType, required this.onTapSort}); - final JobsViewModel vm; + final JobsSortType sortType; final ValueSetter onTapSort; @override @@ -26,43 +26,43 @@ class JobsFilterButton extends StatelessWidget { _Option( text: 'Sort by Active', type: JobsSortType.active, - enabled: vm.sortFn != JobsSortType.active, + enabled: sortType != JobsSortType.active, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.active, colorScheme)), ), _Option( text: 'Sort by Name', type: JobsSortType.names, - enabled: vm.sortFn != JobsSortType.names, + enabled: sortType != JobsSortType.names, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.names, colorScheme)), ), _Option( text: 'Sort by Owed', type: JobsSortType.owed, - enabled: vm.sortFn != JobsSortType.owed, + enabled: sortType != JobsSortType.owed, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.owed, colorScheme)), ), _Option( text: 'Sort by Payments', type: JobsSortType.payments, - enabled: vm.sortFn != JobsSortType.payments, + enabled: sortType != JobsSortType.payments, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.payments, colorScheme)), ), _Option( text: 'Sort by Price', type: JobsSortType.price, - enabled: vm.sortFn != JobsSortType.price, + enabled: sortType != JobsSortType.price, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.price, colorScheme)), ), _Option( text: 'Sort by Recent', type: JobsSortType.recent, - enabled: vm.sortFn != JobsSortType.recent, + enabled: sortType != JobsSortType.recent, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.recent, colorScheme)), ), _Option( text: 'No Sort', type: JobsSortType.reset, - enabled: vm.sortFn != JobsSortType.reset, + enabled: sortType != JobsSortType.reset, style: optionTheme.copyWith(color: _colorTestFn(JobsSortType.reset, colorScheme)), ), ]; @@ -71,14 +71,14 @@ class JobsFilterButton extends StatelessWidget { ), Align( alignment: const Alignment(0.75, -0.5), - child: vm.hasSortFn ? Dots(color: colorScheme.secondary) : null, + child: sortType != JobsSortType.reset ? Dots(color: colorScheme.secondary) : null, ), ], ), ); } - Color? _colorTestFn(JobsSortType type, ColorScheme colorScheme) => vm.sortFn == type ? colorScheme.secondary : null; + Color? _colorTestFn(JobsSortType type, ColorScheme colorScheme) => sortType == type ? colorScheme.secondary : null; } class _Option extends PopupMenuItem { diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index 1776c00..b2610f9 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -1,4 +1,5 @@ export 'state/account_provider.dart'; export 'state/contacts_provider.dart'; +export 'state/jobs_provider.dart'; export 'state/registry_provider.dart'; export 'state/state_notifier_mixin.dart'; diff --git a/lib/presentation/state/jobs_provider.dart b/lib/presentation/state/jobs_provider.dart new file mode 100644 index 0000000..9dc94ca --- /dev/null +++ b/lib/presentation/state/jobs_provider.dart @@ -0,0 +1,14 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; + +part 'jobs_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream> jobs(JobsRef ref) async* { + final AccountEntity account = await ref.watch(accountProvider.future); + + yield* ref.read(registryProvider).get().fetchAll(account.uid); +} diff --git a/lib/presentation/utils.dart b/lib/presentation/utils.dart index c70007c..4f069b5 100644 --- a/lib/presentation/utils.dart +++ b/lib/presentation/utils.dart @@ -4,11 +4,12 @@ export 'utils/app_money.dart'; export 'utils/app_sliver_separator_builder_delegate.dart'; export 'utils/app_status_bar.dart'; export 'utils/app_version_builder.dart'; +export 'utils/contacts_sort_type.dart'; export 'utils/extensions.dart'; export 'utils/image_utils.dart'; export 'utils/input_validator.dart'; +export 'utils/jobs_sort_type.dart'; export 'utils/route_transitions.dart'; export 'utils/show_child_dialog.dart'; export 'utils/show_choice_dialog.dart'; export 'utils/show_image_choice_dialog.dart'; -export 'utils/sort_type.dart'; diff --git a/lib/presentation/utils/sort_type.dart b/lib/presentation/utils/contacts_sort_type.dart similarity index 100% rename from lib/presentation/utils/sort_type.dart rename to lib/presentation/utils/contacts_sort_type.dart diff --git a/lib/presentation/rebloc/jobs/sort_type.dart b/lib/presentation/utils/jobs_sort_type.dart similarity index 100% rename from lib/presentation/rebloc/jobs/sort_type.dart rename to lib/presentation/utils/jobs_sort_type.dart From 7af2ffb1b0df83fe96c08d676430abb13f64ac27 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 20:20:47 +0200 Subject: [PATCH 05/17] Remove unused blocs --- lib/presentation/rebloc/auth/bloc.dart | 2 - lib/presentation/rebloc/contacts/actions.dart | 11 -- lib/presentation/rebloc/contacts/bloc.dart | 118 ---------------- lib/presentation/rebloc/jobs/actions.dart | 12 -- lib/presentation/rebloc/jobs/bloc.dart | 126 ------------------ lib/presentation/rebloc/store_factory.dart | 4 - 6 files changed, 273 deletions(-) delete mode 100644 lib/presentation/rebloc/contacts/actions.dart delete mode 100644 lib/presentation/rebloc/jobs/actions.dart diff --git a/lib/presentation/rebloc/auth/bloc.dart b/lib/presentation/rebloc/auth/bloc.dart index 5e26815..a0c50a1 100644 --- a/lib/presentation/rebloc/auth/bloc.dart +++ b/lib/presentation/rebloc/auth/bloc.dart @@ -19,8 +19,6 @@ class AuthBloc extends SimpleBloc { dispatcher(AccountAction.init(action.user)); dispatcher(MeasuresAction.init(action.user)); dispatcher(StatsAction.init(action.user)); - dispatcher(JobsAction.init(action.user)); - dispatcher(ContactsAction.init(action.user)); } return action; } diff --git a/lib/presentation/rebloc/contacts/actions.dart b/lib/presentation/rebloc/contacts/actions.dart deleted file mode 100644 index 81516a4..0000000 --- a/lib/presentation/rebloc/contacts/actions.dart +++ /dev/null @@ -1,11 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class ContactsAction with _$ContactsAction, AppAction { - const factory ContactsAction.init(String userId) = _InitContactsAction; - const factory ContactsAction.sort(ContactsSortType payload) = _SortContacts; - const factory ContactsAction.search(String payload) = _SearchContactAction; - const factory ContactsAction.searchSuccess(List payload) = _SearchSuccessContactAction; - const factory ContactsAction.searchCancel() = _CancelSearchContactAction; - const factory ContactsAction.searchStart() = _StartSearchContactAction; -} diff --git a/lib/presentation/rebloc/contacts/bloc.dart b/lib/presentation/rebloc/contacts/bloc.dart index c916792..07d29c2 100644 --- a/lib/presentation/rebloc/contacts/bloc.dart +++ b/lib/presentation/rebloc/contacts/bloc.dart @@ -1,124 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation/rebloc.dart'; -part 'actions.dart'; part 'bloc.freezed.dart'; part 'state.dart'; - -class ContactsBloc extends SimpleBloc { - ContactsBloc(this.contacts); - - final Contacts contacts; - - @override - Stream> applyMiddleware(Stream> input) { - MergeStream>(>>[ - input.whereAction<_SearchContactAction>().switchMap(_makeSearch), - input.whereAction<_InitContactsAction>().switchMap(_onAfterLogin(contacts)), - ]).untilAction().listen((WareContext context) => context.dispatcher(context.action)); - - return input; - } - - @override - AppState reducer(AppState state, Action action) { - final ContactsState contacts = state.contacts; - - if (action is OnDataAction>) { - return state.copyWith( - contacts: contacts.copyWith( - contacts: List.of(action.payload..sort(_sort(contacts.sortFn))), - status: StateStatus.success, - ), - ); - } - - if (action is _StartSearchContactAction) { - return state.copyWith( - contacts: contacts.copyWith( - status: StateStatus.loading, - isSearching: true, - ), - ); - } - - if (action is _SearchSuccessContactAction) { - return state.copyWith( - contacts: contacts.copyWith( - searchResults: List.of(action.payload..sort(_sort(contacts.sortFn))), - status: StateStatus.success, - ), - ); - } - - if (action is _SortContacts) { - return state.copyWith( - contacts: contacts.copyWith( - contacts: List.of(contacts.contacts!.toList()..sort(_sort(action.payload))), - hasSortFn: action.payload != ContactsSortType.reset, - sortFn: action.payload, - status: StateStatus.success, - ), - ); - } - - if (action is _CancelSearchContactAction) { - return state.copyWith( - contacts: contacts.copyWith( - status: StateStatus.success, - isSearching: false, - searchResults: List.of([]), - ), - ); - } - - return state; - } -} - -Comparator _sort(ContactsSortType sortType) { - switch (sortType) { - case ContactsSortType.jobs: - return (ContactEntity a, ContactEntity b) => b.totalJobs.compareTo(a.totalJobs); - case ContactsSortType.names: - return (ContactEntity a, ContactEntity b) => a.fullname.compareTo(b.fullname); - case ContactsSortType.completed: - return (ContactEntity a, ContactEntity b) => (b.totalJobs - b.pendingJobs).compareTo(a.totalJobs - a.pendingJobs); - case ContactsSortType.pending: - return (ContactEntity a, ContactEntity b) => b.pendingJobs.compareTo(a.pendingJobs); - case ContactsSortType.recent: - return (ContactEntity a, ContactEntity b) => b.createdAt.compareTo(a.createdAt); - case ContactsSortType.reset: - return (ContactEntity a, ContactEntity b) => a.id.compareTo(b.id); - } -} - -Middleware _onAfterLogin(Contacts contacts) { - return (WareContext context) { - return contacts - .fetchAll((context.action as _InitContactsAction).userId) - .map(OnDataAction>.new) - .map((OnDataAction> action) => context.copyWith(action)); - }; -} - -Stream> _makeSearch(WareContext context) { - return Stream.value((context.action as _SearchContactAction).payload) - .doOnData((_) => context.dispatcher(const ContactsAction.searchStart())) - .map((String text) => text.trim()) - .distinct() - .where((String text) => text.length > 1) - .debounceTime(const Duration(milliseconds: 750)) - .map( - (String text) => ContactsAction.searchSuccess( - context.state.contacts.contacts! - .where((ContactEntity contact) => contact.fullname.contains(RegExp(text, caseSensitive: false))) - .toList(), - ), - ) - .takeWhile((ContactsAction action) => action is! _CancelSearchContactAction) - .map((ContactsAction action) => context.copyWith(action)); -} diff --git a/lib/presentation/rebloc/jobs/actions.dart b/lib/presentation/rebloc/jobs/actions.dart deleted file mode 100644 index ca1dd90..0000000 --- a/lib/presentation/rebloc/jobs/actions.dart +++ /dev/null @@ -1,12 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class JobsAction with _$JobsAction, AppAction { - const factory JobsAction.init(String userId) = _InitJobsAction; - const factory JobsAction.toggle(JobEntity payload) = _ToggleCompleteJob; - const factory JobsAction.sort(JobsSortType payload) = _SortJobs; - const factory JobsAction.search(String payload) = _SearchJobAction; - const factory JobsAction.searchSuccess(List payload) = _SearchSuccessJobAction; - const factory JobsAction.searchCancel() = _CancelSearchJobAction; - const factory JobsAction.searchStart() = _StartSearchJobAction; -} diff --git a/lib/presentation/rebloc/jobs/bloc.dart b/lib/presentation/rebloc/jobs/bloc.dart index 366343b..07d29c2 100644 --- a/lib/presentation/rebloc/jobs/bloc.dart +++ b/lib/presentation/rebloc/jobs/bloc.dart @@ -1,132 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation/rebloc.dart'; -part 'actions.dart'; part 'bloc.freezed.dart'; part 'state.dart'; - -class JobsBloc extends SimpleBloc { - JobsBloc(this.jobs); - - final Jobs jobs; - - @override - Stream> applyMiddleware(Stream> input) { - MergeStream>(>>[ - input.whereAction<_SearchJobAction>().switchMap(_makeSearch), - input.whereAction<_InitJobsAction>().switchMap(_onAfterLogin(jobs)), - ]).untilAction().listen((WareContext context) => context.dispatcher(context.action)); - - return input; - } - - @override - AppState reducer(AppState state, Action action) { - final JobsState jobs = state.jobs; - - if (action is OnDataAction>) { - return state.copyWith( - jobs: jobs.copyWith( - jobs: List.of(action.payload..sort(_sort(jobs.sortFn))), - status: StateStatus.success, - ), - ); - } - - if (action is _StartSearchJobAction) { - return state.copyWith( - jobs: jobs.copyWith( - status: StateStatus.loading, - isSearching: true, - ), - ); - } - - if (action is _CancelSearchJobAction) { - return state.copyWith( - jobs: jobs.copyWith( - status: StateStatus.success, - isSearching: false, - searchResults: List.from([]), - ), - ); - } - - if (action is _SearchSuccessJobAction) { - return state.copyWith( - jobs: jobs.copyWith( - searchResults: List.from(action.payload..sort(_sort(jobs.sortFn))), - status: StateStatus.success, - ), - ); - } - - if (action is _SortJobs) { - return state.copyWith( - jobs: jobs.copyWith( - jobs: List.from(jobs.jobs!.toList()..sort(_sort(action.payload))), - hasSortFn: action.payload != JobsSortType.reset, - sortFn: action.payload, - status: StateStatus.success, - ), - ); - } - - return state; - } -} - -Comparator _sort(JobsSortType sortType) { - switch (sortType) { - case JobsSortType.active: - return (JobEntity a, JobEntity b) => (a.isComplete == b.isComplete) - ? 0 - : a.isComplete - ? 1 - : -1; - case JobsSortType.names: - return (JobEntity a, JobEntity b) => a.name.compareTo(b.name); - case JobsSortType.payments: - double foldPrice(double acc, PaymentEntity model) => acc + model.price; - return (JobEntity a, JobEntity b) => - b.payments.fold(0.0, foldPrice).compareTo(a.payments.fold(0.0, foldPrice)); - case JobsSortType.owed: - return (JobEntity a, JobEntity b) => b.pendingPayment.compareTo(a.pendingPayment); - case JobsSortType.price: - return (JobEntity a, JobEntity b) => b.price.compareTo(a.price); - case JobsSortType.recent: - return (JobEntity a, JobEntity b) => b.createdAt.compareTo(a.createdAt); - case JobsSortType.reset: - return (JobEntity a, JobEntity b) => a.id.compareTo(b.id); - } -} - -Stream> _makeSearch(WareContext context) { - return Stream.value((context.action as _SearchJobAction).payload) - .doOnData((_) => context.dispatcher(const JobsAction.searchStart())) - .map((String text) => text.trim()) - .distinct() - .where((String text) => text.length > 1) - .debounceTime(const Duration(milliseconds: 750)) - .map( - (String text) => JobsAction.searchSuccess( - context.state.jobs.jobs! - .where((JobEntity job) => job.name.contains(RegExp(text, caseSensitive: false))) - .toList(), - ), - ) - .takeWhile((JobsAction action) => action is! _CancelSearchJobAction) - .map((JobsAction action) => context.copyWith(action)); -} - -Middleware _onAfterLogin(Jobs jobs) { - return (WareContext context) { - return jobs - .fetchAll((context.action as _InitJobsAction).userId) - .map(OnDataAction>.new) - .map((OnDataAction> action) => context.copyWith(action)); - }; -} diff --git a/lib/presentation/rebloc/store_factory.dart b/lib/presentation/rebloc/store_factory.dart index aa2c3fa..1108e2d 100644 --- a/lib/presentation/rebloc/store_factory.dart +++ b/lib/presentation/rebloc/store_factory.dart @@ -7,8 +7,6 @@ import 'app_state.dart'; import 'auth/bloc.dart'; import 'common/initialize.dart'; import 'common/logger.dart'; -import 'contacts/bloc.dart'; -import 'jobs/bloc.dart'; import 'measures/bloc.dart'; import 'settings/bloc.dart'; import 'stats/bloc.dart'; @@ -20,11 +18,9 @@ Store storeFactory(Registry registry) { InitializeBloc(), AuthBloc(registry.get()), AccountBloc(registry.get()), - ContactsBloc(registry.get()), MeasuresBloc(registry.get()), SettingsBloc(registry.get()), StatsBloc(registry.get()), - JobsBloc(registry.get()), LoggerBloc(registry.get().isTesting), ], ); From 6d13d6f7e582f356831742bdbf7585cf5b3873cd Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 20:51:06 +0200 Subject: [PATCH 06/17] Introduce `selectedContactProvider` --- .../coordinator/contacts_coordinator.dart | 6 +- lib/presentation/rebloc.dart | 1 - .../rebloc/common/home_view_model.dart | 4 +- .../rebloc/contacts/view_model.dart | 65 -------------- .../screens/contacts/contact.dart | 87 +++++++++---------- .../screens/contacts/contacts_create.dart | 2 +- .../providers/selected_contact_provider.dart | 40 +++++++++ .../contacts/widgets/contacts_list_item.dart | 2 +- .../screens/gallery/widgets/gallery_view.dart | 10 +-- lib/presentation/screens/jobs/job.dart | 2 +- .../screens/payments/payment.dart | 4 +- 11 files changed, 96 insertions(+), 127 deletions(-) delete mode 100644 lib/presentation/rebloc/contacts/view_model.dart create mode 100644 lib/presentation/screens/contacts/providers/selected_contact_provider.dart diff --git a/lib/presentation/coordinator/contacts_coordinator.dart b/lib/presentation/coordinator/contacts_coordinator.dart index b0d1b59..11fd01e 100644 --- a/lib/presentation/coordinator/contacts_coordinator.dart +++ b/lib/presentation/coordinator/contacts_coordinator.dart @@ -14,10 +14,10 @@ import 'coordinator_base.dart'; class ContactsCoordinator extends CoordinatorBase { const ContactsCoordinator(super.navigatorKey); - void toContact(ContactEntity contact, {bool replace = false}) { + void toContact(String id, {bool replace = false}) { replace - ? navigator?.pushReplacement(RouteTransitions.slideIn(ContactPage(contact: contact))) - : navigator?.push(RouteTransitions.slideIn(ContactPage(contact: contact))); + ? navigator?.pushReplacement(RouteTransitions.slideIn(ContactPage(id: id))) + : navigator?.push(RouteTransitions.slideIn(ContactPage(id: id))); } void toContactEdit(String userId, ContactEntity contact) { diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index 74f336a..d26aff0 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -9,7 +9,6 @@ export 'rebloc/common/home_view_model.dart'; export 'rebloc/common/middleware.dart'; export 'rebloc/common/state_status.dart'; export 'rebloc/contacts/bloc.dart'; -export 'rebloc/contacts/view_model.dart'; export 'rebloc/extensions.dart'; export 'rebloc/jobs/bloc.dart'; export 'rebloc/jobs/view_model.dart'; diff --git a/lib/presentation/rebloc/common/home_view_model.dart b/lib/presentation/rebloc/common/home_view_model.dart index f6930e7..f7e21c1 100644 --- a/lib/presentation/rebloc/common/home_view_model.dart +++ b/lib/presentation/rebloc/common/home_view_model.dart @@ -10,9 +10,7 @@ class HomeViewModel extends Equatable { stats = state.stats.stats, settings = state.settings.settings, hasSkippedPremium = state.account.hasSkipedPremium == true, - isLoading = state.stats.status == StateStatus.loading || - state.contacts.status == StateStatus.loading || - state.account.status == StateStatus.loading; + isLoading = state.stats.status == StateStatus.loading || state.account.status == StateStatus.loading; final AccountEntity? account; diff --git a/lib/presentation/rebloc/contacts/view_model.dart b/lib/presentation/rebloc/contacts/view_model.dart deleted file mode 100644 index 5114f42..0000000 --- a/lib/presentation/rebloc/contacts/view_model.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class ContactsViewModel extends Equatable { - ContactsViewModel(AppState state, {this.contactID}) - : _model = state.contacts.contacts ?? [], - _searchResults = state.contacts.searchResults ?? [], - isSearching = state.contacts.isSearching, - hasSortFn = state.contacts.hasSortFn, - measuresGrouped = state.measures.grouped ?? >{}, - userId = state.account.account!.uid, - _jobs = state.jobs.jobs ?? [], - sortFn = state.contacts.sortFn, - isLoading = state.contacts.status == StateStatus.loading, - hasError = state.contacts.status == StateStatus.failure, - error = state.contacts.error; - - List get contacts => isSearching ? searchResults : model; - - final Map> measuresGrouped; - - ContactEntity? get selected => model.firstWhereOrNull((_) => _.id == contactID); - - List get selectedJobs => jobs.where((JobEntity job) => job.contactID == selected?.id).toList(); - - final String? contactID; - - final String userId; - - final List _searchResults; - - List get searchResults => _searchResults; - - final List _jobs; - - List get jobs => _jobs; - - final List _model; - - List get model => _model; - - final bool hasSortFn; - final ContactsSortType sortFn; - final bool isLoading; - final bool hasError; - final bool isSearching; - final dynamic error; - - @override - List get props => [ - model, - hasSortFn, - sortFn, - userId, - isSearching, - jobs, - contacts, - searchResults, - isLoading, - hasError, - error - ]; -} diff --git a/lib/presentation/screens/contacts/contact.dart b/lib/presentation/screens/contacts/contact.dart index 9ae5bdf..bbb20a9 100644 --- a/lib/presentation/screens/contacts/contact.dart +++ b/lib/presentation/screens/contacts/contact.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/presentation/widgets.dart'; import '../jobs/widgets/jobs_list.dart'; +import 'providers/selected_contact_provider.dart'; import 'widgets/contact_appbar.dart'; import 'widgets/contact_gallery_grid.dart'; import 'widgets/contact_payments_list.dart'; @@ -13,65 +12,63 @@ import 'widgets/contact_payments_list.dart'; const List _tabs = ['Jobs', 'Gallery', 'Payments']; class ContactPage extends StatelessWidget { - const ContactPage({super.key, required this.contact}); + const ContactPage({super.key, required this.id}); - final ContactEntity contact; + final String id; @override Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; - return ViewModelSubscriber( - converter: (AppState state) => ContactsViewModel(state, contactID: contact.id), - builder: (BuildContext context, DispatchFunction dispatch, ContactsViewModel viewModel) { - // in the case of newly created contacts - final ContactEntity contact = viewModel.selected ?? this.contact; - return DefaultTabController( - length: _tabs.length, - child: Scaffold( - appBar: AppBar( - backgroundColor: colorScheme.secondary, - automaticallyImplyLeading: false, - title: ContactAppBar( - userId: viewModel.userId, - contact: contact, - grouped: viewModel.measuresGrouped, - ), - titleSpacing: 0.0, - centerTitle: false, - bottom: TabBar( - labelStyle: Theme.of(context).textTheme.labelLarge, - tabs: _tabs.map((String tab) => Tab(child: Text(tab))).toList(), - ), - systemOverlayStyle: SystemUiOverlayStyle.light, + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => DefaultTabController( + length: _tabs.length, + child: Scaffold( + appBar: AppBar( + backgroundColor: colorScheme.secondary, + automaticallyImplyLeading: false, + title: ref.watch(selectedContactProvider(id)).maybeWhen( + skipLoadingOnReload: true, + data: (ContactState data) => ContactAppBar( + userId: data.userId, + contact: data.contact, + grouped: data.measurements, + ), + orElse: () => const SizedBox.shrink(), + ), + titleSpacing: 0.0, + centerTitle: false, + bottom: TabBar( + labelStyle: theme.textTheme.labelLarge, + tabs: _tabs.map((String tab) => Tab(child: Text(tab))).toList(), ), - body: Builder( - builder: (BuildContext context) { - if (viewModel.isLoading) { - return const Center(child: LoadingSpinner()); - } - - return TabBarView( + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + body: ref.watch(selectedContactProvider(id)).when( + skipLoadingOnReload: true, + data: (ContactState data) => TabBarView( children: [ _TabView( name: _tabs[0].toLowerCase(), - child: JobList(jobs: viewModel.selectedJobs), + child: JobList(jobs: data.jobs), ), _TabView( name: _tabs[1].toLowerCase(), - child: GalleryGridWidget(jobs: viewModel.selectedJobs), + child: GalleryGridWidget(jobs: data.jobs), ), _TabView( name: _tabs[2].toLowerCase(), - child: PaymentsListWidget(jobs: viewModel.selectedJobs), + child: PaymentsListWidget(jobs: data.jobs), ), ], - ); - }, - ), - ), - ); - }, + ), + error: ErrorView.new, + loading: () => child!, + ), + ), + ), + child: const Center(child: LoadingSpinner()), ); } } diff --git a/lib/presentation/screens/contacts/contacts_create.dart b/lib/presentation/screens/contacts/contacts_create.dart index ff8ab1a..64640c4 100644 --- a/lib/presentation/screens/contacts/contacts_create.dart +++ b/lib/presentation/screens/contacts/contacts_create.dart @@ -107,7 +107,7 @@ class _ContactsCreatePageState extends State { final ContactEntity snap = await contacts.create(widget.userId, contact); snackBar.success('Successfully Added'); - contactsCoordinator.toContact(snap, replace: true); + contactsCoordinator.toContact(snap.id, replace: true); } catch (error, stackTrace) { AppLog.e(error, stackTrace); snackBar.error(error.toString()); diff --git a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart new file mode 100644 index 0000000..9507fe0 --- /dev/null +++ b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart @@ -0,0 +1,40 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; + +part 'selected_contact_provider.g.dart'; + +@Riverpod(dependencies: [account, jobs, contacts]) +Future selectedContact(SelectedContactRef ref, String id) async { + final AccountEntity account = await ref.watch(accountProvider.future); + final List contacts = await ref.watch(contactsProvider.future); + final List jobs = await ref.watch( + jobsProvider.selectAsync((_) => _.where((_) => _.contactID == id).toList()), + ); + + return ContactState( + contact: contacts.firstWhere((_) => _.id == id), + jobs: jobs, + userId: account.uid, + measurements: >{}, //todo + ); +} + +class ContactState with EquatableMixin { + const ContactState({ + required this.contact, + required this.jobs, + required this.userId, + required this.measurements, + }); + + final ContactEntity contact; + final List jobs; + final String userId; + final Map> measurements; + + @override + List get props => [contact, userId, jobs, measurements]; +} diff --git a/lib/presentation/screens/contacts/widgets/contacts_list_item.dart b/lib/presentation/screens/contacts/widgets/contacts_list_item.dart index 6fc4db5..6f57ea5 100644 --- a/lib/presentation/screens/contacts/widgets/contacts_list_item.dart +++ b/lib/presentation/screens/contacts/widgets/contacts_list_item.dart @@ -20,7 +20,7 @@ class ContactsListItem extends StatelessWidget { return ListTile( dense: true, contentPadding: const EdgeInsets.fromLTRB(16, 4, 16, 4), - onTap: onTapContact ?? () => context.registry.get().toContact(contact), + onTap: onTapContact ?? () => context.registry.get().toContact(contact.id), leading: _Avatar(contact: contact), title: Text( contact.fullname, diff --git a/lib/presentation/screens/gallery/widgets/gallery_view.dart b/lib/presentation/screens/gallery/widgets/gallery_view.dart index 967311a..8bc7ec1 100644 --- a/lib/presentation/screens/gallery/widgets/gallery_view.dart +++ b/lib/presentation/screens/gallery/widgets/gallery_view.dart @@ -26,7 +26,7 @@ class GalleryView extends StatelessWidget { ), builder: (_, __, ContactJobViewModel vm) { return Scaffold( - appBar: _MyAppBar(contact: vm.selectedContact, job: vm.selectedJob, account: vm.account), + appBar: _MyAppBar(contactId: vm.selectedContact?.id, job: vm.selectedJob, account: vm.account), body: PhotoView( imageProvider: NetworkImage(src), loadingBuilder: (_, __) => const LoadingSpinner(), @@ -39,9 +39,9 @@ class GalleryView extends StatelessWidget { } class _MyAppBar extends StatelessWidget implements PreferredSizeWidget { - const _MyAppBar({this.contact, this.job, required this.account}); + const _MyAppBar({this.contactId, this.job, required this.account}); - final ContactEntity? contact; + final String? contactId; final JobEntity? job; final AccountEntity? account; @@ -63,10 +63,10 @@ class _MyAppBar extends StatelessWidget implements PreferredSizeWidget { icon: const Icon(Icons.work, color: iconColor), onPressed: () => context.registry.get().toJob(job), ), - if (contact case final ContactEntity contact) + if (contactId case final String contactId) IconButton( icon: const Icon(Icons.person, color: iconColor), - onPressed: () => context.registry.get().toContact(contact), + onPressed: () => context.registry.get().toContact(contactId), ), if (account!.hasPremiumEnabled) const IconButton( diff --git a/lib/presentation/screens/jobs/job.dart b/lib/presentation/screens/jobs/job.dart index c6bdf69..90a47bd 100644 --- a/lib/presentation/screens/jobs/job.dart +++ b/lib/presentation/screens/jobs/job.dart @@ -240,7 +240,7 @@ class _AvatarAppBar extends StatelessWidget { tag: contact.createdAt.toString(), imageUrl: contact.imageUrl, title: GestureDetector( - onTap: () => context.registry.get().toContact(contact), + onTap: () => context.registry.get().toContact(contact.id), child: Text( contact.fullname, maxLines: 1, diff --git a/lib/presentation/screens/payments/payment.dart b/lib/presentation/screens/payments/payment.dart index 7da36ab..b8f00a4 100644 --- a/lib/presentation/screens/payments/payment.dart +++ b/lib/presentation/screens/payments/payment.dart @@ -32,10 +32,10 @@ class PaymentPage extends StatelessWidget { icon: const Icon(Icons.work), onPressed: () => context.registry.get().toJob(job), ), - if (vm.selectedContact case final ContactEntity contact) + if (vm.selectedContact?.id case final String contactId) IconButton( icon: const Icon(Icons.person), - onPressed: () => context.registry.get().toContact(contact), + onPressed: () => context.registry.get().toContact(contactId), ), if (vm.account!.hasPremiumEnabled) const IconButton( From 93ef80e027c17ad46ee729ce3c1b06b5cd06a206 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 21:15:32 +0200 Subject: [PATCH 07/17] Introduce `selectedJobProvider` --- lib/presentation/rebloc/jobs/view_model.dart | 37 +--- .../providers/selected_contact_provider.dart | 6 +- lib/presentation/screens/jobs/job.dart | 161 +++++++++--------- .../jobs/providers/selected_job_provider.dart | 39 +++++ 4 files changed, 128 insertions(+), 115 deletions(-) create mode 100644 lib/presentation/screens/jobs/providers/selected_job_provider.dart diff --git a/lib/presentation/rebloc/jobs/view_model.dart b/lib/presentation/rebloc/jobs/view_model.dart index c745685..3b9dbbd 100644 --- a/lib/presentation/rebloc/jobs/view_model.dart +++ b/lib/presentation/rebloc/jobs/view_model.dart @@ -7,28 +7,17 @@ class JobsViewModel extends Equatable { JobsViewModel(AppState state, {this.jobID}) : _model = state.jobs.jobs ?? [], _contacts = state.contacts.contacts ?? [], - _searchResults = state.jobs.searchResults ?? [], - isSearching = state.jobs.isSearching, - hasSortFn = state.jobs.hasSortFn, - measures = state.measures.grouped ?? >{}, userId = state.account.account!.uid, - sortFn = state.jobs.sortFn, - isLoading = state.jobs.status == StateStatus.loading, - hasError = state.jobs.status == StateStatus.failure, - error = state.jobs.error; - - List get jobs => isSearching ? searchResults : model; + isLoading = false; List get tasks { - final List tasks = model.where((JobEntity job) => !job.isComplete).toList(); + final List tasks = _model.where((JobEntity job) => !job.isComplete).toList(); return tasks..sort((JobEntity a, JobEntity b) => a.dueAt.compareTo(b.dueAt)); } - JobEntity? get selected => model.firstWhereOrNull((_) => _.id == jobID); - - ContactEntity? get selectedContact => contacts.firstWhereOrNull((_) => _.id == selected?.contactID); + JobEntity? get selected => _model.firstWhereOrNull((_) => _.id == jobID); - List get selectedJobs => jobs.where((JobEntity job) => job.contactID == selected?.id).toList(); + ContactEntity? get selectedContact => _contacts.firstWhereOrNull((_) => _.id == selected?.contactID); final String? jobID; @@ -36,26 +25,10 @@ class JobsViewModel extends Equatable { final List _contacts; - List get contacts => _contacts; - - final List _searchResults; - - List get searchResults => _searchResults; - - final Map> measures; - final List _model; - List get model => _model; - - final bool hasSortFn; - final JobsSortType sortFn; final bool isLoading; - final bool isSearching; - final bool hasError; - final String? error; @override - List get props => - [model, hasSortFn, sortFn, userId, isSearching, searchResults, contacts, isLoading, hasError, error]; + List get props => [_model, userId, _contacts, isLoading]; } diff --git a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart index 9507fe0..a0c04db 100644 --- a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart +++ b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart @@ -9,13 +9,15 @@ part 'selected_contact_provider.g.dart'; @Riverpod(dependencies: [account, jobs, contacts]) Future selectedContact(SelectedContactRef ref, String id) async { final AccountEntity account = await ref.watch(accountProvider.future); - final List contacts = await ref.watch(contactsProvider.future); + final ContactEntity contact = await ref.watch( + contactsProvider.selectAsync((_) => _.firstWhere((_) => _.id == id)), + ); final List jobs = await ref.watch( jobsProvider.selectAsync((_) => _.where((_) => _.contactID == id).toList()), ); return ContactState( - contact: contacts.firstWhere((_) => _.id == id), + contact: contact, jobs: jobs, userId: account.uid, measurements: >{}, //todo diff --git a/lib/presentation/screens/jobs/job.dart b/lib/presentation/screens/jobs/job.dart index 90a47bd..3f97436 100644 --- a/lib/presentation/screens/jobs/job.dart +++ b/lib/presentation/screens/jobs/job.dart @@ -1,11 +1,12 @@ import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; +import 'providers/selected_job_provider.dart'; import 'widgets/avatar_app_bar.dart'; import 'widgets/gallery_grids.dart'; import 'widgets/payment_grids.dart'; @@ -25,93 +26,91 @@ class _JobPageState extends State { final ThemeData theme = Theme.of(context); final ColorScheme colorScheme = theme.colorScheme; - return ViewModelSubscriber( - converter: (AppState store) => JobsViewModel(store, jobID: widget.job.id), - builder: (BuildContext context, _, JobsViewModel vm) { - // in the case of newly created jobs - final JobEntity job = vm.selected ?? widget.job; - final ContactEntity? contact = vm.selectedContact; - if (vm.isLoading || contact == null) { - return const Center(child: LoadingSpinner()); - } - return Scaffold( - body: NestedScrollView( - headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { - return [ - SliverAppBar( - expandedHeight: 250.0, - flexibleSpace: FlexibleSpaceBar(background: _Header(job: job)), - pinned: true, - titleSpacing: 0.0, - elevation: 1.0, - automaticallyImplyLeading: false, - centerTitle: false, - title: _AvatarAppBar(job: job, contact: contact), - ), - ]; - }, - body: SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 16.0), - Expanded( - child: Text('DUE DATE', style: theme.textTheme.bodySmall), + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => + ref.watch(selectedJobProvider(widget.job.id)).when( + skipLoadingOnReload: true, + data: (JobState state) => Scaffold( + body: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverAppBar( + expandedHeight: 250.0, + flexibleSpace: FlexibleSpaceBar(background: _Header(job: state.job)), + pinned: true, + titleSpacing: 0.0, + elevation: 1.0, + automaticallyImplyLeading: false, + centerTitle: false, + title: _AvatarAppBar(job: state.job, contact: state.contact), ), - AppClearButton( - onPressed: job.isComplete ? null : () => _onSaveDate(job), - child: Text( - 'EXTEND DATE', - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: AppFontWeight.medium, - color: colorScheme.secondary, + ]; + }, + body: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 16.0), + Expanded( + child: Text('DUE DATE', style: theme.textTheme.bodySmall), + ), + AppClearButton( + onPressed: state.job.isComplete ? null : () => _onSaveDate(state.job), + child: Text( + 'EXTEND DATE', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: AppFontWeight.medium, + color: colorScheme.secondary, + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + AppDate(state.job.dueAt, day: 'EEEE', month: 'MMMM', year: 'yyyy').formatted!, + style: theme.textTheme.labelLarge, + ), + ), + const SizedBox(height: 4.0), + GalleryGrids(job: state.job, userId: state.userId), + const SizedBox(height: 4.0), + PaymentGrids(job: state.job, userId: state.userId), + const SizedBox(height: 32.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + state.job.notes, + style: theme.textTheme.labelLarge?.copyWith(fontWeight: AppFontWeight.light), + textAlign: TextAlign.justify, + ), ), - ), + const SizedBox(height: 48.0), + ], ), - const SizedBox(width: 16.0), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text( - AppDate(job.dueAt, day: 'EEEE', month: 'MMMM', year: 'yyyy').formatted!, - style: theme.textTheme.labelLarge, ), ), - const SizedBox(height: 4.0), - GalleryGrids(job: job, userId: vm.userId), - const SizedBox(height: 4.0), - PaymentGrids(job: job, userId: vm.userId), - const SizedBox(height: 32.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - job.notes, - style: theme.textTheme.labelLarge?.copyWith(fontWeight: AppFontWeight.light), - textAlign: TextAlign.justify, - ), - ), - const SizedBox(height: 48.0), - ], + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton.extended( + icon: Icon(state.job.isComplete ? Icons.undo : Icons.check), + backgroundColor: state.job.isComplete ? colorScheme.onSecondary : colorScheme.secondary, + foregroundColor: state.job.isComplete ? colorScheme.secondary : colorScheme.onSecondary, + label: Text(state.job.isComplete ? 'Undo Completed' : 'Mark Completed'), + onPressed: () => _onTapComplete(state.job), + ), ), + error: ErrorView.new, + loading: () => child!, ), - ), - ), - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: FloatingActionButton.extended( - icon: Icon(job.isComplete ? Icons.undo : Icons.check), - backgroundColor: job.isComplete ? colorScheme.onSecondary : colorScheme.secondary, - foregroundColor: job.isComplete ? colorScheme.secondary : colorScheme.onSecondary, - label: Text(job.isComplete ? 'Undo Completed' : 'Mark Completed'), - onPressed: () => _onTapComplete(job), - ), - ); - }, + child: const Center(child: LoadingSpinner()), ); } diff --git a/lib/presentation/screens/jobs/providers/selected_job_provider.dart b/lib/presentation/screens/jobs/providers/selected_job_provider.dart new file mode 100644 index 0000000..1076a87 --- /dev/null +++ b/lib/presentation/screens/jobs/providers/selected_job_provider.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; + +part 'selected_job_provider.g.dart'; + +@Riverpod(dependencies: [account, jobs, contacts]) +Future selectedJob(SelectedJobRef ref, String id) async { + final AccountEntity account = await ref.watch(accountProvider.future); + final List jobs = await ref.watch(jobsProvider.future); + + final JobEntity job = jobs.firstWhere((_) => _.id == id); + final ContactEntity contact = await ref.watch( + contactsProvider.selectAsync((_) => _.firstWhere((_) => _.id == job.contactID)), + ); + + return JobState( + job: job, + contact: contact, + userId: account.uid, + ); +} + +class JobState with EquatableMixin { + const JobState({ + required this.job, + required this.contact, + required this.userId, + }); + + final JobEntity job; + final ContactEntity contact; + final String userId; + + @override + List get props => [job, contact, userId]; +} From d3265a99f246093fec0028b1a8916a6f1d5b02ee Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 21:24:52 +0200 Subject: [PATCH 08/17] Introduce `tasksProvider` --- lib/presentation/rebloc.dart | 2 - .../rebloc/accounts/view_model.dart | 19 --------- lib/presentation/rebloc/jobs/view_model.dart | 34 --------------- .../screens/contacts/contacts.dart | 2 +- lib/presentation/screens/jobs/jobs.dart | 2 +- .../tasks/providers/tasks_provider.dart | 14 +++++++ lib/presentation/screens/tasks/tasks.dart | 41 +++++++++---------- 7 files changed, 36 insertions(+), 78 deletions(-) delete mode 100644 lib/presentation/rebloc/accounts/view_model.dart delete mode 100644 lib/presentation/rebloc/jobs/view_model.dart create mode 100644 lib/presentation/screens/tasks/providers/tasks_provider.dart diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index d26aff0..6bb4ff6 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -1,5 +1,4 @@ export 'rebloc/accounts/bloc.dart'; -export 'rebloc/accounts/view_model.dart'; export 'rebloc/app_state.dart'; export 'rebloc/auth/bloc.dart'; export 'rebloc/common/actions.dart'; @@ -11,7 +10,6 @@ export 'rebloc/common/state_status.dart'; export 'rebloc/contacts/bloc.dart'; export 'rebloc/extensions.dart'; export 'rebloc/jobs/bloc.dart'; -export 'rebloc/jobs/view_model.dart'; export 'rebloc/measures/bloc.dart'; export 'rebloc/measures/view_model.dart'; export 'rebloc/settings/bloc.dart'; diff --git a/lib/presentation/rebloc/accounts/view_model.dart b/lib/presentation/rebloc/accounts/view_model.dart deleted file mode 100644 index 5d336ca..0000000 --- a/lib/presentation/rebloc/accounts/view_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class AccountViewModel extends Equatable { - AccountViewModel(AppState state) - : model = state.account.account, - isLoading = state.account.status == StateStatus.loading, - hasError = state.account.status == StateStatus.failure, - error = state.account.error; - - final AccountEntity? model; - final bool isLoading; - final bool hasError; - final String? error; - - @override - List get props => [model, isLoading, hasError, error]; -} diff --git a/lib/presentation/rebloc/jobs/view_model.dart b/lib/presentation/rebloc/jobs/view_model.dart deleted file mode 100644 index 3b9dbbd..0000000 --- a/lib/presentation/rebloc/jobs/view_model.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class JobsViewModel extends Equatable { - JobsViewModel(AppState state, {this.jobID}) - : _model = state.jobs.jobs ?? [], - _contacts = state.contacts.contacts ?? [], - userId = state.account.account!.uid, - isLoading = false; - - List get tasks { - final List tasks = _model.where((JobEntity job) => !job.isComplete).toList(); - return tasks..sort((JobEntity a, JobEntity b) => a.dueAt.compareTo(b.dueAt)); - } - - JobEntity? get selected => _model.firstWhereOrNull((_) => _.id == jobID); - - ContactEntity? get selectedContact => _contacts.firstWhereOrNull((_) => _.id == selected?.contactID); - - final String? jobID; - - final String userId; - - final List _contacts; - - final List _model; - - final bool isLoading; - - @override - List get props => [_model, userId, _contacts, isLoading]; -} diff --git a/lib/presentation/screens/contacts/contacts.dart b/lib/presentation/screens/contacts/contacts.dart index f552ab1..2df3828 100644 --- a/lib/presentation/screens/contacts/contacts.dart +++ b/lib/presentation/screens/contacts/contacts.dart @@ -59,7 +59,7 @@ class _ContactsPageState extends State { }, ); }, - child: const LoadingSpinner(), + child: const Center(child: LoadingSpinner()), ); } } diff --git a/lib/presentation/screens/jobs/jobs.dart b/lib/presentation/screens/jobs/jobs.dart index c246a1b..31d7f2a 100644 --- a/lib/presentation/screens/jobs/jobs.dart +++ b/lib/presentation/screens/jobs/jobs.dart @@ -61,7 +61,7 @@ class JobsPage extends StatelessWidget { }, ); }, - child: const LoadingSpinner(), + child: const Center(child: LoadingSpinner()), ); } } diff --git a/lib/presentation/screens/tasks/providers/tasks_provider.dart b/lib/presentation/screens/tasks/providers/tasks_provider.dart new file mode 100644 index 0000000..31ab053 --- /dev/null +++ b/lib/presentation/screens/tasks/providers/tasks_provider.dart @@ -0,0 +1,14 @@ +import 'package:collection/collection.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; + +part 'tasks_provider.g.dart'; + +@Riverpod(dependencies: [jobs]) +Future> tasks(TasksRef ref) async => ref.watch( + jobsProvider.selectAsync( + (_) => _.where((_) => !_.isComplete).sorted((JobEntity a, JobEntity b) => a.dueAt.compareTo(b.dueAt)), + ), + ); diff --git a/lib/presentation/screens/tasks/tasks.dart b/lib/presentation/screens/tasks/tasks.dart index d6567c9..d9a193b 100644 --- a/lib/presentation/screens/tasks/tasks.dart +++ b/lib/presentation/screens/tasks/tasks.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; +import 'package:tailor_made/presentation/screens/tasks/providers/tasks_provider.dart'; import 'package:tailor_made/presentation/widgets.dart'; import 'widgets/task_list_item.dart'; @@ -13,26 +13,25 @@ class TasksPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: const CustomAppBar(title: Text('Tasks')), - body: ViewModelSubscriber( - converter: JobsViewModel.new, - builder: (BuildContext context, _, JobsViewModel vm) { - if (vm.isLoading) { - return const LoadingSpinner(); - } - final List tasks = vm.tasks; + body: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(tasksProvider).when( + data: (List data) { + if (data.isEmpty) { + return const Center(child: EmptyResultView(message: 'No tasks available')); + } - if (tasks.isEmpty) { - return const Center(child: EmptyResultView(message: 'No tasks available')); - } - - return ListView.separated( - itemCount: tasks.length, - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 96.0), - itemBuilder: (_, int index) => TaskListItem(task: tasks[index]), - separatorBuilder: (_, __) => const Divider(height: 0.0), - ); - }, + return ListView.separated( + itemCount: data.length, + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 96.0), + itemBuilder: (_, int index) => TaskListItem(task: data[index]), + separatorBuilder: (_, __) => const Divider(height: 0.0), + ); + }, + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), ); } From 160b83a5de8f494f093a13261e42ffcd720e7420 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 21:48:31 +0200 Subject: [PATCH 09/17] Introduce `selectedContactJobProvider` --- lib/presentation/rebloc.dart | 1 - .../rebloc/common/contact_job_view_model.dart | 29 ----- .../screens/gallery/widgets/gallery_view.dart | 61 +++++----- .../screens/payments/payment.dart | 109 +++++++++--------- lib/presentation/state.dart | 1 + .../state/selected_contact_job_provider.dart | 41 +++++++ 6 files changed, 126 insertions(+), 116 deletions(-) delete mode 100644 lib/presentation/rebloc/common/contact_job_view_model.dart create mode 100644 lib/presentation/state/selected_contact_job_provider.dart diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index 6bb4ff6..fded001 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -3,7 +3,6 @@ export 'rebloc/app_state.dart'; export 'rebloc/auth/bloc.dart'; export 'rebloc/common/actions.dart'; export 'rebloc/common/app_action.dart'; -export 'rebloc/common/contact_job_view_model.dart'; export 'rebloc/common/home_view_model.dart'; export 'rebloc/common/middleware.dart'; export 'rebloc/common/state_status.dart'; diff --git a/lib/presentation/rebloc/common/contact_job_view_model.dart b/lib/presentation/rebloc/common/contact_job_view_model.dart deleted file mode 100644 index 9a757e2..0000000 --- a/lib/presentation/rebloc/common/contact_job_view_model.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class ContactJobViewModel extends Equatable { - ContactJobViewModel( - AppState state, { - required this.contactID, - required this.jobID, - }) : _contacts = state.contacts.contacts ?? [], - _jobs = state.jobs.jobs ?? [], - account = state.account.account; - - ContactEntity? get selectedContact => _contacts.firstWhereOrNull((_) => _.id == contactID); - - JobEntity? get selectedJob => _jobs.firstWhereOrNull((_) => _.id == jobID); - - final String contactID; - final String jobID; - final AccountEntity? account; - - final List _jobs; - - final List _contacts; - - @override - List get props => [account, _jobs, _contacts]; -} diff --git a/lib/presentation/screens/gallery/widgets/gallery_view.dart b/lib/presentation/screens/gallery/widgets/gallery_view.dart index 8bc7ec1..ba4bd1f 100644 --- a/lib/presentation/screens/gallery/widgets/gallery_view.dart +++ b/lib/presentation/screens/gallery/widgets/gallery_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:rebloc/rebloc.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; @@ -18,32 +18,33 @@ class GalleryView extends StatelessWidget { @override Widget build(BuildContext context) { - return ViewModelSubscriber( - converter: (AppState store) => ContactJobViewModel( - store, - contactID: contactID, - jobID: jobID, - ), - builder: (_, __, ContactJobViewModel vm) { - return Scaffold( - appBar: _MyAppBar(contactId: vm.selectedContact?.id, job: vm.selectedJob, account: vm.account), - body: PhotoView( - imageProvider: NetworkImage(src), - loadingBuilder: (_, __) => const LoadingSpinner(), - heroAttributes: PhotoViewHeroAttributes(tag: src), + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref + .watch(selectedContactJobProvider(contactId: contactID, jobId: jobID)) + .when( + skipLoadingOnReload: true, + data: (ContactJobState state) => Scaffold( + appBar: _MyAppBar(contactId: state.selectedContact.id, job: state.selectedJob, account: state.account), + body: PhotoView( + imageProvider: NetworkImage(src), + loadingBuilder: (_, __) => const LoadingSpinner(), + heroAttributes: PhotoViewHeroAttributes(tag: src), + ), + ), + error: ErrorView.new, + loading: () => child!, ), - ); - }, + child: const Center(child: LoadingSpinner()), ); } } class _MyAppBar extends StatelessWidget implements PreferredSizeWidget { - const _MyAppBar({this.contactId, this.job, required this.account}); + const _MyAppBar({required this.contactId, required this.job, required this.account}); - final String? contactId; - final JobEntity? job; - final AccountEntity? account; + final String contactId; + final JobEntity job; + final AccountEntity account; @override Widget build(BuildContext context) { @@ -58,17 +59,15 @@ class _MyAppBar extends StatelessWidget implements PreferredSizeWidget { children: [ const AppBackButton(color: iconColor), const Expanded(child: SizedBox()), - if (job case final JobEntity job) - IconButton( - icon: const Icon(Icons.work, color: iconColor), - onPressed: () => context.registry.get().toJob(job), - ), - if (contactId case final String contactId) - IconButton( - icon: const Icon(Icons.person, color: iconColor), - onPressed: () => context.registry.get().toContact(contactId), - ), - if (account!.hasPremiumEnabled) + IconButton( + icon: const Icon(Icons.work, color: iconColor), + onPressed: () => context.registry.get().toJob(job), + ), + IconButton( + icon: const Icon(Icons.person, color: iconColor), + onPressed: () => context.registry.get().toContact(contactId), + ), + if (account.hasPremiumEnabled) const IconButton( icon: Icon(Icons.share, color: iconColor), // TODO(Jogboms): Handle diff --git a/lib/presentation/screens/payments/payment.dart b/lib/presentation/screens/payments/payment.dart index b8f00a4..a35bf13 100644 --- a/lib/presentation/screens/payments/payment.dart +++ b/lib/presentation/screens/payments/payment.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; @@ -15,64 +15,63 @@ class PaymentPage extends StatelessWidget { final String price = AppMoney(payment.price).formatted; final String? date = AppDate(payment.createdAt, day: 'EEEE', month: 'MMMM').formatted; - return ViewModelSubscriber( - converter: (AppState store) => ContactJobViewModel( - store, - contactID: payment.contactID, - jobID: payment.jobID, - ), - builder: (BuildContext context, __, ContactJobViewModel vm) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0.0, - actions: [ - if (vm.selectedJob case final JobEntity job) - IconButton( - icon: const Icon(Icons.work), - onPressed: () => context.registry.get().toJob(job), - ), - if (vm.selectedContact?.id case final String contactId) - IconButton( - icon: const Icon(Icons.person), - onPressed: () => context.registry.get().toContact(contactId), - ), - if (vm.account!.hasPremiumEnabled) - const IconButton( - icon: Icon(Icons.share), - onPressed: null, - ), - ], - ), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: 180.0, - width: double.infinity, - child: Center(child: Text(price, style: textTheme.displayMedium)), + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref + .watch(selectedContactJobProvider(contactId: payment.contactID, jobId: payment.jobID)) + .when( + skipLoadingOnReload: true, + data: (ContactJobState state) => Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0.0, + actions: [ + IconButton( + icon: const Icon(Icons.work), + onPressed: () => context.registry.get().toJob(state.selectedJob), + ), + IconButton( + icon: const Icon(Icons.person), + onPressed: () => context.registry.get().toContact(state.selectedContact.id), + ), + if (state.account.hasPremiumEnabled) + const IconButton( + icon: Icon(Icons.share), + onPressed: null, + ), + ], ), - if (date != null) - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - date, - style: textTheme.labelLarge?.copyWith(fontWeight: AppFontWeight.light), - textAlign: TextAlign.justify, + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 180.0, + width: double.infinity, + child: Center(child: Text(price, style: textTheme.displayMedium)), + ), + if (date != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + date, + style: textTheme.labelLarge?.copyWith(fontWeight: AppFontWeight.light), + textAlign: TextAlign.justify, + ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + payment.notes, + style: textTheme.labelLarge?.copyWith(fontWeight: AppFontWeight.light), + textAlign: TextAlign.justify, + ), ), - ), - Padding( - padding: const EdgeInsets.all(24.0), - child: Text( - payment.notes, - style: textTheme.labelLarge?.copyWith(fontWeight: AppFontWeight.light), - textAlign: TextAlign.justify, - ), + ], ), - ], + ), + error: ErrorView.new, + loading: () => child!, ), - ); - }, + child: const Center(child: LoadingSpinner()), ); } } diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index b2610f9..d4246de 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -2,4 +2,5 @@ export 'state/account_provider.dart'; export 'state/contacts_provider.dart'; export 'state/jobs_provider.dart'; export 'state/registry_provider.dart'; +export 'state/selected_contact_job_provider.dart'; export 'state/state_notifier_mixin.dart'; diff --git a/lib/presentation/state/selected_contact_job_provider.dart b/lib/presentation/state/selected_contact_job_provider.dart new file mode 100644 index 0000000..bc75d17 --- /dev/null +++ b/lib/presentation/state/selected_contact_job_provider.dart @@ -0,0 +1,41 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'contacts_provider.dart'; +import 'jobs_provider.dart'; + +part 'selected_contact_job_provider.g.dart'; + +@Riverpod(dependencies: [account, contacts, jobs]) +Future selectedContactJob( + SelectedContactJobRef ref, { + required String contactId, + required String jobId, +}) async { + final AccountEntity account = await ref.watch(accountProvider.future); + final ContactEntity contact = await ref.watch( + contactsProvider.selectAsync((_) => _.firstWhere((_) => _.id == contactId)), + ); + final JobEntity job = await ref.watch( + jobsProvider.selectAsync((_) => _.firstWhere((_) => _.id == jobId)), + ); + + return ContactJobState( + selectedContact: contact, + selectedJob: job, + account: account, + ); +} + +class ContactJobState { + const ContactJobState({ + required this.selectedContact, + required this.selectedJob, + required this.account, + }); + + final ContactEntity selectedContact; + final JobEntity selectedJob; + final AccountEntity account; +} From b25cb2e3199b3943bd8eba128350fb4bc745b4e6 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 22:36:03 +0200 Subject: [PATCH 10/17] Introduce `homeNotifierProvider` --- lib/presentation/rebloc.dart | 1 - lib/presentation/rebloc/accounts/actions.dart | 2 - lib/presentation/rebloc/accounts/bloc.dart | 21 ---- .../rebloc/common/home_view_model.dart | 39 ------- .../screens/homepage/homepage.dart | 46 ++++---- .../providers/home_notifier_provider.dart | 107 ++++++++++++++++++ 6 files changed, 133 insertions(+), 83 deletions(-) delete mode 100644 lib/presentation/rebloc/common/home_view_model.dart create mode 100644 lib/presentation/screens/homepage/providers/home_notifier_provider.dart diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index fded001..34e8545 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -3,7 +3,6 @@ export 'rebloc/app_state.dart'; export 'rebloc/auth/bloc.dart'; export 'rebloc/common/actions.dart'; export 'rebloc/common/app_action.dart'; -export 'rebloc/common/home_view_model.dart'; export 'rebloc/common/middleware.dart'; export 'rebloc/common/state_status.dart'; export 'rebloc/contacts/bloc.dart'; diff --git a/lib/presentation/rebloc/accounts/actions.dart b/lib/presentation/rebloc/accounts/actions.dart index 18ad39c..ae1aa5b 100644 --- a/lib/presentation/rebloc/accounts/actions.dart +++ b/lib/presentation/rebloc/accounts/actions.dart @@ -3,8 +3,6 @@ part of 'bloc.dart'; @freezed class AccountAction with _$AccountAction, AppAction { const factory AccountAction.init(String userId) = _InitAccountAction; - const factory AccountAction.premiumSignUp(AccountEntity payload) = _OnPremiumSignUp; const factory AccountAction.readNotice(AccountEntity payload) = _OnReadNotice; const factory AccountAction.sendRating(AccountEntity account, int rating) = _OnSendRating; - const factory AccountAction.skippedPremium() = _OnSkipedPremium; } diff --git a/lib/presentation/rebloc/accounts/bloc.dart b/lib/presentation/rebloc/accounts/bloc.dart index 9ab722e..604ac2c 100644 --- a/lib/presentation/rebloc/accounts/bloc.dart +++ b/lib/presentation/rebloc/accounts/bloc.dart @@ -21,7 +21,6 @@ class AccountBloc extends SimpleBloc { input.whereAction<_InitAccountAction>().switchMap(_getAccount(accounts)), input.whereAction<_OnReadNotice>().switchMap(_readNotice(accounts)), input.whereAction<_OnSendRating>().switchMap(_sendRating(accounts)), - input.whereAction<_OnPremiumSignUp>().switchMap(_signUp(accounts)), ]).untilAction().listen((WareContext context) => context.dispatcher(context.action)); return input; @@ -40,12 +39,6 @@ class AccountBloc extends SimpleBloc { ); } - if (action is _OnSkipedPremium) { - return state.copyWith( - account: account.copyWith(hasSkipedPremium: true), - ); - } - return state; } } @@ -80,20 +73,6 @@ Middleware _sendRating(Accounts accounts) { }; } -Middleware _signUp(Accounts accounts) { - return (WareContext context) async* { - final AccountEntity account = (context.action as _OnPremiumSignUp).payload.copyWith( - status: AccountStatus.pending, - notice: context.state.settings.settings!.premiumNotice, - hasReadNotice: false, - hasPremiumEnabled: true, - ); - await accounts.signUp(account); - - yield context; - }; -} - Middleware _getAccount(Accounts accounts) { return (WareContext context) async* { final AccountEntity? account = await accounts.getAccount((context.action as _InitAccountAction).userId); diff --git a/lib/presentation/rebloc/common/home_view_model.dart b/lib/presentation/rebloc/common/home_view_model.dart deleted file mode 100644 index f7e21c1..0000000 --- a/lib/presentation/rebloc/common/home_view_model.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class HomeViewModel extends Equatable { - HomeViewModel(AppState state) - : account = state.account.account, - _contacts = state.contacts.contacts ?? [], - _jobs = state.jobs.jobs ?? [], - stats = state.stats.stats, - settings = state.settings.settings, - hasSkippedPremium = state.account.hasSkipedPremium == true, - isLoading = state.stats.status == StateStatus.loading || state.account.status == StateStatus.loading; - - final AccountEntity? account; - - final List _contacts; - - List get contacts => _contacts; - - final List _jobs; - - final StatsEntity? stats; - final SettingEntity? settings; - final bool isLoading; - final bool hasSkippedPremium; - - bool get isDisabled => account != null && account!.status == AccountStatus.disabled; - - bool get isWarning => account != null && account!.status == AccountStatus.warning; - - bool get isPending => account != null && account!.status == AccountStatus.pending; - - bool get shouldSendRating => - account != null && !account!.hasSendRating && (contacts.length >= 10 || _jobs.length >= 10); - - @override - List get props => [stats, settings, hasSkippedPremium, isLoading, account, contacts, _jobs]; -} diff --git a/lib/presentation/screens/homepage/homepage.dart b/lib/presentation/screens/homepage/homepage.dart index ff69b40..0fecf68 100644 --- a/lib/presentation/screens/homepage/homepage.dart +++ b/lib/presentation/screens/homepage/homepage.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rebloc/rebloc.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; +import 'package:tailor_made/presentation/screens/homepage/providers/home_notifier_provider.dart'; import 'package:version/version.dart'; import 'widgets/access_denied.dart'; @@ -60,10 +62,18 @@ class HomePage extends StatelessWidget { return child!; }, - child: ViewModelSubscriber( - converter: HomeViewModel.new, - builder: (_, DispatchFunction dispatch, HomeViewModel vm) => - _Body(viewModel: vm, dispatch: dispatch, isMock: isMock), + child: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(homeNotifierProvider).when( + skipLoadingOnReload: true, + data: (HomeState state) => _Body( + state: state, + notifier: ref.read(homeNotifierProvider.notifier), + isMock: isMock, + ), + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), ), ], @@ -75,22 +85,18 @@ class HomePage extends StatelessWidget { } class _Body extends StatelessWidget { - const _Body({required this.dispatch, required this.viewModel, required this.isMock}); + const _Body({required this.state, required this.notifier, required this.isMock}); - final HomeViewModel viewModel; - final DispatchFunction dispatch; + final HomeState state; + final HomeNotifier notifier; final bool isMock; @override Widget build(BuildContext context) { - final AccountEntity? account = viewModel.account; - final StatsEntity? stats = viewModel.stats; + final AccountEntity account = state.account; + final StatsEntity stats = state.stats; - if (viewModel.isLoading || account == null || stats == null) { - return const LoadingSpinner(); - } - - if (viewModel.isDisabled) { + if (state.isDisabled) { return AccessDeniedPage( onSendMail: () { email( @@ -101,10 +107,10 @@ class _Body extends StatelessWidget { ); } - if (viewModel.isWarning && viewModel.hasSkippedPremium == false) { + if (state.isWarning && state.hasSkippedPremium == false) { return RateLimitPage( - onSignUp: () => dispatch(AccountAction.premiumSignUp(account)), - onSkippedPremium: () => dispatch(const AccountAction.skippedPremium()), + onSignUp: notifier.premiumSetup, + onSkippedPremium: notifier.skippedPremium, ); } @@ -122,12 +128,12 @@ class _Body extends StatelessWidget { SizedBox(height: Theme.of(context).buttonTheme.height + MediaQuery.of(context).padding.bottom), ], ), - CreateButton(userId: account.uid, contacts: viewModel.contacts), + CreateButton(userId: account.uid, contacts: state.contacts), TopButtonBar( account: account, - shouldSendRating: viewModel.shouldSendRating, + shouldSendRating: state.shouldSendRating, onLogout: () { - dispatch(const AuthAction.logout()); + notifier.logout(); context.registry.get().toSplash(isMock); }, ), diff --git a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart new file mode 100644 index 0000000..68bb8e2 --- /dev/null +++ b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart @@ -0,0 +1,107 @@ +import 'package:registry/registry.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:tailor_made/domain.dart'; + +import '../../../state.dart'; + +part 'home_notifier_provider.g.dart'; + +@Riverpod(dependencies: [registry, account, contacts, jobs]) +class HomeNotifier extends _$HomeNotifier { + @override + Stream build() async* { + final Registry registry = ref.read(registryProvider); + final AccountEntity account = await ref.watch(accountProvider.future); + final List contacts = await ref.watch(contactsProvider.future); + final List jobs = await ref.watch(jobsProvider.future); + + yield* CombineLatestStream.combine2( + registry.get().fetch(account.uid), //todo + registry.get().fetch(), //todo + (StatsEntity stats, SettingEntity settings) => HomeState( + account: account, + contacts: contacts, + jobs: jobs, + isLoading: false, + stats: stats, + settings: settings, + hasSkippedPremium: false, + ), + ); + } + + void premiumSetup() async { + final HomeState homeState = state.requireValue; + await ref.read(registryProvider).get().signUp( + homeState.account.copyWith( + status: AccountStatus.pending, + notice: homeState.settings.premiumNotice, + hasReadNotice: false, + hasPremiumEnabled: true, + ), + ); + ref.invalidateSelf(); + } + + void skippedPremium() { + state = AsyncValue.data( + state.requireValue.copyWith( + hasSkippedPremium: true, + ), + ); + } + + void logout() async { + await ref.read(registryProvider).get().signOut(); + ref.invalidateSelf(); + } +} + +class HomeState { + const HomeState({ + required this.account, + required this.contacts, + required this.jobs, + required this.stats, + required this.settings, + required this.isLoading, + required this.hasSkippedPremium, + }); + + final AccountEntity account; + final List contacts; + final List jobs; + final StatsEntity stats; + final SettingEntity settings; + final bool isLoading; + final bool hasSkippedPremium; + + bool get isDisabled => account.status == AccountStatus.disabled; + + bool get isWarning => account.status == AccountStatus.warning; + + bool get isPending => account.status == AccountStatus.pending; + + bool get shouldSendRating => !account.hasSendRating && (contacts.length >= 10 || jobs.length >= 10); + + HomeState copyWith({ + AccountEntity? account, + List? contacts, + List? jobs, + StatsEntity? stats, + SettingEntity? settings, + bool? isLoading, + bool? hasSkippedPremium, + }) { + return HomeState( + account: account ?? this.account, + contacts: contacts ?? this.contacts, + jobs: jobs ?? this.jobs, + stats: stats ?? this.stats, + settings: settings ?? this.settings, + isLoading: isLoading ?? this.isLoading, + hasSkippedPremium: hasSkippedPremium ?? this.hasSkippedPremium, + ); + } +} From e6a5fdbd95d06151577e05c9425c150d90c7c3ba Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 22:57:26 +0200 Subject: [PATCH 11/17] Introduce `settingsProvider` & `statsProvider` --- lib/presentation/rebloc.dart | 2 - lib/presentation/rebloc/app_state.dart | 32 ----- lib/presentation/rebloc/auth/actions.dart | 1 - lib/presentation/rebloc/auth/bloc.dart | 9 -- .../rebloc/common/initialize.dart | 23 ---- lib/presentation/rebloc/settings/bloc.dart | 17 --- .../rebloc/settings/view_model.dart | 19 --- lib/presentation/rebloc/stats/actions.dart | 6 - lib/presentation/rebloc/stats/bloc.dart | 24 ---- lib/presentation/rebloc/stats/view_model.dart | 19 --- lib/presentation/rebloc/store_factory.dart | 2 - .../screens/homepage/homepage.dart | 54 ++++---- .../providers/home_notifier_provider.dart | 30 ++--- lib/presentation/screens/splash/splash.dart | 115 +++++++++--------- lib/presentation/state.dart | 2 + lib/presentation/state/settings_provider.dart | 10 ++ lib/presentation/state/stats_provider.dart | 14 +++ 17 files changed, 123 insertions(+), 256 deletions(-) delete mode 100644 lib/presentation/rebloc/common/initialize.dart delete mode 100644 lib/presentation/rebloc/settings/view_model.dart delete mode 100644 lib/presentation/rebloc/stats/actions.dart delete mode 100644 lib/presentation/rebloc/stats/view_model.dart create mode 100644 lib/presentation/state/settings_provider.dart create mode 100644 lib/presentation/state/stats_provider.dart diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index 34e8545..152492e 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -11,9 +11,7 @@ export 'rebloc/jobs/bloc.dart'; export 'rebloc/measures/bloc.dart'; export 'rebloc/measures/view_model.dart'; export 'rebloc/settings/bloc.dart'; -export 'rebloc/settings/view_model.dart'; export 'rebloc/stats/bloc.dart'; -export 'rebloc/stats/view_model.dart'; export 'rebloc/store_factory.dart'; export 'utils/contacts_sort_type.dart'; export 'utils/jobs_sort_type.dart'; diff --git a/lib/presentation/rebloc/app_state.dart b/lib/presentation/rebloc/app_state.dart index 90d9dd6..2d8bbeb 100644 --- a/lib/presentation/rebloc/app_state.dart +++ b/lib/presentation/rebloc/app_state.dart @@ -6,33 +6,11 @@ part 'app_state.freezed.dart'; @freezed class AppState with _$AppState { const factory AppState({ - required ContactsState contacts, - required JobsState jobs, - required StatsState stats, required AccountState account, required MeasuresState measures, - required SettingsState settings, }) = _AppState; static const AppState initialState = AppState( - contacts: ContactsState( - contacts: null, - status: StateStatus.loading, - hasSortFn: true, - sortFn: ContactsSortType.names, - searchResults: null, - isSearching: false, - error: null, - ), - jobs: JobsState( - jobs: null, - status: StateStatus.loading, - hasSortFn: true, - sortFn: JobsSortType.active, - searchResults: null, - isSearching: false, - error: null, - ), account: AccountState( account: null, error: null, @@ -46,15 +24,5 @@ class AppState with _$AppState { hasSkippedPremium: false, error: null, ), - settings: SettingsState( - settings: null, - status: StateStatus.loading, - error: null, - ), - stats: StatsState( - stats: null, - status: StateStatus.loading, - error: null, - ), ); } diff --git a/lib/presentation/rebloc/auth/actions.dart b/lib/presentation/rebloc/auth/actions.dart index a22a70b..0bb7e8e 100644 --- a/lib/presentation/rebloc/auth/actions.dart +++ b/lib/presentation/rebloc/auth/actions.dart @@ -3,5 +3,4 @@ part of 'bloc.dart'; @freezed class AuthAction with _$AuthAction, AppAction { const factory AuthAction.login(String user) = _OnLoginAction; - const factory AuthAction.logout() = _OnLogoutAction; } diff --git a/lib/presentation/rebloc/auth/bloc.dart b/lib/presentation/rebloc/auth/bloc.dart index a0c50a1..76d1321 100644 --- a/lib/presentation/rebloc/auth/bloc.dart +++ b/lib/presentation/rebloc/auth/bloc.dart @@ -18,26 +18,17 @@ class AuthBloc extends SimpleBloc { if (action is _OnLoginAction) { dispatcher(AccountAction.init(action.user)); dispatcher(MeasuresAction.init(action.user)); - dispatcher(StatsAction.init(action.user)); } return action; } @override AppState reducer(AppState state, Action action) { - if (action is _OnLogoutAction) { - return AppState.initialState; - } - return state; } @override Future afterware(DispatchFunction dispatcher, AppState state, Action action) async { - if (action is _OnLogoutAction) { - await accounts.signOut(); - dispatcher(const SettingsAction.init()); - } return action; } } diff --git a/lib/presentation/rebloc/common/initialize.dart b/lib/presentation/rebloc/common/initialize.dart deleted file mode 100644 index 878c78b..0000000 --- a/lib/presentation/rebloc/common/initialize.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:async'; - -import 'package:rebloc/rebloc.dart'; - -import '../app_state.dart'; -import '../settings/bloc.dart'; -import 'actions.dart'; - -class InitializeBloc extends SimpleBloc { - @override - Future middleware(DispatchFunction dispatcher, AppState state, Action action) async { - if (action is OnInitAction) { - dispatcher(const SettingsAction.init()); - } - return action; - } - - @override - Future afterware(DispatchFunction dispatcher, AppState state, Action action) async { - if (action is OnDisposeAction) {} - return action; - } -} diff --git a/lib/presentation/rebloc/settings/bloc.dart b/lib/presentation/rebloc/settings/bloc.dart index fdbe731..f357779 100644 --- a/lib/presentation/rebloc/settings/bloc.dart +++ b/lib/presentation/rebloc/settings/bloc.dart @@ -32,23 +32,6 @@ class SettingsBloc extends SimpleBloc { @override AppState reducer(AppState state, Action action) { - final SettingsState settings = state.settings; - - if (action is OnDataAction) { - return state.copyWith( - settings: settings.copyWith( - settings: action.payload, - status: StateStatus.success, - ), - ); - } - - if (action is _OnErrorSettingsAction) { - return state.copyWith( - settings: settings.copyWith(status: StateStatus.failure), - ); - } - return state; } } diff --git a/lib/presentation/rebloc/settings/view_model.dart b/lib/presentation/rebloc/settings/view_model.dart deleted file mode 100644 index 09552a4..0000000 --- a/lib/presentation/rebloc/settings/view_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class SettingsViewModel extends Equatable { - SettingsViewModel(AppState state) - : model = state.settings.settings, - isLoading = state.settings.status == StateStatus.loading, - hasError = state.settings.status == StateStatus.failure, - error = state.settings.error; - - final SettingEntity? model; - final bool isLoading; - final bool hasError; - final String? error; - - @override - List get props => [model, isLoading, hasError, error]; -} diff --git a/lib/presentation/rebloc/stats/actions.dart b/lib/presentation/rebloc/stats/actions.dart deleted file mode 100644 index 342e1da..0000000 --- a/lib/presentation/rebloc/stats/actions.dart +++ /dev/null @@ -1,6 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class StatsAction with _$StatsAction, AppAction { - const factory StatsAction.init(String userId) = _InitStatsAction; -} diff --git a/lib/presentation/rebloc/stats/bloc.dart b/lib/presentation/rebloc/stats/bloc.dart index 0214e25..2b824af 100644 --- a/lib/presentation/rebloc/stats/bloc.dart +++ b/lib/presentation/rebloc/stats/bloc.dart @@ -1,10 +1,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:rebloc/rebloc.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation/rebloc.dart'; -part 'actions.dart'; part 'bloc.freezed.dart'; part 'state.dart'; @@ -15,33 +13,11 @@ class StatsBloc extends SimpleBloc { @override Stream> applyMiddleware(Stream> input) { - input - .whereAction<_InitStatsAction>() - .switchMap( - (WareContext context) => stats - .fetch((context.action as _InitStatsAction).userId) - .map(OnDataAction.new) - .map((OnDataAction action) => context.copyWith(action)), - ) - .untilAction() - .listen((WareContext context) => context.dispatcher(context.action)); - return input; } @override AppState reducer(AppState state, Action action) { - final StatsState stats = state.stats; - - if (action is OnDataAction) { - return state.copyWith( - stats: stats.copyWith( - stats: action.payload, - status: StateStatus.success, - ), - ); - } - return state; } } diff --git a/lib/presentation/rebloc/stats/view_model.dart b/lib/presentation/rebloc/stats/view_model.dart deleted file mode 100644 index c325bbc..0000000 --- a/lib/presentation/rebloc/stats/view_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class StatsViewModel extends Equatable { - StatsViewModel(AppState state) - : model = state.stats.stats, - isLoading = state.stats.status == StateStatus.loading, - hasError = state.stats.status == StateStatus.failure, - error = state.stats.error; - - final StatsEntity? model; - final bool isLoading; - final bool hasError; - final String? error; - - @override - List get props => [model, isLoading, hasError, error]; -} diff --git a/lib/presentation/rebloc/store_factory.dart b/lib/presentation/rebloc/store_factory.dart index 1108e2d..3eef6cc 100644 --- a/lib/presentation/rebloc/store_factory.dart +++ b/lib/presentation/rebloc/store_factory.dart @@ -5,7 +5,6 @@ import 'package:tailor_made/core.dart'; import 'accounts/bloc.dart'; import 'app_state.dart'; import 'auth/bloc.dart'; -import 'common/initialize.dart'; import 'common/logger.dart'; import 'measures/bloc.dart'; import 'settings/bloc.dart'; @@ -15,7 +14,6 @@ Store storeFactory(Registry registry) { return Store( initialState: AppState.initialState, blocs: >[ - InitializeBloc(), AuthBloc(registry.get()), AccountBloc(registry.get()), MeasuresBloc(registry.get()), diff --git a/lib/presentation/screens/homepage/homepage.dart b/lib/presentation/screens/homepage/homepage.dart index 0fecf68..7f65b72 100644 --- a/lib/presentation/screens/homepage/homepage.dart +++ b/lib/presentation/screens/homepage/homepage.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:rebloc/rebloc.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; import 'package:tailor_made/presentation/screens/homepage/providers/home_notifier_provider.dart'; @@ -40,41 +39,40 @@ class HomePage extends StatelessWidget { ), ), ), - AppVersionBuilder( - valueBuilder: () => AppVersion.retrieve(isMock), - builder: (BuildContext context, String? appVersion, Widget? child) { - if (appVersion == null) { - return child!; - } + Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(homeNotifierProvider).when( + skipLoadingOnReload: true, + data: (HomeState state) => AppVersionBuilder( + valueBuilder: () => AppVersion.retrieve(isMock), + builder: (BuildContext context, String? appVersion, Widget? child) { + if (appVersion == null) { + return child!; + } - final Version currentVersion = Version.parse(appVersion); - final AppState state = StoreProvider.of(context).states.valueWrapper!.value; - final Version latestVersion = Version.parse(state.settings.settings?.versionName ?? '1.0.0'); + final Version currentVersion = Version.parse(appVersion); + final Version latestVersion = Version.parse(state.settings.versionName); - if (latestVersion > currentVersion) { - return OutDatedPage( - onUpdate: () { - // TODO(Jogboms): take note for apple if that ever happens - open('https://play.google.com/store/apps/details?id=io.github.jogboms.tailormade'); - }, - ); - } + if (latestVersion > currentVersion) { + return OutDatedPage( + onUpdate: () { + // TODO(Jogboms): take note for apple if that ever happens + open('https://play.google.com/store/apps/details?id=io.github.jogboms.tailormade'); + }, + ); + } - return child!; - }, - child: Consumer( - builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(homeNotifierProvider).when( - skipLoadingOnReload: true, - data: (HomeState state) => _Body( + return child!; + }, + child: _Body( state: state, notifier: ref.read(homeNotifierProvider.notifier), isMock: isMock, ), - error: ErrorView.new, - loading: () => child!, ), - child: const Center(child: LoadingSpinner()), - ), + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), ], ), diff --git a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart index 68bb8e2..66cd57b 100644 --- a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart +++ b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart @@ -1,33 +1,28 @@ -import 'package:registry/registry.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:tailor_made/domain.dart'; import '../../../state.dart'; part 'home_notifier_provider.g.dart'; -@Riverpod(dependencies: [registry, account, contacts, jobs]) +@Riverpod(dependencies: [registry, account, contacts, jobs, settings, stats]) class HomeNotifier extends _$HomeNotifier { @override Stream build() async* { - final Registry registry = ref.read(registryProvider); final AccountEntity account = await ref.watch(accountProvider.future); final List contacts = await ref.watch(contactsProvider.future); final List jobs = await ref.watch(jobsProvider.future); + final SettingEntity settings = await ref.watch(settingsProvider.future); + final StatsEntity stats = await ref.watch(statsProvider.future); - yield* CombineLatestStream.combine2( - registry.get().fetch(account.uid), //todo - registry.get().fetch(), //todo - (StatsEntity stats, SettingEntity settings) => HomeState( - account: account, - contacts: contacts, - jobs: jobs, - isLoading: false, - stats: stats, - settings: settings, - hasSkippedPremium: false, - ), + yield HomeState( + account: account, + contacts: contacts, + jobs: jobs, + isLoading: false, + stats: stats, + settings: settings, + hasSkippedPremium: false, ); } @@ -41,6 +36,7 @@ class HomeNotifier extends _$HomeNotifier { hasPremiumEnabled: true, ), ); + ref.invalidate(accountProvider); ref.invalidateSelf(); } @@ -54,7 +50,7 @@ class HomeNotifier extends _$HomeNotifier { void logout() async { await ref.read(registryProvider).get().signOut(); - ref.invalidateSelf(); + ref.invalidate(accountProvider); } } diff --git a/lib/presentation/screens/splash/splash.dart b/lib/presentation/screens/splash/splash.dart index 9422c1a..74d4cb5 100644 --- a/lib/presentation/screens/splash/splash.dart +++ b/lib/presentation/screens/splash/splash.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rebloc/rebloc.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; @@ -98,66 +99,66 @@ class _ContentState extends State<_Content> { @override Widget build(BuildContext context) { - return ViewModelSubscriber( - converter: SettingsViewModel.new, - builder: (BuildContext context, DispatchFunction dispatch, SettingsViewModel vm) { - return Stack( - children: [ - if (!_isLoading || !widget.isColdStart || vm.hasError) - const Center( - child: Image( - image: AppImages.logo, - width: 148.0, - color: Colors.white30, - colorBlendMode: BlendMode.saturation, - ), - ), - Positioned.fill( - top: null, - bottom: 124.0, - child: Builder( - builder: (_) { - if ((vm.isLoading && widget.isColdStart) || _isLoading) { - return const LoadingSpinner(); - } - - if (vm.hasError) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 48.0, vertical: 16.0), - child: Center( - child: Column( - children: [ - Text(vm.error.toString(), textAlign: TextAlign.center), - const SizedBox(height: 8.0), - ElevatedButton( - child: const Text('RETRY'), - onPressed: () => dispatch(const SettingsAction.init()), - ), - ], - ), - ), - ); - } - - return Center( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - ), - icon: const Image(image: AppImages.googleLogo, width: 24.0), - label: Text( - 'Continue with Google', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: AppFontWeight.bold), + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(settingsProvider).when( + data: (SettingEntity data) { + return Stack( + children: [ + if (!_isLoading || !widget.isColdStart) + const Center( + child: Image( + image: AppImages.logo, + width: 148.0, + color: Colors.white30, + colorBlendMode: BlendMode.saturation, ), - onPressed: _onLogin, ), - ); - }, + Positioned.fill( + top: null, + bottom: 124.0, + child: Builder( + builder: (_) { + if ((widget.isColdStart) || _isLoading) { + return const LoadingSpinner(); + } + + return Center( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + ), + icon: const Image(image: AppImages.googleLogo, width: 24.0), + label: Text( + 'Continue with Google', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: AppFontWeight.bold), + ), + onPressed: _onLogin, + ), + ); + }, + ), + ) + ], + ); + }, + error: (Object error, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 48.0, vertical: 16.0), + child: Center( + child: Column( + children: [ + Text(error.toString(), textAlign: TextAlign.center), + const SizedBox(height: 8.0), + ElevatedButton( + child: const Text('RETRY'), + onPressed: () => ref.invalidate(settingsProvider), + ), + ], + ), ), - ) - ], - ); - }, + ), + loading: () => child!, + ), + child: const LoadingSpinner(), ); } diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index d4246de..407c84b 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -3,4 +3,6 @@ export 'state/contacts_provider.dart'; export 'state/jobs_provider.dart'; export 'state/registry_provider.dart'; export 'state/selected_contact_job_provider.dart'; +export 'state/settings_provider.dart'; export 'state/state_notifier_mixin.dart'; +export 'state/stats_provider.dart'; diff --git a/lib/presentation/state/settings_provider.dart b/lib/presentation/state/settings_provider.dart new file mode 100644 index 0000000..31b4b69 --- /dev/null +++ b/lib/presentation/state/settings_provider.dart @@ -0,0 +1,10 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; + +part 'settings_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream settings(SettingsRef ref) => ref.read(registryProvider).get().fetch(); diff --git a/lib/presentation/state/stats_provider.dart b/lib/presentation/state/stats_provider.dart new file mode 100644 index 0000000..2657e9f --- /dev/null +++ b/lib/presentation/state/stats_provider.dart @@ -0,0 +1,14 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; + +part 'stats_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream stats(StatsRef ref) async* { + final AccountEntity account = await ref.watch(accountProvider.future); + + yield* ref.read(registryProvider).get().fetch(account.uid); +} From 8674b9c7fdce7c002b791477b751663ddc45af43 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sat, 1 Jul 2023 23:14:05 +0200 Subject: [PATCH 12/17] Clean up blocs --- lib/presentation/app.dart | 10 +---- lib/presentation/rebloc.dart | 4 -- lib/presentation/rebloc/accounts/actions.dart | 1 - lib/presentation/rebloc/accounts/bloc.dart | 30 ++------------- lib/presentation/rebloc/app_state.dart | 7 ---- lib/presentation/rebloc/auth/bloc.dart | 29 --------------- lib/presentation/rebloc/common/actions.dart | 6 --- lib/presentation/rebloc/common/logger.dart | 27 -------------- lib/presentation/rebloc/contacts/bloc.dart | 6 --- lib/presentation/rebloc/contacts/state.dart | 14 ------- lib/presentation/rebloc/extensions.dart | 4 -- lib/presentation/rebloc/jobs/bloc.dart | 6 --- lib/presentation/rebloc/jobs/state.dart | 14 ------- lib/presentation/rebloc/measures/bloc.dart | 4 +- .../rebloc/measures/view_model.dart | 2 +- lib/presentation/rebloc/settings/actions.dart | 7 ---- lib/presentation/rebloc/settings/bloc.dart | 37 ------------------- lib/presentation/rebloc/settings/state.dart | 10 ----- lib/presentation/rebloc/stats/bloc.dart | 23 ------------ lib/presentation/rebloc/stats/state.dart | 10 ----- lib/presentation/rebloc/store_factory.dart | 9 ----- .../providers/home_notifier_provider.dart | 2 + 22 files changed, 9 insertions(+), 253 deletions(-) delete mode 100644 lib/presentation/rebloc/common/logger.dart delete mode 100644 lib/presentation/rebloc/contacts/bloc.dart delete mode 100644 lib/presentation/rebloc/contacts/state.dart delete mode 100644 lib/presentation/rebloc/jobs/bloc.dart delete mode 100644 lib/presentation/rebloc/jobs/state.dart delete mode 100644 lib/presentation/rebloc/settings/actions.dart delete mode 100644 lib/presentation/rebloc/settings/bloc.dart delete mode 100644 lib/presentation/rebloc/settings/state.dart delete mode 100644 lib/presentation/rebloc/stats/bloc.dart delete mode 100644 lib/presentation/rebloc/stats/state.dart diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index a408143..a8e1d04 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -34,17 +34,9 @@ class _AppState extends State { late final Environment environment = widget.registry.get(); late final String bannerMessage = environment.name.toUpperCase(); - @override - void initState() { - super.initState(); - widget.store.dispatch(const CommonAction.init()); - } - @override void dispose() { - widget.store - ..dispatch(const CommonAction.dispose()) - ..dispose(); + widget.store.dispose(); super.dispose(); } diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index 152492e..3b30441 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -5,13 +5,9 @@ export 'rebloc/common/actions.dart'; export 'rebloc/common/app_action.dart'; export 'rebloc/common/middleware.dart'; export 'rebloc/common/state_status.dart'; -export 'rebloc/contacts/bloc.dart'; export 'rebloc/extensions.dart'; -export 'rebloc/jobs/bloc.dart'; export 'rebloc/measures/bloc.dart'; export 'rebloc/measures/view_model.dart'; -export 'rebloc/settings/bloc.dart'; -export 'rebloc/stats/bloc.dart'; export 'rebloc/store_factory.dart'; export 'utils/contacts_sort_type.dart'; export 'utils/jobs_sort_type.dart'; diff --git a/lib/presentation/rebloc/accounts/actions.dart b/lib/presentation/rebloc/accounts/actions.dart index ae1aa5b..c30ee0b 100644 --- a/lib/presentation/rebloc/accounts/actions.dart +++ b/lib/presentation/rebloc/accounts/actions.dart @@ -2,7 +2,6 @@ part of 'bloc.dart'; @freezed class AccountAction with _$AccountAction, AppAction { - const factory AccountAction.init(String userId) = _InitAccountAction; const factory AccountAction.readNotice(AccountEntity payload) = _OnReadNotice; const factory AccountAction.sendRating(AccountEntity account, int rating) = _OnSendRating; } diff --git a/lib/presentation/rebloc/accounts/bloc.dart b/lib/presentation/rebloc/accounts/bloc.dart index 604ac2c..d64dbbd 100644 --- a/lib/presentation/rebloc/accounts/bloc.dart +++ b/lib/presentation/rebloc/accounts/bloc.dart @@ -18,31 +18,15 @@ class AccountBloc extends SimpleBloc { @override Stream> applyMiddleware(Stream> input) { MergeStream>(>>[ - input.whereAction<_InitAccountAction>().switchMap(_getAccount(accounts)), input.whereAction<_OnReadNotice>().switchMap(_readNotice(accounts)), input.whereAction<_OnSendRating>().switchMap(_sendRating(accounts)), - ]).untilAction().listen((WareContext context) => context.dispatcher(context.action)); + ]).listen((WareContext context) => context.dispatcher(context.action)); return input; } - - @override - AppState reducer(AppState state, Action action) { - final AccountState account = state.account; - - if (action is OnDataAction) { - return state.copyWith( - account: account.copyWith( - account: action.payload, - status: StateStatus.success, - ), - ); - } - - return state; - } } +//todo: move to accountProvider Middleware _readNotice(Accounts accounts) { return (WareContext context) async* { final AccountEntity account = (context.action as _OnReadNotice).payload; @@ -57,6 +41,7 @@ Middleware _readNotice(Accounts accounts) { }; } +//todo: move to accountProvider Middleware _sendRating(Accounts accounts) { return (WareContext context) async* { final _OnSendRating action = context.action as _OnSendRating; @@ -72,12 +57,3 @@ Middleware _sendRating(Accounts accounts) { yield context; }; } - -Middleware _getAccount(Accounts accounts) { - return (WareContext context) async* { - final AccountEntity? account = await accounts.getAccount((context.action as _InitAccountAction).userId); - if (account != null) { - yield context.copyWith(OnDataAction(account)); - } - }; -} diff --git a/lib/presentation/rebloc/app_state.dart b/lib/presentation/rebloc/app_state.dart index 2d8bbeb..94f8634 100644 --- a/lib/presentation/rebloc/app_state.dart +++ b/lib/presentation/rebloc/app_state.dart @@ -6,17 +6,10 @@ part 'app_state.freezed.dart'; @freezed class AppState with _$AppState { const factory AppState({ - required AccountState account, required MeasuresState measures, }) = _AppState; static const AppState initialState = AppState( - account: AccountState( - account: null, - error: null, - status: StateStatus.loading, - hasSkipedPremium: false, - ), measures: MeasuresState( measures: null, grouped: null, diff --git a/lib/presentation/rebloc/auth/bloc.dart b/lib/presentation/rebloc/auth/bloc.dart index 76d1321..cee47a3 100644 --- a/lib/presentation/rebloc/auth/bloc.dart +++ b/lib/presentation/rebloc/auth/bloc.dart @@ -1,34 +1,5 @@ -import 'dart:async'; - import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation/rebloc.dart'; part 'actions.dart'; part 'bloc.freezed.dart'; - -class AuthBloc extends SimpleBloc { - AuthBloc(this.accounts); - - final Accounts accounts; - - @override - Future middleware(DispatchFunction dispatcher, AppState state, Action action) async { - if (action is _OnLoginAction) { - dispatcher(AccountAction.init(action.user)); - dispatcher(MeasuresAction.init(action.user)); - } - return action; - } - - @override - AppState reducer(AppState state, Action action) { - return state; - } - - @override - Future afterware(DispatchFunction dispatcher, AppState state, Action action) async { - return action; - } -} diff --git a/lib/presentation/rebloc/common/actions.dart b/lib/presentation/rebloc/common/actions.dart index aa785a8..605ad56 100644 --- a/lib/presentation/rebloc/common/actions.dart +++ b/lib/presentation/rebloc/common/actions.dart @@ -3,12 +3,6 @@ import 'package:tailor_made/presentation/rebloc.dart'; part 'actions.freezed.dart'; -@freezed -class CommonAction with _$CommonAction, AppAction { - const factory CommonAction.init() = OnInitAction; - const factory CommonAction.dispose() = OnDisposeAction; -} - @freezed class CommonActionData with _$CommonActionData, AppAction { const factory CommonActionData.data(T payload) = OnDataAction; diff --git a/lib/presentation/rebloc/common/logger.dart b/lib/presentation/rebloc/common/logger.dart deleted file mode 100644 index b583001..0000000 --- a/lib/presentation/rebloc/common/logger.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:async' show Future; - -import 'package:flutter/foundation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class LoggerBloc extends SimpleBloc { - LoggerBloc(this.isTesting); - - final bool isTesting; - - @override - Future afterware(DispatchFunction dispatcher, AppState state, Action action) async { - if (!isTesting) { - debugPrint(state.toString()); - } - return action; - } - - @override - Future middleware(DispatchFunction dispatcher, AppState state, Action action) async { - if (!isTesting) { - debugPrint('[ReBLoC]: ${action.runtimeType}'); - } - return action; - } -} diff --git a/lib/presentation/rebloc/contacts/bloc.dart b/lib/presentation/rebloc/contacts/bloc.dart deleted file mode 100644 index 07d29c2..0000000 --- a/lib/presentation/rebloc/contacts/bloc.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'bloc.freezed.dart'; -part 'state.dart'; diff --git a/lib/presentation/rebloc/contacts/state.dart b/lib/presentation/rebloc/contacts/state.dart deleted file mode 100644 index 875f927..0000000 --- a/lib/presentation/rebloc/contacts/state.dart +++ /dev/null @@ -1,14 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class ContactsState with _$ContactsState { - const factory ContactsState({ - required List? contacts, - required StateStatus status, - required bool hasSortFn, - required ContactsSortType sortFn, - required List? searchResults, - required bool isSearching, - required String? error, - }) = _ContactsState; -} diff --git a/lib/presentation/rebloc/extensions.dart b/lib/presentation/rebloc/extensions.dart index c2569ae..f1a7201 100644 --- a/lib/presentation/rebloc/extensions.dart +++ b/lib/presentation/rebloc/extensions.dart @@ -3,7 +3,3 @@ import 'package:rebloc/rebloc.dart'; extension ObservableExtensions on Stream> { Stream> whereAction() => where((WareContext context) => context.action is U); } - -extension StreamExtensions on Stream> { - Stream> untilAction() => takeWhile((WareContext context) => context.action is! U); -} diff --git a/lib/presentation/rebloc/jobs/bloc.dart b/lib/presentation/rebloc/jobs/bloc.dart deleted file mode 100644 index 07d29c2..0000000 --- a/lib/presentation/rebloc/jobs/bloc.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'bloc.freezed.dart'; -part 'state.dart'; diff --git a/lib/presentation/rebloc/jobs/state.dart b/lib/presentation/rebloc/jobs/state.dart deleted file mode 100644 index b2759cf..0000000 --- a/lib/presentation/rebloc/jobs/state.dart +++ /dev/null @@ -1,14 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class JobsState with _$JobsState { - const factory JobsState({ - required List? jobs, - required bool hasSortFn, - required JobsSortType sortFn, - required List? searchResults, - required bool isSearching, - required StateStatus status, - required String? error, - }) = _JobsState; -} diff --git a/lib/presentation/rebloc/measures/bloc.dart b/lib/presentation/rebloc/measures/bloc.dart index 7294327..f7c3010 100644 --- a/lib/presentation/rebloc/measures/bloc.dart +++ b/lib/presentation/rebloc/measures/bloc.dart @@ -19,7 +19,7 @@ class MeasuresBloc extends SimpleBloc { MergeStream>(>>[ input.whereAction<_UpdateMeasureAction>().switchMap(_onUpdateMeasure(measures)), input.whereAction<_InitMeasuresAction>().switchMap(_onInitMeasure(measures)), - ]).untilAction().listen((WareContext context) => context.dispatcher(context.action)); + ]).listen((WareContext context) => context.dispatcher(context.action)); return input; } @@ -77,7 +77,7 @@ Middleware _onInitMeasure(Measures measures) { final String userId = (context.action as _InitMeasuresAction).userId; return measures.fetchAll(userId).map((List measures) { if (measures.isEmpty) { - return _UpdateMeasureAction(BaseMeasureEntity.defaults, userId); + return MeasuresAction.update(BaseMeasureEntity.defaults, userId); } return OnDataAction<_Union>( diff --git a/lib/presentation/rebloc/measures/view_model.dart b/lib/presentation/rebloc/measures/view_model.dart index e7d3d2b..d6853ec 100644 --- a/lib/presentation/rebloc/measures/view_model.dart +++ b/lib/presentation/rebloc/measures/view_model.dart @@ -6,7 +6,7 @@ class MeasuresViewModel extends Equatable { MeasuresViewModel(AppState state) : _model = state.measures.measures ?? [], grouped = state.measures.grouped ?? >{}, - userId = state.account.account!.uid, + userId = '1', //todo isLoading = state.measures.status == StateStatus.loading, hasError = state.measures.status == StateStatus.failure, error = state.measures.error; diff --git a/lib/presentation/rebloc/settings/actions.dart b/lib/presentation/rebloc/settings/actions.dart deleted file mode 100644 index f52e8fd..0000000 --- a/lib/presentation/rebloc/settings/actions.dart +++ /dev/null @@ -1,7 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class SettingsAction with _$SettingsAction, AppAction { - const factory SettingsAction.init() = _InitSettingsAction; - const factory SettingsAction.error() = _OnErrorSettingsAction; -} diff --git a/lib/presentation/rebloc/settings/bloc.dart b/lib/presentation/rebloc/settings/bloc.dart deleted file mode 100644 index f357779..0000000 --- a/lib/presentation/rebloc/settings/bloc.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'actions.dart'; -part 'bloc.freezed.dart'; -part 'state.dart'; - -class SettingsBloc extends SimpleBloc { - SettingsBloc(this.settings); - - final Settings settings; - - @override - Stream> applyMiddleware(Stream> input) { - input - .whereAction<_InitSettingsAction>() - .switchMap( - (WareContext context) => settings - .fetch() - .handleError((dynamic _) => context.dispatcher(const SettingsAction.error())) - .map(OnDataAction.new) - .map((OnDataAction action) => context.copyWith(action)), - ) - .untilAction() - .listen((WareContext context) => context.dispatcher(context.action)); - - return input; - } - - @override - AppState reducer(AppState state, Action action) { - return state; - } -} diff --git a/lib/presentation/rebloc/settings/state.dart b/lib/presentation/rebloc/settings/state.dart deleted file mode 100644 index c38fbc4..0000000 --- a/lib/presentation/rebloc/settings/state.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class SettingsState with _$SettingsState { - const factory SettingsState({ - required SettingEntity? settings, - required StateStatus status, - required String? error, - }) = _SettingsState; -} diff --git a/lib/presentation/rebloc/stats/bloc.dart b/lib/presentation/rebloc/stats/bloc.dart deleted file mode 100644 index 2b824af..0000000 --- a/lib/presentation/rebloc/stats/bloc.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'bloc.freezed.dart'; -part 'state.dart'; - -class StatsBloc extends SimpleBloc { - StatsBloc(this.stats); - - final Stats stats; - - @override - Stream> applyMiddleware(Stream> input) { - return input; - } - - @override - AppState reducer(AppState state, Action action) { - return state; - } -} diff --git a/lib/presentation/rebloc/stats/state.dart b/lib/presentation/rebloc/stats/state.dart deleted file mode 100644 index 611d20e..0000000 --- a/lib/presentation/rebloc/stats/state.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class StatsState with _$StatsState { - const factory StatsState({ - required StatsEntity? stats, - required StateStatus status, - required String? error, - }) = _StatsState; -} diff --git a/lib/presentation/rebloc/store_factory.dart b/lib/presentation/rebloc/store_factory.dart index 3eef6cc..66695bf 100644 --- a/lib/presentation/rebloc/store_factory.dart +++ b/lib/presentation/rebloc/store_factory.dart @@ -1,25 +1,16 @@ import 'package:rebloc/rebloc.dart'; import 'package:registry/registry.dart'; -import 'package:tailor_made/core.dart'; import 'accounts/bloc.dart'; import 'app_state.dart'; -import 'auth/bloc.dart'; -import 'common/logger.dart'; import 'measures/bloc.dart'; -import 'settings/bloc.dart'; -import 'stats/bloc.dart'; Store storeFactory(Registry registry) { return Store( initialState: AppState.initialState, blocs: >[ - AuthBloc(registry.get()), AccountBloc(registry.get()), MeasuresBloc(registry.get()), - SettingsBloc(registry.get()), - StatsBloc(registry.get()), - LoggerBloc(registry.get().isTesting), ], ); } diff --git a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart index 66cd57b..7dca520 100644 --- a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart +++ b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart @@ -26,6 +26,7 @@ class HomeNotifier extends _$HomeNotifier { ); } +//todo: move to accountProvider void premiumSetup() async { final HomeState homeState = state.requireValue; await ref.read(registryProvider).get().signUp( @@ -48,6 +49,7 @@ class HomeNotifier extends _$HomeNotifier { ); } +//todo: move to accountProvider void logout() async { await ref.read(registryProvider).get().signOut(); ref.invalidate(accountProvider); From 7b745ddb795cd8aa3936bf25972d9f2b1a80ccd2 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sun, 2 Jul 2023 09:03:02 +0200 Subject: [PATCH 13/17] Introduce `measurementsProvider` --- lib/core.dart | 1 - lib/core/group_by.dart | 7 - lib/presentation/rebloc.dart | 5 - lib/presentation/rebloc/accounts/state.dart | 1 - lib/presentation/rebloc/app_state.dart | 15 +- lib/presentation/rebloc/auth/actions.dart | 6 - lib/presentation/rebloc/auth/bloc.dart | 5 - lib/presentation/rebloc/common/actions.dart | 9 -- .../rebloc/common/state_status.dart | 7 - lib/presentation/rebloc/measures/actions.dart | 8 - lib/presentation/rebloc/measures/bloc.dart | 91 ----------- lib/presentation/rebloc/measures/state.dart | 12 -- .../rebloc/measures/view_model.dart | 27 ---- lib/presentation/rebloc/store_factory.dart | 2 - .../screens/contacts/contacts_create.dart | 29 ++-- .../providers/selected_contact_provider.dart | 5 +- .../screens/jobs/jobs_create.dart | 33 ++-- .../screens/measures/measures.dart | 49 +++--- .../screens/measures/measures_create.dart | 151 +++++++++--------- .../screens/measures/measures_manage.dart | 65 ++++---- lib/presentation/screens/splash/splash.dart | 6 +- lib/presentation/state.dart | 1 + .../state/measurements_provider.dart | 43 +++++ test/core/group_by_test.dart | 26 --- 24 files changed, 219 insertions(+), 385 deletions(-) delete mode 100644 lib/core/group_by.dart delete mode 100644 lib/presentation/rebloc/auth/actions.dart delete mode 100644 lib/presentation/rebloc/auth/bloc.dart delete mode 100644 lib/presentation/rebloc/common/actions.dart delete mode 100644 lib/presentation/rebloc/common/state_status.dart delete mode 100644 lib/presentation/rebloc/measures/actions.dart delete mode 100644 lib/presentation/rebloc/measures/bloc.dart delete mode 100644 lib/presentation/rebloc/measures/state.dart delete mode 100644 lib/presentation/rebloc/measures/view_model.dart create mode 100644 lib/presentation/state/measurements_provider.dart delete mode 100644 test/core/group_by_test.dart diff --git a/lib/core.dart b/lib/core.dart index 422fc63..02dfa5e 100644 --- a/lib/core.dart +++ b/lib/core.dart @@ -4,4 +4,3 @@ export 'core/environment.dart'; export 'core/error_handling/error_boundary.dart'; export 'core/error_handling/error_reporter.dart'; export 'core/error_handling/handle_uncaught_error.dart'; -export 'core/group_by.dart'; diff --git a/lib/core/group_by.dart b/lib/core/group_by.dart deleted file mode 100644 index 315ba7b..0000000 --- a/lib/core/group_by.dart +++ /dev/null @@ -1,7 +0,0 @@ -Map> groupBy(List list, K Function(T) fn) { - return list.fold(>{}, (Map> rv, T x) { - final K key = fn(x); - (rv[key] = rv[key] ?? []).add(x); - return rv; - }); -} diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart index 3b30441..4d3c218 100644 --- a/lib/presentation/rebloc.dart +++ b/lib/presentation/rebloc.dart @@ -1,13 +1,8 @@ export 'rebloc/accounts/bloc.dart'; export 'rebloc/app_state.dart'; -export 'rebloc/auth/bloc.dart'; -export 'rebloc/common/actions.dart'; export 'rebloc/common/app_action.dart'; export 'rebloc/common/middleware.dart'; -export 'rebloc/common/state_status.dart'; export 'rebloc/extensions.dart'; -export 'rebloc/measures/bloc.dart'; -export 'rebloc/measures/view_model.dart'; export 'rebloc/store_factory.dart'; export 'utils/contacts_sort_type.dart'; export 'utils/jobs_sort_type.dart'; diff --git a/lib/presentation/rebloc/accounts/state.dart b/lib/presentation/rebloc/accounts/state.dart index 07c0c40..32aa873 100644 --- a/lib/presentation/rebloc/accounts/state.dart +++ b/lib/presentation/rebloc/accounts/state.dart @@ -4,7 +4,6 @@ part of 'bloc.dart'; class AccountState with _$AccountState { const factory AccountState({ required AccountEntity? account, - required StateStatus status, required bool hasSkipedPremium, required String? error, }) = _AccountState; diff --git a/lib/presentation/rebloc/app_state.dart b/lib/presentation/rebloc/app_state.dart index 94f8634..55fc693 100644 --- a/lib/presentation/rebloc/app_state.dart +++ b/lib/presentation/rebloc/app_state.dart @@ -1,21 +1,10 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; part 'app_state.freezed.dart'; @freezed class AppState with _$AppState { - const factory AppState({ - required MeasuresState measures, - }) = _AppState; + const factory AppState() = _AppState; - static const AppState initialState = AppState( - measures: MeasuresState( - measures: null, - grouped: null, - status: StateStatus.loading, - hasSkippedPremium: false, - error: null, - ), - ); + static const AppState initialState = AppState(); } diff --git a/lib/presentation/rebloc/auth/actions.dart b/lib/presentation/rebloc/auth/actions.dart deleted file mode 100644 index 0bb7e8e..0000000 --- a/lib/presentation/rebloc/auth/actions.dart +++ /dev/null @@ -1,6 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class AuthAction with _$AuthAction, AppAction { - const factory AuthAction.login(String user) = _OnLoginAction; -} diff --git a/lib/presentation/rebloc/auth/bloc.dart b/lib/presentation/rebloc/auth/bloc.dart deleted file mode 100644 index cee47a3..0000000 --- a/lib/presentation/rebloc/auth/bloc.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'actions.dart'; -part 'bloc.freezed.dart'; diff --git a/lib/presentation/rebloc/common/actions.dart b/lib/presentation/rebloc/common/actions.dart deleted file mode 100644 index 605ad56..0000000 --- a/lib/presentation/rebloc/common/actions.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'actions.freezed.dart'; - -@freezed -class CommonActionData with _$CommonActionData, AppAction { - const factory CommonActionData.data(T payload) = OnDataAction; -} diff --git a/lib/presentation/rebloc/common/state_status.dart b/lib/presentation/rebloc/common/state_status.dart deleted file mode 100644 index f0eb9d6..0000000 --- a/lib/presentation/rebloc/common/state_status.dart +++ /dev/null @@ -1,7 +0,0 @@ -enum StateStatus { - loading, - success, - failure; - - static StateStatus valueOf(String name) => values.byName(name); -} diff --git a/lib/presentation/rebloc/measures/actions.dart b/lib/presentation/rebloc/measures/actions.dart deleted file mode 100644 index c772e9e..0000000 --- a/lib/presentation/rebloc/measures/actions.dart +++ /dev/null @@ -1,8 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class MeasuresAction with _$MeasuresAction, AppAction { - const factory MeasuresAction.init(String userId) = _InitMeasuresAction; - const factory MeasuresAction.update(Iterable payload, String userId) = _UpdateMeasureAction; - const factory MeasuresAction.toggle() = _ToggleMeasuresLoading; -} diff --git a/lib/presentation/rebloc/measures/bloc.dart b/lib/presentation/rebloc/measures/bloc.dart deleted file mode 100644 index f7c3010..0000000 --- a/lib/presentation/rebloc/measures/bloc.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:tailor_made/core.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'actions.dart'; -part 'bloc.freezed.dart'; -part 'state.dart'; - -class MeasuresBloc extends SimpleBloc { - MeasuresBloc(this.measures); - - final Measures measures; - - @override - Stream> applyMiddleware(Stream> input) { - MergeStream>(>>[ - input.whereAction<_UpdateMeasureAction>().switchMap(_onUpdateMeasure(measures)), - input.whereAction<_InitMeasuresAction>().switchMap(_onInitMeasure(measures)), - ]).listen((WareContext context) => context.dispatcher(context.action)); - - return input; - } - - @override - AppState reducer(AppState state, Action action) { - final MeasuresState measures = state.measures; - - if (action is OnDataAction<_Union>) { - return state.copyWith( - measures: measures.copyWith( - measures: List.from( - action.payload.first..sort((MeasureEntity a, MeasureEntity b) => a.group.compareTo(b.group)), - ), - grouped: action.payload.second, - status: StateStatus.success, - ), - ); - } - - if (action is _ToggleMeasuresLoading || action is _UpdateMeasureAction) { - return state.copyWith( - measures: measures.copyWith(status: StateStatus.loading), - ); - } - - return state; - } -} - -class _Union { - const _Union(this.first, this.second); - - final List first; - final Map> second; - - @override - String toString() => '_Union($first, $second)'; -} - -Middleware _onUpdateMeasure(Measures measures) { - return (WareContext context) async* { - try { - final _UpdateMeasureAction action = context.action as _UpdateMeasureAction; - await measures.update(action.payload, action.userId); - yield context.copyWith(MeasuresAction.init(action.userId)); - } catch (e) { - yield context; - } - }; -} - -Middleware _onInitMeasure(Measures measures) { - return (WareContext context) { - final String userId = (context.action as _InitMeasuresAction).userId; - return measures.fetchAll(userId).map((List measures) { - if (measures.isEmpty) { - return MeasuresAction.update(BaseMeasureEntity.defaults, userId); - } - - return OnDataAction<_Union>( - _Union( - measures, - groupBy(measures, (MeasureEntity measure) => measure.group), - ), - ); - }).map((Action action) => context.copyWith(action)); - }; -} diff --git a/lib/presentation/rebloc/measures/state.dart b/lib/presentation/rebloc/measures/state.dart deleted file mode 100644 index bb1ee57..0000000 --- a/lib/presentation/rebloc/measures/state.dart +++ /dev/null @@ -1,12 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class MeasuresState with _$MeasuresState { - const factory MeasuresState({ - required List? measures, - required Map>? grouped, - required bool hasSkippedPremium, - required StateStatus status, - required String? error, - }) = _MeasuresState; -} diff --git a/lib/presentation/rebloc/measures/view_model.dart b/lib/presentation/rebloc/measures/view_model.dart deleted file mode 100644 index d6853ec..0000000 --- a/lib/presentation/rebloc/measures/view_model.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -class MeasuresViewModel extends Equatable { - MeasuresViewModel(AppState state) - : _model = state.measures.measures ?? [], - grouped = state.measures.grouped ?? >{}, - userId = '1', //todo - isLoading = state.measures.status == StateStatus.loading, - hasError = state.measures.status == StateStatus.failure, - error = state.measures.error; - - final Map> grouped; - - final List _model; - - List get model => _model; - - final String userId; - final bool isLoading; - final bool hasError; - final String? error; - - @override - List get props => [model, userId, isLoading, hasError, error]; -} diff --git a/lib/presentation/rebloc/store_factory.dart b/lib/presentation/rebloc/store_factory.dart index 66695bf..159a52e 100644 --- a/lib/presentation/rebloc/store_factory.dart +++ b/lib/presentation/rebloc/store_factory.dart @@ -3,14 +3,12 @@ import 'package:registry/registry.dart'; import 'accounts/bloc.dart'; import 'app_state.dart'; -import 'measures/bloc.dart'; Store storeFactory(Registry registry) { return Store( initialState: AppState.initialState, blocs: >[ AccountBloc(registry.get()), - MeasuresBloc(registry.get()), ], ); } diff --git a/lib/presentation/screens/contacts/contacts_create.dart b/lib/presentation/screens/contacts/contacts_create.dart index 64640c4..15fff95 100644 --- a/lib/presentation/screens/contacts/contacts_create.dart +++ b/lib/presentation/screens/contacts/contacts_create.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_native_contact_picker/flutter_native_contact_picker.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; @@ -43,17 +43,20 @@ class _ContactsCreatePageState extends State { color: context.theme.primaryColor, onPressed: _handleSelectContact, ), - ViewModelSubscriber( - converter: MeasuresViewModel.new, - builder: (_, __, MeasuresViewModel vm) { - return IconButton( - icon: Icon( - Icons.content_cut, - color: _contact.measurements.isEmpty ? colorScheme.secondary : null, + Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(measurementsProvider).when( + skipLoadingOnReload: true, + data: (MeasurementsState state) => IconButton( + icon: Icon( + Icons.content_cut, + color: _contact.measurements.isEmpty ? colorScheme.secondary : null, + ), + onPressed: () => _handleSelectMeasure(state.grouped), + ), + error: ErrorView.new, + loading: () => child!, ), - onPressed: () => _handleSelectMeasure(vm), - ); - }, + child: const Center(child: LoadingSpinner()), ), ], ), @@ -114,10 +117,10 @@ class _ContactsCreatePageState extends State { } } - void _handleSelectMeasure(MeasuresViewModel vm) async { + void _handleSelectMeasure(Map> grouped) async { final Map? result = await context.registry.get().toContactMeasure( contact: null, - grouped: vm.grouped, + grouped: grouped, ); if (result == null) { return; diff --git a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart index a0c04db..8fa35bd 100644 --- a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart +++ b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart @@ -6,7 +6,7 @@ import '../../../state.dart'; part 'selected_contact_provider.g.dart'; -@Riverpod(dependencies: [account, jobs, contacts]) +@Riverpod(dependencies: [account, jobs, contacts, measurements]) Future selectedContact(SelectedContactRef ref, String id) async { final AccountEntity account = await ref.watch(accountProvider.future); final ContactEntity contact = await ref.watch( @@ -15,12 +15,13 @@ Future selectedContact(SelectedContactRef ref, String id) async { final List jobs = await ref.watch( jobsProvider.selectAsync((_) => _.where((_) => _.contactID == id).toList()), ); + final MeasurementsState measurements = await ref.watch(measurementsProvider.future); return ContactState( contact: contact, jobs: jobs, userId: account.uid, - measurements: >{}, //todo + measurements: measurements.grouped, ); } diff --git a/lib/presentation/screens/jobs/jobs_create.dart b/lib/presentation/screens/jobs/jobs_create.dart index 7c020e4..fcc18a2 100644 --- a/lib/presentation/screens/jobs/jobs_create.dart +++ b/lib/presentation/screens/jobs/jobs_create.dart @@ -1,13 +1,13 @@ import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:flutter_masked_text2/flutter_masked_text2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; -import 'package:rebloc/rebloc.dart'; import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; import 'package:tailor_made/presentation/theme.dart'; import 'package:tailor_made/presentation/widgets.dart'; +import '../../state.dart'; import '../measures/widgets/measure_create_items.dart'; import 'jobs_create_view_model.dart'; import 'widgets/avatar_app_bar.dart'; @@ -142,19 +142,22 @@ class _JobsCreatePageState extends JobsCreateViewModel { } Widget _buildMeasures() { - return ViewModelSubscriber( - converter: MeasuresViewModel.new, - builder: (_, __, MeasuresViewModel vm) { - return MeasureCreateItems( - grouped: vm.grouped, - measurements: job.measurements, - onSaved: (Map? value) { - if (value != null) { - job = job.copyWith(measurements: value); - } - }, - ); - }, + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(measurementsProvider).when( + skipLoadingOnReload: true, + data: (MeasurementsState state) => MeasureCreateItems( + grouped: state.grouped, + measurements: job.measurements, + onSaved: (Map? value) { + if (value != null) { + job = job.copyWith(measurements: value); + } + }, + ), + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ); } diff --git a/lib/presentation/screens/measures/measures.dart b/lib/presentation/screens/measures/measures.dart index dfc3c07..c5325f0 100644 --- a/lib/presentation/screens/measures/measures.dart +++ b/lib/presentation/screens/measures/measures.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; -import 'package:tailor_made/presentation/utils.dart'; -import 'package:tailor_made/presentation/widgets.dart'; +import 'package:tailor_made/presentation.dart'; import 'widgets/measure_list_item.dart'; @@ -20,26 +18,31 @@ class MeasuresPage extends StatelessWidget { elevation: 0.0, systemOverlayStyle: Theme.of(context).brightness.systemOverlayStyle, ), - body: ViewModelSubscriber( - converter: MeasuresViewModel.new, - builder: (BuildContext context, _, MeasuresViewModel vm) { - if (vm.model.isEmpty) { - return const Center( - child: EmptyResultView(message: 'No measurements available'), - ); - } + body: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(measurementsProvider).when( + skipLoadingOnReload: true, + data: (MeasurementsState state) { + if (state.measures.isEmpty) { + return const Center( + child: EmptyResultView(message: 'No measurements available'), + ); + } - return ListView.separated( - itemCount: vm.model.length, - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 96.0), - itemBuilder: (_, int index) { - final MeasureEntity measure = vm.model[index]; - return MeasureListItem(item: measure, value: measurements[measure.id] ?? 0.0); - }, - separatorBuilder: (_, __) => const Divider(), - ); - }, + return ListView.separated( + itemCount: state.measures.length, + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 96.0), + itemBuilder: (_, int index) { + final MeasureEntity measure = state.measures[index]; + return MeasureListItem(item: measure, value: measurements[measure.id] ?? 0.0); + }, + separatorBuilder: (_, __) => const Divider(), + ); + }, + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), ); } diff --git a/lib/presentation/screens/measures/measures_create.dart b/lib/presentation/screens/measures/measures_create.dart index 08b465b..e3ec0fa 100644 --- a/lib/presentation/screens/measures/measures_create.dart +++ b/lib/presentation/screens/measures/measures_create.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; @@ -25,85 +25,88 @@ class _MeasuresCreateState extends State with StoreDispatchMixin @override Widget build(BuildContext context) { - return ViewModelSubscriber( - converter: MeasuresViewModel.new, - builder: (BuildContext context, _, MeasuresViewModel vm) { - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: CustomAppBar( - title: const Text(''), - leading: const AppCloseButton(), - actions: [ - AppClearButton( - onPressed: _measures.isEmpty ? null : () => _handleSubmit(vm), - child: const Text('SAVE'), - ) - ], + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: CustomAppBar( + title: const Text(''), + leading: const AppCloseButton(), + actions: [ + Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(accountProvider).when( + skipLoadingOnReload: true, + data: (_) => AppClearButton( + onPressed: _measures.isEmpty ? null : () => _handleSubmit(_.uid), + child: const Text('SAVE'), + ), + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), - body: SafeArea( - top: false, - child: SingleChildScrollView( - child: Form( - key: _formKey, - autovalidateMode: _autovalidate ? AutovalidateMode.always : AutovalidateMode.disabled, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const FormSectionHeader(title: 'Group Name'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), - child: TextFormField( - initialValue: _groupName.displayName, - textCapitalization: TextCapitalization.words, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.text, - decoration: const InputDecoration( - isDense: true, - hintText: 'eg Blouse', - ), - validator: (String? value) => (value!.isNotEmpty) ? null : 'Please input a name', - onSaved: (String? value) => _groupName = MeasureGroup.valueOf(value!.trim()), - ), + ], + ), + body: SafeArea( + top: false, + child: SingleChildScrollView( + child: Form( + key: _formKey, + autovalidateMode: _autovalidate ? AutovalidateMode.always : AutovalidateMode.disabled, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const FormSectionHeader(title: 'Group Name'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + child: TextFormField( + initialValue: _groupName.displayName, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.text, + decoration: const InputDecoration( + isDense: true, + hintText: 'eg Blouse', ), - const FormSectionHeader(title: 'Group Unit'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), - child: TextFormField( - initialValue: _unitValue, - keyboardType: TextInputType.text, - decoration: const InputDecoration( - isDense: true, - hintText: 'Unit (eg. In, cm)', - ), - validator: (String? value) => (value!.isNotEmpty) ? null : 'Please input a value', - onSaved: (String? value) => _unitValue = value!.trim(), - ), + validator: (String? value) => (value!.isNotEmpty) ? null : 'Please input a name', + onSaved: (String? value) => _groupName = MeasureGroup.valueOf(value!.trim()), + ), + ), + const FormSectionHeader(title: 'Group Unit'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + child: TextFormField( + initialValue: _unitValue, + keyboardType: TextInputType.text, + decoration: const InputDecoration( + isDense: true, + hintText: 'Unit (eg. In, cm)', ), - if (_measures.isNotEmpty) ...[ - const FormSectionHeader(title: 'Group Items'), - _GroupItems( - measures: _measures, - onPressed: (BaseMeasureEntity measure) => _onTapDeleteItem(vm, measure), - ), - const SizedBox(height: 84.0) - ] - ], + validator: (String? value) => (value!.isNotEmpty) ? null : 'Please input a value', + onSaved: (String? value) => _unitValue = value!.trim(), + ), ), - ), + if (_measures.isNotEmpty) ...[ + const FormSectionHeader(title: 'Group Items'), + _GroupItems( + measures: _measures, + onPressed: _onTapDeleteItem, + ), + const SizedBox(height: 84.0) + ] + ], ), ), - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.add_circle_outline), - label: const Text('Add Item'), - onPressed: _handleAddItem, - ), - ); - }, + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add_circle_outline), + label: const Text('Add Item'), + onPressed: _handleAddItem, + ), ); } - void _onTapDeleteItem(MeasuresViewModel vm, BaseMeasureEntity measure) async { + void _onTapDeleteItem(BaseMeasureEntity measure) async { final Registry registry = context.registry; final AppSnackBar snackBar = AppSnackBar.of(context); final bool? choice = await showChoiceDialog(context: context, message: 'Are you sure?'); @@ -116,7 +119,6 @@ class _MeasuresCreateState extends State with StoreDispatchMixin } else if (measure is MeasureEntity) { snackBar.loading(); try { - dispatchAction(const MeasuresAction.toggle()); await registry.get().deleteOne(measure.reference); _removeFromLocal(measure.localKey); snackBar.hide(); @@ -144,17 +146,16 @@ class _MeasuresCreateState extends State with StoreDispatchMixin } } - void _handleSubmit(MeasuresViewModel vm) async { + void _handleSubmit(String userId) async { if (_isOkForm()) { final AppSnackBar snackBar = AppSnackBar.of(context)..loading(); try { final NavigatorState navigator = Navigator.of(context); - dispatchAction(const MeasuresAction.toggle()); // TODO(Jogboms): move this out of here await context.registry.get().create( _measures, - vm.userId, + userId, groupName: _groupName, unitValue: _unitValue, ); diff --git a/lib/presentation/screens/measures/measures_manage.dart b/lib/presentation/screens/measures/measures_manage.dart index d29526a..1a86b92 100644 --- a/lib/presentation/screens/measures/measures_manage.dart +++ b/lib/presentation/screens/measures/measures_manage.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/presentation.dart'; import 'widgets/measures_slide_block.dart'; @@ -17,40 +17,41 @@ class _MeasuresManagePageState extends State { return Scaffold( resizeToAvoidBottomInset: false, appBar: const CustomAppBar(title: Text('Measurements')), - body: ViewModelSubscriber( - converter: MeasuresViewModel.new, - builder: (_, __, MeasuresViewModel vm) { - if (vm.isLoading) { - return const Center(child: LoadingSpinner()); - } + body: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(measurementsProvider).when( + skipLoadingOnReload: true, + data: (MeasurementsState state) { + if (state.measures.isEmpty) { + return const Center(child: EmptyResultView(message: 'No measurements available')); + } - if (vm.model.isEmpty) { - return const Center(child: EmptyResultView(message: 'No measurements available')); - } - - return SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - color: Theme.of(context).colorScheme.outlineVariant, - padding: const EdgeInsets.all(16), - child: const Text('Long-Press on any group to see more actions.'), - ), - for (int i = 0; i < vm.grouped.length; i++) - MeasureSlideBlock( - groupName: vm.grouped.keys.elementAt(i), - measures: vm.grouped.values.elementAt(i), - userId: vm.userId, + return SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: Theme.of(context).colorScheme.outlineVariant, + padding: const EdgeInsets.all(16), + child: const Text('Long-Press on any group to see more actions.'), + ), + for (int i = 0; i < state.grouped.length; i++) + MeasureSlideBlock( + groupName: state.grouped.keys.elementAt(i), + measures: state.grouped.values.elementAt(i), + userId: state.userId, + ), + const SizedBox(height: 72.0) + ], ), - const SizedBox(height: 72.0) - ], - ), + ), + ); + }, + error: ErrorView.new, + loading: () => child!, ), - ); - }, + child: const Center(child: LoadingSpinner()), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: FloatingActionButton.extended( diff --git a/lib/presentation/screens/splash/splash.dart b/lib/presentation/screens/splash/splash.dart index 74d4cb5..10cb2a9 100644 --- a/lib/presentation/screens/splash/splash.dart +++ b/lib/presentation/screens/splash/splash.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:rebloc/rebloc.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; @@ -57,10 +56,7 @@ class SplashPage extends StatelessWidget { final String? data = snapshot.data; if (data != null) { WidgetsBinding.instance.addPostFrameCallback( - (_) async { - StoreProvider.of(context).dispatch(AuthAction.login(data)); - context.registry.get().toHome(isMock); - }, + (_) async => context.registry.get().toHome(isMock), ); return const SizedBox(); diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index 407c84b..01d2a67 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -1,6 +1,7 @@ export 'state/account_provider.dart'; export 'state/contacts_provider.dart'; export 'state/jobs_provider.dart'; +export 'state/measurements_provider.dart'; export 'state/registry_provider.dart'; export 'state/selected_contact_job_provider.dart'; export 'state/settings_provider.dart'; diff --git a/lib/presentation/state/measurements_provider.dart b/lib/presentation/state/measurements_provider.dart new file mode 100644 index 0000000..2ffc15d --- /dev/null +++ b/lib/presentation/state/measurements_provider.dart @@ -0,0 +1,43 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; + +part 'measurements_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +Stream measurements(MeasurementsRef ref) async* { + final AccountEntity account = await ref.watch(accountProvider.future); + + final Measures measures = ref.read(registryProvider).get(); + + yield* measures.fetchAll(account.uid).map((List items) { + if (items.isEmpty) { + measures.update(BaseMeasureEntity.defaults, account.uid).ignore(); + } + + return MeasurementsState( + userId: account.uid, + measures: items.sorted((MeasureEntity a, MeasureEntity b) => a.group.compareTo(b.group)), + grouped: groupBy(items, (_) => _.group), + ); + }); +} + +class MeasurementsState with EquatableMixin { + const MeasurementsState({ + required this.userId, + required this.measures, + required this.grouped, + }); + + final String userId; + final List measures; + final Map> grouped; + + @override + List get props => [userId, measures, grouped]; +} diff --git a/test/core/group_by_test.dart b/test/core/group_by_test.dart deleted file mode 100644 index 97697ef..0000000 --- a/test/core/group_by_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:tailor_made/core.dart'; -import 'package:tailor_made/domain.dart'; - -void main() { - group('group_mode_by', () { - test('test group_mode_by', () { - final List measures = [ - const DefaultMeasureEntity(group: MeasureGroup.blouse, name: 'length'), - const DefaultMeasureEntity(group: MeasureGroup.blouse, name: 'Waist'), - const DefaultMeasureEntity(group: MeasureGroup.trouser, name: 'Waist'), - const DefaultMeasureEntity(group: MeasureGroup.trouser, name: 'length'), - const DefaultMeasureEntity(group: MeasureGroup.blouse, name: 'Arm'), - ]; - - final Map> grouped = groupBy( - measures, - (DefaultMeasureEntity measure) => measure.group, - ); - - expect(grouped.isNotEmpty, true); - expect(grouped[MeasureGroup.blouse]!.length, 3); - expect(grouped[MeasureGroup.trouser]!.length, 2); - }); - }); -} From 5b7e4f733d922676af8bd8cd1c37953027770962 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sun, 2 Jul 2023 09:20:55 +0200 Subject: [PATCH 14/17] Introduce `accountNotifierProvider` --- lib/main.dart | 1 - lib/presentation.dart | 1 - lib/presentation/app.dart | 67 +++++++------------ lib/presentation/mixins.dart | 1 - .../mixins/store_dispatch_mixin.dart | 8 --- lib/presentation/rebloc.dart | 8 --- lib/presentation/rebloc/accounts/actions.dart | 7 -- lib/presentation/rebloc/accounts/bloc.dart | 59 ---------------- lib/presentation/rebloc/accounts/state.dart | 10 --- lib/presentation/rebloc/app_state.dart | 10 --- .../rebloc/common/app_action.dart | 3 - .../rebloc/common/middleware.dart | 4 -- lib/presentation/rebloc/extensions.dart | 5 -- lib/presentation/rebloc/store_factory.dart | 14 ---- .../widgets/contacts_filter_button.dart | 3 +- .../screens/homepage/homepage.dart | 19 ++++-- .../providers/home_notifier_provider.dart | 21 ------ .../homepage/widgets/top_button_bar.dart | 55 +++++++-------- .../jobs/widgets/jobs_filter_button.dart | 3 +- .../screens/measures/measures_create.dart | 2 +- lib/presentation/state.dart | 1 + .../state/account_notifier_provider.dart | 56 ++++++++++++++++ test/presentation/app_test.dart | 1 - 23 files changed, 129 insertions(+), 230 deletions(-) delete mode 100644 lib/presentation/mixins/store_dispatch_mixin.dart delete mode 100644 lib/presentation/rebloc.dart delete mode 100644 lib/presentation/rebloc/accounts/actions.dart delete mode 100644 lib/presentation/rebloc/accounts/bloc.dart delete mode 100644 lib/presentation/rebloc/accounts/state.dart delete mode 100644 lib/presentation/rebloc/app_state.dart delete mode 100644 lib/presentation/rebloc/common/app_action.dart delete mode 100644 lib/presentation/rebloc/common/middleware.dart delete mode 100644 lib/presentation/rebloc/extensions.dart delete mode 100644 lib/presentation/rebloc/store_factory.dart create mode 100644 lib/presentation/state/account_notifier_provider.dart diff --git a/lib/main.dart b/lib/main.dart index 3fb6467..89ef971 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -96,7 +96,6 @@ void main(List args) async { child: App( registry: registry, navigatorKey: navigatorKey, - store: storeFactory(registry), navigatorObservers: [navigationObserver], ), ), diff --git a/lib/presentation.dart b/lib/presentation.dart index 6e6f3f0..2b12dcf 100644 --- a/lib/presentation.dart +++ b/lib/presentation.dart @@ -2,7 +2,6 @@ export 'presentation/app.dart'; export 'presentation/constants.dart'; export 'presentation/coordinator.dart'; export 'presentation/mixins.dart'; -export 'presentation/rebloc.dart'; export 'presentation/registry.dart'; export 'presentation/state.dart'; export 'presentation/theme.dart'; diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index a8e1d04..8616c63 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:rebloc/rebloc.dart'; import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'constants.dart'; -import 'rebloc.dart'; import 'registry.dart'; import 'screens/splash/splash.dart'; import 'theme.dart'; @@ -17,13 +15,11 @@ class App extends StatefulWidget { super.key, required this.registry, required this.navigatorKey, - required this.store, this.navigatorObservers, }); final Registry registry; final GlobalKey navigatorKey; - final Store store; final List? navigatorObservers; @override @@ -34,47 +30,36 @@ class _AppState extends State { late final Environment environment = widget.registry.get(); late final String bannerMessage = environment.name.toUpperCase(); - @override - void dispose() { - widget.store.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return RegistryProvider( registry: widget.registry, - child: StoreProvider( - store: widget.store, - child: Builder( - builder: (BuildContext context) => _Banner( - key: Key(bannerMessage), - visible: !environment.isProduction, - message: bannerMessage, - child: MaterialApp( - debugShowCheckedModeBanner: false, - color: Colors.white, - navigatorKey: widget.navigatorKey, - navigatorObservers: widget.navigatorObservers ?? [], - theme: themeBuilder(ThemeData.light()), - darkTheme: themeBuilder(ThemeData.dark()), - onGenerateTitle: (BuildContext context) => context.l10n.appName, - localizationsDelegates: const >[ - L10n.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: L10n.supportedLocales, - builder: (_, Widget? child) => SnackBarProvider( - navigatorKey: widget.navigatorKey, - child: child!, - ), - onGenerateRoute: (RouteSettings settings) => _PageRoute( - builder: (_) => SplashPage(isColdStart: true, isMock: environment.isMock), - settings: RouteSettings(name: AppRoutes.start, arguments: settings.arguments), - ), - ), + child: _Banner( + key: Key(bannerMessage), + visible: !environment.isProduction, + message: bannerMessage, + child: MaterialApp( + debugShowCheckedModeBanner: false, + color: Colors.white, + navigatorKey: widget.navigatorKey, + navigatorObservers: widget.navigatorObservers ?? [], + theme: themeBuilder(ThemeData.light()), + darkTheme: themeBuilder(ThemeData.dark()), + onGenerateTitle: (BuildContext context) => context.l10n.appName, + localizationsDelegates: const >[ + L10n.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: L10n.supportedLocales, + builder: (_, Widget? child) => SnackBarProvider( + navigatorKey: widget.navigatorKey, + child: child!, + ), + onGenerateRoute: (RouteSettings settings) => _PageRoute( + builder: (_) => SplashPage(isColdStart: true, isMock: environment.isMock), + settings: RouteSettings(name: AppRoutes.start, arguments: settings.arguments), ), ), ), diff --git a/lib/presentation/mixins.dart b/lib/presentation/mixins.dart index 3f20e50..5fbdfb0 100644 --- a/lib/presentation/mixins.dart +++ b/lib/presentation/mixins.dart @@ -1,2 +1 @@ export 'mixins/dismiss_keyboard_mixin.dart'; -export 'mixins/store_dispatch_mixin.dart'; diff --git a/lib/presentation/mixins/store_dispatch_mixin.dart b/lib/presentation/mixins/store_dispatch_mixin.dart deleted file mode 100644 index f8c60a5..0000000 --- a/lib/presentation/mixins/store_dispatch_mixin.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter/material.dart' hide Action; -import 'package:rebloc/rebloc.dart'; - -abstract mixin class StoreDispatchMixin { - BuildContext get context; - - void dispatchAction(Action action) => StoreProvider.of(context).dispatch(action); -} diff --git a/lib/presentation/rebloc.dart b/lib/presentation/rebloc.dart deleted file mode 100644 index 4d3c218..0000000 --- a/lib/presentation/rebloc.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'rebloc/accounts/bloc.dart'; -export 'rebloc/app_state.dart'; -export 'rebloc/common/app_action.dart'; -export 'rebloc/common/middleware.dart'; -export 'rebloc/extensions.dart'; -export 'rebloc/store_factory.dart'; -export 'utils/contacts_sort_type.dart'; -export 'utils/jobs_sort_type.dart'; diff --git a/lib/presentation/rebloc/accounts/actions.dart b/lib/presentation/rebloc/accounts/actions.dart deleted file mode 100644 index c30ee0b..0000000 --- a/lib/presentation/rebloc/accounts/actions.dart +++ /dev/null @@ -1,7 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class AccountAction with _$AccountAction, AppAction { - const factory AccountAction.readNotice(AccountEntity payload) = _OnReadNotice; - const factory AccountAction.sendRating(AccountEntity account, int rating) = _OnSendRating; -} diff --git a/lib/presentation/rebloc/accounts/bloc.dart b/lib/presentation/rebloc/accounts/bloc.dart deleted file mode 100644 index d64dbbd..0000000 --- a/lib/presentation/rebloc/accounts/bloc.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; - -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:rebloc/rebloc.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -part 'actions.dart'; -part 'bloc.freezed.dart'; -part 'state.dart'; - -class AccountBloc extends SimpleBloc { - AccountBloc(this.accounts); - - final Accounts accounts; - - @override - Stream> applyMiddleware(Stream> input) { - MergeStream>(>>[ - input.whereAction<_OnReadNotice>().switchMap(_readNotice(accounts)), - input.whereAction<_OnSendRating>().switchMap(_sendRating(accounts)), - ]).listen((WareContext context) => context.dispatcher(context.action)); - - return input; - } -} - -//todo: move to accountProvider -Middleware _readNotice(Accounts accounts) { - return (WareContext context) async* { - final AccountEntity account = (context.action as _OnReadNotice).payload; - await accounts.updateAccount( - account.uid, - id: account.reference.id, - path: account.reference.path, - hasReadNotice: true, - ); - - yield context; - }; -} - -//todo: move to accountProvider -Middleware _sendRating(Accounts accounts) { - return (WareContext context) async* { - final _OnSendRating action = context.action as _OnSendRating; - final AccountEntity account = action.account; - await accounts.updateAccount( - account.uid, - id: account.reference.id, - path: account.reference.path, - hasSendRating: true, - rating: action.rating, - ); - - yield context; - }; -} diff --git a/lib/presentation/rebloc/accounts/state.dart b/lib/presentation/rebloc/accounts/state.dart deleted file mode 100644 index 32aa873..0000000 --- a/lib/presentation/rebloc/accounts/state.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'bloc.dart'; - -@freezed -class AccountState with _$AccountState { - const factory AccountState({ - required AccountEntity? account, - required bool hasSkipedPremium, - required String? error, - }) = _AccountState; -} diff --git a/lib/presentation/rebloc/app_state.dart b/lib/presentation/rebloc/app_state.dart deleted file mode 100644 index 55fc693..0000000 --- a/lib/presentation/rebloc/app_state.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'app_state.freezed.dart'; - -@freezed -class AppState with _$AppState { - const factory AppState() = _AppState; - - static const AppState initialState = AppState(); -} diff --git a/lib/presentation/rebloc/common/app_action.dart b/lib/presentation/rebloc/common/app_action.dart deleted file mode 100644 index dbdff51..0000000 --- a/lib/presentation/rebloc/common/app_action.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:rebloc/rebloc.dart'; - -mixin AppAction implements Action {} diff --git a/lib/presentation/rebloc/common/middleware.dart b/lib/presentation/rebloc/common/middleware.dart deleted file mode 100644 index 10f14fe..0000000 --- a/lib/presentation/rebloc/common/middleware.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:rebloc/rebloc.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; - -typedef Middleware = Stream> Function(WareContext context); diff --git a/lib/presentation/rebloc/extensions.dart b/lib/presentation/rebloc/extensions.dart deleted file mode 100644 index f1a7201..0000000 --- a/lib/presentation/rebloc/extensions.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:rebloc/rebloc.dart'; - -extension ObservableExtensions on Stream> { - Stream> whereAction() => where((WareContext context) => context.action is U); -} diff --git a/lib/presentation/rebloc/store_factory.dart b/lib/presentation/rebloc/store_factory.dart deleted file mode 100644 index 159a52e..0000000 --- a/lib/presentation/rebloc/store_factory.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:rebloc/rebloc.dart'; -import 'package:registry/registry.dart'; - -import 'accounts/bloc.dart'; -import 'app_state.dart'; - -Store storeFactory(Registry registry) { - return Store( - initialState: AppState.initialState, - blocs: >[ - AccountBloc(registry.get()), - ], - ); -} diff --git a/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart b/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart index a1579ec..86b52b2 100644 --- a/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart +++ b/lib/presentation/screens/contacts/widgets/contacts_filter_button.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; import 'package:tailor_made/presentation/widgets.dart'; +import '../../../utils.dart'; + class ContactsFilterButton extends StatelessWidget { const ContactsFilterButton({super.key, required this.sortType, required this.onTapSort}); diff --git a/lib/presentation/screens/homepage/homepage.dart b/lib/presentation/screens/homepage/homepage.dart index 7f65b72..a720d14 100644 --- a/lib/presentation/screens/homepage/homepage.dart +++ b/lib/presentation/screens/homepage/homepage.dart @@ -65,7 +65,8 @@ class HomePage extends StatelessWidget { }, child: _Body( state: state, - notifier: ref.read(homeNotifierProvider.notifier), + homeNotifier: ref.read(homeNotifierProvider.notifier), + accountNotifier: ref.read(accountNotifierProvider.notifier), isMock: isMock, ), ), @@ -83,10 +84,16 @@ class HomePage extends StatelessWidget { } class _Body extends StatelessWidget { - const _Body({required this.state, required this.notifier, required this.isMock}); + const _Body({ + required this.state, + required this.homeNotifier, + required this.accountNotifier, + required this.isMock, + }); final HomeState state; - final HomeNotifier notifier; + final HomeNotifier homeNotifier; + final AccountNotifier accountNotifier; final bool isMock; @override @@ -107,8 +114,8 @@ class _Body extends StatelessWidget { if (state.isWarning && state.hasSkippedPremium == false) { return RateLimitPage( - onSignUp: notifier.premiumSetup, - onSkippedPremium: notifier.skippedPremium, + onSignUp: accountNotifier.premiumSetup, + onSkippedPremium: homeNotifier.skippedPremium, ); } @@ -131,7 +138,7 @@ class _Body extends StatelessWidget { account: account, shouldSendRating: state.shouldSendRating, onLogout: () { - notifier.logout(); + accountNotifier.logout(); context.registry.get().toSplash(isMock); }, ), diff --git a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart index 7dca520..7b4fb74 100644 --- a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart +++ b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart @@ -26,21 +26,6 @@ class HomeNotifier extends _$HomeNotifier { ); } -//todo: move to accountProvider - void premiumSetup() async { - final HomeState homeState = state.requireValue; - await ref.read(registryProvider).get().signUp( - homeState.account.copyWith( - status: AccountStatus.pending, - notice: homeState.settings.premiumNotice, - hasReadNotice: false, - hasPremiumEnabled: true, - ), - ); - ref.invalidate(accountProvider); - ref.invalidateSelf(); - } - void skippedPremium() { state = AsyncValue.data( state.requireValue.copyWith( @@ -48,12 +33,6 @@ class HomeNotifier extends _$HomeNotifier { ), ); } - -//todo: move to accountProvider - void logout() async { - await ref.read(registryProvider).get().signOut(); - ref.invalidate(accountProvider); - } } class HomeState { diff --git a/lib/presentation/screens/homepage/widgets/top_button_bar.dart b/lib/presentation/screens/homepage/widgets/top_button_bar.dart index 6b04e77..8676da8 100644 --- a/lib/presentation/screens/homepage/widgets/top_button_bar.dart +++ b/lib/presentation/screens/homepage/widgets/top_button_bar.dart @@ -1,6 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:rebloc/rebloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:registry/registry.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; @@ -41,28 +41,30 @@ class TopButtonBar extends StatelessWidget { shape: BoxShape.circle, border: Border.all(color: colorScheme.primary.withOpacity(.5), width: 1.5), ), - child: GestureDetector( - onTap: _onTapAccount(context), - child: Stack( - fit: StackFit.expand, - children: [ - if (account.photoURL case final String photoUrl) - CircleAvatar( - backgroundImage: CachedNetworkImageProvider(photoUrl), - ) - else - const Icon(Icons.person), - Align( - alignment: const Alignment(0.0, 2.25), - child: account.hasPremiumEnabled - ? ImageIcon(AppImages.verified, color: colorScheme.primary) // - : null, - ), - Align( - alignment: Alignment(1.25, account.hasPremiumEnabled ? -1.25 : 1.25), - child: _shouldShowIndicator ? Dots(color: colorScheme.secondary) : null, - ), - ], + child: Consumer( + builder: (BuildContext context, WidgetRef ref, _) => GestureDetector( + onTap: _onTapAccount(context, ref.read(accountNotifierProvider.notifier)), + child: Stack( + fit: StackFit.expand, + children: [ + if (account.photoURL case final String photoUrl) + CircleAvatar( + backgroundImage: CachedNetworkImageProvider(photoUrl), + ) + else + const Icon(Icons.person), + Align( + alignment: const Alignment(0.0, 2.25), + child: account.hasPremiumEnabled + ? ImageIcon(AppImages.verified, color: colorScheme.primary) // + : null, + ), + Align( + alignment: Alignment(1.25, account.hasPremiumEnabled ? -1.25 : 1.25), + child: _shouldShowIndicator ? Dots(color: colorScheme.secondary) : null, + ), + ], + ), ), ), ), @@ -74,23 +76,22 @@ class TopButtonBar extends StatelessWidget { bool get _shouldShowIndicator => !account.hasReadNotice || shouldSendRating; - VoidCallback _onTapAccount(BuildContext context) { + VoidCallback _onTapAccount(BuildContext context, AccountNotifier notifier) { final Registry registry = context.registry; return () async { - final Store store = StoreProvider.of(context); if (shouldSendRating) { final int? rating = await showChildDialog(context: context, child: const ReviewModal()); if (rating != null) { - store.dispatch(AccountAction.sendRating(account, rating)); + notifier.sendRating(rating); } return; } if (_shouldShowIndicator) { await showChildDialog(context: context, child: NoticeDialog(account: account)); - store.dispatch(AccountAction.readNotice(account)); + notifier.readNotice(); return; } diff --git a/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart b/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart index 7fd3fcc..ba4158e 100644 --- a/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart +++ b/lib/presentation/screens/jobs/widgets/jobs_filter_button.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:tailor_made/presentation/rebloc.dart'; import 'package:tailor_made/presentation/widgets.dart'; +import '../../../utils.dart'; + class JobsFilterButton extends StatelessWidget { const JobsFilterButton({super.key, required this.sortType, required this.onTapSort}); diff --git a/lib/presentation/screens/measures/measures_create.dart b/lib/presentation/screens/measures/measures_create.dart index e3ec0fa..f47fa56 100644 --- a/lib/presentation/screens/measures/measures_create.dart +++ b/lib/presentation/screens/measures/measures_create.dart @@ -16,7 +16,7 @@ class MeasuresCreate extends StatefulWidget { State createState() => _MeasuresCreateState(); } -class _MeasuresCreateState extends State with StoreDispatchMixin { +class _MeasuresCreateState extends State { final GlobalKey _formKey = GlobalKey(); bool _autovalidate = false; late MeasureGroup _groupName = widget.groupName ?? MeasureGroup.empty; diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index 01d2a67..bc67f04 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -1,3 +1,4 @@ +export 'state/account_notifier_provider.dart'; export 'state/account_provider.dart'; export 'state/contacts_provider.dart'; export 'state/jobs_provider.dart'; diff --git a/lib/presentation/state/account_notifier_provider.dart b/lib/presentation/state/account_notifier_provider.dart new file mode 100644 index 0000000..4fa320d --- /dev/null +++ b/lib/presentation/state/account_notifier_provider.dart @@ -0,0 +1,56 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; +import 'settings_provider.dart'; + +part 'account_notifier_provider.g.dart'; + +@Riverpod(dependencies: [registry, account, settings]) +class AccountNotifier extends _$AccountNotifier { + @override + Future build() async => ref.watch(accountProvider.future); + + void readNotice() async { + final AccountEntity account = state.requireValue; + await ref.read(registryProvider).get().updateAccount( + account.uid, + id: account.reference.id, + path: account.reference.path, + hasReadNotice: true, + ); + ref.invalidate(accountProvider); + } + + void sendRating(int rating) async { + final AccountEntity account = state.requireValue; + await ref.read(registryProvider).get().updateAccount( + account.uid, + id: account.reference.id, + path: account.reference.path, + hasSendRating: true, + rating: rating, + ); + ref.invalidate(accountProvider); + } + + void premiumSetup() async { + final SettingEntity settings = await ref.watch(settingsProvider.future); + + await ref.read(registryProvider).get().signUp( + state.requireValue.copyWith( + status: AccountStatus.pending, + notice: settings.premiumNotice, + hasReadNotice: false, + hasPremiumEnabled: true, + ), + ); + ref.invalidate(accountProvider); + } + + void logout() async { + await ref.read(registryProvider).get().signOut(); + ref.invalidate(accountProvider); + } +} diff --git a/test/presentation/app_test.dart b/test/presentation/app_test.dart index 6eeb940..c57122f 100644 --- a/test/presentation/app_test.dart +++ b/test/presentation/app_test.dart @@ -36,7 +36,6 @@ void main() { App( registry: registry, navigatorKey: navigatorKey, - store: storeFactory(registry), navigatorObservers: [mockObserver], ), ); From 36c1b68f1e2ed53ddedb75c00d4d0fc661242e7c Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sun, 2 Jul 2023 16:54:02 +0200 Subject: [PATCH 15/17] Introduce `authStateNotifierProvider` --- l10n/intl_en.arb | 3 +- .../repositories/accounts/accounts_impl.dart | 2 +- .../accounts/accounts_mock_impl.dart | 2 +- lib/domain.dart | 2 + lib/domain/entities/auth_exception.dart | 2 +- lib/domain/repositories/accounts.dart | 2 +- lib/domain/use_cases/sign_in_use_case.dart | 41 +++ lib/domain/use_cases/sign_out_use_case.dart | 29 ++ lib/main.dart | 2 + lib/presentation/app.dart | 2 +- .../coordinator/shared_coordinator.dart | 8 +- .../screens/homepage/homepage.dart | 17 +- .../providers/home_notifier_provider.dart | 24 +- .../screens/homepage/widgets/bottom_row.dart | 2 +- .../screens/homepage/widgets/mid_row.dart | 2 +- .../screens/homepage/widgets/stats.dart | 14 +- .../homepage/widgets/top_button_bar.dart | 24 +- .../screens/homepage/widgets/top_row.dart | 4 +- lib/presentation/screens/splash/splash.dart | 261 ++++++++---------- lib/presentation/state.dart | 1 + .../state/account_notifier_provider.dart | 17 +- .../state/auth_state_notifier_provider.dart | 124 +++++++++ test/presentation/app_test.dart | 2 +- 23 files changed, 379 insertions(+), 208 deletions(-) create mode 100644 lib/domain/use_cases/sign_in_use_case.dart create mode 100644 lib/domain/use_cases/sign_out_use_case.dart create mode 100644 lib/presentation/state/auth_state_notifier_provider.dart diff --git a/l10n/intl_en.arb b/l10n/intl_en.arb index 9b7c8ee..0d4868a 100644 --- a/l10n/intl_en.arb +++ b/l10n/intl_en.arb @@ -12,5 +12,6 @@ "bannedUserMessage": "This account has been disabled by administration", "loadingMessage": "Loading...", "tryAgainMessage": "Do try again after some time", - "popupBlockedByBrowserMessage": "The auto-login popup was blocked by your browser. Try the continue button" + "popupBlockedByBrowserMessage": "The auto-login popup was blocked by your browser. Try the continue button", + "continueWithGoogle": "Continue with Google" } diff --git a/lib/data/repositories/accounts/accounts_impl.dart b/lib/data/repositories/accounts/accounts_impl.dart index 767df5b..096e07d 100644 --- a/lib/data/repositories/accounts/accounts_impl.dart +++ b/lib/data/repositories/accounts/accounts_impl.dart @@ -15,7 +15,7 @@ class AccountsImpl extends Accounts { static const String collectionName = 'accounts'; @override - Future signInWithGoogle() => firebase.auth.signInWithGoogle(); + Future signIn() => firebase.auth.signInWithGoogle(); @override Stream get onAuthStateChanged => firebase.auth.onAuthStateChanged; diff --git a/lib/data/repositories/accounts/accounts_mock_impl.dart b/lib/data/repositories/accounts/accounts_mock_impl.dart index 20c92e7..b5729b3 100644 --- a/lib/data/repositories/accounts/accounts_mock_impl.dart +++ b/lib/data/repositories/accounts/accounts_mock_impl.dart @@ -2,7 +2,7 @@ import 'package:tailor_made/domain.dart'; class AccountsMockImpl extends Accounts { @override - Future signInWithGoogle() async { + Future signIn() async { return; } diff --git a/lib/domain.dart b/lib/domain.dart index f6b4995..850ad6a 100644 --- a/lib/domain.dart +++ b/lib/domain.dart @@ -9,3 +9,5 @@ export 'domain/repositories/payments.dart'; export 'domain/repositories/settings.dart'; export 'domain/repositories/stats.dart'; export 'domain/use_cases/fetch_account_use_case.dart'; +export 'domain/use_cases/sign_in_use_case.dart'; +export 'domain/use_cases/sign_out_use_case.dart'; diff --git a/lib/domain/entities/auth_exception.dart b/lib/domain/entities/auth_exception.dart index 36e6c3f..a7ce86d 100644 --- a/lib/domain/entities/auth_exception.dart +++ b/lib/domain/entities/auth_exception.dart @@ -1,4 +1,4 @@ -abstract class AuthException { +sealed class AuthException { const factory AuthException.unknown(Exception exception) = AuthExceptionUnknown; const factory AuthException.canceled() = AuthExceptionCanceled; diff --git a/lib/domain/repositories/accounts.dart b/lib/domain/repositories/accounts.dart index 2b677ae..fc251c7 100644 --- a/lib/domain/repositories/accounts.dart +++ b/lib/domain/repositories/accounts.dart @@ -1,7 +1,7 @@ import '../entities.dart'; abstract class Accounts { - Future signInWithGoogle(); + Future signIn(); Stream get onAuthStateChanged; diff --git a/lib/domain/use_cases/sign_in_use_case.dart b/lib/domain/use_cases/sign_in_use_case.dart new file mode 100644 index 0000000..890a399 --- /dev/null +++ b/lib/domain/use_cases/sign_in_use_case.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:rxdart/transformers.dart'; +import 'package:tailor_made/core.dart'; + +import '../entities/account_entity.dart'; +import '../entities/auth_exception.dart'; +import '../repositories/accounts.dart'; + +class SignInUseCase { + const SignInUseCase({required Accounts accounts}) : _accounts = accounts; + + final Accounts _accounts; + + Future call() async { + final Completer completer = Completer(); + + late StreamSubscription sub; + sub = _accounts.onAuthStateChanged.whereType().listen( + (_) { + completer.complete(_accounts.fetch()); + sub.cancel(); + }, + onError: (Object error, StackTrace stackTrace) { + completer.completeError(error, stackTrace); + sub.cancel(); + }, + ); + + try { + await _accounts.signIn(); + } on AuthException catch (error, stackTrace) { + if (error is AuthExceptionFailed) { + AppLog.e(error, stackTrace); + } + rethrow; + } + + return completer.future; + } +} diff --git a/lib/domain/use_cases/sign_out_use_case.dart b/lib/domain/use_cases/sign_out_use_case.dart new file mode 100644 index 0000000..98bb429 --- /dev/null +++ b/lib/domain/use_cases/sign_out_use_case.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import '../repositories/accounts.dart'; + +class SignOutUseCase { + const SignOutUseCase({required Accounts accounts}) : _accounts = accounts; + + final Accounts _accounts; + + Future call() async { + final Completer completer = Completer(); + + late StreamSubscription sub; + sub = _accounts.onAuthStateChanged.where((String? id) => id == null).listen( + (_) { + completer.complete(); + sub.cancel(); + }, + onError: (Object error, StackTrace st) { + completer.completeError(error, st); + sub.cancel(); + }, + ); + + await _accounts.signOut(); + + return completer.future; + } +} diff --git a/lib/main.dart b/lib/main.dart index 89ef971..a2ce74b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -75,6 +75,8 @@ void main(List args) async { ..set(repository.measures) ..set(repository.stats) ..factory((RegistryFactory di) => FetchAccountUseCase(accounts: di())) + ..factory((RegistryFactory di) => SignInUseCase(accounts: di())) + ..factory((RegistryFactory di) => SignOutUseCase(accounts: di())) ..set(ContactsCoordinator(navigatorKey)) ..set(GalleryCoordinator(navigatorKey)) ..set(SharedCoordinator(navigatorKey)) diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index 8616c63..794cd98 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -58,7 +58,7 @@ class _AppState extends State { child: child!, ), onGenerateRoute: (RouteSettings settings) => _PageRoute( - builder: (_) => SplashPage(isColdStart: true, isMock: environment.isMock), + builder: (_) => const SplashPage(isColdStart: true), settings: RouteSettings(name: AppRoutes.start, arguments: settings.arguments), ), ), diff --git a/lib/presentation/coordinator/shared_coordinator.dart b/lib/presentation/coordinator/shared_coordinator.dart index 11f92ac..3a5706e 100644 --- a/lib/presentation/coordinator/shared_coordinator.dart +++ b/lib/presentation/coordinator/shared_coordinator.dart @@ -12,9 +12,9 @@ import 'coordinator_base.dart'; class SharedCoordinator extends CoordinatorBase { const SharedCoordinator(super.navigatorKey); - void toHome(bool isMock) { + void toHome() { navigator?.pushAndRemoveUntil( - RouteTransitions.fadeIn(HomePage(isMock: isMock)), + RouteTransitions.fadeIn(const HomePage()), (Route route) => false, ); } @@ -23,9 +23,9 @@ class SharedCoordinator extends CoordinatorBase { return navigator?.push(RouteTransitions.fadeIn(StoreNameDialog(account: account))); } - void toSplash(bool isMock) { + void toSplash() { navigator?.pushAndRemoveUntil( - RouteTransitions.fadeIn(SplashPage(isColdStart: false, isMock: isMock), name: AppRoutes.start), + RouteTransitions.fadeIn(const SplashPage(isColdStart: false), name: AppRoutes.start), (Route route) => false, ); } diff --git a/lib/presentation/screens/homepage/homepage.dart b/lib/presentation/screens/homepage/homepage.dart index a720d14..39a2702 100644 --- a/lib/presentation/screens/homepage/homepage.dart +++ b/lib/presentation/screens/homepage/homepage.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; -import 'package:tailor_made/presentation/screens/homepage/providers/home_notifier_provider.dart'; import 'package:version/version.dart'; +import 'providers/home_notifier_provider.dart'; import 'widgets/access_denied.dart'; import 'widgets/bottom_row.dart'; import 'widgets/create_button.dart'; @@ -17,9 +18,7 @@ import 'widgets/top_button_bar.dart'; import 'widgets/top_row.dart'; class HomePage extends StatelessWidget { - const HomePage({super.key, required this.isMock}); - - final bool isMock; + const HomePage({super.key}); @override Widget build(BuildContext context) { @@ -43,7 +42,7 @@ class HomePage extends StatelessWidget { builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(homeNotifierProvider).when( skipLoadingOnReload: true, data: (HomeState state) => AppVersionBuilder( - valueBuilder: () => AppVersion.retrieve(isMock), + valueBuilder: () => AppVersion.retrieve(environment.isMock), builder: (BuildContext context, String? appVersion, Widget? child) { if (appVersion == null) { return child!; @@ -67,7 +66,6 @@ class HomePage extends StatelessWidget { state: state, homeNotifier: ref.read(homeNotifierProvider.notifier), accountNotifier: ref.read(accountNotifierProvider.notifier), - isMock: isMock, ), ), error: ErrorView.new, @@ -88,13 +86,11 @@ class _Body extends StatelessWidget { required this.state, required this.homeNotifier, required this.accountNotifier, - required this.isMock, }); final HomeState state; final HomeNotifier homeNotifier; final AccountNotifier accountNotifier; - final bool isMock; @override Widget build(BuildContext context) { @@ -137,10 +133,7 @@ class _Body extends StatelessWidget { TopButtonBar( account: account, shouldSendRating: state.shouldSendRating, - onLogout: () { - accountNotifier.logout(); - context.registry.get().toSplash(isMock); - }, + onLogout: () => context.registry.get().toSplash(), ), ], ); diff --git a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart index 7b4fb74..10f2c0f 100644 --- a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart +++ b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tailor_made/domain.dart'; @@ -35,7 +36,7 @@ class HomeNotifier extends _$HomeNotifier { } } -class HomeState { +class HomeState with EquatableMixin { const HomeState({ required this.account, required this.contacts, @@ -62,22 +63,19 @@ class HomeState { bool get shouldSendRating => !account.hasSendRating && (contacts.length >= 10 || jobs.length >= 10); + @override + List get props => [account, contacts, jobs, stats, settings, isLoading, hasSkippedPremium]; + HomeState copyWith({ - AccountEntity? account, - List? contacts, - List? jobs, - StatsEntity? stats, - SettingEntity? settings, - bool? isLoading, bool? hasSkippedPremium, }) { return HomeState( - account: account ?? this.account, - contacts: contacts ?? this.contacts, - jobs: jobs ?? this.jobs, - stats: stats ?? this.stats, - settings: settings ?? this.settings, - isLoading: isLoading ?? this.isLoading, + account: account, + contacts: contacts, + jobs: jobs, + stats: stats, + settings: settings, + isLoading: isLoading, hasSkippedPremium: hasSkippedPremium ?? this.hasSkippedPremium, ); } diff --git a/lib/presentation/screens/homepage/widgets/bottom_row.dart b/lib/presentation/screens/homepage/widgets/bottom_row.dart index e96301c..15e2a43 100644 --- a/lib/presentation/screens/homepage/widgets/bottom_row.dart +++ b/lib/presentation/screens/homepage/widgets/bottom_row.dart @@ -35,7 +35,7 @@ class BottomRowWidget extends StatelessWidget { color: colorScheme.outline, icon: Icons.event, title: 'Tasks', - subTitle: '${stats.jobs.pending} Pending', + subTitle: '${stats.jobs.pending.toInt()} Pending', onPressed: () => context.registry.get().toTasks(), ), ), diff --git a/lib/presentation/screens/homepage/widgets/mid_row.dart b/lib/presentation/screens/homepage/widgets/mid_row.dart index 2cf26b8..60c9b6a 100644 --- a/lib/presentation/screens/homepage/widgets/mid_row.dart +++ b/lib/presentation/screens/homepage/widgets/mid_row.dart @@ -34,7 +34,7 @@ class MidRowWidget extends StatelessWidget { color: Colors.blueAccent, icon: Icons.image, title: 'Gallery', - subTitle: '${stats.gallery.total} Photos', + subTitle: '${stats.gallery.total.toInt()} Photos', onPressed: () => context.registry.get().toGallery(userId), ), ), diff --git a/lib/presentation/screens/homepage/widgets/stats.dart b/lib/presentation/screens/homepage/widgets/stats.dart index 3ccae7e..1c2e15e 100644 --- a/lib/presentation/screens/homepage/widgets/stats.dart +++ b/lib/presentation/screens/homepage/widgets/stats.dart @@ -18,7 +18,7 @@ class StatsWidget extends StatelessWidget { child: Row( children: [ Expanded( - child: _StatTile(title: 'Pending', count: stats.jobs.pending.toString()), + child: _StatTile(title: 'Pending', count: stats.jobs.pending.toInt().toString()), ), const _VerticalDivider(), Expanded( @@ -26,7 +26,7 @@ class StatsWidget extends StatelessWidget { ), const _VerticalDivider(), Expanded( - child: _StatTile(title: 'Completed', count: stats.jobs.completed.toString()), + child: _StatTile(title: 'Completed', count: stats.jobs.completed.toInt().toString()), ), ], ), @@ -43,7 +43,7 @@ class _VerticalDivider extends StatelessWidget { return Container( color: dividerTheme.color, width: dividerTheme.thickness, - height: _kStatGridsHeight, + height: 40.0, ); } } @@ -56,14 +56,14 @@ class _StatTile extends StatelessWidget { @override Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).textTheme; + return Column( children: [ - Text(count, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: AppFontWeight.medium)), + Text(count, style: textTheme.titleLarge?.copyWith(fontWeight: AppFontWeight.medium)), const SizedBox(height: 2.0), - Text(title, style: Theme.of(context).textTheme.bodySmall), + Text(title, style: textTheme.bodySmall), ], ); } } - -const double _kStatGridsHeight = 40.0; diff --git a/lib/presentation/screens/homepage/widgets/top_button_bar.dart b/lib/presentation/screens/homepage/widgets/top_button_bar.dart index 8676da8..e5e6418 100644 --- a/lib/presentation/screens/homepage/widgets/top_button_bar.dart +++ b/lib/presentation/screens/homepage/widgets/top_button_bar.dart @@ -43,7 +43,11 @@ class TopButtonBar extends StatelessWidget { ), child: Consumer( builder: (BuildContext context, WidgetRef ref, _) => GestureDetector( - onTap: _onTapAccount(context, ref.read(accountNotifierProvider.notifier)), + onTap: _onTapAccount( + context, + accountNotifier: ref.read(accountNotifierProvider.notifier), + authStateNotifier: ref.read(authStateNotifierProvider.notifier), + ), child: Stack( fit: StackFit.expand, children: [ @@ -76,7 +80,11 @@ class TopButtonBar extends StatelessWidget { bool get _shouldShowIndicator => !account.hasReadNotice || shouldSendRating; - VoidCallback _onTapAccount(BuildContext context, AccountNotifier notifier) { + VoidCallback _onTapAccount( + BuildContext context, { + required AccountNotifier accountNotifier, + required AuthStateNotifier authStateNotifier, + }) { final Registry registry = context.registry; return () async { @@ -84,14 +92,14 @@ class TopButtonBar extends StatelessWidget { final int? rating = await showChildDialog(context: context, child: const ReviewModal()); if (rating != null) { - notifier.sendRating(rating); + accountNotifier.sendRating(rating); } return; } if (_shouldShowIndicator) { await showChildDialog(context: context, child: NoticeDialog(account: account)); - notifier.readNotice(); + accountNotifier.readNotice(); return; } @@ -133,12 +141,7 @@ class TopButtonBar extends StatelessWidget { final String? storeName = await registry.get().toStoreNameDialog(account); if (storeName != null && storeName != account.storeName) { - await registry.get().updateAccount( - account.uid, - id: account.reference.id, - path: account.reference.path, - storeName: storeName, - ); + accountNotifier.updateStoreName(storeName); } break; @@ -148,6 +151,7 @@ class TopButtonBar extends StatelessWidget { final bool? response = await showChoiceDialog(context: context, message: 'You are about to logout.'); if (response == true) { + authStateNotifier.signOut(); onLogout(); } } diff --git a/lib/presentation/screens/homepage/widgets/top_row.dart b/lib/presentation/screens/homepage/widgets/top_row.dart index 10ee7fb..cafbe91 100644 --- a/lib/presentation/screens/homepage/widgets/top_row.dart +++ b/lib/presentation/screens/homepage/widgets/top_row.dart @@ -23,7 +23,7 @@ class TopRowWidget extends StatelessWidget { icon: Icons.supervisor_account, color: Colors.orangeAccent, title: 'Contacts', - subTitle: '${stats.contacts.total} Contacts', + subTitle: '${stats.contacts.total.toInt()} Contacts', onPressed: () => context.registry.get().toContacts(), ), ), @@ -34,7 +34,7 @@ class TopRowWidget extends StatelessWidget { icon: Icons.work, color: Colors.greenAccent.shade400, title: 'Jobs', - subTitle: '${stats.jobs.total} Total', + subTitle: '${stats.jobs.total.toInt()} Total', onPressed: () => context.registry.get().toJobs(), ), ), diff --git a/lib/presentation/screens/splash/splash.dart b/lib/presentation/screens/splash/splash.dart index 10cb2a9..faf0ad5 100644 --- a/lib/presentation/screens/splash/splash.dart +++ b/lib/presentation/screens/splash/splash.dart @@ -1,193 +1,164 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/core.dart'; -import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation.dart'; -class SplashPage extends StatelessWidget { - const SplashPage({super.key, required this.isColdStart, required this.isMock}); +import '../../constants.dart'; +import '../../coordinator.dart'; +import '../../registry.dart'; +import '../../state.dart'; +import '../../theme.dart'; +import '../../utils.dart'; +import '../../widgets.dart'; + +class SplashPage extends StatefulWidget { + const SplashPage({super.key, required this.isColdStart}); final bool isColdStart; - final bool isMock; + + @override + State createState() => SplashPageState(); +} + +@visibleForTesting +class SplashPageState extends State { + static const Key dataViewKey = Key('dataViewKey'); @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - return AppStatusBar( - child: Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - const Opacity( - opacity: .5, - child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage(image: AppImages.pattern, fit: BoxFit.cover), - ), + return Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + const Opacity( + opacity: .5, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage(image: AppImages.pattern, fit: BoxFit.cover), ), ), - Positioned.fill( - top: null, - bottom: 32.0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.appName, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: AppFontWeight.semibold), - textAlign: TextAlign.center, + ), + Positioned.fill( + bottom: MediaQuery.paddingOf(context).bottom + 16.0, + child: Column( + children: [ + Expanded( + child: _DataView( + key: dataViewKey, + isColdStart: widget.isColdStart, ), - AppVersionBuilder( - valueBuilder: () => AppVersion.retrieve(isMock), - builder: (_, String? version, __) => Text( - 'v$version', - style: theme.textTheme.bodySmall?.copyWith(height: 1.5), - textAlign: TextAlign.center, - ), + ), + const SizedBox(height: 16.0), + Text( + context.l10n.appName, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: AppFontWeight.semibold), + textAlign: TextAlign.center, + ), + AppVersionBuilder( + valueBuilder: () => AppVersion.retrieve(environment.isMock), + builder: (_, String? version, __) => Text( + 'v$version', + style: theme.textTheme.bodySmall?.copyWith(height: 1.5), + textAlign: TextAlign.center, ), - ], - ), - ), - StreamBuilder( - // TODO(Jogboms): move this out of here - stream: context.registry.get().onAuthStateChanged, - builder: (BuildContext context, AsyncSnapshot snapshot) { - final String? data = snapshot.data; - if (data != null) { - WidgetsBinding.instance.addPostFrameCallback( - (_) async => context.registry.get().toHome(isMock), - ); - - return const SizedBox(); - } - - return _Content(isColdStart: isColdStart); - }, + ), + ], ), - ], - ), + ), + ], ), ); } } -class _Content extends StatefulWidget { - const _Content({required this.isColdStart}); +class _DataView extends ConsumerStatefulWidget { + const _DataView({super.key, required this.isColdStart}); final bool isColdStart; @override - State<_Content> createState() => _ContentState(); + ConsumerState<_DataView> createState() => OnboardingDataViewState(); } -class _ContentState extends State<_Content> { - late bool _isLoading; +@visibleForTesting +class OnboardingDataViewState extends ConsumerState<_DataView> { + static const Key signInButtonKey = Key('signInButtonKey'); + late final AuthStateNotifier auth = ref.read(authStateNotifierProvider.notifier); @override void initState() { super.initState(); - _isLoading = widget.isColdStart; + if (widget.isColdStart) { - _onLogin(); + Future.microtask(auth.signIn); } } @override Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(settingsProvider).when( - data: (SettingEntity data) { - return Stack( - children: [ - if (!_isLoading || !widget.isColdStart) - const Center( - child: Image( - image: AppImages.logo, - width: 148.0, - color: Colors.white30, - colorBlendMode: BlendMode.saturation, - ), - ), - Positioned.fill( - top: null, - bottom: 124.0, - child: Builder( - builder: (_) { - if ((widget.isColdStart) || _isLoading) { - return const LoadingSpinner(); - } - - return Center( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - ), - icon: const Image(image: AppImages.googleLogo, width: 24.0), - label: Text( - 'Continue with Google', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: AppFontWeight.bold), - ), - onPressed: _onLogin, - ), - ); - }, - ), - ) - ], - ); - }, - error: (Object error, _) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 48.0, vertical: 16.0), - child: Center( - child: Column( - children: [ - Text(error.toString(), textAlign: TextAlign.center), - const SizedBox(height: 8.0), - ElevatedButton( - child: const Text('RETRY'), - onPressed: () => ref.invalidate(settingsProvider), + ref.listen(authStateNotifierProvider, _authStateListener); + + return AnimatedSwitcher( + duration: kThemeAnimationDuration, + child: ref.watch(authStateNotifierProvider) == AuthState.loading + ? const Center(child: LoadingSpinner()) + : Stack( + children: [ + const Center( + child: Image( + image: AppImages.logo, + width: 148.0, + color: Colors.white30, + colorBlendMode: BlendMode.saturation, + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: ElevatedButton.icon( + key: signInButtonKey, + style: ElevatedButton.styleFrom(backgroundColor: Colors.white), + icon: const Image(image: AppImages.googleLogo, width: 24.0), + label: Text( + context.l10n.continueWithGoogle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: AppFontWeight.bold), ), - ], + onPressed: auth.signIn, + ), ), - ), + ], ), - loading: () => child!, - ), - child: const LoadingSpinner(), ); } - void _onLogin() async { - final Accounts accounts = context.registry.get(); - try { - setState(() => _isLoading = true); - // TODO(Jogboms): move this out of here - await accounts.signInWithGoogle(); - } catch (error, stackTrace) { - AppLog.e(error, stackTrace); - if (!mounted) { - return; + void _authStateListener(AuthState? _, AuthState state) { + if (state is AuthErrorState) { + final AppSnackBar snackBar = context.snackBar; + final String message = state.toPrettyMessage(context.l10n, environment.isProduction); + if (state.reason != AuthErrorStateReason.popupBlockedByBrowser) { + snackBar.error(message); } + } else if (state == AuthState.complete) { + context.registry.get().toHome(); + } + } +} - // TODO(Jogboms): move this out of here - final Environment environment = context.registry.get(); - final String message = AppStrings.genericError(error, environment.isDev)!; - - if (message.isNotEmpty) { - AppSnackBar.of(context).error(message, duration: const Duration(milliseconds: 3500)); - } - - // TODO(Jogboms): move this out of here - await accounts.signOut(); - - if (!mounted) { - return; - } - - setState(() => _isLoading = false); - - AppSnackBar.of(context).error(error.toString()); +extension on AuthErrorState { + String toPrettyMessage(L10n l10n, bool isProduction) { + switch (reason) { + case AuthErrorStateReason.message: + return isProduction ? l10n.genericErrorMessage : error; + case AuthErrorStateReason.tooManyRequests: + return l10n.tryAgainMessage; + case AuthErrorStateReason.userDisabled: + return l10n.bannedUserMessage; + case AuthErrorStateReason.failed: + return l10n.failedSignInMessage; + case AuthErrorStateReason.networkUnavailable: + return l10n.tryAgainMessage; + case AuthErrorStateReason.popupBlockedByBrowser: + return l10n.popupBlockedByBrowserMessage; } } } diff --git a/lib/presentation/state.dart b/lib/presentation/state.dart index bc67f04..f7cb206 100644 --- a/lib/presentation/state.dart +++ b/lib/presentation/state.dart @@ -1,5 +1,6 @@ export 'state/account_notifier_provider.dart'; export 'state/account_provider.dart'; +export 'state/auth_state_notifier_provider.dart'; export 'state/contacts_provider.dart'; export 'state/jobs_provider.dart'; export 'state/measurements_provider.dart'; diff --git a/lib/presentation/state/account_notifier_provider.dart b/lib/presentation/state/account_notifier_provider.dart index 4fa320d..d502c64 100644 --- a/lib/presentation/state/account_notifier_provider.dart +++ b/lib/presentation/state/account_notifier_provider.dart @@ -12,6 +12,17 @@ class AccountNotifier extends _$AccountNotifier { @override Future build() async => ref.watch(accountProvider.future); + void updateStoreName(String name) async { + final AccountEntity account = state.requireValue; + await ref.read(registryProvider).get().updateAccount( + account.uid, + id: account.reference.id, + path: account.reference.path, + storeName: name, + ); + ref.invalidate(accountProvider); + } + void readNotice() async { final AccountEntity account = state.requireValue; await ref.read(registryProvider).get().updateAccount( @@ -37,7 +48,6 @@ class AccountNotifier extends _$AccountNotifier { void premiumSetup() async { final SettingEntity settings = await ref.watch(settingsProvider.future); - await ref.read(registryProvider).get().signUp( state.requireValue.copyWith( status: AccountStatus.pending, @@ -48,9 +58,4 @@ class AccountNotifier extends _$AccountNotifier { ); ref.invalidate(accountProvider); } - - void logout() async { - await ref.read(registryProvider).get().signOut(); - ref.invalidate(accountProvider); - } } diff --git a/lib/presentation/state/auth_state_notifier_provider.dart b/lib/presentation/state/auth_state_notifier_provider.dart new file mode 100644 index 0000000..bc6f585 --- /dev/null +++ b/lib/presentation/state/auth_state_notifier_provider.dart @@ -0,0 +1,124 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:registry/registry.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tailor_made/core.dart'; +import 'package:tailor_made/domain.dart'; + +import 'account_provider.dart'; +import 'registry_provider.dart'; +import 'state_notifier_mixin.dart'; + +part 'auth_state_notifier_provider.g.dart'; + +@Riverpod(dependencies: [registry, account]) +class AuthStateNotifier extends _$AuthStateNotifier with StateNotifierMixin { + @override + AuthState build() => AuthState.idle; + + void signIn() async { + final RegistryFactory di = ref.read(registryProvider).get; + + setState(AuthState.loading); + + try { + await di()(); + + setState(AuthState.complete); + } on AuthException catch (error, stackTrace) { + switch (error) { + case AuthExceptionCanceled(): + setState(AuthState.idle); + case AuthExceptionUserNotFound(): + // TODO(jogboms): account creation happens on the backend. get rid of this + _waitForAccountSetup(); + case AuthExceptionNetworkUnavailable(): + setState(AuthState.reason(AuthErrorStateReason.networkUnavailable)); + case AuthExceptionPopupBlockedByBrowser(): + setState(AuthState.reason(AuthErrorStateReason.popupBlockedByBrowser)); + case AuthExceptionTooManyRequests(): + setState(AuthState.reason(AuthErrorStateReason.tooManyRequests)); + case AuthExceptionUserDisabled(): + setState(AuthState.reason(AuthErrorStateReason.userDisabled)); + case AuthExceptionFailed(): + setState(AuthState.reason(AuthErrorStateReason.failed)); + case AuthExceptionInvalidEmail(): + case AuthExceptionUnknown(): + _handleError(error, stackTrace); + } + } catch (error, stackTrace) { + await di()(); + _handleError(error, stackTrace); + } + } + + void signOut() async { + setState(AuthState.loading); + + try { + await ref.read(registryProvider).get()(); + ref.invalidate(accountProvider); + setState(AuthState.complete); + } catch (error, stackTrace) { + _handleError(error, stackTrace); + } + } + + // TODO(Jogboms): need to get rid of this + void _waitForAccountSetup() async { + setState(AuthState.loading); + + try { + await ref.read(registryProvider).get()(); + setState(AuthState.complete); + } on AuthExceptionUserNotFound { + _waitForAccountSetup(); + } + } + + void _handleError(Object error, StackTrace stackTrace) { + final String message = error.toString(); + AppLog.e(error, stackTrace); + setState(AuthState.error(message)); + } +} + +@visibleForTesting +enum AuthStateType { idle, loading, error, complete } + +base class AuthState with EquatableMixin { + const AuthState(this.type); + + factory AuthState.error(String error, [AuthErrorStateReason reason]) = AuthErrorState; + + factory AuthState.reason(AuthErrorStateReason reason) => AuthState.error('', reason); + + static const AuthState idle = AuthState(AuthStateType.idle); + static const AuthState loading = AuthState(AuthStateType.loading); + static const AuthState complete = AuthState(AuthStateType.complete); + + @visibleForTesting + final AuthStateType type; + + @override + List get props => [type]; +} + +enum AuthErrorStateReason { + message, + failed, + networkUnavailable, + popupBlockedByBrowser, + tooManyRequests, + userDisabled, +} + +final class AuthErrorState extends AuthState { + const AuthErrorState(this.error, [this.reason = AuthErrorStateReason.message]) : super(AuthStateType.error); + + final String error; + final AuthErrorStateReason reason; + + @override + List get props => [...super.props, error, reason]; +} diff --git a/test/presentation/app_test.dart b/test/presentation/app_test.dart index c57122f..05fe6db 100644 --- a/test/presentation/app_test.dart +++ b/test/presentation/app_test.dart @@ -23,7 +23,7 @@ void main() { navigatorKey: navigatorKey, ); - when(mockRepositories.accounts.signInWithGoogle).thenAnswer((_) async {}); + when(mockRepositories.accounts.signIn).thenAnswer((_) async {}); when(() => mockRepositories.accounts.onAuthStateChanged).thenAnswer((_) => Stream.value('1')); when(() => mockRepositories.accounts.getAccount(any())).thenAnswer((_) => AccountsMockImpl().getAccount('1')); when(mockRepositories.settings.fetch).thenAnswer((_) => SettingsMockImpl().fetch()); From be79845659927110a40dcfe1b73e93b3cc6f1d16 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Sun, 2 Jul 2023 18:17:48 +0200 Subject: [PATCH 16/17] Clean up --- README.md | 17 +--- .../coordinator/contacts_coordinator.dart | 8 +- .../coordinator/jobs_coordinator.dart | 5 +- .../screens/contacts/contact.dart | 1 - .../screens/contacts/contacts.dart | 4 +- .../screens/contacts/contacts_create.dart | 28 +++--- .../screens/contacts/contacts_edit.dart | 29 ++++-- .../providers/selected_contact_provider.dart | 8 +- .../contacts/widgets/contact_appbar.dart | 6 +- .../screens/homepage/homepage.dart | 2 +- .../providers/home_notifier_provider.dart | 9 +- .../homepage/widgets/create_button.dart | 72 +++++++------- lib/presentation/screens/jobs/jobs.dart | 5 +- .../screens/jobs/jobs_create.dart | 96 +++++++++++++++---- .../screens/jobs/jobs_create_view_model.dart | 30 +++--- pubspec.lock | 66 ++++--------- pubspec.yaml | 19 ++-- 17 files changed, 209 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 1a185bf..f0397a3 100644 --- a/README.md +++ b/README.md @@ -14,22 +14,9 @@ --- -TailorMade is what actually started out as an experiment with [Flutter](https://flutter.io/), [~flutter_redux~](https://github.com/brianegan/flutter_redux) [ReBLoC](https://github.com/redbrogdon/rebloc) and [Firebase Cloud Functions](https://github.com/flutter/plugins/tree/master/packages/cloud_functions) but instead turned out to be a valuable tool for managing a Fashion designer's daily routine. It is clean, easy on the eyes and overall has a very smooth feel. It also handles offline use cases with Firebase Cloud. Logo, Design & Concept by Me. +TailorMade is what actually started out as an experiment with [Flutter](https://flutter.io/) and [Firebase Cloud Functions](https://github.com/flutter/plugins/tree/master/packages/cloud_functions) but instead turned out to be a valuable tool for managing a Fashion designer's daily routine. It is clean, easy on the eyes and overall has a very smooth feel. It also handles offline use cases with Firebase Cloud. Logo, Design & Concept by Me. -## Tools - -- Firebase Auth -- Firebase Cloud Firestore -- Firebase Cloud Functions -- Firebase Storage -- Google SignIn -- RxDart -- ReBLoC -- Built Value & Collection -- Equatable -- Injector - -For a full description of OSS used, see pubspec.yaml +> For a full description of OSS used, see pubspec.yaml ## UI Shots diff --git a/lib/presentation/coordinator/contacts_coordinator.dart b/lib/presentation/coordinator/contacts_coordinator.dart index 11fd01e..41415a9 100644 --- a/lib/presentation/coordinator/contacts_coordinator.dart +++ b/lib/presentation/coordinator/contacts_coordinator.dart @@ -20,8 +20,8 @@ class ContactsCoordinator extends CoordinatorBase { : navigator?.push(RouteTransitions.slideIn(ContactPage(id: id))); } - void toContactEdit(String userId, ContactEntity contact) { - navigator?.push(RouteTransitions.slideIn(ContactsEditPage(userId: userId, contact: contact))); + void toContactEdit(ContactEntity contact) { + navigator?.push(RouteTransitions.slideIn(ContactsEditPage(contact: contact))); } Future?>? toContactMeasure({ @@ -41,7 +41,7 @@ class ContactsCoordinator extends CoordinatorBase { return navigator?.push(RouteTransitions.fadeIn(ContactLists(contacts: contacts))); } - void toCreateContact(String userId) { - navigator?.push(RouteTransitions.slideIn(ContactsCreatePage(userId: userId))); + void toCreateContact() { + navigator?.push(RouteTransitions.slideIn(const ContactsCreatePage())); } } diff --git a/lib/presentation/coordinator/jobs_coordinator.dart b/lib/presentation/coordinator/jobs_coordinator.dart index ad52cf9..b2ab157 100644 --- a/lib/presentation/coordinator/jobs_coordinator.dart +++ b/lib/presentation/coordinator/jobs_coordinator.dart @@ -21,8 +21,7 @@ class JobsCoordinator extends CoordinatorBase { navigator?.push(RouteTransitions.slideIn(const JobsPage())); } - void toCreateJob(String userId, List contacts, [ContactEntity? contact]) { - navigator - ?.push(RouteTransitions.slideIn(JobsCreatePage(userId: userId, contacts: contacts, contact: contact))); + void toCreateJob(String? contactId) { + navigator?.push(RouteTransitions.slideIn(JobsCreatePage(contactId: contactId))); } } diff --git a/lib/presentation/screens/contacts/contact.dart b/lib/presentation/screens/contacts/contact.dart index bbb20a9..081404a 100644 --- a/lib/presentation/screens/contacts/contact.dart +++ b/lib/presentation/screens/contacts/contact.dart @@ -31,7 +31,6 @@ class ContactPage extends StatelessWidget { title: ref.watch(selectedContactProvider(id)).maybeWhen( skipLoadingOnReload: true, data: (ContactState data) => ContactAppBar( - userId: data.userId, contact: data.contact, grouped: data.measurements, ), diff --git a/lib/presentation/screens/contacts/contacts.dart b/lib/presentation/screens/contacts/contacts.dart index 2df3828..5edb886 100644 --- a/lib/presentation/screens/contacts/contacts.dart +++ b/lib/presentation/screens/contacts/contacts.dart @@ -44,9 +44,7 @@ class _ContactsPageState extends State { ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.person_add), - onPressed: () => context.registry.get().toCreateContact( - ref.read(accountProvider).requireValue.uid, //todo: remove - ), + onPressed: () => context.registry.get().toCreateContact(), ), ), onWillPop: () async { diff --git a/lib/presentation/screens/contacts/contacts_create.dart b/lib/presentation/screens/contacts/contacts_create.dart index 15fff95..e63e488 100644 --- a/lib/presentation/screens/contacts/contacts_create.dart +++ b/lib/presentation/screens/contacts/contacts_create.dart @@ -5,14 +5,11 @@ import 'package:registry/registry.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; -import 'package:uuid/uuid.dart'; import 'widgets/contact_form.dart'; class ContactsCreatePage extends StatefulWidget { - const ContactsCreatePage({super.key, required this.userId}); - - final String userId; + const ContactsCreatePage({super.key}); @override State createState() => _ContactsCreatePageState(); @@ -20,7 +17,6 @@ class ContactsCreatePage extends StatefulWidget { class _ContactsCreatePageState extends State { final GlobalKey _formKey = GlobalKey(); - late final String id = const Uuid().v4(); late CreateContactData _contact = const CreateContactData( fullname: '', phone: '', @@ -60,11 +56,19 @@ class _ContactsCreatePageState extends State { ), ], ), - body: ContactForm( - key: _formKey, - contact: _contact, - onHandleSubmit: _handleSubmit, - userId: widget.userId, + body: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(accountProvider).when( + skipLoadingOnReload: true, + data: (AccountEntity data) => ContactForm( + key: _formKey, + contact: _contact, + onHandleSubmit: (CreateContactData contact) => _handleSubmit(contact, data.uid), + userId: data.uid, + ), + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), ); } @@ -86,7 +90,7 @@ class _ContactsCreatePageState extends State { ); } - void _handleSubmit(CreateContactData contact) async { + void _handleSubmit(CreateContactData contact, String userId) async { final AppSnackBar snackBar = AppSnackBar.of(context); if (contact.measurements.isEmpty) { snackBar.info(AppStrings.leavingEmptyMeasures); @@ -107,7 +111,7 @@ class _ContactsCreatePageState extends State { ); // TODO(Jogboms): move this out of here - final ContactEntity snap = await contacts.create(widget.userId, contact); + final ContactEntity snap = await contacts.create(userId, contact); snackBar.success('Successfully Added'); contactsCoordinator.toContact(snap.id, replace: true); diff --git a/lib/presentation/screens/contacts/contacts_edit.dart b/lib/presentation/screens/contacts/contacts_edit.dart index 4a8b97e..b59d31c 100644 --- a/lib/presentation/screens/contacts/contacts_edit.dart +++ b/lib/presentation/screens/contacts/contacts_edit.dart @@ -1,17 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_native_contact_picker/flutter_native_contact_picker.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tailor_made/core.dart'; import 'package:tailor_made/domain.dart'; -import 'package:tailor_made/presentation/registry.dart'; +import '../../registry.dart'; +import '../../state.dart'; import '../../widgets.dart'; import 'widgets/contact_form.dart'; class ContactsEditPage extends StatefulWidget { - const ContactsEditPage({super.key, required this.contact, required this.userId}); + const ContactsEditPage({super.key, required this.contact}); final ContactEntity contact; - final String userId; @override State createState() => _ContactsEditPageState(); @@ -36,11 +37,19 @@ class _ContactsEditPageState extends State { IconButton(icon: const Icon(Icons.contacts), onPressed: _handleSelectContact), ], ), - body: ContactForm( - key: _formKey, - contact: _contact, - onHandleSubmit: _handleSubmit, - userId: widget.userId, + body: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) => ref.watch(accountProvider).when( + skipLoadingOnReload: true, + data: (AccountEntity data) => ContactForm( + key: _formKey, + contact: _contact, + onHandleSubmit: (CreateContactData contact) => _handleSubmit(contact, data.uid), + userId: data.uid, + ), + error: ErrorView.new, + loading: () => child!, + ), + child: const Center(child: LoadingSpinner()), ), ); } @@ -63,13 +72,13 @@ class _ContactsEditPageState extends State { ); } - void _handleSubmit(CreateContactData contact) async { + void _handleSubmit(CreateContactData contact, String userId) async { final AppSnackBar snackBar = AppSnackBar.of(context)..loading(); try { // TODO(Jogboms): move this out of here await context.registry.get().update( - widget.userId, + userId, reference: widget.contact.reference, fullname: contact.fullname, phone: contact.phone, diff --git a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart index 8fa35bd..9bcfaca 100644 --- a/lib/presentation/screens/contacts/providers/selected_contact_provider.dart +++ b/lib/presentation/screens/contacts/providers/selected_contact_provider.dart @@ -6,9 +6,8 @@ import '../../../state.dart'; part 'selected_contact_provider.g.dart'; -@Riverpod(dependencies: [account, jobs, contacts, measurements]) +@Riverpod(dependencies: [jobs, contacts, measurements]) Future selectedContact(SelectedContactRef ref, String id) async { - final AccountEntity account = await ref.watch(accountProvider.future); final ContactEntity contact = await ref.watch( contactsProvider.selectAsync((_) => _.firstWhere((_) => _.id == id)), ); @@ -20,7 +19,6 @@ Future selectedContact(SelectedContactRef ref, String id) async { return ContactState( contact: contact, jobs: jobs, - userId: account.uid, measurements: measurements.grouped, ); } @@ -29,15 +27,13 @@ class ContactState with EquatableMixin { const ContactState({ required this.contact, required this.jobs, - required this.userId, required this.measurements, }); final ContactEntity contact; final List jobs; - final String userId; final Map> measurements; @override - List get props => [contact, userId, jobs, measurements]; + List get props => [contact, jobs, measurements]; } diff --git a/lib/presentation/screens/contacts/widgets/contact_appbar.dart b/lib/presentation/screens/contacts/widgets/contact_appbar.dart index 92bf6b0..1307704 100644 --- a/lib/presentation/screens/contacts/widgets/contact_appbar.dart +++ b/lib/presentation/screens/contacts/widgets/contact_appbar.dart @@ -8,14 +8,12 @@ enum Choice { createJob, editMeasure, editAccount, sendText } class ContactAppBar extends StatefulWidget { const ContactAppBar({ super.key, - required this.userId, required this.grouped, required this.contact, }); final Map> grouped; final ContactEntity contact; - final String userId; @override State createState() => _ContactAppBarState(); @@ -26,13 +24,13 @@ class _ContactAppBarState extends State { final Registry registry = context.registry; switch (choice) { case Choice.createJob: - registry.get().toCreateJob(widget.userId, [], widget.contact); + registry.get().toCreateJob(widget.contact.id); break; case Choice.editMeasure: registry.get().toContactMeasure(contact: widget.contact, grouped: widget.grouped); break; case Choice.editAccount: - registry.get().toContactEdit(widget.userId, widget.contact); + registry.get().toContactEdit(widget.contact); break; case Choice.sendText: sms(widget.contact.phone); diff --git a/lib/presentation/screens/homepage/homepage.dart b/lib/presentation/screens/homepage/homepage.dart index 39a2702..aa496d0 100644 --- a/lib/presentation/screens/homepage/homepage.dart +++ b/lib/presentation/screens/homepage/homepage.dart @@ -129,7 +129,7 @@ class _Body extends StatelessWidget { SizedBox(height: Theme.of(context).buttonTheme.height + MediaQuery.of(context).padding.bottom), ], ), - CreateButton(userId: account.uid, contacts: state.contacts), + const CreateButton(), TopButtonBar( account: account, shouldSendRating: state.shouldSendRating, diff --git a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart index 10f2c0f..df5244e 100644 --- a/lib/presentation/screens/homepage/providers/home_notifier_provider.dart +++ b/lib/presentation/screens/homepage/providers/home_notifier_provider.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter/cupertino.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tailor_made/domain.dart'; @@ -20,7 +21,6 @@ class HomeNotifier extends _$HomeNotifier { account: account, contacts: contacts, jobs: jobs, - isLoading: false, stats: stats, settings: settings, hasSkippedPremium: false, @@ -43,16 +43,16 @@ class HomeState with EquatableMixin { required this.jobs, required this.stats, required this.settings, - required this.isLoading, required this.hasSkippedPremium, }); final AccountEntity account; + @visibleForTesting final List contacts; + @visibleForTesting final List jobs; final StatsEntity stats; final SettingEntity settings; - final bool isLoading; final bool hasSkippedPremium; bool get isDisabled => account.status == AccountStatus.disabled; @@ -64,7 +64,7 @@ class HomeState with EquatableMixin { bool get shouldSendRating => !account.hasSendRating && (contacts.length >= 10 || jobs.length >= 10); @override - List get props => [account, contacts, jobs, stats, settings, isLoading, hasSkippedPremium]; + List get props => [account, contacts, jobs, stats, settings, hasSkippedPremium]; HomeState copyWith({ bool? hasSkippedPremium, @@ -75,7 +75,6 @@ class HomeState with EquatableMixin { jobs: jobs, stats: stats, settings: settings, - isLoading: isLoading, hasSkippedPremium: hasSkippedPremium ?? this.hasSkippedPremium, ); } diff --git a/lib/presentation/screens/homepage/widgets/create_button.dart b/lib/presentation/screens/homepage/widgets/create_button.dart index b05f950..b35aba6 100644 --- a/lib/presentation/screens/homepage/widgets/create_button.dart +++ b/lib/presentation/screens/homepage/widgets/create_button.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:registry/registry.dart'; -import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; import 'helpers.dart'; @@ -8,10 +7,7 @@ import 'helpers.dart'; enum _CreateOptions { contacts, jobs } class CreateButton extends StatefulWidget { - const CreateButton({super.key, required this.userId, required this.contacts}); - - final List contacts; - final String userId; + const CreateButton({super.key}); @override State createState() => _CreateButtonState(); @@ -40,7 +36,7 @@ class _CreateButtonState extends State with SingleTickerProviderSt width: double.infinity, child: PrimaryButton( useSafeArea: true, - onPressed: _onTapCreate(widget.contacts), + onPressed: _onTapCreate, shape: const RoundedRectangleBorder(), child: ScaleTransition( scale: Tween(begin: 0.95, end: 1.025).animate(_controller), @@ -55,40 +51,38 @@ class _CreateButtonState extends State with SingleTickerProviderSt ); } - VoidCallback _onTapCreate(List contacts) { - return () async { - final Registry registry = context.registry; - final _CreateOptions? result = await showDialog<_CreateOptions>( - context: context, - builder: (BuildContext context) { - return SimpleDialog( - title: Text('Select action', style: Theme.of(context).textTheme.labelLarge), - children: [ - SimpleDialogOption( - onPressed: () => Navigator.pop(context, _CreateOptions.contacts), - child: const TMListTile(color: Colors.orangeAccent, icon: Icons.supervisor_account, title: 'Contact'), - ), - SimpleDialogOption( - onPressed: () => Navigator.pop(context, _CreateOptions.jobs), - child: TMListTile(color: Colors.greenAccent.shade400, icon: Icons.attach_money, title: 'Job'), - ), - ], - ); - }, - ); + void _onTapCreate() async { + final Registry registry = context.registry; + final _CreateOptions? result = await showDialog<_CreateOptions>( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text('Select action', style: Theme.of(context).textTheme.labelLarge), + children: [ + SimpleDialogOption( + onPressed: () => Navigator.pop(context, _CreateOptions.contacts), + child: const TMListTile(color: Colors.orangeAccent, icon: Icons.supervisor_account, title: 'Contact'), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(context, _CreateOptions.jobs), + child: TMListTile(color: Colors.greenAccent.shade400, icon: Icons.attach_money, title: 'Job'), + ), + ], + ); + }, + ); - if (result == null) { - return; - } + if (result == null) { + return; + } - switch (result) { - case _CreateOptions.contacts: - registry.get().toCreateContact(widget.userId); - break; - case _CreateOptions.jobs: - registry.get().toCreateJob(widget.userId, contacts); - break; - } - }; + switch (result) { + case _CreateOptions.contacts: + registry.get().toCreateContact(); + break; + case _CreateOptions.jobs: + registry.get().toCreateJob(null); + break; + } } } diff --git a/lib/presentation/screens/jobs/jobs.dart b/lib/presentation/screens/jobs/jobs.dart index 31d7f2a..b1b4937 100644 --- a/lib/presentation/screens/jobs/jobs.dart +++ b/lib/presentation/screens/jobs/jobs.dart @@ -45,10 +45,7 @@ class JobsPage extends StatelessWidget { ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.library_add), - onPressed: () => context.registry.get().toCreateJob( - ref.read(accountProvider).requireValue.uid, //todo: remove - ref.read(contactsProvider).requireValue, //todo: remove - ), + onPressed: () => context.registry.get().toCreateJob(null), ), ), onWillPop: () async { diff --git a/lib/presentation/screens/jobs/jobs_create.dart b/lib/presentation/screens/jobs/jobs_create.dart index fcc18a2..949f25b 100644 --- a/lib/presentation/screens/jobs/jobs_create.dart +++ b/lib/presentation/screens/jobs/jobs_create.dart @@ -4,6 +4,7 @@ import 'package:flutter_masked_text2/flutter_masked_text2.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:tailor_made/domain.dart'; +import 'package:tailor_made/presentation/screens/contacts/providers/selected_contact_provider.dart'; import 'package:tailor_made/presentation/theme.dart'; import 'package:tailor_made/presentation/widgets.dart'; @@ -15,28 +16,72 @@ import 'widgets/gallery_grid_item.dart'; import 'widgets/image_form_value.dart'; import 'widgets/input_dropdown.dart'; -class JobsCreatePage extends StatefulWidget { +class JobsCreatePage extends StatelessWidget { const JobsCreatePage({ + super.key, + required this.contactId, + }); + + static const Key dataViewKey = Key('dataViewKey'); + + final String? contactId; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) { + final String? userId = ref.watch(accountProvider).valueOrNull?.uid; + if (userId == null) { + return const Scaffold( + body: Center( + child: LoadingSpinner(), + ), + ); + } + + final ContactEntity? contact; + if (contactId case final String contactId) { + contact = ref.watch(selectedContactProvider(contactId)).valueOrNull?.contact; + } else { + contact = null; + } + + return _DataView( + key: dataViewKey, + contact: contact, + userId: userId, + ); + }, + ); + } +} + +class _DataView extends StatefulWidget { + const _DataView({ super.key, this.contact, - required this.contacts, required this.userId, }); final ContactEntity? contact; - final List contacts; final String userId; @override - State createState() => _JobsCreatePageState(); + State<_DataView> createState() => _DataViewState(); } -class _JobsCreatePageState extends JobsCreateViewModel { +class _DataViewState extends State<_DataView> with JobsCreateViewModel { final MoneyMaskedTextController _controller = MoneyMaskedTextController( decimalSeparator: '.', thousandSeparator: ',', ); + @override + ContactEntity? get defaultContact => widget.contact; + + @override + String get userId => widget.userId; + @override void dispose() { _controller.dispose(); @@ -63,27 +108,38 @@ class _JobsCreatePageState extends JobsCreateViewModel { style: theme.textTheme.pageTitle, ), subtitle: Text('${contact.totalJobs} Jobs', style: theme.textTheme.bodySmall), - actions: widget.contacts.isNotEmpty - ? [ - IconButton(icon: const Icon(Icons.people), onPressed: handleSelectContact), - ] - : null, + actions: [ + Consumer( + builder: (BuildContext context, WidgetRef ref, _) => ref.watch(contactsProvider).maybeWhen( + data: (List data) => IconButton( + icon: const Icon(Icons.people), + onPressed: () => handleSelectContact(data), + ), + orElse: () => const SizedBox.shrink(), + ), + ), + ], ) : CustomAppBar.empty, body: Builder( builder: (BuildContext context) { if (contact == null) { return Center( - child: AppClearButton( - onPressed: handleSelectContact, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircleAvatar(radius: 50.0, child: Icon(Icons.person_add)), - const SizedBox(height: 16.0), - Text('SELECT A CLIENT', style: theme.textTheme.bodySmall), - ], - ), + child: Consumer( + builder: (BuildContext context, WidgetRef ref, _) => ref.watch(contactsProvider).maybeWhen( + data: (List data) => AppClearButton( + onPressed: () => handleSelectContact(data), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircleAvatar(radius: 50.0, child: Icon(Icons.person_add)), + const SizedBox(height: 16.0), + Text('SELECT A CLIENT', style: theme.textTheme.bodySmall), + ], + ), + ), + orElse: () => const LoadingSpinner(), + ), ), ); } diff --git a/lib/presentation/screens/jobs/jobs_create_view_model.dart b/lib/presentation/screens/jobs/jobs_create_view_model.dart index 516c2a1..042f17b 100644 --- a/lib/presentation/screens/jobs/jobs_create_view_model.dart +++ b/lib/presentation/screens/jobs/jobs_create_view_model.dart @@ -7,29 +7,34 @@ import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation.dart'; import 'package:uuid/uuid.dart'; -import 'jobs_create.dart'; import 'widgets/image_form_value.dart'; -abstract class JobsCreateViewModel extends State { +@optionalTypeArgs +mixin JobsCreateViewModel on State { @protected - List images = []; + final List images = []; @protected late CreateJobData job; @protected late ContactEntity? contact; @protected final GlobalKey formKey = GlobalKey(); - @protected bool autovalidate = false; + @protected + String get userId; + + @protected + ContactEntity? get defaultContact; + @override void initState() { super.initState(); - contact = widget.contact; + contact = defaultContact; job = CreateJobData( id: const Uuid().v4(), - userID: widget.userId, + userID: userId, contactID: contact?.id, measurements: contact?.measurements ?? {}, price: 0.0, @@ -53,14 +58,14 @@ abstract class JobsCreateViewModel extends State { // TODO(Jogboms): move this out of here final ImageFileReference ref = await registry.get().createReferenceImage( path: imageFile.path, - userId: widget.userId, + userId: userId, ); setState(() { images.add( ImageCreateFormValue( CreateImageData( - userID: widget.userId, + userID: userId, contactID: contact!.id, jobID: job.id, src: ref.src, @@ -74,9 +79,8 @@ abstract class JobsCreateViewModel extends State { } } - void handleSelectContact() async { - final ContactEntity? selectedContact = - await context.registry.get().toContactsList(widget.contacts); + void handleSelectContact(List contacts) async { + final ContactEntity? selectedContact = await context.registry.get().toContactsList(contacts); if (selectedContact != null) { setState(() { contact = selectedContact; @@ -93,7 +97,7 @@ abstract class JobsCreateViewModel extends State { ImageCreateFormValue(:final CreateImageData data) => (src: data.src, path: data.path), ImageModifyFormValue(:final ImageEntity data) => (src: data.src, path: data.path), }; - await context.registry.get().delete(reference: reference, userId: widget.userId); + await context.registry.get().delete(reference: reference, userId: userId); if (mounted) { setState(() { images.remove(value); @@ -132,7 +136,7 @@ abstract class JobsCreateViewModel extends State { try { // TODO(Jogboms): move this out of here final Registry registry = context.registry; - final JobEntity result = await registry.get().create(widget.userId, job); + final JobEntity result = await registry.get().create(userId, job); snackBar.hide(); registry.get().toJob(result, replace: true); } catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 8095040..edc4565 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -566,16 +566,8 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed: - dependency: "direct dev" - description: - name: freezed - sha256: a9520490532087cf38bf3f7de478ab6ebeb5f68bb1eb2641546d92719b224445 - url: "https://pub.dev" - source: hosted - version: "2.3.5" freezed_annotation: - dependency: "direct main" + dependency: transitive description: name: freezed_annotation sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338 @@ -607,14 +599,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "7940fdc3b1035db4d65d387c1bdd6f9574deaa6777411569c05ecc25672efacd" + url: "https://pub.dev" + source: hosted + version: "0.2.1" google_sign_in: dependency: "direct main" description: name: google_sign_in - sha256: "821f354c053d51a2d417b02d42532a19a6ea8057d2f9ebb8863c07d81c98aaf9" + sha256: aab6fdc41374014494f9e9026b9859e7309639d50a0bf4a2a412467a5ae4abc6 url: "https://pub.dev" source: hosted - version: "5.4.4" + version: "6.1.4" google_sign_in_android: dependency: transitive description: @@ -643,10 +643,10 @@ packages: dependency: transitive description: name: google_sign_in_web - sha256: "75cc41ebc53b1756320ee14d9c3018ad3e6cea298147dbcd86e9d0c8d6720b40" + sha256: "69b9ce0e760945ff52337921a8b5871592b74c92f85e7632293310701eea68cc" url: "https://pub.dev" source: hosted - version: "0.10.2+1" + version: "0.12.0+2" graphs: dependency: transitive description: @@ -691,10 +691,10 @@ packages: dependency: "direct main" description: name: image_picker - sha256: b6951e25b795d053a6ba03af5f710069c99349de9341af95155d52665cb4607c + sha256: b9603755b35253ccfad4be0762bb74d5e8bf9ff75edebf0ac3beec24fac1c5b5 url: "https://pub.dev" source: hosted - version: "0.8.9" + version: "1.0.0" image_picker_android: dependency: transitive description: @@ -783,14 +783,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.1" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: "61a60716544392a82726dd0fa1dd6f5f1fd32aec66422b6e229e7b90d52325c4" - url: "https://pub.dev" - source: hosted - version: "6.7.0" lints: dependency: transitive description: @@ -1015,14 +1007,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" - rebloc: - dependency: "direct main" - description: - name: rebloc - sha256: b711688bf9fdb85c490eadd9dc6527b42961911514640fcfc6b2f9a9ccab300c - url: "https://pub.dev" - source: hosted - version: "0.4.0" registry: dependency: "direct main" description: @@ -1076,18 +1060,18 @@ packages: dependency: "direct main" description: name: rxdart - sha256: "2ef8b4e91cb3b55d155e0e34eeae0ac7107974e451495c955ac04ddee8cc21fd" + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted - version: "0.26.0" + version: "0.27.7" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "396f85b8afc6865182610c0a2fc470853d56499f75f7499e2a73a9f0539d23d0" + sha256: "0344316c947ffeb3a529eac929e1978fcd37c26be4e8468628bac399365a3ca1" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.0" shared_preferences_android: dependency: transitive description: @@ -1116,10 +1100,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d + sha256: "23b052f17a25b90ff2b61aad4cc962154da76fb62848a9ce088efe30d7c50ab1" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" shared_preferences_web: dependency: transitive description: @@ -1181,14 +1165,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f" - url: "https://pub.dev" - source: hosted - version: "1.3.3" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b0dbeb4..1637212 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: cached_network_image: ^3.2.3 clock: ^1.1.1 cloud_firestore: ^4.0.4 - collection: ^1.16.0 + collection: ^1.17.2 equatable: ^2.0.5 firebase_analytics: ^10.0.4 firebase_auth: ^4.1.1 @@ -29,25 +29,23 @@ dependencies: url: https://github.com/btastic/flutter_native_image.git flutter_riverpod: ^2.3.6 flutter_spinkit: ^5.2.0 - freezed_annotation: ^2.2.0 get_version: git: url: https://github.com/Creky/get_version.git - google_sign_in: ^5.4.2 - image_picker: ^0.8.6 - intl: ^0.18.0 + google_sign_in: ^6.1.4 + image_picker: ^1.0.0 + intl: ^0.18.1 json_annotation: ^4.8.1 logging: ^1.2.0 - meta: ^1.8.0 + meta: ^1.9.1 photo_view: ^0.14.0 platform: ^3.1.0 - rebloc: ^0.4.0 registry: git: https://github.com/jogboms/registry.dart.git riverpod: ^2.3.6 riverpod_annotation: ^2.1.1 rxdart: ^0.27.7 - shared_preferences: ^2.1.2 + shared_preferences: ^2.2.0 timeago: ^3.4.0 universal_io: ^2.2.2 url_launcher: ^6.1.11 @@ -55,15 +53,14 @@ dependencies: version: ^3.0.2 dependency_overrides: + collection: 1.17.1 # required by flutter_test + intl: 0.18.0 # required by flutter_localizations meta: 1.8.0 # required by registry - rxdart: ^0.26.0 # required by rebloc dev_dependencies: build_runner: flutter_test: sdk: flutter - freezed: ^2.3.5 - json_serializable: ^6.7.0 mfsao: ^3.0.0 mocktail: ^0.3.0 riverpod_generator: ^2.2.3 From 7611663e4639d742431597700e5650d430cb5a24 Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Mon, 3 Jul 2023 17:20:05 +0200 Subject: [PATCH 17/17] Fix broken smoke test --- lib/presentation/app.dart | 4 +++- test/presentation/app_test.dart | 31 +++++++++++++++++++++---------- test/utils.dart | 31 ++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index 794cd98..5a4b106 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -15,11 +15,13 @@ class App extends StatefulWidget { super.key, required this.registry, required this.navigatorKey, + this.home, this.navigatorObservers, }); final Registry registry; final GlobalKey navigatorKey; + final Widget? home; final List? navigatorObservers; @override @@ -58,7 +60,7 @@ class _AppState extends State { child: child!, ), onGenerateRoute: (RouteSettings settings) => _PageRoute( - builder: (_) => const SplashPage(isColdStart: true), + builder: (_) => widget.home ?? const SplashPage(isColdStart: true), settings: RouteSettings(name: AppRoutes.start, arguments: settings.arguments), ), ), diff --git a/test/presentation/app_test.dart b/test/presentation/app_test.dart index 05fe6db..48db430 100644 --- a/test/presentation/app_test.dart +++ b/test/presentation/app_test.dart @@ -2,8 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:registry/registry.dart'; -import 'package:tailor_made/data.dart'; -import 'package:tailor_made/presentation.dart'; +import 'package:tailor_made/domain.dart'; import 'package:tailor_made/presentation/screens/homepage/homepage.dart'; import 'package:tailor_made/presentation/screens/splash/splash.dart'; @@ -12,6 +11,22 @@ import '../utils.dart'; void main() { group('App', () { + const AccountEntity dummyAccount = AccountEntity( + reference: ReferenceEntity(id: 'id', path: 'path'), + uid: '1', + notice: 'Hello', + phoneNumber: 123456789, + email: 'jeremiah@gmail.com', + displayName: 'Jogboms', + status: AccountStatus.enabled, + rating: 5, + hasPremiumEnabled: true, + hasReadNotice: false, + hasSendRating: true, + photoURL: 'https://secure.gravatar.com/avatar/96b338e14ff9d18b1b2d6e5dc279a710', + storeName: 'Jogboms', + ); + setUpAll(() { registerFallbackValue(FakeRoute()); }); @@ -25,18 +40,14 @@ void main() { when(mockRepositories.accounts.signIn).thenAnswer((_) async {}); when(() => mockRepositories.accounts.onAuthStateChanged).thenAnswer((_) => Stream.value('1')); - when(() => mockRepositories.accounts.getAccount(any())).thenAnswer((_) => AccountsMockImpl().getAccount('1')); - when(mockRepositories.settings.fetch).thenAnswer((_) => SettingsMockImpl().fetch()); - when(() => mockRepositories.measures.fetchAll(any())).thenAnswer((_) => MeasuresMockImpl().fetchAll('1')); - when(() => mockRepositories.jobs.fetchAll(any())).thenAnswer((_) => JobsMockImpl().fetchAll('1')); - when(() => mockRepositories.contacts.fetchAll(any())).thenAnswer((_) => ContactsMockImpl().fetchAll('1')); - when(() => mockRepositories.stats.fetch(any())).thenAnswer((_) => StatsMockImpl().fetch('1')); + when(mockRepositories.accounts.fetch).thenAnswer((_) async => dummyAccount); await tester.pumpWidget( - App( + createApp( registry: registry, navigatorKey: navigatorKey, - navigatorObservers: [mockObserver], + observers: [mockObserver], + includeMaterial: false, ), ); await tester.pump(); diff --git a/test/utils.dart b/test/utils.dart index f29c147..768f0e3 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -1,4 +1,5 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart' as mt; import 'package:registry/registry.dart'; @@ -50,6 +51,9 @@ Registry createRegistry({ ..set(mockRepositories.measures) ..set(mockRepositories.stats) ..set(environment) + ..factory((RegistryFactory di) => FetchAccountUseCase(accounts: di())) + ..factory((RegistryFactory di) => SignInUseCase(accounts: di())) + ..factory((RegistryFactory di) => SignOutUseCase(accounts: di())) ..set(ContactsCoordinator(navigatorKey)) ..set(GalleryCoordinator(navigatorKey)) ..set(SharedCoordinator(navigatorKey)) @@ -59,6 +63,31 @@ Registry createRegistry({ ..set(TasksCoordinator(navigatorKey)); } +Widget createApp({ + Widget? home, + Registry? registry, + List? overrides, + List? observers, + GlobalKey? navigatorKey, + bool includeMaterial = true, +}) { + registry ??= createRegistry(); + navigatorKey ??= GlobalKey(); + + return ProviderScope( + overrides: [ + registryProvider.overrideWithValue(registry), + ...?overrides, + ], + child: App( + registry: registry, + navigatorKey: navigatorKey, + navigatorObservers: observers, + home: includeMaterial ? Material(child: home) : home, + ), + ); +} + extension WidgetTesterExtensions on WidgetTester { Future verifyPushNavigation(NavigatorObserver observer) async { // NOTE: This is done for pages that show any indefinite animated loaders, CircularProgress