From a5c5d5bad62bbb9d16b1af440dd4743288395923 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 25 Jun 2024 22:17:27 +0200 Subject: [PATCH] explorer: page review --- lib/widgets/explorer/app_bar.dart | 151 ++++++++++++ lib/widgets/explorer/explorer_page.dart | 220 +++++++----------- .../privacy/file_picker/crumb_line.dart | 25 +- 3 files changed, 255 insertions(+), 141 deletions(-) create mode 100644 lib/widgets/explorer/app_bar.dart diff --git a/lib/widgets/explorer/app_bar.dart b/lib/widgets/explorer/app_bar.dart new file mode 100644 index 000000000..3b672c1c1 --- /dev/null +++ b/lib/widgets/explorer/app_bar.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; +import 'package:aves/widgets/common/basic/popup/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class ExplorerAppBar extends StatefulWidget { + final ValueNotifier directoryNotifier; + final void Function(String path) goTo; + + const ExplorerAppBar({ + super.key, + required this.directoryNotifier, + required this.goTo, + }); + + @override + State createState() => _ExplorerAppBarState(); +} + +class _ExplorerAppBarState extends State with WidgetsBindingObserver { + Set get _volumes => androidFileUtils.storageVolumes; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final animations = context.select((s) => s.accessibilityAnimations); + return AvesAppBar( + contentHeight: appBarContentHeight, + pinned: true, + leading: const DrawerButton(), + title: _buildAppBarTitle(context), + actions: [ + IconButton( + icon: const Icon(AIcons.search), + onPressed: () => _goToSearch(context), + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + ), + if (_volumes.length > 1) + FontSizeIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return _volumes.map((v) { + final selected = widget.directoryNotifier.value.volumePath == v.path; + final icon = v.isRemovable ? AIcons.storageCard : AIcons.storageMain; + return PopupMenuItem( + value: v, + enabled: !selected, + child: MenuRow( + text: v.getDescription(context), + icon: Icon(icon), + ), + ); + }).toList(); + }, + onSelected: (volume) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(animations.popUpAnimationDelay * timeDilation); + widget.goTo(volume.path); + }, + popUpAnimationStyle: animations.popUpAnimationStyle, + ), + ), + ], + bottom: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: CrumbLine.getPreferredHeight(MediaQuery.textScalerOf(context)), + child: ValueListenableBuilder( + valueListenable: widget.directoryNotifier, + builder: (context, directory, child) { + return CrumbLine( + key: const Key('crumbs'), + directory: directory, + onTap: widget.goTo, + ); + }, + ), + ); + }, + ), + ); + } + + InteractiveAppBarTitle _buildAppBarTitle(BuildContext context) { + final appMode = context.watch>().value; + Widget title = Text( + context.l10n.explorerPageTitle, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + if (appMode == AppMode.main) { + title = SourceStateAwareAppBarTitle( + title: title, + source: context.read(), + ); + } + return InteractiveAppBarTitle( + onTap: () => _goToSearch(context), + child: title, + ); + } + + double get appBarContentHeight { + final textScaler = MediaQuery.textScalerOf(context); + return textScaler.scale(kToolbarHeight) + CrumbLine.getPreferredHeight(textScaler); + } + + void _goToSearch(BuildContext context) { + Navigator.maybeOf(context)?.push( + SearchPageRoute( + delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, + searchFieldStyle: Themes.searchFieldStyle(context), + source: context.read(), + ), + ), + ); + } +} diff --git a/lib/widgets/explorer/explorer_page.dart b/lib/widgets/explorer/explorer_page.dart index f575964ff..36d83de3c 100644 --- a/lib/widgets/explorer/explorer_page.dart +++ b/lib/widgets/explorer/explorer_page.dart @@ -4,31 +4,22 @@ import 'dart:io'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/path.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; -import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/theme/themes.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/view/view.dart'; import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; -import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; -import 'package:aves/widgets/common/basic/popup/menu_row.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/scaffold.dart'; import 'package:aves/widgets/common/behaviour/pop/double_back.dart'; import 'package:aves/widgets/common/behaviour/pop/scope.dart'; import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/explorer/app_bar.dart'; import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; -import 'package:aves/widgets/search/search_delegate.dart'; -import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -61,8 +52,6 @@ class _ExplorerPageState extends State { return pContext.join(dir.volumePath, dir.relativeDir); } - static const double _crumblineHeight = kMinInteractiveDimension; - @override void initState() { super.initState(); @@ -75,6 +64,7 @@ class _ExplorerPageState extends State { _goTo(primaryVolume.path); } } + _contents.addListener(() => PrimaryScrollController.of(context).jumpTo(0)); WidgetsBinding.instance.addPostFrameCallback((_) { final source = context.read(); _subscriptions.add(source.eventBus.on().listen((event) => _updateContents())); @@ -86,6 +76,8 @@ class _ExplorerPageState extends State { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); + _directory.dispose(); + _contents.dispose(); _doubleBackPopHandler.dispose(); super.dispose(); } @@ -106,78 +98,73 @@ class _ExplorerPageState extends State { _doubleBackPopHandler.pop, ], child: AvesScaffold( - appBar: _buildAppBar(context), drawer: const AppDrawer(), - body: SafeArea( + body: GestureAreaProtectorStack( child: Column( children: [ Expanded( child: ValueListenableBuilder>( valueListenable: _contents, builder: (context, contents, child) { - if (contents.isEmpty) { - return Selector( - selector: (context, source) => source.state == SourceState.loading, - builder: (context, loading, child) { - Widget? bottom; - if (loading) { - bottom = const CircularProgressIndicator(); - } else { - final source = context.read(); - final album = _getAlbumPath(source, Directory(_currentDirectoryPath)); - if (album != null) { - bottom = AvesFilterChip( - filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), - maxWidth: double.infinity, - onTap: (filter) => _goToCollectionPage(context, filter), - onLongPress: null, - ); - } - } - - return Center( - child: EmptyContent( - icon: AIcons.folder, - text: '', - bottom: bottom, - ), - ); - }, - ); - } final durations = context.watch(); - return AnimationLimiter( - key: ValueKey(_currentDirectoryPath), - child: ListView( - children: AnimationConfiguration.toStaggeredList( - duration: durations.staggeredAnimation, - delay: durations.staggeredAnimationDelay * timeDilation, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), - ), - children: contents.map((v) => _buildContentLine(context, v)).toList(), + return CustomScrollView( + // workaround to prevent scrolling the app bar away + // when there is no content and we use `SliverFillRemaining` + physics: contents.isEmpty ? const NeverScrollableScrollPhysics() : null, + slivers: [ + ExplorerAppBar( + key: const Key('appbar'), + directoryNotifier: _directory, + goTo: _goTo, ), - ), + AnimationLimiter( + // animation limiter should not be above the app bar + // so that the crumb line can automatically scroll + key: ValueKey(_currentDirectoryPath), + child: SliverList.builder( + itemBuilder: (context, index) { + return AnimationConfiguration.staggeredList( + position: index, + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: _buildContentLine(context, contents[index]), + ), + ), + ); + }, + itemCount: contents.length, + ), + ), + contents.isEmpty + ? SliverFillRemaining( + child: _buildEmptyContent(), + ) + : const SliverPadding(padding: EdgeInsets.only(bottom: 8)), + ], ); }, ), ), const Divider(height: 0), - Padding( - padding: const EdgeInsets.all(8), - child: ValueListenableBuilder( - valueListenable: _directory, - builder: (context, directory, child) { - return AvesFilterChip( - filter: PathFilter(_currentDirectoryPath), - maxWidth: double.infinity, - onTap: (filter) => _goToCollectionPage(context, filter), - onLongPress: null, - ); - }, + SafeArea( + top: false, + bottom: true, + child: Padding( + padding: const EdgeInsets.all(8), + child: ValueListenableBuilder( + valueListenable: _directory, + builder: (context, directory, child) { + return AvesFilterChip( + filter: PathFilter(_currentDirectoryPath), + maxWidth: double.infinity, + onTap: (filter) => _goToCollectionPage(context, filter), + onLongPress: null, + ); + }, + ), ), ), ], @@ -187,61 +174,34 @@ class _ExplorerPageState extends State { ); } - AppBar _buildAppBar(BuildContext context) { - final animations = context.select((s) => s.accessibilityAnimations); + Widget _buildEmptyContent() { + return Selector( + selector: (context, source) => source.state == SourceState.loading, + builder: (context, loading, child) { + Widget? bottom; + if (loading) { + bottom = const CircularProgressIndicator(); + } else { + final source = context.read(); + final album = _getAlbumPath(source, Directory(_currentDirectoryPath)); + if (album != null) { + bottom = AvesFilterChip( + filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), + maxWidth: double.infinity, + onTap: (filter) => _goToCollectionPage(context, filter), + onLongPress: null, + ); + } + } - return AppBar( - title: InteractiveAppBarTitle( - onTap: _goToSearch, - child: Text( - context.l10n.explorerPageTitle, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ), - actions: [ - if (_volumes.length > 1) - FontSizeIconTheme( - child: PopupMenuButton( - itemBuilder: (context) { - return _volumes.map((v) { - final selected = _directory.value.volumePath == v.path; - final icon = v.isRemovable ? AIcons.storageCard : AIcons.storageMain; - return PopupMenuItem( - value: v, - enabled: !selected, - child: MenuRow( - text: v.getDescription(context), - icon: Icon(icon), - ), - ); - }).toList(); - }, - onSelected: (volume) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(animations.popUpAnimationDelay * timeDilation); - _goTo(volume.path); - }, - popUpAnimationStyle: animations.popUpAnimationStyle, - ), + return Center( + child: EmptyContent( + icon: AIcons.folder, + text: '', + bottom: bottom, ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(_crumblineHeight), - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: _crumblineHeight), - child: ValueListenableBuilder( - valueListenable: _directory, - builder: (context, directory, child) { - return CrumbLine( - directory: directory, - onTap: _goTo, - ); - }, - ), - ), - ), + ); + }, ); } @@ -302,18 +262,6 @@ class _ExplorerPageState extends State { }); } - void _goToSearch() { - Navigator.maybeOf(context)?.push( - SearchPageRoute( - delegate: CollectionSearchDelegate( - searchFieldLabel: context.l10n.searchCollectionFieldHint, - searchFieldStyle: Themes.searchFieldStyle(context), - source: context.read(), - ), - ), - ); - } - void _goToCollectionPage(BuildContext context, CollectionFilter filter) { Navigator.maybeOf(context)?.push( MaterialPageRoute( diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart index 7ad896d88..2501c6eb5 100644 --- a/lib/widgets/settings/privacy/file_picker/crumb_line.dart +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -1,8 +1,10 @@ +import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/view/view.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class CrumbLine extends StatefulWidget { final VolumeRelativeDirectory directory; @@ -16,6 +18,8 @@ class CrumbLine extends StatefulWidget { @override State createState() => _CrumbLineState(); + + static double getPreferredHeight(TextScaler textScaler) => textScaler.scale(kToolbarHeight); } class _CrumbLineState extends State { @@ -23,18 +27,29 @@ class _CrumbLineState extends State { VolumeRelativeDirectory get directory => widget.directory; + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant CrumbLine oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.directory.relativeDir.length < widget.directory.relativeDir.length) { // scroll to show last crumb WidgetsBinding.instance.addPostFrameCallback((_) { + final animate = context.read().animate; final extent = _scrollController.position.maxScrollExtent; - _scrollController.animateTo( - extent, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutQuad, - ); + if (animate) { + _scrollController.animateTo( + extent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutQuad, + ); + } else { + _scrollController.jumpTo(extent); + } }); } }