From 1adbb453fd94a41d847329019e978951cd5c13cb Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 23 Oct 2024 00:42:50 +0800 Subject: [PATCH 1/5] New nav bar design in Collections page --- app/lib/exception_util.dart | 8 + app/lib/stream_util.dart | 31 ++ app/lib/widget/home_collections.dart | 69 +--- app/lib/widget/home_collections.g.dart | 7 + .../home_collections/navigation_bar.dart | 380 ++++++++++++++++++ app/lib/widget/home_collections/view.dart | 128 ------ 6 files changed, 430 insertions(+), 193 deletions(-) create mode 100644 app/lib/widget/home_collections/navigation_bar.dart diff --git a/app/lib/exception_util.dart b/app/lib/exception_util.dart index 6ce32a62..a0fc8051 100644 --- a/app/lib/exception_util.dart +++ b/app/lib/exception_util.dart @@ -8,6 +8,12 @@ import 'package:nc_photos/exception.dart'; import 'package:nc_photos/navigation_manager.dart'; import 'package:nc_photos/widget/trusted_cert_manager.dart'; +class AppMessageException implements Exception { + const AppMessageException(this.message); + + final String message; +} + /// Convert an exception to a user-facing string /// /// Typically used with SnackBar to show a proper error message @@ -65,6 +71,8 @@ String toUserString(Object? exception) { "Failed to update files: ${exception.files.map((f) => f.filename).join(", ")}", null ); + } else if (exception is AppMessageException) { + return (exception.message, null); } return (exception?.toString() ?? "Unknown error", null); } diff --git a/app/lib/stream_util.dart b/app/lib/stream_util.dart index 199bf5bc..31d71092 100644 --- a/app/lib/stream_util.dart +++ b/app/lib/stream_util.dart @@ -11,3 +11,34 @@ class ValueStreamBuilder extends StreamBuilder { initialData: stream?.value, ); } + +class ValueStreamBuilderEx extends StreamBuilder { + ValueStreamBuilderEx({ + super.key, + ValueStream? stream, + required StreamWidgetBuilder builder, + }) : super( + stream: stream, + initialData: stream?.value, + builder: builder.snapshotBuilder ?? + (context, snapshot) { + return builder.valueBuilder!(context, snapshot.requireData); + }, + ); +} + +class StreamWidgetBuilder { + const StreamWidgetBuilder._({ + this.snapshotBuilder, + this.valueBuilder, + }); + + const StreamWidgetBuilder.snapshot(AsyncWidgetBuilder builder) + : this._(snapshotBuilder: builder); + const StreamWidgetBuilder.value( + Widget Function(BuildContext context, T value) builder) + : this._(valueBuilder: builder); + + final AsyncWidgetBuilder? snapshotBuilder; + final Widget Function(BuildContext context, T value)? valueBuilder; +} diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 1f3b3fd8..63a4c47a 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -21,10 +21,12 @@ import 'package:nc_photos/entity/collection/content_provider/album.dart'; import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; import 'package:nc_photos/entity/collection/util.dart' as collection_util; import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/stream_util.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme/dimension.dart'; import 'package:nc_photos/widget/album_importer.dart'; @@ -49,6 +51,7 @@ import 'package:to_string/to_string.dart'; part 'home_collections.g.dart'; part 'home_collections/app_bar.dart'; part 'home_collections/bloc.dart'; +part 'home_collections/navigation_bar.dart'; part 'home_collections/state_event.dart'; part 'home_collections/type.dart'; part 'home_collections/view.dart'; @@ -138,41 +141,7 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> ? const _AppBar() : const _SelectionAppBar(), ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: _BlocBuilder( - buildWhen: (previous, current) => - previous.selectedItems.isEmpty != - current.selectedItems.isEmpty, - builder: (context, state) => _ButtonGrid( - account: _bloc.account, - isEnabled: state.selectedItems.isEmpty, - onSharingPressed: () { - Navigator.of(context).pushNamed( - SharingBrowser.routeName, - arguments: SharingBrowserArguments(_bloc.account)); - }, - onEnhancedPhotosPressed: () { - Navigator.of(context).pushNamed( - EnhancedPhotoBrowser.routeName, - arguments: - const EnhancedPhotoBrowserArguments(null)); - }, - onArchivePressed: () { - Navigator.of(context) - .pushNamed(ArchiveBrowser.routeName); - }, - onTrashbinPressed: () { - Navigator.of(context).pushNamed( - TrashbinBrowser.routeName, - arguments: TrashbinBrowserArguments(_bloc.account)); - }, - onNewCollectionPressed: () { - _onNewCollectionPressed(context); - }, - ), - ), - ), + const _NavigationBar(), const SliverToBoxAdapter( child: SizedBox(height: 8), ), @@ -241,36 +210,6 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> } } - Future _onNewCollectionPressed(BuildContext context) async { - try { - final collection = await showDialog( - context: context, - builder: (_) => NewCollectionDialog( - account: _bloc.account, - ), - ); - if (collection == null) { - return; - } - // Right now we don't have a way to add photos inside the - // CollectionBrowser, eventually we should add that and remove this - // branching - if (collection.isDynamicCollection) { - // open the newly created collection - unawaited(Navigator.of(context).pushNamed( - CollectionBrowser.routeName, - arguments: CollectionBrowserArguments(collection), - )); - } - } catch (e, stacktrace) { - _log.shout("[_onNewCollectionPressed] Failed", e, stacktrace); - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().createCollectionFailureNotification), - duration: k.snackBarDurationNormal, - )); - } - } - Future _onBackButtonPressed(BuildContext context) async { if (context.state.selectedItems.isEmpty) { return DoubleTapExitHandler()(); diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index 36be50b2..31d70f16 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -85,6 +85,13 @@ extension _$_BlocNpLog on _Bloc { static final log = Logger("widget.home_collections._Bloc"); } +extension _$_NavBarNewButtonNpLog on _NavBarNewButton { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.home_collections._NavBarNewButton"); +} + extension _$_ItemNpLog on _Item { // ignore: unused_element Logger get _log => log; diff --git a/app/lib/widget/home_collections/navigation_bar.dart b/app/lib/widget/home_collections/navigation_bar.dart new file mode 100644 index 00000000..3f608f08 --- /dev/null +++ b/app/lib/widget/home_collections/navigation_bar.dart @@ -0,0 +1,380 @@ +part of '../home_collections.dart'; + +enum HomeCollectionsNavBarButtonType { + // the order must not be changed + sharing, + edited, + archive, + trash, + ; + + static HomeCollectionsNavBarButtonType fromValue(int value) => + HomeCollectionsNavBarButtonType.values[value]; +} + +class _NavigationBar extends StatefulWidget { + const _NavigationBar(); + + @override + State createState() => _NavigationBarState(); +} + +class _NavigationBarState extends State<_NavigationBar> { + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _scrollController + .addListener(() => _updateButtonScroll(_scrollController.position)); + _ensureUpdateButtonScroll(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final buttons = + _buttons.map((e) => _buildButton(context, e)).nonNulls.toList(); + return SliverToBoxAdapter( + child: SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 16), + itemCount: buttons.length, + itemBuilder: (context, i) => buttons[i], + separatorBuilder: (context, _) => const SizedBox(width: 16), + ), + if (_hasLeftContent) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: IgnorePointer( + ignoring: true, + child: Container( + width: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.background, + Theme.of(context) + .colorScheme + .background + .withOpacity(0), + ], + ), + ), + ), + ), + ), + if (_hasRightContent) + Positioned( + right: 0, + top: 0, + bottom: 0, + child: IgnorePointer( + ignoring: true, + child: Container( + width: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context) + .colorScheme + .background + .withOpacity(0), + Theme.of(context).colorScheme.background, + ], + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const _NavBarNewButton(), + const SizedBox(width: 16), + ], + ), + ), + ); + } + + Widget? _buildButton( + BuildContext context, HomeCollectionsNavBarButtonType type) { + switch (type) { + case HomeCollectionsNavBarButtonType.sharing: + return const _NavBarSharingButton(); + case HomeCollectionsNavBarButtonType.edited: + return features.isSupportEnhancement + ? const _NavBarEditedButton() + : null; + case HomeCollectionsNavBarButtonType.archive: + return const _NavBarArchiveButton(); + case HomeCollectionsNavBarButtonType.trash: + return const _NavBarTrashButton(); + } + } + + bool _updateButtonScroll(ScrollPosition pos) { + if (!pos.hasContentDimensions || !pos.hasPixels) { + return false; + } + if (pos.pixels <= pos.minScrollExtent) { + if (_hasLeftContent) { + setState(() { + _hasLeftContent = false; + }); + } + } else { + if (!_hasLeftContent) { + setState(() { + _hasLeftContent = true; + }); + } + } + if (pos.pixels >= pos.maxScrollExtent) { + if (_hasRightContent) { + setState(() { + _hasRightContent = false; + }); + } + } else { + if (!_hasRightContent) { + setState(() { + _hasRightContent = true; + }); + } + } + _hasFirstScrollUpdate = true; + return true; + } + + void _ensureUpdateButtonScroll() { + if (_hasFirstScrollUpdate || !mounted) { + return; + } + if (_scrollController.hasClients) { + if (_updateButtonScroll(_scrollController.position)) { + return; + } + } + Timer(const Duration(milliseconds: 100), _ensureUpdateButtonScroll); + } + + static const _buttons = [ + HomeCollectionsNavBarButtonType.sharing, + HomeCollectionsNavBarButtonType.edited, + HomeCollectionsNavBarButtonType.archive, + HomeCollectionsNavBarButtonType.trash, + ]; + + late final ScrollController _scrollController; + var _hasFirstScrollUpdate = false; + var _hasLeftContent = false; + var _hasRightContent = false; +} + +class _NavBarButtonIndicator extends StatelessWidget { + const _NavBarButtonIndicator(); + + @override + Widget build(BuildContext context) { + return ClipOval( + child: Container( + width: 4, + height: 4, + color: Theme.of(context).colorScheme.error, + ), + ); + } +} + +class _NavBarButton extends StatelessWidget { + const _NavBarButton({ + required this.icon, + required this.label, + required this.isMinimized, + this.isShowIndicator = false, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return _BlocSelector( + selector: (state) => state.selectedItems.isEmpty, + builder: (context, isEnabled) => isMinimized + ? IconButton.outlined( + icon: Stack( + children: [ + icon, + if (isShowIndicator) + const Positioned( + right: 2, + top: 2, + child: _NavBarButtonIndicator(), + ), + ], + ), + tooltip: label, + onPressed: isEnabled ? onPressed : null, + ) + : ActionChip( + avatar: icon, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + if (isShowIndicator) ...const [ + SizedBox(width: 4), + _NavBarButtonIndicator(), + ], + ], + ), + onPressed: isEnabled ? onPressed : null, + ), + ); + } + + final Widget icon; + final String label; + final bool isMinimized; + final bool isShowIndicator; + final VoidCallback onPressed; +} + +@npLog +class _NavBarNewButton extends StatelessWidget { + const _NavBarNewButton(); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.add_outlined), + label: L10n.global().createCollectionTooltip, + isMinimized: true, + onPressed: () async { + try { + final collection = await showDialog( + context: context, + builder: (_) => NewCollectionDialog( + account: context.bloc.account, + ), + ); + if (collection == null) { + return; + } + // Right now we don't have a way to add photos inside the + // CollectionBrowser, eventually we should add that and remove this + // branching + if (collection.isDynamicCollection) { + // open the newly created collection + unawaited(Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(collection), + )); + } + } catch (e, stacktrace) { + _log.shout("[build] Uncaught exception", e, stacktrace); + context.addEvent(_SetError(AppMessageException( + L10n.global().createCollectionFailureNotification))); + } + }, + ); + } +} + +class _NavBarSharingButton extends StatelessWidget { + const _NavBarSharingButton(); + + @override + Widget build(BuildContext context) { + return ValueStreamBuilderEx( + stream: context + .read() + .accountPrefController + .hasNewSharedAlbum, + builder: StreamWidgetBuilder.value( + (context, hasNewSharedAlbum) => _NavBarButton( + icon: const Icon(Icons.share_outlined), + label: L10n.global().collectionSharingLabel, + isMinimized: false, + isShowIndicator: hasNewSharedAlbum, + onPressed: () { + Navigator.of(context).pushNamed( + SharingBrowser.routeName, + arguments: SharingBrowserArguments(context.bloc.account), + ); + }, + ), + ), + ); + } +} + +class _NavBarEditedButton extends StatelessWidget { + const _NavBarEditedButton(); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.auto_fix_high_outlined), + label: L10n.global().collectionEditedPhotosLabel, + isMinimized: false, + onPressed: () { + Navigator.of(context).pushNamed( + EnhancedPhotoBrowser.routeName, + arguments: const EnhancedPhotoBrowserArguments(null), + ); + }, + ); + } +} + +class _NavBarArchiveButton extends StatelessWidget { + const _NavBarArchiveButton(); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.archive_outlined), + label: L10n.global().albumArchiveLabel, + isMinimized: false, + onPressed: () { + Navigator.of(context).pushNamed(ArchiveBrowser.routeName); + }, + ); + } +} + +class _NavBarTrashButton extends StatelessWidget { + const _NavBarTrashButton(); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.delete_outlined), + label: L10n.global().albumTrashLabel, + isMinimized: false, + onPressed: () { + Navigator.of(context).pushNamed( + TrashbinBrowser.routeName, + arguments: TrashbinBrowserArguments(context.bloc.account), + ); + }, + ); + } +} diff --git a/app/lib/widget/home_collections/view.dart b/app/lib/widget/home_collections/view.dart index ab9f2baf..449277a9 100644 --- a/app/lib/widget/home_collections/view.dart +++ b/app/lib/widget/home_collections/view.dart @@ -1,133 +1,5 @@ part of '../home_collections.dart'; -class _ButtonGrid extends StatelessWidget { - const _ButtonGrid({ - required this.account, - required this.isEnabled, - this.onSharingPressed, - this.onEnhancedPhotosPressed, - this.onArchivePressed, - this.onTrashbinPressed, - this.onNewCollectionPressed, - }); - - @override - Widget build(BuildContext context) { - // needed to workaround a scrolling bug when there are more than one - // SliverStaggeredGrids in a CustomScrollView - // see: https://github.com/letsar/flutter_staggered_grid_view/issues/98 and - // https://github.com/letsar/flutter_staggered_grid_view/issues/265 - return SliverToBoxAdapter( - child: StaggeredGridView.extent( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.all(0), - maxCrossAxisExtent: 256, - staggeredTiles: List.filled(5, const StaggeredTile.fit(1)), - children: [ - _ButtonGridItemView( - icon: Icons.share_outlined, - label: L10n.global().collectionSharingLabel, - isShowIndicator: context - .read() - .accountPrefController - .hasNewSharedAlbumValue, - isEnabled: isEnabled, - onTap: () { - onSharingPressed?.call(); - }, - ), - if (features.isSupportEnhancement) - _ButtonGridItemView( - icon: Icons.auto_fix_high_outlined, - label: L10n.global().collectionEditedPhotosLabel, - isEnabled: isEnabled, - onTap: () { - onEnhancedPhotosPressed?.call(); - }, - ), - _ButtonGridItemView( - icon: Icons.archive_outlined, - label: L10n.global().albumArchiveLabel, - isEnabled: isEnabled, - onTap: () { - onArchivePressed?.call(); - }, - ), - _ButtonGridItemView( - icon: Icons.delete_outlined, - label: L10n.global().albumTrashLabel, - isEnabled: isEnabled, - onTap: () { - onTrashbinPressed?.call(); - }, - ), - _ButtonGridItemView( - icon: Icons.add, - label: L10n.global().createCollectionTooltip, - isEnabled: isEnabled, - onTap: () { - onNewCollectionPressed?.call(); - }, - ), - ], - ), - ); - } - - final Account account; - final bool isEnabled; - final VoidCallback? onSharingPressed; - final VoidCallback? onEnhancedPhotosPressed; - final VoidCallback? onArchivePressed; - final VoidCallback? onTrashbinPressed; - final VoidCallback? onNewCollectionPressed; -} - -class _ButtonGridItemView extends StatelessWidget { - const _ButtonGridItemView({ - required this.icon, - required this.label, - this.isShowIndicator = false, - required this.isEnabled, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(4), - child: ActionChip( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - labelPadding: const EdgeInsetsDirectional.fromSTEB(8, 0, 0, 0), - // specify icon size explicitly to workaround size flickering during - // theme transition - avatar: Icon(icon, size: 18), - label: Row( - children: [ - Expanded( - child: Text(label), - ), - if (isShowIndicator) - Icon( - Icons.circle, - color: Theme.of(context).colorScheme.tertiary, - size: 8, - ), - ], - ), - onPressed: isEnabled ? onTap : null, - ), - ); - } - - final IconData icon; - final String label; - final bool isShowIndicator; - final bool isEnabled; - final VoidCallback? onTap; -} - class _ItemView extends StatelessWidget { const _ItemView({ required this.account, From 121f611c7f4aef00b3e6913a5b3114eb068b3403 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 26 Oct 2024 21:35:48 +0800 Subject: [PATCH 2/5] New settings page to configure nav bar in collections tab --- app/lib/controller/pref_controller.dart | 39 +++ app/lib/controller/pref_controller.g.dart | 12 + app/lib/controller/pref_controller/type.dart | 21 ++ app/lib/controller/pref_controller/util.dart | 10 + app/lib/entity/pref.dart | 3 + app/lib/l10n/app_en.arb | 5 + app/lib/l10n/untranslated-messages.txt | 56 +++- app/lib/widget/home_collections.dart | 2 + app/lib/widget/home_collections.g.dart | 13 +- app/lib/widget/home_collections/bloc.dart | 12 + .../home_collections/nav_bar_buttons.dart | 241 ++++++++++++++++++ .../home_collections/navigation_bar.dart | 224 ++-------------- .../widget/home_collections/state_event.dart | 15 ++ .../widget/settings/collection_settings.dart | 12 + .../settings/collections_nav_bar/bloc.dart | 122 +++++++++ .../settings/collections_nav_bar/buttons.dart | 108 ++++++++ .../collections_nav_bar/state_event.dart | 106 ++++++++ .../settings/collections_nav_bar/view.dart | 199 +++++++++++++++ .../collections_nav_bar_settings.dart | 120 +++++++++ .../collections_nav_bar_settings.g.dart | 110 ++++++++ np_collection/lib/src/list_extension.dart | 3 + 21 files changed, 1220 insertions(+), 213 deletions(-) create mode 100644 app/lib/widget/home_collections/nav_bar_buttons.dart create mode 100644 app/lib/widget/settings/collections_nav_bar/bloc.dart create mode 100644 app/lib/widget/settings/collections_nav_bar/buttons.dart create mode 100644 app/lib/widget/settings/collections_nav_bar/state_event.dart create mode 100644 app/lib/widget/settings/collections_nav_bar/view.dart create mode 100644 app/lib/widget/settings/collections_nav_bar_settings.dart create mode 100644 app/lib/widget/settings/collections_nav_bar_settings.g.dart diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart index feaae446..5eca835b 100644 --- a/app/lib/controller/pref_controller.dart +++ b/app/lib/controller/pref_controller.dart @@ -12,9 +12,11 @@ import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/language_util.dart'; import 'package:nc_photos/protected_page_handler.dart'; import 'package:nc_photos/size.dart'; +import 'package:nc_photos/widget/home_collections.dart'; import 'package:nc_photos/widget/viewer.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/object_util.dart'; +import 'package:np_common/type.dart'; import 'package:np_gps_map/np_gps_map.dart'; import 'package:np_string/np_string.dart'; import 'package:rxdart/rxdart.dart'; @@ -261,6 +263,17 @@ class PrefController { defaultValue: _viewerBottomAppBarButtonsDefault, ); + Future setHomeCollectionsNavBarButtons( + List? value) => + _setOrRemove( + controller: _homeCollectionsNavBarButtonsController, + setter: (pref, value) => pref.setHomeCollectionsNavBarButtonsJson( + jsonEncode(value.map((e) => e.toJson()).toList())), + remover: (pref) => pref.setHomeCollectionsNavBarButtonsJson(null), + value: value, + defaultValue: _homeCollectionsNavBarButtonsDefault, + ); + Future _set({ required BehaviorSubject controller, required Future Function(Pref pref, T value) setter, @@ -406,6 +419,14 @@ class PrefController { @npSubjectAccessor late final _viewerBottomAppBarButtonsController = BehaviorSubject.seeded( pref.getViewerBottomAppBarButtons() ?? _viewerBottomAppBarButtonsDefault); + @npSubjectAccessor + late final _homeCollectionsNavBarButtonsController = BehaviorSubject.seeded( + pref.getHomeCollectionsNavBarButtonsJson()?.let((s) => + (jsonDecode(s) as List) + .cast() + .map(PrefHomeCollectionsNavButton.fromJson) + .toList()) ?? + _homeCollectionsNavBarButtonsDefault); } extension PrefControllerExtension on PrefController { @@ -561,6 +582,24 @@ const _viewerBottomAppBarButtonsDefault = [ ViewerAppBarButtonType.download, ViewerAppBarButtonType.delete, ]; +const _homeCollectionsNavBarButtonsDefault = [ + PrefHomeCollectionsNavButton( + type: HomeCollectionsNavBarButtonType.sharing, + isMinimized: false, + ), + PrefHomeCollectionsNavButton( + type: HomeCollectionsNavBarButtonType.edited, + isMinimized: false, + ), + PrefHomeCollectionsNavButton( + type: HomeCollectionsNavBarButtonType.archive, + isMinimized: false, + ), + PrefHomeCollectionsNavButton( + type: HomeCollectionsNavBarButtonType.trash, + isMinimized: false, + ), +]; @npLog // ignore: camel_case_types diff --git a/app/lib/controller/pref_controller.g.dart b/app/lib/controller/pref_controller.g.dart index 9b1c5bbb..f2e92c47 100644 --- a/app/lib/controller/pref_controller.g.dart +++ b/app/lib/controller/pref_controller.g.dart @@ -252,6 +252,18 @@ extension $PrefControllerNpSubjectAccessor on PrefController { viewerBottomAppBarButtons.distinct().skip(1); List get viewerBottomAppBarButtonsValue => _viewerBottomAppBarButtonsController.value; +// _homeCollectionsNavBarButtonsController + ValueStream> + get homeCollectionsNavBarButtons => + _homeCollectionsNavBarButtonsController.stream; + Stream> + get homeCollectionsNavBarButtonsNew => + homeCollectionsNavBarButtons.skip(1); + Stream> + get homeCollectionsNavBarButtonsChange => + homeCollectionsNavBarButtons.distinct().skip(1); + List get homeCollectionsNavBarButtonsValue => + _homeCollectionsNavBarButtonsController.value; } extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController { diff --git a/app/lib/controller/pref_controller/type.dart b/app/lib/controller/pref_controller/type.dart index 43c41786..25c93d2e 100644 --- a/app/lib/controller/pref_controller/type.dart +++ b/app/lib/controller/pref_controller/type.dart @@ -14,3 +14,24 @@ enum PrefMapDefaultRangeType { final int value; } + +class PrefHomeCollectionsNavButton { + const PrefHomeCollectionsNavButton({ + required this.type, + required this.isMinimized, + }); + + static PrefHomeCollectionsNavButton fromJson(JsonObj json) => + PrefHomeCollectionsNavButton( + type: HomeCollectionsNavBarButtonType.fromValue(json["type"]), + isMinimized: json["isMinimized"], + ); + + JsonObj toJson() => { + "type": type.index, + "isMinimized": isMinimized, + }; + + final HomeCollectionsNavBarButtonType type; + final bool isMinimized; +} diff --git a/app/lib/controller/pref_controller/util.dart b/app/lib/controller/pref_controller/util.dart index 491f0e09..ad3c6f2e 100644 --- a/app/lib/controller/pref_controller/util.dart +++ b/app/lib/controller/pref_controller/util.dart @@ -185,6 +185,16 @@ extension on Pref { value.map((e) => e.index).toList()); } } + + String? getHomeCollectionsNavBarButtonsJson() => + provider.getString(PrefKey.homeCollectionsNavBarButtons); + Future setHomeCollectionsNavBarButtonsJson(String? value) { + if (value == null) { + return provider.remove(PrefKey.homeCollectionsNavBarButtons); + } else { + return provider.setString(PrefKey.homeCollectionsNavBarButtons, value); + } + } } MapCoord? _tryMapCoordFromJson(dynamic json) { diff --git a/app/lib/entity/pref.dart b/app/lib/entity/pref.dart index 57e8b603..5cfb6a8b 100644 --- a/app/lib/entity/pref.dart +++ b/app/lib/entity/pref.dart @@ -117,6 +117,7 @@ enum PrefKey implements PrefKeyInterface { mapDefaultCustomRange, viewerAppBarButtons, viewerBottomAppBarButtons, + homeCollectionsNavBarButtons, ; @override @@ -215,6 +216,8 @@ enum PrefKey implements PrefKeyInterface { return "viewerAppBarButtons"; case PrefKey.viewerBottomAppBarButtons: return "viewerBottomAppBarButtons"; + case PrefKey.homeCollectionsNavBarButtons: + return "homeCollectionsNavBarButtons"; } } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 03387093..f02db856 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -360,6 +360,7 @@ "settingsViewerCustomizeBottomAppBarTitle": "Customize bottom app bar", "settingsShowDateInAlbumTitle": "Group photos by date", "settingsShowDateInAlbumDescription": "Apply only when the album is sorted by time", + "settingsCollectionsCustomizeNavigationBarTitle": "Customize navigation bar", "settingsImageEditTitle": "Editor", "@settingsImageEditTitle": { "description": "Include settings for image enhancements and the image editor" @@ -1512,6 +1513,10 @@ "@dragAndDropRearrangeButtons": { "description": "Instruction to customize buttons layout" }, + "customizeCollectionsNavBarDescription": "Drag and drop to rearrange buttons, tap the buttons above to minimize them", + "@customizeCollectionsNavBarDescription": { + "description": "Instruction to customize navigation bar buttons in the Collections page" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index d42861b6..e0acea0d 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -18,6 +18,7 @@ "settingsViewerCustomizeBottomAppBarTitle", "settingsShowDateInAlbumTitle", "settingsShowDateInAlbumDescription", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsImageEditTitle", "settingsImageEditDescription", "settingsEnhanceMaxResolutionTitle2", @@ -265,6 +266,7 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -278,17 +280,21 @@ "cs": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "de": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "el": [ @@ -308,6 +314,7 @@ "settingsMemoriesRangeValueText", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsImageEditTitle", "settingsImageEditDescription", "settingsEnhanceMaxResolutionTitle2", @@ -441,20 +448,24 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "es": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "fi": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsThemePrimaryColor", "settingsThemeSecondaryColor", "settingsThemePresets", @@ -493,12 +504,14 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "fr": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsThemePrimaryColor", "settingsThemeSecondaryColor", "settingsThemePresets", @@ -537,13 +550,15 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "it": [ "settingsPersonProviderTitle", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsImageEditTitle", "settingsThemePrimaryColor", "settingsThemeSecondaryColor", @@ -586,7 +601,8 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "nl": [ @@ -647,6 +663,7 @@ "settingsViewerCustomizeBottomAppBarTitle", "settingsShowDateInAlbumTitle", "settingsShowDateInAlbumDescription", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsImageEditTitle", "settingsImageEditDescription", "settingsEnhanceMaxResolutionTitle2", @@ -972,6 +989,7 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -986,6 +1004,7 @@ "settingsMemoriesRangeValueText", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsThemePrimaryColor", "settingsThemeSecondaryColor", "settingsThemePresets", @@ -1027,7 +1046,8 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "pt": [ @@ -1036,6 +1056,7 @@ "settingsPersonProviderTitle", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsSeedColorSystemColorDescription", "settingsThemePrimaryColor", "settingsThemeSecondaryColor", @@ -1091,12 +1112,14 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "ru": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsThemePrimaryColor", "settingsThemeSecondaryColor", "settingsThemePresets", @@ -1135,15 +1158,18 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "tr": [ "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "zh": [ @@ -1152,6 +1178,7 @@ "settingsMemoriesRangeValueText", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsSeedColorDescription", "settingsSeedColorSystemColorDescription", "settingsThemePrimaryColor", @@ -1218,7 +1245,8 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ], "zh_Hant": [ @@ -1238,6 +1266,7 @@ "settingsMemoriesRangeValueText", "settingsViewerCustomizeAppBarTitle", "settingsViewerCustomizeBottomAppBarTitle", + "settingsCollectionsCustomizeNavigationBarTitle", "settingsImageEditTitle", "settingsImageEditDescription", "settingsEnhanceMaxResolutionTitle2", @@ -1387,6 +1416,7 @@ "todayText", "alternativeSignIn", "livePhotoTooltip", - "dragAndDropRearrangeButtons" + "dragAndDropRearrangeButtons", + "customizeCollectionsNavBarDescription" ] } diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 63a4c47a..7d8276f6 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -51,6 +51,7 @@ import 'package:to_string/to_string.dart'; part 'home_collections.g.dart'; part 'home_collections/app_bar.dart'; part 'home_collections/bloc.dart'; +part 'home_collections/nav_bar_buttons.dart'; part 'home_collections/navigation_bar.dart'; part 'home_collections/state_event.dart'; part 'home_collections/type.dart'; @@ -226,6 +227,7 @@ typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; typedef _BlocListener = BlocListener<_Bloc, _State>; // typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _Emitter = Emitter<_State>; extension on BuildContext { _Bloc get bloc => read<_Bloc>(); diff --git a/app/lib/widget/home_collections.g.dart b/app/lib/widget/home_collections.g.dart index 31d70f16..a77dda91 100644 --- a/app/lib/widget/home_collections.g.dart +++ b/app/lib/widget/home_collections.g.dart @@ -20,6 +20,7 @@ abstract class $_StateCopyWithWorker { List<_Item>? transformedItems, Set<_Item>? selectedItems, Map? itemCounts, + List? navBarButtons, ExceptionEvent? error, ExceptionEvent? removeError}); } @@ -35,6 +36,7 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { dynamic transformedItems, dynamic selectedItems, dynamic itemCounts, + dynamic navBarButtons, dynamic error = copyWithNull, dynamic removeError = copyWithNull}) { return _State( @@ -45,6 +47,8 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { transformedItems as List<_Item>? ?? that.transformedItems, selectedItems: selectedItems as Set<_Item>? ?? that.selectedItems, itemCounts: itemCounts as Map? ?? that.itemCounts, + navBarButtons: navBarButtons as List? ?? + that.navBarButtons, error: error == copyWithNull ? that.error : error as ExceptionEvent?, removeError: removeError == copyWithNull ? that.removeError @@ -106,7 +110,7 @@ extension _$_ItemNpLog on _Item { extension _$_StateToString on _State { String _$toString() { // ignore: unnecessary_string_interpolations - return "_State {collections: [length: ${collections.length}], sort: ${sort.name}, isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, itemCounts: {length: ${itemCounts.length}}, error: $error, removeError: $removeError}"; + return "_State {collections: [length: ${collections.length}], sort: ${sort.name}, isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, itemCounts: {length: ${itemCounts.length}}, navBarButtons: [length: ${navBarButtons.length}], error: $error, removeError: $removeError}"; } } @@ -166,6 +170,13 @@ extension _$_SetItemCountToString on _SetItemCount { } } +extension _$_SetNavBarButtonsToString on _SetNavBarButtons { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetNavBarButtons {value: [length: ${value.length}]}"; + } +} + extension _$_SetErrorToString on _SetError { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/home_collections/bloc.dart b/app/lib/widget/home_collections/bloc.dart index a9817df3..fe3971ba 100644 --- a/app/lib/widget/home_collections/bloc.dart +++ b/app/lib/widget/home_collections/bloc.dart @@ -9,6 +9,7 @@ class _Bloc extends Bloc<_Event, _State> required this.prefController, }) : super(_State.init( sort: prefController.homeAlbumsSortValue, + navBarButtons: prefController.homeCollectionsNavBarButtonsValue, )) { on<_LoadCollections>(_onLoad); on<_ReloadCollections>(_onReload); @@ -21,6 +22,8 @@ class _Bloc extends Bloc<_Event, _State> on<_SetCollectionSort>(_onSetCollectionSort); on<_SetItemCount>(_onSetItemCount); + on<_SetNavBarButtons>(_onSetNavBarButtons); + on<_SetError>(_onSetError); _subscriptions.add(prefController.homeAlbumsSortChange.listen((event) { @@ -39,6 +42,10 @@ class _Bloc extends Bloc<_Event, _State> })); } })); + _subscriptions + .add(prefController.homeCollectionsNavBarButtonsChange.listen((event) { + add(_SetNavBarButtons(event)); + })); } @override @@ -134,6 +141,11 @@ class _Bloc extends Bloc<_Event, _State> emit(state.copyWith(itemCounts: next)); } + void _onSetNavBarButtons(_SetNavBarButtons ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(navBarButtons: ev.value)); + } + void _onSetError(_SetError ev, Emitter<_State> emit) { _log.info(ev); emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); diff --git a/app/lib/widget/home_collections/nav_bar_buttons.dart b/app/lib/widget/home_collections/nav_bar_buttons.dart new file mode 100644 index 00000000..324c65cd --- /dev/null +++ b/app/lib/widget/home_collections/nav_bar_buttons.dart @@ -0,0 +1,241 @@ +part of '../home_collections.dart'; + +class HomeCollectionsNavBarButton extends StatelessWidget { + const HomeCollectionsNavBarButton({ + super.key, + required this.icon, + required this.label, + required this.isMinimized, + this.isShowIndicator = false, + this.isEnabled = true, + this.isUseTooltipWhenMinimized = true, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + if (isMinimized) { + return IconButtonTheme( + data: const IconButtonThemeData( + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + child: IconButton.outlined( + icon: Stack( + children: [ + IconTheme( + data: IconThemeData( + size: 18, + color: Theme.of(context).colorScheme.secondary, + ), + child: icon, + ), + if (isShowIndicator) + const Positioned( + right: 2, + top: 2, + child: _NavBarButtonIndicator(), + ), + ], + ), + tooltip: isUseTooltipWhenMinimized ? label : null, + onPressed: isEnabled ? onPressed : null, + ), + ); + } else { + return ActionChip( + avatar: icon, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + if (isShowIndicator) ...const [ + SizedBox(width: 4), + _NavBarButtonIndicator(), + ], + ], + ), + onPressed: isEnabled ? onPressed : null, + ); + } + } + + final Widget icon; + final String label; + final bool isMinimized; + final bool isShowIndicator; + final bool isEnabled; + final bool isUseTooltipWhenMinimized; + final VoidCallback? onPressed; +} + +class _NavBarButton extends StatelessWidget { + const _NavBarButton({ + required this.icon, + required this.label, + required this.isMinimized, + this.isShowIndicator = false, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return _BlocSelector( + selector: (state) => state.selectedItems.isEmpty, + builder: (context, isEnabled) => HomeCollectionsNavBarButton( + icon: icon, + label: label, + isMinimized: isMinimized, + isShowIndicator: isShowIndicator, + isEnabled: isEnabled, + onPressed: onPressed, + ), + ); + } + + final Widget icon; + final String label; + final bool isMinimized; + final bool isShowIndicator; + final VoidCallback? onPressed; +} + +@npLog +class _NavBarNewButton extends StatelessWidget { + const _NavBarNewButton(); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.add_outlined), + label: L10n.global().createCollectionTooltip, + isMinimized: true, + onPressed: () async { + try { + final collection = await showDialog( + context: context, + builder: (_) => NewCollectionDialog( + account: context.bloc.account, + ), + ); + if (collection == null) { + return; + } + // Right now we don't have a way to add photos inside the + // CollectionBrowser, eventually we should add that and remove this + // branching + if (collection.isDynamicCollection) { + // open the newly created collection + unawaited(Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(collection), + )); + } + } catch (e, stacktrace) { + _log.shout("[build] Uncaught exception", e, stacktrace); + context.addEvent(_SetError(AppMessageException( + L10n.global().createCollectionFailureNotification))); + } + }, + ); + } +} + +class _NavBarSharingButton extends StatelessWidget { + const _NavBarSharingButton({ + required this.isMinimized, + }); + + @override + Widget build(BuildContext context) { + return ValueStreamBuilderEx( + stream: context + .read() + .accountPrefController + .hasNewSharedAlbum, + builder: StreamWidgetBuilder.value( + (context, hasNewSharedAlbum) => _NavBarButton( + icon: const Icon(Icons.share_outlined), + label: L10n.global().collectionSharingLabel, + isMinimized: isMinimized, + isShowIndicator: hasNewSharedAlbum, + onPressed: () { + Navigator.of(context).pushNamed( + SharingBrowser.routeName, + arguments: SharingBrowserArguments(context.bloc.account), + ); + }, + ), + ), + ); + } + + final bool isMinimized; +} + +class _NavBarEditedButton extends StatelessWidget { + const _NavBarEditedButton({ + required this.isMinimized, + }); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.auto_fix_high_outlined), + label: L10n.global().collectionEditedPhotosLabel, + isMinimized: isMinimized, + onPressed: () { + Navigator.of(context).pushNamed( + EnhancedPhotoBrowser.routeName, + arguments: const EnhancedPhotoBrowserArguments(null), + ); + }, + ); + } + + final bool isMinimized; +} + +class _NavBarArchiveButton extends StatelessWidget { + const _NavBarArchiveButton({ + required this.isMinimized, + }); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.archive_outlined), + label: L10n.global().albumArchiveLabel, + isMinimized: isMinimized, + onPressed: () { + Navigator.of(context).pushNamed(ArchiveBrowser.routeName); + }, + ); + } + + final bool isMinimized; +} + +class _NavBarTrashButton extends StatelessWidget { + const _NavBarTrashButton({ + required this.isMinimized, + }); + + @override + Widget build(BuildContext context) { + return _NavBarButton( + icon: const Icon(Icons.delete_outlined), + label: L10n.global().albumTrashLabel, + isMinimized: isMinimized, + onPressed: () { + Navigator.of(context).pushNamed( + TrashbinBrowser.routeName, + arguments: TrashbinBrowserArguments(context.bloc.account), + ); + }, + ); + } + + final bool isMinimized; +} diff --git a/app/lib/widget/home_collections/navigation_bar.dart b/app/lib/widget/home_collections/navigation_bar.dart index 3f608f08..999ee7b1 100644 --- a/app/lib/widget/home_collections/navigation_bar.dart +++ b/app/lib/widget/home_collections/navigation_bar.dart @@ -37,8 +37,6 @@ class _NavigationBarState extends State<_NavigationBar> { @override Widget build(BuildContext context) { - final buttons = - _buttons.map((e) => _buildButton(context, e)).nonNulls.toList(); return SliverToBoxAdapter( child: SizedBox( height: 48, @@ -47,13 +45,25 @@ class _NavigationBarState extends State<_NavigationBar> { Expanded( child: Stack( children: [ - ListView.separated( - controller: _scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(left: 16), - itemCount: buttons.length, - itemBuilder: (context, i) => buttons[i], - separatorBuilder: (context, _) => const SizedBox(width: 16), + _BlocSelector( + selector: (state) => state.navBarButtons, + builder: (context, navBarButtons) { + final buttons = navBarButtons + .map((e) => _buildButton(context, e)) + .nonNulls + .toList(); + return ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 16), + itemCount: buttons.length, + itemBuilder: (context, i) => Center( + child: buttons[i], + ), + separatorBuilder: (context, _) => + const SizedBox(width: 12), + ); + }, ), if (_hasLeftContent) Positioned( @@ -113,19 +123,18 @@ class _NavigationBarState extends State<_NavigationBar> { ); } - Widget? _buildButton( - BuildContext context, HomeCollectionsNavBarButtonType type) { - switch (type) { + Widget? _buildButton(BuildContext context, PrefHomeCollectionsNavButton btn) { + switch (btn.type) { case HomeCollectionsNavBarButtonType.sharing: - return const _NavBarSharingButton(); + return _NavBarSharingButton(isMinimized: btn.isMinimized); case HomeCollectionsNavBarButtonType.edited: return features.isSupportEnhancement - ? const _NavBarEditedButton() + ? _NavBarEditedButton(isMinimized: btn.isMinimized) : null; case HomeCollectionsNavBarButtonType.archive: - return const _NavBarArchiveButton(); + return _NavBarArchiveButton(isMinimized: btn.isMinimized); case HomeCollectionsNavBarButtonType.trash: - return const _NavBarTrashButton(); + return _NavBarTrashButton(isMinimized: btn.isMinimized); } } @@ -175,13 +184,6 @@ class _NavigationBarState extends State<_NavigationBar> { Timer(const Duration(milliseconds: 100), _ensureUpdateButtonScroll); } - static const _buttons = [ - HomeCollectionsNavBarButtonType.sharing, - HomeCollectionsNavBarButtonType.edited, - HomeCollectionsNavBarButtonType.archive, - HomeCollectionsNavBarButtonType.trash, - ]; - late final ScrollController _scrollController; var _hasFirstScrollUpdate = false; var _hasLeftContent = false; @@ -202,179 +204,3 @@ class _NavBarButtonIndicator extends StatelessWidget { ); } } - -class _NavBarButton extends StatelessWidget { - const _NavBarButton({ - required this.icon, - required this.label, - required this.isMinimized, - this.isShowIndicator = false, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return _BlocSelector( - selector: (state) => state.selectedItems.isEmpty, - builder: (context, isEnabled) => isMinimized - ? IconButton.outlined( - icon: Stack( - children: [ - icon, - if (isShowIndicator) - const Positioned( - right: 2, - top: 2, - child: _NavBarButtonIndicator(), - ), - ], - ), - tooltip: label, - onPressed: isEnabled ? onPressed : null, - ) - : ActionChip( - avatar: icon, - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - if (isShowIndicator) ...const [ - SizedBox(width: 4), - _NavBarButtonIndicator(), - ], - ], - ), - onPressed: isEnabled ? onPressed : null, - ), - ); - } - - final Widget icon; - final String label; - final bool isMinimized; - final bool isShowIndicator; - final VoidCallback onPressed; -} - -@npLog -class _NavBarNewButton extends StatelessWidget { - const _NavBarNewButton(); - - @override - Widget build(BuildContext context) { - return _NavBarButton( - icon: const Icon(Icons.add_outlined), - label: L10n.global().createCollectionTooltip, - isMinimized: true, - onPressed: () async { - try { - final collection = await showDialog( - context: context, - builder: (_) => NewCollectionDialog( - account: context.bloc.account, - ), - ); - if (collection == null) { - return; - } - // Right now we don't have a way to add photos inside the - // CollectionBrowser, eventually we should add that and remove this - // branching - if (collection.isDynamicCollection) { - // open the newly created collection - unawaited(Navigator.of(context).pushNamed( - CollectionBrowser.routeName, - arguments: CollectionBrowserArguments(collection), - )); - } - } catch (e, stacktrace) { - _log.shout("[build] Uncaught exception", e, stacktrace); - context.addEvent(_SetError(AppMessageException( - L10n.global().createCollectionFailureNotification))); - } - }, - ); - } -} - -class _NavBarSharingButton extends StatelessWidget { - const _NavBarSharingButton(); - - @override - Widget build(BuildContext context) { - return ValueStreamBuilderEx( - stream: context - .read() - .accountPrefController - .hasNewSharedAlbum, - builder: StreamWidgetBuilder.value( - (context, hasNewSharedAlbum) => _NavBarButton( - icon: const Icon(Icons.share_outlined), - label: L10n.global().collectionSharingLabel, - isMinimized: false, - isShowIndicator: hasNewSharedAlbum, - onPressed: () { - Navigator.of(context).pushNamed( - SharingBrowser.routeName, - arguments: SharingBrowserArguments(context.bloc.account), - ); - }, - ), - ), - ); - } -} - -class _NavBarEditedButton extends StatelessWidget { - const _NavBarEditedButton(); - - @override - Widget build(BuildContext context) { - return _NavBarButton( - icon: const Icon(Icons.auto_fix_high_outlined), - label: L10n.global().collectionEditedPhotosLabel, - isMinimized: false, - onPressed: () { - Navigator.of(context).pushNamed( - EnhancedPhotoBrowser.routeName, - arguments: const EnhancedPhotoBrowserArguments(null), - ); - }, - ); - } -} - -class _NavBarArchiveButton extends StatelessWidget { - const _NavBarArchiveButton(); - - @override - Widget build(BuildContext context) { - return _NavBarButton( - icon: const Icon(Icons.archive_outlined), - label: L10n.global().albumArchiveLabel, - isMinimized: false, - onPressed: () { - Navigator.of(context).pushNamed(ArchiveBrowser.routeName); - }, - ); - } -} - -class _NavBarTrashButton extends StatelessWidget { - const _NavBarTrashButton(); - - @override - Widget build(BuildContext context) { - return _NavBarButton( - icon: const Icon(Icons.delete_outlined), - label: L10n.global().albumTrashLabel, - isMinimized: false, - onPressed: () { - Navigator.of(context).pushNamed( - TrashbinBrowser.routeName, - arguments: TrashbinBrowserArguments(context.bloc.account), - ); - }, - ); - } -} diff --git a/app/lib/widget/home_collections/state_event.dart b/app/lib/widget/home_collections/state_event.dart index fc35c3ed..245ea086 100644 --- a/app/lib/widget/home_collections/state_event.dart +++ b/app/lib/widget/home_collections/state_event.dart @@ -10,12 +10,14 @@ class _State { required this.transformedItems, required this.selectedItems, required this.itemCounts, + required this.navBarButtons, this.error, required this.removeError, }); factory _State.init({ required collection_util.CollectionSort sort, + required List navBarButtons, }) { return _State( collections: [], @@ -24,6 +26,7 @@ class _State { transformedItems: [], selectedItems: {}, itemCounts: {}, + navBarButtons: navBarButtons, removeError: null, ); } @@ -38,6 +41,8 @@ class _State { final Set<_Item> selectedItems; final Map itemCounts; + final List navBarButtons; + final ExceptionEvent? error; final ExceptionEvent? removeError; } @@ -128,6 +133,16 @@ class _SetItemCount implements _Event { final int value; } +@toString +class _SetNavBarButtons implements _Event { + const _SetNavBarButtons(this.value); + + @override + String toString() => _$toString(); + + final List value; +} + @toString class _SetError implements _Event { const _SetError(this.error, [this.stackTrace]); diff --git a/app/lib/widget/settings/collection_settings.dart b/app/lib/widget/settings/collection_settings.dart index 076d4f35..3c518a25 100644 --- a/app/lib/widget/settings/collection_settings.dart +++ b/app/lib/widget/settings/collection_settings.dart @@ -8,6 +8,7 @@ import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; +import 'package:nc_photos/widget/settings/collections_nav_bar_settings.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:to_string/to_string.dart'; @@ -85,6 +86,17 @@ class _WrappedAlbumSettingsState extends State<_WrappedAlbumSettings> ); }, ), + ListTile( + title: Text(L10n.global() + .settingsCollectionsCustomizeNavigationBarTitle), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CollectionsNavBarSettings(), + ), + ); + }, + ), ], ), ), diff --git a/app/lib/widget/settings/collections_nav_bar/bloc.dart b/app/lib/widget/settings/collections_nav_bar/bloc.dart new file mode 100644 index 00000000..9032195f --- /dev/null +++ b/app/lib/widget/settings/collections_nav_bar/bloc.dart @@ -0,0 +1,122 @@ +part of '../collections_nav_bar_settings.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> + with BlocLogger, BlocForEachMixin<_Event, _State> { + _Bloc({ + required this.prefController, + required this.isBottom, + }) : super(_State.init( + buttons: prefController.homeCollectionsNavBarButtonsValue, + )) { + on<_MoveButton>(_onMoveButton); + on<_RemoveButton>(_onRemoveButton); + on<_ToggleMinimized>(_onToggleMinimized); + on<_RevertDefault>(_onRevertDefault); + + on<_SetError>(_onSetError); + } + + @override + Future close() { + for (final s in _subscriptions) { + s.cancel(); + } + return super.close(); + } + + @override + String get tag => _log.fullName; + + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + + void _onMoveButton(_MoveButton ev, _Emitter emit) { + _log.info(ev); + final pos = state.buttons.indexWhere((e) => e.type == ev.which); + final found = pos >= 0 ? state.buttons[pos] : null; + final insert = found ?? + PrefHomeCollectionsNavButton(type: ev.which, isMinimized: false); + var result = + pos >= 0 ? state.buttons.removedAt(pos) : List.of(state.buttons); + if (ev.before == null && ev.after == null) { + // add at the beginning + emit(state.copyWith(buttons: result..insert(0, insert))); + return; + } + + final target = (ev.before ?? ev.after)!; + if (ev.which == target) { + // dropping on itself, do nothing + return; + } + final targetPos = result.indexWhere((e) => e.type == target); + if (targetPos == -1) { + _log.severe("[_onMoveButton] Target not found: $target"); + return; + } + if (ev.before != null) { + // insert before + result.insert(targetPos, insert); + } else { + // insert after + result.insert(targetPos + 1, insert); + } + _log.fine( + "[_onMoveButton] From ${state.buttons.toReadableString()} -> ${result.toReadableString()}"); + emit(state.copyWith(buttons: result)); + } + + void _onRemoveButton(_RemoveButton ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith( + buttons: state.buttons.removedWhere((e) => e.type == ev.value), + )); + } + + void _onToggleMinimized(_ToggleMinimized ev, _Emitter emit) { + _log.info(ev); + final result = List.of(state.buttons); + final pos = result.indexWhere((e) => e.type == ev.value); + if (pos == -1) { + // button not enabled + _log.severe( + "[_onToggleMinimized] Type not found in buttons: ${ev.value}"); + return; + } + result[pos] = PrefHomeCollectionsNavButton( + type: ev.value, + isMinimized: !result[pos].isMinimized, + ); + emit(state.copyWith(buttons: result)); + } + + Future _onRevertDefault(_RevertDefault ev, _Emitter emit) async { + _log.info(ev); + await prefController.setHomeCollectionsNavBarButtons(null); + emit(state.copyWith( + buttons: prefController.homeCollectionsNavBarButtonsValue, + )); + } + + void _onSetError(_SetError ev, _Emitter emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + final PrefController prefController; + final bool isBottom; + + final _subscriptions = []; + var _isHandlingError = false; +} diff --git a/app/lib/widget/settings/collections_nav_bar/buttons.dart b/app/lib/widget/settings/collections_nav_bar/buttons.dart new file mode 100644 index 00000000..ca01c239 --- /dev/null +++ b/app/lib/widget/settings/collections_nav_bar/buttons.dart @@ -0,0 +1,108 @@ +part of '../collections_nav_bar_settings.dart'; + +class _NewButton extends StatelessWidget { + const _NewButton(); + + @override + Widget build(BuildContext context) { + return HomeCollectionsNavBarButton( + icon: const Icon(Icons.add_outlined), + label: L10n.global().createCollectionTooltip, + isMinimized: true, + isUseTooltipWhenMinimized: false, + onPressed: () {}, + ); + } +} + +class _SharingButton extends StatelessWidget { + const _SharingButton({ + required this.isMinimized, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return HomeCollectionsNavBarButton( + icon: const Icon(Icons.share_outlined), + label: L10n.global().collectionSharingLabel, + isMinimized: isMinimized, + isUseTooltipWhenMinimized: false, + onPressed: () { + onPressed?.call(); + }, + ); + } + + final bool isMinimized; + final VoidCallback? onPressed; +} + +class _EditedButton extends StatelessWidget { + const _EditedButton({ + required this.isMinimized, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return HomeCollectionsNavBarButton( + icon: const Icon(Icons.auto_fix_high_outlined), + label: L10n.global().collectionEditedPhotosLabel, + isMinimized: isMinimized, + isUseTooltipWhenMinimized: false, + onPressed: () { + onPressed?.call(); + }, + ); + } + + final bool isMinimized; + final VoidCallback? onPressed; +} + +class _ArchiveButton extends StatelessWidget { + const _ArchiveButton({ + required this.isMinimized, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return HomeCollectionsNavBarButton( + icon: const Icon(Icons.archive_outlined), + label: L10n.global().albumArchiveLabel, + isMinimized: isMinimized, + isUseTooltipWhenMinimized: false, + onPressed: () { + onPressed?.call(); + }, + ); + } + + final bool isMinimized; + final VoidCallback? onPressed; +} + +class _TrashButton extends StatelessWidget { + const _TrashButton({ + required this.isMinimized, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return HomeCollectionsNavBarButton( + icon: const Icon(Icons.delete_outlined), + label: L10n.global().albumTrashLabel, + isMinimized: isMinimized, + isUseTooltipWhenMinimized: false, + onPressed: () { + onPressed?.call(); + }, + ); + } + + final bool isMinimized; + final VoidCallback? onPressed; +} diff --git a/app/lib/widget/settings/collections_nav_bar/state_event.dart b/app/lib/widget/settings/collections_nav_bar/state_event.dart new file mode 100644 index 00000000..eec93ee3 --- /dev/null +++ b/app/lib/widget/settings/collections_nav_bar/state_event.dart @@ -0,0 +1,106 @@ +part of '../collections_nav_bar_settings.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.buttons, + this.error, + }); + + factory _State.init({ + required List buttons, + }) { + return _State( + buttons: buttons, + ); + } + + @override + String toString() => _$toString(); + + final List buttons; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _Init implements _Event { + const _Init(); + + @override + String toString() => _$toString(); +} + +@toString +class _MoveButton implements _Event { + const _MoveButton._({ + required this.which, + this.before, + this.after, + }); + + const _MoveButton.first({ + required HomeCollectionsNavBarButtonType which, + }) : this._(which: which); + + const _MoveButton.before({ + required HomeCollectionsNavBarButtonType which, + required HomeCollectionsNavBarButtonType target, + }) : this._(which: which, before: target); + + const _MoveButton.after({ + required HomeCollectionsNavBarButtonType which, + required HomeCollectionsNavBarButtonType target, + }) : this._(which: which, after: target); + + @override + String toString() => _$toString(); + + final HomeCollectionsNavBarButtonType which; + final HomeCollectionsNavBarButtonType? before; + final HomeCollectionsNavBarButtonType? after; +} + +@toString +class _RemoveButton implements _Event { + const _RemoveButton(this.value); + + @override + String toString() => _$toString(); + + final HomeCollectionsNavBarButtonType value; +} + +@toString +class _ToggleMinimized implements _Event { + const _ToggleMinimized(this.value); + + @override + String toString() => _$toString(); + + final HomeCollectionsNavBarButtonType value; +} + +@toString +class _RevertDefault implements _Event { + const _RevertDefault(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/app/lib/widget/settings/collections_nav_bar/view.dart b/app/lib/widget/settings/collections_nav_bar/view.dart new file mode 100644 index 00000000..fe8d7aed --- /dev/null +++ b/app/lib/widget/settings/collections_nav_bar/view.dart @@ -0,0 +1,199 @@ +part of '../collections_nav_bar_settings.dart'; + +class _DemoView extends StatelessWidget { + const _DemoView(); + + @override + Widget build(BuildContext context) { + return _BlocSelector( + selector: (state) => state.buttons, + builder: (context, buttons) { + final navBar = SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 16 - 6), + itemCount: buttons.length, + itemBuilder: (context, i) { + final btn = buttons[i]; + return my.Draggable( + data: btn.type, + feedback: _CandidateButtonDelegate(btn.type), + onDropBefore: (data) { + context.addEvent(_MoveButton.before( + which: data, + target: btn.type, + )); + }, + onDropAfter: (data) { + context.addEvent(_MoveButton.after( + which: data, + target: btn.type, + )); + }, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: _DemoButtonDelegate( + btn.type, + isMinimized: btn.isMinimized, + ), + ), + ), + ); + }, + ), + ), + const SizedBox(width: 8), + const _NewButton(), + const SizedBox(width: 16), + ], + ), + ); + if (buttons.isEmpty) { + return DragTarget( + builder: (context, candidateData, rejectedData) => SizedBox( + height: 48, + child: Stack( + children: [ + navBar, + IgnorePointer( + child: Opacity( + opacity: candidateData.isNotEmpty ? .35 : 0, + child: Container( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + onAcceptWithDetails: (details) { + context.addEvent(_MoveButton.first(which: details.data)); + }, + ); + } else { + return navBar; + } + }, + ); + } +} + +class _DemoButtonDelegate extends StatelessWidget { + const _DemoButtonDelegate( + this.type, { + required this.isMinimized, + }); + + @override + Widget build(BuildContext context) { + switch (type) { + case HomeCollectionsNavBarButtonType.sharing: + return _SharingButton( + isMinimized: isMinimized, + onPressed: () { + context.addEvent(_ToggleMinimized(type)); + }, + ); + case HomeCollectionsNavBarButtonType.edited: + return _EditedButton( + isMinimized: isMinimized, + onPressed: () { + context.addEvent(_ToggleMinimized(type)); + }, + ); + case HomeCollectionsNavBarButtonType.archive: + return _ArchiveButton( + isMinimized: isMinimized, + onPressed: () { + context.addEvent(_ToggleMinimized(type)); + }, + ); + case HomeCollectionsNavBarButtonType.trash: + return _TrashButton( + isMinimized: isMinimized, + onPressed: () { + context.addEvent(_ToggleMinimized(type)); + }, + ); + } + } + + final HomeCollectionsNavBarButtonType type; + final bool isMinimized; +} + +class _CandidateGrid extends StatelessWidget { + const _CandidateGrid(); + + @override + Widget build(BuildContext context) { + return DragTarget( + builder: (context, candidateData, rejectedData) => Stack( + children: [ + _BlocSelector( + selector: (state) => state.buttons, + builder: (context, buttons) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Wrap( + direction: Axis.horizontal, + spacing: 16, + runSpacing: 8, + children: HomeCollectionsNavBarButtonType.values + .where((e) => !buttons.any((b) => b.type == e)) + .map((e) => my.Draggable( + data: e, + feedback: _CandidateButtonDelegate(e), + child: _CandidateButtonDelegate(e), + )) + .toList(), + ), + ), + ), + IgnorePointer( + child: Opacity( + opacity: candidateData.isNotEmpty ? .1 : 0, + child: Container( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + onAcceptWithDetails: (details) { + context.addEvent(_RemoveButton(details.data)); + }, + onWillAcceptWithDetails: (details) { + // moving down + return context.state.buttons.any((e) => e.type == details.data); + }, + ); + } +} + +class _CandidateButtonDelegate extends StatelessWidget { + const _CandidateButtonDelegate(this.type); + + @override + Widget build(BuildContext context) { + switch (type) { + case HomeCollectionsNavBarButtonType.sharing: + return const _SharingButton(isMinimized: false); + case HomeCollectionsNavBarButtonType.edited: + return const _EditedButton(isMinimized: false); + case HomeCollectionsNavBarButtonType.archive: + return const _ArchiveButton(isMinimized: false); + case HomeCollectionsNavBarButtonType.trash: + return const _TrashButton(isMinimized: false); + } + } + + final HomeCollectionsNavBarButtonType type; +} diff --git a/app/lib/widget/settings/collections_nav_bar_settings.dart b/app/lib/widget/settings/collections_nav_bar_settings.dart new file mode 100644 index 00000000..75094383 --- /dev/null +++ b/app/lib/widget/settings/collections_nav_bar_settings.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/widget/draggable.dart' as my; +import 'package:nc_photos/widget/home_collections.dart'; +import 'package:nc_photos/widget/page_visibility_mixin.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_collection/np_collection.dart'; +import 'package:to_string/to_string.dart'; + +part 'collections_nav_bar/bloc.dart'; +part 'collections_nav_bar/buttons.dart'; +part 'collections_nav_bar/state_event.dart'; +part 'collections_nav_bar/view.dart'; +part 'collections_nav_bar_settings.g.dart'; + +class CollectionsNavBarSettings extends StatelessWidget { + const CollectionsNavBarSettings({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _Bloc( + isBottom: true, + prefController: context.read(), + ), + child: const _WrappedCollectionsNavBarSettings(), + ); + } +} + +class _WrappedCollectionsNavBarSettings extends StatefulWidget { + const _WrappedCollectionsNavBarSettings(); + + @override + State createState() => + _WrappedCollectionsNavBarSettingsState(); +} + +@npLog +class _WrappedCollectionsNavBarSettingsState + extends State<_WrappedCollectionsNavBarSettings> + with RouteAware, PageVisibilityMixin { + @override + Widget build(BuildContext context) { + return PopScope( + canPop: true, + onPopInvoked: (_) { + final prefController = context.bloc.prefController; + final from = prefController.homeCollectionsNavBarButtonsValue; + final to = context.state.buttons; + if (!listEquals(from, to)) { + _log.info("[build] Updated: ${to.toReadableString()}"); + prefController.setHomeCollectionsNavBarButtons(to); + } + }, + child: Scaffold( + appBar: AppBar( + title: Text( + L10n.global().settingsCollectionsCustomizeNavigationBarTitle), + actions: [ + TextButton( + onPressed: () { + context.addEvent(const _RevertDefault()); + }, + child: Text(L10n.global().defaultButtonLabel), + ), + ], + ), + body: MultiBlocListener( + listeners: [ + _BlocListener( + listenWhen: (previous, current) => + previous.error != current.error, + listener: (context, state) { + if (state.error != null && isPageVisible()) { + SnackBarManager() + .showSnackBarForException(state.error!.error); + } + }, + ), + ], + child: Column( + children: [ + const _DemoView(), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: + Text(L10n.global().customizeCollectionsNavBarDescription), + ), + const Expanded(child: _CandidateGrid()), + ], + ), + ), + ), + ); + } +} + +// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +typedef _BlocListener = BlocListener<_Bloc, _State>; +// typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _Emitter = Emitter<_State>; + +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); +} diff --git a/app/lib/widget/settings/collections_nav_bar_settings.g.dart b/app/lib/widget/settings/collections_nav_bar_settings.g.dart new file mode 100644 index 00000000..c3409f05 --- /dev/null +++ b/app/lib/widget/settings/collections_nav_bar_settings.g.dart @@ -0,0 +1,110 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'collections_nav_bar_settings.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List? buttons, ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call({dynamic buttons, dynamic error = copyWithNull}) { + return _State( + buttons: buttons as List? ?? that.buttons, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedCollectionsNavBarSettingsStateNpLog + on _WrappedCollectionsNavBarSettingsState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger( + "widget.settings.collections_nav_bar_settings._WrappedCollectionsNavBarSettingsState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.settings.collections_nav_bar_settings._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {buttons: [length: ${buttons.length}], error: $error}"; + } +} + +extension _$_InitToString on _Init { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Init {}"; + } +} + +extension _$_MoveButtonToString on _MoveButton { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_MoveButton {which: ${which.name}, before: ${before == null ? null : "${before!.name}"}, after: ${after == null ? null : "${after!.name}"}}"; + } +} + +extension _$_RemoveButtonToString on _RemoveButton { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RemoveButton {value: ${value.name}}"; + } +} + +extension _$_ToggleMinimizedToString on _ToggleMinimized { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ToggleMinimized {value: ${value.name}}"; + } +} + +extension _$_RevertDefaultToString on _RevertDefault { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RevertDefault {}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/np_collection/lib/src/list_extension.dart b/np_collection/lib/src/list_extension.dart index addc165f..558661eb 100644 --- a/np_collection/lib/src/list_extension.dart +++ b/np_collection/lib/src/list_extension.dart @@ -88,4 +88,7 @@ extension ListExtension on List { List removed(T value) => toList()..remove(value); List removedAt(int index) => toList()..removeAt(index); + + List removedWhere(bool Function(T element) test) => + toList()..removeWhere(test); } From 6f148e57d1237b66c9d8eeea3a66686aa312da01 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 27 Oct 2024 00:02:23 +0800 Subject: [PATCH 3/5] Make nav bar part of app bar instead in collections page --- app/lib/widget/home_app_bar.dart | 3 + app/lib/widget/home_collections.dart | 1 - app/lib/widget/home_collections/app_bar.dart | 4 + .../home_collections/nav_bar_buttons.dart | 25 ++-- .../home_collections/navigation_bar.dart | 132 +++++++----------- 5 files changed, 74 insertions(+), 91 deletions(-) diff --git a/app/lib/widget/home_app_bar.dart b/app/lib/widget/home_app_bar.dart index 49cf549f..be705374 100644 --- a/app/lib/widget/home_app_bar.dart +++ b/app/lib/widget/home_app_bar.dart @@ -20,6 +20,7 @@ class HomeSliverAppBar extends StatelessWidget { this.menuActions, this.onSelectedMenuActions, this.isShowProgressIcon = false, + this.bottom, }); @override @@ -39,6 +40,7 @@ class HomeSliverAppBar extends StatelessWidget { blurFilter: Theme.of(context).appBarBlurFilter, floating: true, automaticallyImplyLeading: false, + bottom: bottom, actions: [ ...actions ?? [], if (menuActions?.isNotEmpty == true) @@ -70,6 +72,7 @@ class HomeSliverAppBar extends StatelessWidget { /// Screen specific action buttons final List? actions; + final PreferredSizeWidget? bottom; /// Screen specific actions under the overflow menu. The value of each item /// much >= 0 diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 7d8276f6..4f884165 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -142,7 +142,6 @@ class _WrappedHomeCollectionsState extends State<_WrappedHomeCollections> ? const _AppBar() : const _SelectionAppBar(), ), - const _NavigationBar(), const SliverToBoxAdapter( child: SizedBox(height: 8), ), diff --git a/app/lib/widget/home_collections/app_bar.dart b/app/lib/widget/home_collections/app_bar.dart index 5b87307f..ba63eda0 100644 --- a/app/lib/widget/home_collections/app_bar.dart +++ b/app/lib/widget/home_collections/app_bar.dart @@ -31,6 +31,10 @@ class _AppBar extends StatelessWidget { break; } }, + bottom: const PreferredSize( + preferredSize: Size.fromHeight(48), + child: _NavigationBar(), + ), ), ); } diff --git a/app/lib/widget/home_collections/nav_bar_buttons.dart b/app/lib/widget/home_collections/nav_bar_buttons.dart index 324c65cd..fb6ee7cb 100644 --- a/app/lib/widget/home_collections/nav_bar_buttons.dart +++ b/app/lib/widget/home_collections/nav_bar_buttons.dart @@ -44,19 +44,22 @@ class HomeCollectionsNavBarButton extends StatelessWidget { ), ); } else { - return ActionChip( - avatar: icon, - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - if (isShowIndicator) ...const [ - SizedBox(width: 4), - _NavBarButtonIndicator(), + return Theme( + data: Theme.of(context).copyWith(canvasColor: Colors.transparent), + child: ActionChip( + avatar: icon, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + if (isShowIndicator) ...const [ + SizedBox(width: 4), + _NavBarButtonIndicator(), + ], ], - ], + ), + onPressed: isEnabled ? onPressed : null, ), - onPressed: isEnabled ? onPressed : null, ); } } diff --git a/app/lib/widget/home_collections/navigation_bar.dart b/app/lib/widget/home_collections/navigation_bar.dart index 999ee7b1..591ba87d 100644 --- a/app/lib/widget/home_collections/navigation_bar.dart +++ b/app/lib/widget/home_collections/navigation_bar.dart @@ -37,88 +37,62 @@ class _NavigationBarState extends State<_NavigationBar> { @override Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: SizedBox( - height: 48, - child: Row( - children: [ - Expanded( - child: Stack( - children: [ - _BlocSelector( - selector: (state) => state.navBarButtons, - builder: (context, navBarButtons) { - final buttons = navBarButtons - .map((e) => _buildButton(context, e)) - .nonNulls - .toList(); - return ListView.separated( - controller: _scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(left: 16), - itemCount: buttons.length, - itemBuilder: (context, i) => Center( - child: buttons[i], - ), - separatorBuilder: (context, _) => - const SizedBox(width: 12), - ); - }, - ), - if (_hasLeftContent) - Positioned( - left: 0, - top: 0, - bottom: 0, - child: IgnorePointer( - ignoring: true, - child: Container( - width: 32, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.background, - Theme.of(context) - .colorScheme - .background - .withOpacity(0), - ], - ), - ), - ), - ), + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: ShaderMask( + shaderCallback: (rect) { + final colors = []; + final stops = []; + if (_hasLeftContent) { + colors.addAll([Colors.white, Colors.transparent]); + stops.addAll([0, .1]); + } else { + colors.add(Colors.transparent); + stops.add(0); + } + if (_hasRightContent) { + colors.addAll([Colors.transparent, Colors.white]); + stops.addAll([.9, 1]); + } else { + colors.add(Colors.transparent); + stops.add(1); + } + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: colors, + stops: stops, + ).createShader(rect); + }, + blendMode: BlendMode.dstOut, + child: _BlocSelector( + selector: (state) => state.navBarButtons, + builder: (context, navBarButtons) { + final buttons = navBarButtons + .map((e) => _buildButton(context, e)) + .nonNulls + .toList(); + return ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 16), + itemCount: buttons.length, + itemBuilder: (context, i) => Center( + child: buttons[i], ), - if (_hasRightContent) - Positioned( - right: 0, - top: 0, - bottom: 0, - child: IgnorePointer( - ignoring: true, - child: Container( - width: 32, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context) - .colorScheme - .background - .withOpacity(0), - Theme.of(context).colorScheme.background, - ], - ), - ), - ), - ), - ), - ], + separatorBuilder: (context, _) => const SizedBox(width: 12), + ); + }, ), ), - const SizedBox(width: 8), - const _NavBarNewButton(), - const SizedBox(width: 16), - ], - ), + ), + const SizedBox(width: 8), + const _NavBarNewButton(), + const SizedBox(width: 16), + ], ), ); } From 78c02fd2804a921d3fb43db419af1404df106a2f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 27 Oct 2024 01:56:09 +0800 Subject: [PATCH 4/5] Warn user when they try to remove a fixed button --- app/lib/l10n/app_en.arb | 4 ++ app/lib/l10n/untranslated-messages.txt | 41 +++++++++++++------ .../settings/collections_nav_bar/buttons.dart | 29 ++++++++++--- .../collections_nav_bar_settings.dart | 1 + .../settings/viewer_app_bar/demo_buttons.dart | 29 +++++++++++++ .../widget/settings/viewer_app_bar/view.dart | 8 +--- .../settings/viewer_app_bar_settings.dart | 1 + 7 files changed, 87 insertions(+), 26 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index f02db856..bfd3e57d 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1517,6 +1517,10 @@ "@customizeCollectionsNavBarDescription": { "description": "Instruction to customize navigation bar buttons in the Collections page" }, + "customizeButtonsUnsupportedWarning": "This button cannot be customized", + "@customizeButtonsUnsupportedWarning": { + "description": "Some button can't be removed. This message will be shown instead when user try to do so" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index e0acea0d..04bc7157 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -267,6 +267,7 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -284,7 +285,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "de": [ @@ -294,7 +296,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "el": [ @@ -449,7 +452,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "es": [ @@ -459,7 +463,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "fi": [ @@ -505,7 +510,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "fr": [ @@ -551,7 +557,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "it": [ @@ -602,7 +609,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "nl": [ @@ -990,6 +998,7 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -1047,7 +1056,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "pt": [ @@ -1113,7 +1123,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "ru": [ @@ -1159,7 +1170,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "tr": [ @@ -1169,7 +1181,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "zh": [ @@ -1246,7 +1259,8 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ], "zh_Hant": [ @@ -1417,6 +1431,7 @@ "alternativeSignIn", "livePhotoTooltip", "dragAndDropRearrangeButtons", - "customizeCollectionsNavBarDescription" + "customizeCollectionsNavBarDescription", + "customizeButtonsUnsupportedWarning" ] } diff --git a/app/lib/widget/settings/collections_nav_bar/buttons.dart b/app/lib/widget/settings/collections_nav_bar/buttons.dart index ca01c239..fa73b040 100644 --- a/app/lib/widget/settings/collections_nav_bar/buttons.dart +++ b/app/lib/widget/settings/collections_nav_bar/buttons.dart @@ -5,12 +5,29 @@ class _NewButton extends StatelessWidget { @override Widget build(BuildContext context) { - return HomeCollectionsNavBarButton( - icon: const Icon(Icons.add_outlined), - label: L10n.global().createCollectionTooltip, - isMinimized: true, - isUseTooltipWhenMinimized: false, - onPressed: () {}, + return GestureDetector( + onTap: () { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().customizeButtonsUnsupportedWarning), + duration: k.snackBarDurationNormal, + )); + }, + onLongPress: () { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().customizeButtonsUnsupportedWarning), + duration: k.snackBarDurationNormal, + )); + }, + child: AbsorbPointer( + absorbing: true, + child: HomeCollectionsNavBarButton( + icon: const Icon(Icons.add_outlined), + label: L10n.global().createCollectionTooltip, + isMinimized: true, + isUseTooltipWhenMinimized: false, + onPressed: () {}, + ), + ), ); } } diff --git a/app/lib/widget/settings/collections_nav_bar_settings.dart b/app/lib/widget/settings/collections_nav_bar_settings.dart index 75094383..5f64820c 100644 --- a/app/lib/widget/settings/collections_nav_bar_settings.dart +++ b/app/lib/widget/settings/collections_nav_bar_settings.dart @@ -9,6 +9,7 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/widget/draggable.dart' as my; import 'package:nc_photos/widget/home_collections.dart'; diff --git a/app/lib/widget/settings/viewer_app_bar/demo_buttons.dart b/app/lib/widget/settings/viewer_app_bar/demo_buttons.dart index d01c8cd3..859e7ca9 100644 --- a/app/lib/widget/settings/viewer_app_bar/demo_buttons.dart +++ b/app/lib/widget/settings/viewer_app_bar/demo_buttons.dart @@ -15,6 +15,35 @@ class _DemoButton extends StatelessWidget { final Widget icon; } +class _DemoMoreButton extends StatelessWidget { + const _DemoMoreButton(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().customizeButtonsUnsupportedWarning), + duration: k.snackBarDurationNormal, + )); + }, + onLongPress: () { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().customizeButtonsUnsupportedWarning), + duration: k.snackBarDurationNormal, + )); + }, + child: AbsorbPointer( + absorbing: true, + child: IconButton( + onPressed: () {}, + icon: Icon(Icons.adaptive.more), + ), + ), + ); + } +} + class _DemoLivePhotoButton extends StatelessWidget { const _DemoLivePhotoButton(); diff --git a/app/lib/widget/settings/viewer_app_bar/view.dart b/app/lib/widget/settings/viewer_app_bar/view.dart index 4a704b54..838ece4d 100644 --- a/app/lib/widget/settings/viewer_app_bar/view.dart +++ b/app/lib/widget/settings/viewer_app_bar/view.dart @@ -41,13 +41,7 @@ class _DemoView extends StatelessWidget { child: _DemoButtonDelegate(e), ), )), - IgnorePointer( - ignoring: true, - child: IconButton( - onPressed: () {}, - icon: Icon(Icons.adaptive.more), - ), - ), + const _DemoMoreButton(), ], ); if (buttons.isEmpty) { diff --git a/app/lib/widget/settings/viewer_app_bar_settings.dart b/app/lib/widget/settings/viewer_app_bar_settings.dart index fb386721..311961d2 100644 --- a/app/lib/widget/settings/viewer_app_bar_settings.dart +++ b/app/lib/widget/settings/viewer_app_bar_settings.dart @@ -10,6 +10,7 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/pref_controller.dart'; import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/widget/draggable.dart' as my; From 058a8d38af3929189c34902cd0c67d73318b22e0 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 27 Oct 2024 03:29:04 +0800 Subject: [PATCH 5/5] Add fade out effect also to nav bar settings page --- app/lib/widget/fade_out_list.dart | 118 ++++++++++++++++++ app/lib/widget/home_collections.dart | 1 + .../home_collections/navigation_bar.dart | 88 +------------ .../settings/collections_nav_bar/view.dart | 79 +++++++----- .../collections_nav_bar_settings.dart | 1 + 5 files changed, 171 insertions(+), 116 deletions(-) create mode 100644 app/lib/widget/fade_out_list.dart diff --git a/app/lib/widget/fade_out_list.dart b/app/lib/widget/fade_out_list.dart new file mode 100644 index 00000000..2ea75176 --- /dev/null +++ b/app/lib/widget/fade_out_list.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class FadeOutListContainer extends StatefulWidget { + const FadeOutListContainer({ + super.key, + required this.scrollController, + required this.child, + }); + + @override + State createState() => _FadeOutListContainerState(); + + final ScrollController scrollController; + final Widget child; +} + +class _FadeOutListContainerState extends State { + @override + void initState() { + super.initState(); + widget.scrollController.addListener(_onScrollEvent); + _ensureUpdateButtonScroll(); + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScrollEvent); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ShaderMask( + shaderCallback: (rect) { + final colors = []; + final stops = []; + if (_hasLeftContent) { + colors.addAll([Colors.white, Colors.transparent]); + stops.addAll([0, .1]); + } else { + colors.add(Colors.transparent); + stops.add(0); + } + if (_hasRightContent) { + colors.addAll([Colors.transparent, Colors.white]); + stops.addAll([.9, 1]); + } else { + colors.add(Colors.transparent); + stops.add(1); + } + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: colors, + stops: stops, + ).createShader(rect); + }, + blendMode: BlendMode.dstOut, + child: widget.child, + ); + } + + void _onScrollEvent() { + _updateButtonScroll(widget.scrollController.position); + } + + bool _updateButtonScroll(ScrollPosition pos) { + if (!pos.hasContentDimensions || !pos.hasPixels) { + return false; + } + if (pos.pixels <= pos.minScrollExtent) { + if (_hasLeftContent) { + setState(() { + _hasLeftContent = false; + }); + } + } else { + if (!_hasLeftContent) { + setState(() { + _hasLeftContent = true; + }); + } + } + if (pos.pixels >= pos.maxScrollExtent) { + if (_hasRightContent) { + setState(() { + _hasRightContent = false; + }); + } + } else { + if (!_hasRightContent) { + setState(() { + _hasRightContent = true; + }); + } + } + _hasFirstScrollUpdate = true; + return true; + } + + void _ensureUpdateButtonScroll() { + if (_hasFirstScrollUpdate || !mounted) { + return; + } + if (widget.scrollController.hasClients) { + if (_updateButtonScroll(widget.scrollController.position)) { + return; + } + } + Timer(const Duration(milliseconds: 100), _ensureUpdateButtonScroll); + } + + var _hasFirstScrollUpdate = false; + var _hasLeftContent = false; + var _hasRightContent = false; +} diff --git a/app/lib/widget/home_collections.dart b/app/lib/widget/home_collections.dart index 4f884165..0cb3727a 100644 --- a/app/lib/widget/home_collections.dart +++ b/app/lib/widget/home_collections.dart @@ -34,6 +34,7 @@ import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/collection_grid_item.dart'; import 'package:nc_photos/widget/enhanced_photo_browser.dart'; +import 'package:nc_photos/widget/fade_out_list.dart'; import 'package:nc_photos/widget/handler/double_tap_exit_handler.dart'; import 'package:nc_photos/widget/home_app_bar.dart'; import 'package:nc_photos/widget/navigation_bar_blur_filter.dart'; diff --git a/app/lib/widget/home_collections/navigation_bar.dart b/app/lib/widget/home_collections/navigation_bar.dart index 591ba87d..3dee99cd 100644 --- a/app/lib/widget/home_collections/navigation_bar.dart +++ b/app/lib/widget/home_collections/navigation_bar.dart @@ -20,15 +20,6 @@ class _NavigationBar extends StatefulWidget { } class _NavigationBarState extends State<_NavigationBar> { - @override - void initState() { - super.initState(); - _scrollController = ScrollController(); - _scrollController - .addListener(() => _updateButtonScroll(_scrollController.position)); - _ensureUpdateButtonScroll(); - } - @override void dispose() { _scrollController.dispose(); @@ -42,32 +33,8 @@ class _NavigationBarState extends State<_NavigationBar> { child: Row( children: [ Expanded( - child: ShaderMask( - shaderCallback: (rect) { - final colors = []; - final stops = []; - if (_hasLeftContent) { - colors.addAll([Colors.white, Colors.transparent]); - stops.addAll([0, .1]); - } else { - colors.add(Colors.transparent); - stops.add(0); - } - if (_hasRightContent) { - colors.addAll([Colors.transparent, Colors.white]); - stops.addAll([.9, 1]); - } else { - colors.add(Colors.transparent); - stops.add(1); - } - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: colors, - stops: stops, - ).createShader(rect); - }, - blendMode: BlendMode.dstOut, + child: FadeOutListContainer( + scrollController: _scrollController, child: _BlocSelector( selector: (state) => state.navBarButtons, builder: (context, navBarButtons) { @@ -112,56 +79,7 @@ class _NavigationBarState extends State<_NavigationBar> { } } - bool _updateButtonScroll(ScrollPosition pos) { - if (!pos.hasContentDimensions || !pos.hasPixels) { - return false; - } - if (pos.pixels <= pos.minScrollExtent) { - if (_hasLeftContent) { - setState(() { - _hasLeftContent = false; - }); - } - } else { - if (!_hasLeftContent) { - setState(() { - _hasLeftContent = true; - }); - } - } - if (pos.pixels >= pos.maxScrollExtent) { - if (_hasRightContent) { - setState(() { - _hasRightContent = false; - }); - } - } else { - if (!_hasRightContent) { - setState(() { - _hasRightContent = true; - }); - } - } - _hasFirstScrollUpdate = true; - return true; - } - - void _ensureUpdateButtonScroll() { - if (_hasFirstScrollUpdate || !mounted) { - return; - } - if (_scrollController.hasClients) { - if (_updateButtonScroll(_scrollController.position)) { - return; - } - } - Timer(const Duration(milliseconds: 100), _ensureUpdateButtonScroll); - } - - late final ScrollController _scrollController; - var _hasFirstScrollUpdate = false; - var _hasLeftContent = false; - var _hasRightContent = false; + final _scrollController = ScrollController(); } class _NavBarButtonIndicator extends StatelessWidget { diff --git a/app/lib/widget/settings/collections_nav_bar/view.dart b/app/lib/widget/settings/collections_nav_bar/view.dart index fe8d7aed..cb26e583 100644 --- a/app/lib/widget/settings/collections_nav_bar/view.dart +++ b/app/lib/widget/settings/collections_nav_bar/view.dart @@ -1,8 +1,19 @@ part of '../collections_nav_bar_settings.dart'; -class _DemoView extends StatelessWidget { +class _DemoView extends StatefulWidget { const _DemoView(); + @override + State createState() => _DemoViewState(); +} + +class _DemoViewState extends State<_DemoView> { + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return _BlocSelector( @@ -13,38 +24,42 @@ class _DemoView extends StatelessWidget { child: Row( children: [ Expanded( - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only(left: 16 - 6), - itemCount: buttons.length, - itemBuilder: (context, i) { - final btn = buttons[i]; - return my.Draggable( - data: btn.type, - feedback: _CandidateButtonDelegate(btn.type), - onDropBefore: (data) { - context.addEvent(_MoveButton.before( - which: data, - target: btn.type, - )); - }, - onDropAfter: (data) { - context.addEvent(_MoveButton.after( - which: data, - target: btn.type, - )); - }, - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: _DemoButtonDelegate( - btn.type, - isMinimized: btn.isMinimized, + child: FadeOutListContainer( + scrollController: _scrollController, + child: ListView.builder( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 16 - 6), + itemCount: buttons.length, + itemBuilder: (context, i) { + final btn = buttons[i]; + return my.Draggable( + data: btn.type, + feedback: _CandidateButtonDelegate(btn.type), + onDropBefore: (data) { + context.addEvent(_MoveButton.before( + which: data, + target: btn.type, + )); + }, + onDropAfter: (data) { + context.addEvent(_MoveButton.after( + which: data, + target: btn.type, + )); + }, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: _DemoButtonDelegate( + btn.type, + isMinimized: btn.isMinimized, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), const SizedBox(width: 8), @@ -81,6 +96,8 @@ class _DemoView extends StatelessWidget { }, ); } + + final _scrollController = ScrollController(); } class _DemoButtonDelegate extends StatelessWidget { diff --git a/app/lib/widget/settings/collections_nav_bar_settings.dart b/app/lib/widget/settings/collections_nav_bar_settings.dart index 5f64820c..834f85d2 100644 --- a/app/lib/widget/settings/collections_nav_bar_settings.dart +++ b/app/lib/widget/settings/collections_nav_bar_settings.dart @@ -12,6 +12,7 @@ import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/widget/draggable.dart' as my; +import 'package:nc_photos/widget/fade_out_list.dart'; import 'package:nc_photos/widget/home_collections.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:np_codegen/np_codegen.dart';