diff --git a/assets/icons/star_filled.svg b/assets/icons/star_filled.svg new file mode 100644 index 0000000..3ae6fef --- /dev/null +++ b/assets/icons/star_filled.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/star_outline.svg b/assets/icons/star_outline.svg new file mode 100644 index 0000000..99b6849 --- /dev/null +++ b/assets/icons/star_outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/features/core/abstracts/app_setup.dart b/lib/features/core/abstracts/app_setup.dart index 854f6a2..05b08be 100644 --- a/lib/features/core/abstracts/app_setup.dart +++ b/lib/features/core/abstracts/app_setup.dart @@ -1,14 +1,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import '/l10n/generated/l10n.dart'; import '/locator.dart'; abstract class AppSetup { - // TODO: When locator is properly refactored we should not have to use these stub methods for testing static Future initialise() async { WidgetsFlutterBinding.ensureInitialized(); @@ -19,6 +19,8 @@ abstract class AppSetup { DeviceOrientation.portraitDown, ]); + await Hive.initFlutter(); + await Locator.setup(); } diff --git a/lib/features/core/data/constants/const_media.dart b/lib/features/core/data/constants/const_media.dart new file mode 100644 index 0000000..9e2af06 --- /dev/null +++ b/lib/features/core/data/constants/const_media.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +abstract class ConstMedia { + static const String favStarFilled = 'assets/icons/star_filled.svg'; + static const String favStarOutline = 'assets/icons/star_outline.svg'; + + static SvgPicture buildIcon( + String iconReference, { + Color? color, + double? width, + double? height, + BoxFit fit = BoxFit.contain, + Clip clipBehavior = Clip.hardEdge, + Alignment alignment = Alignment.center, + }) => + SvgPicture.asset( + iconReference, + color: color, + width: width, + height: height, + fit: fit, + clipBehavior: clipBehavior, + alignment: alignment, + ); +} diff --git a/lib/features/core/data/extensions/map_extensions.dart b/lib/features/core/data/extensions/map_extensions.dart index 26f310a..74de75a 100644 --- a/lib/features/core/data/extensions/map_extensions.dart +++ b/lib/features/core/data/extensions/map_extensions.dart @@ -1,6 +1,17 @@ +import 'dart:collection'; + extension MapExtensions on Map { Map get deepCopy => {...this}; /// Returns the values of a [Map] at given [keys] indices. Iterable valuesByKeys({required Iterable keys}) => keys.map((final key) => this[key]!); } + +extension LinkedHashMapExtensions on LinkedHashMap { + /// Updated the value at [valueIndex] to [newValue], in addition to preserving the order. + void updateValueAt({ + required int valueIndex, + required B newValue, + }) => + this[keys.toList()[valueIndex]] = newValue; +} diff --git a/lib/features/core/data/extensions/value_notifier_extensions.dart b/lib/features/core/data/extensions/value_notifier_extensions.dart new file mode 100644 index 0000000..8d4b278 --- /dev/null +++ b/lib/features/core/data/extensions/value_notifier_extensions.dart @@ -0,0 +1,5 @@ +import 'package:flutter/cupertino.dart'; + +extension ValueNotifierBoolExtensions on ValueNotifier { + void flipValue() => value = !value; +} diff --git a/lib/features/core/services/local_storage_service.dart b/lib/features/core/services/local_storage_service.dart new file mode 100644 index 0000000..0b4898c --- /dev/null +++ b/lib/features/core/services/local_storage_service.dart @@ -0,0 +1,47 @@ +import 'package:hive/hive.dart'; +import 'package:mc_gallery/features/core/services/logging_service.dart'; +import 'package:mc_gallery/locator.dart'; + +class LocalStorageService { + LocalStorageService() { + _init(); + } + + final LoggingService _loggingService = LoggingService.locate; + + static const String _userBoxKey = 'userBoxKey'; + + late final Box _userBox; + + Future _init() async { + _userBox = await Hive.openBox(_userBoxKey); + + Locator.instance().signalReady(this); + } + + Iterable get storedFavouritesStates => _userBox.values; + + void initNewFavourites({required Iterable newValues}) { + _userBox.addAll(newValues); + _loggingService.info('Adding new favourites value'); + } + + void updateFavourite({ + required index, + required bool newValue, + }) { + try { + _userBox.putAt(index, newValue); + _loggingService.good('Successfully updated favourite status at $index -> $newValue'); + } on Exception catch (ex, stackTrace) { + _loggingService.handleException(ex, stackTrace); + } + } + + void resetFavourites() { + _userBox.clear(); + _loggingService.info('Cleared favourites table'); + } + + static LocalStorageService get locate => Locator.locate(); +} diff --git a/lib/features/core/services/logging_service.dart b/lib/features/core/services/logging_service.dart index 6144583..8a968b1 100644 --- a/lib/features/core/services/logging_service.dart +++ b/lib/features/core/services/logging_service.dart @@ -25,7 +25,8 @@ class LoggingService { void Function(dynamic msg, [Object? exception, StackTrace? stackTrace]) get fine => _talker.fine; void Function(dynamic msg, [Object? exception, StackTrace? stackTrace]) get good => _talker.good; - void Function(dynamic msg, [Object? exception, StackTrace? stackTrace]) get info => _talker.info; + void Function(dynamic msg, [Object? exception, StackTrace? stackTrace]) get info => + _talker.verbose; void Function(dynamic msg, [Object exception, StackTrace stackTrace]) get warning => _talker.warning; diff --git a/lib/features/home/abstracts/images_api.dart b/lib/features/home/abstracts/images_api.dart index de9314f..ecc1e1c 100644 --- a/lib/features/home/abstracts/images_api.dart +++ b/lib/features/home/abstracts/images_api.dart @@ -1,13 +1,15 @@ import 'dart:async'; +import '../data/dtos/image_model_dto.dart'; + /// Interface for implementing image-fetching strategies, specific to a resource location on the internet. /// /// 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}); + FutureOr> fetchImageUri({required String token}); - FutureOr>> searchImages({ + 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 0ed3974..283fcab 100644 --- a/lib/features/home/api/unsplash_images_api.dart +++ b/lib/features/home/api/unsplash_images_api.dart @@ -7,18 +7,19 @@ import '/features/core/services/logging_service.dart'; import '/l10n/generated/l10n.dart'; import '/locator.dart'; import '../abstracts/images_api.dart'; -import '../data/models/image_model.dart'; +import '../data/dtos/image_model_dto.dart'; class UnsplashImagesApi implements ImagesApi { final LoggingService _loggingService = LoggingService.locate; final random = Random(); @override - FutureOr>> fetchImageUri({required String token}) async { + FutureOr> fetchImageUri({required String token}) async { // Dummy fetching delay emulation await Future.delayed(const Duration( milliseconds: ConstValues.defaultEmulatedLatencyMillis * ConstValues.numberOfImages)); + final Iterable> fetchedImageModelDtos; try { // Create fixed number of images final dummyImageModels = @@ -29,7 +30,7 @@ class UnsplashImagesApi implements ImagesApi { final imageUri = _imageUrlGenerator(imageSide: imageSide); - return ImageModel( + return ImageModelDTO( imageIndex: imageIndex, uri: imageUri, // Custom dummy name for the image @@ -39,15 +40,19 @@ class UnsplashImagesApi implements ImagesApi { }); // Emulating serialization - return dummyImageModels.map((final dummyModel) => dummyModel.toJson()); + fetchedImageModelDtos = dummyImageModels.map((final dummyModel) => dummyModel.toJson()); } on Exception catch (ex, stackTrace) { _loggingService.handleException(ex, stackTrace); return const Iterable.empty(); } + + // Emulating deserialization + return fetchedImageModelDtos + .map((final emulatedModelSerialized) => ImageModelDTO.fromJson(emulatedModelSerialized)); } @override - FutureOr>> searchImages({ + FutureOr> searchImages({ required String searchStr, required String token, }) async { @@ -57,6 +62,7 @@ class UnsplashImagesApi implements ImagesApi { await Future.delayed( Duration(milliseconds: ConstValues.defaultEmulatedLatencyMillis * numberOfResults)); + final Iterable> searchImageModelDtos; try { // Create (randomly-bounded) dummy number of images final dummyImageModels = Iterable.generate(numberOfResults).map((final imageIndex) { @@ -66,7 +72,7 @@ class UnsplashImagesApi implements ImagesApi { final imageUri = _imageUrlGenerator(imageSide: imageSide); - return ImageModel( + return ImageModelDTO( imageIndex: imageIndex, uri: imageUri, // Custom dummy name for the image @@ -75,11 +81,14 @@ class UnsplashImagesApi implements ImagesApi { }); // Emulating serialization - return dummyImageModels.map((final dummyModel) => dummyModel.toJson()); + searchImageModelDtos = dummyImageModels.map((final dummyModel) => dummyModel.toJson()); } on Exception catch (ex, stackTrace) { _loggingService.handleException(ex, stackTrace); return List.empty(); } + + return searchImageModelDtos + .map((final emulatedModelSerialized) => ImageModelDTO.fromJson(emulatedModelSerialized)); } Uri _imageUrlGenerator({required int imageSide}) => Uri( diff --git a/lib/features/home/data/dtos/image_model_dto.dart b/lib/features/home/data/dtos/image_model_dto.dart new file mode 100644 index 0000000..be81dab --- /dev/null +++ b/lib/features/home/data/dtos/image_model_dto.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'image_model_dto.g.dart'; + +@JsonSerializable() +class ImageModelDTO { + const ImageModelDTO({ + required this.uri, + required this.imageIndex, + required this.imageName, + }); + + /// An image's target [Uri]. + /// + /// Storing an image's [ByteData] is more expensive, memory-wise. + final Uri uri; + + /// A unique identifier that can be used for indexing the image. + final int imageIndex; + + /// Given name of the image. + final String imageName; + + factory ImageModelDTO.fromJson(Map json) => _$ImageModelDTOFromJson(json); + Map toJson() => _$ImageModelDTOToJson(this); +} diff --git a/lib/features/home/data/models/image_model.g.dart b/lib/features/home/data/dtos/image_model_dto.g.dart similarity index 73% rename from lib/features/home/data/models/image_model.g.dart rename to lib/features/home/data/dtos/image_model_dto.g.dart index fd16a20..da8769d 100644 --- a/lib/features/home/data/models/image_model.g.dart +++ b/lib/features/home/data/dtos/image_model_dto.g.dart @@ -1,18 +1,19 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'image_model.dart'; +part of 'image_model_dto.dart'; // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** -ImageModel _$ImageModelFromJson(Map json) => ImageModel( +ImageModelDTO _$ImageModelDTOFromJson(Map json) => + ImageModelDTO( uri: Uri.parse(json['uri'] as String), imageIndex: json['imageIndex'] as int, imageName: json['imageName'] as String, ); -Map _$ImageModelToJson(ImageModel instance) => +Map _$ImageModelDTOToJson(ImageModelDTO instance) => { 'uri': instance.uri.toString(), 'imageIndex': instance.imageIndex, diff --git a/lib/features/home/data/models/image_model.dart b/lib/features/home/data/models/image_model.dart index 4765552..60ddb65 100644 --- a/lib/features/home/data/models/image_model.dart +++ b/lib/features/home/data/models/image_model.dart @@ -1,13 +1,11 @@ -import 'package:json_annotation/json_annotation.dart'; +import '../dtos/image_model_dto.dart'; -part 'image_model.g.dart'; - -@JsonSerializable() class ImageModel { const ImageModel({ required this.uri, required this.imageIndex, required this.imageName, + required this.isFavourite, }); /// An image's target [Uri]. @@ -21,8 +19,31 @@ class ImageModel { /// Given name of the image. final String imageName; - factory ImageModel.fromJson(Map json) => _$ImageModelFromJson(json); + /// Whether the image was 'Starred' ot not. + final bool isFavourite; - /// Connect the generated [_$PersonToJson] function to the `toJson` method. - Map toJson() => _$ImageModelToJson(this); + factory ImageModel.fromDto({ + required ImageModelDTO imageModelDto, + required bool isFavourite, + }) => + ImageModel( + uri: imageModelDto.uri, + imageIndex: imageModelDto.imageIndex, + imageName: imageModelDto.imageName, + isFavourite: isFavourite, + ); + + ImageModel copyWith({ + Uri? uri, + int? imageIndex, + String? imageName, + bool? isFavourite, + }) { + return ImageModel( + uri: uri ?? this.uri, + imageIndex: imageIndex ?? this.imageIndex, + imageName: imageName ?? this.imageName, + isFavourite: isFavourite ?? this.isFavourite, + ); + } } diff --git a/lib/features/home/services/image_cache_manager_service.dart b/lib/features/home/services/image_cache_manager_service.dart index c0282e4..fcba448 100644 --- a/lib/features/home/services/image_cache_manager_service.dart +++ b/lib/features/home/services/image_cache_manager_service.dart @@ -2,19 +2,24 @@ 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 { - ImageCacheManagerService({ - required AppLifecycleService appLifecycleService, - }) : _appLifecycleService = appLifecycleService { + ImageCacheManagerService( + {required AppLifecycleService appLifecycleService, + required LocalStorageService localStorageService}) + : _appLifecycleService = appLifecycleService, + _localStorageService = localStorageService { _init(); } final AppLifecycleService _appLifecycleService; + final LocalStorageService _localStorageService; + final _cacheManager = DefaultCacheManager(); - Future emptyCache() async => await DefaultCacheManager().emptyCache(); + Future emptyCache() async => await _cacheManager.emptyCache(); Future _init() async { _appLifecycleService.addListener( @@ -27,7 +32,8 @@ class ImageCacheManagerService with LoggingService { case AppLifecycleState.paused: case AppLifecycleState.detached: info('Discarding cached images'); - await DefaultCacheManager().emptyCache(); + await _cacheManager.emptyCache(); + _localStorageService.resetFavourites(); } }, ); diff --git a/lib/features/home/services/images_service.dart b/lib/features/home/services/images_service.dart index c69a80f..212a2ee 100644 --- a/lib/features/home/services/images_service.dart +++ b/lib/features/home/services/images_service.dart @@ -1,10 +1,14 @@ import 'dart:async'; +import 'dart:collection'; -import 'package:mc_gallery/features/core/data/extensions/string_extensions.dart'; +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'; import '/features/core/data/extensions/map_extensions.dart'; +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 '/locator.dart'; @@ -19,16 +23,19 @@ import '../data/models/image_model.dart'; class ImagesService { ImagesService({ required ImagesApi imagesApi, + required LocalStorageService localStorageService, required LoggingService loggingService, }) : _imagesApi = imagesApi, + _localStorageService = localStorageService, _loggingService = loggingService { _init(); } final ImagesApi _imagesApi; + final LocalStorageService _localStorageService; final LoggingService _loggingService; - late final Map _imageModels; + late final LinkedHashMap _imageModels; Iterable get imageModels => _imageModels.values.deepCopy; final Mutex _searchMutex = Mutex(); @@ -41,11 +48,37 @@ class ImagesService { Future _init() async { _loggingService.info('Fetching and creating image models...'); - _imageModels = { - for (final imageModel in (await _imagesApi.fetchImageUri(token: '')) - .map((final emulatedModelSerialized) => ImageModel.fromJson(emulatedModelSerialized))) - imageModel.imageName: imageModel - }; + + final fetchedImageModelDtos = await _imagesApi.fetchImageUri(token: ''); + final favouritesStatuses = _localStorageService.storedFavouritesStates; + + // Prefill from stored values + if (favouritesStatuses.isNotEmpty) { + _loggingService.good('Found favourites statuses on device -> Prefilling'); + assert(fetchedImageModelDtos.length == favouritesStatuses.length); + _imageModels = LinkedHashMap.of({ + for (final pair in IterableZip([fetchedImageModelDtos, favouritesStatuses])) + (pair[0] as ImageModelDTO).imageName: ImageModel.fromDto( + imageModelDto: pair[0] as ImageModelDTO, + isFavourite: pair[1] as bool, + ) + }); + + // Set to false and create the stored values + } else { + _loggingService.good('NO favourites statuses found -> creating new'); + _imageModels = LinkedHashMap.of({ + for (final fetchedImageModelDto in fetchedImageModelDtos) + fetchedImageModelDto.imageName: ImageModel.fromDto( + imageModelDto: fetchedImageModelDto, + isFavourite: false, + ) + }); + + _localStorageService.initNewFavourites( + newValues: _imageModels.values.map((final imageModel) => imageModel.isFavourite), + ); + } _imageModels.isNotEmpty ? _loggingService.good("Created ${_imageModels.length} images' models") @@ -89,14 +122,20 @@ class ImagesService { ..sort((final a, final b) => ConstSorters.stringsSimilarityTarget(targetWord: imageNamePart, a, b)) ..reversed; + return _imageModels.valuesByKeys(keys: rankedKeys).toList(growable: false); + case SearchOption.web: return (await _imagesApi.searchImages( searchStr: imageNamePart, token: '', )) .map( - (final emulatedModelSerialized) => ImageModel.fromJson(emulatedModelSerialized)) + (final imageModelDto) => ImageModel.fromDto( + imageModelDto: imageModelDto, + isFavourite: false, + ), + ) .toList(growable: false); } } finally { @@ -105,5 +144,19 @@ class ImagesService { }); } + void updateImageFavouriteStatus({ + required ImageModel imageModel, + required bool newFavouriteStatus, + }) { + _imageModels.updateValueAt( + valueIndex: imageModel.imageIndex, + newValue: imageModel.copyWith(isFavourite: newFavouriteStatus), + ); + + //todo(mehul): Consider adding an update listener to _imageModels, sync with _localStorageService + _localStorageService.updateFavourite( + index: imageModel.imageIndex, newValue: newFavouriteStatus); + } + static ImagesService get locate => Locator.locate(); } diff --git a/lib/features/home/views/gallery/downloaded_gallery_view.dart b/lib/features/home/views/gallery/downloaded_gallery_view.dart index 36b9746..4247391 100644 --- a/lib/features/home/views/gallery/downloaded_gallery_view.dart +++ b/lib/features/home/views/gallery/downloaded_gallery_view.dart @@ -15,30 +15,108 @@ class _DownloadedGalleryView extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8), // 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 galleryViewModel.imageModels) - GestureDetector( - onTap: () => galleryViewModel.pushImageCarouselView( - context, - imageModel: imageModel, + child: ValueListenableBuilder( + valueListenable: galleryViewModel.isViewingFavouriteListenable, + builder: (context, final isViewingFavourites, _) => !isViewingFavourites + ? Wrap( + runSpacing: 24, + spacing: 8, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final imageModel in galleryViewModel.imageModels) + _StarrableImage( + key: ValueKey(imageModel.imageIndex), + imageModel: imageModel, + galleryViewModel: galleryViewModel, + ), + ], + ) + : Wrap( + runSpacing: 24, + spacing: 8, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final favouriteImageModel in galleryViewModel.favouriteImageModels) + _StarrableImage( + key: ValueKey(favouriteImageModel.imageIndex), + imageModel: favouriteImageModel, + galleryViewModel: galleryViewModel, + ), + ], ), - child: CachedNetworkImage( - imageUrl: imageModel.uri.toString(), - cacheKey: imageModel.imageIndex.toString(), - progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator( - value: galleryViewModel.downloadProgressValue(progress: progress), - ), - ), - ), - ], ), ), ); } } + +class _StarrableImage extends StatefulWidget { + const _StarrableImage({ + required this.galleryViewModel, + required this.imageModel, + super.key, + }); + + final GalleryViewModel galleryViewModel; + final ImageModel imageModel; + + @override + State<_StarrableImage> createState() => _StarrableImageState(); +} + +class _StarrableImageState extends State<_StarrableImage> { + late bool isMarkedFavourite; + + @override + void initState() { + super.initState(); + + isMarkedFavourite = widget.imageModel.isFavourite; + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.topRight.add(const Alignment(0.2, -0.2)), + children: [ + GestureDetector( + onTap: () => widget.galleryViewModel.pushImageCarouselView( + 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), + ), + ), + ), + GestureDetector( + child: isMarkedFavourite + ? ConstMedia.buildIcon( + ConstMedia.favStarFilled, + width: 16, + height: 16, + ) + : ConstMedia.buildIcon( + ConstMedia.favStarOutline, + width: 16, + height: 16, + ), + onTap: () { + widget.galleryViewModel.updateImageFavouriteStatus( + imageModel: widget.imageModel, + newFavouriteStatus: !isMarkedFavourite, + ); + setState(() => isMarkedFavourite = !isMarkedFavourite); + }, + ), + ], + ); + } +} diff --git a/lib/features/home/views/gallery/gallery_view.dart b/lib/features/home/views/gallery/gallery_view.dart index 2e5e914..211fba5 100644 --- a/lib/features/home/views/gallery/gallery_view.dart +++ b/lib/features/home/views/gallery/gallery_view.dart @@ -1,5 +1,6 @@ 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'; @@ -80,9 +81,21 @@ class GalleryView extends StatelessWidget { valueListenable: model.isSearchingListenable, builder: (context, final isSearching, _) => AnimatedSwitcher( duration: ConstDurations.oneAndHalfDefaultAnimationDuration, - child: !isSearching - ? _DownloadedGalleryView(galleryViewModel: model) - : _SearchGalleryView(galleryViewModel: model), + 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), + ], + ), ), ); } diff --git a/lib/features/home/views/gallery/gallery_view_model.dart b/lib/features/home/views/gallery/gallery_view_model.dart index 65cdc5d..f10b515 100644 --- a/lib/features/home/views/gallery/gallery_view_model.dart +++ b/lib/features/home/views/gallery/gallery_view_model.dart @@ -3,6 +3,7 @@ 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'; @@ -27,6 +28,7 @@ class GalleryViewModel extends BaseViewModel { final ImagesService _imagesService; final NavigationService _navigationService; + //todo(mehul): Use to implement pull-to-refresh or an extra widget final ImageCacheManagerService _imageCacheManagerService; final LoggingService _loggingService; @@ -40,6 +42,9 @@ class GalleryViewModel extends BaseViewModel { final ValueNotifier> _imageSearchResultsNotifier = ValueNotifier([]); ValueListenable> get imageSearchResultsListenable => _imageSearchResultsNotifier; + final ValueNotifier _isViewingFavouriteNotifier = ValueNotifier(false); + ValueListenable get isViewingFavouriteListenable => _isViewingFavouriteNotifier; + @override Future initialise(bool Function() mounted, [arguments]) async { super.initialise(mounted, arguments); @@ -80,7 +85,7 @@ class GalleryViewModel extends BaseViewModel { _loggingService.info('Clearing of results on view mode change'); } - _isSearchingNotifier.value = !_isSearchingNotifier.value; + _isSearchingNotifier.flipValue(); } Future get lastQueryResultDone => _imagesService.lastQueryIsCompleted; @@ -92,9 +97,24 @@ class GalleryViewModel extends BaseViewModel { _imageSearchResultsNotifier.value = []; _loggingService.info('Cleared resultsw from view'); - //todo(mehul): Either redo search or force user to type in by clearing field + //todo(mehul): Either redo search or force user to type in new (trigger) by clearing field } + void onFavouriteViewChange(bool newValue) => _isViewingFavouriteNotifier.value = newValue; + + void updateImageFavouriteStatus({ + required ImageModel imageModel, + required bool newFavouriteStatus, + }) { + _imagesService.updateImageFavouriteStatus( + imageModel: imageModel, + newFavouriteStatus: newFavouriteStatus, + ); + } + + Iterable get favouriteImageModels => + imageModels.where((final imageModel) => imageModel.isFavourite); + void onPromptPressed() => _isDisplayingPressingPrompt.value = false; Iterable get imageModels => _imagesService.imageModels; 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 f0a34fb..f7929c2 100644 --- a/lib/features/home/views/image_carousel/image_carousel_view.dart +++ b/lib/features/home/views/image_carousel/image_carousel_view.dart @@ -3,7 +3,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:mc_gallery/features/home/data/models/image_model.dart'; import '/features/core/data/constants/const_colors.dart'; import '/features/core/data/constants/const_text.dart'; @@ -11,6 +10,7 @@ import '/features/core/widgets/gap.dart'; import '/features/core/widgets/mcg_scaffold.dart'; import '/features/home/views/image_carousel/image_carousel_view_model.dart'; import '../../../core/widgets/state/view_model_builder.dart'; +import '../../data/models/image_model.dart'; class ImageCarouselViewArguments { const ImageCarouselViewArguments({required this.imageIndexKey}); 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 63dcdcc..9d87e63 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 @@ -5,10 +5,10 @@ import 'package:flutter_cache_manager/flutter_cache_manager.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'; +import '../../data/models/image_model.dart'; class ImageCarouselViewModel extends BaseViewModel { ImageCarouselViewModel({ diff --git a/lib/locator.dart b/lib/locator.dart index ecc5b57..1f10137 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -7,6 +7,7 @@ import 'package:internet_connection_checker/internet_connection_checker.dart'; import 'features/core/abstracts/router/app_router.dart'; import 'features/core/services/app_lifecycle_service.dart'; import 'features/core/services/connections_service.dart'; +import 'features/core/services/local_storage_service.dart'; import 'features/core/services/logging_service.dart'; import 'features/core/services/navigation_service.dart'; import 'features/core/services/overlay_service.dart'; @@ -94,14 +95,24 @@ class Locator { dispose: (final param) async => await param.dispose(), ); - it.registerSingleton( - ImagesService(imagesApi: UnsplashImagesApi.locate, loggingService: LoggingService.locate), + it.registerSingleton( + LocalStorageService(), + signalsReady: true, + ); + await it.isReady(); + + it.registerSingleton( + ImagesService( + imagesApi: UnsplashImagesApi.locate, + localStorageService: LocalStorageService.locate, + loggingService: LoggingService.locate, + ), ); - //await it.isReady(); it.registerSingleton( ImageCacheManagerService( appLifecycleService: AppLifecycleService.locate, + localStorageService: LocalStorageService.locate, ), ); } diff --git a/pubspec.lock b/pubspec.lock index 5a3a54c..41b1854 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -345,6 +345,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.0" + hive: + dependency: "direct main" + description: + name: hive + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" http: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5bc5132..123390b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,10 @@ dependencies: cached_network_image: ^3.2.3 flutter_cache_manager: ^3.3.0 + # Storage + hive: ^2.2.3 + hive_flutter: ^1.1.0 + # Util backend intl_utils: ^2.8.1 connectivity_plus: ^3.0.2 @@ -52,8 +56,11 @@ dev_dependencies: # Builders build_runner: ^2.3.3 - json_annotation: ^4.7.0 json_serializable: ^6.5.4 + hive_generator: ^2.0.0 + + # Annotations + json_annotation: ^4.7.0 flutter: uses-material-design: true