diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 47035d6ea..816a4468c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -447,10 +447,8 @@ "genericFailureFeedback": "Failed", "@genericFailureFeedback": {}, - "menuActionSort": "Sort", - "@menuActionSort": {}, - "menuActionGroup": "Group", - "@menuActionGroup": {}, + "menuActionConfigureView": "View", + "@menuActionConfigureView": {}, "menuActionSelect": "Select", "@menuActionSelect": {}, "menuActionSelectAll": "Select all", @@ -462,6 +460,18 @@ "menuActionStats": "Stats", "@menuActionStats": {}, + "viewDialogTabSort": "Sort", + "@viewDialogTabSort": {}, + "viewDialogTabGroup": "Group", + "@viewDialogTabGroup": {}, + "viewDialogTabLayout": "Layout", + "@viewDialogTabLayout": {}, + + "tileLayoutGrid": "Grid", + "@tileLayoutGrid": {}, + "tileLayoutList": "List", + "@tileLayoutList": {}, + "aboutPageTitle": "About", "@aboutPageTitle": {}, "aboutLinkSources": "Sources", @@ -566,8 +576,6 @@ "collectionSearchTitlesHintText": "Search titles", "@collectionSearchTitlesHintText": {}, - "collectionSortTitle": "Sort", - "@collectionSortTitle": {}, "collectionSortDate": "By date", "@collectionSortDate": {}, "collectionSortSize": "By size", @@ -575,8 +583,6 @@ "collectionSortName": "By album & file name", "@collectionSortName": {}, - "collectionGroupTitle": "Group", - "@collectionGroupTitle": {}, "collectionGroupAlbum": "By album", "@collectionGroupAlbum": {}, "collectionGroupMonth": "By month", @@ -674,8 +680,6 @@ "drawerCollectionSphericalVideos": "360° Videos", "@drawerCollectionSphericalVideos": {}, - "chipSortTitle": "Sort", - "@chipSortTitle": {}, "chipSortDate": "By date", "@chipSortDate": {}, "chipSortName": "By name", @@ -683,8 +687,6 @@ "chipSortCount": "By item count", "@chipSortCount": {}, - "albumGroupTitle": "Group", - "@albumGroupTitle": {}, "albumGroupTier": "By tier", "@albumGroupTier": {}, "albumGroupVolume": "By storage volume", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 18c00c86b..e25a14c4d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -203,14 +203,20 @@ "genericSuccessFeedback": "Succès !", "genericFailureFeedback": "Échec", - "menuActionSort": "Trier", - "menuActionGroup": "Grouper", + "menuActionConfigureView": "Vue", "menuActionSelect": "Sélectionner", "menuActionSelectAll": "Tout sélectionner", "menuActionSelectNone": "Tout désélectionner", "menuActionMap": "Carte", "menuActionStats": "Statistiques", + "viewDialogTabSort": "Tri", + "viewDialogTabGroup": "Groupes", + "viewDialogTabLayout": "Vue", + + "tileLayoutGrid": "Grille", + "tileLayoutList": "Liste", + "aboutPageTitle": "À propos", "aboutLinkSources": "Sources", "aboutLinkLicense": "Licence", @@ -261,12 +267,10 @@ "collectionSearchTitlesHintText": "Recherche de titres", - "collectionSortTitle": "Trier", "collectionSortDate": "par date", "collectionSortSize": "par taille", - "collectionSortName": "alphabétiquement", + "collectionSortName": "alphabétique", - "collectionGroupTitle": "Grouper", "collectionGroupAlbum": "par album", "collectionGroupMonth": "par mois", "collectionGroupDay": "par jour", @@ -302,12 +306,10 @@ "drawerCollectionRaws": "Photos Raw", "drawerCollectionSphericalVideos": "Vidéos à 360°", - "chipSortTitle": "Trier", "chipSortDate": "par date", - "chipSortName": "par nom", + "chipSortName": "alphabétique", "chipSortCount": "par nombre d’éléments", - "albumGroupTitle": "Grouper", "albumGroupTier": "par importance", "albumGroupVolume": "par volume de stockage", "albumGroupNone": "ne pas grouper", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 75d6b0b1c..954edd672 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -203,14 +203,20 @@ "genericSuccessFeedback": "정상 처리됐습니다", "genericFailureFeedback": "오류가 발생했습니다", - "menuActionSort": "정렬", - "menuActionGroup": "묶음", + "menuActionConfigureView": "뷰 설정", "menuActionSelect": "선택", "menuActionSelectAll": "모두 선택", "menuActionSelectNone": "모두 해제", "menuActionMap": "지도", "menuActionStats": "통계", + "viewDialogTabSort": "정렬", + "viewDialogTabGroup": "묶음", + "viewDialogTabLayout": "배치", + + "tileLayoutGrid": "그리드", + "tileLayoutList": "목록", + "aboutPageTitle": "앱 정보", "aboutLinkSources": "소스 코드", "aboutLinkLicense": "라이선스", @@ -261,12 +267,10 @@ "collectionSearchTitlesHintText": "제목 검색", - "collectionSortTitle": "정렬", "collectionSortDate": "날짜", "collectionSortSize": "크기", "collectionSortName": "이름", - "collectionGroupTitle": "묶음", "collectionGroupAlbum": "앨범별로", "collectionGroupMonth": "월별로", "collectionGroupDay": "날짜별로", @@ -302,12 +306,10 @@ "drawerCollectionRaws": "Raw 이미지", "drawerCollectionSphericalVideos": "360° 동영상", - "chipSortTitle": "정렬", "chipSortDate": "날짜", "chipSortName": "이름", "chipSortCount": "항목수", - "albumGroupTitle": "묶음", "albumGroupTier": "단계별로", "albumGroupVolume": "저장공간별로", "albumGroupNone": "묶음 없음", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 730b74295..2596a7991 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -203,14 +203,15 @@ "genericSuccessFeedback": "Выполнено!", "genericFailureFeedback": "Не удалось", - "menuActionSort": "Сортировка", - "menuActionGroup": "Группировка", "menuActionSelect": "Выбрать", "menuActionSelectAll": "Выбрать все", "menuActionSelectNone": "Снять выделение", "menuActionMap": "Карта", "menuActionStats": "Статистика", + "viewDialogTabSort": "Сортировка", + "viewDialogTabGroup": "Группировка", + "aboutPageTitle": "О нас", "aboutLinkSources": "Исходники", "aboutLinkLicense": "Лицензия", @@ -261,12 +262,10 @@ "collectionSearchTitlesHintText": "Поиск заголовков", - "collectionSortTitle": "Сортировка", "collectionSortDate": "По дате", "collectionSortSize": "По размеру", "collectionSortName": "По имени альбома и файла", - "collectionGroupTitle": "Группировка", "collectionGroupAlbum": "По альбому", "collectionGroupMonth": "По месяцу", "collectionGroupDay": "По дню", @@ -302,12 +301,10 @@ "drawerCollectionRaws": "RAW", "drawerCollectionSphericalVideos": "360° видео", - "chipSortTitle": "Сортировка", "chipSortDate": "По дате", "chipSortName": "По названию", "chipSortCount": "По количеству объектов", - "albumGroupTitle": "Группировка", "albumGroupTier": "По уровню", "albumGroupVolume": "По накопителю", "albumGroupNone": "Не группировать", diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 24d604796..7a73a8186 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -4,8 +4,7 @@ import 'package:flutter/material.dart'; enum ChipSetAction { // general - sort, - group, + configureView, select, selectAll, selectNone, @@ -27,8 +26,7 @@ enum ChipSetAction { class ChipSetActions { static const general = [ - ChipSetAction.sort, - ChipSetAction.group, + ChipSetAction.configureView, ChipSetAction.select, ChipSetAction.selectAll, ChipSetAction.selectNone, @@ -57,10 +55,8 @@ extension ExtraChipSetAction on ChipSetAction { String getText(BuildContext context) { switch (this) { // general - case ChipSetAction.sort: - return context.l10n.menuActionSort; - case ChipSetAction.group: - return context.l10n.menuActionGroup; + case ChipSetAction.configureView: + return context.l10n.menuActionConfigureView; case ChipSetAction.select: return context.l10n.menuActionSelect; case ChipSetAction.selectAll: @@ -101,10 +97,8 @@ extension ExtraChipSetAction on ChipSetAction { IconData _getIconData() { switch (this) { // general - case ChipSetAction.sort: - return AIcons.sort; - case ChipSetAction.group: - return AIcons.group; + case ChipSetAction.configureView: + return AIcons.view; case ChipSetAction.select: return AIcons.select; case ChipSetAction.selectAll: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 0db4efa6e..2d97dabca 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -4,8 +4,7 @@ import 'package:flutter/material.dart'; enum EntrySetAction { // general - sort, - group, + configureView, select, selectAll, selectNone, @@ -32,8 +31,7 @@ enum EntrySetAction { class EntrySetActions { static const general = [ - EntrySetAction.sort, - EntrySetAction.group, + EntrySetAction.configureView, EntrySetAction.select, EntrySetAction.selectAll, EntrySetAction.selectNone, @@ -63,10 +61,8 @@ extension ExtraEntrySetAction on EntrySetAction { String getText(BuildContext context) { switch (this) { // general - case EntrySetAction.sort: - return context.l10n.menuActionSort; - case EntrySetAction.group: - return context.l10n.menuActionGroup; + case EntrySetAction.configureView: + return context.l10n.menuActionConfigureView; case EntrySetAction.select: return context.l10n.menuActionSelect; case EntrySetAction.selectAll: @@ -119,10 +115,8 @@ extension ExtraEntrySetAction on EntrySetAction { IconData _getIconData() { switch (this) { // general - case EntrySetAction.sort: - return AIcons.sort; - case EntrySetAction.group: - return AIcons.group; + case EntrySetAction.configureView: + return AIcons.view; case EntrySetAction.select: return AIcons.select; case EntrySetAction.selectAll: diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index e5e8654dd..9edf03541 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -44,12 +44,11 @@ class AlbumFilter extends CollectionFilter { String getTooltip(BuildContext context) => album; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { return IconUtils.getAlbumIcon( context: context, albumPath: album, size: size, - embossed: embossed, ) ?? (showGenericIcon ? Icon(AIcons.album, size: size) : null); } diff --git a/lib/model/filters/coordinate.dart b/lib/model/filters/coordinate.dart index 5f733ffc3..4f51e4b57 100644 --- a/lib/model/filters/coordinate.dart +++ b/lib/model/filters/coordinate.dart @@ -56,7 +56,7 @@ class CoordinateFilter extends CollectionFilter { String getLabel(BuildContext context) => _formatBounds(context.l10n, context.read().coordinateFormat); @override - Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size); + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.geoBounds, size: size); @override String get category => type; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 6b378712e..9805fdfc2 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -29,7 +29,7 @@ class FavouriteFilter extends CollectionFilter { String getLabel(BuildContext context) => context.l10n.filterFavouriteLabel; @override - Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size); + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size); @override Future color(BuildContext context) => SynchronousFuture(Colors.red); diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 5942153a0..2da4daaa9 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -81,7 +81,7 @@ abstract class CollectionFilter extends Equatable implements Comparable getLabel(context); - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => null; + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => null; Future color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context))); diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 3f593b7cc..235475160 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -58,15 +58,13 @@ class LocationFilter extends CollectionFilter { String getLabel(BuildContext context) => _location.isEmpty ? context.l10n.filterLocationEmptyLabel : _location; @override - Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { if (_countryCode != null && device.canRenderFlagEmojis) { final flag = countryCodeToFlag(_countryCode); - // as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates, - // not filled with the shadow color as expected, so we remove them if (flag != null) { return Text( flag, - style: TextStyle(fontSize: size, shadows: const []), + style: TextStyle(fontSize: size), textScaleFactor: 1.0, ); } diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 9aba9d61f..0eab98db4 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -68,7 +68,7 @@ class MimeFilter extends CollectionFilter { } @override - Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size); + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); @override String get category => type; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index b7d3bd414..aae48e395 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -64,7 +64,7 @@ class QueryFilter extends CollectionFilter { String get universalLabel => query; @override - Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size); + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size); @override Future color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor); diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index f525ccdb1..cda7ef146 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -44,7 +44,7 @@ class TagFilter extends CollectionFilter { String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null; + Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null; @override String get category => type; diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index b445c3e7d..efc6420a2 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -94,7 +94,7 @@ class TypeFilter extends CollectionFilter { } @override - Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size); + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); @override String get category => type; diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index b7f37b315..cf13171fd 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -19,6 +19,7 @@ class SettingsDefaults { static const mustBackTwiceToExit = true; static const keepScreenOn = KeepScreenOn.viewerOnly; static const homePage = HomePageSetting.collection; + static const tileLayout = TileLayout.grid; // drawer static final drawerTypeBookmarks = [ diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index a70d23ad2..f5b9ac2c2 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -49,6 +49,7 @@ class Settings extends ChangeNotifier { static const homePageKey = 'home_page'; static const catalogTimeZoneKey = 'catalog_time_zone'; static const tileExtentPrefixKey = 'tile_extent_'; + static const tileLayoutPrefixKey = 'tile_layout_'; // drawer static const drawerTypeBookmarksKey = 'drawer_type_bookmarks'; @@ -247,6 +248,10 @@ class Settings extends ChangeNotifier { void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue); + TileLayout getTileLayout(String routeName) => getEnumOrDefault(tileLayoutPrefixKey + routeName, SettingsDefaults.tileLayout, TileLayout.values); + + void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString()); + // drawer List get drawerTypeBookmarks => @@ -570,6 +575,12 @@ class Settings extends ChangeNotifier { } else { debugPrint('failed to import key=$key, value=$value is not a double'); } + } else if (key.startsWith(tileLayoutPrefixKey)) { + if (value is String) { + _prefs!.setString(key, value); + } else { + debugPrint('failed to import key=$key, value=$value is not a string'); + } } else { switch (key) { case subtitleTextColorKey: diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 451e27fef..ec4f816b5 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -7,3 +7,5 @@ enum AlbumChipGroupFactor { none, importance, volume } enum EntrySortFactor { date, size, name } enum EntryGroupFactor { none, album, month, day } + +enum TileLayout { grid, list } \ No newline at end of file diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index ae9c0a772..7bfb34635 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -32,6 +32,11 @@ class AIcons { static const IconData tag = Icons.local_offer_outlined; static const IconData tagOff = MdiIcons.tagOffOutline; + // view + static const IconData group = Icons.group_work_outlined; + static const IconData layout = Icons.grid_view_outlined; + static const IconData sort = Icons.sort_outlined; + // actions static const IconData add = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; @@ -54,7 +59,6 @@ class AIcons { static const IconData filterOff = MdiIcons.filterOffOutline; static const IconData geoBounds = Icons.public_outlined; static const IconData goUp = Icons.arrow_upward_outlined; - static const IconData group = Icons.group_work_outlined; static const IconData hide = Icons.visibility_off_outlined; static const IconData import = MdiIcons.fileImportOutline; static const IconData info = Icons.info_outlined; @@ -80,7 +84,6 @@ class AIcons { static const IconData setCover = MdiIcons.imageEditOutline; static const IconData share = Icons.share_outlined; static const IconData show = Icons.visibility_outlined; - static const IconData sort = Icons.sort_outlined; static const IconData speed = Icons.speed_outlined; static const IconData stats = Icons.pie_chart_outlined; static const IconData streams = Icons.translate_outlined; @@ -88,6 +91,7 @@ class AIcons { static const IconData streamAudio = Icons.audiotrack_outlined; static const IconData streamText = Icons.closed_caption_outlined; static const IconData videoSettings = Icons.video_settings_outlined; + static const IconData view = Icons.grid_view_outlined; static const IconData zoomIn = Icons.add_outlined; static const IconData zoomOut = Icons.remove_outlined; static const IconData collapse = Icons.expand_less_outlined; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 7ffda0c5a..3088114a5 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; class Constants { - // as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped + // as of Flutter v2.8.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // so we give it a `strutStyle` with a slightly larger height static const overflowStrutStyle = StrutStyle(height: 1.3); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index c4b273af1..49ebf691e 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -12,6 +12,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/query_bar.dart'; @@ -19,7 +20,7 @@ import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -395,11 +396,8 @@ class _CollectionAppBarState extends State with SingleTickerPr Future _onActionSelected(EntrySetAction action) async { switch (action) { // general - case EntrySetAction.sort: - await _sort(); - break; - case EntrySetAction.group: - await _group(); + case EntrySetAction.configureView: + await _configureView(); break; case EntrySetAction.select: context.read>().select(); @@ -434,47 +432,42 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - Future _sort() async { - final value = await showDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.collectionSortFactor, - options: { - EntrySortFactor.date: context.l10n.collectionSortDate, - EntrySortFactor.size: context.l10n.collectionSortSize, - EntrySortFactor.name: context.l10n.collectionSortName, - }, - title: context.l10n.collectionSortTitle, - ), + Future _configureView() async { + final initialValue = Tuple3( + settings.collectionSortFactor, + settings.collectionSectionFactor, + settings.getTileLayout(CollectionPage.routeName), ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - settings.collectionSortFactor = value; - } - } - - Future _group() async { - final value = await showDialog( + final value = await showDialog>( context: context, builder: (context) { final l10n = context.l10n; - return AvesSelectionDialog( - initialValue: settings.collectionSectionFactor, - options: { + return TileViewDialog( + initialValue: initialValue, + sortOptions: { + EntrySortFactor.date: l10n.collectionSortDate, + EntrySortFactor.size: l10n.collectionSortSize, + EntrySortFactor.name: l10n.collectionSortName, + }, + groupOptions: { EntryGroupFactor.album: l10n.collectionGroupAlbum, EntryGroupFactor.month: l10n.collectionGroupMonth, EntryGroupFactor.day: l10n.collectionGroupDay, EntryGroupFactor.none: l10n.collectionGroupNone, }, - title: l10n.collectionGroupTitle, + layoutOptions: { + TileLayout.grid: l10n.tileLayoutGrid, + TileLayout.list: l10n.tileLayoutList, + }, ); }, ); // wait for the dialog to hide as applying the change may block the UI await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - settings.collectionSectionFactor = value; + if (value != null && initialValue != value) { + settings.collectionSortFactor = value.item1!; + settings.collectionSectionFactor = value.item2!; + settings.setTileLayout(CollectionPage.routeName, value.item3!); } } diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index a852e3a80..4df61a326 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -4,6 +4,7 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/ref/mime_types.dart'; @@ -11,21 +12,22 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/draggable_thumb_label.dart'; +import 'package:aves/widgets/collection/grid/list_details_theme.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart'; -import 'package:aves/widgets/collection/grid/thumbnail.dart'; +import 'package:aves/widgets/collection/grid/tile.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; +import 'package:aves/widgets/common/grid/scaling.dart'; import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; -import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/thumbnail/decorated.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:flutter/material.dart'; @@ -74,11 +76,13 @@ class _CollectionGridContent extends StatelessWidget { @override Widget build(BuildContext context) { + final settingsRouteKey = context.read().settingsRouteKey; + final tileLayout = context.select((s) => s.getTileLayout(settingsRouteKey)); return Consumer( builder: (context, collection, child) { final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), - builder: (context, tileExtent, child) { + builder: (context, thumbnailExtent, child) { return Selector>( selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), builder: (context, c, child) { @@ -89,22 +93,27 @@ class _CollectionGridContent extends StatelessWidget { final target = context.read().staggeredAnimationPageTarget; final tileAnimationDelay = context.read().getTileAnimationDelay(target); return GridTheme( - extent: tileExtent, - child: SectionedEntryListLayoutProvider( - collection: collection, - scrollableWidth: scrollableWidth, - columnCount: columnCount, - spacing: tileSpacing, - tileExtent: tileExtent, - tileBuilder: (entry) => InteractiveThumbnail( - key: ValueKey(entry.contentId), + extent: thumbnailExtent, + child: EntryListDetailsTheme( + extent: thumbnailExtent, + child: SectionedEntryListLayoutProvider( collection: collection, - entry: entry, - tileExtent: tileExtent, - isScrollingNotifier: _isScrollingNotifier, + scrollableWidth: scrollableWidth, + tileLayout: tileLayout, + columnCount: columnCount, + spacing: tileSpacing, + tileExtent: thumbnailExtent, + tileBuilder: (entry) => InteractiveTile( + key: ValueKey(entry.contentId), + collection: collection, + entry: entry, + thumbnailExtent: thumbnailExtent, + tileLayout: tileLayout, + isScrollingNotifier: _isScrollingNotifier, + ), + tileAnimationDelay: tileAnimationDelay, + child: child!, ), - tileAnimationDelay: tileAnimationDelay, - child: child!, ), ); }, @@ -115,6 +124,7 @@ class _CollectionGridContent extends StatelessWidget { collection: collection, isScrollingNotifier: _isScrollingNotifier, scrollController: PrimaryScrollController.of(context)!, + tileLayout: tileLayout, ), ); return sectionedListLayoutProvider; @@ -127,27 +137,28 @@ class _CollectionSectionedContent extends StatefulWidget { final CollectionLens collection; final ValueNotifier isScrollingNotifier; final ScrollController scrollController; + final TileLayout tileLayout; const _CollectionSectionedContent({ required this.collection, required this.isScrollingNotifier, required this.scrollController, + required this.tileLayout, }); @override _CollectionSectionedContentState createState() => _CollectionSectionedContentState(); } -class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin { +class _CollectionSectionedContentState extends State<_CollectionSectionedContent> { CollectionLens get collection => widget.collection; - @override + TileLayout get tileLayout => widget.tileLayout; + ScrollController get scrollController => widget.scrollController; - @override final ValueNotifier appBarHeightNotifier = ValueNotifier(0); - @override final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); @override @@ -169,6 +180,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent final scaler = _CollectionScaler( scrollableKey: scrollableKey, appBarHeightNotifier: appBarHeightNotifier, + tileLayout: tileLayout, child: scrollView, ); @@ -181,18 +193,26 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent child: scaler, ); - return selector; + return GridItemTracker( + scrollableKey: scrollableKey, + tileLayout: tileLayout, + appBarHeightNotifier: appBarHeightNotifier, + scrollController: scrollController, + child: selector, + ); } } class _CollectionScaler extends StatelessWidget { final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; + final TileLayout tileLayout; final Widget child; const _CollectionScaler({ required this.scrollableKey, required this.appBarHeightNotifier, + required this.tileLayout, required this.child, }); @@ -201,10 +221,12 @@ class _CollectionScaler extends StatelessWidget { final tileSpacing = context.select((controller) => controller.spacing); return GridScaleGestureDetector( scrollableKey: scrollableKey, + tileLayout: tileLayout, heightForWidth: (width) => width, gridBuilder: (center, tileSize, child) => CustomPaint( painter: GridPainter( - center: center, + tileLayout: tileLayout, + tileCenter: center, tileSize: tileSize, spacing: tileSpacing, borderWidth: DecoratedThumbnail.borderWidth, @@ -213,11 +235,13 @@ class _CollectionScaler extends StatelessWidget { ), child: child, ), - scaledBuilder: (entry, tileSize) => DecoratedThumbnail( - entry: entry, - tileExtent: context.read().effectiveExtentMax, - selectable: false, - highlightable: false, + scaledBuilder: (entry, tileSize) => EntryListDetailsTheme( + extent: tileSize.height, + child: Tile( + entry: entry, + thumbnailExtent: context.read().effectiveExtentMax, + tileLayout: tileLayout, + ), ), child: child, ); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index bda341314..9bb428159 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -50,10 +50,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa }) { switch (action) { // general - case EntrySetAction.sort: + case EntrySetAction.configureView: return true; - case EntrySetAction.group: - return sortFactor == EntrySortFactor.date; case EntrySetAction.select: return appMode.canSelect && !isSelecting; case EntrySetAction.selectAll: @@ -97,8 +95,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final hasSelection = selectedItemCount > 0; switch (action) { - case EntrySetAction.sort: - case EntrySetAction.group: + case EntrySetAction.configureView: return true; case EntrySetAction.select: return hasItems; @@ -132,8 +129,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa void onActionSelected(BuildContext context, EntrySetAction action) { switch (action) { // general - case EntrySetAction.sort: - case EntrySetAction.group: + case EntrySetAction.configureView: case EntrySetAction.select: case EntrySetAction.selectAll: case EntrySetAction.selectNone: @@ -226,7 +222,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa context: context, builder: (context) { return AvesDialog( - context: context, content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), actions: [ TextButton( @@ -274,19 +269,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa Future _move(BuildContext context, {required MoveType moveType}) async { final l10n = context.l10n; - final source = context.read(); final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - final destinationAlbum = await Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: AlbumPickPage.routeName), - builder: (context) => AlbumPickPage(source: source, moveType: moveType), - ), - ); - if (destinationAlbum == null || destinationAlbum.isEmpty) return; + final destinationAlbum = await pickAlbum(context: context, moveType: moveType); + if (destinationAlbum == null) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; @@ -326,6 +314,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa nameConflictStrategy = value; } + final source = context.read(); source.pauseMonitoring(); final opId = mediaFileService.newOpId; showOpReport( @@ -470,7 +459,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa builder: (context) { final l10n = context.l10n; return AvesDialog( - context: context, title: l10n.unsupportedTypeDialogTitle, content: Text(l10n.unsupportedTypeDialogMessage(unsupportedTypes.length, unsupportedTypes.join(', '))), actions: [ diff --git a/lib/widgets/collection/grid/list_details.dart b/lib/widgets/collection/grid/list_details.dart new file mode 100644 index 000000000..149ffe83a --- /dev/null +++ b/lib/widgets/collection/grid/list_details.dart @@ -0,0 +1,95 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/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/constants.dart'; +import 'package:aves/widgets/collection/grid/list_details_theme.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EntryListDetails extends StatelessWidget { + final AvesEntry entry; + + const EntryListDetails({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final detailsTheme = context.watch(); + + return Container( + padding: EntryListDetailsTheme.contentPadding, + foregroundDecoration: BoxDecoration( + border: Border(top: AvesBorder.side), + ), + margin: EntryListDetailsTheme.contentMargin, + child: IconTheme.merge( + data: detailsTheme.iconTheme, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.bestTitle ?? context.l10n.viewerInfoUnknown, + style: detailsTheme.titleStyle, + softWrap: false, + overflow: detailsTheme.titleMaxLines == 1 ? TextOverflow.fade : TextOverflow.ellipsis, + maxLines: detailsTheme.titleMaxLines, + ), + const SizedBox(height: EntryListDetailsTheme.titleDetailPadding), + if (detailsTheme.showDate) _buildDateRow(context, detailsTheme.captionStyle), + if (detailsTheme.showLocation && entry.hasGps) _buildLocationRow(context, detailsTheme.captionStyle), + ], + ), + ), + ); + } + + Widget _buildDateRow(BuildContext context, TextStyle style) { + final locale = context.l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + final date = entry.bestDate; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; + + return Row( + children: [ + const Icon(AIcons.date), + const SizedBox(width: 8), + Expanded( + child: Text( + dateText, + style: style, + strutStyle: Constants.overflowStrutStyle, + softWrap: false, + overflow: TextOverflow.fade, + ), + ), + ], + ); + } + + Widget _buildLocationRow(BuildContext context, TextStyle style) { + final location = entry.hasAddress ? entry.shortAddress : settings.coordinateFormat.format(context.l10n, entry.latLng!); + + return Row( + children: [ + const Icon(AIcons.location), + const SizedBox(width: 8), + Expanded( + child: Text( + location, + style: style, + strutStyle: Constants.overflowStrutStyle, + softWrap: false, + overflow: TextOverflow.fade, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/collection/grid/list_details_theme.dart b/lib/widgets/collection/grid/list_details_theme.dart new file mode 100644 index 000000000..0c3f16b43 --- /dev/null +++ b/lib/widgets/collection/grid/list_details_theme.dart @@ -0,0 +1,99 @@ +import 'package:aves/theme/format.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +class EntryListDetailsTheme extends StatelessWidget { + final double extent; + final Widget child; + + static const EdgeInsets contentMargin = EdgeInsets.symmetric(horizontal: 8); + static const EdgeInsets contentPadding = EdgeInsets.symmetric(vertical: 4); + static const double titleDetailPadding = 6; + + const EntryListDetailsTheme({ + Key? key, + required this.extent, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ProxyProvider( + update: (context, mq, previous) { + final locale = context.l10n.localeName; + + final use24hour = mq.alwaysUse24HourFormat; + final textScaleFactor = mq.textScaleFactor; + + final textTheme = Theme.of(context).textTheme; + final titleStyle = textTheme.bodyText2!; + final captionStyle = textTheme.caption!; + + final titleLineHeight = (RenderParagraph( + TextSpan(text: 'Fake Title', style: titleStyle), + textDirection: TextDirection.ltr, + textScaleFactor: textScaleFactor, + )..layout(const BoxConstraints(), parentUsesSize: true)) + .getMaxIntrinsicHeight(double.infinity); + + final captionLineHeight = (RenderParagraph( + TextSpan(text: formatDateTime(DateTime.now(), locale, use24hour), style: captionStyle), + textDirection: TextDirection.ltr, + textScaleFactor: textScaleFactor, + strutStyle: Constants.overflowStrutStyle, + )..layout(const BoxConstraints(), parentUsesSize: true)) + .getMaxIntrinsicHeight(double.infinity); + + var titleMaxLines = 1; + var showDate = false; + var showLocation = false; + + var availableHeight = extent - contentMargin.vertical - contentPadding.vertical; + if (availableHeight >= titleLineHeight + titleDetailPadding + captionLineHeight) { + showDate = true; + availableHeight -= titleLineHeight + titleDetailPadding + captionLineHeight; + if (availableHeight >= captionLineHeight) { + showLocation = true; + availableHeight -= captionLineHeight; + titleMaxLines += availableHeight ~/ titleLineHeight; + } + } + + return EntryListDetailsThemeData( + extent: extent, + titleMaxLines: titleMaxLines, + showDate: showDate, + showLocation: showLocation, + titleStyle: titleStyle, + captionStyle: captionStyle, + iconTheme: IconThemeData( + color: captionStyle.color, + size: captionStyle.fontSize! * textScaleFactor, + ), + ); + }, + child: child, + ); + } +} + +class EntryListDetailsThemeData { + final double extent; + final int titleMaxLines; + final bool showDate, showLocation; + final TextStyle titleStyle, captionStyle; + final IconThemeData iconTheme; + + const EntryListDetailsThemeData({ + required this.extent, + required this.titleMaxLines, + required this.showDate, + required this.showLocation, + required this.titleStyle, + required this.captionStyle, + required this.iconTheme, + }); +} diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart index 99ba1dbd4..528dce550 100644 --- a/lib/widgets/collection/grid/section_layout.dart +++ b/lib/widgets/collection/grid/section_layout.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/widgets/collection/grid/headers/any.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; @@ -12,6 +13,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider? isScrollingNotifier; - const InteractiveThumbnail({ + const InteractiveTile({ Key? key, required this.collection, required this.entry, - required this.tileExtent, + required this.thumbnailExtent, + required this.tileLayout, this.isScrollingNotifier, }) : super(key: key); @override Widget build(BuildContext context) { - return GestureDetector( - key: ValueKey(entry.uri), + return InkWell( onTap: () { final appMode = context.read>().value; switch (appMode) { @@ -51,13 +55,13 @@ class InteractiveThumbnail extends StatelessWidget { }, child: MetaData( metaData: ScalerMetadata(entry), - child: DecoratedThumbnail( + child: Tile( entry: entry, - tileExtent: tileExtent, - // when the user is scrolling faster than we can retrieve the thumbnails, - // the retrieval task queue can pile up for thumbnails that got disposed - // in this case we pause the image retrieval task to get it out of the queue - cancellableNotifier: isScrollingNotifier, + thumbnailExtent: thumbnailExtent, + tileLayout: tileLayout, + selectable: true, + highlightable: true, + isScrollingNotifier: isScrollingNotifier, // hero tag should include a collection identifier, so that it animates // between different views of the entry in the same collection (e.g. thumbnails <-> viewer) // but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer) @@ -86,3 +90,58 @@ class InteractiveThumbnail extends StatelessWidget { ); } } + +class Tile extends StatelessWidget { + final AvesEntry entry; + final double thumbnailExtent; + final TileLayout tileLayout; + final bool selectable, highlightable; + final ValueNotifier? isScrollingNotifier; + final Object? Function()? heroTagger; + + const Tile({ + Key? key, + required this.entry, + required this.thumbnailExtent, + required this.tileLayout, + this.selectable = false, + this.highlightable = false, + this.isScrollingNotifier, + this.heroTagger, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + switch (tileLayout) { + case TileLayout.grid: + return _buildThumbnail(); + case TileLayout.list: + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox.square( + dimension: context.select((v) => v.extent), + child: _buildThumbnail(), + ), + Expanded( + child: EntryListDetails( + entry: entry, + ), + ), + ], + ); + } + } + + Widget _buildThumbnail() => DecoratedThumbnail( + entry: entry, + tileExtent: thumbnailExtent, + // when the user is scrolling faster than we can retrieve the thumbnails, + // the retrieval task queue can pile up for thumbnails that got disposed + // in this case we pause the image retrieval task to get it out of the queue + cancellableNotifier: isScrollingNotifier, + selectable: selectable, + highlightable: highlightable, + heroTagger: heroTagger, + ); +} diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 2ef66c82f..5b383cbc7 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -55,7 +55,6 @@ mixin EntryEditorMixin { context: context, builder: (context) { return AvesDialog( - context: context, content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage), actions: [ TextButton( diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index 9b44db144..ab41e0717 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -50,7 +50,6 @@ mixin PermissionAwareMixin { final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir); final volume = dir.getVolumeDescription(context); return AvesDialog( - context: context, title: context.l10n.storageAccessDialogTitle, content: Text(context.l10n.storageAccessDialogMessage(directory, volume)), actions: [ @@ -84,7 +83,6 @@ mixin PermissionAwareMixin { final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir); final volume = dir.getVolumeDescription(context); return AvesDialog( - context: context, title: context.l10n.restrictedAccessDialogTitle, content: Text(context.l10n.restrictedAccessDialogMessage(directory, volume)), actions: [ diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index bdf94ba41..a7f37b123 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -80,7 +80,6 @@ mixin SizeAwareMixin { final freeSize = formatFileSize(locale, free); final volume = destinationVolume.getDescription(context); return AvesDialog( - context: context, title: l10n.notEnoughSpaceDialogTitle, content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), actions: [ diff --git a/lib/widgets/common/basic/color_list_tile.dart b/lib/widgets/common/basic/color_list_tile.dart index 2aee5b8dc..4ee3a65ba 100644 --- a/lib/widgets/common/basic/color_list_tile.dart +++ b/lib/widgets/common/basic/color_list_tile.dart @@ -71,7 +71,6 @@ class _ColorPickerDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( - context: context, scrollableContent: [ ColorPicker( color: color, diff --git a/lib/widgets/common/grid/item_tracker.dart b/lib/widgets/common/grid/item_tracker.dart index a97c2bf0d..f7b16f219 100644 --- a/lib/widgets/common/grid/item_tracker.dart +++ b/lib/widgets/common/grid/item_tracker.dart @@ -2,21 +2,45 @@ import 'dart:async'; import 'dart:math'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -mixin GridItemTrackerMixin on State, WidgetsBindingObserver { - ValueNotifier get appBarHeightNotifier; +class GridItemTracker extends StatefulWidget { + final GlobalKey scrollableKey; + final ValueNotifier appBarHeightNotifier; + final TileLayout tileLayout; + final ScrollController scrollController; + final Widget child; - ScrollController get scrollController; + const GridItemTracker({ + Key? key, + required this.scrollableKey, + required this.appBarHeightNotifier, + required this.tileLayout, + required this.scrollController, + required this.child, + }) : super(key: key); - GlobalKey get scrollableKey; + @override + _GridItemTrackerState createState() => _GridItemTrackerState(); +} + +class _GridItemTrackerState extends State> with WidgetsBindingObserver { + ValueNotifier get appBarHeightNotifier => widget.appBarHeightNotifier; + + ScrollController get scrollController => widget.scrollController; + + @override + Widget build(BuildContext context) { + return widget.child; + } Size get scrollableSize { - final scrollableContext = scrollableKey.currentContext!; + final scrollableContext = widget.scrollableKey.currentContext!; return (scrollableContext.findRenderObject() as RenderBox).size; } @@ -43,11 +67,27 @@ mixin GridItemTrackerMixin on State, WidgetsBind } @override - void didUpdateWidget(covariant oldWidget) { + void didUpdateWidget(covariant GridItemTracker oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.tileLayout != widget.tileLayout) { + _onLayoutChange(); + } _saveLayoutMetrics(); } + @override + void didChangeMetrics() { + // the order of `WidgetsBindingObserver` metrics change notification is unreliable + // w.r.t. the `MediaQuery` update, and consequentially to this widget update: + // `WidgetsBindingObserver` is notified mostly before, sometimes after, the widget update + final orientation = _windowOrientation; + if (_lastOrientation != orientation) { + _lastOrientation = orientation; + _onLayoutChange(); + _saveLayoutMetrics(); + } + } + @override void dispose() { WidgetsBinding.instance!.removeObserver(this); @@ -95,18 +135,9 @@ mixin GridItemTrackerMixin on State, WidgetsBind } } - @override - void didChangeMetrics() { - final orientation = _windowOrientation; - if (_lastOrientation != orientation) { - _lastOrientation = orientation; - _onWindowOrientationChange(); - } - } - Future _saveLayoutMetrics() async { // use a delay to obtain current layout metrics - // so that we can handle window orientation change beforehand with the previous metrics, + // so that we can handle window orientation change with the previous metrics, // regardless of the `MediaQuery`/`WidgetsBindingObserver` order uncertainty await Future.delayed(const Duration(milliseconds: 500)); @@ -116,10 +147,10 @@ mixin GridItemTrackerMixin on State, WidgetsBind } } - // the order of `WidgetsBindingObserver` metrics change notification is unreliable - // w.r.t. the `MediaQuery` update, and consequentially to this widget update - // `WidgetsBindingObserver` is notified mostly before, sometimes after, the widget update - void _onWindowOrientationChange() { + void _onLayoutChange() { + // do not track when view shows top edge + if (scrollController.offset == 0) return; + final layout = _lastSectionedListLayout; final halfSize = _lastScrollableSize / 2; final center = Offset( diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/grid/scaling.dart similarity index 68% rename from lib/widgets/common/scaling.dart rename to lib/widgets/common/grid/scaling.dart index 054cb20e4..4b62f3a9f 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/grid/scaling.dart @@ -1,6 +1,7 @@ import 'dart:ui' as ui; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/behaviour/eager_scale_gesture_recognizer.dart'; import 'package:aves/widgets/common/grid/theme.dart'; @@ -21,6 +22,7 @@ class ScalerMetadata { class GridScaleGestureDetector extends StatefulWidget { final GlobalKey scrollableKey; + final TileLayout tileLayout; final double Function(double width) heightForWidth; final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder; final Widget Function(T item, Size tileSize) scaledBuilder; @@ -30,6 +32,7 @@ class GridScaleGestureDetector extends StatefulWidget { const GridScaleGestureDetector({ Key? key, required this.scrollableKey, + required this.tileLayout, required this.heightForWidth, required this.gridBuilder, required this.scaledBuilder, @@ -111,17 +114,29 @@ class _GridScaleGestureDetectorState extends State ScaleOverlay( - builder: (scaledTileSize) => SizedBox.fromSize( - size: scaledTileSize, - child: GridTheme( - extent: scaledTileSize.width, - child: widget.scaledBuilder(_metadata!.item, scaledTileSize), - ), - ), - center: thumbnailCenter, + builder: (context) => _ScaleOverlay( + builder: (scaledTileSize) { + late final double themeExtent; + switch (widget.tileLayout) { + case TileLayout.grid: + themeExtent = scaledTileSize.width; + break; + case TileLayout.list: + themeExtent = scaledTileSize.height; + break; + } + return SizedBox.fromSize( + size: scaledTileSize, + child: GridTheme( + extent: themeExtent, + child: widget.scaledBuilder(_metadata!.item, scaledTileSize), + ), + ); + }, + tileLayout: widget.tileLayout, + center: tileCenter, viewportWidth: gridWidth, gridBuilder: widget.gridBuilder, scaledSizeNotifier: _scaledSizeNotifier!, @@ -133,8 +148,16 @@ class _GridScaleGestureDetectorState extends State extends State(); final oldExtent = tileExtentController.extentNotifier.value; // sanitize and update grid layout if necessary - final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width); + late final double preferredExtent; + switch (widget.tileLayout) { + case TileLayout.grid: + preferredExtent = _scaledSizeNotifier!.value.width; + break; + case TileLayout.list: + preferredExtent = _scaledSizeNotifier!.value.height; + break; + } + final newExtent = tileExtentController.setUserPreferredExtent(preferredExtent); _scaledSizeNotifier = null; if (newExtent == oldExtent) { _applyingScale = false; @@ -183,16 +215,18 @@ class _GridScaleGestureDetectorState extends State scaledSizeNotifier; - final Widget Function(Offset center, Size extent, Widget child) gridBuilder; + final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder; - const ScaleOverlay({ + const _ScaleOverlay({ Key? key, required this.builder, + required this.tileLayout, required this.center, required this.viewportWidth, required this.scaledSizeNotifier, @@ -203,7 +237,7 @@ class ScaleOverlay extends StatefulWidget { _ScaleOverlayState createState() => _ScaleOverlayState(); } -class _ScaleOverlayState extends State { +class _ScaleOverlayState extends State<_ScaleOverlay> { bool _init = false; Offset get center => widget.center; @@ -222,26 +256,7 @@ class _ScaleOverlayState extends State { child: Builder( builder: (context) => IgnorePointer( child: AnimatedContainer( - decoration: _init - ? BoxDecoration( - gradient: RadialGradient( - center: FractionalOffset.fromOffsetAndSize(center, context.select((mq) => mq.size)), - radius: 1, - colors: const [ - Colors.black, - Colors.black54, - ], - ), - ) - : const BoxDecoration( - // provide dummy gradient to lerp to the other one during animation - gradient: RadialGradient( - colors: [ - Colors.transparent, - Colors.transparent, - ], - ), - ), + decoration: _buildBackgroundDecoration(context), duration: Durations.collectionScalingBackgroundAnimation, child: ValueListenableBuilder( valueListenable: widget.scaledSizeNotifier, @@ -281,17 +296,53 @@ class _ScaleOverlayState extends State { ), ); } + + BoxDecoration _buildBackgroundDecoration(BuildContext context) { + late final Offset gradientCenter; + switch (widget.tileLayout) { + case TileLayout.grid: + gradientCenter = center; + break; + case TileLayout.list: + gradientCenter = Offset(0, center.dy); + break; + } + + return _init + ? BoxDecoration( + gradient: RadialGradient( + center: FractionalOffset.fromOffsetAndSize(gradientCenter, context.select((mq) => mq.size)), + radius: 1, + colors: const [ + Colors.black, + Colors.black54, + // Colors.amber, + ], + ), + ) + : const BoxDecoration( + // provide dummy gradient to lerp to the other one during animation + gradient: RadialGradient( + colors: [ + Colors.transparent, + Colors.transparent, + ], + ), + ); + } } class GridPainter extends CustomPainter { - final Offset center; + final TileLayout tileLayout; + final Offset tileCenter; final Size tileSize; final double spacing, borderWidth; final Radius borderRadius; final Color color; const GridPainter({ - required this.center, + required this.tileLayout, + required this.tileCenter, required this.tileSize, required this.spacing, required this.borderWidth, @@ -301,40 +352,73 @@ class GridPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final tileWidth = tileSize.width; - final tileHeight = tileSize.height; - + late final Offset chipCenter; + late final Size chipSize; + late final int deltaColumn; + late final Shader strokeShader; + switch (tileLayout) { + case TileLayout.grid: + chipCenter = tileCenter; + chipSize = tileSize; + deltaColumn = 2; + strokeShader = ui.Gradient.radial( + tileCenter, + chipSize.shortestSide * 2, + [ + color, + Colors.transparent, + ], + [ + .8, + 1, + ], + ); + break; + case TileLayout.list: + chipSize = Size.square(tileSize.shortestSide); + chipCenter = Offset(chipSize.width / 2, tileCenter.dy); + deltaColumn = 0; + strokeShader = ui.Gradient.linear( + tileCenter - Offset(0, chipSize.shortestSide * 3), + tileCenter + Offset(0, chipSize.shortestSide * 3), + [ + Colors.transparent, + color, + color, + Colors.transparent, + ], + [ + 0, + .2, + .8, + 1, + ], + ); + break; + } final strokePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = borderWidth - ..shader = ui.Gradient.radial( - center, - tileWidth * 2, - [ - color, - Colors.transparent, - ], - [ - .8, - 1, - ], - ); + ..shader = strokeShader; final fillPaint = Paint() ..style = PaintingStyle.fill ..color = color.withOpacity(.25); - final deltaX = tileWidth + spacing; - final deltaY = tileHeight + spacing; - for (var i = -2; i <= 2; i++) { + final chipWidth = chipSize.width; + final chipHeight = chipSize.height; + + final deltaX = tileSize.width + spacing; + final deltaY = tileSize.height + spacing; + for (var i = -deltaColumn; i <= deltaColumn; i++) { final dx = deltaX * i; for (var j = -2; j <= 2; j++) { if (i == 0 && j == 0) continue; final dy = deltaY * j; final rect = RRect.fromRectAndRadius( Rect.fromCenter( - center: center + Offset(dx, dy), - width: tileWidth, - height: tileHeight, + center: chipCenter + Offset(dx, dy), + width: chipWidth - borderWidth, + height: chipHeight - borderWidth, ), borderRadius, ); diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index 88aaad51e..c2d9999bc 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:collection/collection.dart'; @@ -12,6 +13,7 @@ import 'package:provider/provider.dart'; abstract class SectionedListLayoutProvider extends StatelessWidget { final double scrollableWidth; + final TileLayout tileLayout; final int columnCount; final double spacing, tileWidth, tileHeight; final Widget Function(T item) tileBuilder; @@ -21,14 +23,17 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { const SectionedListLayoutProvider({ Key? key, required this.scrollableWidth, - required this.columnCount, + required this.tileLayout, + required int columnCount, required this.spacing, - required this.tileWidth, + required double tileWidth, required this.tileHeight, required this.tileBuilder, required this.tileAnimationDelay, required this.child, }) : assert(scrollableWidth != 0), + columnCount = tileLayout == TileLayout.list ? 1 : columnCount, + tileWidth = tileLayout == TileLayout.list ? scrollableWidth : tileWidth, super(key: key); @override diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 8e2f0fabe..4c790c718 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -37,7 +37,7 @@ class AvesFilterDecoration { class AvesFilterChip extends StatefulWidget { final CollectionFilter filter; - final bool removable, showGenericIcon, useFilterColor; + final bool removable, showText, showGenericIcon, useFilterColor; final AvesFilterDecoration? decoration; final String? banner; final Widget? leadingOverride, details; @@ -60,6 +60,7 @@ class AvesFilterChip extends StatefulWidget { Key? key, required this.filter, this.removable = false, + this.showText = true, this.showGenericIcon = true, this.useFilterColor = true, this.decoration, @@ -160,66 +161,70 @@ class _AvesFilterChipState extends State { @override Widget build(BuildContext context) { - final chipBackground = Theme.of(context).scaffoldBackgroundColor; - final textScaleFactor = MediaQuery.textScaleFactorOf(context); - final iconSize = AvesFilterChip.iconSize * textScaleFactor; - final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon); - final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null; - final decoration = widget.decoration; - Widget content = Row( - mainAxisSize: decoration != null ? MainAxisSize.max : MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (leading != null) ...[ - leading, - SizedBox(width: padding), - ], - Flexible( - child: Text( - filter.getLabel(context), - style: const TextStyle( - fontSize: AvesFilterChip.fontSize, - ), - softWrap: false, - overflow: TextOverflow.fade, - ), - ), - if (trailing != null) ...[ - SizedBox(width: padding), - trailing, - ], - ], - ); + final chipBackground = Theme.of(context).scaffoldBackgroundColor; - final details = widget.details; - if (details != null) { - content = Column( - mainAxisSize: MainAxisSize.min, + Widget? content; + if (widget.showText) { + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + final iconSize = AvesFilterChip.iconSize * textScaleFactor; + final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon); + final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null; + + content = Row( + mainAxisSize: decoration != null ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - content, - Flexible(child: details), + if (leading != null) ...[ + leading, + SizedBox(width: padding), + ], + Flexible( + child: Text( + filter.getLabel(context), + style: const TextStyle( + fontSize: AvesFilterChip.fontSize, + ), + softWrap: false, + overflow: TextOverflow.fade, + ), + ), + if (trailing != null) ...[ + SizedBox(width: padding), + trailing, + ], ], ); - } - if (decoration != null) { - content = Align( - alignment: Alignment.bottomCenter, - child: ClipRRect( - borderRadius: decoration.textBorderRadius, - child: Container( - padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: AvesFilterChip.decoratedContentVerticalPadding), - color: chipBackground, - child: content, + final details = widget.details; + if (details != null) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + content, + Flexible(child: details), + ], + ); + } + + if (decoration != null) { + content = Align( + alignment: Alignment.bottomCenter, + child: ClipRRect( + borderRadius: decoration.textBorderRadius, + child: Container( + padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: AvesFilterChip.decoratedContentVerticalPadding), + color: chipBackground, + child: content, + ), ), - ), - ); - } else { - content = Padding( - padding: EdgeInsets.symmetric(horizontal: padding * 2), - child: content, - ); + ); + } else { + content = Padding( + padding: EdgeInsets.symmetric(horizontal: padding * 2), + child: content, + ); + } } final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); @@ -244,7 +249,7 @@ class _AvesFilterChipState extends State { borderRadius: borderRadius, ), child: InkWell( - // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, + // as of Flutter v2.8.0, `InkWell` does not have `onLongPressStart` like `GestureDetector`, // so we get the long press details from the tap instead onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, onTap: onTap != null diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 530adbe46..a64941ac1 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -2,9 +2,7 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/grid/theme.dart'; -import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -201,25 +199,9 @@ class IconUtils { required BuildContext context, required String albumPath, double? size, - bool embossed = false, }) { size ??= IconTheme.of(context).size; - Widget buildIcon(IconData icon) => embossed - ? MediaQuery( - // `DecoratedIcon` internally uses `Text`, - // which size depends on the ambient `textScaleFactor` - // but we already accommodate for it upstream - data: context.read().copyWith(textScaleFactor: 1.0), - child: DecoratedIcon( - icon, - shadows: Constants.embossShadows, - size: size, - ), - ) - : Icon( - icon, - size: size, - ); + Widget buildIcon(IconData icon) => Icon(icon, size: size); switch (androidFileUtils.getAlbumType(albumPath)) { case AlbumType.camera: return buildIcon(AIcons.cameraAlbum); diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 54bf19487..94cbbbbd3 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -60,7 +60,6 @@ class _AddShortcutDialogState extends State { final shortestSide = context.select((mq) => mq.size.shortestSide); final extent = (shortestSide / 3.0).clamp(60.0, 160.0); return AvesDialog( - context: context, scrollableContent: [ if (_coverEntry != null) Container( diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index 87bc38ee1..020a70956 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -3,64 +3,66 @@ import 'dart:ui'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -class AvesDialog extends AlertDialog { +class AvesDialog extends StatelessWidget { + final String? title; + final ScrollController? scrollController; + final List? scrollableContent; + final bool hasScrollBar; + final double horizontalContentPadding; + final Widget? content; + final List actions; + static const double defaultHorizontalContentPadding = 24; static const double controlCaptionPadding = 16; static const double borderWidth = 1.0; - AvesDialog({ + const AvesDialog({ Key? key, - required BuildContext context, - String? title, - ScrollController? scrollController, - List? scrollableContent, - bool hasScrollBar = true, - double horizontalContentPadding = defaultHorizontalContentPadding, - Widget? content, - required List actions, + this.title, + this.scrollController, + this.scrollableContent, + this.hasScrollBar = true, + this.horizontalContentPadding = defaultHorizontalContentPadding, + this.content, + required this.actions, }) : assert((scrollableContent != null) ^ (content != null)), - super( - key: key, - title: title != null - ? Padding( - // padding to avoid transparent border overlapping - padding: const EdgeInsets.symmetric(horizontal: borderWidth), - child: DialogTitle(title: title), - ) - : null, - titlePadding: EdgeInsets.zero, - // the `scrollable` flag of `AlertDialog` makes it - // scroll both the title and the content together, - // and overflow feedback ignores the dialog shape, - // so we restrict scrolling to the content instead - content: _buildContent(context, scrollController, scrollableContent, hasScrollBar, content), - contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(horizontalContentPadding, 20, horizontalContentPadding, 0), - actions: actions, - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - shape: RoundedRectangleBorder( - side: Divider.createBorderSide(context, width: borderWidth), - borderRadius: const BorderRadius.all(Radius.circular(24)), - ), - ); + super(key: key); - static Widget _buildContent( - BuildContext context, - ScrollController? scrollController, - List? scrollableContent, - bool hasScrollBar, - Widget? content, - ) { + @override + Widget build(BuildContext context) { + return AlertDialog( + title: title != null + ? Padding( + // padding to avoid transparent border overlapping + padding: const EdgeInsets.symmetric(horizontal: borderWidth), + child: DialogTitle(title: title!), + ) + : null, + titlePadding: EdgeInsets.zero, + // the `scrollable` flag of `AlertDialog` makes it + // scroll both the title and the content together, + // and overflow feedback ignores the dialog shape, + // so we restrict scrolling to the content instead + content: _buildContent(context), + contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(horizontalContentPadding, 20, horizontalContentPadding, 0), + actions: actions, + actionsPadding: const EdgeInsets.symmetric(horizontal: 8), + shape: shape(context), + ); + } + + Widget _buildContent(BuildContext context) { if (content != null) { - return content; + return content!; } if (scrollableContent != null) { - scrollController ??= ScrollController(); + final _scrollController = scrollController ?? ScrollController(); Widget child = ListView( - controller: scrollController, + controller: _scrollController, shrinkWrap: true, - children: scrollableContent, + children: scrollableContent!, ); if (hasScrollBar) { @@ -75,7 +77,7 @@ class AvesDialog extends AlertDialog { ), ), child: Scrollbar( - controller: scrollController, + controller: _scrollController, child: child, ), ); @@ -89,11 +91,7 @@ class AvesDialog extends AlertDialog { // but the `ListView` viewport does not have one width: 1, child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: Divider.createBorderSide(context, width: borderWidth), - ), - ), + decoration: contentDecoration(context), child: child, ), ); @@ -101,6 +99,21 @@ class AvesDialog extends AlertDialog { return const SizedBox(); } + + static Decoration contentDecoration(BuildContext context) => BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: borderWidth), + ), + ); + + static const Radius cornerRadius = Radius.circular(24); + + static ShapeBorder shape(BuildContext context) { + return RoundedRectangleBorder( + side: Divider.createBorderSide(context, width: borderWidth), + borderRadius: const BorderRadius.all(cornerRadius), + ); + } } class DialogTitle extends StatelessWidget { @@ -116,11 +129,7 @@ class DialogTitle extends StatelessWidget { return Container( alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), - decoration: BoxDecoration( - border: Border( - bottom: Divider.createBorderSide(context, width: AvesDialog.borderWidth), - ), - ), + decoration: AvesDialog.contentDecoration(context), child: Text( title, style: const TextStyle( @@ -138,7 +147,6 @@ void showNoMatchingAppDialog(BuildContext context) { context: context, builder: (context) { return AvesDialog( - context: context, title: context.l10n.noMatchingAppDialogTitle, content: Text(context.l10n.noMatchingAppDialogMessage), actions: [ diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index 206d6620d..ff5295e3f 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -40,7 +40,6 @@ class _AvesSelectionDialogState extends State> { final confirmationButtonLabel = widget.confirmationButtonLabel; final needConfirmation = confirmationButtonLabel != null; return AvesDialog( - context: context, title: widget.title, scrollableContent: [ if (message != null) diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 970f0ac89..e323847c6 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -129,7 +129,6 @@ class _EditEntryDateDialogState extends State { ), ), child: AvesDialog( - context: context, title: l10n.editEntryDateDialogTitle, scrollableContent: [ setTile, @@ -289,7 +288,6 @@ class _TimeShiftDialogState extends State { Widget build(BuildContext context) { const textStyle = TextStyle(fontSize: 34); return AvesDialog( - context: context, scrollableContent: [ Center( child: Padding( diff --git a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart index 54c16d7ec..581cafdaf 100644 --- a/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart @@ -46,7 +46,6 @@ class _RemoveEntryMetadataDialogState extends State { final l10n = context.l10n; final animationDuration = context.select((v) => v.expansionTileAnimation); return AvesDialog( - context: context, title: l10n.removeEntryMetadataDialogTitle, scrollableContent: [ ..._mainOptions.map(_toTile), diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart index 7458f5043..2213bf598 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart @@ -41,7 +41,6 @@ class _RenameEntryDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( - context: context, content: TextField( controller: _nameController, decoration: InputDecoration( diff --git a/lib/widgets/dialogs/export_entry_dialog.dart b/lib/widgets/dialogs/export_entry_dialog.dart index c25bdf57e..9e528f0fe 100644 --- a/lib/widgets/dialogs/export_entry_dialog.dart +++ b/lib/widgets/dialogs/export_entry_dialog.dart @@ -33,7 +33,6 @@ class _ExportEntryDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( - context: context, content: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart index 6b9ee9663..72f463e92 100644 --- a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart @@ -4,11 +4,9 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; -import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -29,14 +27,13 @@ class CoverSelectionDialog extends StatefulWidget { class _CoverSelectionDialogState extends State { late bool _isCustom; - AvesEntry? _customEntry, _recentEntry; + AvesEntry? _customEntry; CollectionFilter get filter => widget.filter; @override void initState() { super.initState(); - _recentEntry = context.read().recentEntry(filter); _customEntry = widget.customEntry; _isCustom = _customEntry != null; } @@ -47,10 +44,7 @@ class _CoverSelectionDialogState extends State { child: Builder( builder: (context) { final l10n = context.l10n; - final shortestSide = context.select((mq) => mq.size.shortestSide); - final extent = (shortestSide / 3.0).clamp(60.0, 160.0); return AvesDialog( - context: context, title: l10n.setCoverDialogTitle, scrollableContent: [ ...[false, true].map( @@ -89,17 +83,6 @@ class _CoverSelectionDialogState extends State { ); }, ), - Container( - alignment: Alignment.center, - padding: const EdgeInsets.only(bottom: 16), - child: CoveredFilterChip( - filter: filter, - extent: extent, - coverEntry: _isCustom ? _customEntry : _recentEntry, - onTap: (filter) => _pickEntry(), - heroType: HeroType.never, - ), - ), ], actions: [ TextButton( diff --git a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart index b213f5deb..0c8305314 100644 --- a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -63,7 +63,6 @@ class _CreateAlbumDialogState extends State { } return AvesDialog( - context: context, title: context.l10n.newAlbumDialogTitle, scrollController: _scrollController, scrollableContent: [ diff --git a/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart b/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart index 209468c6a..d13ff04f6 100644 --- a/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart @@ -42,7 +42,6 @@ class _RenameAlbumDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( - context: context, content: ValueListenableBuilder( valueListenable: _existsNotifier, builder: (context, exists, child) { diff --git a/lib/widgets/dialogs/tile_view_dialog.dart b/lib/widgets/dialogs/tile_view_dialog.dart new file mode 100644 index 000000000..744017fc0 --- /dev/null +++ b/lib/widgets/dialogs/tile_view_dialog.dart @@ -0,0 +1,287 @@ +import 'dart:math'; + +import 'package:aves/model/source/enums.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:tuple/tuple.dart'; + +import 'aves_dialog.dart'; + +class TileViewDialog extends StatefulWidget { + final Tuple3 initialValue; + final Map sortOptions; + final Map groupOptions; + final Map layoutOptions; + + const TileViewDialog({ + Key? key, + required this.initialValue, + this.sortOptions = const {}, + this.groupOptions = const {}, + this.layoutOptions = const {}, + }) : super(key: key); + + @override + _TileViewDialogState createState() => _TileViewDialogState(); +} + +class _TileViewDialogState extends State> with SingleTickerProviderStateMixin { + late S? _selectedSort; + late G? _selectedGroup; + late L? _selectedLayout; + late final TabController _tabController; + late final String _optionLines; + + Map get sortOptions => widget.sortOptions; + + Map get groupOptions => widget.groupOptions; + + Map get layoutOptions => widget.layoutOptions; + + double tabBarHeight(BuildContext context) => 64 * max(1, MediaQuery.textScaleFactorOf(context)); + + static const double tabIndicatorWeight = 2; + + @override + void initState() { + super.initState(); + final initialValue = widget.initialValue; + _selectedSort = initialValue.item1; + _selectedGroup = initialValue.item2; + _selectedLayout = initialValue.item3; + + final allOptions = [ + sortOptions, + groupOptions, + layoutOptions, + ]; + + final tabCount = allOptions.where((options) => options.isNotEmpty).length; + _tabController = TabController(length: tabCount, vsync: this); + _tabController.addListener(_onTabChange); + + _optionLines = allOptions.expand((v) => v.values).fold('', (previousValue, element) => '$previousValue\n$element'); + } + + @override + void dispose() { + _tabController.removeListener(_onTabChange); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final tabs = >[ + if (sortOptions.isNotEmpty) + Tuple2( + _buildTab(context, AIcons.sort, l10n.viewDialogTabSort), + Column( + children: sortOptions.entries + .map((kv) => _buildRadioListTile( + kv.key, + kv.value, + () => _selectedSort, + (v) => _selectedSort = v, + )) + .toList(), + ), + ), + if (groupOptions.isNotEmpty) + Tuple2( + _buildTab(context, AIcons.group, l10n.viewDialogTabGroup, color: canGroup ? null : Theme.of(context).disabledColor), + Column( + children: groupOptions.entries + .map((kv) => _buildRadioListTile( + kv.key, + kv.value, + () => _selectedGroup, + (v) => _selectedGroup = v, + )) + .toList(), + ), + ), + if (layoutOptions.isNotEmpty) + Tuple2( + _buildTab(context, AIcons.layout, l10n.viewDialogTabLayout), + Column( + children: layoutOptions.entries + .map((kv) => _buildRadioListTile( + kv.key, + kv.value, + () => _selectedLayout, + (v) => _selectedLayout = v, + )) + .toList(), + ), + ), + ]; + + final contentWidget = DecoratedBox( + decoration: AvesDialog.contentDecoration(context), + child: LayoutBuilder( + builder: (context, constraints) { + final availableBodyHeight = constraints.maxHeight - tabBarHeight(context) - tabIndicatorWeight; + final maxHeight = min(availableBodyHeight, tabBodyMaxHeight(context)); + return Column( + mainAxisSize: MainAxisSize.min, + // crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + borderRadius: const BorderRadius.only( + topLeft: AvesDialog.cornerRadius, + topRight: AvesDialog.cornerRadius, + ), + clipBehavior: Clip.antiAlias, + child: TabBar( + indicatorWeight: tabIndicatorWeight, + tabs: tabs.map((t) => t.item1).toList(), + controller: _tabController, + ), + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + ), + child: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), + children: tabs + .map((t) => SingleChildScrollView( + child: t.item2, + )) + .toList(), + ), + ), + ], + ); + }, + ), + ); + + const actionsPadding = EdgeInsets.symmetric(horizontal: 8); + const double actionsSpacing = 8.0; + final actionsWidget = Padding( + padding: actionsPadding.add(const EdgeInsets.all(actionsSpacing)), + child: OverflowBar( + alignment: MainAxisAlignment.end, + spacing: actionsSpacing, + overflowAlignment: OverflowBarAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, Tuple3(_selectedSort, _selectedGroup, _selectedLayout)), + child: Text(l10n.applyButtonLabel), + ) + ], + ), + ); + + Widget dialogChild = LayoutBuilder( + builder: (context, constraints) { + final availableBodyWidth = constraints.maxWidth; + final maxWidth = min(availableBodyWidth, tabBodyMaxWidth(context)); + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible(child: contentWidget), + actionsWidget, + ], + ), + ); + }, + ); + + return Dialog( + shape: AvesDialog.shape(context), + child: dialogChild, + ); + } + + Tab _buildTab(BuildContext context, IconData icon, String text, {Color? color}) { + // cannot use `IconTheme` over `TabBar` to change size, + // because `TabBar` does so internally + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + final iconSize = IconTheme.of(context).size! * textScaleFactor; + return Tab( + height: tabBarHeight(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: iconSize, + color: color, + ), + const SizedBox(height: 4), + Text( + text, + style: TextStyle(color: color), + softWrap: false, + overflow: TextOverflow.fade, + ), + ], + ), + ); + } + + bool get canGroup => _selectedSort == EntrySortFactor.date || _selectedSort is ChipSortFactor; + + void _onTabChange() { + if (!canGroup && _tabController.index == 1) { + _tabController.index = _tabController.previousIndex; + } + } + + // based on `ListTile` height computation (one line, no subtitle, not dense) + double singleOptionTileHeight(BuildContext context) => 56.0 + Theme.of(context).visualDensity.baseSizeAdjustment.dy; + + double tabBodyMaxWidth(BuildContext context) { + final para = RenderParagraph( + TextSpan(text: _optionLines, style: Theme.of(context).textTheme.subtitle1!), + textDirection: TextDirection.ltr, + textScaleFactor: MediaQuery.textScaleFactorOf(context), + )..layout(const BoxConstraints(), parentUsesSize: true); + final textWidth = para.getMaxIntrinsicWidth(double.infinity); + + // from `RadioListTile` layout + const contentPadding = 32; + const leadingWidth = kMinInteractiveDimension + 8; + return contentPadding + leadingWidth + textWidth; + } + + double tabBodyMaxHeight(BuildContext context) => + [ + sortOptions, + groupOptions, + layoutOptions, + ].map((v) => v.length).fold(0, max) * + singleOptionTileHeight(context); + + Widget _buildRadioListTile(T value, String title, T? Function() get, void Function(T value) set) { + return RadioListTile( + // key is expected by test driver + key: Key(value.toString()), + value: value, + groupValue: get(), + onChanged: (v) => setState(() => set(v!)), + title: Text( + title, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } +} diff --git a/lib/widgets/dialogs/video_speed_dialog.dart b/lib/widgets/dialogs/video_speed_dialog.dart index d7ecebb7a..7aad16f24 100644 --- a/lib/widgets/dialogs/video_speed_dialog.dart +++ b/lib/widgets/dialogs/video_speed_dialog.dart @@ -31,7 +31,6 @@ class _VideoSpeedDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( - context: context, horizontalContentPadding: 4, content: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/dialogs/video_stream_selection_dialog.dart b/lib/widgets/dialogs/video_stream_selection_dialog.dart index ff313221b..a841051d8 100644 --- a/lib/widgets/dialogs/video_stream_selection_dialog.dart +++ b/lib/widgets/dialogs/video_stream_selection_dialog.dart @@ -47,7 +47,6 @@ class _VideoStreamSelectionDialogState extends State final canSelectText = _textStreams.length > 1; final canSelect = canSelectVideo || canSelectAudio || canSelectText; return AvesDialog( - context: context, content: canSelect ? null : Text(context.l10n.videoStreamSelectionDialogNoSelection), scrollableContent: canSelect ? [ diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index df1c5ae49..5857eb046 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -1,3 +1,4 @@ +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/filters/album.dart'; @@ -24,6 +25,21 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; +Future pickAlbum({ + required BuildContext context, + required MoveType? moveType, +}) async { + final source = context.read(); + final filter = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: AlbumPickPage.routeName), + builder: (context) => AlbumPickPage(source: source, moveType: moveType), + ), + ); + return filter?.album; +} + class AlbumPickPage extends StatefulWidget { static const routeName = '/album_pick'; @@ -47,45 +63,47 @@ class _AlbumPickPageState extends State { @override Widget build(BuildContext context) { - return Selector>( - selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor), - builder: (context, s, child) { - return StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - final gridItems = AlbumListPage.getAlbumGridItems(context, source); - return SelectionProvider>( - child: FilterGridPage( - settingsRouteKey: AlbumListPage.routeName, - appBar: AlbumPickAppBar( - source: source, - moveType: widget.moveType, - actionDelegate: AlbumChipSetActionDelegate(gridItems), + return ListenableProvider>.value( + value: ValueNotifier(AppMode.pickInternal), + child: Selector>( + selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor), + builder: (context, s, child) { + return StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + final gridItems = AlbumListPage.getAlbumGridItems(context, source); + return SelectionProvider>( + child: FilterGridPage( + settingsRouteKey: AlbumListPage.routeName, + appBar: AlbumPickAppBar( + source: source, + moveType: widget.moveType, + actionDelegate: AlbumChipSetActionDelegate(gridItems), + queryNotifier: _queryNotifier, + ), + appBarHeight: AlbumPickAppBar.preferredHeight, + sections: AlbumListPage.groupToSections(context, source, gridItems), + newFilters: source.getNewAlbumFilters(context), + sortFactor: settings.albumSortFactor, + showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, + selectable: false, queryNotifier: _queryNotifier, + applyQuery: (filters, query) { + if (query.isEmpty) return filters; + query = query.toUpperCase(); + return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList(); + }, + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: context.l10n.albumEmpty, + ), + heroType: HeroType.never, ), - appBarHeight: AlbumPickAppBar.preferredHeight, - sections: AlbumListPage.groupToSections(context, source, gridItems), - newFilters: source.getNewAlbumFilters(context), - sortFactor: settings.albumSortFactor, - showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, - selectable: false, - queryNotifier: _queryNotifier, - applyQuery: (filters, query) { - if (query.isEmpty) return filters; - query = query.toUpperCase(); - return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList(); - }, - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: context.l10n.albumEmpty, - ), - onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter).album), - heroType: HeroType.never, - ), - ); - }, - ); - }, + ); + }, + ); + }, + ), ); } } @@ -152,12 +170,8 @@ class AlbumPickAppBar extends StatelessWidget { itemBuilder: (context) { return [ PopupMenuItem( - value: ChipSetAction.sort, - child: MenuRow(text: context.l10n.menuActionSort, icon: const Icon(AIcons.sort)), - ), - PopupMenuItem( - value: ChipSetAction.group, - child: MenuRow(text: context.l10n.menuActionGroup, icon: const Icon(AIcons.group)), + value: ChipSetAction.configureView, + child: MenuRow(text: context.l10n.menuActionConfigureView, icon: const Icon(AIcons.view)), ), ]; }, diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index beaa7d5c1..786d82e0c 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -17,14 +17,16 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart'; +import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; class AlbumChipSetActionDelegate extends ChipSetActionDelegate { final Iterable> _items; @@ -40,6 +42,12 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { @override set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; + @override + TileLayout get tileLayout => settings.getTileLayout(AlbumListPage.routeName); + + @override + set tileLayout(TileLayout tileLayout) => settings.setTileLayout(AlbumListPage.routeName, tileLayout); + @override bool isVisible( ChipSetAction action, { @@ -49,8 +57,6 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { required Set selectedFilters, }) { switch (action) { - case ChipSetAction.group: - return true; case ChipSetAction.createAlbum: return appMode == AppMode.main && !isSelecting; case ChipSetAction.delete: @@ -96,9 +102,6 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { void onActionSelected(BuildContext context, Set filters, ChipSetAction action) { switch (action) { // general - case ChipSetAction.group: - _group(context); - break; case ChipSetAction.createAlbum: _createAlbum(context); break; @@ -118,23 +121,42 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { void _browse(BuildContext context) => context.read>>().browse(); - Future _group(BuildContext context) async { - final factor = await showDialog( + @override + Future configureView(BuildContext context) async { + final initialValue = Tuple3( + sortFactor, + settings.albumGroupFactor, + tileLayout, + ); + final value = await showDialog>( context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.albumGroupFactor, - options: { - AlbumChipGroupFactor.importance: context.l10n.albumGroupTier, - AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume, - AlbumChipGroupFactor.none: context.l10n.albumGroupNone, - }, - title: context.l10n.albumGroupTitle, - ), + builder: (context) { + final l10n = context.l10n; + return TileViewDialog( + initialValue: initialValue, + sortOptions: { + ChipSortFactor.date: context.l10n.chipSortDate, + ChipSortFactor.name: context.l10n.chipSortName, + ChipSortFactor.count: context.l10n.chipSortCount, + }, + groupOptions: { + AlbumChipGroupFactor.importance: context.l10n.albumGroupTier, + AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume, + AlbumChipGroupFactor.none: context.l10n.albumGroupNone, + }, + layoutOptions: { + TileLayout.grid: l10n.tileLayoutGrid, + TileLayout.list: l10n.tileLayoutList, + }, + ); + }, ); // wait for the dialog to hide as applying the change may block the UI await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (factor != null) { - settings.albumGroupFactor = factor; + if (value != null && initialValue != value) { + sortFactor = value.item1!; + settings.albumGroupFactor = value.item2!; + tileLayout = value.item3!; } } @@ -172,7 +194,6 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { context: context, builder: (context) { return AvesDialog( - context: context, content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)), actions: [ TextButton( diff --git a/lib/widgets/filter_grids/common/action_delegates/chip.dart b/lib/widgets/filter_grids/common/action_delegates/chip.dart index 229709bae..bc5c1de97 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip.dart @@ -35,7 +35,6 @@ class ChipActionDelegate { context: context, builder: (context) { return AvesDialog( - context: context, content: Text(context.l10n.hideFilterConfirmationDialogMessage), actions: [ TextButton( diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 4a2167d31..c55aa8d8d 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -14,8 +14,8 @@ 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_dialog.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/cover_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -32,6 +32,10 @@ abstract class ChipSetActionDelegate with FeedbackMi set sortFactor(ChipSortFactor factor); + TileLayout get tileLayout; + + set tileLayout(TileLayout tileLayout); + bool isVisible( ChipSetAction action, { required AppMode appMode, @@ -43,10 +47,8 @@ abstract class ChipSetActionDelegate with FeedbackMi final hasSelection = selectedFilters.isNotEmpty; switch (action) { // general - case ChipSetAction.sort: + case ChipSetAction.configureView: return true; - case ChipSetAction.group: - return false; case ChipSetAction.select: return appMode.canSelect && !isSelecting; case ChipSetAction.selectAll: @@ -91,8 +93,7 @@ abstract class ChipSetActionDelegate with FeedbackMi switch (action) { // general - case ChipSetAction.sort: - case ChipSetAction.group: + case ChipSetAction.configureView: case ChipSetAction.select: case ChipSetAction.selectAll: case ChipSetAction.selectNone: @@ -120,10 +121,8 @@ abstract class ChipSetActionDelegate with FeedbackMi void onActionSelected(BuildContext context, Set filters, ChipSetAction action) { switch (action) { // general - case ChipSetAction.sort: - _showSortDialog(context); - break; - case ChipSetAction.group: + case ChipSetAction.configureView: + configureView(context); break; case ChipSetAction.select: context.read>>().select(); @@ -178,23 +177,35 @@ abstract class ChipSetActionDelegate with FeedbackMi return filters.isEmpty ? visibleEntries : visibleEntries.where((entry) => filters.any((f) => f.test(entry))); } - Future _showSortDialog(BuildContext context) async { - final factor = await showDialog( + Future configureView(BuildContext context) async { + final initialValue = Tuple3( + sortFactor, + null, + tileLayout, + ); + final value = await showDialog>( context: context, - builder: (context) => AvesSelectionDialog( - initialValue: sortFactor, - options: { - ChipSortFactor.date: context.l10n.chipSortDate, - ChipSortFactor.name: context.l10n.chipSortName, - ChipSortFactor.count: context.l10n.chipSortCount, - }, - title: context.l10n.chipSortTitle, - ), + builder: (context) { + final l10n = context.l10n; + return TileViewDialog( + initialValue: initialValue, + sortOptions: { + ChipSortFactor.date: context.l10n.chipSortDate, + ChipSortFactor.name: context.l10n.chipSortName, + ChipSortFactor.count: context.l10n.chipSortCount, + }, + layoutOptions: { + TileLayout.grid: l10n.tileLayoutGrid, + TileLayout.list: l10n.tileLayoutList, + }, + ); + }, ); // wait for the dialog to hide as applying the change may block the UI await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (factor != null) { - sortFactor = factor; + if (value != null && initialValue != value) { + sortFactor = value.item1!; + tileLayout = value.item3!; } } @@ -244,7 +255,6 @@ abstract class ChipSetActionDelegate with FeedbackMi context: context, builder: (context) { return AvesDialog( - context: context, content: Text(context.l10n.hideFilterConfirmationDialogMessage), actions: [ TextButton( diff --git a/lib/widgets/filter_grids/common/action_delegates/country_set.dart b/lib/widgets/filter_grids/common/action_delegates/country_set.dart index b9a212372..7ae5e9baf 100644 --- a/lib/widgets/filter_grids/common/action_delegates/country_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/country_set.dart @@ -3,6 +3,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; +import 'package:aves/widgets/filter_grids/countries_page.dart'; class CountryChipSetActionDelegate extends ChipSetActionDelegate { final Iterable> _items; @@ -17,4 +18,10 @@ class CountryChipSetActionDelegate extends ChipSetActionDelegate @override set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor; + + @override + TileLayout get tileLayout => settings.getTileLayout(CountryListPage.routeName); + + @override + set tileLayout(TileLayout tileLayout) => settings.setTileLayout(CountryListPage.routeName, tileLayout); } diff --git a/lib/widgets/filter_grids/common/action_delegates/tag_set.dart b/lib/widgets/filter_grids/common/action_delegates/tag_set.dart index fc4db32f2..87d7c3b07 100644 --- a/lib/widgets/filter_grids/common/action_delegates/tag_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/tag_set.dart @@ -3,6 +3,7 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; +import 'package:aves/widgets/filter_grids/tags_page.dart'; class TagChipSetActionDelegate extends ChipSetActionDelegate { final Iterable> _items; @@ -17,4 +18,10 @@ class TagChipSetActionDelegate extends ChipSetActionDelegate { @override set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor; + + @override + TileLayout get tileLayout => settings.getTileLayout(TagListPage.routeName); + + @override + set tileLayout(TileLayout tileLayout) => settings.setTileLayout(TagListPage.routeName, tileLayout); } diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 2a8ceffdd..47451ffb9 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -12,11 +12,9 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; -import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -24,7 +22,7 @@ class CoveredFilterChip extends StatelessWidget { final T filter; final double extent, thumbnailExtent; final AvesEntry? coverEntry; - final bool pinned; + final bool showText, pinned; final String? banner; final FilterCallback? onTap; final HeroType heroType; @@ -35,6 +33,7 @@ class CoveredFilterChip extends StatelessWidget { required this.extent, double? thumbnailExtent, this.coverEntry, + this.showText = true, this.pinned = false, this.banner, this.onTap, @@ -42,11 +41,14 @@ class CoveredFilterChip extends StatelessWidget { }) : thumbnailExtent = thumbnailExtent ?? extent, super(key: key); - static double tileHeight({required double extent, required double textScaleFactor}) { - return extent + infoHeight(extent: extent, textScaleFactor: textScaleFactor); + static double tileHeight({required double extent, required double textScaleFactor, required bool showText}) { + return extent + infoHeight(extent: extent, textScaleFactor: textScaleFactor, showText: showText); } - static double infoHeight({required double extent, required double textScaleFactor}) { + // info includes title and content details + static double infoHeight({required double extent, required double textScaleFactor, required bool showText}) { + if (!showText) return 0; + // height can actually be a little larger or smaller, when info includes icons or non-latin scripts // but it's not worth measuring text metrics, as the widget is flexible enough to absorb the difference return (AvesFilterChip.fontSize + detailFontSize(extent) + 4) * textScaleFactor + AvesFilterChip.decoratedContentVerticalPadding * 2; @@ -106,13 +108,20 @@ class CoveredFilterChip extends StatelessWidget { return AvesFilterChip( key: chipKey, filter: filter, + showText: showText, showGenericIcon: false, decoration: AvesFilterDecoration( widget: Selector( selector: (context, mq) => mq.textScaleFactor, builder: (context, textScaleFactor, child) { return Padding( - padding: EdgeInsets.only(bottom: infoHeight(extent: extent, textScaleFactor: textScaleFactor)), + padding: EdgeInsets.only( + bottom: infoHeight( + extent: extent, + textScaleFactor: textScaleFactor, + showText: showText, + ), + ), child: child, ); }, @@ -142,7 +151,7 @@ class CoveredFilterChip extends StatelessWidget { radius: radius(extent), ), banner: banner, - details: _buildDetails(source, filter), + details: showText ? _buildDetails(source, filter) : null, padding: titlePadding, heroType: heroType, onTap: onTap, @@ -161,10 +170,9 @@ class CoveredFilterChip extends StatelessWidget { AnimatedPadding( padding: EdgeInsets.only(right: padding), duration: Durations.chipDecorationAnimation, - child: DecoratedIcon( + child: Icon( AIcons.pin, color: FilterGridPage.detailColor, - shadows: Constants.embossShadows, size: iconSize, ), ), @@ -172,10 +180,9 @@ class CoveredFilterChip extends StatelessWidget { AnimatedPadding( padding: EdgeInsets.only(right: padding), duration: Durations.chipDecorationAnimation, - child: DecoratedIcon( + child: Icon( AIcons.removableStorage, color: FilterGridPage.detailColor, - shadows: Constants.embossShadows, size: iconSize, ), ), diff --git a/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart b/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart index 04b07f33d..1f6d68cea 100644 --- a/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart +++ b/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart @@ -7,12 +7,15 @@ import 'package:flutter/widgets.dart'; class FilterChipGridDecorator> extends StatelessWidget { final U gridItem; final double extent; + final bool selectable, highlightable; final Widget child; const FilterChipGridDecorator({ Key? key, required this.gridItem, required this.extent, + this.selectable = true, + this.highlightable = true, required this.child, }) : super(key: key); @@ -26,16 +29,18 @@ class FilterChipGridDecorator>( - item: gridItem, - borderRadius: borderRadius, - padding: EdgeInsets.all(extent / 24), - ), - ChipHighlightOverlay( - filter: gridItem.filter, - extent: extent, - borderRadius: borderRadius, - ), + if (selectable) + GridItemSelectionOverlay>( + item: gridItem, + borderRadius: borderRadius, + padding: EdgeInsets.all(extent / 24), + ), + if (highlightable) + ChipHighlightOverlay( + filter: gridItem.filter, + extent: extent, + borderRadius: borderRadius, + ), ], ), ); diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index ae5398d31..767a723e1 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -12,6 +12,7 @@ import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; +import 'package:aves/widgets/common/grid/scaling.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; @@ -20,12 +21,12 @@ import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; -import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart'; -import 'package:aves/widgets/filter_grids/common/filter_chip_grid_decorator.dart'; +import 'package:aves/widgets/filter_grids/common/filter_tile.dart'; +import 'package:aves/widgets/filter_grids/common/list_details_theme.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_layout.dart'; import 'package:collection/collection.dart'; @@ -48,7 +49,6 @@ class FilterGridPage extends StatelessWidget { final ValueNotifier queryNotifier; final QueryTest? applyQuery; final Widget Function() emptyBuilder; - final FilterCallback onTap; final HeroType heroType; const FilterGridPage({ @@ -64,7 +64,6 @@ class FilterGridPage extends StatelessWidget { required this.queryNotifier, this.applyQuery, required this.emptyBuilder, - required this.onTap, required this.heroType, }) : super(key: key); @@ -103,7 +102,6 @@ class FilterGridPage extends StatelessWidget { queryNotifier: queryNotifier, applyQuery: applyQuery, emptyBuilder: emptyBuilder, - onTap: onTap, heroType: heroType, ), ), @@ -129,7 +127,6 @@ class FilterGrid extends StatefulWidget { final ValueNotifier queryNotifier; final QueryTest? applyQuery; final Widget Function() emptyBuilder; - final FilterCallback onTap; final HeroType heroType; const FilterGrid({ @@ -145,7 +142,6 @@ class FilterGrid extends StatefulWidget { required this.queryNotifier, required this.applyQuery, required this.emptyBuilder, - required this.onTap, required this.heroType, }) : super(key: key); @@ -183,7 +179,6 @@ class _FilterGridState extends State> queryNotifier: widget.queryNotifier, applyQuery: widget.applyQuery, emptyBuilder: widget.emptyBuilder, - onTap: widget.onTap, heroType: widget.heroType, ), ); @@ -199,7 +194,6 @@ class _FilterGridContent extends StatelessWidget { final ValueNotifier queryNotifier; final Widget Function() emptyBuilder; final QueryTest? applyQuery; - final FilterCallback onTap; final HeroType heroType; final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); @@ -216,7 +210,6 @@ class _FilterGridContent extends StatelessWidget { required this.queryNotifier, required this.applyQuery, required this.emptyBuilder, - required this.onTap, required this.heroType, }) : super(key: key) { _appBarHeightNotifier.value = appBarHeight; @@ -224,6 +217,8 @@ class _FilterGridContent extends StatelessWidget { @override Widget build(BuildContext context) { + final settingsRouteKey = context.read().settingsRouteKey; + final tileLayout = context.select((s) => s.getTileLayout(settingsRouteKey)); return ValueListenableBuilder( valueListenable: queryNotifier, builder: (context, query, child) { @@ -240,10 +235,9 @@ class _FilterGridContent extends StatelessWidget { }); } - final pinnedFilters = settings.pinnedFilters; final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), - builder: (context, tileExtent, child) { + builder: (context, thumbnailExtent, child) { return Selector>( selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), builder: (context, c, child) { @@ -256,38 +250,37 @@ class _FilterGridContent extends StatelessWidget { return Selector( selector: (context, mq) => mq.textScaleFactor, builder: (context, textScaleFactor, child) { - final tileHeight = CoveredFilterChip.tileHeight(extent: tileExtent, textScaleFactor: textScaleFactor); + final tileHeight = CoveredFilterChip.tileHeight( + extent: thumbnailExtent, + textScaleFactor: textScaleFactor, + showText: tileLayout == TileLayout.grid, + ); return GridTheme( - extent: tileExtent, - child: SectionedFilterListLayoutProvider( - sections: visibleSections, - showHeaders: showHeaders, - scrollableWidth: scrollableWidth, - columnCount: columnCount, - spacing: tileSpacing, - tileWidth: tileExtent, - tileHeight: tileHeight, - tileBuilder: (gridItem) { - final filter = gridItem.filter; - return MetaData( - metaData: ScalerMetadata(gridItem), - child: FilterChipGridDecorator>( + extent: thumbnailExtent, + child: FilterListDetailsTheme( + extent: thumbnailExtent, + child: SectionedFilterListLayoutProvider( + sections: visibleSections, + showHeaders: showHeaders, + tileLayout: tileLayout, + scrollableWidth: scrollableWidth, + columnCount: columnCount, + spacing: tileSpacing, + tileWidth: thumbnailExtent, + tileHeight: tileHeight, + tileBuilder: (gridItem) { + return InteractiveFilterTile( gridItem: gridItem, - extent: tileExtent, - child: CoveredFilterChip( - key: Key(filter.key), - filter: filter, - extent: tileExtent, - pinned: pinnedFilters.contains(filter), - banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null, - onTap: onTap, - heroType: heroType, - ), - ), - ); - }, - tileAnimationDelay: tileAnimationDelay, - child: child!, + chipExtent: thumbnailExtent, + thumbnailExtent: thumbnailExtent, + tileLayout: tileLayout, + banner: _getFilterBanner(context, gridItem.filter), + heroType: heroType, + ); + }, + tileAnimationDelay: tileAnimationDelay, + child: child!, + ), ), ); }, @@ -304,13 +297,20 @@ class _FilterGridContent extends StatelessWidget { sortFactor: sortFactor, selectable: selectable, emptyBuilder: emptyBuilder, + bannerBuilder: _getFilterBanner, scrollController: PrimaryScrollController.of(context)!, + tileLayout: tileLayout, ), ); return sectionedListLayoutProvider; }, ); } + + String? _getFilterBanner(BuildContext context, T filter) { + final isNew = newFilters.contains(filter); + return isNew ? context.l10n.newFilterBanner : null; + } } class _FilterSectionedContent extends StatefulWidget { @@ -320,7 +320,9 @@ class _FilterSectionedContent extends StatefulWidget final ChipSortFactor sortFactor; final bool selectable; final Widget Function() emptyBuilder; + final String? Function(BuildContext context, T filter) bannerBuilder; final ScrollController scrollController; + final TileLayout tileLayout; const _FilterSectionedContent({ required this.appBar, @@ -329,27 +331,28 @@ class _FilterSectionedContent extends StatefulWidget required this.sortFactor, required this.selectable, required this.emptyBuilder, + required this.bannerBuilder, required this.scrollController, + required this.tileLayout, }); @override _FilterSectionedContentState createState() => _FilterSectionedContentState(); } -class _FilterSectionedContentState extends State<_FilterSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin, _FilterSectionedContent> { +class _FilterSectionedContentState extends State<_FilterSectionedContent> { Widget get appBar => widget.appBar; - @override ValueNotifier get appBarHeightNotifier => widget.appBarHeightNotifier; + TileLayout get tileLayout => widget.tileLayout; + Map>> get visibleSections => widget.visibleSections; Widget Function() get emptyBuilder => widget.emptyBuilder; - @override ScrollController get scrollController => widget.scrollController; - @override final GlobalKey scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable'); @override @@ -374,6 +377,8 @@ class _FilterSectionedContentState extends State<_Fi final scaler = _FilterScaler( scrollableKey: scrollableKey, appBarHeightNotifier: appBarHeightNotifier, + tileLayout: tileLayout, + bannerBuilder: widget.bannerBuilder, child: scrollView, ); @@ -386,7 +391,13 @@ class _FilterSectionedContentState extends State<_Fi child: scaler, ); - return selector; + return GridItemTracker>( + scrollableKey: scrollableKey, + tileLayout: tileLayout, + appBarHeightNotifier: appBarHeightNotifier, + scrollController: scrollController, + child: selector, + ); } Future _checkInitHighlight() async { @@ -405,43 +416,48 @@ class _FilterSectionedContentState extends State<_Fi class _FilterScaler extends StatelessWidget { final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; + final TileLayout tileLayout; + final String? Function(BuildContext context, T filter) bannerBuilder; final Widget child; const _FilterScaler({ required this.scrollableKey, required this.appBarHeightNotifier, + required this.tileLayout, + required this.bannerBuilder, required this.child, }); @override Widget build(BuildContext context) { - final pinnedFilters = settings.pinnedFilters; final tileSpacing = context.select((controller) => controller.spacing); final textScaleFactor = context.select((mq) => mq.textScaleFactor); return GridScaleGestureDetector>( scrollableKey: scrollableKey, - heightForWidth: (width) => CoveredFilterChip.tileHeight(extent: width, textScaleFactor: textScaleFactor), + tileLayout: tileLayout, + heightForWidth: (width) => CoveredFilterChip.tileHeight(extent: width, textScaleFactor: textScaleFactor, showText: true), gridBuilder: (center, tileSize, child) => CustomPaint( painter: GridPainter( - center: center, + tileLayout: tileLayout, + tileCenter: center, tileSize: tileSize, spacing: tileSpacing, borderWidth: AvesFilterChip.outlineWidth, - borderRadius: CoveredFilterChip.radius(tileSize.width), + borderRadius: CoveredFilterChip.radius(tileSize.shortestSide), color: Colors.grey.shade700, ), child: child, ), - scaledBuilder: (item, tileSize) { - final filter = item.filter; - return CoveredFilterChip( - filter: filter, - extent: tileSize.width, + scaledBuilder: (item, tileSize) => FilterListDetailsTheme( + extent: tileSize.height, + child: FilterTile( + gridItem: item, + chipExtent: tileLayout == TileLayout.grid ? tileSize.width : tileSize.height, thumbnailExtent: context.read().effectiveExtentMax, - pinned: pinnedFilters.contains(filter), - heroType: HeroType.never, - ); - }, + tileLayout: tileLayout, + banner: bannerBuilder(context, item.filter), + ), + ), highlightItem: (item) => item.filter, child: child, ); diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index c5cc5ac76..cdfff8149 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -1,8 +1,6 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; -import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; @@ -56,28 +54,14 @@ class FilterNavigationPage extends StatelessWidget { return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink(); }, ), - onTap: (filter) => _goToCollection(context, filter), + // do not always enable hero, otherwise unwanted hero gets triggered + // when using `Show in [...]` action from a chip in the Collection filter bar heroType: HeroType.onTap, ), ), ); } - void _goToCollection(BuildContext context, CollectionFilter filter) { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - collection: CollectionLens( - source: source, - filters: {filter}, - ), - ), - ), - ); - } - static int compareFiltersByDate(FilterGridItem a, FilterGridItem b) { final c = (b.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)).compareTo(a.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)); return c != 0 ? c : a.filter.compareTo(b.filter); diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart new file mode 100644 index 000000000..3877871e3 --- /dev/null +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -0,0 +1,190 @@ +import 'package:aves/app_mode.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/grid/scaling.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/common/filter_chip_grid_decorator.dart'; +import 'package:aves/widgets/filter_grids/common/list_details.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class InteractiveFilterTile extends StatefulWidget { + final FilterGridItem gridItem; + final double chipExtent, thumbnailExtent; + final TileLayout tileLayout; + final String? banner; + final HeroType heroType; + + const InteractiveFilterTile({ + Key? key, + required this.gridItem, + required this.chipExtent, + required this.thumbnailExtent, + required this.tileLayout, + this.banner, + required this.heroType, + }) : super(key: key); + + @override + _InteractiveFilterTileState createState() => _InteractiveFilterTileState(); +} + +class _InteractiveFilterTileState extends State> { + HeroType? _heroTypeOverride; + + FilterGridItem get gridItem => widget.gridItem; + + HeroType get effectiveHeroType => _heroTypeOverride ?? widget.heroType; + + @override + Widget build(BuildContext context) { + final filter = gridItem.filter; + + void onTap() { + final appMode = context.read>().value; + switch (appMode) { + case AppMode.main: + final selection = context.read>>(); + if (selection.isSelecting) { + selection.toggleSelection(gridItem); + } else { + _goToCollection(context, filter); + } + break; + case AppMode.pickInternal: + Navigator.pop(context, filter); + break; + case AppMode.pickExternal: + case AppMode.view: + break; + } + } + + return MetaData( + metaData: ScalerMetadata(gridItem), + child: FilterTile( + gridItem: gridItem, + chipExtent: widget.chipExtent, + thumbnailExtent: widget.thumbnailExtent, + tileLayout: widget.tileLayout, + banner: widget.banner, + selectable: true, + highlightable: true, + onTap: onTap, + heroType: effectiveHeroType, + ), + ); + } + + void _goToCollection(BuildContext context, CollectionFilter filter) { + if (effectiveHeroType == HeroType.onTap) { + // make sure the chip hero triggers, even when tapping on the list view details + setState(() => _heroTypeOverride = HeroType.always); + } + WidgetsBinding.instance!.addPostFrameCallback((_) { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + collection: CollectionLens( + source: context.read(), + filters: {filter}, + ), + ), + ), + ); + }); + } +} + +class FilterTile extends StatelessWidget { + final FilterGridItem gridItem; + final double chipExtent, thumbnailExtent; + final TileLayout tileLayout; + final String? banner; + final bool selectable, highlightable; + final VoidCallback? onTap; + final HeroType heroType; + + const FilterTile({ + Key? key, + required this.gridItem, + required this.chipExtent, + required this.thumbnailExtent, + required this.tileLayout, + this.banner, + this.selectable = false, + this.highlightable = false, + this.onTap, + this.heroType = HeroType.never, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final filter = gridItem.filter; + final pinned = settings.pinnedFilters.contains(filter); + final onChipTap = onTap != null ? (filter) => onTap?.call() : null; + + switch (tileLayout) { + case TileLayout.grid: + return FilterChipGridDecorator>( + gridItem: gridItem, + extent: chipExtent, + selectable: selectable, + highlightable: highlightable, + child: CoveredFilterChip( + filter: filter, + extent: chipExtent, + thumbnailExtent: thumbnailExtent, + showText: true, + pinned: pinned, + banner: banner, + onTap: onChipTap, + heroType: heroType, + ), + ); + case TileLayout.list: + Widget child = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilterChipGridDecorator>( + gridItem: gridItem, + extent: chipExtent, + selectable: selectable, + highlightable: highlightable, + child: CoveredFilterChip( + filter: filter, + extent: chipExtent, + thumbnailExtent: thumbnailExtent, + showText: false, + banner: banner, + onTap: onChipTap, + heroType: heroType, + ), + ), + Expanded( + child: FilterListDetails( + gridItem: gridItem, + pinned: pinned, + ), + ), + ], + ); + if (onTap != null) { + child = InkWell( + borderRadius: const BorderRadius.only(topLeft: Radius.circular(123), bottomLeft: Radius.circular(123)), + onTap: onTap, + child: child, + ); + } + return child; + } + } +} diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart new file mode 100644 index 000000000..1b3bd9ecc --- /dev/null +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -0,0 +1,160 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/format.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/filter_grids/common/list_details_theme.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class FilterListDetails extends StatelessWidget { + final FilterGridItem gridItem; + final bool pinned; + + T get filter => gridItem.filter; + + AvesEntry? get entry => gridItem.entry; + + const FilterListDetails({ + Key? key, + required this.gridItem, + required this.pinned, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final detailsTheme = context.watch(); + + final leading = filter.iconBuilder(context, detailsTheme.titleIconSize, showGenericIcon: false); + final hasTitleLeading = leading != null; + + return Container( + padding: FilterListDetailsTheme.contentPadding, + foregroundDecoration: BoxDecoration( + border: Border(top: AvesBorder.side), + ), + margin: FilterListDetailsTheme.contentMargin, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + if (hasTitleLeading) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: FilterListDetailsTheme.titleIconPadding), + child: IconTheme( + data: IconThemeData(color: detailsTheme.titleStyle.color), + child: leading!, + ), + ), + ), + TextSpan( + text: filter.getLabel(context), + style: detailsTheme.titleStyle, + ), + ], + ), + softWrap: false, + overflow: detailsTheme.titleMaxLines == 1 ? TextOverflow.fade : TextOverflow.ellipsis, + maxLines: detailsTheme.titleMaxLines, + // `textScaleFactor` is applied to font size and icon size at the theme level, + // otherwise the leading icon will be low-res scaled up/down + textScaleFactor: 1, + ), + const SizedBox(height: FilterListDetailsTheme.titleDetailPadding), + if (detailsTheme.showDate) _buildDateRow(context, detailsTheme, hasTitleLeading), + if (detailsTheme.showCount) _buildCountRow(context, detailsTheme, hasTitleLeading), + ], + ), + ); + } + + Widget _buildDateRow(BuildContext context, FilterListDetailsThemeData detailsTheme, bool hasTitleLeading) { + final locale = context.l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + final date = entry?.bestDate; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; + + Widget leading = const Icon(AIcons.date); + if (hasTitleLeading) { + leading = ConstrainedBox( + constraints: BoxConstraints(minWidth: detailsTheme.titleIconSize), + child: leading, + ); + } + return IconTheme.merge( + data: detailsTheme.captionIconTheme, + child: Row( + children: [ + leading, + const SizedBox(width: 8), + Expanded( + child: Text( + dateText, + style: detailsTheme.captionStyle, + strutStyle: Constants.overflowStrutStyle, + softWrap: false, + overflow: TextOverflow.fade, + ), + ), + ], + ), + ); + } + + Widget _buildCountRow(BuildContext context, FilterListDetailsThemeData detailsTheme, bool hasTitleLeading) { + final _filter = filter; + final removableStorage = _filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(_filter.album); + + List leadingIcons = [ + if (pinned) const Icon(AIcons.pin), + if (removableStorage) const Icon(AIcons.removableStorage), + ]; + + Widget? leading; + if (leadingIcons.isNotEmpty) { + leading = Row( + children: leadingIcons + .mapIndexed((i, child) => i > 0 + ? Padding( + padding: const EdgeInsets.only(left: 8), + child: child, + ) + : child) + .toList(), + ); + } + + leading = ConstrainedBox( + constraints: BoxConstraints(minWidth: hasTitleLeading ? detailsTheme.titleIconSize : detailsTheme.captionIconTheme.size!), + child: Center(child: leading ?? const SizedBox()), + ); + + return IconTheme.merge( + data: detailsTheme.captionIconTheme, + child: Row( + children: [ + leading, + const SizedBox(width: 8), + Text( + context.l10n.itemCount(context.read().count(filter)), + style: detailsTheme.captionStyle, + strutStyle: Constants.overflowStrutStyle, + softWrap: false, + overflow: TextOverflow.fade, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/filter_grids/common/list_details_theme.dart b/lib/widgets/filter_grids/common/list_details_theme.dart new file mode 100644 index 000000000..894d160b5 --- /dev/null +++ b/lib/widgets/filter_grids/common/list_details_theme.dart @@ -0,0 +1,109 @@ +import 'dart:math'; + +import 'package:aves/theme/format.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +class FilterListDetailsTheme extends StatelessWidget { + final double extent; + final Widget child; + + static const EdgeInsets contentMargin = EdgeInsets.symmetric(horizontal: 8); + static const EdgeInsets contentPadding = EdgeInsets.symmetric(vertical: 4); + static const double titleIconPadding = 8; + static const double titleDetailPadding = 6; + + const FilterListDetailsTheme({ + Key? key, + required this.extent, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ProxyProvider( + update: (context, mq, previous) { + final locale = context.l10n.localeName; + + final use24hour = mq.alwaysUse24HourFormat; + final textScaleFactor = mq.textScaleFactor; + + final textTheme = Theme.of(context).textTheme; + final titleStyleBase = textTheme.bodyText2!; + final titleStyle = titleStyleBase.copyWith(fontSize: titleStyleBase.fontSize! * textScaleFactor); + final captionStyle = textTheme.caption!; + + final titleIconSize = AvesFilterChip.iconSize * textScaleFactor; + final titleLineHeight = (RenderParagraph( + TextSpan(text: 'Fake Title', style: titleStyle), + textDirection: TextDirection.ltr, + textScaleFactor: textScaleFactor, + )..layout(const BoxConstraints(), parentUsesSize: true)) + .getMaxIntrinsicHeight(double.infinity); + + final captionLineHeight = (RenderParagraph( + TextSpan(text: formatDateTime(DateTime.now(), locale, use24hour), style: captionStyle), + textDirection: TextDirection.ltr, + textScaleFactor: textScaleFactor, + strutStyle: Constants.overflowStrutStyle, + )..layout(const BoxConstraints(), parentUsesSize: true)) + .getMaxIntrinsicHeight(double.infinity); + + var titleMaxLines = 1; + var showCount = false; + var showDate = false; + + var availableHeight = extent - contentMargin.vertical - contentPadding.vertical; + final firstTitleLineHeight = max(titleLineHeight, titleIconSize); + if (availableHeight >= firstTitleLineHeight + titleDetailPadding + captionLineHeight) { + showCount = true; + availableHeight -= firstTitleLineHeight + titleDetailPadding + captionLineHeight; + if (availableHeight >= captionLineHeight) { + showDate = true; + availableHeight -= captionLineHeight; + titleMaxLines += availableHeight ~/ titleLineHeight; + } + } + + return FilterListDetailsThemeData( + extent: extent, + titleMaxLines: titleMaxLines, + showCount: showCount, + showDate: showDate, + titleStyle: titleStyle, + captionStyle: captionStyle, + titleIconSize: titleIconSize, + captionIconTheme: IconThemeData( + color: captionStyle.color, + size: captionStyle.fontSize! * textScaleFactor, + ), + ); + }, + child: child, + ); + } +} + +class FilterListDetailsThemeData { + final double extent; + final int titleMaxLines; + final bool showCount, showDate; + final TextStyle titleStyle, captionStyle; + final double titleIconSize; + final IconThemeData captionIconTheme; + + const FilterListDetailsThemeData({ + required this.extent, + required this.titleMaxLines, + required this.showCount, + required this.showDate, + required this.titleStyle, + required this.captionStyle, + required this.titleIconSize, + required this.captionIconTheme, + }); +} diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart index 6c350b6f1..7055bac26 100644 --- a/lib/widgets/filter_grids/common/section_layout.dart +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -1,4 +1,5 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/filter_grids/common/section_header.dart'; @@ -11,6 +12,7 @@ class SectionedFilterListLayoutProvider extends Sect required this.sections, required this.showHeaders, required double scrollableWidth, + required TileLayout tileLayout, required int columnCount, required double spacing, required double tileWidth, @@ -21,6 +23,7 @@ class SectionedFilterListLayoutProvider extends Sect }) : super( key: key, scrollableWidth: scrollableWidth, + tileLayout: tileLayout, columnCount: columnCount, spacing: spacing, tileWidth: tileWidth, diff --git a/lib/widgets/map/map_info_row.dart b/lib/widgets/map/map_info_row.dart index b4d540a78..53ad3c54f 100644 --- a/lib/widgets/map/map_info_row.dart +++ b/lib/widgets/map/map_info_row.dart @@ -104,7 +104,7 @@ class _AddressRowState extends State<_AddressRow> { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: MapInfoRow.iconPadding), - const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: MapInfoRow.iconSize), + const DecoratedIcon(AIcons.location, size: MapInfoRow.iconSize), const SizedBox(width: MapInfoRow.iconPadding), Expanded( child: Container( @@ -175,7 +175,7 @@ class _DateRow extends StatelessWidget { return Row( children: [ const SizedBox(width: MapInfoRow.iconPadding), - const DecoratedIcon(AIcons.date, shadows: Constants.embossShadows, size: MapInfoRow.iconSize), + const DecoratedIcon(AIcons.date, size: MapInfoRow.iconSize), const SizedBox(width: MapInfoRow.iconPadding), Text( dateText, diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index 3862c7f81..b7d42d4f8 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -63,20 +63,9 @@ class _DrawerAlbumTabState extends State { icon: const Icon(AIcons.add), label: context.l10n.settingsNavigationDrawerAddAlbum, onPressed: () async { - final source = context.read(); - final album = await Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: AlbumPickPage.routeName), - builder: (context) => AlbumPickPage(source: source, moveType: null), - ), - ); - - if (album == null || album.isEmpty) return; - - setState(() { - widget.items.add(album); - }); + final album = await pickAlbum(context: context, moveType: null); + if (album == null) return; + setState(() => widget.items.add(album)); }, ) ], diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 97b7e37af..5c4ba00da 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -161,7 +161,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix context: context, builder: (context) { return AvesDialog( - context: context, content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(1)), actions: [ TextButton( @@ -197,15 +196,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix await source.init(); unawaited(source.refresh()); } - final destinationAlbum = await Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: AlbumPickPage.routeName), - builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export), - ), - ); - - if (destinationAlbum == null || destinationAlbum.isEmpty) return; + final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); + if (destinationAlbum == null) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index 21e1c69b7..fdeff47bf 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -62,7 +62,6 @@ abstract class AvesVideoController { context: context, builder: (context) { return AvesDialog( - context: context, content: Text(context.l10n.videoResumeDialogMessage(formatFriendlyDuration(Duration(milliseconds: resumeTime)))), actions: [ TextButton( diff --git a/untranslated.json b/untranslated.json index 9e26dfeeb..c783aff7e 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,8 @@ -{} \ No newline at end of file +{ + "ru": [ + "menuActionConfigureView", + "viewDialogTabLayout", + "tileLayoutGrid", + "tileLayoutList" + ] +}