diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 88902bb..d384b97 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -27,6 +27,12 @@ "common_sign_in": "Sign In", "common_skip_for_now": "Skip for Now", + "@_TAB_TITLE": {}, + "home_tab_title": "Home", + "transfer_tab_title": "Transfer", + "album_tab_title": "Albums", + "account_tab_title": "Accounts", + "@_ERROR":{}, "no_internet_connection_error": "No internet connection! Please check your network and try again.", "something_went_wrong_error": "Oops, something went wrong. Please try again, we’re on it and will fix it soon!", @@ -157,5 +163,11 @@ "orientation_text": "Orientation", "path_text": "Path", "display_size_text": "Display Size", - "source_text": "Source" + "source_text": "Source", + + "@_CLEAN_UP_SCREEN":{}, + "clean_up_screen_title": "Clean Up", + "empty_clean_up_title": "All Clear and Clean!", + "empty_clean_up_message": "Looks like there's nothing to tidy up—you're already sparkling clean!", + "clean_up_title": "Clean Up" } \ No newline at end of file diff --git a/app/lib/ui/flow/accounts/components/settings_action_list.dart b/app/lib/ui/flow/accounts/components/settings_action_list.dart index 41265b3..532c995 100644 --- a/app/lib/ui/flow/accounts/components/settings_action_list.dart +++ b/app/lib/ui/flow/accounts/components/settings_action_list.dart @@ -12,6 +12,7 @@ import 'package:style/buttons/segmented_button.dart'; import 'package:style/buttons/switch.dart'; import 'package:style/extensions/context_extensions.dart'; import '../../../../gen/assets.gen.dart'; +import '../../../navigation/app_route.dart'; import '../accounts_screen_view_model.dart'; class SettingsActionList extends ConsumerWidget { @@ -24,6 +25,7 @@ class SettingsActionList extends ConsumerWidget { _notificationAction(context), _themeAction(context, ref), _rateUsAction(context, ref), + _cleanUpMedia(context), _clearCacheAction(context, ref), _termAndConditionAction(context, ref), _privacyPolicyAction(context, ref), @@ -31,6 +33,23 @@ class SettingsActionList extends ConsumerWidget { ); } + Widget _cleanUpMedia(BuildContext context) => ActionListItem( + leading: Icon( + Icons.cleaning_services, + color: context.colorScheme.textPrimary, + size: 22, + ), + onPressed: () { + CleanUpRoute().push(context); + }, + title: context.l10n.clean_up_screen_title, + trailing: Icon( + CupertinoIcons.right_chevron, + color: context.colorScheme.outline, + size: 22, + ), + ); + Widget _notificationAction(BuildContext context) => ActionListItem( leading: SvgPicture.asset( width: 22, diff --git a/app/lib/ui/flow/clean_up/clean_up_screen.dart b/app/lib/ui/flow/clean_up/clean_up_screen.dart new file mode 100644 index 0000000..e3c9b7e --- /dev/null +++ b/app/lib/ui/flow/clean_up/clean_up_screen.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:style/animations/fade_in_switcher.dart'; +import 'package:style/buttons/primary_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicators/circular_progress_indicator.dart'; +import '../../../components/app_page.dart'; +import '../../../components/error_screen.dart'; +import '../../../components/place_holder_screen.dart'; +import '../../../components/snack_bar.dart'; +import '../../../domain/extensions/context_extensions.dart'; +import '../home/components/app_media_item.dart'; +import 'clean_up_state_notifier.dart'; + +class CleanUpScreen extends ConsumerStatefulWidget { + const CleanUpScreen({super.key}); + + @override + ConsumerState createState() => _BinScreenState(); +} + +class _BinScreenState extends ConsumerState { + late CleanUpStateNotifier _notifier; + + @override + void initState() { + _notifier = ref.read(cleanUpStateNotifierProvider.notifier); + super.initState(); + } + + void _observeError() { + ref.listen( + cleanUpStateNotifierProvider.select((value) => value.actionError), + (previous, next) { + if (next != null) { + showErrorSnackBar(context: context, error: next); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + _observeError(); + return AppPage( + title: context.l10n.clean_up_screen_title, + body: FadeInSwitcher(child: _body(context)), + ); + } + + Widget _body(BuildContext context) { + final state = ref.watch(cleanUpStateNotifierProvider); + + if (state.loading) { + return const Center(child: AppCircularProgressIndicator()); + } else if (state.error != null) { + return ErrorScreen( + error: state.error!, + onRetryTap: _notifier.loadCleanUpMedias, + ); + } else if (state.medias.isEmpty) { + return PlaceHolderScreen( + icon: Icon( + Icons.cleaning_services, + size: 100, + color: context.colorScheme.containerHighOnSurface, + ), + title: context.l10n.empty_clean_up_title, + message: context.l10n.empty_clean_up_message, + ); + } + + return Column( + children: [ + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(4), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: (context.mediaQuerySize.width > 600 + ? context.mediaQuerySize.width ~/ 180 + : context.mediaQuerySize.width ~/ 100) + .clamp(1, 6), + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: state.medias.length, + itemBuilder: (context, index) { + return AppMediaItem( + media: state.medias[index], + heroTag: "clean_up${state.medias[index].toString()}", + onTap: () async { + _notifier.toggleSelection(state.medias[index].id); + HapticFeedback.lightImpact(); + }, + isSelected: state.selected.contains(state.medias[index].id), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(16) + .copyWith(bottom: 16 + context.systemPadding.bottom), + child: SizedBox( + width: double.infinity, + child: PrimaryButton( + onPressed: _notifier.deleteAll, + text: context.l10n.clean_up_title, + child: state.deleteAllLoading + ? AppCircularProgressIndicator( + color: context.colorScheme.onPrimary, + ) + : Text(context.l10n.clean_up_title), + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/ui/flow/clean_up/clean_up_state_notifier.dart b/app/lib/ui/flow/clean_up/clean_up_state_notifier.dart new file mode 100644 index 0000000..c9693de --- /dev/null +++ b/app/lib/ui/flow/clean_up/clean_up_state_notifier.dart @@ -0,0 +1,126 @@ +import 'package:data/log/logger.dart'; +import 'package:data/models/media/media.dart'; +import 'package:data/services/local_media_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:logger/logger.dart'; + +part 'clean_up_state_notifier.freezed.dart'; + +final cleanUpStateNotifierProvider = + StateNotifierProvider.autoDispose( + (ref) { + return CleanUpStateNotifier( + ref.read(localMediaServiceProvider), + ref.read(loggerProvider), + ); +}); + +class CleanUpStateNotifier extends StateNotifier { + final LocalMediaService _localMediaService; + final Logger _logger; + + CleanUpStateNotifier( + this._localMediaService, + this._logger, + ) : super(const CleanUpState()) { + loadCleanUpMedias(); + } + + Future loadCleanUpMedias() async { + try { + state = state.copyWith(loading: true, error: null); + final cleanUpMedias = await _localMediaService.getCleanUpMedias(); + + final medias = await Future.wait( + cleanUpMedias.map( + (e) => _localMediaService.getMedia(id: e.id), + ), + ).then( + (value) => value.nonNulls.toList(), + ); + + state = state.copyWith(loading: false, medias: medias); + } catch (e, s) { + state = state.copyWith(loading: false, error: e); + _logger.e( + "BinStateNotifier: Error occur while loading bin items", + error: e, + stackTrace: s, + ); + } + } + + void toggleSelection(String id) { + final selected = state.selected.toList(); + if (selected.contains(id)) { + state = state.copyWith(selected: selected..remove(id)); + } else { + state = state.copyWith(selected: [...selected, id]); + } + } + + Future deleteSelected() async { + try { + final deleteMedias = state.selected; + state = state.copyWith( + deleteSelectedLoading: deleteMedias, + selected: [], + actionError: null, + ); + final res = await _localMediaService.deleteMedias(deleteMedias); + if (res.isNotEmpty) { + await _localMediaService.removeFromCleanUpMediaDatabase(res); + } + state = state.copyWith( + deleteSelectedLoading: [], + medias: + state.medias.where((e) => !deleteMedias.contains(e.id)).toList(), + ); + } catch (e, s) { + state = state.copyWith(deleteSelectedLoading: [], actionError: e); + _logger.e( + "BinStateNotifier: Error occur while deleting selected bin items", + error: e, + stackTrace: s, + ); + } + } + + Future deleteAll() async { + try { + state = state.copyWith(deleteAllLoading: true, actionError: null); + final res = await _localMediaService + .deleteMedias(state.medias.map((e) => e.id).toList()); + + if (res.isNotEmpty) { + await _localMediaService.clearCleanUpMediaDatabase(); + } + state = state.copyWith( + deleteAllLoading: false, + selected: [], + medias: [], + ); + } catch (e, s) { + state = state.copyWith(deleteAllLoading: false, actionError: e); + _logger.e( + "BinStateNotifier: Error occur while deleting all bin items", + error: e, + stackTrace: s, + ); + } + } +} + +@freezed +class CleanUpState with _$CleanUpState { + const factory CleanUpState({ + @Default(false) bool deleteAllLoading, + @Default([]) List deleteSelectedLoading, + @Default([]) List medias, + @Default([]) List selected, + @Default(false) bool loading, + Object? error, + Object? actionError, + }) = _CleanUpState; +} diff --git a/app/lib/ui/flow/clean_up/clean_up_state_notifier.freezed.dart b/app/lib/ui/flow/clean_up/clean_up_state_notifier.freezed.dart new file mode 100644 index 0000000..5367751 --- /dev/null +++ b/app/lib/ui/flow/clean_up/clean_up_state_notifier.freezed.dart @@ -0,0 +1,293 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'clean_up_state_notifier.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$CleanUpState { + bool get deleteAllLoading => throw _privateConstructorUsedError; + List get deleteSelectedLoading => throw _privateConstructorUsedError; + List get medias => throw _privateConstructorUsedError; + List get selected => throw _privateConstructorUsedError; + bool get loading => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + Object? get actionError => throw _privateConstructorUsedError; + + /// Create a copy of CleanUpState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $CleanUpStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CleanUpStateCopyWith<$Res> { + factory $CleanUpStateCopyWith( + CleanUpState value, $Res Function(CleanUpState) then) = + _$CleanUpStateCopyWithImpl<$Res, CleanUpState>; + @useResult + $Res call( + {bool deleteAllLoading, + List deleteSelectedLoading, + List medias, + List selected, + bool loading, + Object? error, + Object? actionError}); +} + +/// @nodoc +class _$CleanUpStateCopyWithImpl<$Res, $Val extends CleanUpState> + implements $CleanUpStateCopyWith<$Res> { + _$CleanUpStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CleanUpState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? deleteAllLoading = null, + Object? deleteSelectedLoading = null, + Object? medias = null, + Object? selected = null, + Object? loading = null, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_value.copyWith( + deleteAllLoading: null == deleteAllLoading + ? _value.deleteAllLoading + : deleteAllLoading // ignore: cast_nullable_to_non_nullable + as bool, + deleteSelectedLoading: null == deleteSelectedLoading + ? _value.deleteSelectedLoading + : deleteSelectedLoading // ignore: cast_nullable_to_non_nullable + as List, + medias: null == medias + ? _value.medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + selected: null == selected + ? _value.selected + : selected // ignore: cast_nullable_to_non_nullable + as List, + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$CleanUpStateImplCopyWith<$Res> + implements $CleanUpStateCopyWith<$Res> { + factory _$$CleanUpStateImplCopyWith( + _$CleanUpStateImpl value, $Res Function(_$CleanUpStateImpl) then) = + __$$CleanUpStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool deleteAllLoading, + List deleteSelectedLoading, + List medias, + List selected, + bool loading, + Object? error, + Object? actionError}); +} + +/// @nodoc +class __$$CleanUpStateImplCopyWithImpl<$Res> + extends _$CleanUpStateCopyWithImpl<$Res, _$CleanUpStateImpl> + implements _$$CleanUpStateImplCopyWith<$Res> { + __$$CleanUpStateImplCopyWithImpl( + _$CleanUpStateImpl _value, $Res Function(_$CleanUpStateImpl) _then) + : super(_value, _then); + + /// Create a copy of CleanUpState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? deleteAllLoading = null, + Object? deleteSelectedLoading = null, + Object? medias = null, + Object? selected = null, + Object? loading = null, + Object? error = freezed, + Object? actionError = freezed, + }) { + return _then(_$CleanUpStateImpl( + deleteAllLoading: null == deleteAllLoading + ? _value.deleteAllLoading + : deleteAllLoading // ignore: cast_nullable_to_non_nullable + as bool, + deleteSelectedLoading: null == deleteSelectedLoading + ? _value._deleteSelectedLoading + : deleteSelectedLoading // ignore: cast_nullable_to_non_nullable + as List, + medias: null == medias + ? _value._medias + : medias // ignore: cast_nullable_to_non_nullable + as List, + selected: null == selected + ? _value._selected + : selected // ignore: cast_nullable_to_non_nullable + as List, + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + actionError: freezed == actionError ? _value.actionError : actionError, + )); + } +} + +/// @nodoc + +class _$CleanUpStateImpl implements _CleanUpState { + const _$CleanUpStateImpl( + {this.deleteAllLoading = false, + final List deleteSelectedLoading = const [], + final List medias = const [], + final List selected = const [], + this.loading = false, + this.error, + this.actionError}) + : _deleteSelectedLoading = deleteSelectedLoading, + _medias = medias, + _selected = selected; + + @override + @JsonKey() + final bool deleteAllLoading; + final List _deleteSelectedLoading; + @override + @JsonKey() + List get deleteSelectedLoading { + if (_deleteSelectedLoading is EqualUnmodifiableListView) + return _deleteSelectedLoading; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_deleteSelectedLoading); + } + + final List _medias; + @override + @JsonKey() + List get medias { + if (_medias is EqualUnmodifiableListView) return _medias; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_medias); + } + + final List _selected; + @override + @JsonKey() + List get selected { + if (_selected is EqualUnmodifiableListView) return _selected; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_selected); + } + + @override + @JsonKey() + final bool loading; + @override + final Object? error; + @override + final Object? actionError; + + @override + String toString() { + return 'CleanUpState(deleteAllLoading: $deleteAllLoading, deleteSelectedLoading: $deleteSelectedLoading, medias: $medias, selected: $selected, loading: $loading, error: $error, actionError: $actionError)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CleanUpStateImpl && + (identical(other.deleteAllLoading, deleteAllLoading) || + other.deleteAllLoading == deleteAllLoading) && + const DeepCollectionEquality() + .equals(other._deleteSelectedLoading, _deleteSelectedLoading) && + const DeepCollectionEquality().equals(other._medias, _medias) && + const DeepCollectionEquality().equals(other._selected, _selected) && + (identical(other.loading, loading) || other.loading == loading) && + const DeepCollectionEquality().equals(other.error, error) && + const DeepCollectionEquality() + .equals(other.actionError, actionError)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + deleteAllLoading, + const DeepCollectionEquality().hash(_deleteSelectedLoading), + const DeepCollectionEquality().hash(_medias), + const DeepCollectionEquality().hash(_selected), + loading, + const DeepCollectionEquality().hash(error), + const DeepCollectionEquality().hash(actionError)); + + /// Create a copy of CleanUpState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CleanUpStateImplCopyWith<_$CleanUpStateImpl> get copyWith => + __$$CleanUpStateImplCopyWithImpl<_$CleanUpStateImpl>(this, _$identity); +} + +abstract class _CleanUpState implements CleanUpState { + const factory _CleanUpState( + {final bool deleteAllLoading, + final List deleteSelectedLoading, + final List medias, + final List selected, + final bool loading, + final Object? error, + final Object? actionError}) = _$CleanUpStateImpl; + + @override + bool get deleteAllLoading; + @override + List get deleteSelectedLoading; + @override + List get medias; + @override + List get selected; + @override + bool get loading; + @override + Object? get error; + @override + Object? get actionError; + + /// Create a copy of CleanUpState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CleanUpStateImplCopyWith<_$CleanUpStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/app/lib/ui/flow/home/home_screen_view_model.dart b/app/lib/ui/flow/home/home_screen_view_model.dart index 2635de5..681cdb8 100644 --- a/app/lib/ui/flow/home/home_screen_view_model.dart +++ b/app/lib/ui/flow/home/home_screen_view_model.dart @@ -628,10 +628,12 @@ class HomeViewStateNotifier extends StateNotifier state = state.copyWith(selectedMedias: {}, actionError: null); - await _localMediaService.deleteMedias(ids); + final res = await _localMediaService.deleteMedias(ids); + + if (res.isEmpty) return; _mediaProcessRepo.notifyDeleteMedia( - ids + res .map((e) => DeleteMediaEvent(id: e, source: AppMediaSource.local)) .toList(), ); diff --git a/app/lib/ui/flow/main/main_screen.dart b/app/lib/ui/flow/main/main_screen.dart index a1375dc..19e3af2 100644 --- a/app/lib/ui/flow/main/main_screen.dart +++ b/app/lib/ui/flow/main/main_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:style/extensions/context_extensions.dart'; +import '../../../domain/extensions/context_extensions.dart'; import '../../navigation/app_route.dart'; class MainScreen extends StatefulWidget { @@ -24,22 +25,22 @@ class _MainScreenState extends State { final tabs = [ ( icon: CupertinoIcons.house_fill, - label: "Home", + label: context.l10n.home_tab_title, activeIcon: CupertinoIcons.house_fill, ), ( icon: CupertinoIcons.folder, - label: "Albums", + label: context.l10n.album_tab_title, activeIcon: CupertinoIcons.folder_fill ), ( icon: CupertinoIcons.arrow_up_arrow_down, - label: "Transfer", + label: context.l10n.transfer_tab_title, activeIcon: CupertinoIcons.arrow_up_arrow_down ), ( icon: CupertinoIcons.person, - label: "Account", + label: context.l10n.account_tab_title, activeIcon: CupertinoIcons.person_fill ), ]; diff --git a/app/lib/ui/flow/media_preview/media_preview_view_model.dart b/app/lib/ui/flow/media_preview/media_preview_view_model.dart index 02c5a97..14a2364 100644 --- a/app/lib/ui/flow/media_preview/media_preview_view_model.dart +++ b/app/lib/ui/flow/media_preview/media_preview_view_model.dart @@ -231,7 +231,8 @@ class MediaPreviewStateNotifier extends StateNotifier { Future deleteMediaFromLocal(String id) async { try { state = state.copyWith(actionError: null); - await _localMediaService.deleteMedias([id]); + final res = await _localMediaService.deleteMedias([id]); + if(res.isEmpty) return; _mediaProcessRepo.notifyDeleteMedia([ DeleteMediaEvent(id: id, source: AppMediaSource.local), ]); diff --git a/app/lib/ui/navigation/app_route.dart b/app/lib/ui/navigation/app_route.dart index 3ea1bf7..b941433 100644 --- a/app/lib/ui/navigation/app_route.dart +++ b/app/lib/ui/navigation/app_route.dart @@ -3,6 +3,7 @@ import '../flow/accounts/accounts_screen.dart'; import '../flow/albums/add/add_album_screen.dart'; import '../flow/albums/albums_screen.dart'; import '../flow/albums/media_list/album_media_list_screen.dart'; +import '../flow/clean_up/clean_up_screen.dart'; import '../flow/main/main_screen.dart'; import '../flow/media_selection/media_selection_screen.dart'; import '../flow/media_transfer/media_transfer_screen.dart'; @@ -19,6 +20,7 @@ part 'app_route.g.dart'; class AppRoutePath { static const onBoard = '/on-board'; static const home = '/'; + static const cleanUp = '/clean-up'; static const albums = '/albums'; static const addAlbum = '/add-album'; static const albumMediaList = '/albums/:albumId'; @@ -76,6 +78,15 @@ class MainShellRoute extends StatefulShellRouteData { MainScreen(navigationShell: navigationShell); } +@TypedGoRoute(path: AppRoutePath.cleanUp) +class CleanUpRoute extends GoRouteData { + const CleanUpRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const CleanUpScreen(); +} + class HomeShellBranch extends StatefulShellBranchData {} class AlbumsShellBranch extends StatefulShellBranchData {} diff --git a/app/lib/ui/navigation/app_route.g.dart b/app/lib/ui/navigation/app_route.g.dart index 6c8ff6a..c36f6f2 100644 --- a/app/lib/ui/navigation/app_route.g.dart +++ b/app/lib/ui/navigation/app_route.g.dart @@ -9,6 +9,7 @@ part of 'app_route.dart'; List get $appRoutes => [ $onBoardRoute, $mainShellRoute, + $cleanUpRoute, $addAlbumRoute, $albumMediaListRoute, $mediaPreviewRoute, @@ -149,6 +150,28 @@ extension $AccountRouteExtension on AccountRoute { void replace(BuildContext context) => context.replace(location); } +RouteBase get $cleanUpRoute => GoRouteData.$route( + path: '/clean-up', + factory: $CleanUpRouteExtension._fromState, + ); + +extension $CleanUpRouteExtension on CleanUpRoute { + static CleanUpRoute _fromState(GoRouterState state) => const CleanUpRoute(); + + String get location => GoRouteData.$location( + '/clean-up', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $addAlbumRoute => GoRouteData.$route( path: '/add-album', factory: $AddAlbumRouteExtension._fromState, diff --git a/data/lib/domain/config.dart b/data/lib/domain/config.dart index 4a454f6..f954e06 100644 --- a/data/lib/domain/config.dart +++ b/data/lib/domain/config.dart @@ -13,6 +13,7 @@ class LocalDatabaseConstants { static const String uploadQueueTable = 'UploadQueue'; static const String downloadQueueTable = 'DownloadQueue'; static const String albumsTable = 'Albums'; + static const String cleanUpTable = 'CleanUp'; } class FeatureFlag { diff --git a/data/lib/models/clean_up/clean_up.dart b/data/lib/models/clean_up/clean_up.dart new file mode 100644 index 0000000..ed47c6c --- /dev/null +++ b/data/lib/models/clean_up/clean_up.dart @@ -0,0 +1,21 @@ +// ignore_for_file: non_constant_identifier_names +import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../domain/json_converters/date_time_json_converter.dart'; +import '../media/media.dart'; + +part 'clean_up.freezed.dart'; + +part 'clean_up.g.dart'; + +@freezed +class CleanUpMedia with _$CleanUpMedia { + const factory CleanUpMedia({ + required String id, + String? provider_ref_id, + required AppMediaSource provider, + @DateTimeJsonConverter() required DateTime created_at, + }) = _CleanUpMedia; + + factory CleanUpMedia.fromJson(Map json) => + _$CleanUpMediaFromJson(json); +} diff --git a/data/lib/models/clean_up/clean_up.freezed.dart b/data/lib/models/clean_up/clean_up.freezed.dart new file mode 100644 index 0000000..25e158a --- /dev/null +++ b/data/lib/models/clean_up/clean_up.freezed.dart @@ -0,0 +1,236 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'clean_up.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +CleanUpMedia _$CleanUpMediaFromJson(Map json) { + return _CleanUpMedia.fromJson(json); +} + +/// @nodoc +mixin _$CleanUpMedia { + String get id => throw _privateConstructorUsedError; + String? get provider_ref_id => throw _privateConstructorUsedError; + AppMediaSource get provider => throw _privateConstructorUsedError; + @DateTimeJsonConverter() + DateTime get created_at => throw _privateConstructorUsedError; + + /// Serializes this CleanUpMedia to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of CleanUpMedia + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $CleanUpMediaCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CleanUpMediaCopyWith<$Res> { + factory $CleanUpMediaCopyWith( + CleanUpMedia value, $Res Function(CleanUpMedia) then) = + _$CleanUpMediaCopyWithImpl<$Res, CleanUpMedia>; + @useResult + $Res call( + {String id, + String? provider_ref_id, + AppMediaSource provider, + @DateTimeJsonConverter() DateTime created_at}); +} + +/// @nodoc +class _$CleanUpMediaCopyWithImpl<$Res, $Val extends CleanUpMedia> + implements $CleanUpMediaCopyWith<$Res> { + _$CleanUpMediaCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CleanUpMedia + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? provider_ref_id = freezed, + Object? provider = null, + Object? created_at = null, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + provider_ref_id: freezed == provider_ref_id + ? _value.provider_ref_id + : provider_ref_id // ignore: cast_nullable_to_non_nullable + as String?, + provider: null == provider + ? _value.provider + : provider // ignore: cast_nullable_to_non_nullable + as AppMediaSource, + created_at: null == created_at + ? _value.created_at + : created_at // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$CleanUpMediaImplCopyWith<$Res> + implements $CleanUpMediaCopyWith<$Res> { + factory _$$CleanUpMediaImplCopyWith( + _$CleanUpMediaImpl value, $Res Function(_$CleanUpMediaImpl) then) = + __$$CleanUpMediaImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String? provider_ref_id, + AppMediaSource provider, + @DateTimeJsonConverter() DateTime created_at}); +} + +/// @nodoc +class __$$CleanUpMediaImplCopyWithImpl<$Res> + extends _$CleanUpMediaCopyWithImpl<$Res, _$CleanUpMediaImpl> + implements _$$CleanUpMediaImplCopyWith<$Res> { + __$$CleanUpMediaImplCopyWithImpl( + _$CleanUpMediaImpl _value, $Res Function(_$CleanUpMediaImpl) _then) + : super(_value, _then); + + /// Create a copy of CleanUpMedia + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? provider_ref_id = freezed, + Object? provider = null, + Object? created_at = null, + }) { + return _then(_$CleanUpMediaImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + provider_ref_id: freezed == provider_ref_id + ? _value.provider_ref_id + : provider_ref_id // ignore: cast_nullable_to_non_nullable + as String?, + provider: null == provider + ? _value.provider + : provider // ignore: cast_nullable_to_non_nullable + as AppMediaSource, + created_at: null == created_at + ? _value.created_at + : created_at // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$CleanUpMediaImpl implements _CleanUpMedia { + const _$CleanUpMediaImpl( + {required this.id, + this.provider_ref_id, + required this.provider, + @DateTimeJsonConverter() required this.created_at}); + + factory _$CleanUpMediaImpl.fromJson(Map json) => + _$$CleanUpMediaImplFromJson(json); + + @override + final String id; + @override + final String? provider_ref_id; + @override + final AppMediaSource provider; + @override + @DateTimeJsonConverter() + final DateTime created_at; + + @override + String toString() { + return 'CleanUpMedia(id: $id, provider_ref_id: $provider_ref_id, provider: $provider, created_at: $created_at)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CleanUpMediaImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.provider_ref_id, provider_ref_id) || + other.provider_ref_id == provider_ref_id) && + (identical(other.provider, provider) || + other.provider == provider) && + (identical(other.created_at, created_at) || + other.created_at == created_at)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, provider_ref_id, provider, created_at); + + /// Create a copy of CleanUpMedia + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CleanUpMediaImplCopyWith<_$CleanUpMediaImpl> get copyWith => + __$$CleanUpMediaImplCopyWithImpl<_$CleanUpMediaImpl>(this, _$identity); + + @override + Map toJson() { + return _$$CleanUpMediaImplToJson( + this, + ); + } +} + +abstract class _CleanUpMedia implements CleanUpMedia { + const factory _CleanUpMedia( + {required final String id, + final String? provider_ref_id, + required final AppMediaSource provider, + @DateTimeJsonConverter() required final DateTime created_at}) = + _$CleanUpMediaImpl; + + factory _CleanUpMedia.fromJson(Map json) = + _$CleanUpMediaImpl.fromJson; + + @override + String get id; + @override + String? get provider_ref_id; + @override + AppMediaSource get provider; + @override + @DateTimeJsonConverter() + DateTime get created_at; + + /// Create a copy of CleanUpMedia + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CleanUpMediaImplCopyWith<_$CleanUpMediaImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/lib/models/clean_up/clean_up.g.dart b/data/lib/models/clean_up/clean_up.g.dart new file mode 100644 index 0000000..93be8dc --- /dev/null +++ b/data/lib/models/clean_up/clean_up.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'clean_up.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$CleanUpMediaImpl _$$CleanUpMediaImplFromJson(Map json) => + _$CleanUpMediaImpl( + id: json['id'] as String, + provider_ref_id: json['provider_ref_id'] as String?, + provider: $enumDecode(_$AppMediaSourceEnumMap, json['provider']), + created_at: + const DateTimeJsonConverter().fromJson(json['created_at'] as String), + ); + +Map _$$CleanUpMediaImplToJson(_$CleanUpMediaImpl instance) => + { + 'id': instance.id, + 'provider_ref_id': instance.provider_ref_id, + 'provider': _$AppMediaSourceEnumMap[instance.provider]!, + 'created_at': const DateTimeJsonConverter().toJson(instance.created_at), + }; + +const _$AppMediaSourceEnumMap = { + AppMediaSource.local: 'local', + AppMediaSource.googleDrive: 'google_drive', + AppMediaSource.dropbox: 'dropbox', +}; diff --git a/data/lib/repositories/media_process_repository.dart b/data/lib/repositories/media_process_repository.dart index cb63163..212be1f 100644 --- a/data/lib/repositories/media_process_repository.dart +++ b/data/lib/repositories/media_process_repository.dart @@ -12,6 +12,7 @@ import '../domain/config.dart'; import '../domain/formatters/byte_formatter.dart'; import '../errors/app_error.dart'; import '../handlers/notification_handler.dart'; +import '../models/clean_up/clean_up.dart'; import '../models/media/media.dart'; import '../models/media/media_extension.dart'; import '../models/media_process/media_process.dart'; @@ -547,6 +548,17 @@ class MediaProcessRepo extends ChangeNotifier { ); await clearUploadProcessResponse(id: process.id); + + await _localMediaService.addToCleanUpMediaDatabase( + medias: [ + CleanUpMedia( + id: process.media_id, + provider_ref_id: res.id, + provider: AppMediaSource.googleDrive, + created_at: DateTime.now(), + ), + ], + ); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) { showNotification('Upload to Google Drive cancelled'); @@ -636,6 +648,17 @@ class MediaProcessRepo extends ChangeNotifier { ); await clearUploadProcessResponse(id: process.id); + + await _localMediaService.addToCleanUpMediaDatabase( + medias: [ + CleanUpMedia( + id: process.media_id, + provider_ref_id: res.id, + provider: AppMediaSource.dropbox, + created_at: DateTime.now(), + ), + ], + ); } catch (e) { if (e is DioException && e.type == DioExceptionType.cancel) { showNotification('Upload to Dropbox cancelled'); diff --git a/data/lib/services/google_drive_service.dart b/data/lib/services/google_drive_service.dart index c4ba390..eda0751 100644 --- a/data/lib/services/google_drive_service.dart +++ b/data/lib/services/google_drive_service.dart @@ -92,7 +92,7 @@ class GoogleDriveService extends CloudProviderService { while (hasMore) { final res = await _client.req( GoogleDriveListEndpoint( - q: "'$folder' in parents and trashed=false and name!='${ProviderConstants.albumFileName}", + q: "'$folder' in parents and trashed=false and name!='${ProviderConstants.albumFileName}'", pageSize: 1000, pageToken: pageToken, ), diff --git a/data/lib/services/local_media_service.dart b/data/lib/services/local_media_service.dart index 3cc87ed..0146fc1 100644 --- a/data/lib/services/local_media_service.dart +++ b/data/lib/services/local_media_service.dart @@ -4,6 +4,7 @@ import 'package:sqflite/sqflite.dart'; import '../domain/config.dart'; import '../domain/json_converters/date_time_json_converter.dart'; import '../models/album/album.dart'; +import '../models/clean_up/clean_up.dart'; import '../models/media/media.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -103,6 +104,59 @@ class LocalMediaService { return asset != null ? AppMedia.fromAssetEntity(asset) : null; } + // CLEAN UP ------------------------------------------------------------------ + + Future openCleanUpDatabase() async { + return await openDatabase( + LocalDatabaseConstants.databaseName, + version: 1, + onConfigure: (Database db) async { + await db.execute( + 'CREATE TABLE IF NOT EXISTS ${LocalDatabaseConstants.cleanUpTable} (' + 'id TEXT PRIMARY KEY, ' + 'provider TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, ' + 'provider_ref_id TEXT' + ')', + ); + }, + ); + } + + Future addToCleanUpMediaDatabase({ + required List medias, + }) async { + final database = await openCleanUpDatabase(); + final batch = database.batch(); + for (CleanUpMedia media in medias) { + batch.insert( + LocalDatabaseConstants.cleanUpTable, + media.toJson(), + ); + } + await batch.commit(); + } + + Future removeFromCleanUpMediaDatabase(List ids) async { + final database = await openCleanUpDatabase(); + await database.delete( + LocalDatabaseConstants.cleanUpTable, + where: 'id IN (${List.filled(ids.length, '?').join(',')})', + whereArgs: ids, + ); + } + + Future clearCleanUpMediaDatabase() async { + final database = await openCleanUpDatabase(); + await database.delete(LocalDatabaseConstants.cleanUpTable); + } + + Future> getCleanUpMedias() async { + final database = await openCleanUpDatabase(); + final res = await database.query(LocalDatabaseConstants.cleanUpTable); + return res.map((e) => CleanUpMedia.fromJson(e)).toList(); + } + // ALBUM --------------------------------------------------------------------- Future openAlbumDatabase() async {