From 49c97192c2581b4076c2076ed36a0b7c23902ddb Mon Sep 17 00:00:00 2001 From: Mehul Ahal <112100480+MehulAhal@users.noreply.github.com> Date: Tue, 20 Dec 2022 21:52:24 +0100 Subject: [PATCH] ui backbone --- analysis_options.yaml | 239 ++++++++++++++++-- lib/features/core/abstracts/app_setup.dart | 4 +- .../core/abstracts/base_view_model.dart | 13 +- .../core/abstracts/router/app_router.dart | 10 + .../core/abstracts/router/routes.dart | 2 +- .../core/data/constants/const_colors.dart | 2 +- .../core/data/constants/const_values.dart | 4 +- .../core/services/app_lifecycle_service.dart | 103 ++++++++ .../core/services/navigation_service.dart | 14 +- lib/features/core/widgets/mcg_scaffold.dart | 28 +- .../core/widgets/view_model_builder.dart | 47 ++++ .../home/api/unsplash_images_api.dart | 6 +- .../home/data/models/image_model.dart | 6 +- .../home/services/images_service.dart | 1 + .../home/views/gallery/gallery_view.dart | 65 ++++- .../views/gallery/gallery_view_model.dart | 75 ++++++ .../image_carousel/image_carousel_view.dart | 43 ++++ .../image_carousel_view_model.dart | 68 +++++ lib/l10n/generated/intl/messages_en.dart | 6 +- lib/l10n/generated/l10n.dart | 30 +++ lib/l10n/intl_en.arb | 7 +- lib/locator.dart | 36 ++- pubspec.lock | 175 +++++++++++++ pubspec.yaml | 5 +- 24 files changed, 917 insertions(+), 72 deletions(-) create mode 100644 lib/features/core/services/app_lifecycle_service.dart create mode 100644 lib/features/core/widgets/view_model_builder.dart create mode 100644 lib/features/home/views/image_carousel/image_carousel_view_model.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 61b6c4d..0fafef6 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,29 +1,218 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. +analyzer: + strong-mode: + implicit-dynamic: true + implicit-casts: true -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml + errors: + avoid_dynamic_calls: error + missing_required_param: error + missing_return: error + # allow having TODOs in the code + todo: info + missing_enum_constant_in_switch: error + implicit_dynamic_type: info + implicit_dynamic_map_literal: info + implicit_dynamic_list_literal: info + implicit_dynamic_method: warning + implicit_dynamic_function: warning + invalid_use_of_protected_member: error + unused_local_variable: warning + deprecated_member_use: warning + unused_element: warning + unused_field: warning + dead_code: error + must_call_super: error + + # linter errors + unnecessary_statements: error + recursive_getters: error + unnecessary_new: error + unnecessary_getters_setters: error + use_function_type_syntax_for_parameters: error + no_duplicate_case_values: error + no_adjacent_strings_in_list: error + non_constant_identifier_names: error + constant_identifier_names: error + avoid_returning_this: error + prefer_const_constructors_in_immutables: error + prefer_const_literals_to_create_immutables: error + avoid_equals_and_hash_code_on_mutable_classes: error + camel_case_types: error + camel_case_extensions: error + library_names: error + file_names: error + library_prefixes: error + + # linter warnings + avoid_print: warning + unnecessary_lambdas: warning + use_key_in_widget_constructors: warning + prefer_final_fields: warning + prefer_final_locals: warning + prefer_final_in_for_each: warning + prefer_const_constructors: warning + unnecessary_const: warning + unnecessary_brace_in_string_interps: warning + + # Ignore analyzer hints for updating pubspecs when using Future or + # Stream and not importing dart:async + # Please see https://github.com/flutter/flutter/pull/24528 for details. + sdk_version_async_exported_from_core: ignore + exclude: + - "bin/cache/**" + - "**/*.chopper.dart" + - "**/generated/**" + - "**/*.g.dart" linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml + - always_declare_return_types + # always_put_control_body_on_new_line + # always_put_required_named_parameters_first + - always_require_non_null_named_parameters + - avoid_dynamic_calls + # - always_specify_types + - annotate_overrides + - avoid_print + # - avoid_annotating_with_dynamic # conflicts with always_specify_types + - avoid_returning_this + - avoid_init_to_null + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # we do this commonly + # - avoid_catching_errors # we do this commonly + # - avoid_classes_with_only_static_members + # - avoid_double_and_int_checks # only useful when targeting JS runtime + - avoid_empty_else + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + # - avoid_js_rounded_ints # only useful when targeting JS runtime + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + # - avoid_returning_null # there are plenty of valid reasons to return null + # - avoid_returning_null_for_future # not yet tested + - avoid_returning_null_for_void + # - avoid_returning_this # there are plenty of valid reasons to return this + # - avoid_setters_without_getters # not yet tested + # - avoid_shadowing_type_parameters # not yet tested + # - avoid_single_cascade_in_expression_statements # not yet tested + - avoid_slow_async_io + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # conflicts with always_specify_types + - avoid_unused_constructor_parameters + - avoid_void_async + - await_only_futures + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # not yet tested + # - close_sinks # not reliable enough + # - comment_references # blocked on https://github.com/flutter/flutter/issues/20765 + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + # - curly_braces_in_flow_control_structures # not yet tested + # - diagnostic_describe_all_properties # not yet tested + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + # - file_names # not yet tested + - hash_and_equals + - implementation_imports + # - invariant_booleans # too many false positives: https://github.com/dart-lang/linter/issues/811 + - iterable_contains_unrelated_type + # - join_return_with_assignment # not yet tested + - library_names + - library_prefixes + # - lines_longer_than_80_chars # not yet tested + - list_remove_unrelated_type + # - literal_only_boolean_expressions # too many false positives: https://github.com/dart-lang/sdk/issues/34181 + - no_adjacent_strings_in_list + - no_duplicate_case_values + - non_constant_identifier_names + # - null_closures # not yet tested + # - omit_local_variable_types # opposite of always_specify_types + # - one_member_abstracts # too many false positives + # - only_throw_errors # https://github.com/flutter/flutter/issues/5792 + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not yet tested + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # not yet tested + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + - prefer_equal_for_default_values + # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods + - prefer_final_fields + # - prefer_final_in_for_each # not yet tested + - prefer_final_locals + # - prefer_for_elements_to_map_fromIterable # not yet tested + - prefer_foreach + # - prefer_function_declarations_over_variables # not yet tested + - prefer_generic_function_type_aliases + # - prefer_if_elements_to_conditional_expressions # not yet tested + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # not yet tested + # - prefer_interpolation_to_compose_strings # not yet tested + - prefer_is_empty + - prefer_is_not_empty + - prefer_iterable_whereType + # - prefer_mixin # https://github.com/dart-lang/language/issues/32 + # - prefer_null_aware_operators # disable until NNBD, see https://github.com/flutter/flutter/pull/32711#issuecomment-492930932 + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + # - provide_deprecation_message # not yet tested + # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + - slash_for_doc_comments + # - sort_child_properties_last # not yet tested + # - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + - unawaited_futures + # - unnecessary_await_in_return # not yet tested + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_getters_setters + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_this + - unrelated_type_equality_checks + # - unsafe_html # not yet tested + - use_full_hex_values_for_flutter_colors + # - use_function_type_syntax_for_parameters # not yet tested + - use_rethrow_when_possible + - use_key_in_widget_constructors + # - use_setters_to_change_properties # not yet tested + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - valid_regexps + # - void_checks # not yet tested diff --git a/lib/features/core/abstracts/app_setup.dart b/lib/features/core/abstracts/app_setup.dart index d20cea4..854f6a2 100644 --- a/lib/features/core/abstracts/app_setup.dart +++ b/lib/features/core/abstracts/app_setup.dart @@ -12,6 +12,8 @@ abstract class AppSetup { static Future initialise() async { WidgetsFlutterBinding.ensureInitialized(); + await _setupStrings(); + await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, @@ -38,7 +40,7 @@ abstract class AppSetup { return supportedLocales.first; } - static Future setupStrings() async { + static Future _setupStrings() async { await Strings.load(resolveLocale(WidgetsBinding.instance.window.locales, supportedLocales)); } diff --git a/lib/features/core/abstracts/base_view_model.dart b/lib/features/core/abstracts/base_view_model.dart index d315ccb..9539637 100644 --- a/lib/features/core/abstracts/base_view_model.dart +++ b/lib/features/core/abstracts/base_view_model.dart @@ -1,11 +1,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:mc_gallery/features/core/services/logging_service.dart'; -import 'package:mc_gallery/l10n/generated/l10n.dart'; +import '/l10n/generated/l10n.dart'; import '../data/enums/view_model_state.dart'; +import '../services/logging_service.dart'; -abstract class BaseViewModel extends ChangeNotifier { +abstract class BaseViewModel extends ChangeNotifier { final ValueNotifier _isInitialised = ValueNotifier(false); ValueListenable get isInitialised => _isInitialised; @@ -23,12 +23,11 @@ abstract class BaseViewModel extends ChangeNotifier { String get errorMessage => _errorMessage ?? strings.somethingWentWrong; @mustCallSuper - void initialise(DisposableBuildContext disposableBuildContext, bool Function() mounted, - [E? arguments]) { + void initialise(bool Function() mounted, [T? arguments]) { _mounted = mounted; _isInitialised.value = true; _state.value = ViewModelState.isInitialised; - _loggingService.successfulInit(location: runtimeType.runtimeType.toString()); + _loggingService.successfulInit(location: runtimeType.toString()); } void setBusy(bool isBusy) { @@ -56,7 +55,7 @@ abstract class BaseViewModel extends ChangeNotifier { @override void dispose() { super.dispose(); - _loggingService.successfulDispose(location: runtimeType.runtimeType.toString()); + _loggingService.successfulDispose(location: runtimeType.toString()); } late final bool Function() _mounted; diff --git a/lib/features/core/abstracts/router/app_router.dart b/lib/features/core/abstracts/router/app_router.dart index 0f1120e..35244a5 100644 --- a/lib/features/core/abstracts/router/app_router.dart +++ b/lib/features/core/abstracts/router/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mc_gallery/features/home/views/gallery/gallery_view.dart'; +import 'package:mc_gallery/features/home/views/image_carousel/image_carousel_view.dart'; import '../../views/error_page_view.dart'; import 'routes.dart'; @@ -20,6 +21,15 @@ class McgRouter { path: Routes.home.routePath, name: Routes.home.routeName, builder: (context, _) => const GalleryView(), + routes: [ + GoRoute( + path: Routes.imageCarousel.routePath, + name: Routes.imageCarousel.routeName, + builder: (context, state) => ImageCarouselView( + imageCarouselViewArguments: state.extra as ImageCarouselViewArguments, + ), + ), + ], ), ], ); diff --git a/lib/features/core/abstracts/router/routes.dart b/lib/features/core/abstracts/router/routes.dart index 8338fd4..d290ca9 100644 --- a/lib/features/core/abstracts/router/routes.dart +++ b/lib/features/core/abstracts/router/routes.dart @@ -4,7 +4,7 @@ enum Routes { routeName: 'Home', )), imageCarousel(RoutesInfo( - routePath: '/image_carousel', + routePath: 'image_carousel', routeName: 'Image Carousel', )); diff --git a/lib/features/core/data/constants/const_colors.dart b/lib/features/core/data/constants/const_colors.dart index f9aa986..cd6f427 100644 --- a/lib/features/core/data/constants/const_colors.dart +++ b/lib/features/core/data/constants/const_colors.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; abstract class ConstColours { /// Smoke Gray => a neutral grey with neutral warmth/cold - static const imageBackgroundColour = Color.fromRGBO(127, 127, 125, 1.0); + static const galleryBackgroundColour = Color.fromRGBO(127, 127, 125, 1.0); static const white = Colors.white; static const black = Colors.black; diff --git a/lib/features/core/data/constants/const_values.dart b/lib/features/core/data/constants/const_values.dart index 56c85e4..903ab9a 100644 --- a/lib/features/core/data/constants/const_values.dart +++ b/lib/features/core/data/constants/const_values.dart @@ -4,6 +4,6 @@ abstract class ConstValues { static const List backendUrlPathSegments = ['user', 'c_v_r']; static const int numberOfImages = 20; - static const int minImageSize = 100; - static const int maxImageSize = 250; + static const int minImageSize = 50; + static const int maxImageSize = 100; } diff --git a/lib/features/core/services/app_lifecycle_service.dart b/lib/features/core/services/app_lifecycle_service.dart new file mode 100644 index 0000000..b0dbc71 --- /dev/null +++ b/lib/features/core/services/app_lifecycle_service.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mc_gallery/locator.dart'; + +import 'logging_service.dart'; + +typedef AddLifeCycleListener = void Function({ + required void Function(AppLifecycleState appLifecycleState) listener, + required String tag, + bool tryCallListenerOnAdd, +}); +typedef RemoveLifeCycleListener = Future Function({required String tag}); + +/// Used to observe the current app lifecycle state. +class AppLifecycleService with WidgetsBindingObserver { + AppLifecycleService({ + required LoggingService loggingService, + }) : _loggingService = loggingService { + WidgetsBinding.instance.addObserver(this); + _appLifeCycleState = WidgetsBinding.instance.lifecycleState; + } + + final LoggingService _loggingService; + + late final StreamController _streamController = StreamController.broadcast(); + final Map _appLifecycleSubscriptions = {}; + + AppLifecycleState? _appLifeCycleState; + AppLifecycleState? get appLifeCycleState => _appLifeCycleState; + + Future dispose() async { + _loggingService.info('Disposing app lifecycle service..'); + WidgetsBinding.instance.removeObserver(this); + for (final subscription in _appLifecycleSubscriptions.values) { + await subscription.cancel(); + } + _appLifecycleSubscriptions.clear(); + _loggingService.good('App lifecycle service disposed!'); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + try { + _appLifeCycleState = state; + _streamController.add(state); + } catch (error, stackTrace) { + _loggingService.error( + 'Something went wrong logging ${state.name} inside the didChangeAppLifeCycleState method', + error, + stackTrace, + ); + } + super.didChangeAppLifecycleState(state); + } + + void addListener({ + required void Function(AppLifecycleState appLifecycleState) listener, + required String tag, + bool tryCallListenerOnAdd = true, + }) { + try { + if (_appLifecycleSubscriptions.containsKey(tag)) { + _loggingService.warning('Tag already active, returning!'); + } else { + final message = 'Adding $tag appLifecycleState listener'; + _loggingService.info('$message..'); + if (_appLifeCycleState != null && tryCallListenerOnAdd) listener(_appLifeCycleState!); + _appLifecycleSubscriptions[tag] = _streamController.stream.listen(listener); + _loggingService.good('$message success!'); + } + } catch (error, stackTrace) { + _loggingService.error( + 'Something went wrong adding $tag appLifecycleState listener!', + error, + stackTrace, + ); + } + } + + Future removeListener({required String tag}) async { + try { + final message = 'Removing $tag appLifecycleState listener'; + _loggingService.info('$message..'); + final subscription = _appLifecycleSubscriptions[tag]; + if (subscription != null) { + await subscription.cancel(); + _appLifecycleSubscriptions.remove(tag); + _loggingService.good('$message success!'); + } else { + _loggingService.warning('Subscription was not found!'); + } + } catch (error, stackTrace) { + _loggingService.error( + 'Something went wrong removing $tag appLifecycleState listener!', + error, + stackTrace, + ); + } + } + + static AppLifecycleService get locate => Locator.locate(); +} diff --git a/lib/features/core/services/navigation_service.dart b/lib/features/core/services/navigation_service.dart index 823277f..ec25d1d 100644 --- a/lib/features/core/services/navigation_service.dart +++ b/lib/features/core/services/navigation_service.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:mc_gallery/features/home/views/image_carousel/image_carousel_view.dart'; +import 'package:mc_gallery/locator.dart'; import '../abstracts/router/app_router.dart'; import '../abstracts/router/routes.dart'; @@ -11,11 +13,19 @@ class NavigationService { final McgRouter _mcgRouter; - void pushImageCarouselView(BuildContext context) => - context.pushNamed(Routes.imageCarousel.routeName); + void pushImageCarouselView( + BuildContext context, { + required ImageCarouselViewArguments imageCarouselViewArguments, + }) => + context.pushNamed( + Routes.imageCarousel.routeName, + extra: imageCarouselViewArguments, + ); void backToGallery(BuildContext context) => context.pop(); void previous() {} void next() {} + + static NavigationService get locate => Locator.locate(); } diff --git a/lib/features/core/widgets/mcg_scaffold.dart b/lib/features/core/widgets/mcg_scaffold.dart index 677c168..2b15aa3 100644 --- a/lib/features/core/widgets/mcg_scaffold.dart +++ b/lib/features/core/widgets/mcg_scaffold.dart @@ -1,5 +1,4 @@ -import 'dart:async'; - +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -9,7 +8,7 @@ import '../services/connection_service.dart'; class McgScaffold extends StatelessWidget { const McgScaffold({ this.appBar, - this.bodyBuilderCompleter, + this.bodyBuilderWaiter, this.body, this.waitingWidget, this.forceInternetCheck = false, @@ -19,10 +18,10 @@ class McgScaffold extends StatelessWidget { final AppBar? appBar; /// Awaits an external signal (complete) before building the body. - final Completer? bodyBuilderCompleter; + final ValueListenable? bodyBuilderWaiter; final Widget? body; - /// Custom widget to be used while awaiting [bodyBuilderCompleter]. + /// Custom widget to be used while awaiting [bodyBuilderWaiter]. /// /// Defaults to using [PlatformCircularProgressIndicator]. final Widget? waitingWidget; @@ -32,19 +31,12 @@ class McgScaffold extends StatelessWidget { @override Widget build(BuildContext context) { - final Widget loginOptionsBody = bodyBuilderCompleter != null - ? FutureBuilder( - future: bodyBuilderCompleter!.future, - builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return Center(child: waitingWidget ?? const CircularProgressIndicator()); - case ConnectionState.done: - return body ?? const SizedBox.shrink(); - } - }, + final Widget loginOptionsBody = bodyBuilderWaiter != null + ? ValueListenableBuilder( + valueListenable: bodyBuilderWaiter!, + builder: (context, final isReady, child) => !isReady + ? Center(child: waitingWidget ?? const CircularProgressIndicator()) + : body ?? const SizedBox.shrink(), ) : body ?? const SizedBox.shrink(); diff --git a/lib/features/core/widgets/view_model_builder.dart b/lib/features/core/widgets/view_model_builder.dart new file mode 100644 index 0000000..66de637 --- /dev/null +++ b/lib/features/core/widgets/view_model_builder.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../abstracts/base_view_model.dart'; + +class ViewModelBuilder extends StatefulWidget { + const ViewModelBuilder({ + required Widget Function(BuildContext context, T model) builder, + required T Function() viewModelBuilder, + dynamic Function()? argumentBuilder, + super.key, + }) : _builder = builder, + _viewModelBuilder = viewModelBuilder, + _argumentBuilder = argumentBuilder; + + final Widget Function(BuildContext context, T model) _builder; + final T Function() _viewModelBuilder; + final dynamic Function()? _argumentBuilder; + + @override + _ViewModelBuilderState createState() => _ViewModelBuilderState(); +} + +class _ViewModelBuilderState extends State> { + late final T _viewModel; + + @override + void initState() { + _viewModel = widget._viewModelBuilder(); + _viewModel.initialise(() => mounted, widget._argumentBuilder?.call()); + super.initState(); + } + + @override + void dispose() { + _viewModel.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext _) => ChangeNotifierProvider.value( + value: _viewModel, + child: Consumer( + builder: (context, model, _) => widget._builder(context, model), + ), + ); +} diff --git a/lib/features/home/api/unsplash_images_api.dart b/lib/features/home/api/unsplash_images_api.dart index 71d0a87..5cde25c 100644 --- a/lib/features/home/api/unsplash_images_api.dart +++ b/lib/features/home/api/unsplash_images_api.dart @@ -18,8 +18,8 @@ class UnsplashImagesApi implements ImagesApi { final imageUri = _imageUrlGenerator(imageSide: imageSide); - return ImageModel( - comparableIndex: imageIndex, + return ImageModel( + imageIndex: imageIndex, uri: imageUri, imageName: Strings.current.image, ); @@ -29,6 +29,6 @@ class UnsplashImagesApi implements ImagesApi { Uri _imageUrlGenerator({required int imageSide}) => Uri( scheme: ConstValues.httpsScheme, host: ConstValues.backendHost, - pathSegments: ConstValues.backendUrlPathSegments..add('${imageSide}x$imageSide'), + pathSegments: [...ConstValues.backendUrlPathSegments, '${imageSide}x$imageSide'], ); } diff --git a/lib/features/home/data/models/image_model.dart b/lib/features/home/data/models/image_model.dart index 69cc590..959fb3e 100644 --- a/lib/features/home/data/models/image_model.dart +++ b/lib/features/home/data/models/image_model.dart @@ -1,7 +1,7 @@ -class ImageModel { +class ImageModel { const ImageModel({ required this.uri, - required this.comparableIndex, + required this.imageIndex, required this.imageName, }); @@ -11,7 +11,7 @@ class ImageModel { final Uri uri; /// A unique identifier that can be used for indexing the image. - final T comparableIndex; + final int imageIndex; /// Given name of the image. final String imageName; diff --git a/lib/features/home/services/images_service.dart b/lib/features/home/services/images_service.dart index 35c5d9b..0086262 100644 --- a/lib/features/home/services/images_service.dart +++ b/lib/features/home/services/images_service.dart @@ -15,6 +15,7 @@ class ImagesService { final ImagesApi _imagesApi; late final Iterable _imageModels; + Iterable get imageModels => _imageModels; Future _init() async { _imageModels = await _imagesApi.fetchImageUri(token: ''); diff --git a/lib/features/home/views/gallery/gallery_view.dart b/lib/features/home/views/gallery/gallery_view.dart index 90126b9..fb436cb 100644 --- a/lib/features/home/views/gallery/gallery_view.dart +++ b/lib/features/home/views/gallery/gallery_view.dart @@ -1,10 +1,71 @@ -import 'package:flutter/widgets.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '/features/core/data/constants/const_colors.dart'; +import '/features/core/data/constants/const_durations.dart'; +import '/features/core/widgets/mcg_scaffold.dart'; +import '/features/core/widgets/view_model_builder.dart'; +import 'gallery_view_model.dart'; class GalleryView extends StatelessWidget { const GalleryView({super.key}); @override Widget build(BuildContext context) { - return const Placeholder(); + return ViewModelBuilder( + viewModelBuilder: () => GalleryViewModel.locate, + builder: (context, final model) => McgScaffold( + bodyBuilderWaiter: model.isInitialised, + appBar: AppBar( + title: Text(model.strings.gallery), + ), + body: Center( + child: ValueListenableBuilder( + valueListenable: model.isDisplayingPressingPrompt, + builder: (context, final isDisplayingPressingPrompt, _) => AnimatedSwitcher( + duration: ConstDurations.defaultAnimationDuration, + child: isDisplayingPressingPrompt + ? ElevatedButton( + onPressed: model.onPromptPressed, + child: Text(model.strings.startLoadingPrompt), + ) + : DecoratedBox( + decoration: const BoxDecoration(color: ConstColours.galleryBackgroundColour), + child: SingleChildScrollView( + // Using Wrap instead of GridView, to make use of different image sizes + child: Wrap( + runSpacing: 24, + spacing: 8, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final imageModel in model.imageModels) + GestureDetector( + onTap: () => model.pushImageCarouselView( + context, + imageModel: imageModel, + ), + child: Hero( + tag: imageModel.imageIndex.toString(), + child: CachedNetworkImage( + imageUrl: imageModel.uri.toString(), + cacheKey: imageModel.imageIndex.toString(), + progressIndicatorBuilder: (_, __, final progress) => + CircularProgressIndicator( + value: model.downloadProgressValue(progress: progress), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); } } diff --git a/lib/features/home/views/gallery/gallery_view_model.dart b/lib/features/home/views/gallery/gallery_view_model.dart index e69de29..fcacf41 100644 --- a/lib/features/home/views/gallery/gallery_view_model.dart +++ b/lib/features/home/views/gallery/gallery_view_model.dart @@ -0,0 +1,75 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:mc_gallery/features/core/services/app_lifecycle_service.dart'; + +import '/features/core/abstracts/base_view_model.dart'; +import '/features/core/services/logging_service.dart'; +import '/features/core/services/navigation_service.dart'; +import '/features/home/data/models/image_model.dart'; +import '/features/home/services/images_service.dart'; +import '/features/home/views/image_carousel/image_carousel_view.dart'; +import '/locator.dart'; + +class GalleryViewModel extends BaseViewModel { + GalleryViewModel({ + required ImagesService imagesService, + required NavigationService navigationService, + required AppLifecycleService appLifecycleService, + required LoggingService loggingService, + }) : _imagesService = imagesService, + _navigationService = navigationService, + _appLifecycleService = appLifecycleService, + _loggingService = loggingService; + + final ImagesService _imagesService; + final NavigationService _navigationService; + final AppLifecycleService _appLifecycleService; + final LoggingService _loggingService; + + final ValueNotifier _isDisplayingPressingPrompt = ValueNotifier(true); + ValueListenable get isDisplayingPressingPrompt => _isDisplayingPressingPrompt; + + @override + Future initialise(bool Function() mounted, [arguments]) async { + _appLifecycleService.addListener( + tag: runtimeType.toString(), + listener: (final appLifecycleState) async { + switch (appLifecycleState) { + case AppLifecycleState.resumed: + break; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + await DefaultCacheManager().emptyCache(); + } + }, + ); + + super.initialise(mounted, arguments); + } + + @override + Future dispose() async { + await _appLifecycleService.removeListener(tag: runtimeType.toString()); + + super.dispose(); + } + + void onPromptPressed() => _isDisplayingPressingPrompt.value = false; + + Iterable get imageModels => _imagesService.imageModels; + + void pushImageCarouselView(BuildContext context, {required ImageModel imageModel}) => + _navigationService.pushImageCarouselView( + context, + imageCarouselViewArguments: ImageCarouselViewArguments( + imageIndexKey: imageModel.imageIndex, + ), + ); + + static GalleryViewModel get locate => Locator.locate(); + + double? downloadProgressValue({required DownloadProgress progress}) => + progress.totalSize != null ? progress.downloaded / progress.totalSize! : null; +} diff --git a/lib/features/home/views/image_carousel/image_carousel_view.dart b/lib/features/home/views/image_carousel/image_carousel_view.dart index e69de29..2a37c71 100644 --- a/lib/features/home/views/image_carousel/image_carousel_view.dart +++ b/lib/features/home/views/image_carousel/image_carousel_view.dart @@ -0,0 +1,43 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '/features/core/widgets/mcg_scaffold.dart'; +import '/features/core/widgets/view_model_builder.dart'; +import '/features/home/views/image_carousel/image_carousel_view_model.dart'; + +class ImageCarouselViewArguments { + const ImageCarouselViewArguments({required this.imageIndexKey}); + final int imageIndexKey; +} + +class ImageCarouselView extends StatelessWidget { + const ImageCarouselView({ + required this.imageCarouselViewArguments, + super.key, + }); + + final ImageCarouselViewArguments imageCarouselViewArguments; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder( + viewModelBuilder: () => ImageCarouselViewModel.locate, + argumentBuilder: () => imageCarouselViewArguments, + builder: (context, final model) => McgScaffold( + appBar: AppBar( + title: Text(model.strings.imageCarousel), + ), + body: Hero( + tag: model.currentImageKey, + child: CachedNetworkImage( + imageUrl: model.currentImageUrl, + cacheKey: model.currentImageKey, + progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator( + value: model.downloadProgressValue(progress: progress), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/home/views/image_carousel/image_carousel_view_model.dart b/lib/features/home/views/image_carousel/image_carousel_view_model.dart new file mode 100644 index 0000000..76dc40f --- /dev/null +++ b/lib/features/home/views/image_carousel/image_carousel_view_model.dart @@ -0,0 +1,68 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:mc_gallery/features/core/services/app_lifecycle_service.dart'; + +import '/features/core/abstracts/base_view_model.dart'; +import '/features/core/services/logging_service.dart'; +import '/features/core/services/navigation_service.dart'; +import '/features/home/data/models/image_model.dart'; +import '/features/home/services/images_service.dart'; +import '/features/home/views/image_carousel/image_carousel_view.dart'; +import '/locator.dart'; + +class ImageCarouselViewModel extends BaseViewModel { + ImageCarouselViewModel({ + required ImagesService imagesService, + required NavigationService navigationService, + required AppLifecycleService appLifecycleService, + required LoggingService loggingService, + }) : _imagesService = imagesService, + _navigationService = navigationService, + _appLifecycleService = appLifecycleService, + _loggingService = loggingService; + + final ImagesService _imagesService; + final NavigationService _navigationService; + final AppLifecycleService _appLifecycleService; + final LoggingService _loggingService; + + late final ValueNotifier _currentImageModel; + ValueListenable get currentImageModel => _currentImageModel; + + @override + Future initialise(bool Function() mounted, [arguments]) async { + _appLifecycleService.addListener( + tag: runtimeType.toString(), + listener: (final appLifecycleState) async { + switch (appLifecycleState) { + case AppLifecycleState.resumed: + break; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + await DefaultCacheManager().emptyCache(); + } + }); + + _currentImageModel = ValueNotifier(_imagesService.imageModels + .elementAt((arguments! as ImageCarouselViewArguments).imageIndexKey)); + + super.initialise(mounted, arguments); + } + + @override + Future dispose() async { + await _appLifecycleService.removeListener(tag: runtimeType.toString()); + + super.dispose(); + } + + String get currentImageUrl => currentImageModel.value.uri.toString(); + String get currentImageKey => currentImageModel.value.imageIndex.toString(); + double? downloadProgressValue({required DownloadProgress progress}) => + progress.totalSize != null ? progress.downloaded / progress.totalSize! : null; + + static ImageCarouselViewModel get locate => Locator.locate(); +} diff --git a/lib/l10n/generated/intl/messages_en.dart b/lib/l10n/generated/intl/messages_en.dart index 2d75de5..65ff4af 100644 --- a/lib/l10n/generated/intl/messages_en.dart +++ b/lib/l10n/generated/intl/messages_en.dart @@ -22,8 +22,12 @@ class MessageLookup extends MessageLookupByLibrary { final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { + "gallery": MessageLookupByLibrary.simpleMessage("Gallery"), "image": MessageLookupByLibrary.simpleMessage("Image"), + "imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"), "somethingWentWrong": - MessageLookupByLibrary.simpleMessage("Something went wrong") + MessageLookupByLibrary.simpleMessage("Something went wrong"), + "startLoadingPrompt": + MessageLookupByLibrary.simpleMessage("Press me to start loading") }; } diff --git a/lib/l10n/generated/l10n.dart b/lib/l10n/generated/l10n.dart index c28f4de..773df27 100644 --- a/lib/l10n/generated/l10n.dart +++ b/lib/l10n/generated/l10n.dart @@ -69,6 +69,36 @@ class Strings { args: [], ); } + + /// `Gallery` + String get gallery { + return Intl.message( + 'Gallery', + name: 'gallery', + desc: '', + args: [], + ); + } + + /// `Press me to start loading` + String get startLoadingPrompt { + return Intl.message( + 'Press me to start loading', + name: 'startLoadingPrompt', + desc: '', + args: [], + ); + } + + /// `Image carousel` + String get imageCarousel { + return Intl.message( + 'Image carousel', + name: 'imageCarousel', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1e0d82f..0ba0c6d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2,5 +2,10 @@ "@@locale": "en", "somethingWentWrong": "Something went wrong", - "image": "Image" + "image": "Image", + + "gallery": "Gallery", + "startLoadingPrompt": "Press me to start loading", + + "imageCarousel": "Image carousel" } \ No newline at end of file diff --git a/lib/locator.dart b/lib/locator.dart index 1cfcd57..61b4482 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:get_it/get_it.dart'; import 'package:internet_connection_checker/internet_connection_checker.dart'; @@ -6,7 +8,10 @@ import 'package:mc_gallery/features/core/services/logging_service.dart'; import 'package:mc_gallery/features/core/services/navigation_service.dart'; import 'package:mc_gallery/features/home/api/unsplash_images_api.dart'; import 'package:mc_gallery/features/home/services/images_service.dart'; +import 'package:mc_gallery/features/home/views/gallery/gallery_view_model.dart'; +import 'package:mc_gallery/features/home/views/image_carousel/image_carousel_view_model.dart'; +import 'features/core/services/app_lifecycle_service.dart'; import 'features/core/services/connection_service.dart'; import 'features/core/services/overlay_service.dart'; @@ -23,8 +28,8 @@ class Locator { _registerViewModels(); await _registerServices(locator); - await _registerRepos(locator); + _registerSingletons(); } @@ -34,9 +39,26 @@ class Locator { ); } - static void _registerViewModels() {} + static void _registerViewModels() { + instance().registerFactory( + () => GalleryViewModel( + imagesService: ImagesService.locate, + navigationService: NavigationService.locate, + appLifecycleService: AppLifecycleService.locate, + loggingService: LoggingService.locate, + ), + ); + instance().registerFactory( + () => ImageCarouselViewModel( + imagesService: ImagesService.locate, + navigationService: NavigationService.locate, + appLifecycleService: AppLifecycleService.locate, + loggingService: LoggingService.locate, + ), + ); + } - static _registerServices(GetIt it) { + static FutureOr _registerServices(GetIt it) { it.registerLazySingleton( () => NavigationService( mcgRouter: McgRouter.locate, @@ -57,6 +79,12 @@ class Locator { ), dispose: (param) => param.dispose(), ); + instance().registerSingleton( + AppLifecycleService( + loggingService: LoggingService.locate, + ), + dispose: (param) async => await param.dispose(), + ); it.registerSingleton( ImagesService( imagesApi: UnsplashImagesApi(), @@ -65,7 +93,7 @@ class Locator { ); } - static _registerRepos(GetIt locator) {} + static FutureOr _registerRepos(GetIt locator) {} static void _registerSingletons() {} } diff --git a/pubspec.lock b/pubspec.lock index 555f656..a7df332 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,6 +50,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.3" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" characters: dependency: transitive description: @@ -153,6 +174,20 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_blurhash: + dependency: transitive + description: + name: flutter_blurhash + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" flutter_lints: dependency: "direct dev" description: @@ -275,6 +310,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" nm: dependency: transitive description: @@ -282,6 +324,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" package_config: dependency: transitive description: @@ -310,6 +359,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + path_provider: + dependency: transitive + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.22" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.7" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.1" petitparser: dependency: transitive description: @@ -317,6 +422,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.1.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" plugin_platform_interface: dependency: transitive description: @@ -331,6 +443,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.6.2" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.5" pub_semver: dependency: transitive description: @@ -338,6 +464,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.3" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.7" sky_engine: dependency: transitive description: flutter @@ -350,6 +483,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.9.0" + sqflite: + dependency: transitive + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0+2" stack_trace: dependency: transitive description: @@ -371,6 +518,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+3" talker: dependency: "direct main" description: @@ -413,6 +567,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.7" vector_math: dependency: transitive description: @@ -427,6 +588,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+2" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 548be3e..d2b08fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,11 +16,14 @@ dependencies: # Routing go_router: ^6.0.0 - # Service locator + # State management get_it: ^7.2.0 + provider: ^6.0.5 # Networking dio: ^4.0.6 + cached_network_image: ^3.2.3 + flutter_cache_manager: ^3.3.0 # Util backend intl_utils: ^2.8.1