diff --git a/packages/neon/neon/lib/src/widgets/list_view.dart b/packages/neon/neon/lib/src/widgets/list_view.dart index 28035cde..4612e38d 100644 --- a/packages/neon/neon/lib/src/widgets/list_view.dart +++ b/packages/neon/neon/lib/src/widgets/list_view.dart @@ -2,65 +2,85 @@ import 'package:flutter/material.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; -class NeonListView extends StatelessWidget { - const NeonListView({ - required this.items, +class NeonListView extends StatelessWidget { + NeonListView({ required this.isLoading, required this.error, required this.onRefresh, - required this.builder, + required final NullableIndexedWidgetBuilder itemBuilder, + final int? itemCount, + this.scrollKey, + this.topFixedChildren, + this.topScrollingChildren, + super.key, + }) : sliver = SliverList.builder( + itemCount: itemCount, + itemBuilder: itemBuilder, + ); + + const NeonListView.custom({ + required this.isLoading, + required this.error, + required this.onRefresh, + required this.sliver, this.scrollKey, - this.withFloatingActionButton = false, this.topFixedChildren, this.topScrollingChildren, super.key, }); - final Iterable? items; final bool isLoading; final Object? error; final RefreshCallback onRefresh; - final Widget Function(BuildContext, T data) builder; final String? scrollKey; - final bool withFloatingActionButton; final List? topFixedChildren; final List? topScrollingChildren; + final Widget sliver; @override - Widget build(final BuildContext context) => RefreshIndicator( - onRefresh: onRefresh, - child: Column( - children: [ - ...?topFixedChildren, - NeonLinearProgressIndicator( - margin: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - visible: isLoading, + Widget build(final BuildContext context) { + final refreshIndicatorKey = GlobalKey(); + final hasFloatingActionButton = Scaffold.maybeOf(context)?.hasFloatingActionButton ?? false; + + return RefreshIndicator.adaptive( + key: refreshIndicatorKey, + onRefresh: onRefresh, + child: CustomScrollView( + key: scrollKey != null ? PageStorageKey(scrollKey!) : null, + primary: true, + slivers: [ + if (topFixedChildren != null) + SliverList.builder( + itemCount: topFixedChildren!.length, + itemBuilder: (final context, final index) => topFixedChildren![index], ), - Expanded( - child: Scrollbar( - child: ListView( - primary: true, - key: scrollKey != null ? PageStorageKey(scrollKey!) : null, - padding: withFloatingActionButton ? const EdgeInsets.only(bottom: 88) : null, - children: [ - ...?topScrollingChildren, - NeonError( - error, - onRetry: onRefresh, - ), - if (items != null) ...[ - for (final item in items!) ...[ - builder(context, item), - ], - ], - ], + if (isLoading) + const SliverToBoxAdapter( + child: NeonLinearProgressIndicator( + margin: EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, ), ), ), - ], - ), - ); + if (error != null) + SliverToBoxAdapter( + child: NeonError( + error, + onRetry: () async => refreshIndicatorKey.currentState!.show(), + ), + ), + if (topScrollingChildren != null) + SliverList.builder( + itemCount: topScrollingChildren!.length, + itemBuilder: (final context, final index) => topScrollingChildren![index], + ), + SliverPadding( + padding: hasFloatingActionButton ? const EdgeInsets.only(bottom: 88) : EdgeInsets.zero, + sliver: sliver, + ), + ], + ), + ); + } } diff --git a/packages/neon/neon/lib/src/widgets/unified_search_results.dart b/packages/neon/neon/lib/src/widgets/unified_search_results.dart index 1cad5370..078e2520 100644 --- a/packages/neon/neon/lib/src/widgets/unified_search_results.dart +++ b/packages/neon/neon/lib/src/widgets/unified_search_results.dart @@ -29,22 +29,30 @@ class NeonUnifiedSearchResults extends StatelessWidget { final bloc = accountsBloc.activeUnifiedSearchBloc; return ResultBuilder.behaviorSubject( stream: bloc.results, - builder: (final context, final results) => NeonListView( - items: results.data?.entries, - isLoading: results.isLoading, - error: results.error, - onRefresh: bloc.refresh, - builder: (final context, final snapshot) => AnimatedSize( - duration: const Duration(milliseconds: 100), - child: _buildProvider( - context, - accountsBloc, - bloc, - snapshot.key, - snapshot.value, - ), - ), - ), + builder: (final context, final results) { + final values = results.data?.entries.toList(); + + return NeonListView( + isLoading: results.isLoading, + error: results.error, + onRefresh: bloc.refresh, + itemCount: values?.length, + itemBuilder: (final context, final index) { + final snapshot = values![index]; + + return AnimatedSize( + duration: const Duration(milliseconds: 100), + child: _buildProvider( + context, + accountsBloc, + bloc, + snapshot.key, + snapshot.value, + ), + ); + }, + ); + }, ); } diff --git a/packages/neon/neon_files/lib/widgets/browser_view.dart b/packages/neon/neon_files/lib/widgets/browser_view.dart index cc77c376..8d4f03c1 100644 --- a/packages/neon/neon_files/lib/widgets/browser_view.dart +++ b/packages/neon/neon_files/lib/widgets/browser_view.dart @@ -68,108 +68,65 @@ class _FilesBrowserViewState extends State { input: files.data, builder: (final context, final sorted) => ValueListenableBuilder( valueListenable: widget.bloc.options.showHiddenFilesOption, - builder: (final context, final showHiddenFiles, final _) => NeonListView( - scrollKey: 'files-${pathSnapshot.requireData.join('/')}', - withFloatingActionButton: true, - items: [ - for (final uploadTask in tasksSnapshot.requireData.whereType().where( - (final task) => - sorted.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty, - )) ...[ - FileListTile( - bloc: widget.filesBloc, - browserBloc: widget.bloc, - details: FileDetails.fromUploadTask( - task: uploadTask, - ), - mode: widget.mode, - ), - ], - for (final file in sorted) ...[ + builder: (final context, final showHiddenFiles, final _) { + final uploadingTasks = tasksSnapshot.requireData + .whereType() + .where( + (final task) => + sorted.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty, + ) + .toList(); + + return NeonListView( + scrollKey: 'files-${pathSnapshot.requireData.join('/')}', + itemCount: uploadingTasks.length + sorted.length, + itemBuilder: (final context, final index) { + if (index < uploadingTasks.length) { + return FileListTile( + bloc: widget.filesBloc, + browserBloc: widget.bloc, + details: FileDetails.fromUploadTask( + task: uploadingTasks[index], + ), + ); + } + + final file = sorted[index - uploadingTasks.length]; if ((widget.mode != FilesBrowserMode.selectDirectory || file.isDirectory) && - (!file.isHidden || showHiddenFiles)) ...[ - Builder( - builder: (final context) { - final matchingTask = tasksSnapshot.requireData - .firstWhereOrNull((final task) => _pathMatchesFile(task.path, file.name)); - - final details = matchingTask != null - ? FileDetails.fromTask( - task: matchingTask, - file: file, - ) - : FileDetails.fromWebDav( - file: file, - path: widget.bloc.path.value, - ); - - return FileListTile( - bloc: widget.filesBloc, - browserBloc: widget.bloc, - details: details, - mode: widget.mode, - ); - }, - ), - ], - ], - ], - isLoading: files.isLoading, - error: files.error, - onRefresh: widget.bloc.refresh, - builder: (final context, final widget) => widget, - topScrollingChildren: [ - Align( - alignment: Alignment.topLeft, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 10, - ), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - IconButton( - padding: EdgeInsets.zero, - tooltip: AppLocalizations.of(context).goToPath(''), - icon: const Icon( - Icons.house, - ), - onPressed: () { - widget.bloc.setPath([]); - }, - ), - for (var i = 0; i < pathSnapshot.requireData.length; i++) ...[ - Builder( - builder: (final context) { - final path = pathSnapshot.requireData.sublist(0, i + 1); - return Tooltip( - message: AppLocalizations.of(context).goToPath(path.join('/')), - excludeFromSemantics: true, - child: TextButton( - onPressed: () { - widget.bloc.setPath(path); - }, - child: Text( - pathSnapshot.requireData[i], - semanticsLabel: AppLocalizations.of(context).goToPath(path.join('/')), - ), - ), - ); - }, - ), - ], - ] - .intersperse( - const Icon( - Icons.keyboard_arrow_right, - ), + (!file.isHidden || showHiddenFiles)) { + final matchingTask = tasksSnapshot.requireData + .firstWhereOrNull((final task) => _pathMatchesFile(task.path, file.name)); + + final details = matchingTask != null + ? FileDetails.fromTask( + task: matchingTask, + file: file, ) - .toList(), - ), + : FileDetails.fromWebDav( + file: file, + path: widget.bloc.path.value, + ); + + return FileListTile( + bloc: widget.filesBloc, + browserBloc: widget.bloc, + details: details, + ); + } + + return null; + }, + isLoading: files.isLoading, + error: files.error, + onRefresh: widget.bloc.refresh, + topScrollingChildren: [ + FileBrowserNavigator( + path: pathSnapshot.requireData, + bloc: widget.bloc, ), - ), - ], - ), + ], + ); + }, ), ), ), @@ -182,3 +139,71 @@ class _FilesBrowserViewState extends State { path, ); } + +class FileBrowserNavigator extends StatelessWidget { + const FileBrowserNavigator({ + required this.path, + required this.bloc, + super.key, + }); + + final List path; + final FilesBrowserBloc bloc; + + @override + Widget build(final BuildContext context) => Align( + alignment: Alignment.topLeft, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + padding: EdgeInsets.zero, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + tooltip: AppLocalizations.of(context).goToPath(''), + icon: const Icon( + Icons.house, + size: 30, + ), + onPressed: () { + bloc.setPath([]); + }, + ), + for (var i = 0; i < path.length; i++) ...[ + Builder( + builder: (final context) { + final path = this.path.sublist(0, i + 1); + return Tooltip( + message: AppLocalizations.of(context).goToPath(path.join('/')), + excludeFromSemantics: true, + child: TextButton( + onPressed: () { + bloc.setPath(path); + }, + child: Text( + this.path[i], + semanticsLabel: AppLocalizations.of(context).goToPath(path.join('/')), + ), + ), + ); + }, + ), + ], + ] + .intersperse( + const Icon( + Icons.keyboard_arrow_right, + size: 30, + ), + ) + .toList(), + ), + ), + ); +} diff --git a/packages/neon/neon_news/lib/widgets/articles_view.dart b/packages/neon/neon_news/lib/widgets/articles_view.dart index 4a31fcc3..0c98cc77 100644 --- a/packages/neon/neon_news/lib/widgets/articles_view.dart +++ b/packages/neon/neon_news/lib/widgets/articles_view.dart @@ -34,9 +34,8 @@ class _NewsArticlesViewState extends State { sortPropertyOption: widget.newsBloc.options.articlesSortPropertyOption, sortBoxOrderOption: widget.newsBloc.options.articlesSortBoxOrderOption, input: articles.data, - builder: (final context, final sorted) => NeonListView( + builder: (final context, final sorted) => NeonListView( scrollKey: 'news-articles', - items: feeds.hasData ? sorted : null, isLoading: articles.isLoading || feeds.isLoading, error: articles.error ?? feeds.error, onRefresh: () async { @@ -45,11 +44,16 @@ class _NewsArticlesViewState extends State { widget.newsBloc.refresh(), ]); }, - builder: (final context, final article) => _buildArticle( - context, - article, - feeds.requireData.singleWhere((final feed) => feed.id == article.feedId), - ), + itemCount: feeds.hasData ? sorted.length : null, + itemBuilder: (final context, final index) { + final article = sorted[index]; + + return _buildArticle( + context, + article, + feeds.requireData.singleWhere((final feed) => feed.id == article.feedId), + ); + }, topFixedChildren: [ StreamBuilder( stream: widget.bloc.filterType, diff --git a/packages/neon/neon_news/lib/widgets/feeds_view.dart b/packages/neon/neon_news/lib/widgets/feeds_view.dart index 9207cd40..ae4e9a43 100644 --- a/packages/neon/neon_news/lib/widgets/feeds_view.dart +++ b/packages/neon/neon_news/lib/widgets/feeds_view.dart @@ -22,16 +22,15 @@ class NewsFeedsView extends StatelessWidget { input: folders.hasData ? feeds.data?.where((final f) => folderID == null || f.folderId == folderID).toList() : null, - builder: (final context, final sorted) => NeonListView( + builder: (final context, final sorted) => NeonListView( scrollKey: 'news-feeds', - withFloatingActionButton: true, - items: sorted, isLoading: feeds.isLoading || folders.isLoading, error: feeds.error ?? folders.error, onRefresh: bloc.refresh, - builder: (final context, final feed) => _buildFeed( + itemCount: sorted.length, + itemBuilder: (final context, final index) => _buildFeed( context, - feed, + sorted[index], folders.requireData, ), ), diff --git a/packages/neon/neon_news/lib/widgets/folders_view.dart b/packages/neon/neon_news/lib/widgets/folders_view.dart index 661fef13..fc3da967 100644 --- a/packages/neon/neon_news/lib/widgets/folders_view.dart +++ b/packages/neon/neon_news/lib/widgets/folders_view.dart @@ -26,14 +26,16 @@ class NewsFoldersView extends StatelessWidget { return (folder, feedCount, unreadCount); }).toList() : null, - builder: (final context, final sorted) => NeonListView( + builder: (final context, final sorted) => NeonListView( scrollKey: 'news-folders', - withFloatingActionButton: true, - items: sorted, isLoading: feeds.isLoading || folders.isLoading, error: feeds.error ?? folders.error, onRefresh: bloc.refresh, - builder: _buildFolder, + itemCount: sorted.length, + itemBuilder: (final context, final index) => _buildFolder( + context, + sorted[index], + ), ), ), ), diff --git a/packages/neon/neon_notes/lib/widgets/categories_view.dart b/packages/neon/neon_notes/lib/widgets/categories_view.dart index aaec773e..794a2c4b 100644 --- a/packages/neon/neon_notes/lib/widgets/categories_view.dart +++ b/packages/neon/neon_notes/lib/widgets/categories_view.dart @@ -25,13 +25,16 @@ class NotesCategoriesView extends StatelessWidget { ), ) .toList(), - builder: (final context, final sorted) => NeonListView( + builder: (final context, final sorted) => NeonListView( scrollKey: 'notes-categories', - items: sorted, isLoading: notes.isLoading, error: notes.error, onRefresh: bloc.refresh, - builder: _buildCategory, + itemCount: sorted.length, + itemBuilder: (final context, final index) => _buildCategory( + context, + sorted[index], + ), ), ), ); diff --git a/packages/neon/neon_notes/lib/widgets/notes_view.dart b/packages/neon/neon_notes/lib/widgets/notes_view.dart index 7d68c985..07b8730c 100644 --- a/packages/neon/neon_notes/lib/widgets/notes_view.dart +++ b/packages/neon/neon_notes/lib/widgets/notes_view.dart @@ -21,14 +21,13 @@ class NotesView extends StatelessWidget { sortPropertyOption: bloc.options.notesSortPropertyOption, sortBoxOrderOption: bloc.options.notesSortBoxOrderOption, input: category != null ? notes.data?.where((final note) => note.category == category).toList() : notes.data, - builder: (final context, final sorted) => NeonListView( + builder: (final context, final sorted) => NeonListView( scrollKey: 'notes-notes', - withFloatingActionButton: true, - items: sorted, isLoading: notes.isLoading, error: notes.error, onRefresh: bloc.refresh, - builder: _buildNote, + itemCount: sorted.length, + itemBuilder: (final context, final index) => _buildNote(context, sorted[index]), ), ), ); diff --git a/packages/neon/neon_notifications/lib/pages/main.dart b/packages/neon/neon_notifications/lib/pages/main.dart index 34d2b4cd..9fdc10bf 100644 --- a/packages/neon/neon_notifications/lib/pages/main.dart +++ b/packages/neon/neon_notifications/lib/pages/main.dart @@ -35,14 +35,13 @@ class _NotificationsMainPageState extends State { tooltip: AppLocalizations.of(context).notificationsDismissAll, child: const Icon(MdiIcons.checkAll), ), - body: NeonListView( + body: NeonListView( scrollKey: 'notifications-notifications', - withFloatingActionButton: true, - items: notifications.data, isLoading: notifications.isLoading, error: notifications.error, onRefresh: bloc.refresh, - builder: _buildNotification, + itemCount: notifications.data?.length, + itemBuilder: (final context, final index) => _buildNotification(context, notifications.data![index]), ), ), );