From 5b495e95e5c103ed9b7d52c54890babd53a5d56a Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 22 Mar 2023 15:54:42 +0100 Subject: [PATCH] #562 sony burst support --- CHANGELOG.md | 1 + lib/l10n/app_en.arb | 3 + lib/model/entry/extensions/multipage.dart | 12 +- lib/model/settings/settings.dart | 14 +- lib/model/source/collection_lens.dart | 21 ++- lib/ref/bursts.dart | 42 +++++ lib/widgets/collection/grid/list_details.dart | 3 +- .../common/action_mixins/entry_storage.dart | 6 +- lib/widgets/common/map/geo_map.dart | 5 +- .../common/map/map_action_delegate.dart | 5 +- .../dialogs/aves_selection_dialog.dart | 154 ------------------ .../filter_editors/edit_vault_dialog.dart | 5 +- .../dialogs/selection_dialogs/common.dart | 23 +++ .../selection_dialogs/multi_selection.dart | 91 +++++++++++ .../selection_dialogs/radio_list_tile.dart | 56 +++++++ .../selection_dialogs/single_selection.dart | 80 +++++++++ .../dialogs/wallpaper_settings_dialog.dart | 5 +- lib/widgets/settings/common/tiles.dart | 65 +++++++- .../settings/thumbnails/thumbnails.dart | 18 ++ untranslated.json | 79 +++++++++ 20 files changed, 507 insertions(+), 181 deletions(-) create mode 100644 lib/ref/bursts.dart delete mode 100644 lib/widgets/dialogs/aves_selection_dialog.dart create mode 100644 lib/widgets/dialogs/selection_dialogs/common.dart create mode 100644 lib/widgets/dialogs/selection_dialogs/multi_selection.dart create mode 100644 lib/widgets/dialogs/selection_dialogs/radio_list_tile.dart create mode 100644 lib/widgets/dialogs/selection_dialogs/single_selection.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3840ef342..c444c0e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Collection: optional support for Samsung and Sony burst patterns - Info: improved state/place display (requires rescan, limited to AU/GB/EN) - improved support for system font scale diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ad6d058b8..0d2a7ab53 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -754,6 +754,9 @@ "settingsCollectionBrowsingQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when browsing items.", "settingsCollectionSelectionQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when selecting items.", + "settingsCollectionBurstPatternsTile": "Burst patterns", + "settingsCollectionBurstPatternsNone": "None", + "settingsViewerSectionTitle": "Viewer", "settingsViewerGestureSideTapNext": "Tap on screen edges to show previous/next item", "settingsViewerUseCutout": "Use cutout area", diff --git a/lib/model/entry/extensions/multipage.dart b/lib/model/entry/extensions/multipage.dart index f77f252b1..977dd1733 100644 --- a/lib/model/entry/extensions/multipage.dart +++ b/lib/model/entry/extensions/multipage.dart @@ -7,8 +7,6 @@ import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; extension ExtraAvesEntryMultipage on AvesEntry { - static final _burstFilenamePattern = RegExp(r'^(\d{8}_\d{6})_(\d+)$'); - bool get isMultiPage => (catalogMetadata?.isMultiPage ?? false) || isBurst; bool get isBurst => burstEntries?.isNotEmpty == true; @@ -18,11 +16,13 @@ extension ExtraAvesEntryMultipage on AvesEntry { bool get isMotionPhoto => (catalogMetadata?.isMotionPhoto ?? false) || _isMotionPhotoLegacy; - String? get burstKey { + String? getBurstKey(List patterns) { if (filenameWithoutExtension != null) { - final match = _burstFilenamePattern.firstMatch(filenameWithoutExtension!); - if (match != null) { - return '$directory/${match.group(1)}'; + for (final pattern in patterns) { + final match = RegExp(pattern).firstMatch(filenameWithoutExtension!); + if (match != null) { + return '$directory/${match.group(1)}'; + } } } return null; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index ea63e38e3..88061781f 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -13,8 +13,8 @@ import 'package:aves/model/settings/defaults.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/ref/bursts.dart'; import 'package:aves/services/accessibility_service.dart'; -import 'package:aves_utils/aves_utils.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/common/search/page.dart'; @@ -23,7 +23,9 @@ import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/places_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves_map/aves_map.dart'; +import 'package:aves_utils/aves_utils.dart'; import 'package:collection/collection.dart'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; @@ -90,6 +92,7 @@ class Settings extends ChangeNotifier { static const drawerPageBookmarksKey = 'drawer_page_bookmarks'; // collection + static const collectionBurstPatternsKey = 'collection_burst_patterns'; static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionSortReverseKey = 'collection_sort_reverse'; @@ -245,6 +248,10 @@ class Settings extends ChangeNotifier { final performanceClass = await deviceService.getPerformanceClass(); enableBlurEffect = performanceClass >= 29; + final androidInfo = await DeviceInfoPlugin().androidInfo; + final pattern = BurstPatterns.byManufacturer[androidInfo.manufacturer]; + collectionBurstPatterns = pattern != null ? [pattern] : []; + // availability if (flavor.hasMapStyleDefault) { final defaultMapStyle = mobileServices.defaultMapStyle; @@ -495,6 +502,10 @@ class Settings extends ChangeNotifier { // collection + List get collectionBurstPatterns => getStringList(collectionBurstPatternsKey) ?? []; + + set collectionBurstPatterns(List newValue) => _set(collectionBurstPatternsKey, newValue); + EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, SettingsDefaults.collectionSectionFactor, EntryGroupFactor.values); set collectionSectionFactor(EntryGroupFactor newValue) => _set(collectionGroupFactorKey, newValue.toString()); @@ -1152,6 +1163,7 @@ class Settings extends ChangeNotifier { case drawerTypeBookmarksKey: case drawerAlbumBookmarksKey: case drawerPageBookmarksKey: + case collectionBurstPatternsKey: case pinnedFiltersKey: case hiddenFiltersKey: case collectionBrowsingQuickActionsKey: diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index a117a8fdc..38b9895ef 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -28,6 +28,7 @@ import 'package:flutter/foundation.dart'; class CollectionLens with ChangeNotifier { final CollectionSource source; final Set filters; + List burstPatterns; EntryGroupFactor sectionFactor; EntrySortFactor sortFactor; bool sortReverse; @@ -50,6 +51,7 @@ class CollectionLens with ChangeNotifier { this.fixedSort = false, this.fixedSelection, }) : filters = (filters ?? {}).whereNotNull().toSet(), + burstPatterns = settings.collectionBurstPatterns, sectionFactor = settings.collectionSectionFactor, sortFactor = settings.collectionSortFactor, sortReverse = settings.collectionSortReverse { @@ -85,6 +87,7 @@ class CollectionLens with ChangeNotifier { } _subscriptions.add(settings.updateStream .where((event) => [ + Settings.collectionBurstPatternsKey, Settings.collectionSortFactorKey, Settings.collectionGroupFactorKey, Settings.collectionSortReverseKey, @@ -188,7 +191,7 @@ class CollectionLens with ChangeNotifier { } void _groupBursts() { - final byBurstKey = groupBy(_filteredSortedEntries, (entry) => entry.burstKey).whereNotNullKey(); + final byBurstKey = groupBy(_filteredSortedEntries, (entry) => entry.getBurstKey(burstPatterns)).whereNotNullKey(); byBurstKey.forEach((burstKey, entries) { if (entries.length > 1) { entries.sort(AvesEntrySort.compareByName); @@ -287,13 +290,19 @@ class CollectionLens with ChangeNotifier { } void _onSettingsChanged() { + final newBurstPatterns = settings.collectionBurstPatterns; final newSortFactor = settings.collectionSortFactor; final newSectionFactor = settings.collectionSectionFactor; final newSortReverse = settings.collectionSortReverse; - final needSort = sortFactor != newSortFactor || sortReverse != newSortReverse; + final needFilter = burstPatterns != newBurstPatterns; + final needSort = needFilter || sortFactor != newSortFactor || sortReverse != newSortReverse; final needSection = needSort || sectionFactor != newSectionFactor; + if (needFilter) { + burstPatterns = newBurstPatterns; + _applyFilters(); + } if (needSort) { sortFactor = newSortFactor; sortReverse = newSortReverse; @@ -303,6 +312,10 @@ class CollectionLens with ChangeNotifier { sectionFactor = newSectionFactor; _applySection(); } + + if (needFilter) { + filterChangeNotifier.notifyListeners(); + } if (needSort || needSection) { sortSectionChangeNotifier.notifyListeners(); } @@ -316,9 +329,9 @@ class CollectionLens with ChangeNotifier { if (groupBursts) { // find impacted burst groups final obsoleteBurstEntries = {}; - final burstKeys = entries.map((entry) => entry.burstKey).whereNotNull().toSet(); + final burstKeys = entries.map((entry) => entry.getBurstKey(burstPatterns)).whereNotNull().toSet(); if (burstKeys.isNotEmpty) { - _filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.burstKey)).forEach((mainEntry) { + _filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.getBurstKey(burstPatterns))).forEach((mainEntry) { final subEntries = mainEntry.burstEntries!; // remove the deleted sub-entries subEntries.removeWhere(entries.contains); diff --git a/lib/ref/bursts.dart b/lib/ref/bursts.dart new file mode 100644 index 000000000..727506b8c --- /dev/null +++ b/lib/ref/bursts.dart @@ -0,0 +1,42 @@ +class BurstPatterns { + static const samsung = r'^(\d{8}_\d{6})_(\d+)$'; + static const sony = r'^DSC_\d+_BURST(\d{17})(_COVER)?$'; + + static final options = [ + BurstPatterns.samsung, + BurstPatterns.sony, + ]; + + static String getName(String pattern) { + switch (pattern) { + case samsung: + return 'Samsung'; + case sony: + return 'Sony'; + default: + return pattern; + } + } + + static String getExample(String pattern) { + switch (pattern) { + case samsung: + return '20151021_072800_007'; + case sony: + return 'DSC_0007_BURST20151021072800123'; + default: + return '?'; + } + } + + static const byManufacturer = { + _Manufacturers.samsung: samsung, + _Manufacturers.sony: sony, + }; +} + +// values as returned by `DeviceInfoPlugin().androidInfo` +class _Manufacturers { + static const samsung = 'samsung'; + static const sony = 'sony'; +} diff --git a/lib/widgets/collection/grid/list_details.dart b/lib/widgets/collection/grid/list_details.dart index 456ffecc1..468224e9d 100644 --- a/lib/widgets/collection/grid/list_details.dart +++ b/lib/widgets/collection/grid/list_details.dart @@ -4,6 +4,7 @@ import 'package:aves/model/settings/enums/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/collection/grid/list_details_theme.dart'; @@ -80,7 +81,7 @@ class EntryListDetails extends StatelessWidget { final date = entry.bestDate; final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; - final size = entry.sizeBytes; + final size = entry.burstEntries?.map((v) => v.sizeBytes).sum ?? entry.sizeBytes; final sizeText = size != null ? formatFileSize(locale, size) : Constants.overlayUnknown; return _buildRow( diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 768a2d085..3ac7a3e63 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -29,9 +29,9 @@ import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/convert_entry_dialog.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -172,13 +172,13 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { if (uniqueNames.length < names.length) { final value = await showDialog( context: context, - builder: (context) => AvesSelectionDialog( + builder: (context) => AvesSingleSelectionDialog( initialValue: nameConflictStrategy, options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), message: originAlbums.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, confirmationButtonLabel: l10n.continueButtonLabel, ), - routeSettings: const RouteSettings(name: AvesSelectionDialog.routeName), + routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName), ); if (value == null) return; nameConflictStrategy = value; diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 0a2e8691e..0838516a5 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -21,7 +21,8 @@ import 'package:aves/widgets/common/map/buttons/panel.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/leaflet/map.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:aves_map/aves_map.dart'; import 'package:aves_utils/aves_utils.dart'; import 'package:collection/collection.dart'; @@ -212,7 +213,7 @@ class _GeoMapState extends State { child: OverlayTextButton( onPressed: () => showSelectionDialog( context: context, - builder: (context) => AvesSelectionDialog( + builder: (context) => AvesSingleSelectionDialog( initialValue: settings.mapStyle, options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))), title: context.l10n.mapStyleDialogTitle, diff --git a/lib/widgets/common/map/map_action_delegate.dart b/lib/widgets/common/map/map_action_delegate.dart index a41c840d5..1b323d2d9 100644 --- a/lib/widgets/common/map/map_action_delegate.dart +++ b/lib/widgets/common/map/map_action_delegate.dart @@ -3,7 +3,8 @@ import 'package:aves/model/settings/enums/l10n.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:aves_map/aves_map.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -18,7 +19,7 @@ class MapActionDelegate { case MapAction.selectStyle: showSelectionDialog( context: context, - builder: (context) => AvesSelectionDialog( + builder: (context) => AvesSingleSelectionDialog( initialValue: settings.mapStyle, options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))), title: context.l10n.mapStyleDialogTitle, diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart deleted file mode 100644 index 068ef5dd6..000000000 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/basic/list_tiles/reselectable_radio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; - -import 'aves_dialog.dart'; - -Future showSelectionDialog({ - required BuildContext context, - required WidgetBuilder builder, - required void Function(T value) onSelection, -}) async { - final value = await showDialog( - context: context, - builder: builder, - routeSettings: const RouteSettings(name: AvesSelectionDialog.routeName), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - onSelection(value); - } -} - -typedef TextBuilder = String Function(T value); - -class AvesSelectionDialog extends StatefulWidget { - static const routeName = '/dialog/selection'; - - final T initialValue; - final Map options; - final TextBuilder? optionSubtitleBuilder; - final String? title, message, confirmationButtonLabel; - final bool? dense; - - const AvesSelectionDialog({ - super.key, - required this.initialValue, - required this.options, - this.optionSubtitleBuilder, - this.title, - this.message, - this.confirmationButtonLabel, - this.dense, - }); - - @override - State> createState() => _AvesSelectionDialogState(); -} - -class _AvesSelectionDialogState extends State> { - late T _selectedValue; - - @override - void initState() { - super.initState(); - _selectedValue = widget.initialValue; - } - - @override - Widget build(BuildContext context) { - final title = widget.title; - final message = widget.message; - final verticalPadding = (title == null && message == null) ? AvesDialog.cornerRadius.y / 2 : .0; - final confirmationButtonLabel = widget.confirmationButtonLabel; - final needConfirmation = confirmationButtonLabel != null; - return AvesDialog( - title: title, - scrollableContent: [ - if (verticalPadding != 0) SizedBox(height: verticalPadding), - if (message != null) - Padding( - padding: const EdgeInsets.all(16), - child: Text(message), - ), - ...widget.options.entries.map((kv) { - final radioValue = kv.key; - final radioTitle = kv.value; - return SelectionRadioListTile( - value: radioValue, - title: radioTitle, - optionSubtitleBuilder: widget.optionSubtitleBuilder, - needConfirmation: needConfirmation, - dense: widget.dense, - getGroupValue: () => _selectedValue, - setGroupValue: (v) => setState(() => _selectedValue = v), - ); - }), - if (verticalPadding != 0) SizedBox(height: verticalPadding), - ], - actions: [ - const CancelButton(), - if (needConfirmation) - TextButton( - onPressed: () => Navigator.maybeOf(context)?.pop(_selectedValue), - child: Text(confirmationButtonLabel), - ), - ], - ); - } -} - -class SelectionRadioListTile extends StatelessWidget { - final T value; - final String title; - final TextBuilder? optionSubtitleBuilder; - final bool needConfirmation; - final bool? dense; - final T Function() getGroupValue; - final void Function(T value) setGroupValue; - - const SelectionRadioListTile({ - super.key, - required this.value, - required this.title, - this.optionSubtitleBuilder, - required this.needConfirmation, - this.dense, - required this.getGroupValue, - required this.setGroupValue, - }); - - @override - Widget build(BuildContext context) { - final subtitle = optionSubtitleBuilder?.call(value); - return ReselectableRadioListTile( - // key is expected by test driver - key: Key('$value'), - value: value, - groupValue: getGroupValue(), - onChanged: (v) { - if (needConfirmation) { - setGroupValue(v as T); - } else { - Navigator.maybeOf(context)?.pop(v); - } - }, - reselectable: true, - title: Text( - title, - overflow: TextOverflow.ellipsis, - maxLines: 2, - ), - subtitle: subtitle != null - ? Text( - subtitle, - softWrap: false, - overflow: TextOverflow.fade, - ) - : null, - dense: dense, - ); - } -} diff --git a/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart b/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart index 165a8c055..b09784522 100644 --- a/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart @@ -9,7 +9,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_caption.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -105,7 +106,7 @@ class _EditVaultDialogState extends State { _unfocus(); showSelectionDialog( context: context, - builder: (context) => AvesSelectionDialog( + builder: (context) => AvesSingleSelectionDialog( initialValue: _lockType, options: Map.fromEntries(_lockTypeOptions.map((v) => MapEntry(v, v.getText(context)))), ), diff --git a/lib/widgets/dialogs/selection_dialogs/common.dart b/lib/widgets/dialogs/selection_dialogs/common.dart new file mode 100644 index 000000000..4a827e56f --- /dev/null +++ b/lib/widgets/dialogs/selection_dialogs/common.dart @@ -0,0 +1,23 @@ +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +Future showSelectionDialog({ + required BuildContext context, + required WidgetBuilder builder, + required void Function(T value) onSelection, +}) async { + final value = await showDialog( + context: context, + builder: builder, + routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName), + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (value != null) { + onSelection(value); + } +} + +typedef TextBuilder = String Function(T value); diff --git a/lib/widgets/dialogs/selection_dialogs/multi_selection.dart b/lib/widgets/dialogs/selection_dialogs/multi_selection.dart new file mode 100644 index 000000000..f31b7e364 --- /dev/null +++ b/lib/widgets/dialogs/selection_dialogs/multi_selection.dart @@ -0,0 +1,91 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:flutter/material.dart'; + +class AvesMultiSelectionDialog extends StatefulWidget { + static const routeName = '/dialog/multi_selection'; + + final Set initialValue; + final Map options; + final TextBuilder? optionSubtitleBuilder; + final String? title, message; + final bool? dense; + + const AvesMultiSelectionDialog({ + super.key, + required this.initialValue, + required this.options, + this.optionSubtitleBuilder, + this.title, + this.message, + this.dense, + }); + + @override + State> createState() => _AvesMultiSelectionDialogState(); +} + +class _AvesMultiSelectionDialogState extends State> { + late Set _selectedValues; + + @override + void initState() { + super.initState(); + _selectedValues = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + final title = widget.title; + final message = widget.message; + final verticalPadding = (title == null && message == null) ? AvesDialog.cornerRadius.y / 2 : .0; + return AvesDialog( + title: title, + scrollableContent: [ + if (verticalPadding != 0) SizedBox(height: verticalPadding), + if (message != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(message), + ), + ...widget.options.entries.map((kv) { + final value = kv.key; + final title = kv.value; + final subtitle = widget.optionSubtitleBuilder?.call(value); + return SwitchListTile( + value: _selectedValues.contains(value), + onChanged: (v) { + if (v) { + _selectedValues.add(value); + } else { + _selectedValues.remove(value); + } + setState(() {}); + }, + title: Align( + alignment: Alignment.centerLeft, + child: Text(title), + ), + subtitle: subtitle != null + ? Text( + subtitle, + softWrap: false, + overflow: TextOverflow.fade, + ) + : null, + dense: widget.dense, + ); + }), + if (verticalPadding != 0) SizedBox(height: verticalPadding), + ], + actions: [ + const CancelButton(), + TextButton( + onPressed: () => Navigator.maybeOf(context)?.pop(widget.options.keys.where(_selectedValues.contains).toList()), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/selection_dialogs/radio_list_tile.dart b/lib/widgets/dialogs/selection_dialogs/radio_list_tile.dart new file mode 100644 index 000000000..5ef9af327 --- /dev/null +++ b/lib/widgets/dialogs/selection_dialogs/radio_list_tile.dart @@ -0,0 +1,56 @@ +import 'package:aves/widgets/common/basic/list_tiles/reselectable_radio.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:flutter/material.dart'; + +class SelectionRadioListTile extends StatelessWidget { + final T value; + final String title; + final TextBuilder? optionSubtitleBuilder; + final bool needConfirmation; + final bool? dense; + final T Function() getGroupValue; + final void Function(T value) setGroupValue; + + const SelectionRadioListTile({ + super.key, + required this.value, + required this.title, + this.optionSubtitleBuilder, + required this.needConfirmation, + this.dense, + required this.getGroupValue, + required this.setGroupValue, + }); + + @override + Widget build(BuildContext context) { + final subtitle = optionSubtitleBuilder?.call(value); + return ReselectableRadioListTile( + // key is expected by test driver + key: Key('$value'), + value: value, + groupValue: getGroupValue(), + onChanged: (v) { + if (needConfirmation) { + setGroupValue(v as T); + } else { + Navigator.maybeOf(context)?.pop(v); + } + }, + reselectable: true, + title: Text( + title, + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + subtitle: subtitle != null + ? Text( + subtitle, + softWrap: false, + overflow: TextOverflow.fade, + ) + : null, + dense: dense, + ); + } +} diff --git a/lib/widgets/dialogs/selection_dialogs/single_selection.dart b/lib/widgets/dialogs/selection_dialogs/single_selection.dart new file mode 100644 index 000000000..b9970cda4 --- /dev/null +++ b/lib/widgets/dialogs/selection_dialogs/single_selection.dart @@ -0,0 +1,80 @@ +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/radio_list_tile.dart'; +import 'package:flutter/material.dart'; + +class AvesSingleSelectionDialog extends StatefulWidget { + static const routeName = '/dialog/selection'; + + final T initialValue; + final Map options; + final TextBuilder? optionSubtitleBuilder; + final String? title, message, confirmationButtonLabel; + final bool? dense; + + const AvesSingleSelectionDialog({ + super.key, + required this.initialValue, + required this.options, + this.optionSubtitleBuilder, + this.title, + this.message, + this.confirmationButtonLabel, + this.dense, + }); + + @override + State> createState() => _AvesSingleSelectionDialogState(); +} + +class _AvesSingleSelectionDialogState extends State> { + late T _selectedValue; + + @override + void initState() { + super.initState(); + _selectedValue = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + final title = widget.title; + final message = widget.message; + final verticalPadding = (title == null && message == null) ? AvesDialog.cornerRadius.y / 2 : .0; + final confirmationButtonLabel = widget.confirmationButtonLabel; + final needConfirmation = confirmationButtonLabel != null; + return AvesDialog( + title: title, + scrollableContent: [ + if (verticalPadding != 0) SizedBox(height: verticalPadding), + if (message != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(message), + ), + ...widget.options.entries.map((kv) { + final radioValue = kv.key; + final radioTitle = kv.value; + return SelectionRadioListTile( + value: radioValue, + title: radioTitle, + optionSubtitleBuilder: widget.optionSubtitleBuilder, + needConfirmation: needConfirmation, + dense: widget.dense, + getGroupValue: () => _selectedValue, + setGroupValue: (v) => setState(() => _selectedValue = v), + ); + }), + if (verticalPadding != 0) SizedBox(height: verticalPadding), + ], + actions: [ + const CancelButton(), + if (needConfirmation) + TextButton( + onPressed: () => Navigator.maybeOf(context)?.pop(_selectedValue), + child: Text(confirmationButtonLabel), + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/wallpaper_settings_dialog.dart b/lib/widgets/dialogs/wallpaper_settings_dialog.dart index 0e6d46fad..7a4ed7589 100644 --- a/lib/widgets/dialogs/wallpaper_settings_dialog.dart +++ b/lib/widgets/dialogs/wallpaper_settings_dialog.dart @@ -1,12 +1,11 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/wallpaper_target.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_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/radio_list_tile.dart'; import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; -import 'aves_dialog.dart'; - class WallpaperSettingsDialog extends StatefulWidget { static const routeName = '/dialog/wallpaper_settings'; diff --git a/lib/widgets/settings/common/tiles.dart b/lib/widgets/settings/common/tiles.dart index a1892e6fc..8ce5c354a 100644 --- a/lib/widgets/settings/common/tiles.dart +++ b/lib/widgets/settings/common/tiles.dart @@ -1,10 +1,13 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/constants.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_caption.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/duration_dialog.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/multi_selection.dart'; +import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -82,7 +85,7 @@ class SettingsSwitchListTile extends StatelessWidget { } } -class SettingsSelectionListTile extends StatelessWidget { +class SettingsSelectionListTile extends StatelessWidget { final List values; final String Function(BuildContext, T) getName; final T Function(BuildContext, Settings) selector; @@ -123,7 +126,7 @@ class SettingsSelectionListTile extends StatelessWidget { subtitle: AvesCaption(getName(context, current)), onTap: () => showSelectionDialog( context: context, - builder: (context) => AvesSelectionDialog( + builder: (context) => AvesSingleSelectionDialog( initialValue: current, options: Map.fromEntries(values.map((v) => MapEntry(v, getName(context, v)))), optionSubtitleBuilder: optionSubtitleBuilder, @@ -137,6 +140,62 @@ class SettingsSelectionListTile extends StatelessWidget { } } +class SettingsMultiSelectionListTile extends StatelessWidget { + final List values; + final String Function(BuildContext, T) getName; + final List Function(BuildContext, Settings) selector; + final ValueChanged> onSelection; + final String tileTitle, noneSubtitle; + final WidgetBuilder? trailingBuilder; + final String? dialogTitle; + final TextBuilder? optionSubtitleBuilder; + + const SettingsMultiSelectionListTile({ + super.key, + required this.values, + required this.getName, + required this.selector, + required this.onSelection, + required this.tileTitle, + required this.noneSubtitle, + this.trailingBuilder, + this.dialogTitle, + this.optionSubtitleBuilder, + }); + + @override + Widget build(BuildContext context) { + return Selector>( + selector: selector, + builder: (context, current, child) { + Widget titleWidget = Text(tileTitle); + if (trailingBuilder != null) { + titleWidget = Row( + children: [ + Expanded(child: titleWidget), + trailingBuilder!(context), + ], + ); + } + return ListTile( + title: titleWidget, + subtitle: AvesCaption(current.isEmpty ? noneSubtitle : current.map((v) => getName(context, v)).join(Constants.separator)), + onTap: () => showSelectionDialog>( + context: context, + builder: (context) => AvesMultiSelectionDialog( + initialValue: current.toSet(), + options: Map.fromEntries(values.map((v) => MapEntry(v, getName(context, v)))), + optionSubtitleBuilder: optionSubtitleBuilder, + title: dialogTitle, + ), + onSelection: onSelection, + ), + ); + }, + ); + } +} + class SettingsDurationListTile extends StatelessWidget { final int Function(BuildContext, Settings) selector; final ValueChanged onChanged; diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index 2bf00f4e2..3edac6e01 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -1,4 +1,5 @@ import 'package:aves/model/settings/settings.dart'; +import 'package:aves/ref/bursts.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -27,6 +28,7 @@ class ThumbnailsSection extends SettingsSection { List tiles(BuildContext context) => [ if (!settings.useTvLayout) SettingsTileCollectionQuickActions(), SettingsTileThumbnailOverlay(), + SettingsTileBurstPatterns(), ]; } @@ -53,3 +55,19 @@ class SettingsTileThumbnailOverlay extends SettingsTile { builder: (context) => const ThumbnailOverlayPage(), ); } + +class SettingsTileBurstPatterns extends SettingsTile { + @override + String title(BuildContext context) => context.l10n.settingsCollectionBurstPatternsTile; + + @override + Widget build(BuildContext context) => SettingsMultiSelectionListTile( + values: BurstPatterns.options, + getName: (context, v) => BurstPatterns.getName(v), + selector: (context, s) => s.collectionBurstPatterns, + onSelection: (v) => settings.collectionBurstPatterns = v, + tileTitle: title(context), + noneSubtitle: context.l10n.settingsCollectionBurstPatternsNone, + optionSubtitleBuilder: BurstPatterns.getExample, + ); +} diff --git a/untranslated.json b/untranslated.json index 0bffff390..88d9537dd 100644 --- a/untranslated.json +++ b/untranslated.json @@ -443,6 +443,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -1001,6 +1003,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -1176,6 +1180,8 @@ "cs": [ "settingsVideoEnablePip", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle" ], @@ -1211,6 +1217,8 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle", "settingsDisablingBinWarningDialogMessage" @@ -1226,10 +1234,22 @@ "drawerPlacePage", "placePageTitle", "placeEmpty", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle" ], + "es": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + + "eu": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + "fa": [ "clearTooltip", "chipActionGoToPlacePage", @@ -1533,6 +1553,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -1704,6 +1726,11 @@ "filePickerUseThisFolder" ], + "fr": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + "gl": [ "columnCount", "chipActionGoToPlacePage", @@ -2037,6 +2064,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -2672,6 +2701,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -2845,6 +2876,11 @@ "filePickerUseThisFolder" ], + "id": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + "it": [ "chipActionGoToPlacePage", "lengthUnitPixel", @@ -2857,6 +2893,8 @@ "drawerPlacePage", "placePageTitle", "placeEmpty", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle" ], @@ -2904,6 +2942,8 @@ "placeEmpty", "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerShowDescription", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle", @@ -2914,6 +2954,11 @@ "settingsWidgetDisplayedItem" ], + "ko": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + "lt": [ "columnCount", "chipActionGoToPlacePage", @@ -2951,6 +2996,8 @@ "placeEmpty", "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerShowDescription", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle", @@ -2965,6 +3012,8 @@ "settingsVideoEnablePip", "patternDialogEnter", "patternDialogConfirm", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle" ], @@ -3017,6 +3066,8 @@ "placeEmpty", "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerShowRatingTags", "settingsViewerShowDescription", "settingsVideoBackgroundMode", @@ -3241,6 +3292,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -3350,7 +3403,14 @@ "wallpaperUseScrollEffect" ], + "pl": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + "pt": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundModeDialogTitle" ], @@ -3366,6 +3426,8 @@ "drawerPlacePage", "placePageTitle", "placeEmpty", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle" ], @@ -3383,6 +3445,8 @@ "drawerPlacePage", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle", "settingsVideoGestureVerticalDragBrightnessVolume" @@ -3638,6 +3702,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -3996,6 +4062,8 @@ "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", "settingsCollectionSelectionQuickActionEditorBanner", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerSectionTitle", "settingsViewerGestureSideTapNext", "settingsViewerUseCutout", @@ -4200,11 +4268,18 @@ "placePageTitle", "placeEmpty", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle", "settingsDisablingBinWarningDialogMessage" ], + "uk": [ + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone" + ], + "zh": [ "chipActionGoToPlacePage", "chipActionLock", @@ -4240,6 +4315,8 @@ "placeEmpty", "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerShowDescription", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle", @@ -4285,6 +4362,8 @@ "placeEmpty", "settingsModificationWarningDialogMessage", "settingsConfirmationVaultDataLoss", + "settingsCollectionBurstPatternsTile", + "settingsCollectionBurstPatternsNone", "settingsViewerShowDescription", "settingsVideoBackgroundMode", "settingsVideoBackgroundModeDialogTitle",