diff --git a/.gitignore b/.gitignore index df138c9..1bd96dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Custom *.pdf +*.env +env.g.dart # Miscellaneous *.class diff --git a/lib/app.dart b/lib/app.dart index 1070a19..914c1e5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'features/core/abstracts/app_setup.dart'; import 'features/core/abstracts/router/app_router.dart'; import 'features/core/data/constants/const_colors.dart'; +import 'l10n/generated/l10n.dart'; class McgApp extends StatelessWidget { const McgApp({super.key}); @@ -11,7 +12,7 @@ class McgApp extends StatelessWidget { Widget build(BuildContext context) { final appRouter = McgRouter.locate.router; return MaterialApp.router( - title: 'MC Gallery App', + title: Strings.current.appTitle, theme: ConstThemes.materialLightTheme, darkTheme: ConstThemes.materialDarkTheme, supportedLocales: AppSetup.supportedLocales, diff --git a/lib/env/env.dart b/lib/env/env.dart new file mode 100644 index 0000000..e593f22 --- /dev/null +++ b/lib/env/env.dart @@ -0,0 +1,12 @@ +import 'package:envied/envied.dart'; + +part 'env.g.dart'; + +@Envied() +abstract class Env { + @EnviedField( + varName: 'UNSPLASH_API_KEY', + defaultValue: '', + ) + static const unsplashApiKey = _Env.unsplashApiKey; +} diff --git a/lib/features/core/abstracts/app_setup.dart b/lib/features/core/abstracts/app_setup.dart index 05b08be..ad33b16 100644 --- a/lib/features/core/abstracts/app_setup.dart +++ b/lib/features/core/abstracts/app_setup.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -49,5 +50,11 @@ abstract class AppSetup { static void Function(Object error, StackTrace stackTrace) get onUncaughtException => ( error, stackTrace, - ) {}; + ) { + log( + 'Error occurred during app startup', + error: error, + stackTrace: stackTrace, + ); + }; } diff --git a/lib/features/core/abstracts/base_view_model.dart b/lib/features/core/abstracts/base_view_model.dart index 9539637..7c57017 100644 --- a/lib/features/core/abstracts/base_view_model.dart +++ b/lib/features/core/abstracts/base_view_model.dart @@ -17,7 +17,7 @@ abstract class BaseViewModel extends ChangeNotifier { final ValueNotifier _state = ValueNotifier(ViewModelState.isInitialising); ValueListenable get state => _state; - final LoggingService _loggingService = LoggingService.locate; + final LoggingService log = LoggingService.locate; String? _errorMessage; String get errorMessage => _errorMessage ?? strings.somethingWentWrong; @@ -27,7 +27,7 @@ abstract class BaseViewModel extends ChangeNotifier { _mounted = mounted; _isInitialised.value = true; _state.value = ViewModelState.isInitialised; - _loggingService.successfulInit(location: runtimeType.toString()); + log.successfulInit(location: runtimeType.toString()); } void setBusy(bool isBusy) { @@ -55,7 +55,7 @@ abstract class BaseViewModel extends ChangeNotifier { @override void dispose() { super.dispose(); - _loggingService.successfulDispose(location: runtimeType.toString()); + log.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 bcf84a7..2c4d3c5 100644 --- a/lib/features/core/abstracts/router/app_router.dart +++ b/lib/features/core/abstracts/router/app_router.dart @@ -1,8 +1,8 @@ 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 '/features/home/views/gallery/gallery_view.dart'; +import '/features/home/views/image_carousel/image_carousel_view.dart'; import '../../views/error_page_view.dart'; import 'routes.dart'; @@ -15,7 +15,6 @@ class McgRouter { key: state.pageKey, child: ErrorPageView(error: state.error), ), - //todo(mehul): Add Redirect routes: [ GoRoute( path: Routes.home.routePath, diff --git a/lib/features/core/abstracts/router/routes.dart b/lib/features/core/abstracts/router/routes.dart index d290ca9..751b821 100644 --- a/lib/features/core/abstracts/router/routes.dart +++ b/lib/features/core/abstracts/router/routes.dart @@ -1,6 +1,6 @@ enum Routes { home(RoutesInfo( - routePath: '/gallery', + routePath: '/', routeName: 'Home', )), imageCarousel(RoutesInfo( diff --git a/lib/features/core/data/constants/const_colors.dart b/lib/features/core/data/constants/const_colors.dart index 47465c2..4f69947 100644 --- a/lib/features/core/data/constants/const_colors.dart +++ b/lib/features/core/data/constants/const_colors.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; abstract class ConstColours { /// Smoke Gray => a neutral grey with neutral warmth/cold static const galleryBackgroundColour = Color.fromRGBO(127, 127, 125, 1.0); + static const red = Colors.red; 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 a468f1b..c8a9a7d 100644 --- a/lib/features/core/data/constants/const_values.dart +++ b/lib/features/core/data/constants/const_values.dart @@ -3,7 +3,7 @@ abstract class ConstValues { static const String backendHost = 'source.unsplash.com'; static const List backendUrlPathSegments = ['user', 'c_v_r']; - static const int numberOfImages = 20; + static const int numberOfImages = 25; static const int minImageSize = 50; static const int maxImageSize = 100; diff --git a/lib/features/core/data/extensions/map_extensions.dart b/lib/features/core/data/extensions/map_extensions.dart index 74de75a..183e294 100644 --- a/lib/features/core/data/extensions/map_extensions.dart +++ b/lib/features/core/data/extensions/map_extensions.dart @@ -8,7 +8,7 @@ extension MapExtensions on Map { } extension LinkedHashMapExtensions on LinkedHashMap { - /// Updated the value at [valueIndex] to [newValue], in addition to preserving the order. + /// Updates the value at [valueIndex] to [newValue], in addition to preserving the order. void updateValueAt({ required int valueIndex, required B newValue, diff --git a/lib/features/core/data/extensions/value_notifier_extensions.dart b/lib/features/core/data/extensions/value_notifier_extensions.dart index 8d4b278..e38a995 100644 --- a/lib/features/core/data/extensions/value_notifier_extensions.dart +++ b/lib/features/core/data/extensions/value_notifier_extensions.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; extension ValueNotifierBoolExtensions on ValueNotifier { void flipValue() => value = !value; diff --git a/lib/features/core/services/app_lifecycle_service.dart b/lib/features/core/services/app_lifecycle_service.dart index b0dbc71..be55f60 100644 --- a/lib/features/core/services/app_lifecycle_service.dart +++ b/lib/features/core/services/app_lifecycle_service.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:mc_gallery/locator.dart'; +import 'package:flutter/widgets.dart'; +import '/locator.dart'; import 'logging_service.dart'; typedef AddLifeCycleListener = void Function({ @@ -23,7 +23,8 @@ class AppLifecycleService with WidgetsBindingObserver { final LoggingService _loggingService; - late final StreamController _streamController = StreamController.broadcast(); + late final StreamController _lifecycleStateStreamController = + StreamController.broadcast(); final Map _appLifecycleSubscriptions = {}; AppLifecycleState? _appLifeCycleState; @@ -43,14 +44,15 @@ class AppLifecycleService with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { try { _appLifeCycleState = state; - _streamController.add(state); + _lifecycleStateStreamController.add(state); } catch (error, stackTrace) { _loggingService.error( - 'Something went wrong logging ${state.name} inside the didChangeAppLifeCycleState method', + 'Something went wrong with ${state.name} inside the didChangeAppLifeCycleState method', error, stackTrace, ); } + super.didChangeAppLifecycleState(state); } @@ -61,17 +63,17 @@ class AppLifecycleService with WidgetsBindingObserver { }) { try { if (_appLifecycleSubscriptions.containsKey(tag)) { - _loggingService.warning('Tag already active, returning!'); + _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); + _appLifecycleSubscriptions[tag] = _lifecycleStateStreamController.stream.listen(listener); _loggingService.good('$message success!'); } } catch (error, stackTrace) { _loggingService.error( - 'Something went wrong adding $tag appLifecycleState listener!', + 'Something went wrong adding $tag appLifecycleState listener', error, stackTrace, ); @@ -88,11 +90,11 @@ class AppLifecycleService with WidgetsBindingObserver { _appLifecycleSubscriptions.remove(tag); _loggingService.good('$message success!'); } else { - _loggingService.warning('Subscription was not found!'); + _loggingService.warning('Subscription was not found'); } } catch (error, stackTrace) { _loggingService.error( - 'Something went wrong removing $tag appLifecycleState listener!', + 'Something went wrong removing $tag appLifecycleState listener', error, stackTrace, ); diff --git a/lib/features/core/services/connections_service.dart b/lib/features/core/services/connections_service.dart index c975563..d22c133 100644 --- a/lib/features/core/services/connections_service.dart +++ b/lib/features/core/services/connections_service.dart @@ -4,10 +4,10 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:internet_connection_checker/internet_connection_checker.dart'; -import '/features/core/services/logging_service.dart'; import '/locator.dart'; +import 'logging_service.dart'; -/// Used to observe the current connection type. +/// Used to observe the current connection type and status. class ConnectionsService { ConnectionsService({ required Connectivity connectivity, diff --git a/lib/features/core/services/local_storage_service.dart b/lib/features/core/services/local_storage_service.dart index 0b4898c..3220b68 100644 --- a/lib/features/core/services/local_storage_service.dart +++ b/lib/features/core/services/local_storage_service.dart @@ -1,7 +1,9 @@ import 'package:hive/hive.dart'; -import 'package:mc_gallery/features/core/services/logging_service.dart'; -import 'package:mc_gallery/locator.dart'; +import '/locator.dart'; +import 'logging_service.dart'; + +/// Handles storing state data locally, onto the device itself class LocalStorageService { LocalStorageService() { _init(); @@ -9,10 +11,10 @@ class LocalStorageService { final LoggingService _loggingService = LoggingService.locate; - static const String _userBoxKey = 'userBoxKey'; - late final Box _userBox; + static const String _userBoxKey = 'userBoxKey'; + Future _init() async { _userBox = await Hive.openBox(_userBoxKey); diff --git a/lib/features/core/services/logging_service.dart b/lib/features/core/services/logging_service.dart index 8a968b1..f58f32c 100644 --- a/lib/features/core/services/logging_service.dart +++ b/lib/features/core/services/logging_service.dart @@ -8,6 +8,7 @@ import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; import '/locator.dart'; +/// Handles logging of events. class LoggingService { final Talker _talker = Talker( settings: TalkerSettings( diff --git a/lib/features/core/services/navigation_service.dart b/lib/features/core/services/navigation_service.dart index ec25d1d..a9fc291 100644 --- a/lib/features/core/services/navigation_service.dart +++ b/lib/features/core/services/navigation_service.dart @@ -1,11 +1,12 @@ 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 '/features/home/views/image_carousel/image_carousel_view.dart'; +import '/locator.dart'; import '../abstracts/router/app_router.dart'; import '../abstracts/router/routes.dart'; +/// Handles the navigation to and fro all the screens in the app. class NavigationService { const NavigationService({ required McgRouter mcgRouter, @@ -22,10 +23,5 @@ class NavigationService { extra: imageCarouselViewArguments, ); - void backToGallery(BuildContext context) => context.pop(); - - void previous() {} - void next() {} - static NavigationService get locate => Locator.locate(); } diff --git a/lib/features/core/services/overlay_service.dart b/lib/features/core/services/overlay_service.dart index aba06c9..2c59011 100644 --- a/lib/features/core/services/overlay_service.dart +++ b/lib/features/core/services/overlay_service.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; -import 'package:mc_gallery/features/core/services/logging_service.dart'; -import 'package:mc_gallery/locator.dart'; + +import '/locator.dart'; +import 'logging_service.dart'; class OverlayService { OverlayService({ diff --git a/lib/features/core/utils/mutex.dart b/lib/features/core/utils/mutex.dart index aa8ecce..70be6d3 100644 --- a/lib/features/core/utils/mutex.dart +++ b/lib/features/core/utils/mutex.dart @@ -20,6 +20,7 @@ class Mutex { return value; } + /// Allows listening to the completion status of the last worker process to be released. Future get lastOperationCompletionAwaiter => _completerQueue.isNotEmpty ? _completerQueue.last.future : Future.value(); } diff --git a/lib/features/core/views/error_page_view.dart b/lib/features/core/views/error_page_view.dart index 84aa5fb..8f7041e 100644 --- a/lib/features/core/views/error_page_view.dart +++ b/lib/features/core/views/error_page_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import '/l10n/generated/l10n.dart'; import '../widgets/gap.dart'; class ErrorPageView extends StatelessWidget { @@ -15,9 +16,8 @@ class ErrorPageView extends StatelessWidget { return Center( child: Column( children: [ - const Text('Oopsie, there has been an error. Hang tight till we do something about it.'), + Text(Strings.current.errorPageMessage), const Gap(16), - Text('what happened: $error'), ], ), ); diff --git a/lib/features/core/widgets/animated_column.dart b/lib/features/core/widgets/animated_column.dart new file mode 100644 index 0000000..8b3e37e --- /dev/null +++ b/lib/features/core/widgets/animated_column.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; + +import '/features/core/data/constants/const_durations.dart'; + +/// [AnimatedColumn] Animates its children when they get added or removed at the end +class AnimatedColumn extends StatelessWidget { + const AnimatedColumn({ + required this.children, + this.duration = ConstDurations.oneAndHalfDefaultAnimationDuration, + this.mainAxisAlignment = MainAxisAlignment.start, + this.mainAxisSize = MainAxisSize.max, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.textDirection, + this.verticalDirection = VerticalDirection.down, + this.textBaseline, + this.maxAnimatingChildren = 2, + super.key, + }); + + /// [duration] specifies the duration of the add/remove animation + final Duration duration; + + /// [maxAnimatingChildren] determines the maximum number of chidren that can + /// be animating at once, if more are removed or added at within an animation + /// duration they will pop in instead + final int maxAnimatingChildren; + + final MainAxisAlignment mainAxisAlignment; + final MainAxisSize mainAxisSize; + final CrossAxisAlignment crossAxisAlignment; + final TextDirection? textDirection; + final VerticalDirection verticalDirection; + final TextBaseline? textBaseline; + final List children; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: crossAxisAlignment, + children: [ + for (int i = 0; i < children.length + maxAnimatingChildren; i++) + AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ), + child: i < children.length ? children[i] : const SizedBox.shrink(), + ), + ], + ); + } +} diff --git a/lib/features/home/abstracts/images_api.dart b/lib/features/home/abstracts/images_api.dart index ecc1e1c..17836ce 100644 --- a/lib/features/home/abstracts/images_api.dart +++ b/lib/features/home/abstracts/images_api.dart @@ -7,10 +7,15 @@ import '../data/dtos/image_model_dto.dart'; /// Since I used a site that was more obscure than the ones in the examples, this (otherwise pointless /// and convoluting) interface is for adding a bit of flexibility to change strategy to some other site. abstract class ImagesApi { - FutureOr> fetchImageUri({required String token}); + ImagesApi({required String token}) : _token = token; + + /// Access token provided to be used with API calls + final String _token; + + /// Returns images fetched through an API as [ImageModelDTO]s. + FutureOr> fetchImageUri(); FutureOr> searchImages({ required String searchStr, - required String token, }); } diff --git a/lib/features/home/api/unsplash_images_api.dart b/lib/features/home/api/unsplash_images_api.dart index 283fcab..81f6805 100644 --- a/lib/features/home/api/unsplash_images_api.dart +++ b/lib/features/home/api/unsplash_images_api.dart @@ -9,12 +9,14 @@ import '/locator.dart'; import '../abstracts/images_api.dart'; import '../data/dtos/image_model_dto.dart'; -class UnsplashImagesApi implements ImagesApi { +class UnsplashImagesApi extends ImagesApi { final LoggingService _loggingService = LoggingService.locate; final random = Random(); + UnsplashImagesApi({required super.token}); + @override - FutureOr> fetchImageUri({required String token}) async { + FutureOr> fetchImageUri() async { // Dummy fetching delay emulation await Future.delayed(const Duration( milliseconds: ConstValues.defaultEmulatedLatencyMillis * ConstValues.numberOfImages)); @@ -54,7 +56,6 @@ class UnsplashImagesApi implements ImagesApi { @override FutureOr> searchImages({ required String searchStr, - required String token, }) async { final numberOfResults = random.nextIntInRange(min: 0, max: ConstValues.numberOfImages); diff --git a/lib/features/home/data/enums/search_option.dart b/lib/features/home/data/enums/search_option.dart index f95cdf9..96654d5 100644 --- a/lib/features/home/data/enums/search_option.dart +++ b/lib/features/home/data/enums/search_option.dart @@ -1,5 +1,6 @@ import '/l10n/generated/l10n.dart'; +/// Represents an option for specifying a search strategy, for an [ImageModel] enum SearchOption { local, web; diff --git a/lib/features/home/data/models/image_model.dart b/lib/features/home/data/models/image_model.dart index 60ddb65..0b04c17 100644 --- a/lib/features/home/data/models/image_model.dart +++ b/lib/features/home/data/models/image_model.dart @@ -1,5 +1,6 @@ import '../dtos/image_model_dto.dart'; +/// Represents an Image, that would be displayed in the gallery. class ImageModel { const ImageModel({ required this.uri, diff --git a/lib/features/home/services/image_cache_manager_service.dart b/lib/features/home/services/image_cache_manager_service.dart index fcba448..6ded6a7 100644 --- a/lib/features/home/services/image_cache_manager_service.dart +++ b/lib/features/home/services/image_cache_manager_service.dart @@ -1,12 +1,14 @@ import 'dart:ui'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:mc_gallery/features/core/services/app_lifecycle_service.dart'; -import 'package:mc_gallery/features/core/services/local_storage_service.dart'; -import 'package:mc_gallery/features/core/services/logging_service.dart'; -import 'package:mc_gallery/locator.dart'; -class ImageCacheManagerService with LoggingService { +import '/features/core/services/app_lifecycle_service.dart'; +import '/features/core/services/local_storage_service.dart'; +import '/features/core/services/logging_service.dart'; +import '/locator.dart'; + +/// Handles maintaining the caching of downloaded images +class ImageCacheManagerService { ImageCacheManagerService( {required AppLifecycleService appLifecycleService, required LocalStorageService localStorageService}) @@ -17,6 +19,7 @@ class ImageCacheManagerService with LoggingService { final AppLifecycleService _appLifecycleService; final LocalStorageService _localStorageService; + final LoggingService _loggingService = LoggingService.locate; final _cacheManager = DefaultCacheManager(); Future emptyCache() async => await _cacheManager.emptyCache(); @@ -31,7 +34,7 @@ class ImageCacheManagerService with LoggingService { case AppLifecycleState.inactive: case AppLifecycleState.paused: case AppLifecycleState.detached: - info('Discarding cached images'); + _loggingService.info('Discarding cached images'); await _cacheManager.emptyCache(); _localStorageService.resetFavourites(); } diff --git a/lib/features/home/services/images_service.dart b/lib/features/home/services/images_service.dart index 212a2ee..ce6f532 100644 --- a/lib/features/home/services/images_service.dart +++ b/lib/features/home/services/images_service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:collection'; import 'package:collection/collection.dart'; -import 'package:mc_gallery/features/home/data/dtos/image_model_dto.dart'; import '/features/core/data/constants/const_sorters.dart'; import '/features/core/data/extensions/iterable_extensions.dart'; @@ -11,6 +10,7 @@ import '/features/core/data/extensions/string_extensions.dart'; import '/features/core/services/local_storage_service.dart'; import '/features/core/services/logging_service.dart'; import '/features/core/utils/mutex.dart'; +import '/features/home/data/dtos/image_model_dto.dart'; import '/locator.dart'; import '../abstracts/images_api.dart'; import '../data/enums/search_option.dart'; @@ -49,7 +49,7 @@ class ImagesService { Future _init() async { _loggingService.info('Fetching and creating image models...'); - final fetchedImageModelDtos = await _imagesApi.fetchImageUri(token: ''); + final fetchedImageModelDtos = await _imagesApi.fetchImageUri(); final favouritesStatuses = _localStorageService.storedFavouritesStates; // Prefill from stored values @@ -128,7 +128,6 @@ class ImagesService { case SearchOption.web: return (await _imagesApi.searchImages( searchStr: imageNamePart, - token: '', )) .map( (final imageModelDto) => ImageModel.fromDto( diff --git a/lib/features/home/views/gallery/downloaded_gallery_view.dart b/lib/features/home/views/gallery/downloaded_gallery_view.dart index 4247391..631e242 100644 --- a/lib/features/home/views/gallery/downloaded_gallery_view.dart +++ b/lib/features/home/views/gallery/downloaded_gallery_view.dart @@ -18,12 +18,7 @@ class _DownloadedGalleryView extends StatelessWidget { child: ValueListenableBuilder( valueListenable: galleryViewModel.isViewingFavouriteListenable, builder: (context, final isViewingFavourites, _) => !isViewingFavourites - ? Wrap( - runSpacing: 24, - spacing: 8, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, + ? CustomWrap( children: [ for (final imageModel in galleryViewModel.imageModels) _StarrableImage( @@ -33,12 +28,7 @@ class _DownloadedGalleryView extends StatelessWidget { ), ], ) - : Wrap( - runSpacing: 24, - spacing: 8, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, + : CustomWrap( children: [ for (final favouriteImageModel in galleryViewModel.favouriteImageModels) _StarrableImage( @@ -88,11 +78,14 @@ class _StarrableImageState extends State<_StarrableImage> { context, imageModel: widget.imageModel, ), - child: CachedNetworkImage( - imageUrl: widget.imageModel.uri.toString(), - cacheKey: widget.imageModel.imageIndex.toString(), - progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator( - value: widget.galleryViewModel.downloadProgressValue(progress: progress), + child: Hero( + tag: widget.imageModel.imageIndex, + child: CachedNetworkImage( + imageUrl: widget.imageModel.uri.toString(), + cacheKey: widget.imageModel.imageIndex.toString(), + progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator( + value: widget.galleryViewModel.downloadProgressValue(progress: progress), + ), ), ), ), diff --git a/lib/features/home/views/gallery/gallery_view.dart b/lib/features/home/views/gallery/gallery_view.dart index 211fba5..8faba98 100644 --- a/lib/features/home/views/gallery/gallery_view.dart +++ b/lib/features/home/views/gallery/gallery_view.dart @@ -1,13 +1,14 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:mc_gallery/features/core/data/constants/const_media.dart'; import '/features/core/data/constants/const_colors.dart'; import '/features/core/data/constants/const_durations.dart'; +import '/features/core/data/constants/const_media.dart'; import '/features/core/widgets/gap.dart'; import '/features/core/widgets/mcg_scaffold.dart'; import '/features/core/widgets/state/multi_value_listenable_builder.dart'; import '/features/core/widgets/state/view_model_builder.dart'; +import '/features/home/widgets/custom_wrap.dart'; import '../../data/enums/search_option.dart'; import '../../data/models/image_model.dart'; import 'gallery_view_model.dart'; @@ -81,21 +82,38 @@ class GalleryView extends StatelessWidget { valueListenable: model.isSearchingListenable, builder: (context, final isSearching, _) => AnimatedSwitcher( duration: ConstDurations.oneAndHalfDefaultAnimationDuration, - child: Column( - children: [ - ValueListenableBuilder( - valueListenable: model.isViewingFavouriteListenable, - builder: (context, final isViewingFavourites, child) => - Switch( - value: isViewingFavourites, - onChanged: model.onFavouriteViewChange, - ), - ), - !isSearching - ? _DownloadedGalleryView(galleryViewModel: model) - : _SearchGalleryView(galleryViewModel: model), - ], - ), + child: !isSearching + ? Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ValueListenableBuilder( + valueListenable: model.isViewingFavouriteListenable, + builder: + (context, final isViewingFavourites, child) => + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstMedia.buildIcon( + ConstMedia.favStarOutline, + width: 24, + height: 24, + ), + Switch( + value: isViewingFavourites, + onChanged: model.onFavouriteViewChange, + ), + ConstMedia.buildIcon( + ConstMedia.favStarFilled, + width: 24, + height: 24, + ), + ], + ), + ), + _DownloadedGalleryView(galleryViewModel: model), + ], + ) + : _SearchGalleryView(galleryViewModel: model), ), ); } diff --git a/lib/features/home/views/gallery/gallery_view_model.dart b/lib/features/home/views/gallery/gallery_view_model.dart index f10b515..20304f6 100644 --- a/lib/features/home/views/gallery/gallery_view_model.dart +++ b/lib/features/home/views/gallery/gallery_view_model.dart @@ -3,10 +3,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:mc_gallery/features/core/data/extensions/value_notifier_extensions.dart'; import '/features/core/abstracts/base_view_model.dart'; -import '/features/core/services/logging_service.dart'; +import '/features/core/data/extensions/value_notifier_extensions.dart'; import '/features/core/services/navigation_service.dart'; import '/locator.dart'; import '../../data/enums/search_option.dart'; @@ -20,17 +19,14 @@ class GalleryViewModel extends BaseViewModel { required ImagesService imagesService, required NavigationService navigationService, required ImageCacheManagerService imageCacheManagerService, - required LoggingService loggingService, }) : _imagesService = imagesService, _navigationService = navigationService, - _imageCacheManagerService = imageCacheManagerService, - _loggingService = loggingService; + _imageCacheManagerService = imageCacheManagerService; final ImagesService _imagesService; final NavigationService _navigationService; //todo(mehul): Use to implement pull-to-refresh or an extra widget final ImageCacheManagerService _imageCacheManagerService; - final LoggingService _loggingService; final ValueNotifier _isDisplayingPressingPrompt = ValueNotifier(true); ValueListenable get isDisplayingPressingPrompt => _isDisplayingPressingPrompt; @@ -59,7 +55,7 @@ class GalleryViewModel extends BaseViewModel { // If empty-string (from backspacing) -> reset state. if (searchTerm.isEmpty) { _imageSearchResultsNotifier.value = []; - _loggingService.info('Clearing results on search string removal'); + log.info('Clearing results on search string removal'); return; } @@ -82,7 +78,7 @@ class GalleryViewModel extends BaseViewModel { // If transitioning from 'Searching', clear previous results immediately if (_isSearchingNotifier.value) { _imageSearchResultsNotifier.value = []; - _loggingService.info('Clearing of results on view mode change'); + log.info('Clearing of results on view mode change'); } _isSearchingNotifier.flipValue(); @@ -92,10 +88,10 @@ class GalleryViewModel extends BaseViewModel { void onSearchOptionChanged(SearchOption? option) { _searchOptionNotifier.value = option!; - _loggingService.info('Switched over to $option search'); + log.info('Switched over to $option search'); _imageSearchResultsNotifier.value = []; - _loggingService.info('Cleared resultsw from view'); + log.info('Cleared resultsw from view'); //todo(mehul): Either redo search or force user to type in new (trigger) by clearing field } diff --git a/lib/features/home/views/gallery/search_gallery_view.dart b/lib/features/home/views/gallery/search_gallery_view.dart index 7ec68b3..87b8df7 100644 --- a/lib/features/home/views/gallery/search_gallery_view.dart +++ b/lib/features/home/views/gallery/search_gallery_view.dart @@ -29,12 +29,7 @@ class _SearchGalleryView extends StatelessWidget { builder: (context, final searchOption, child) { switch (searchOption) { case SearchOption.local: - return Wrap( - runSpacing: 24, - spacing: 8, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, + return CustomWrap( children: [ for (final resultsImageModel in resultsImageModels) CachedNetworkImage( @@ -48,12 +43,7 @@ class _SearchGalleryView extends StatelessWidget { ], ); case SearchOption.web: - return Wrap( - runSpacing: 24, - spacing: 8, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, + return CustomWrap( children: [ for (final imageResult in resultsImageModels) Image.network( 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 c8f5259..235f2e9 100644 --- a/lib/features/home/views/image_carousel/image_carousel_view.dart +++ b/lib/features/home/views/image_carousel/image_carousel_view.dart @@ -61,13 +61,16 @@ class ImageCarouselView extends StatelessWidget { children: [ ValueListenableBuilder( valueListenable: model.currentImageModelListenable, - builder: (context, _, __) => CachedNetworkImage( - imageUrl: model.currentImageUrl, - cacheKey: model.currentImageKey, - fit: BoxFit.contain, - progressIndicatorBuilder: (_, __, final progress) => - CircularProgressIndicator( - value: model.downloadProgressValue(progress: progress), + builder: (context, _, __) => Hero( + tag: model.currentImageIndex, + child: CachedNetworkImage( + imageUrl: model.currentImageUrl, + cacheKey: model.currentImageKey, + fit: BoxFit.contain, + 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 index 695e2df..85f4f1b 100644 --- a/lib/features/home/views/image_carousel/image_carousel_view_model.dart +++ b/lib/features/home/views/image_carousel/image_carousel_view_model.dart @@ -1,26 +1,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:mc_gallery/features/home/views/image_carousel/image_carousel_view.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/services/images_service.dart'; -import '/features/home/views/image_carousel/image_carousel_view.dart'; import '/locator.dart'; import '../../data/models/image_model.dart'; class ImageCarouselViewModel extends BaseViewModel { ImageCarouselViewModel({ required ImagesService imagesService, - required NavigationService navigationService, - required LoggingService loggingService, - }) : _imagesService = imagesService, - _navigationService = navigationService, - _loggingService = loggingService; + }) : _imagesService = imagesService; final ImagesService _imagesService; - final NavigationService _navigationService; - final LoggingService _loggingService; late final ValueNotifier _currentImageModelNotifier; ValueListenable get currentImageModelListenable => _currentImageModelNotifier; @@ -28,8 +20,8 @@ class ImageCarouselViewModel extends BaseViewModel { @override Future initialise(bool Function() mounted, [arguments]) async { _currentImageModelNotifier = ValueNotifier(_imagesService.imageModels - .elementAt((arguments! as ImageCarouselViewArguments).imageIndexKey)); - _loggingService.info('Initialized with image: ${_currentImageModelNotifier.value.imageIndex}'); + .elementAt((arguments as ImageCarouselViewArguments).imageIndexKey)); + log.info('Initialized with image: ${_currentImageModelNotifier.value.imageIndex}'); super.initialise(mounted, arguments); } @@ -41,7 +33,7 @@ class ImageCarouselViewModel extends BaseViewModel { void swipedTo({required int newIndex}) { _currentImageModelNotifier.value = _imagesService.imageModelAt(index: newIndex); - _loggingService.info('Swiped to image: ${_currentImageModelNotifier.value.imageIndex}'); + log.info('Swiped to image: ${_currentImageModelNotifier.value.imageIndex}'); } String get currentImageUrl => currentImageModelListenable.value.uri.toString(); diff --git a/lib/features/home/widgets/custom_wrap.dart b/lib/features/home/widgets/custom_wrap.dart new file mode 100644 index 0000000..7f7689c --- /dev/null +++ b/lib/features/home/widgets/custom_wrap.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import '/features/core/data/constants/const_colors.dart'; + +class CustomWrap extends StatelessWidget { + const CustomWrap({ + required this.children, + super.key, + }); + + final List children; + + @override + Widget build(BuildContext context) { + return children.isNotEmpty + ? Wrap( + runSpacing: 24, + spacing: 8, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: children, + ) + : const Icon( + Icons.image, + size: 80, + color: ConstColours.red, + ); + } +} diff --git a/lib/l10n/generated/intl/messages_en.dart b/lib/l10n/generated/intl/messages_en.dart index 2b66a59..a9e2d94 100644 --- a/lib/l10n/generated/intl/messages_en.dart +++ b/lib/l10n/generated/intl/messages_en.dart @@ -28,6 +28,9 @@ class MessageLookup extends MessageLookupByLibrary { final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { + "appTitle": MessageLookupByLibrary.simpleMessage("MC Gallery App"), + "errorPageMessage": MessageLookupByLibrary.simpleMessage( + "Oopsie, there has been an error. Hang tight till we do something about it."), "gallery": MessageLookupByLibrary.simpleMessage("Gallery"), "imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"), "imageDetails": MessageLookupByLibrary.simpleMessage( diff --git a/lib/l10n/generated/l10n.dart b/lib/l10n/generated/l10n.dart index d2b457c..360fa09 100644 --- a/lib/l10n/generated/l10n.dart +++ b/lib/l10n/generated/l10n.dart @@ -50,6 +50,16 @@ class Strings { return Localizations.of(context, Strings); } + /// `MC Gallery App` + String get appTitle { + return Intl.message( + 'MC Gallery App', + name: 'appTitle', + desc: '', + args: [], + ); + } + /// `Something went wrong` String get somethingWentWrong { return Intl.message( @@ -60,6 +70,16 @@ class Strings { ); } + /// `Oopsie, there has been an error. Hang tight till we do something about it.` + String get errorPageMessage { + return Intl.message( + 'Oopsie, there has been an error. Hang tight till we do something about it.', + name: 'errorPageMessage', + desc: '', + args: [], + ); + } + /// `Image {imageNumber}: size={imageSide}` String imageNameFetch(Object imageNumber, Object imageSide) { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1880e95..6a2f658 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,6 +1,8 @@ { "@@locale": "en", + "appTitle": "MC Gallery App", "somethingWentWrong": "Something went wrong", + "errorPageMessage": "Oopsie, there has been an error. Hang tight till we do something about it.", "imageNameFetch": "Image {imageNumber}: size={imageSide}", "imageNameSearch": "Search term '{searchStr}' result: Image {imageNumber}", diff --git a/lib/locator.dart b/lib/locator.dart index 1f10137..6547d25 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -3,6 +3,7 @@ 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'; +import 'package:mc_gallery/env/env.dart'; import 'features/core/abstracts/router/app_router.dart'; import 'features/core/services/app_lifecycle_service.dart'; @@ -37,7 +38,7 @@ class Locator { static void _registerAPIs() { instance().registerFactory( - () => UnsplashImagesApi(), + () => UnsplashImagesApi(token: Env.unsplashApiKey), ); } @@ -47,14 +48,11 @@ class Locator { imagesService: ImagesService.locate, navigationService: NavigationService.locate, imageCacheManagerService: ImageCacheManagerService.locate, - loggingService: LoggingService.locate, ), ); instance().registerFactory( () => ImageCarouselViewModel( imagesService: ImagesService.locate, - navigationService: NavigationService.locate, - loggingService: LoggingService.locate, ), ); } diff --git a/pubspec.lock b/pubspec.lock index 41b1854..b9708a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -232,6 +232,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.6" + envied: + dependency: "direct main" + description: + name: envied + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + envied_generator: + dependency: "direct dev" + description: + name: envied_generator + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 123390b..9e9ae6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,9 @@ dependencies: hive: ^2.2.3 hive_flutter: ^1.1.0 + # Environment + envied: ^0.3.0 + # Util backend intl_utils: ^2.8.1 connectivity_plus: ^3.0.2 @@ -58,6 +61,7 @@ dev_dependencies: build_runner: ^2.3.3 json_serializable: ^6.5.4 hive_generator: ^2.0.0 + envied_generator: ^0.3.0 # Annotations json_annotation: ^4.7.0