#249 Pick: allow selecting multiple items according to request intent

This commit is contained in:
Thibault Deckers 2022-05-16 11:48:49 +09:00
parent 406e99b13f
commit 16c0ba90f9
17 changed files with 143 additions and 96 deletions

View file

@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
- Collection: thumbnail overlay tag icon
- Collection: fast-scrolling shows breadcrumbs from groups
- Settings: search
- Pick: allow selecting multiple items according to request intent
- `huawei` app flavor (Petal Maps, no Crashlytics)
### Changed

View file

@ -177,12 +177,12 @@ dependencies {
android.productFlavors.each { flavor ->
def tasks = gradle.startParameter.taskRequests.toString().toLowerCase()
if (tasks.contains(flavor.name) && flavor.ext.useCrashlytics) {
println("Building flavor with Crashlytics [${flavor.name}] - applying plugin")
println("Building flavor [${flavor.name}] with Crashlytics plugin")
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
}
if (tasks.contains(flavor.name) && flavor.ext.useHMS) {
println("Building flavor with HMS [${flavor.name}] - applying plugin")
println("Building flavor [${flavor.name}] with HMS plugin")
apply plugin: 'com.huawei.agconnect'
}
}

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Build
@ -16,7 +17,6 @@ import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@ -222,6 +222,7 @@ class MainActivity : FlutterActivity() {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
INTENT_DATA_KEY_MIME_TYPE to intent.type,
INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false),
)
}
Intent.ACTION_SEARCH -> {
@ -246,10 +247,20 @@ class MainActivity : FlutterActivity() {
}
private fun pick(call: MethodCall) {
val pickedUri = call.argument<String>("uri")
if (pickedUri != null) {
val pickedUris = call.argument<List<String>>("uris")
if (pickedUris != null && pickedUris.isNotEmpty()) {
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(context, Uri.parse(uriString)) }
val intent = Intent().apply {
data = Uri.parse(pickedUri)
val firstUri = toUri(pickedUris.first())
if (pickedUris.size == 1) {
data = firstUri
} else {
clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
pickedUris.drop(1).forEach {
addItem(ClipData.Item(toUri(it)))
}
}
}
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
setResult(RESULT_OK, intent)
@ -307,6 +318,7 @@ class MainActivity : FlutterActivity() {
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_QUERY = "query"

View file

@ -216,7 +216,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
try {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard != null) {
val clip = ClipData.newUri(context.contentResolver, label, getShareableUri(uri))
val clip = ClipData.newUri(context.contentResolver, label, getShareableUri(context, uri))
clipboard.setPrimaryClip(clip)
result.success(true)
} else {
@ -239,7 +239,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType)
.setDataAndType(getShareableUri(context, uri), mimeType)
val started = safeStartActivityChooser(title, intent)
result.success(started)
@ -256,7 +256,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType)
.setDataAndType(getShareableUri(context, uri), mimeType)
val started = safeStartActivityChooser(title, intent)
result.success(started)
@ -286,7 +286,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType)
.setDataAndType(getShareableUri(context, uri), mimeType)
val started = safeStartActivityChooser(title, intent)
result.success(started)
@ -311,7 +311,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType)
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
.putExtra(Intent.EXTRA_STREAM, getShareableUri(context, uri))
safeStartActivityChooser(title, intent)
} else {
var mimeType = "*/*"
@ -368,18 +368,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return false
}
private fun getShareableUri(uri: Uri): Uri? {
return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
uri.path?.let { path ->
val authority = "${context.applicationContext.packageName}.file_provider"
FileProvider.getUriForFile(context, authority, File(path))
}
}
else -> uri
}
}
// shortcuts
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
@ -443,5 +431,17 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
companion object {
private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>()
const val CHANNEL = "deckers.thibault/aves/app"
fun getShareableUri(context: Context, uri: Uri): Uri? {
return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
uri.path?.let { path ->
val authority = "${context.applicationContext.packageName}.file_provider"
FileProvider.getUriForFile(context, authority, File(path))
}
}
else -> uri
}
}
}
}

View file

@ -1,11 +1,13 @@
enum AppMode { main, pickMediaExternal, pickMediaInternal, pickFilterInternal, view }
enum AppMode { main, pickSingleMediaExternal, pickMultipleMediaExternal, pickMediaInternal, pickFilterInternal, view }
extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickMediaExternal;
bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;
bool get canSelect => this == AppMode.main;
bool get canSelectMedia => this == AppMode.main || this == AppMode.pickMultipleMediaExternal;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickMediaExternal;
bool get canSelectFilter => this == AppMode.main;
bool get isPickingMedia => this == AppMode.pickMediaExternal || this == AppMode.pickMediaInternal;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;
bool get isPickingMedia => this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal || this == AppMode.pickMediaInternal;
}

View file

