refactoring

security update
This commit is contained in:
Mguy13 2022-12-25 22:17:59 +01:00
parent 1747ab0245
commit 78fe9e7b09
39 changed files with 289 additions and 132 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
# Custom # Custom
*.pdf *.pdf
*.env
env.g.dart
# Miscellaneous # Miscellaneous
*.class *.class

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'features/core/abstracts/app_setup.dart'; import 'features/core/abstracts/app_setup.dart';
import 'features/core/abstracts/router/app_router.dart'; import 'features/core/abstracts/router/app_router.dart';
import 'features/core/data/constants/const_colors.dart'; import 'features/core/data/constants/const_colors.dart';
import 'l10n/generated/l10n.dart';
class McgApp extends StatelessWidget { class McgApp extends StatelessWidget {
const McgApp({super.key}); const McgApp({super.key});
@ -11,7 +12,7 @@ class McgApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appRouter = McgRouter.locate.router; final appRouter = McgRouter.locate.router;
return MaterialApp.router( return MaterialApp.router(
title: 'MC Gallery App', title: Strings.current.appTitle,
theme: ConstThemes.materialLightTheme, theme: ConstThemes.materialLightTheme,
darkTheme: ConstThemes.materialDarkTheme, darkTheme: ConstThemes.materialDarkTheme,
supportedLocales: AppSetup.supportedLocales, supportedLocales: AppSetup.supportedLocales,

12
lib/env/env.dart vendored Normal file
View File

@ -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;
}

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -49,5 +50,11 @@ abstract class AppSetup {
static void Function(Object error, StackTrace stackTrace) get onUncaughtException => ( static void Function(Object error, StackTrace stackTrace) get onUncaughtException => (
error, error,
stackTrace, stackTrace,
) {}; ) {
log(
'Error occurred during app startup',
error: error,
stackTrace: stackTrace,
);
};
} }

View File

@ -17,7 +17,7 @@ abstract class BaseViewModel<T extends Object?> extends ChangeNotifier {
final ValueNotifier<ViewModelState> _state = ValueNotifier(ViewModelState.isInitialising); final ValueNotifier<ViewModelState> _state = ValueNotifier(ViewModelState.isInitialising);
ValueListenable<ViewModelState> get state => _state; ValueListenable<ViewModelState> get state => _state;
final LoggingService _loggingService = LoggingService.locate; final LoggingService log = LoggingService.locate;
String? _errorMessage; String? _errorMessage;
String get errorMessage => _errorMessage ?? strings.somethingWentWrong; String get errorMessage => _errorMessage ?? strings.somethingWentWrong;
@ -27,7 +27,7 @@ abstract class BaseViewModel<T extends Object?> extends ChangeNotifier {
_mounted = mounted; _mounted = mounted;
_isInitialised.value = true; _isInitialised.value = true;
_state.value = ViewModelState.isInitialised; _state.value = ViewModelState.isInitialised;
_loggingService.successfulInit(location: runtimeType.toString()); log.successfulInit(location: runtimeType.toString());
} }
void setBusy(bool isBusy) { void setBusy(bool isBusy) {
@ -55,7 +55,7 @@ abstract class BaseViewModel<T extends Object?> extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
_loggingService.successfulDispose(location: runtimeType.toString()); log.successfulDispose(location: runtimeType.toString());
} }
late final bool Function() _mounted; late final bool Function() _mounted;

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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 '../../views/error_page_view.dart';
import 'routes.dart'; import 'routes.dart';
@ -15,7 +15,6 @@ class McgRouter {
key: state.pageKey, key: state.pageKey,
child: ErrorPageView(error: state.error), child: ErrorPageView(error: state.error),
), ),
//todo(mehul): Add Redirect
routes: [ routes: [
GoRoute( GoRoute(
path: Routes.home.routePath, path: Routes.home.routePath,

View File

@ -1,6 +1,6 @@
enum Routes { enum Routes {
home(RoutesInfo( home(RoutesInfo(
routePath: '/gallery', routePath: '/',
routeName: 'Home', routeName: 'Home',
)), )),
imageCarousel(RoutesInfo( imageCarousel(RoutesInfo(

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
abstract class ConstColours { abstract class ConstColours {
/// Smoke Gray => a neutral grey with neutral warmth/cold /// Smoke Gray => a neutral grey with neutral warmth/cold
static const galleryBackgroundColour = Color.fromRGBO(127, 127, 125, 1.0); static const galleryBackgroundColour = Color.fromRGBO(127, 127, 125, 1.0);
static const red = Colors.red;
static const white = Colors.white; static const white = Colors.white;
static const black = Colors.black; static const black = Colors.black;

View File

@ -3,7 +3,7 @@ abstract class ConstValues {
static const String backendHost = 'source.unsplash.com'; static const String backendHost = 'source.unsplash.com';
static const List<String> backendUrlPathSegments = ['user', 'c_v_r']; static const List<String> backendUrlPathSegments = ['user', 'c_v_r'];
static const int numberOfImages = 20; static const int numberOfImages = 25;
static const int minImageSize = 50; static const int minImageSize = 50;
static const int maxImageSize = 100; static const int maxImageSize = 100;

View File

@ -8,7 +8,7 @@ extension MapExtensions<A, B> on Map<A, B> {
} }
extension LinkedHashMapExtensions<A, B> on LinkedHashMap<A, B> { extension LinkedHashMapExtensions<A, B> on LinkedHashMap<A, B> {
/// 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({ void updateValueAt({
required int valueIndex, required int valueIndex,
required B newValue, required B newValue,

View File

@ -1,4 +1,4 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart';
extension ValueNotifierBoolExtensions on ValueNotifier<bool> { extension ValueNotifierBoolExtensions on ValueNotifier<bool> {
void flipValue() => value = !value; void flipValue() => value = !value;

View File

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart';
import 'package:mc_gallery/locator.dart';
import '/locator.dart';
import 'logging_service.dart'; import 'logging_service.dart';
typedef AddLifeCycleListener = void Function({ typedef AddLifeCycleListener = void Function({
@ -23,7 +23,8 @@ class AppLifecycleService with WidgetsBindingObserver {
final LoggingService _loggingService; final LoggingService _loggingService;
late final StreamController<AppLifecycleState> _streamController = StreamController.broadcast(); late final StreamController<AppLifecycleState> _lifecycleStateStreamController =
StreamController.broadcast();
final Map<String, StreamSubscription> _appLifecycleSubscriptions = {}; final Map<String, StreamSubscription> _appLifecycleSubscriptions = {};
AppLifecycleState? _appLifeCycleState; AppLifecycleState? _appLifeCycleState;
@ -43,14 +44,15 @@ class AppLifecycleService with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
try { try {
_appLifeCycleState = state; _appLifeCycleState = state;
_streamController.add(state); _lifecycleStateStreamController.add(state);
} catch (error, stackTrace) { } catch (error, stackTrace) {
_loggingService.error( _loggingService.error(
'Something went wrong logging ${state.name} inside the didChangeAppLifeCycleState method', 'Something went wrong with ${state.name} inside the didChangeAppLifeCycleState method',
error, error,
stackTrace, stackTrace,
); );
} }
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
} }
@ -61,17 +63,17 @@ class AppLifecycleService with WidgetsBindingObserver {
}) { }) {
try { try {
if (_appLifecycleSubscriptions.containsKey(tag)) { if (_appLifecycleSubscriptions.containsKey(tag)) {
_loggingService.warning('Tag already active, returning!'); _loggingService.warning('Tag already active, returning');
} else { } else {
final message = 'Adding $tag appLifecycleState listener'; final message = 'Adding $tag appLifecycleState listener';
_loggingService.info('$message..'); _loggingService.info('$message..');
if (_appLifeCycleState != null && tryCallListenerOnAdd) listener(_appLifeCycleState!); if (_appLifeCycleState != null && tryCallListenerOnAdd) listener(_appLifeCycleState!);
_appLifecycleSubscriptions[tag] = _streamController.stream.listen(listener); _appLifecycleSubscriptions[tag] = _lifecycleStateStreamController.stream.listen(listener);
_loggingService.good('$message success!'); _loggingService.good('$message success!');
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_loggingService.error( _loggingService.error(
'Something went wrong adding $tag appLifecycleState listener!', 'Something went wrong adding $tag appLifecycleState listener',
error, error,
stackTrace, stackTrace,
); );
@ -88,11 +90,11 @@ class AppLifecycleService with WidgetsBindingObserver {
_appLifecycleSubscriptions.remove(tag); _appLifecycleSubscriptions.remove(tag);
_loggingService.good('$message success!'); _loggingService.good('$message success!');
} else { } else {
_loggingService.warning('Subscription was not found!'); _loggingService.warning('Subscription was not found');
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_loggingService.error( _loggingService.error(
'Something went wrong removing $tag appLifecycleState listener!', 'Something went wrong removing $tag appLifecycleState listener',
error, error,
stackTrace, stackTrace,
); );

View File

@ -4,10 +4,10 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:internet_connection_checker/internet_connection_checker.dart'; import 'package:internet_connection_checker/internet_connection_checker.dart';
import '/features/core/services/logging_service.dart';
import '/locator.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 { class ConnectionsService {
ConnectionsService({ ConnectionsService({
required Connectivity connectivity, required Connectivity connectivity,

View File

@ -1,7 +1,9 @@
import 'package:hive/hive.dart'; 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 { class LocalStorageService {
LocalStorageService() { LocalStorageService() {
_init(); _init();
@ -9,10 +11,10 @@ class LocalStorageService {
final LoggingService _loggingService = LoggingService.locate; final LoggingService _loggingService = LoggingService.locate;
static const String _userBoxKey = 'userBoxKey';
late final Box<bool> _userBox; late final Box<bool> _userBox;
static const String _userBoxKey = 'userBoxKey';
Future<void> _init() async { Future<void> _init() async {
_userBox = await Hive.openBox(_userBoxKey); _userBox = await Hive.openBox(_userBoxKey);

View File

@ -8,6 +8,7 @@ import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
import '/locator.dart'; import '/locator.dart';
/// Handles logging of events.
class LoggingService { class LoggingService {
final Talker _talker = Talker( final Talker _talker = Talker(
settings: TalkerSettings( settings: TalkerSettings(

View File

@ -1,11 +1,12 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.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/app_router.dart';
import '../abstracts/router/routes.dart'; import '../abstracts/router/routes.dart';
/// Handles the navigation to and fro all the screens in the app.
class NavigationService { class NavigationService {
const NavigationService({ const NavigationService({
required McgRouter mcgRouter, required McgRouter mcgRouter,
@ -22,10 +23,5 @@ class NavigationService {
extra: imageCarouselViewArguments, extra: imageCarouselViewArguments,
); );
void backToGallery(BuildContext context) => context.pop();
void previous() {}
void next() {}
static NavigationService get locate => Locator.locate(); static NavigationService get locate => Locator.locate();
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart'; 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 { class OverlayService {
OverlayService({ OverlayService({

View File

@ -20,6 +20,7 @@ class Mutex {
return value; return value;
} }
/// Allows listening to the completion status of the last worker process to be released.
Future<void> get lastOperationCompletionAwaiter => Future<void> get lastOperationCompletionAwaiter =>
_completerQueue.isNotEmpty ? _completerQueue.last.future : Future.value(); _completerQueue.isNotEmpty ? _completerQueue.last.future : Future.value();
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '/l10n/generated/l10n.dart';
import '../widgets/gap.dart'; import '../widgets/gap.dart';
class ErrorPageView extends StatelessWidget { class ErrorPageView extends StatelessWidget {
@ -15,9 +16,8 @@ class ErrorPageView extends StatelessWidget {
return Center( return Center(
child: Column( child: Column(
children: [ children: [
const Text('Oopsie, there has been an error. Hang tight till we do something about it.'), Text(Strings.current.errorPageMessage),
const Gap(16), const Gap(16),
Text('what happened: $error'),
], ],
), ),
); );

View File

@ -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<Widget> 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(),
),
],
);
}
}

View File

@ -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 /// 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. /// and convoluting) interface is for adding a bit of flexibility to change strategy to some other site.
abstract class ImagesApi { abstract class ImagesApi {
FutureOr<Iterable<ImageModelDTO>> 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<Iterable<ImageModelDTO>> fetchImageUri();
FutureOr<Iterable<ImageModelDTO>> searchImages({ FutureOr<Iterable<ImageModelDTO>> searchImages({
required String searchStr, required String searchStr,
required String token,
}); });
} }

View File

@ -9,12 +9,14 @@ import '/locator.dart';
import '../abstracts/images_api.dart'; import '../abstracts/images_api.dart';
import '../data/dtos/image_model_dto.dart'; import '../data/dtos/image_model_dto.dart';
class UnsplashImagesApi implements ImagesApi { class UnsplashImagesApi extends ImagesApi {
final LoggingService _loggingService = LoggingService.locate; final LoggingService _loggingService = LoggingService.locate;
final random = Random(); final random = Random();
UnsplashImagesApi({required super.token});
@override @override
FutureOr<Iterable<ImageModelDTO>> fetchImageUri({required String token}) async { FutureOr<Iterable<ImageModelDTO>> fetchImageUri() async {
// Dummy fetching delay emulation // Dummy fetching delay emulation
await Future.delayed(const Duration( await Future.delayed(const Duration(
milliseconds: ConstValues.defaultEmulatedLatencyMillis * ConstValues.numberOfImages)); milliseconds: ConstValues.defaultEmulatedLatencyMillis * ConstValues.numberOfImages));
@ -54,7 +56,6 @@ class UnsplashImagesApi implements ImagesApi {
@override @override
FutureOr<Iterable<ImageModelDTO>> searchImages({ FutureOr<Iterable<ImageModelDTO>> searchImages({
required String searchStr, required String searchStr,
required String token,
}) async { }) async {
final numberOfResults = random.nextIntInRange(min: 0, max: ConstValues.numberOfImages); final numberOfResults = random.nextIntInRange(min: 0, max: ConstValues.numberOfImages);

View File

@ -1,5 +1,6 @@
import '/l10n/generated/l10n.dart'; import '/l10n/generated/l10n.dart';
/// Represents an option for specifying a search strategy, for an [ImageModel]
enum SearchOption { enum SearchOption {
local, local,
web; web;

View File

@ -1,5 +1,6 @@
import '../dtos/image_model_dto.dart'; import '../dtos/image_model_dto.dart';
/// Represents an Image, that would be displayed in the gallery.
class ImageModel { class ImageModel {
const ImageModel({ const ImageModel({
required this.uri, required this.uri,

View File

@ -1,12 +1,14 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 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( ImageCacheManagerService(
{required AppLifecycleService appLifecycleService, {required AppLifecycleService appLifecycleService,
required LocalStorageService localStorageService}) required LocalStorageService localStorageService})
@ -17,6 +19,7 @@ class ImageCacheManagerService with LoggingService {
final AppLifecycleService _appLifecycleService; final AppLifecycleService _appLifecycleService;
final LocalStorageService _localStorageService; final LocalStorageService _localStorageService;
final LoggingService _loggingService = LoggingService.locate;
final _cacheManager = DefaultCacheManager(); final _cacheManager = DefaultCacheManager();
Future<void> emptyCache() async => await _cacheManager.emptyCache(); Future<void> emptyCache() async => await _cacheManager.emptyCache();
@ -31,7 +34,7 @@ class ImageCacheManagerService with LoggingService {
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
case AppLifecycleState.paused: case AppLifecycleState.paused:
case AppLifecycleState.detached: case AppLifecycleState.detached:
info('Discarding cached images'); _loggingService.info('Discarding cached images');
await _cacheManager.emptyCache(); await _cacheManager.emptyCache();
_localStorageService.resetFavourites(); _localStorageService.resetFavourites();
} }

View File

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:collection/collection.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/constants/const_sorters.dart';
import '/features/core/data/extensions/iterable_extensions.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/local_storage_service.dart';
import '/features/core/services/logging_service.dart'; import '/features/core/services/logging_service.dart';
import '/features/core/utils/mutex.dart'; import '/features/core/utils/mutex.dart';
import '/features/home/data/dtos/image_model_dto.dart';
import '/locator.dart'; import '/locator.dart';
import '../abstracts/images_api.dart'; import '../abstracts/images_api.dart';
import '../data/enums/search_option.dart'; import '../data/enums/search_option.dart';
@ -49,7 +49,7 @@ class ImagesService {
Future<void> _init() async { Future<void> _init() async {
_loggingService.info('Fetching and creating image models...'); _loggingService.info('Fetching and creating image models...');
final fetchedImageModelDtos = await _imagesApi.fetchImageUri(token: ''); final fetchedImageModelDtos = await _imagesApi.fetchImageUri();
final favouritesStatuses = _localStorageService.storedFavouritesStates; final favouritesStatuses = _localStorageService.storedFavouritesStates;
// Prefill from stored values // Prefill from stored values
@ -128,7 +128,6 @@ class ImagesService {
case SearchOption.web: case SearchOption.web:
return (await _imagesApi.searchImages( return (await _imagesApi.searchImages(
searchStr: imageNamePart, searchStr: imageNamePart,
token: '',
)) ))
.map( .map(
(final imageModelDto) => ImageModel.fromDto( (final imageModelDto) => ImageModel.fromDto(

View File

@ -18,12 +18,7 @@ class _DownloadedGalleryView extends StatelessWidget {
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
valueListenable: galleryViewModel.isViewingFavouriteListenable, valueListenable: galleryViewModel.isViewingFavouriteListenable,
builder: (context, final isViewingFavourites, _) => !isViewingFavourites builder: (context, final isViewingFavourites, _) => !isViewingFavourites
? Wrap( ? CustomWrap(
runSpacing: 24,
spacing: 8,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final imageModel in galleryViewModel.imageModels) for (final imageModel in galleryViewModel.imageModels)
_StarrableImage( _StarrableImage(
@ -33,12 +28,7 @@ class _DownloadedGalleryView extends StatelessWidget {
), ),
], ],
) )
: Wrap( : CustomWrap(
runSpacing: 24,
spacing: 8,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final favouriteImageModel in galleryViewModel.favouriteImageModels) for (final favouriteImageModel in galleryViewModel.favouriteImageModels)
_StarrableImage( _StarrableImage(
@ -88,11 +78,14 @@ class _StarrableImageState extends State<_StarrableImage> {
context, context,
imageModel: widget.imageModel, imageModel: widget.imageModel,
), ),
child: CachedNetworkImage( child: Hero(
imageUrl: widget.imageModel.uri.toString(), tag: widget.imageModel.imageIndex,
cacheKey: widget.imageModel.imageIndex.toString(), child: CachedNetworkImage(
progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator( imageUrl: widget.imageModel.uri.toString(),
value: widget.galleryViewModel.downloadProgressValue(progress: progress), cacheKey: widget.imageModel.imageIndex.toString(),
progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator(
value: widget.galleryViewModel.downloadProgressValue(progress: progress),
),
), ),
), ),
), ),

View File

@ -1,13 +1,14 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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_colors.dart';
import '/features/core/data/constants/const_durations.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/gap.dart';
import '/features/core/widgets/mcg_scaffold.dart'; import '/features/core/widgets/mcg_scaffold.dart';
import '/features/core/widgets/state/multi_value_listenable_builder.dart'; import '/features/core/widgets/state/multi_value_listenable_builder.dart';
import '/features/core/widgets/state/view_model_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/enums/search_option.dart';
import '../../data/models/image_model.dart'; import '../../data/models/image_model.dart';
import 'gallery_view_model.dart'; import 'gallery_view_model.dart';
@ -81,21 +82,38 @@ class GalleryView extends StatelessWidget {
valueListenable: model.isSearchingListenable, valueListenable: model.isSearchingListenable,
builder: (context, final isSearching, _) => AnimatedSwitcher( builder: (context, final isSearching, _) => AnimatedSwitcher(
duration: ConstDurations.oneAndHalfDefaultAnimationDuration, duration: ConstDurations.oneAndHalfDefaultAnimationDuration,
child: Column( child: !isSearching
children: [ ? Column(
ValueListenableBuilder<bool>( mainAxisAlignment: MainAxisAlignment.spaceEvenly,
valueListenable: model.isViewingFavouriteListenable, children: [
builder: (context, final isViewingFavourites, child) => ValueListenableBuilder<bool>(
Switch( valueListenable: model.isViewingFavouriteListenable,
value: isViewingFavourites, builder:
onChanged: model.onFavouriteViewChange, (context, final isViewingFavourites, child) =>
), Row(
), mainAxisAlignment: MainAxisAlignment.center,
!isSearching children: [
? _DownloadedGalleryView(galleryViewModel: model) ConstMedia.buildIcon(
: _SearchGalleryView(galleryViewModel: model), 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),
), ),
); );
} }

View File

@ -3,10 +3,9 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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/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 '/features/core/services/navigation_service.dart';
import '/locator.dart'; import '/locator.dart';
import '../../data/enums/search_option.dart'; import '../../data/enums/search_option.dart';
@ -20,17 +19,14 @@ class GalleryViewModel extends BaseViewModel {
required ImagesService imagesService, required ImagesService imagesService,
required NavigationService navigationService, required NavigationService navigationService,
required ImageCacheManagerService imageCacheManagerService, required ImageCacheManagerService imageCacheManagerService,
required LoggingService loggingService,
}) : _imagesService = imagesService, }) : _imagesService = imagesService,
_navigationService = navigationService, _navigationService = navigationService,
_imageCacheManagerService = imageCacheManagerService, _imageCacheManagerService = imageCacheManagerService;
_loggingService = loggingService;
final ImagesService _imagesService; final ImagesService _imagesService;
final NavigationService _navigationService; final NavigationService _navigationService;
//todo(mehul): Use to implement pull-to-refresh or an extra widget //todo(mehul): Use to implement pull-to-refresh or an extra widget
final ImageCacheManagerService _imageCacheManagerService; final ImageCacheManagerService _imageCacheManagerService;
final LoggingService _loggingService;
final ValueNotifier<bool> _isDisplayingPressingPrompt = ValueNotifier(true); final ValueNotifier<bool> _isDisplayingPressingPrompt = ValueNotifier(true);
ValueListenable<bool> get isDisplayingPressingPrompt => _isDisplayingPressingPrompt; ValueListenable<bool> get isDisplayingPressingPrompt => _isDisplayingPressingPrompt;
@ -59,7 +55,7 @@ class GalleryViewModel extends BaseViewModel {
// If empty-string (from backspacing) -> reset state. // If empty-string (from backspacing) -> reset state.
if (searchTerm.isEmpty) { if (searchTerm.isEmpty) {
_imageSearchResultsNotifier.value = []; _imageSearchResultsNotifier.value = [];
_loggingService.info('Clearing results on search string removal'); log.info('Clearing results on search string removal');
return; return;
} }
@ -82,7 +78,7 @@ class GalleryViewModel extends BaseViewModel {
// If transitioning from 'Searching', clear previous results immediately // If transitioning from 'Searching', clear previous results immediately
if (_isSearchingNotifier.value) { if (_isSearchingNotifier.value) {
_imageSearchResultsNotifier.value = []; _imageSearchResultsNotifier.value = [];
_loggingService.info('Clearing of results on view mode change'); log.info('Clearing of results on view mode change');
} }
_isSearchingNotifier.flipValue(); _isSearchingNotifier.flipValue();
@ -92,10 +88,10 @@ class GalleryViewModel extends BaseViewModel {
void onSearchOptionChanged(SearchOption? option) { void onSearchOptionChanged(SearchOption? option) {
_searchOptionNotifier.value = option!; _searchOptionNotifier.value = option!;
_loggingService.info('Switched over to $option search'); log.info('Switched over to $option search');
_imageSearchResultsNotifier.value = []; _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 //todo(mehul): Either redo search or force user to type in new (trigger) by clearing field
} }

View File

@ -29,12 +29,7 @@ class _SearchGalleryView extends StatelessWidget {
builder: (context, final searchOption, child) { builder: (context, final searchOption, child) {
switch (searchOption) { switch (searchOption) {
case SearchOption.local: case SearchOption.local:
return Wrap( return CustomWrap(
runSpacing: 24,
spacing: 8,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final resultsImageModel in resultsImageModels) for (final resultsImageModel in resultsImageModels)
CachedNetworkImage( CachedNetworkImage(
@ -48,12 +43,7 @@ class _SearchGalleryView extends StatelessWidget {
], ],
); );
case SearchOption.web: case SearchOption.web:
return Wrap( return CustomWrap(
runSpacing: 24,
spacing: 8,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
for (final imageResult in resultsImageModels) for (final imageResult in resultsImageModels)
Image.network( Image.network(

View File

@ -62,13 +62,16 @@ class ImageCarouselView extends StatelessWidget {
children: [ children: [
ValueListenableBuilder<ImageModel>( ValueListenableBuilder<ImageModel>(
valueListenable: model.currentImageModelListenable, valueListenable: model.currentImageModelListenable,
builder: (context, _, __) => CachedNetworkImage( builder: (context, _, __) => Hero(
imageUrl: model.currentImageUrl, tag: model.currentImageIndex,
cacheKey: model.currentImageKey, child: CachedNetworkImage(
fit: BoxFit.contain, imageUrl: model.currentImageUrl,
progressIndicatorBuilder: (_, __, final progress) => cacheKey: model.currentImageKey,
CircularProgressIndicator( fit: BoxFit.contain,
value: model.downloadProgressValue(progress: progress), progressIndicatorBuilder: (_, __, final progress) =>
CircularProgressIndicator(
value: model.downloadProgressValue(progress: progress),
),
), ),
), ),
), ),

View File

@ -1,27 +1,19 @@
import 'package:carousel_slider/carousel_controller.dart'; import 'package:carousel_slider/carousel_controller.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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/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/services/images_service.dart';
import '/features/home/views/image_carousel/image_carousel_view.dart';
import '/locator.dart'; import '/locator.dart';
import '../../data/models/image_model.dart'; import '../../data/models/image_model.dart';
class ImageCarouselViewModel extends BaseViewModel { class ImageCarouselViewModel extends BaseViewModel {
ImageCarouselViewModel({ ImageCarouselViewModel({
required ImagesService imagesService, required ImagesService imagesService,
required NavigationService navigationService, }) : _imagesService = imagesService;
required LoggingService loggingService,
}) : _imagesService = imagesService,
_navigationService = navigationService,
_loggingService = loggingService;
final ImagesService _imagesService; final ImagesService _imagesService;
final NavigationService _navigationService;
final LoggingService _loggingService;
late final ValueNotifier<ImageModel> _currentImageModelNotifier; late final ValueNotifier<ImageModel> _currentImageModelNotifier;
ValueListenable<ImageModel> get currentImageModelListenable => _currentImageModelNotifier; ValueListenable<ImageModel> get currentImageModelListenable => _currentImageModelNotifier;
@ -31,8 +23,8 @@ class ImageCarouselViewModel extends BaseViewModel {
@override @override
Future<void> initialise(bool Function() mounted, [arguments]) async { Future<void> initialise(bool Function() mounted, [arguments]) async {
_currentImageModelNotifier = ValueNotifier(_imagesService.imageModels _currentImageModelNotifier = ValueNotifier(_imagesService.imageModels
.elementAt((arguments! as ImageCarouselViewArguments).imageIndexKey)); .elementAt((arguments as ImageCarouselViewArguments).imageIndexKey));
_loggingService.info('Initialized with image: ${_currentImageModelNotifier.value.imageIndex}'); log.info('Initialized with image: ${_currentImageModelNotifier.value.imageIndex}');
super.initialise(mounted, arguments); super.initialise(mounted, arguments);
} }
@ -44,7 +36,7 @@ class ImageCarouselViewModel extends BaseViewModel {
void swipedTo({required int newIndex}) { void swipedTo({required int newIndex}) {
_currentImageModelNotifier.value = _imagesService.imageModelAt(index: newIndex); _currentImageModelNotifier.value = _imagesService.imageModelAt(index: newIndex);
_loggingService.info('Swiped to image: ${_currentImageModelNotifier.value.imageIndex}'); log.info('Swiped to image: ${_currentImageModelNotifier.value.imageIndex}');
} }
void onPreviousPressed() => carouselController.previousPage(); void onPreviousPressed() => carouselController.previousPage();

View File

@ -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<Widget> 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,
);
}
}

View File

@ -28,6 +28,9 @@ class MessageLookup extends MessageLookupByLibrary {
final messages = _notInlinedMessages(_notInlinedMessages); final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{ static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"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"), "gallery": MessageLookupByLibrary.simpleMessage("Gallery"),
"imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"), "imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"),
"imageDetails": MessageLookupByLibrary.simpleMessage( "imageDetails": MessageLookupByLibrary.simpleMessage(

View File

@ -50,6 +50,16 @@ class Strings {
return Localizations.of<Strings>(context, Strings); return Localizations.of<Strings>(context, Strings);
} }
/// `MC Gallery App`
String get appTitle {
return Intl.message(
'MC Gallery App',
name: 'appTitle',
desc: '',
args: [],
);
}
/// `Something went wrong` /// `Something went wrong`
String get somethingWentWrong { String get somethingWentWrong {
return Intl.message( 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}` /// `Image {imageNumber}: size={imageSide}`
String imageNameFetch(Object imageNumber, Object imageSide) { String imageNameFetch(Object imageNumber, Object imageSide) {
return Intl.message( return Intl.message(

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "en", "@@locale": "en",
"appTitle": "MC Gallery App",
"somethingWentWrong": "Something went wrong", "somethingWentWrong": "Something went wrong",
"errorPageMessage": "Oopsie, there has been an error. Hang tight till we do something about it.",
"imageNameFetch": "Image {imageNumber}: size={imageSide}", "imageNameFetch": "Image {imageNumber}: size={imageSide}",
"imageNameSearch": "Search term '{searchStr}' result: Image {imageNumber}", "imageNameSearch": "Search term '{searchStr}' result: Image {imageNumber}",

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:internet_connection_checker/internet_connection_checker.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/abstracts/router/app_router.dart';
import 'features/core/services/app_lifecycle_service.dart'; import 'features/core/services/app_lifecycle_service.dart';
@ -37,7 +38,7 @@ class Locator {
static void _registerAPIs() { static void _registerAPIs() {
instance().registerFactory( instance().registerFactory(
() => UnsplashImagesApi(), () => UnsplashImagesApi(token: Env.unsplashApiKey),
); );
} }
@ -47,14 +48,11 @@ class Locator {
imagesService: ImagesService.locate, imagesService: ImagesService.locate,
navigationService: NavigationService.locate, navigationService: NavigationService.locate,
imageCacheManagerService: ImageCacheManagerService.locate, imageCacheManagerService: ImageCacheManagerService.locate,
loggingService: LoggingService.locate,
), ),
); );
instance().registerFactory( instance().registerFactory(
() => ImageCarouselViewModel( () => ImageCarouselViewModel(
imagesService: ImagesService.locate, imagesService: ImagesService.locate,
navigationService: NavigationService.locate,
loggingService: LoggingService.locate,
), ),
); );
} }

View File

@ -232,6 +232,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.6" 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: fake_async:
dependency: transitive dependency: transitive
description: description:

View File

@ -29,6 +29,9 @@ dependencies:
hive: ^2.2.3 hive: ^2.2.3
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
# Environment
envied: ^0.3.0
# Util backend # Util backend
intl_utils: ^2.8.1 intl_utils: ^2.8.1
connectivity_plus: ^3.0.2 connectivity_plus: ^3.0.2
@ -58,6 +61,7 @@ dev_dependencies:
build_runner: ^2.3.3 build_runner: ^2.3.3
json_serializable: ^6.5.4 json_serializable: ^6.5.4
hive_generator: ^2.0.0 hive_generator: ^2.0.0
envied_generator: ^0.3.0
# Annotations # Annotations
json_annotation: ^4.7.0 json_annotation: ^4.7.0