Favourites
This commit is contained in:
		
							parent
							
								
									1a7abb9e4b
								
							
						
					
					
						commit
						68d7f70ded
					
				
					 23 changed files with 469 additions and 67 deletions
				
			
		
							
								
								
									
										41
									
								
								assets/icons/star_filled.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								assets/icons/star_filled.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | |||
| <?xml version="1.0" encoding="iso-8859-1"?> | ||||
| <!-- Uploaded to: SVGRepo, www.svgrepo.com, Transformed by: SVGRepo Tools --> | ||||
| <svg height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  | ||||
| 	 viewBox="0 0 47.94 47.94" xml:space="preserve"> | ||||
| <path style="fill:#ED8A19;" d="M26.285,2.486l5.407,10.956c0.376,0.762,1.103,1.29,1.944,1.412l12.091,1.757 | ||||
| 	c2.118,0.308,2.963,2.91,1.431,4.403l-8.749,8.528c-0.608,0.593-0.886,1.448-0.742,2.285l2.065,12.042 | ||||
| 	c0.362,2.109-1.852,3.717-3.746,2.722l-10.814-5.685c-0.752-0.395-1.651-0.395-2.403,0l-10.814,5.685 | ||||
| 	c-1.894,0.996-4.108-0.613-3.746-2.722l2.065-12.042c0.144-0.837-0.134-1.692-0.742-2.285l-8.749-8.528 | ||||
| 	c-1.532-1.494-0.687-4.096,1.431-4.403l12.091-1.757c0.841-0.122,1.568-0.65,1.944-1.412l5.407-10.956 | ||||
| 	C22.602,0.567,25.338,0.567,26.285,2.486z"/> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| <g> | ||||
| </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1 KiB | 
							
								
								
									
										1
									
								
								assets/icons/star_outline.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/icons/star_outline.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 117.42"><path d="M66.71 3.55L81.1 37.26l36.58 3.28v-.01c1.55.13 2.91.89 3.85 2.01a5.663 5.663 0 011.32 4.13v.01a5.673 5.673 0 01-1.69 3.57c-.12.13-.25.25-.39.36L93.25 74.64l8.19 35.83c.35 1.53.05 3.06-.73 4.29a5.652 5.652 0 01-3.54 2.52l-.14.03c-.71.14-1.43.15-2.12.02v.01c-.75-.13-1.47-.42-2.11-.84l-.05-.03-31.3-18.71-31.55 18.86a5.664 5.664 0 01-7.79-1.96c-.38-.64-.62-1.33-.73-2.02-.1-.63-.09-1.27.02-1.89.02-.13.04-.27.08-.4l8.16-35.7c-9.24-8.07-18.74-16.1-27.83-24.3l-.08-.08a5.64 5.64 0 01-1.72-3.7c-.1-1.45.36-2.93 1.4-4.12l.12-.13.08-.08a5.668 5.668 0 013.77-1.72h.06l36.34-3.26 14.44-33.8c.61-1.44 1.76-2.5 3.11-3.05 1.35-.54 2.9-.57 4.34.04.69.29 1.3.71 1.8 1.22.53.53.94 1.15 1.22 1.82l.02.06zm10.19 37.2L61.85 5.51a.42.42 0 00-.09-.14.42.42 0 00-.14-.09.427.427 0 00-.35 0c-.1.04-.19.12-.24.24L45.98 40.75c-.37.86-1.18 1.49-2.18 1.58l-37.9 3.4c-.08.01-.16.02-.24.02-.06 0-.13.02-.18.05-.03.01-.05.03-.07.05l-.1.12c-.05.08-.07.17-.06.26.01.09.04.18.09.25.06.05.13.11.19.17l28.63 25c.77.61 1.17 1.62.94 2.65l-8.51 37.22-.03.14c-.01.06-.02.12-.01.17a.454.454 0 00.33.36c.12.03.24.02.34-.04l32.85-19.64c.8-.5 1.85-.54 2.72-.02L95.43 112c.08.04.16.09.24.14.05.03.1.05.16.06v.01c.04.01.09.01.14 0l.04-.01c.12-.03.22-.1.28-.2.06-.09.08-.21.05-.33L87.8 74.28a2.6 2.6 0 01.83-2.55l28.86-25.2c.04-.03.07-.08.1-.13.02-.04.03-.1.04-.17a.497.497 0 00-.09-.33.48.48 0 00-.3-.15v-.01c-.01 0-.03 0-.03-.01l-37.97-3.41c-1-.01-1.93-.6-2.34-1.57z" fill="#ffcf00"/></svg> | ||||
