viewer: add shortcut action

This commit is contained in:
Thibault Deckers 2021-11-11 17:35:29 +09:00
parent fe8948ef5e
commit b2a9a33015
9 changed files with 121 additions and 68 deletions

View file

@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
### Added
- Viewer: action to add shortcut to media item
### Fixed
- video playback was not using hardware-accelerated codecs on recent devices
## [v1.5.5] - 2021-11-08 ## [v1.5.5] - 2021-11-08
### Added ### Added

View file

@ -329,7 +329,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val label = call.argument<String>("label") val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes") val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters") val filters = call.argument<List<String>>("filters")
if (label == null || filters == null) { val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (label == null || (filters == null && uri == null)) {
result.error("pin-args", "failed because of missing arguments", null) result.error("pin-args", "failed because of missing arguments", null)
return return
} }
@ -356,12 +357,19 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection) icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
} }
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) val intent = when {
.putExtra("page", "/collection") uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
.putExtra("filters", filters.toTypedArray()) filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut .putExtra("page", "/collection")
// so we use a joined `String` as fallback .putExtra("filters", filters.toTypedArray())
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR)) // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
else -> {
result.error("pin-intent", "failed to build intent", null)
return
}
}
// multiple shortcuts sharing the same ID cannot be created with different labels or icons // multiple shortcuts sharing the same ID cannot be created with different labels or icons
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any // so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any

View file

