diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e477e93..23996dc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb - Albums: localized common album names - Collection: select shortcut icon image +- Viewer: customizable quick actions ### Changed - Upgraded Flutter to beta v2.1.0-12.2.pre diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 28318eff3..793c48019 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -525,6 +525,19 @@ "settingsViewerShowShootingDetails": "Show shooting details", "@settingsViewerShowShootingDetails": {}, + "settingsViewerQuickActionsTile": "Quick actions", + "@settingsViewerQuickActionsTile": {}, + "settingsViewerQuickActionEditorTitle": "Quick Actions", + "@settingsViewerQuickActionEditorTitle": {}, + "settingsViewerQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed in the viewer.", + "@settingsViewerQuickActionEditorBanner": {}, + "settingsViewerQuickActionEditorDisplayedButtons": "Displayed Buttons", + "@settingsViewerQuickActionEditorDisplayedButtons": {}, + "settingsViewerQuickActionEditorAvailableButtons": "Available Buttons", + "@settingsViewerQuickActionEditorAvailableButtons": {}, + "settingsViewerQuickActionEmpty": "No buttons", + "@settingsViewerQuickActionEmpty": {}, + "settingsSectionVideo": "Video", "@settingsSectionVideo": {}, "settingsVideoShowVideos": "Show videos", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6637ae14e..e047384b5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -243,6 +243,13 @@ "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", "settingsViewerShowShootingDetails": "촬영 정보 표시", + "settingsViewerQuickActionsTile": "빠른 작업", + "settingsViewerQuickActionEditorTitle": "빠른 작업", + "settingsViewerQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 뷰어에 표시될 버튼을 선택하세요.", + "settingsViewerQuickActionEditorDisplayedButtons": "표시될 버튼", + "settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼", + "settingsViewerQuickActionEmpty": "버튼이 없습니다", + "settingsSectionVideo": "동영상", "settingsVideoShowVideos": "미디어에 동영상 표시", diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index cf52147ad..18d59805a 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; @@ -46,6 +47,7 @@ class Settings extends ChangeNotifier { static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; + static const viewerQuickActionsKey = 'viewer_quick_actions'; // info static const infoMapStyleKey = 'info_map_style'; @@ -63,6 +65,12 @@ class Settings extends ChangeNotifier { // version static const lastVersionCheckDateKey = 'last_version_check_date'; + // defaults + static const viewerQuickActionsDefault = [ + EntryAction.toggleFavourite, + EntryAction.share, + ]; + Future init() async { _prefs = await SharedPreferences.getInstance(); } @@ -211,6 +219,10 @@ class Settings extends ChangeNotifier { set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); + List get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values); + + set viewerQuickActions(List newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList()); + // info EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values); @@ -258,16 +270,16 @@ class Settings extends ChangeNotifier { T getEnumOrDefault(String key, T defaultValue, Iterable values) { final valueString = _prefs.getString(key); - for (final element in values) { - if (element.toString() == valueString) { - return element; + for (final v in values) { + if (v.toString() == valueString) { + return v; } } return defaultValue; } List getEnumListOrDefault(String key, List defaultValue, Iterable values) { - return _prefs.getStringList(key)?.map((s) => values.firstWhere((el) => el.toString() == s, orElse: () => null))?.where((el) => el != null)?.toList() ?? defaultValue; + return _prefs.getStringList(key)?.map((s) => values.firstWhere((v) => v.toString() == s, orElse: () => null))?.where((v) => v != null)?.toList() ?? defaultValue; } void setAndNotify(String key, dynamic newValue, {bool notify = true}) { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 55f5a0928..ded2c0f31 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -1,12 +1,15 @@ import 'package:flutter/scheduler.dart'; class Durations { + // Flutter animations (with margin) + static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute` + static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute` + static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` + // common animations static const iconAnimation = Duration(milliseconds: 300); static const sweeperOpacityAnimation = Duration(milliseconds: 150); static const sweepingAnimation = Duration(milliseconds: 650); - static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration - static const dialogTransitionAnimation = Duration(milliseconds: 150); // ref `transitionDuration` in `showDialog()` static const staggeredAnimation = Duration(milliseconds: 375); static const staggeredAnimationPageTarget = Duration(milliseconds: 900); @@ -42,6 +45,10 @@ class Durations { static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300); + // settings animations + static const quickActionListAnimation = Duration(milliseconds: 200); + static const quickActionHighlightAnimation = Duration(milliseconds: 200); + // delays & refresh intervals static const opToastDisplay = Duration(seconds: 3); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index cbf5c41b6..6fb8685ad 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -320,6 +320,8 @@ class _CollectionAppBarState extends State with SingleTickerPr title: context.l10n.collectionGroupTitle, ), ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (value != null) { settings.collectionGroupFactor = value; collection.group(value); @@ -338,6 +340,8 @@ class _CollectionAppBarState extends State with SingleTickerPr title: context.l10n.collectionSortTitle, ), ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (value != null) { settings.collectionSortFactor = value; collection.sort(value); diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index 6cf73dbe6..e6666d51f 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -123,7 +123,7 @@ class ScrollLabel extends StatelessWidget { child: Material( elevation: 4.0, color: backgroundColor, - borderRadius: BorderRadius.all(Radius.circular(16.0)), + borderRadius: BorderRadius.circular(16), child: child, ), ), diff --git a/lib/widgets/common/basic/link_chip.dart b/lib/widgets/common/basic/link_chip.dart index 4842b8ce3..d64258966 100644 --- a/lib/widgets/common/basic/link_chip.dart +++ b/lib/widgets/common/basic/link_chip.dart @@ -9,7 +9,7 @@ class LinkChip extends StatelessWidget { final Color color; final TextStyle textStyle; - static const borderRadius = BorderRadius.all(Radius.circular(8)); + static final borderRadius = BorderRadius.circular(8); const LinkChip({ Key key, diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index 9cbc8df7f..b4622e6a2 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -3,6 +3,7 @@ import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:flutter/material.dart'; class AvesExpansionTile extends StatelessWidget { + final String value; final Widget leading; final String title; final Color color; @@ -11,6 +12,7 @@ class AvesExpansionTile extends StatelessWidget { final List children; const AvesExpansionTile({ + String value, this.leading, @required this.title, this.color, @@ -18,7 +20,7 @@ class AvesExpansionTile extends StatelessWidget { this.initiallyExpanded = false, this.showHighlight = true, @required this.children, - }); + }): value = value ?? title; @override Widget build(BuildContext context) { @@ -44,8 +46,8 @@ class AvesExpansionTile extends StatelessWidget { accentColor: Colors.white, ), child: ExpansionTileCard( - key: Key('tilecard-$title'), - value: title, + key: Key('tilecard-$value'), + value: value, expandedNotifier: expandedNotifier, title: titleChild, expandable: enabled, diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index d7a2e7e2a..9fe245ac7 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -44,7 +44,7 @@ class AvesFilterChip extends StatefulWidget { this.showGenericIcon = true, this.background, this.details, - this.borderRadius = const BorderRadius.all(Radius.circular(defaultRadius)), + this.borderRadius, this.padding = 6.0, this.heroType = HeroType.onTap, this.onTap, @@ -96,8 +96,6 @@ class _AvesFilterChipState extends State { CollectionFilter get filter => widget.filter; - BorderRadius get borderRadius => widget.borderRadius; - double get padding => widget.padding; FilterCallback get onTap => widget.onTap; @@ -197,6 +195,7 @@ class _AvesFilterChipState extends State { ); } + final borderRadius = widget.borderRadius ?? BorderRadius.circular(AvesFilterChip.defaultRadius); Widget chip = Container( constraints: BoxConstraints( minWidth: AvesFilterChip.minChipWidth, diff --git a/lib/widgets/common/identity/scroll_thumb.dart b/lib/widgets/common/identity/scroll_thumb.dart index b3faab19e..ed0ee3ec1 100644 --- a/lib/widgets/common/identity/scroll_thumb.dart +++ b/lib/widgets/common/identity/scroll_thumb.dart @@ -12,7 +12,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({ final scrollThumb = Container( decoration: BoxDecoration( color: Colors.black26, - borderRadius: BorderRadius.circular(12.0), + borderRadius: BorderRadius.circular(12), ), height: height, margin: EdgeInsets.only(right: .5), @@ -23,7 +23,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({ width: 20.0, decoration: BoxDecoration( color: backgroundColor, - borderRadius: BorderRadius.circular(12.0), + borderRadius: BorderRadius.circular(12), ), ), ), diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index 4ad248219..e83bba3a5 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -37,6 +37,7 @@ class DebugSettingsSection extends StatelessWidget { 'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}', 'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}', 'infoMapZoom': '${settings.infoMapZoom}', + 'viewerQuickActions': '${settings.viewerQuickActions}', 'pinnedFilters': toMultiline(settings.pinnedFilters), 'hiddenFilters': toMultiline(settings.hiddenFilters), 'searchHistory': toMultiline(settings.searchHistory), diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 8dd050a0b..61b7a7e2f 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -108,7 +108,7 @@ class _AddShortcutDialogState extends State { return GestureDetector( onTap: _pickEntry, child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(32)), + borderRadius: BorderRadius.circular(32), child: SizedBox( width: extent, height: extent, diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index d2c8b9185..99af9d174 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -53,11 +53,7 @@ class _AvesSelectionDialogState extends State> { key: Key(value.toString()), value: value, groupValue: _selectedValue, - onChanged: (v) { - _selectedValue = v; - Navigator.pop(context, _selectedValue); - setState(() {}); - }, + onChanged: (v) => Navigator.pop(context, v), reselectable: true, title: Text( title, diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 299a47744..c9081d0e4 100644 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -2,10 +2,12 @@ import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/stats/stats.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; abstract class ChipSetActionDelegate { @@ -39,6 +41,8 @@ abstract class ChipSetActionDelegate { title: context.l10n.chipSortTitle, ), ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (factor != null) { sortFactor = factor; } @@ -90,6 +94,8 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { title: context.l10n.albumGroupTitle, ), ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (factor != null) { settings.albumGroupFactor = factor; } diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index 8501084e1..fb01a0460 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -93,7 +93,7 @@ class DecoratedFilterChip extends StatelessWidget { ); final radius = min(AvesFilterChip.defaultRadius, extent / 4); final titlePadding = min(4.0, extent / 32); - final borderRadius = BorderRadius.all(Radius.circular(radius)); + final borderRadius = BorderRadius.circular(radius); Widget child = AvesFilterChip( filter: filter, showGenericIcon: false, diff --git a/lib/widgets/settings/language.dart b/lib/widgets/settings/language.dart index ca4e1599e..33f9daba4 100644 --- a/lib/widgets/settings/language.dart +++ b/lib/widgets/settings/language.dart @@ -1,10 +1,12 @@ import 'dart:collection'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart'; @@ -30,6 +32,8 @@ class LanguageTile extends StatelessWidget { title: context.l10n.settingsLanguage, ), ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (value != null) { settings.locale = value == _systemLocaleOption ? null : value; } diff --git a/lib/widgets/settings/quick_actions/available_actions.dart b/lib/widgets/settings/quick_actions/available_actions.dart new file mode 100644 index 000000000..e73891cb2 --- /dev/null +++ b/lib/widgets/settings/quick_actions/available_actions.dart @@ -0,0 +1,103 @@ +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/settings/quick_actions/common.dart'; +import 'package:flutter/material.dart'; + +class AvailableActionPanel extends StatelessWidget { + final List quickActions; + final Listenable quickActionsChangeNotifier; + final ValueNotifier panelHighlight; + final ValueNotifier draggedQuickAction; + final ValueNotifier draggedAvailableAction; + final bool Function(EntryAction action) removeQuickAction; + + const AvailableActionPanel({ + @required this.quickActions, + @required this.quickActionsChangeNotifier, + @required this.panelHighlight, + @required this.draggedQuickAction, + @required this.draggedAvailableAction, + @required this.removeQuickAction, + }); + + static const allActions = [ + EntryAction.info, + EntryAction.toggleFavourite, + EntryAction.share, + EntryAction.delete, + EntryAction.rename, + EntryAction.export, + EntryAction.print, + EntryAction.viewSource, + EntryAction.flip, + EntryAction.rotateCCW, + EntryAction.rotateCW, + ]; + + @override + Widget build(BuildContext context) { + return DragTarget( + onWillAccept: (data) { + if (draggedQuickAction.value != null) { + _setPanelHighlight(true); + } + return true; + }, + onAcceptWithDetails: (details) { + removeQuickAction(draggedQuickAction.value); + _setDraggedQuickAction(null); + _setPanelHighlight(false); + }, + onLeave: (data) => _setPanelHighlight(false), + builder: (context, accepted, rejected) { + return AnimatedBuilder( + animation: Listenable.merge([quickActionsChangeNotifier, draggedAvailableAction]), + builder: (context, child) => Padding( + padding: EdgeInsets.all(8), + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + spacing: 8, + runSpacing: 8, + children: allActions.map((action) { + final dragged = action == draggedAvailableAction.value; + final enabled = dragged || !quickActions.contains(action); + Widget child = ActionButton( + action: action, + enabled: enabled, + ); + if (dragged) { + child = DraggedPlaceholder(child: child); + } + if (enabled) { + child = _buildDraggable(action, child); + } + return child; + }).toList(), + ), + ), + ); + }, + ); + } + + Widget _buildDraggable(EntryAction action, Widget child) => LongPressDraggable( + data: action, + maxSimultaneousDrags: 1, + onDragStarted: () => _setDraggedAvailableAction(action), + onDragEnd: (details) => _setDraggedAvailableAction(null), + feedback: MediaQueryDataProvider( + child: ActionButton( + action: action, + showCaption: false, + ), + ), + childWhenDragging: child, + child: child, + ); + + void _setDraggedQuickAction(EntryAction action) => draggedQuickAction.value = action; + + void _setDraggedAvailableAction(EntryAction action) => draggedAvailableAction.value = action; + + void _setPanelHighlight(bool flag) => panelHighlight.value = flag; +} diff --git a/lib/widgets/settings/quick_actions/common.dart b/lib/widgets/settings/quick_actions/common.dart new file mode 100644 index 000000000..7d98968ed --- /dev/null +++ b/lib/widgets/settings/quick_actions/common.dart @@ -0,0 +1,95 @@ +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:flutter/material.dart'; + +class ActionPanel extends StatelessWidget { + final bool highlight; + final Widget child; + + const ActionPanel({ + this.highlight = false, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + final color = highlight ? Theme.of(context).accentColor : Colors.blueGrey; + return AnimatedContainer( + foregroundDecoration: BoxDecoration( + color: color.withOpacity(.2), + border: Border.all( + color: color, + width: highlight ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + margin: EdgeInsets.all(16), + duration: Durations.quickActionHighlightAnimation, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: child, + ), + ); + } +} + +class ActionButton extends StatelessWidget { + final EntryAction action; + final bool enabled, showCaption; + + const ActionButton({ + @required this.action, + this.enabled = true, + this.showCaption = true, + }); + + static const padding = 8.0; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.caption; + return SizedBox( + width: OverlayButton.getSize(context) + padding * 2, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: padding), + OverlayButton( + child: IconButton( + icon: Icon(action.getIcon()), + onPressed: enabled ? () {} : null, + ), + ), + if (showCaption) ...[ + SizedBox(height: padding), + Text( + action.getText(context), + style: enabled ? textStyle : textStyle.copyWith(color: textStyle.color.withOpacity(.2)), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ], + SizedBox(height: padding), + ], + ), + ); + } +} + +class DraggedPlaceholder extends StatelessWidget { + final Widget child; + + const DraggedPlaceholder({ + @required this.child, + }); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: .2, + child: child, + ); + } +} diff --git a/lib/widgets/settings/quick_actions/editor.dart b/lib/widgets/settings/quick_actions/editor.dart new file mode 100644 index 000000000..9109dabd4 --- /dev/null +++ b/lib/widgets/settings/quick_actions/editor.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/utils/constants.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/settings/quick_actions/available_actions.dart'; +import 'package:aves/widgets/settings/quick_actions/common.dart'; +import 'package:aves/widgets/settings/quick_actions/quick_actions.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class QuickActionsTile extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(context.l10n.settingsViewerQuickActionsTile), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: QuickActionEditorPage.routeName), + builder: (context) => QuickActionEditorPage(), + ), + ); + }, + ); + } +} + +class QuickActionEditorPage extends StatefulWidget { + static const routeName = '/settings/quick_actions'; + + @override + _QuickActionEditorPageState createState() => _QuickActionEditorPageState(); +} + +class _QuickActionEditorPageState extends State { + final GlobalKey _animatedListKey = GlobalKey(debugLabel: 'quick-actions-animated-list'); + Timer _targetLeavingTimer; + List _quickActions; + final ValueNotifier _draggedQuickAction = ValueNotifier(null); + final ValueNotifier _draggedAvailableAction = ValueNotifier(null); + final ValueNotifier _quickActionHighlight = ValueNotifier(false); + final ValueNotifier _availableActionHighlight = ValueNotifier(false); + final AChangeNotifier _quickActionsChangeNotifier = AChangeNotifier(); + + // use a flag to prevent quick action target accept/leave when already animating reorder + // as dragging a button against axis direction messes index resolution while items pop in and out + bool _reordering = false; + + static const quickActionVerticalPadding = 16.0; + + @override + void initState() { + super.initState(); + _quickActions = settings.viewerQuickActions.toList(); + } + + @override + void dispose() { + _stopLeavingTimer(); + super.dispose(); + } + + void _onQuickActionTargetLeave() { + _stopLeavingTimer(); + final action = _draggedAvailableAction.value; + _targetLeavingTimer = Timer(Durations.quickActionListAnimation + Duration(milliseconds: 50), () { + _removeQuickAction(action); + _quickActionHighlight.value = false; + }); + } + + @override + Widget build(BuildContext context) { + final header = QuickActionButton( + placement: QuickActionPlacement.header, + panelHighlight: _quickActionHighlight, + draggedQuickAction: _draggedQuickAction, + draggedAvailableAction: _draggedAvailableAction, + insertAction: _insertQuickAction, + removeAction: _removeQuickAction, + onTargetLeave: _onQuickActionTargetLeave, + ); + final footer = QuickActionButton( + placement: QuickActionPlacement.footer, + panelHighlight: _quickActionHighlight, + draggedQuickAction: _draggedQuickAction, + draggedAvailableAction: _draggedAvailableAction, + insertAction: _insertQuickAction, + removeAction: _removeQuickAction, + onTargetLeave: _onQuickActionTargetLeave, + ); + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsViewerQuickActionEditorTitle), + ), + body: WillPopScope( + onWillPop: () { + settings.viewerQuickActions = _quickActions; + return SynchronousFuture(true); + }, + child: SafeArea( + child: ListView( + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + children: [ + Icon(AIcons.info), + SizedBox(width: 16), + Expanded(child: Text(context.l10n.settingsViewerQuickActionEditorBanner)), + ], + ), + ), + Divider(), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + context.l10n.settingsViewerQuickActionEditorDisplayedButtons, + style: Constants.titleTextStyle, + ), + ), + ValueListenableBuilder( + valueListenable: _quickActionHighlight, + builder: (context, highlight, child) => ActionPanel( + highlight: highlight, + child: child, + ), + child: Container( + height: OverlayButton.getSize(context) + quickActionVerticalPadding * 2, + child: Stack( + children: [ + Positioned.fill( + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: .5, + child: header, + ), + ), + Positioned.fill( + child: FractionallySizedBox( + alignment: Alignment.centerRight, + widthFactor: .5, + child: footer, + ), + ), + Container( + alignment: Alignment.center, + child: AnimatedList( + key: _animatedListKey, + initialItemCount: _quickActions.length, + scrollDirection: Axis.horizontal, + shrinkWrap: true, + padding: EdgeInsets.zero, + itemBuilder: (context, index, animation) { + if (index >= _quickActions.length) return null; + final action = _quickActions[index]; + return QuickActionButton( + placement: QuickActionPlacement.action, + action: action, + panelHighlight: _quickActionHighlight, + draggedQuickAction: _draggedQuickAction, + draggedAvailableAction: _draggedAvailableAction, + insertAction: _insertQuickAction, + removeAction: _removeQuickAction, + onTargetLeave: _onQuickActionTargetLeave, + child: _buildQuickActionButton(action, animation), + ); + }, + ), + ), + AnimatedBuilder( + animation: _quickActionsChangeNotifier, + builder: (context, child) => _quickActions.isEmpty + ? Center( + child: Text( + context.l10n.settingsViewerQuickActionEmpty, + style: Theme.of(context).textTheme.caption, + ), + ) + : SizedBox(), + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + context.l10n.settingsViewerQuickActionEditorAvailableButtons, + style: Constants.titleTextStyle, + ), + ), + ValueListenableBuilder( + valueListenable: _availableActionHighlight, + builder: (context, highlight, child) => ActionPanel( + highlight: highlight, + child: child, + ), + child: AvailableActionPanel( + quickActions: _quickActions, + quickActionsChangeNotifier: _quickActionsChangeNotifier, + panelHighlight: _availableActionHighlight, + draggedQuickAction: _draggedQuickAction, + draggedAvailableAction: _draggedAvailableAction, + removeQuickAction: _removeQuickAction, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _stopLeavingTimer() => _targetLeavingTimer?.cancel(); + + bool _insertQuickAction(EntryAction action, QuickActionPlacement placement, EntryAction overAction) { + if (action == null) return false; + _stopLeavingTimer(); + if (_reordering) return false; + + final currentIndex = _quickActions.indexOf(action); + final contained = currentIndex != -1; + int targetIndex; + switch (placement) { + case QuickActionPlacement.header: + targetIndex = 0; + break; + case QuickActionPlacement.footer: + targetIndex = _quickActions.length - (contained ? 1 : 0); + break; + case QuickActionPlacement.action: + targetIndex = _quickActions.indexOf(overAction); + break; + } + if (currentIndex == targetIndex) return false; + + _reordering = true; + _removeQuickAction(action); + _quickActions.insert(targetIndex, action); + _animatedListKey.currentState.insertItem( + targetIndex, + duration: Durations.quickActionListAnimation, + ); + _quickActionsChangeNotifier.notifyListeners(); + Future.delayed(Durations.quickActionListAnimation).then((value) => _reordering = false); + return true; + } + + bool _removeQuickAction(EntryAction action) { + if (!_quickActions.contains(action)) return false; + + final index = _quickActions.indexOf(action); + _quickActions.removeAt(index); + _animatedListKey.currentState.removeItem( + index, + (context, animation) => DraggedPlaceholder(child: _buildQuickActionButton(action, animation)), + duration: Durations.quickActionListAnimation, + ); + _quickActionsChangeNotifier.notifyListeners(); + return true; + } + + Widget _buildQuickActionButton(EntryAction action, Animation animation) { + animation = animation.drive(CurveTween(curve: Curves.easeInOut)); + Widget child = FadeTransition( + opacity: animation, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: Padding( + padding: EdgeInsets.symmetric(vertical: _QuickActionEditorPageState.quickActionVerticalPadding, horizontal: 4), + child: OverlayButton( + child: IconButton( + icon: Icon(action.getIcon()), + onPressed: () {}, + ), + ), + ), + ), + ); + + child = AnimatedBuilder( + animation: Listenable.merge([_draggedQuickAction, _draggedAvailableAction]), + builder: (context, child) { + final dragged = _draggedQuickAction.value == action || _draggedAvailableAction.value == action; + if (dragged) { + child = DraggedPlaceholder(child: child); + } + return child; + }, + child: child, + ); + + return child; + } +} diff --git a/lib/widgets/settings/quick_actions/quick_actions.dart b/lib/widgets/settings/quick_actions/quick_actions.dart new file mode 100644 index 000000000..c0c6aa2d7 --- /dev/null +++ b/lib/widgets/settings/quick_actions/quick_actions.dart @@ -0,0 +1,80 @@ +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/settings/quick_actions/common.dart'; +import 'package:flutter/material.dart'; + +enum QuickActionPlacement { header, action, footer } + +class QuickActionButton extends StatelessWidget { + final QuickActionPlacement placement; + final EntryAction action; + final ValueNotifier panelHighlight; + final ValueNotifier draggedQuickAction; + final ValueNotifier draggedAvailableAction; + final bool Function(EntryAction action, QuickActionPlacement placement, EntryAction overAction) insertAction; + final bool Function(EntryAction action) removeAction; + final VoidCallback onTargetLeave; + final Widget child; + + const QuickActionButton({ + @required this.placement, + this.action, + @required this.panelHighlight, + @required this.draggedQuickAction, + @required this.draggedAvailableAction, + @required this.insertAction, + @required this.removeAction, + @required this.onTargetLeave, + this.child, + }); + + @override + Widget build(BuildContext context) { + var child = this.child; + child = _buildDragTarget(child); + if (action != null) { + child = _buildDraggable(child); + } + return child; + } + + DragTarget _buildDragTarget(Widget child) { + return DragTarget( + onWillAccept: (data) { + if (draggedQuickAction.value != null) { + insertAction(draggedQuickAction.value, placement, action); + } + if (draggedAvailableAction.value != null) { + insertAction(draggedAvailableAction.value, placement, action); + _setPanelHighlight(true); + } + return true; + }, + onAcceptWithDetails: (details) => _setPanelHighlight(false), + onLeave: (data) => onTargetLeave(), + builder: (context, accepted, rejected) => child, + ); + } + + Widget _buildDraggable(Widget child) => LongPressDraggable( + data: action, + maxSimultaneousDrags: 1, + onDragStarted: () => _setDraggedQuickAction(action), + // `onDragEnd` is only called when the widget is mounted, + // so we rely on `onDraggableCanceled` and `onDragCompleted` instead + onDraggableCanceled: (velocity, offset) => _setDraggedQuickAction(null), + onDragCompleted: () => _setDraggedQuickAction(null), + feedback: MediaQueryDataProvider( + child: ActionButton( + action: action, + showCaption: false, + ), + ), + childWhenDragging: child, + child: child, + ); + + void _setDraggedQuickAction(EntryAction action) => draggedQuickAction.value = action; + + void _setPanelHighlight(bool flag) => panelHighlight.value = flag; +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 53f00356b..17ace382b 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -18,6 +18,7 @@ import 'package:aves/widgets/settings/access_grants.dart'; import 'package:aves/widgets/settings/entry_background.dart'; import 'package:aves/widgets/settings/hidden_filters.dart'; import 'package:aves/widgets/settings/language.dart'; +import 'package:aves/widgets/settings/quick_actions/editor.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; @@ -131,8 +132,8 @@ class _SettingsPageState extends State { } Widget _buildThumbnailsSection(BuildContext context) { - final iconColor = IconTheme.of(context).color; - Color iconColorFor(bool enabled) => iconColor.withOpacity(enabled ? 1 : .12); + final iconSize = IconTheme.of(context).size * MediaQuery.of(context).textScaleFactor; + double opacityFor(bool enabled) => enabled ? 1 : .2; return AvesExpansionTile( leading: _buildLeading(AIcons.grid, stringToColor('Thumbnails')), title: context.l10n.settingsSectionThumbnails, @@ -145,9 +146,13 @@ class _SettingsPageState extends State { title: Row( children: [ Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), - Icon( - AIcons.location, - color: iconColorFor(settings.showThumbnailLocation), + AnimatedOpacity( + opacity: opacityFor(settings.showThumbnailLocation), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.location, + size: iconSize, + ), ), ], ), @@ -158,9 +163,13 @@ class _SettingsPageState extends State { title: Row( children: [ Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), - Icon( - AIcons.raw, - color: iconColorFor(settings.showThumbnailRaw), + AnimatedOpacity( + opacity: opacityFor(settings.showThumbnailRaw), + duration: Durations.toggleableTransitionAnimation, + child: Icon( + AIcons.raw, + size: iconSize, + ), ), ], ), @@ -181,20 +190,7 @@ class _SettingsPageState extends State { expandedNotifier: _expandedNotifier, showHighlight: false, children: [ - ListTile( - title: Text(context.l10n.settingsRasterImageBackground), - trailing: EntryBackgroundSelector( - getter: () => settings.rasterBackground, - setter: (value) => settings.rasterBackground = value, - ), - ), - ListTile( - title: Text(context.l10n.settingsVectorImageBackground), - trailing: EntryBackgroundSelector( - getter: () => settings.vectorBackground, - setter: (value) => settings.vectorBackground = value, - ), - ), + QuickActionsTile(), SwitchListTile( value: settings.showOverlayMinimap, onChanged: (v) => settings.showOverlayMinimap = v, @@ -211,6 +207,20 @@ class _SettingsPageState extends State { onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null, title: Text(context.l10n.settingsViewerShowShootingDetails), ), + ListTile( + title: Text(context.l10n.settingsRasterImageBackground), + trailing: EntryBackgroundSelector( + getter: () => settings.rasterBackground, + setter: (value) => settings.rasterBackground = value, + ), + ), + ListTile( + title: Text(context.l10n.settingsVectorImageBackground), + trailing: EntryBackgroundSelector( + getter: () => settings.vectorBackground, + setter: (value) => settings.vectorBackground = value, + ), + ), ], ); } @@ -263,6 +273,9 @@ class _SettingsPageState extends State { Widget _buildLanguageSection(BuildContext context) { return AvesExpansionTile( + // use a fixed value instead of the title to identify this expansion tile + // so that the tile state is kept when the language is modified + value: 'language', leading: _buildLeading(AIcons.language, stringToColor('Language')), title: context.l10n.settingsSectionLanguage, expandedNotifier: _expandedNotifier, diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart index 3a7ccc699..efc307b0c 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -17,7 +17,7 @@ import 'package:flutter/scheduler.dart'; class MapDecorator extends StatelessWidget { final Widget child; - static const BorderRadius mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles + static final BorderRadius mapBorderRadius = BorderRadius.circular(24); // to match button circles const MapDecorator({@required this.child}); @@ -90,7 +90,7 @@ class MapButtonPanel extends StatelessWidget { ); }, ); - // wait for the dialog to hide because switching to Google Maps layer may block the UI + // wait for the dialog to hide as applying the change may block the UI await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (style != null && style != settings.infoMapStyle) { settings.infoMapStyle = style; diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index da559fd63..e2bcdd9d6 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -33,6 +33,9 @@ class OverlayButton extends StatelessWidget { ), ); } + + // icon (24) + icon padding (8) + button padding (16) + border (2) + static double getSize(BuildContext context) => 50.0; } class OverlayTextButton extends StatelessWidget { @@ -66,7 +69,7 @@ class OverlayTextButton extends StatelessWidget { minimumSize: _minSize, side: MaterialStateProperty.all(AvesCircleBorder.buildSide(context)), shape: MaterialStateProperty.all(RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), + borderRadius: BorderRadius.circular(_borderRadius), )), // shape: MaterialStateProperty.all(CircleBorder()), ), diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 8a4b4558f..08f4631c0 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; @@ -17,7 +15,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class ViewerTopOverlay extends StatelessWidget { final AvesEntry entry; @@ -30,10 +27,6 @@ class ViewerTopOverlay extends StatelessWidget { static const double padding = 8; - static const int landscapeActionCount = 3; - - static const int portraitActionCount = 2; - const ViewerTopOverlay({ Key key, @required this.entry, @@ -52,21 +45,11 @@ class ViewerTopOverlay extends StatelessWidget { minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero), child: Padding( padding: EdgeInsets.all(padding), - child: Selector>( - selector: (c, mq) => Tuple2(mq.size.width, mq.orientation), - builder: (c, mq, child) { - final mqWidth = mq.item1; - final mqOrientation = mq.item2; - - final targetCount = mqOrientation == Orientation.landscape ? landscapeActionCount : portraitActionCount; - final availableCount = (mqWidth / (kMinInteractiveDimension + padding)).floor() - 2; - final quickActionCount = min(targetCount, availableCount); - - final quickActions = [ - EntryAction.toggleFavourite, - EntryAction.share, - EntryAction.delete, - ].where(_canDo).take(quickActionCount).toList(); + child: Selector( + selector: (c, mq) => mq.size.width - mq.padding.horizontal, + builder: (c, mqWidth, child) { + final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2; + final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList(); final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); final buttonRow = _TopOverlayRow(