#26 viewer: custom quick actions
This commit is contained in:
parent
9d2954d717
commit
f7ced5832a
25 changed files with 706 additions and 70 deletions
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -243,6 +243,13 @@
|
||||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||||
|
|
||||||
|
"settingsViewerQuickActionsTile": "빠른 작업",
|
||||||
|
"settingsViewerQuickActionEditorTitle": "빠른 작업",
|
||||||
|
"settingsViewerQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 뷰어에 표시될 버튼을 선택하세요.",
|
||||||
|
"settingsViewerQuickActionEditorDisplayedButtons": "표시될 버튼",
|
||||||
|
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
|
||||||
|
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
|
||||||
|
|
||||||
"settingsSectionVideo": "동영상",
|
"settingsSectionVideo": "동영상",
|
||||||
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
||||||
|
|
||||||
|
|
|
@ -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}) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
103
lib/widgets/settings/quick_actions/available_actions.dart
Normal file
103
lib/widgets/settings/quick_actions/available_actions.dart
Normal 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;
|
||||||
|
}
|
95
lib/widgets/settings/quick_actions/common.dart
Normal file
95
lib/widgets/settings/quick_actions/common.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
307
lib/widgets/settings/quick_actions/editor.dart
Normal file
307
lib/widgets/settings/quick_actions/editor.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
80
lib/widgets/settings/quick_actions/quick_actions.dart
Normal file
80
lib/widgets/settings/quick_actions/quick_actions.dart
Normal 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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in a new issue