@ -15,10 +15,10 @@ class ViewerService {
return {};
}
static Future<void> pick(String uri) async {
static Future<void> pick(List<String> uris) async {
try {
await platform.invokeMethod('pick', <String, dynamic>{
'uri': uri,
'uris': uris,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);

View file

@ -10,6 +10,7 @@ class AIcons {
static const IconData accessibility = Icons.accessibility_new_outlined;
static const IconData android = Icons.android;
static const IconData app = Icons.apps_outlined;
static const IconData apply = Icons.done_outlined;
static const IconData bin = Icons.delete_outlined;
static const IconData broken = Icons.broken_image_outlined;
static const IconData checked = Icons.done_outlined;

View file

@ -184,7 +184,8 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
case AppLifecycleState.inactive:
switch (appModeNotifier.value) {
case AppMode.main:
case AppMode.pickMediaExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
_saveTopEntries();
break;
case AppMode.pickMediaInternal:

View file

@ -250,7 +250,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
),
if (isSelecting && !isTrash)
if (isSelecting && !isTrash && appMode == AppMode.main)
PopupMenuItem<EntrySetAction>(
enabled: canApplyEditActions,
padding: EdgeInsets.zero,

View file

@ -216,10 +216,9 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
child: scrollView,
);
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector(
scrollableKey: scrollableKey,
selectable: isMainMode,
selectable: context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia),
items: collection.sortedEntries,
scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier,

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/query.dart';
@ -9,11 +10,14 @@ import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/query_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
@ -75,9 +79,14 @@ class _CollectionPageState extends State<CollectionPage> {
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
return MediaQueryDataProvider(
child: Selector<Settings, bool>(
child: SelectionProvider<AvesEntry>(
child: Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.selectedItems.isNotEmpty,
builder: (context, hasSelection, child) {
return Selector<Settings, bool>(
selector: (context, s) => s.showBottomNavigationBar,
builder: (context, showBottomNavigationBar, child) {
return NotificationListener<DraggableScrollBarNotification>(
@ -86,8 +95,7 @@ class _CollectionPageState extends State<CollectionPage> {
return false;
},
child: Scaffold(
body: SelectionProvider<AvesEntry>(
child: QueryProvider(
body: QueryProvider(
initialQuery: liveFilter?.query,
child: Builder(
builder: (context) => WillPopScope(
@ -117,7 +125,17 @@ class _CollectionPageState extends State<CollectionPage> {
),
),
),
),
floatingActionButton: appMode == AppMode.pickMultipleMediaExternal && hasSelection
? FloatingActionButton(
tooltip: context.l10n.collectionPickPageTitle,
onPressed: () {
final items = context.read<Selection<AvesEntry>>().selectedItems;
final uris = items.map((entry) => entry.uri).toList();
ViewerService.pick(uris);
},
child: const Icon(AIcons.apply),
)
: null,
drawer: AppDrawer(currentCollection: _collection),
bottomNavigationBar: showBottomNavigationBar
? AppBottomNavBar(
@ -130,6 +148,9 @@ class _CollectionPageState extends State<CollectionPage> {
),
);
},
);
},
),
),
);
}

View file

@ -56,7 +56,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.configureView:
return true;
case EntrySetAction.select:
return appMode.canSelect && !isSelecting;
return appMode.canSelectMedia && !isSelecting;
case EntrySetAction.selectAll:
return isSelecting && selectedItemCount < itemCount;
case EntrySetAction.selectNone:
@ -69,7 +69,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.addShortcut:
return appMode == AppMode.main && !isSelecting && device.canPinShortcut && !isTrash;
case EntrySetAction.emptyBin:
return isTrash;
return appMode == AppMode.main && isTrash;
// browsing or selecting
case EntrySetAction.map:
case EntrySetAction.stats:

View file

@ -43,8 +43,12 @@ class InteractiveTile extends StatelessWidget {
_goToViewer(context);
}
break;
case AppMode.pickMediaExternal:
ViewerService.pick(entry.uri);
case AppMode.pickSingleMediaExternal:
ViewerService.pick([entry.uri]);
break;
case AppMode.pickMultipleMediaExternal:
final selection = context.read<Selection<AvesEntry>>();
selection.toggleSelection(entry);
break;
case AppMode.pickMediaInternal:
Navigator.pop(context, entry);

View file

@ -52,7 +52,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
case ChipSetAction.configureView:
return true;
case ChipSetAction.select:
return appMode.canSelect && !isSelecting;
return appMode.canSelectFilter && !isSelecting;
case ChipSetAction.selectAll:
return isSelecting && selectedItemCount < itemCount;
case ChipSetAction.selectNone:

View file

@ -401,10 +401,9 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
child: scrollView,
);
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector<FilterGridItem<T>>(
scrollableKey: scrollableKey,
selectable: isMainMode && widget.selectable,
selectable: context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectFilter) && widget.selectable,
items: visibleSections.values.expand((v) => v).toList(),
scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier,

View file

@ -50,7 +50,8 @@ class _InteractiveFilterTileState<T extends CollectionFilter> extends State<Inte
final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) {
case AppMode.main:
case AppMode.pickMediaExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
final selection = context.read<Selection<FilterGridItem<T>>>();
if (selection.isSelecting) {
selection.toggleSelection(gridItem);

View file

@ -95,11 +95,12 @@ class _HomePageState extends State<HomePage> {
}
break;
case 'pick':
appMode = AppMode.pickMediaExternal;
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String? pickMimeTypes = intentData['mimeType'];
debugPrint('pick mimeType=$pickMimeTypes');
final multiple = intentData['allowMultiple'] ?? false;
debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
break;
case 'search':
_shortcutRouteName = SearchPage.routeName;
@ -121,7 +122,8 @@ class _HomePageState extends State<HomePage> {
switch (appMode) {
case AppMode.main:
case AppMode.pickMediaExternal:
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>();
@ -226,11 +228,15 @@ class _HomePageState extends State<HomePage> {
String routeName;
Set<CollectionFilter?>? filters;
if (appMode == AppMode.pickMediaExternal) {
switch (appMode) {
case AppMode.pickSingleMediaExternal:
case AppMode.pickMultipleMediaExternal:
routeName = CollectionPage.routeName;
} else {
break;
default:
routeName = _shortcutRouteName ?? settings.homePage.routeName;
filters = (_shortcutFilters ?? {}).map(CollectionFilter.fromJson).toSet();
break;
}
final source = context.read<CollectionSource>();
switch (routeName) {