@ -4,6 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum EntryAction { enum EntryAction {
addShortcut,
copyToClipboard,
delete, delete,
export, export,
info, info,
@ -20,7 +22,6 @@ enum EntryAction {
// motion photo, // motion photo,
viewMotionPhotoVideo, viewMotionPhotoVideo,
// external // external
copyToClipboard,
edit, edit,
open, open,
openMap, openMap,
@ -39,6 +40,7 @@ class EntryActions {
EntryAction.delete, EntryAction.delete,
EntryAction.rename, EntryAction.rename,
EntryAction.export, EntryAction.export,
EntryAction.addShortcut,
EntryAction.copyToClipboard, EntryAction.copyToClipboard,
EntryAction.print, EntryAction.print,
EntryAction.viewSource, EntryAction.viewSource,
@ -63,9 +65,8 @@ class EntryActions {
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {
String getText(BuildContext context) { String getText(BuildContext context) {
switch (this) { switch (this) {
case EntryAction.toggleFavourite: case EntryAction.addShortcut:
// different data depending on toggle state return context.l10n.collectionActionAddShortcut;
return context.l10n.entryActionAddFavourite;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
return context.l10n.entryActionCopyToClipboard; return context.l10n.entryActionCopyToClipboard;
case EntryAction.delete: case EntryAction.delete:
@ -74,12 +75,15 @@ extension ExtraEntryAction on EntryAction {
return context.l10n.entryActionExport; return context.l10n.entryActionExport;
case EntryAction.info: case EntryAction.info:
return context.l10n.entryActionInfo; return context.l10n.entryActionInfo;
case EntryAction.rename:
return context.l10n.entryActionRename;
case EntryAction.print: case EntryAction.print:
return context.l10n.entryActionPrint; return context.l10n.entryActionPrint;
case EntryAction.rename:
return context.l10n.entryActionRename;
case EntryAction.share: case EntryAction.share:
return context.l10n.entryActionShare; return context.l10n.entryActionShare;
case EntryAction.toggleFavourite:
// different data depending on toggle state
return context.l10n.entryActionAddFavourite;
// raster // raster
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
return context.l10n.entryActionRotateCCW; return context.l10n.entryActionRotateCCW;
@ -98,10 +102,10 @@ extension ExtraEntryAction on EntryAction {
return context.l10n.entryActionEdit; return context.l10n.entryActionEdit;
case EntryAction.open: case EntryAction.open:
return context.l10n.entryActionOpen; return context.l10n.entryActionOpen;
case EntryAction.setAs:
return context.l10n.entryActionSetAs;
case EntryAction.openMap: case EntryAction.openMap:
return context.l10n.entryActionOpenMap; return context.l10n.entryActionOpenMap;
case EntryAction.setAs:
return context.l10n.entryActionSetAs;
// platform // platform
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
return context.l10n.entryActionRotateScreen; return context.l10n.entryActionRotateScreen;
@ -129,9 +133,8 @@ extension ExtraEntryAction on EntryAction {
IconData? getIconData() { IconData? getIconData() {
switch (this) { switch (this) {
case EntryAction.toggleFavourite: case EntryAction.addShortcut:
// different data depending on toggle state return AIcons.addShortcut;
return AIcons.favourite;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
return AIcons.clipboard; return AIcons.clipboard;
case EntryAction.delete: case EntryAction.delete:
@ -140,12 +143,15 @@ extension ExtraEntryAction on EntryAction {
return AIcons.saveAs; return AIcons.saveAs;
case EntryAction.info: case EntryAction.info:
return AIcons.info; return AIcons.info;
case EntryAction.rename:
return AIcons.rename;
case EntryAction.print: case EntryAction.print:
return AIcons.print; return AIcons.print;
case EntryAction.rename:
return AIcons.rename;
case EntryAction.share: case EntryAction.share:
return AIcons.share; return AIcons.share;
case EntryAction.toggleFavourite:
// different data depending on toggle state
return AIcons.favourite;
// raster // raster
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
return AIcons.rotateLeft; return AIcons.rotateLeft;
@ -162,8 +168,8 @@ extension ExtraEntryAction on EntryAction {
// external // external
case EntryAction.edit: case EntryAction.edit:
case EntryAction.open: case EntryAction.open:
case EntryAction.setAs:
case EntryAction.openMap: case EntryAction.openMap:
case EntryAction.setAs:
return null; return null;
// platform // platform
case EntryAction.rotateScreen: case EntryAction.rotateScreen:

View file

@ -31,7 +31,7 @@ abstract class AndroidAppService {
Future<bool> canPinToHomeScreen(); Future<bool> canPinToHomeScreen();
Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters); Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
} }
class PlatformAndroidAppService implements AndroidAppService { class PlatformAndroidAppService implements AndroidAppService {
@ -194,17 +194,17 @@ class PlatformAndroidAppService implements AndroidAppService {
} }
@override @override
Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters) async { Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
Uint8List? iconBytes; Uint8List? iconBytes;
if (entry != null) { if (coverEntry != null) {
final size = entry.isVideo ? 0.0 : 256.0; final size = coverEntry.isVideo ? 0.0 : 256.0;
iconBytes = await mediaFileService.getThumbnail( iconBytes = await mediaFileService.getThumbnail(
uri: entry.uri, uri: coverEntry.uri,
mimeType: entry.mimeType, mimeType: coverEntry.mimeType,
pageId: entry.pageId, pageId: coverEntry.pageId,
rotationDegrees: entry.rotationDegrees, rotationDegrees: coverEntry.rotationDegrees,
isFlipped: entry.isFlipped, isFlipped: coverEntry.isFlipped,
dateModifiedSecs: entry.dateModifiedSecs, dateModifiedSecs: coverEntry.dateModifiedSecs,
extent: size, extent: size,
); );
} }
@ -212,7 +212,8 @@ class PlatformAndroidAppService implements AndroidAppService {
await platform.invokeMethod('pin', <String, dynamic>{ await platform.invokeMethod('pin', <String, dynamic>{
'label': label, 'label': label,
'iconBytes': iconBytes, 'iconBytes': iconBytes,
'filters': filters.map((filter) => filter.toJson()).toList(), 'filters': filters?.map((filter) => filter.toJson()).toList(),
'uri': uri,
}); });
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);

View file

@ -586,8 +586,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final result = await showDialog<Tuple2<AvesEntry?, String>>( final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context, context: context,
builder: (context) => AddShortcutDialog( builder: (context) => AddShortcutDialog(
collection: collection,
defaultName: defaultName ?? '', defaultName: defaultName ?? '',
collection: collection,
), ),
); );
if (result == null) return; if (result == null) return;
@ -596,6 +596,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final name = result.item2; final name = result.item2;
if (name.isEmpty) return; if (name.isEmpty) return;
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters));
} }
} }

View file

@ -1,6 +1,5 @@
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/extensions/build_context.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/media_query_data_provider.dart';
@ -14,13 +13,13 @@ import 'package:tuple/tuple.dart';
import 'aves_dialog.dart'; import 'aves_dialog.dart';
class AddShortcutDialog extends StatefulWidget { class AddShortcutDialog extends StatefulWidget {
final CollectionLens collection; final CollectionLens? collection;
final String defaultName; final String defaultName;
const AddShortcutDialog({ const AddShortcutDialog({
Key? key, Key? key,
required this.collection,
required this.defaultName, required this.defaultName,
this.collection,
}) : super(key: key); }) : super(key: key);
@override @override
@ -32,17 +31,16 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
AvesEntry? _coverEntry; AvesEntry? _coverEntry;
CollectionLens get collection => widget.collection;
Set<CollectionFilter> get filters => collection.filters;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final entries = collection.sortedEntries; final _collection = widget.collection;
if (entries.isNotEmpty) { if (_collection != null) {
final coverEntries = filters.map(covers.coverContentId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.contentId == id)).whereNotNull(); final entries = _collection.sortedEntries;
_coverEntry = coverEntries.firstOrNull ?? entries.first; if (entries.isNotEmpty) {
final coverEntries = _collection.filters.map(covers.coverContentId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.contentId == id)).whereNotNull();
_coverEntry = coverEntries.firstOrNull ?? entries.first;
}
} }
_nameController.text = widget.defaultName; _nameController.text = widget.defaultName;
_validate(); _validate();
@ -123,14 +121,17 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
} }
Future<void> _pickEntry() async { Future<void> _pickEntry() async {
final _collection = widget.collection;
if (_collection == null) return;
final entry = await Navigator.push( final entry = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: ItemPickDialog.routeName), settings: const RouteSettings(name: ItemPickDialog.routeName),
builder: (context) => ItemPickDialog( builder: (context) => ItemPickDialog(
collection: CollectionLens( collection: CollectionLens(
source: collection.source, source: _collection.source,
filters: filters, filters: _collection.filters,
), ),
), ),
fullscreenDialog: true, fullscreenDialog: true,

View file

@ -36,6 +36,7 @@ class ViewerActionEditorPage extends StatelessWidget {
EntryAction.delete, EntryAction.delete,
EntryAction.rename, EntryAction.rename,
EntryAction.export, EntryAction.export,
EntryAction.addShortcut,
EntryAction.copyToClipboard, EntryAction.copyToClipboard,
EntryAction.print, EntryAction.print,
EntryAction.rotateScreen, EntryAction.rotateScreen,

View file

@ -18,6 +18,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
@ -30,12 +31,13 @@ import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) { void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.addShortcut:
entry.toggleFavourite(); _addShortcut(context, entry);
break; break;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) {
@ -43,10 +45,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}); });
break; break;
case EntryAction.delete: case EntryAction.delete:
_showDeleteDialog(context, entry); _delete(context, entry);
break; break;
case EntryAction.export: case EntryAction.export:
_showExportDialog(context, entry); _export(context, entry);
break; break;
case EntryAction.info: case EntryAction.info:
ShowInfoNotification().dispatch(context); ShowInfoNotification().dispatch(context);
@ -55,8 +57,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
EntryPrinter(entry).print(context); EntryPrinter(entry).print(context);
break; break;
case EntryAction.rename: case EntryAction.rename:
_showRenameDialog(context, entry); _rename(context, entry);
break; break;
case EntryAction.share:
androidAppService.shareEntries({entry}).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
case EntryAction.toggleFavourite:
entry.toggleFavourite();
break;
// raster
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
_rotate(context, entry, clockwise: false); _rotate(context, entry, clockwise: false);
break; break;
@ -66,6 +77,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.flip: case EntryAction.flip:
_flip(context, entry); _flip(context, entry);
break; break;
// vector
case EntryAction.viewSource:
_goToSourceViewer(context, entry);
break;
// motion photo
case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break;
// external
case EntryAction.edit: case EntryAction.edit:
androidAppService.edit(entry.uri, entry.mimeType).then((success) { androidAppService.edit(entry.uri, entry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
@ -81,31 +101,37 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.rotateScreen:
_rotateScreen(context);
break;
case EntryAction.setAs: case EntryAction.setAs:
androidAppService.setAs(entry.uri, entry.mimeType).then((success) { androidAppService.setAs(entry.uri, entry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.share: // platform
androidAppService.shareEntries({entry}).then((success) { case EntryAction.rotateScreen:
if (!success) showNoMatchingAppDialog(context); _rotateScreen(context);
});
break;
case EntryAction.viewSource:
_goToSourceViewer(context, entry);
break;
case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break; break;
// debug
case EntryAction.debug: case EntryAction.debug:
_goToDebug(context, entry); _goToDebug(context, entry);
break; break;
} }
} }
Future<void> _addShortcut(BuildContext context, AvesEntry entry) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context,
builder: (context) => AddShortcutDialog(
defaultName: entry.bestTitle ?? '',
),
);
if (result == null) return;
final name = result.item2;
if (name.isEmpty) return;
unawaited(androidAppService.pinToHomeScreen(name, entry, uri: entry.uri));
}
Future<void> _flip(BuildContext context, AvesEntry entry) async { Future<void> _flip(BuildContext context, AvesEntry entry) async {
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {entry})) return;
@ -131,7 +157,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _showDeleteDialog(BuildContext context, AvesEntry entry) async { Future<void> _delete(BuildContext context, AvesEntry entry) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
@ -166,7 +192,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _showExportDialog(BuildContext context, AvesEntry entry) async { Future<void> _export(BuildContext context, AvesEntry entry) async {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
if (!source.initialized) { if (!source.initialized) {
await source.init(); await source.init();
@ -273,7 +299,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
Future<void> _showRenameDialog(BuildContext context, AvesEntry entry) async { Future<void> _rename(BuildContext context, AvesEntry entry) async {
final newName = await showDialog<String>( final newName = await showDialog<String>(
context: context, context: context,
builder: (context) => RenameEntryDialog(entry: entry), builder: (context) => RenameEntryDialog(entry: entry),

View file

@ -88,6 +88,7 @@ class ViewerTopOverlay extends StatelessWidget {
return targetEntry.isMotionPhoto; return targetEntry.isMotionPhoto;
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
return settings.isRotationLocked; return settings.isRotationLocked;
case EntryAction.addShortcut:
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
case EntryAction.edit: case EntryAction.edit:
case EntryAction.info: case EntryAction.info:
@ -208,6 +209,7 @@ class _TopOverlayRow extends StatelessWidget {
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
case EntryAction.addShortcut:
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
case EntryAction.delete: case EntryAction.delete:
case EntryAction.export: case EntryAction.export: