#26 viewer: custom quick actions

This commit is contained in:
Thibault Deckers 2021-03-31 09:45:08 +09:00
parent 9d2954d717
commit f7ced5832a
25 changed files with 706 additions and 70 deletions

View file

@ -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 - Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
- Albums: localized common album names - Albums: localized common album names
- Collection: select shortcut icon image - Collection: select shortcut icon image
- Viewer: customizable quick actions
### Changed ### Changed
- Upgraded Flutter to beta v2.1.0-12.2.pre - Upgraded Flutter to beta v2.1.0-12.2.pre

View file

@ -525,6 +525,19 @@
"settingsViewerShowShootingDetails": "Show shooting details", "settingsViewerShowShootingDetails": "Show shooting details",
"@settingsViewerShowShootingDetails": {}, "@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": "Video",
"@settingsSectionVideo": {}, "@settingsSectionVideo": {},
"settingsVideoShowVideos": "Show videos", "settingsVideoShowVideos": "Show videos",

View file

@ -243,6 +243,13 @@
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시", "settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerQuickActionsTile": "빠른 작업",
"settingsViewerQuickActionEditorTitle": "빠른 작업",
"settingsViewerQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 뷰어에 표시될 버튼을 선택하세요.",
"settingsViewerQuickActionEditorDisplayedButtons": "표시될 버튼",
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
"settingsSectionVideo": "동영상", "settingsSectionVideo": "동영상",
"settingsVideoShowVideos": "미디어에 동영상 표시", "settingsVideoShowVideos": "미디어에 동영상 표시",

View file

@ -1,3 +1,4 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
@ -46,6 +47,7 @@ class Settings extends ChangeNotifier {
static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayMinimapKey = 'show_overlay_minimap';
static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayInfoKey = 'show_overlay_info';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const viewerQuickActionsKey = 'viewer_quick_actions';
// info // info
static const infoMapStyleKey = 'info_map_style'; static const infoMapStyleKey = 'info_map_style';
@ -63,6 +65,12 @@ class Settings extends ChangeNotifier {
// version // version
static const lastVersionCheckDateKey = 'last_version_check_date'; static const lastVersionCheckDateKey = 'last_version_check_date';
// defaults
static const viewerQuickActionsDefault = [
EntryAction.toggleFavourite,
EntryAction.share,
];
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
} }
@ -211,6 +219,10 @@ class Settings extends ChangeNotifier {
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
// info // info
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values); EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
@ -258,16 +270,16 @@ class Settings extends ChangeNotifier {
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) { T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {
final valueString = _prefs.getString(key); final valueString = _prefs.getString(key);
for (final element in values) { for (final v in values) {
if (element.toString() == valueString) { if (v.toString() == valueString) {
return element; return v;
} }
} }
return defaultValue; return defaultValue;
} }
List<T> getEnumListOrDefault<T>(String key, List<T> defaultValue, Iterable<T> values) { List<T> getEnumListOrDefault<T>(String key, List<T> defaultValue, Iterable<T> 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}) { void setAndNotify(String key, dynamic newValue, {bool notify = true}) {

View file

@ -1,12 +1,15 @@
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
class Durations { 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 // common animations
static const iconAnimation = Duration(milliseconds: 300); static const iconAnimation = Duration(milliseconds: 300);
static const sweeperOpacityAnimation = Duration(milliseconds: 150); static const sweeperOpacityAnimation = Duration(milliseconds: 150);
static const sweepingAnimation = Duration(milliseconds: 650); 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 staggeredAnimation = Duration(milliseconds: 375);
static const staggeredAnimationPageTarget = Duration(milliseconds: 900); static const staggeredAnimationPageTarget = Duration(milliseconds: 900);
@ -42,6 +45,10 @@ class Durations {
static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = 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 // delays & refresh intervals
static const opToastDisplay = Duration(seconds: 3); static const opToastDisplay = Duration(seconds: 3);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);

View file

@ -320,6 +320,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
title: context.l10n.collectionGroupTitle, 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) { if (value != null) {
settings.collectionGroupFactor = value; settings.collectionGroupFactor = value;
collection.group(value); collection.group(value);
@ -338,6 +340,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
title: context.l10n.collectionSortTitle, 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) { if (value != null) {
settings.collectionSortFactor = value; settings.collectionSortFactor = value;
collection.sort(value); collection.sort(value);

View file

@ -123,7 +123,7 @@ class ScrollLabel extends StatelessWidget {
child: Material( child: Material(
elevation: 4.0, elevation: 4.0,
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.all(Radius.circular(16.0)), borderRadius: BorderRadius.circular(16),
child: child, child: child,
), ),
), ),

View file

@ -9,7 +9,7 @@ class LinkChip extends StatelessWidget {
final Color color; final Color color;
final TextStyle textStyle; final TextStyle textStyle;
static const borderRadius = BorderRadius.all(Radius.circular(8)); static final borderRadius = BorderRadius.circular(8);
const LinkChip({ const LinkChip({
Key key, Key key,

View file

@ -3,6 +3,7 @@ import 'package:expansion_tile_card/expansion_tile_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AvesExpansionTile extends StatelessWidget { class AvesExpansionTile extends StatelessWidget {
final String value;
final Widget leading; final Widget leading;
final String title; final String title;
final Color color; final Color color;
@ -11,6 +12,7 @@ class AvesExpansionTile extends StatelessWidget {
final List<Widget> children; final List<Widget> children;
const AvesExpansionTile({ const AvesExpansionTile({
String value,
this.leading, this.leading,
@required this.title, @required this.title,
this.color, this.color,
@ -18,7 +20,7 @@ class AvesExpansionTile extends StatelessWidget {
this.initiallyExpanded = false, this.initiallyExpanded = false,
this.showHighlight = true, this.showHighlight = true,
@required this.children, @required this.children,
}); }): value = value ?? title;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -44,8 +46,8 @@ class AvesExpansionTile extends StatelessWidget {
accentColor: Colors.white, accentColor: Colors.white,
), ),
child: ExpansionTileCard( child: ExpansionTileCard(
key: Key('tilecard-$title'), key: Key('tilecard-$value'),
value: title, value: value,
expandedNotifier: expandedNotifier, expandedNotifier: expandedNotifier,
title: titleChild, title: titleChild,
expandable: enabled, expandable: enabled,

View file

@ -44,7 +44,7 @@ class AvesFilterChip extends StatefulWidget {
this.showGenericIcon = true, this.showGenericIcon = true,
this.background, this.background,
this.details, this.details,
this.borderRadius = const BorderRadius.all(Radius.circular(defaultRadius)), this.borderRadius,
this.padding = 6.0, this.padding = 6.0,
this.heroType = HeroType.onTap, this.heroType = HeroType.onTap,
this.onTap, this.onTap,
@ -96,8 +96,6 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
CollectionFilter get filter => widget.filter; CollectionFilter get filter => widget.filter;
BorderRadius get borderRadius => widget.borderRadius;
double get padding => widget.padding; double get padding => widget.padding;
FilterCallback get onTap => widget.onTap; FilterCallback get onTap => widget.onTap;
@ -197,6 +195,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
); );
} }
final borderRadius = widget.borderRadius ?? BorderRadius.circular(AvesFilterChip.defaultRadius);
Widget chip = Container( Widget chip = Container(
constraints: BoxConstraints( constraints: BoxConstraints(
minWidth: AvesFilterChip.minChipWidth, minWidth: AvesFilterChip.minChipWidth,

View file

@ -12,7 +12,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
final scrollThumb = Container( final scrollThumb = Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black26, color: Colors.black26,
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12),
), ),
height: height, height: height,
margin: EdgeInsets.only(right: .5), margin: EdgeInsets.only(right: .5),
@ -23,7 +23,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
width: 20.0, width: 20.0,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),

View file

@ -37,6 +37,7 @@ class DebugSettingsSection extends StatelessWidget {
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}', 'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}', 'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
'infoMapZoom': '${settings.infoMapZoom}', 'infoMapZoom': '${settings.infoMapZoom}',
'viewerQuickActions': '${settings.viewerQuickActions}',
'pinnedFilters': toMultiline(settings.pinnedFilters), 'pinnedFilters': toMultiline(settings.pinnedFilters),
'hiddenFilters': toMultiline(settings.hiddenFilters), 'hiddenFilters': toMultiline(settings.hiddenFilters),
'searchHistory': toMultiline(settings.searchHistory), 'searchHistory': toMultiline(settings.searchHistory),

View file

@ -108,7 +108,7 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
return GestureDetector( return GestureDetector(
onTap: _pickEntry, onTap: _pickEntry,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(32)), borderRadius: BorderRadius.circular(32),
child: SizedBox( child: SizedBox(
width: extent, width: extent,
height: extent, height: extent,

View file

@ -53,11 +53,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog<T>> {
key: Key(value.toString()), key: Key(value.toString()),
value: value, value: value,
groupValue: _selectedValue, groupValue: _selectedValue,
onChanged: (v) { onChanged: (v) => Navigator.pop(context, v),
_selectedValue = v;
Navigator.pop(context, _selectedValue);
setState(() {});
},
reselectable: true, reselectable: true,
title: Text( title: Text(
title, title,

View file

@ -2,10 +2,12 @@ import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart'; import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
abstract class ChipSetActionDelegate { abstract class ChipSetActionDelegate {
@ -39,6 +41,8 @@ abstract class ChipSetActionDelegate {
title: context.l10n.chipSortTitle, 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) { if (factor != null) {
sortFactor = factor; sortFactor = factor;
} }
@ -90,6 +94,8 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
title: context.l10n.albumGroupTitle, 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) { if (factor != null) {
settings.albumGroupFactor = factor; settings.albumGroupFactor = factor;
} }

View file

@ -93,7 +93,7 @@ class DecoratedFilterChip extends StatelessWidget {
); );
final radius = min<double>(AvesFilterChip.defaultRadius, extent / 4); final radius = min<double>(AvesFilterChip.defaultRadius, extent / 4);
final titlePadding = min<double>(4.0, extent / 32); final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(Radius.circular(radius)); final borderRadius = BorderRadius.circular(radius);
Widget child = AvesFilterChip( Widget child = AvesFilterChip(
filter: filter, filter: filter,
showGenericIcon: false, showGenericIcon: false,

View file

@ -1,10 +1,12 @@
import 'dart:collection'; import 'dart:collection';
import 'package:aves/model/settings/settings.dart'; 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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart';
@ -30,6 +32,8 @@ class LanguageTile extends StatelessWidget {
title: context.l10n.settingsLanguage, 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) { if (value != null) {
settings.locale = value == _systemLocaleOption ? null : value; settings.locale = value == _systemLocaleOption ? null : value;
} }

View file

@ -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<EntryAction> quickActions;
final Listenable quickActionsChangeNotifier;
final ValueNotifier<bool> panelHighlight;
final ValueNotifier<EntryAction> draggedQuickAction;
final ValueNotifier<EntryAction> 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<EntryAction>(
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<EntryAction>(
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;
}

View file

@ -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,
);
}
}

View file

@ -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<QuickActionEditorPage> {
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'quick-actions-animated-list');
Timer _targetLeavingTimer;
List<EntryAction> _quickActions;
final ValueNotifier<EntryAction> _draggedQuickAction = ValueNotifier(null);
final ValueNotifier<EntryAction> _draggedAvailableAction = ValueNotifier(null);
final ValueNotifier<bool> _quickActionHighlight = ValueNotifier(false);
final ValueNotifier<bool> _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<bool>(
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<bool>(
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<double> 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;
}
}

View file

@ -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<bool> panelHighlight;
final ValueNotifier<EntryAction> draggedQuickAction;
final ValueNotifier<EntryAction> 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<EntryAction> _buildDragTarget(Widget child) {
return DragTarget<EntryAction>(
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;
}

View file

@ -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/entry_background.dart';
import 'package:aves/widgets/settings/hidden_filters.dart'; import 'package:aves/widgets/settings/hidden_filters.dart';
import 'package:aves/widgets/settings/language.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:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
@ -131,8 +132,8 @@ class _SettingsPageState extends State<SettingsPage> {
} }
Widget _buildThumbnailsSection(BuildContext context) { Widget _buildThumbnailsSection(BuildContext context) {
final iconColor = IconTheme.of(context).color; final iconSize = IconTheme.of(context).size * MediaQuery.of(context).textScaleFactor;
Color iconColorFor(bool enabled) => iconColor.withOpacity(enabled ? 1 : .12); double opacityFor(bool enabled) => enabled ? 1 : .2;
return AvesExpansionTile( return AvesExpansionTile(
leading: _buildLeading(AIcons.grid, stringToColor('Thumbnails')), leading: _buildLeading(AIcons.grid, stringToColor('Thumbnails')),
title: context.l10n.settingsSectionThumbnails, title: context.l10n.settingsSectionThumbnails,
@ -145,9 +146,13 @@ class _SettingsPageState extends State<SettingsPage> {
title: Row( title: Row(
children: [ children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)), Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
Icon( AnimatedOpacity(
AIcons.location, opacity: opacityFor(settings.showThumbnailLocation),
color: iconColorFor(settings.showThumbnailLocation), duration: Durations.toggleableTransitionAnimation,
child: Icon(
AIcons.location,
size: iconSize,
),
), ),
], ],
), ),
@ -158,9 +163,13 @@ class _SettingsPageState extends State<SettingsPage> {
title: Row( title: Row(
children: [ children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)), Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
Icon( AnimatedOpacity(
AIcons.raw, opacity: opacityFor(settings.showThumbnailRaw),
color: iconColorFor(settings.showThumbnailRaw), duration: Durations.toggleableTransitionAnimation,
child: Icon(
AIcons.raw,
size: iconSize,
),
), ),
], ],
), ),
@ -181,20 +190,7 @@ class _SettingsPageState extends State<SettingsPage> {
expandedNotifier: _expandedNotifier, expandedNotifier: _expandedNotifier,
showHighlight: false, showHighlight: false,
children: [ children: [
ListTile( QuickActionsTile(),
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,
),
),
SwitchListTile( SwitchListTile(
value: settings.showOverlayMinimap, value: settings.showOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v, onChanged: (v) => settings.showOverlayMinimap = v,
@ -211,6 +207,20 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null, onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
title: Text(context.l10n.settingsViewerShowShootingDetails), 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<SettingsPage> {
Widget _buildLanguageSection(BuildContext context) { Widget _buildLanguageSection(BuildContext context) {
return AvesExpansionTile( 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')), leading: _buildLeading(AIcons.language, stringToColor('Language')),
title: context.l10n.settingsSectionLanguage, title: context.l10n.settingsSectionLanguage,
expandedNotifier: _expandedNotifier, expandedNotifier: _expandedNotifier,

View file

@ -17,7 +17,7 @@ import 'package:flutter/scheduler.dart';
class MapDecorator extends StatelessWidget { class MapDecorator extends StatelessWidget {
final Widget child; 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}); 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); await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (style != null && style != settings.infoMapStyle) { if (style != null && style != settings.infoMapStyle) {
settings.infoMapStyle = style; settings.infoMapStyle = style;

View file

@ -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 { class OverlayTextButton extends StatelessWidget {
@ -66,7 +69,7 @@ class OverlayTextButton extends StatelessWidget {
minimumSize: _minSize, minimumSize: _minSize,
side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)), side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)),
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder( shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), borderRadius: BorderRadius.circular(_borderRadius),
)), )),
// shape: MaterialStateProperty.all<OutlinedBorder>(CircleBorder()), // shape: MaterialStateProperty.all<OutlinedBorder>(CircleBorder()),
), ),

View file

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
@ -17,7 +15,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ViewerTopOverlay extends StatelessWidget { class ViewerTopOverlay extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
@ -30,10 +27,6 @@ class ViewerTopOverlay extends StatelessWidget {
static const double padding = 8; static const double padding = 8;
static const int landscapeActionCount = 3;
static const int portraitActionCount = 2;
const ViewerTopOverlay({ const ViewerTopOverlay({
Key key, Key key,
@required this.entry, @required this.entry,
@ -52,21 +45,11 @@ class ViewerTopOverlay extends StatelessWidget {
minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero), minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero),
child: Padding( child: Padding(
padding: EdgeInsets.all(padding), padding: EdgeInsets.all(padding),
child: Selector<MediaQueryData, Tuple2<double, Orientation>>( child: Selector<MediaQueryData, double>(
selector: (c, mq) => Tuple2(mq.size.width, mq.orientation), selector: (c, mq) => mq.size.width - mq.padding.horizontal,
builder: (c, mq, child) { builder: (c, mqWidth, child) {
final mqWidth = mq.item1; final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
final mqOrientation = mq.item2; final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
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();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
final buttonRow = _TopOverlayRow( final buttonRow = _TopOverlayRow(