| After Width: | Height: | Size: 1.5 KiB | 
|  | @ -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<void> initialise() async { | ||||
|     WidgetsFlutterBinding.ensureInitialized(); | ||||
| 
 | ||||
|  | @ -19,6 +19,8 @@ abstract class AppSetup { | |||
|       DeviceOrientation.portraitDown, | ||||
|     ]); | ||||
| 
 | ||||
|     await Hive.initFlutter(); | ||||
| 
 | ||||
|     await Locator.setup(); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										26
									
								
								lib/features/core/data/constants/const_media.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/features/core/data/constants/const_media.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||
|       ); | ||||
| } | ||||
|  | @ -1,6 +1,17 @@ | |||
| import 'dart:collection'; | ||||
| 
 | ||||
| extension MapExtensions<A, B> on Map<A, B> { | ||||
|   Map<A, B> get deepCopy => {...this}; | ||||
| 
 | ||||
|   /// Returns the values of a [Map] at given [keys] indices. | ||||
|   Iterable<B> valuesByKeys({required Iterable<A> keys}) => keys.map((final key) => this[key]!); | ||||
| } | ||||
| 
 | ||||
| extension LinkedHashMapExtensions<A, B> on LinkedHashMap<A, B> { | ||||
|   /// 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; | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,5 @@ | |||
| import 'package:flutter/cupertino.dart'; | ||||
| 
 | ||||
| extension ValueNotifierBoolExtensions on ValueNotifier<bool> { | ||||
|   void flipValue() => value = !value; | ||||
| } | ||||
							
								
								
									
										47
									
								
								lib/features/core/services/local_storage_service.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								lib/features/core/services/local_storage_service.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<bool> _userBox; | ||||
| 
 | ||||
|   Future<void> _init() async { | ||||
|     _userBox = await Hive.openBox(_userBoxKey); | ||||
| 
 | ||||
|     Locator.instance().signalReady(this); | ||||
|   } | ||||
| 
 | ||||
|   Iterable<bool> get storedFavouritesStates => _userBox.values; | ||||
| 
 | ||||
|   void initNewFavourites({required Iterable<bool> 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(); | ||||
| } | ||||
|  | @ -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; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<Iterable<Map<String, dynamic>>> fetchImageUri({required String token}); | ||||
|   FutureOr<Iterable<ImageModelDTO>> fetchImageUri({required String token}); | ||||
| 
 | ||||
|   FutureOr<Iterable<Map<String, dynamic>>> searchImages({ | ||||
|   FutureOr<Iterable<ImageModelDTO>> searchImages({ | ||||
|     required String searchStr, | ||||
|     required String token, | ||||
|   }); | ||||
|  |  | |||
|  | @ -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<Iterable<Map<String, dynamic>>> fetchImageUri({required String token}) async { | ||||
|   FutureOr<Iterable<ImageModelDTO>> fetchImageUri({required String token}) async { | ||||
|     // Dummy fetching delay emulation | ||||
|     await Future.delayed(const Duration( | ||||
|         milliseconds: ConstValues.defaultEmulatedLatencyMillis * ConstValues.numberOfImages)); | ||||
| 
 | ||||
|     final Iterable<Map<String, dynamic>> 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<Iterable<Map<String, dynamic>>> searchImages({ | ||||
|   FutureOr<Iterable<ImageModelDTO>> 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<Map<String, dynamic>> searchImageModelDtos; | ||||
|     try { | ||||
|       // Create (randomly-bounded) dummy number of images | ||||
|       final dummyImageModels = Iterable<int>.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( | ||||
|  |  | |||
							
								
								
									
										26
									
								
								lib/features/home/data/dtos/image_model_dto.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/features/home/data/dtos/image_model_dto.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<String, dynamic> json) => _$ImageModelDTOFromJson(json); | ||||
|   Map<String, dynamic> toJson() => _$ImageModelDTOToJson(this); | ||||
| } | ||||
|  | @ -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<String, dynamic> json) => ImageModel( | ||||
| ImageModelDTO _$ImageModelDTOFromJson(Map<String, dynamic> json) => | ||||
|     ImageModelDTO( | ||||
|       uri: Uri.parse(json['uri'] as String), | ||||
|       imageIndex: json['imageIndex'] as int, | ||||
|       imageName: json['imageName'] as String, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$ImageModelToJson(ImageModel instance) => | ||||
| Map<String, dynamic> _$ImageModelDTOToJson(ImageModelDTO instance) => | ||||
|     <String, dynamic>{ | ||||
|       'uri': instance.uri.toString(), | ||||
|       'imageIndex': instance.imageIndex, | ||||
|  | @ -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<String, dynamic> json) => _$ImageModelFromJson(json); | ||||
|   /// Whether the image was 'Starred' ot not. | ||||
|   final bool isFavourite; | ||||
| 
 | ||||
|   /// Connect the generated [_$PersonToJson] function to the `toJson` method. | ||||
|   Map<String, dynamic> 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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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<void> emptyCache() async => await DefaultCacheManager().emptyCache(); | ||||
|   Future<void> emptyCache() async => await _cacheManager.emptyCache(); | ||||
| 
 | ||||
|   Future<void> _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(); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|  |  | |||
|  | @ -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<String, ImageModel> _imageModels; | ||||
|   late final LinkedHashMap<String, ImageModel> _imageModels; | ||||
|   Iterable<ImageModel> get imageModels => _imageModels.values.deepCopy; | ||||
| 
 | ||||
|   final Mutex _searchMutex = Mutex(); | ||||
|  | @ -41,11 +48,37 @@ class ImagesService { | |||
| 
 | ||||
|   Future<void> _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(); | ||||
| } | ||||
|  |  | |||
|  | @ -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<bool>( | ||||
|           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); | ||||
|           }, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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<bool>( | ||||
|                                         valueListenable: model.isViewingFavouriteListenable, | ||||
|                                         builder: (context, final isViewingFavourites, child) => | ||||
|                                             Switch( | ||||
|                                           value: isViewingFavourites, | ||||
|                                           onChanged: model.onFavouriteViewChange, | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                       !isSearching | ||||
|                                           ? _DownloadedGalleryView(galleryViewModel: model) | ||||
|                                           : _SearchGalleryView(galleryViewModel: model), | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ); | ||||
|                           } | ||||
|  |  | |||
|  | @ -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<List<ImageModel>> _imageSearchResultsNotifier = ValueNotifier([]); | ||||
|   ValueListenable<List<ImageModel>> get imageSearchResultsListenable => _imageSearchResultsNotifier; | ||||
| 
 | ||||
|   final ValueNotifier<bool> _isViewingFavouriteNotifier = ValueNotifier(false); | ||||
|   ValueListenable<bool> get isViewingFavouriteListenable => _isViewingFavouriteNotifier; | ||||
| 
 | ||||
|   @override | ||||
|   Future<void> 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<void> 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<ImageModel> get favouriteImageModels => | ||||
|       imageModels.where((final imageModel) => imageModel.isFavourite); | ||||
| 
 | ||||
|   void onPromptPressed() => _isDisplayingPressingPrompt.value = false; | ||||
| 
 | ||||
|   Iterable<ImageModel> get imageModels => _imagesService.imageModels; | ||||
|  |  | |||
|  | @ -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}); | ||||
|  |  | |||
|  | @ -4,10 +4,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({ | ||||
|  |  | |||
|  | @ -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>( | ||||
|       ImagesService(imagesApi: UnsplashImagesApi.locate, loggingService: LoggingService.locate), | ||||
|     it.registerSingleton<LocalStorageService>( | ||||
|       LocalStorageService(), | ||||
|       signalsReady: true, | ||||
|     ); | ||||
|     await it.isReady<LocalStorageService>(); | ||||
| 
 | ||||
|     it.registerSingleton<ImagesService>( | ||||
|       ImagesService( | ||||
|         imagesApi: UnsplashImagesApi.locate, | ||||
|         localStorageService: LocalStorageService.locate, | ||||
|         loggingService: LoggingService.locate, | ||||
|       ), | ||||
|     ); | ||||
|     //await it.isReady<ImagesService>(); | ||||
| 
 | ||||
|     it.registerSingleton( | ||||
|       ImageCacheManagerService( | ||||
|         appLifecycleService: AppLifecycleService.locate, | ||||
|         localStorageService: LocalStorageService.locate, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
							
								
								
									
										21
									
								
								pubspec.lock
									
										
									
									
									
								
							
							
						
						
									
										21
									
								
								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: | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue