New nav bar design in Collections page

This commit is contained in:
Ming Ming 2024-10-23 00:42:50 +08:00
parent 791a65713b
commit 1adbb453fd
6 changed files with 430 additions and 193 deletions

View file

@ -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);
}

View file

@ -11,3 +11,34 @@ class ValueStreamBuilder<T> extends StreamBuilder<T> {
initialData: stream?.value,
);
}
class ValueStreamBuilderEx<T> extends StreamBuilder<T> {
ValueStreamBuilderEx({
super.key,
ValueStream<T>? stream,
required StreamWidgetBuilder builder,
}) : super(
stream: stream,
initialData: stream?.value,
builder: builder.snapshotBuilder ??
(context, snapshot) {
return builder.valueBuilder!(context, snapshot.requireData);
},
);
}
class StreamWidgetBuilder<T> {
const StreamWidgetBuilder._({
this.snapshotBuilder,
this.valueBuilder,
});
const StreamWidgetBuilder.snapshot(AsyncWidgetBuilder<T> builder)
: this._(snapshotBuilder: builder);
const StreamWidgetBuilder.value(
Widget Function(BuildContext context, T value) builder)
: this._(valueBuilder: builder);
final AsyncWidgetBuilder<T>? snapshotBuilder;
final Widget Function(BuildContext context, T value)? valueBuilder;
}

View file

@ -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<void> _onNewCollectionPressed(BuildContext context) async {
try {
final collection = await showDialog<Collection>(
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<bool> _onBackButtonPressed(BuildContext context) async {
if (context.state.selectedItems.isEmpty) {
return DoubleTapExitHandler()();

View file

@ -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;

View file

@ -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<StatefulWidget> 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<Collection>(
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<AccountController>()
.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),
);
},
);
}
}

View file

@ -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<AccountController>()
.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,