From 915b0c25f4f70179b10c4d92abe6289b84e9030e Mon Sep 17 00:00:00 2001 From: poppingmoon <63451158+poppingmoon@users.noreply.github.com> Date: Sat, 28 Oct 2023 17:56:25 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E9=81=B8=E6=8A=9E=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/model/drive_page_state.dart | 1 + lib/model/drive_page_state.freezed.dart | 48 ++++++-- lib/router/app_router.gr.dart | 8 +- .../drive_page/drive_page_notifier.dart | 41 +++++++ lib/view/drive_page/drive_file_grid_item.dart | 6 + .../drive_page/drive_file_modal_sheet.dart | 1 + .../drive_page/drive_folder_modal_sheet.dart | 1 + lib/view/drive_page/drive_page.dart | 94 ++++++++++++-- .../drive_selected_files_modal_sheet.dart | 116 ++++++++++++++++++ 9 files changed, 293 insertions(+), 23 deletions(-) create mode 100644 lib/view/drive_page/drive_selected_files_modal_sheet.dart diff --git a/lib/model/drive_page_state.dart b/lib/model/drive_page_state.dart index a21c094f1..46a6a8730 100644 --- a/lib/model/drive_page_state.dart +++ b/lib/model/drive_page_state.dart @@ -7,5 +7,6 @@ part 'drive_page_state.freezed.dart'; class DrivePageState with _$DrivePageState { const factory DrivePageState({ @Default([]) List breadcrumbs, + @Default([]) List selectedFiles, }) = _DrivePageState; } diff --git a/lib/model/drive_page_state.freezed.dart b/lib/model/drive_page_state.freezed.dart index c4cd4736c..29382990e 100644 --- a/lib/model/drive_page_state.freezed.dart +++ b/lib/model/drive_page_state.freezed.dart @@ -17,6 +17,7 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$DrivePageState { List get breadcrumbs => throw _privateConstructorUsedError; + List get selectedFiles => throw _privateConstructorUsedError; @JsonKey(ignore: true) $DrivePageStateCopyWith get copyWith => @@ -29,7 +30,7 @@ abstract class $DrivePageStateCopyWith<$Res> { DrivePageState value, $Res Function(DrivePageState) then) = _$DrivePageStateCopyWithImpl<$Res, DrivePageState>; @useResult - $Res call({List breadcrumbs}); + $Res call({List breadcrumbs, List selectedFiles}); } /// @nodoc @@ -46,12 +47,17 @@ class _$DrivePageStateCopyWithImpl<$Res, $Val extends DrivePageState> @override $Res call({ Object? breadcrumbs = null, + Object? selectedFiles = null, }) { return _then(_value.copyWith( breadcrumbs: null == breadcrumbs ? _value.breadcrumbs : breadcrumbs // ignore: cast_nullable_to_non_nullable as List, + selectedFiles: null == selectedFiles + ? _value.selectedFiles + : selectedFiles // ignore: cast_nullable_to_non_nullable + as List, ) as $Val); } } @@ -64,7 +70,7 @@ abstract class _$$DrivePageStateImplCopyWith<$Res> __$$DrivePageStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({List breadcrumbs}); + $Res call({List breadcrumbs, List selectedFiles}); } /// @nodoc @@ -79,12 +85,17 @@ class __$$DrivePageStateImplCopyWithImpl<$Res> @override $Res call({ Object? breadcrumbs = null, + Object? selectedFiles = null, }) { return _then(_$DrivePageStateImpl( breadcrumbs: null == breadcrumbs ? _value._breadcrumbs : breadcrumbs // ignore: cast_nullable_to_non_nullable as List, + selectedFiles: null == selectedFiles + ? _value._selectedFiles + : selectedFiles // ignore: cast_nullable_to_non_nullable + as List, )); } } @@ -92,8 +103,11 @@ class __$$DrivePageStateImplCopyWithImpl<$Res> /// @nodoc class _$DrivePageStateImpl implements _DrivePageState { - const _$DrivePageStateImpl({final List breadcrumbs = const []}) - : _breadcrumbs = breadcrumbs; + const _$DrivePageStateImpl( + {final List breadcrumbs = const [], + final List selectedFiles = const []}) + : _breadcrumbs = breadcrumbs, + _selectedFiles = selectedFiles; final List _breadcrumbs; @override @@ -104,9 +118,18 @@ class _$DrivePageStateImpl implements _DrivePageState { return EqualUnmodifiableListView(_breadcrumbs); } + final List _selectedFiles; + @override + @JsonKey() + List get selectedFiles { + if (_selectedFiles is EqualUnmodifiableListView) return _selectedFiles; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_selectedFiles); + } + @override String toString() { - return 'DrivePageState(breadcrumbs: $breadcrumbs)'; + return 'DrivePageState(breadcrumbs: $breadcrumbs, selectedFiles: $selectedFiles)'; } @override @@ -115,12 +138,16 @@ class _$DrivePageStateImpl implements _DrivePageState { (other.runtimeType == runtimeType && other is _$DrivePageStateImpl && const DeepCollectionEquality() - .equals(other._breadcrumbs, _breadcrumbs)); + .equals(other._breadcrumbs, _breadcrumbs) && + const DeepCollectionEquality() + .equals(other._selectedFiles, _selectedFiles)); } @override int get hashCode => Object.hash( - runtimeType, const DeepCollectionEquality().hash(_breadcrumbs)); + runtimeType, + const DeepCollectionEquality().hash(_breadcrumbs), + const DeepCollectionEquality().hash(_selectedFiles)); @JsonKey(ignore: true) @override @@ -131,12 +158,15 @@ class _$DrivePageStateImpl implements _DrivePageState { } abstract class _DrivePageState implements DrivePageState { - const factory _DrivePageState({final List breadcrumbs}) = - _$DrivePageStateImpl; + const factory _DrivePageState( + {final List breadcrumbs, + final List selectedFiles}) = _$DrivePageStateImpl; @override List get breadcrumbs; @override + List get selectedFiles; + @override @JsonKey(ignore: true) _$$DrivePageStateImplCopyWith<_$DrivePageStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/router/app_router.gr.dart b/lib/router/app_router.gr.dart index 5852bbd7d..77c4f6258 100644 --- a/lib/router/app_router.gr.dart +++ b/lib/router/app_router.gr.dart @@ -108,6 +108,7 @@ abstract class _$AppRouter extends RootStackRouter { account: args.account, title: args.title, floatingActionButtonBuilder: args.floatingActionButtonBuilder, + tapToSelect: args.tapToSelect, ), ); }, @@ -779,6 +780,7 @@ class DriveRoute extends PageRouteInfo { required Account account, Widget? title, Widget Function(BuildContext)? floatingActionButtonBuilder, + bool tapToSelect = false, List? children, }) : super( DriveRoute.name, @@ -787,6 +789,7 @@ class DriveRoute extends PageRouteInfo { account: account, title: title, floatingActionButtonBuilder: floatingActionButtonBuilder, + tapToSelect: tapToSelect, ), initialChildren: children, ); @@ -802,6 +805,7 @@ class DriveRouteArgs { required this.account, this.title, this.floatingActionButtonBuilder, + this.tapToSelect = false, }); final Key? key; @@ -812,9 +816,11 @@ class DriveRouteArgs { final Widget Function(BuildContext)? floatingActionButtonBuilder; + final bool tapToSelect; + @override String toString() { - return 'DriveRouteArgs{key: $key, account: $account, title: $title, floatingActionButtonBuilder: $floatingActionButtonBuilder}'; + return 'DriveRouteArgs{key: $key, account: $account, title: $title, floatingActionButtonBuilder: $floatingActionButtonBuilder, tapToSelect: $tapToSelect}'; } } diff --git a/lib/state_notifier/drive_page/drive_page_notifier.dart b/lib/state_notifier/drive_page/drive_page_notifier.dart index 4091dd869..d17f0c7e7 100644 --- a/lib/state_notifier/drive_page/drive_page_notifier.dart +++ b/lib/state_notifier/drive_page/drive_page_notifier.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:miria/model/drive_page_state.dart'; import 'package:misskey_dart/misskey_dart.dart'; @@ -35,4 +37,43 @@ class DrivePageNotifier extends AutoDisposeNotifier { ], ); } + + LinkedHashSet get _selectedFiles => LinkedHashSet( + equals: (p0, p1) => p0.id == p1.id, + hashCode: (p0) => p0.id.hashCode, + )..addAll(state.selectedFiles); + + void selectFile(DriveFile file) { + final selectedFiles = _selectedFiles..add(file); + state = state.copyWith( + selectedFiles: selectedFiles.toList(), + ); + } + + void selectFiles(Iterable files) { + final selectedFiles = _selectedFiles..addAll(files); + state = state.copyWith( + selectedFiles: selectedFiles.toList(), + ); + } + + void deselectFile(DriveFile file) { + final selectedFiles = _selectedFiles..remove(file); + state = state.copyWith( + selectedFiles: selectedFiles.toList(), + ); + } + + void deselectFiles(Iterable files) { + final selectedFiles = _selectedFiles..removeAll(files); + state = state.copyWith( + selectedFiles: selectedFiles.toList(), + ); + } + + void deselectAll() { + state = state.copyWith( + selectedFiles: [], + ); + } } diff --git a/lib/view/drive_page/drive_file_grid_item.dart b/lib/view/drive_page/drive_file_grid_item.dart index 8d38cc365..0ed59b335 100644 --- a/lib/view/drive_page/drive_file_grid_item.dart +++ b/lib/view/drive_page/drive_file_grid_item.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:miria/model/account.dart'; import 'package:miria/view/drive_page/drive_file_modal_sheet.dart'; import 'package:miria/view/note_create_page/thumbnail.dart'; +import 'package:miria/view/themes/app_theme.dart'; import 'package:misskey_dart/misskey_dart.dart'; class DriveFileGridItem extends ConsumerWidget { @@ -10,18 +11,23 @@ class DriveFileGridItem extends ConsumerWidget { super.key, required this.account, required this.file, + this.isSelected = false, this.onTap, this.onLongPress, }); final Account account; final DriveFile file; + final bool isSelected; final void Function()? onTap; final void Function()? onLongPress; @override Widget build(BuildContext context, WidgetRef ref) { return Card( + color: isSelected + ? AppTheme.of(context).currentDisplayTabColor.withOpacity(0.7) + : null, child: InkWell( onTap: onTap, onLongPress: onLongPress, diff --git a/lib/view/drive_page/drive_file_modal_sheet.dart b/lib/view/drive_page/drive_file_modal_sheet.dart index 7a70298a9..23664188f 100644 --- a/lib/view/drive_page/drive_file_modal_sheet.dart +++ b/lib/view/drive_page/drive_file_modal_sheet.dart @@ -136,6 +136,7 @@ class DriveFileModalSheet extends ConsumerWidget { driveFilesNotifierProvider((misskey, file.folderId)).notifier, ) .delete(file.id); + ref.read(drivePageNotifierProvider.notifier).deselectFile(file); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("削除しました")), diff --git a/lib/view/drive_page/drive_folder_modal_sheet.dart b/lib/view/drive_page/drive_folder_modal_sheet.dart index 75d6ea04c..48cf8d5e3 100644 --- a/lib/view/drive_page/drive_folder_modal_sheet.dart +++ b/lib/view/drive_page/drive_folder_modal_sheet.dart @@ -77,6 +77,7 @@ class DriveFolderModalSheet extends ConsumerWidget { driveFoldersNotifierProvider((misskey, folder.parentId)).notifier, ) .delete(folder.id); + ref.read(drivePageNotifierProvider.notifier).deselectAll(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("削除しました")), diff --git a/lib/view/drive_page/drive_page.dart b/lib/view/drive_page/drive_page.dart index 220ad239c..4b4298ce9 100644 --- a/lib/view/drive_page/drive_page.dart +++ b/lib/view/drive_page/drive_page.dart @@ -14,6 +14,7 @@ import 'package:miria/view/drive_page/drive_create_modal_sheet.dart'; import 'package:miria/view/drive_page/drive_file_grid_item.dart'; import 'package:miria/view/drive_page/drive_folder_grid_item.dart'; import 'package:miria/view/drive_page/drive_folder_modal_sheet.dart'; +import 'package:miria/view/drive_page/drive_selected_files_modal_sheet.dart'; @RoutePage() class DrivePage extends ConsumerWidget { @@ -22,20 +23,21 @@ class DrivePage extends ConsumerWidget { required this.account, this.title, this.floatingActionButtonBuilder, + this.tapToSelect = false, }); final Account account; final Widget? title; final Widget Function(BuildContext context)? floatingActionButtonBuilder; + final bool tapToSelect; static const itemMaxCrossAxisExtent = 400.0; @override Widget build(BuildContext context, WidgetRef ref) { final misskey = ref.watch(misskeyProvider(account)); - final last = ref.watch( - drivePageNotifierProvider.select((state) => state.breadcrumbs.lastOrNull), - ); + final state = ref.watch(drivePageNotifierProvider); + final last = state.breadcrumbs.lastOrNull; final folderId = last?.id; final arg = (misskey, folderId); final currentFolder = ref.watch( @@ -53,17 +55,25 @@ class DrivePage extends ConsumerWidget { repository.settings.automaticPush == AutomaticPush.automatic, ), ); + final isSelecting = tapToSelect || state.selectedFiles.isNotEmpty; return PopScope( - canPop: currentFolder == null, + canPop: currentFolder == null && state.selectedFiles.isEmpty, onPopInvoked: (_) { - if (currentFolder != null) { + if (state.selectedFiles.isNotEmpty) { + ref.read(drivePageNotifierProvider.notifier).deselectAll(); + } else if (currentFolder != null) { ref.read(drivePageNotifierProvider.notifier).pop(); } }, child: Scaffold( appBar: AppBar( - title: title ?? const Text("ドライブ"), + leading: state.selectedFiles.isEmpty + ? const BackButton() + : const CloseButton(), + title: state.selectedFiles.isEmpty + ? title ?? const Text("ドライブ") + : Text("選択中 (${state.selectedFiles.length})"), actions: [ if (floatingActionButtonBuilder != null) IconButton( @@ -76,7 +86,45 @@ class DrivePage extends ConsumerWidget { ), icon: const Icon(Icons.add), ), - if (currentFolder != null) + if (isSelecting) ...[ + files.maybeWhen( + data: (files) { + if (files.isEmpty) { + return const SizedBox.shrink(); + } + if (files.every( + (file) => state.selectedFiles.any((e) => e.id == file.id), + )) { + return IconButton( + onPressed: () => ref + .read(drivePageNotifierProvider.notifier) + .deselectFiles(files), + icon: const Icon(Icons.deselect), + ); + } else { + return IconButton( + onPressed: () => ref + .read(drivePageNotifierProvider.notifier) + .selectFiles(files), + icon: const Icon(Icons.select_all), + ); + } + }, + orElse: () => const SizedBox.shrink(), + ), + IconButton( + onPressed: state.selectedFiles.isNotEmpty + ? () => showModalBottomSheet( + context: context, + builder: (context) => DriveSelectedFilesModalSheet( + account: account, + files: state.selectedFiles, + ), + ) + : null, + icon: const Icon(Icons.more_vert), + ), + ] else if (currentFolder != null) IconButton( onPressed: () async { await showModalBottomSheet( @@ -218,15 +266,35 @@ class DrivePage extends ConsumerWidget { return const SizedBox.shrink(); } final file = files[index]; + final isSelected = + state.selectedFiles.any((e) => e.id == file.id); return DriveFileGridItem( account: account, file: file, - onTap: () => context.pushRoute( - DriveFileRoute( - account: account, - file: file, - ), - ), + isSelected: isSelected, + onTap: () { + if (isSelecting) { + if (isSelected) { + ref + .read(drivePageNotifierProvider.notifier) + .deselectFile(file); + } else { + ref + .read(drivePageNotifierProvider.notifier) + .selectFile(file); + } + } else { + context.pushRoute( + DriveFileRoute( + account: account, + file: file, + ), + ); + } + }, + onLongPress: () => ref + .read(drivePageNotifierProvider.notifier) + .selectFile(file), ); }, ), diff --git a/lib/view/drive_page/drive_selected_files_modal_sheet.dart b/lib/view/drive_page/drive_selected_files_modal_sheet.dart new file mode 100644 index 000000000..5f8f2c570 --- /dev/null +++ b/lib/view/drive_page/drive_selected_files_modal_sheet.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:miria/model/account.dart'; +import 'package:miria/providers.dart'; +import 'package:miria/view/common/error_dialog_handler.dart'; +import 'package:miria/view/dialogs/simple_confirm_dialog.dart'; +import 'package:miria/view/drive_page/drive_folder_select_dialog.dart'; +import 'package:misskey_dart/misskey_dart.dart'; + +class DriveSelectedFilesModalSheet extends ConsumerWidget { + const DriveSelectedFilesModalSheet({ + super.key, + required this.account, + required this.files, + }); + + final Account account; + final List files; + + Future download(WidgetRef ref) async { + final context = ref.context; + await Future.wait( + files.map( + (file) => + ref.read(downloadFileNotifierProvider.notifier).downloadFile(file), + ), + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("ファイルを保存しました")), + ); + Navigator.of(context).pop(); + } + + Future move(WidgetRef ref) async { + final context = ref.context; + final misskey = ref.read(misskeyProvider(account)); + final result = await showDialog<(DriveFolder?,)>( + context: context, + builder: (context) => DriveFolderSelectDialog(account: account), + ); + if (result == null) return; + await Future.wait( + files.map( + (file) => ref + .read(driveFilesNotifierProvider((misskey, file.folderId)).notifier) + .move( + fileId: file.id, + folderId: result.$1?.id, + ), + ), + ); + ref.read(drivePageNotifierProvider.notifier).deselectAll(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("移動しました")), + ); + Navigator.of(context).pop(); + } + + Future delete(WidgetRef ref) async { + final context = ref.context; + final misskey = ref.read(misskeyProvider(account)); + final result = await SimpleConfirmDialog.show( + context: context, + message: "${files.length}個のファイルを削除しますか?", + primary: "削除する", + secondary: "やめる", + ); + if (result ?? false) { + await Future.wait( + files.map( + (file) => ref + .read( + driveFilesNotifierProvider((misskey, file.folderId)).notifier, + ) + .delete(file.id), + ), + ); + ref.read(drivePageNotifierProvider.notifier).deselectAll(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("削除しました")), + ); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text("${files.length}個のファイル")), + if (Platform.isAndroid || Platform.isIOS) + ListTile( + leading: const Icon(Icons.download), + title: const Text("ダウンロード"), + onTap: () => download(ref).expectFailure(context), + ), + ListTile( + leading: const Icon(Icons.drive_file_move), + title: const Text("移動"), + onTap: () => move(ref).expectFailure(context), + ), + ListTile( + leading: const Icon(Icons.delete), + title: const Text("削除"), + onTap: () => delete(ref).expectFailure(context), + ), + ], + ); + } +}