feat: Update translations, fix scrolling on all pages

This commit is contained in:
Anton Stubenbord
2023-02-22 18:14:02 +01:00
parent 44d9b74fb3
commit a8a41b38a8
13 changed files with 813 additions and 697 deletions

View File

@@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
@@ -16,6 +17,7 @@ import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/core/widgets/offline_banner.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_view.dart';
@@ -42,17 +44,13 @@ class ScannerPage extends StatefulWidget {
class _ScannerPageState extends State<ScannerPage>
with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle actionsHandle =
SliverOverlapAbsorberHandle();
@override
Widget build(BuildContext context) {
final safeAreaPadding = MediaQuery.of(context).padding;
final availableHeight = MediaQuery.of(context).size.height -
2 * kToolbarHeight -
kTextTabBarHeight -
kBottomNavigationBarHeight -
safeAreaPadding.top -
safeAreaPadding.bottom;
print(availableHeight);
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectedState) {
return Scaffold(
@@ -68,54 +66,49 @@ class _ScannerPageState extends State<ScannerPage>
// ),
body: BlocBuilder<DocumentScannerCubit, List<File>>(
builder: (context, state) {
return CustomScrollView(
physics:
state.isEmpty ? const NeverScrollableScrollPhysics() : null,
slivers: [
SearchAppBar(
hintText: S.of(context)!.searchDocuments,
onOpenSearch: showDocumentSearchPage,
bottom: PreferredSize(
child: _buildActions(connectedState.isConnected),
preferredSize: const Size.fromHeight(kTextTabBarHeight),
),
return SafeArea(
child: Scaffold(
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
onPressed: () => _openDocumentScanner(context),
child: const Icon(Icons.add_a_photo_outlined),
),
if (state.isEmpty)
SliverToBoxAdapter(
child: SizedBox(
height: availableHeight,
child: Center(
child: _buildEmptyState(connectedState.isConnected),
body: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: const SliverSearchBar(),
),
SliverOverlapAbsorber(
handle: actionsHandle,
sliver: SliverPersistentHeader(
pinned: true,
delegate: CustomizableSliverPersistentHeaderDelegate(
child: _buildActions(connectedState.isConnected),
maxExtent: kTextTabBarHeight,
minExtent: kTextTabBarHeight,
),
),
),
)
else
_buildImageGrid(state)
],
);
NestedScrollView(
floatHeaderSlivers: false,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SearchAppBar(
hintText: S.of(context)!.searchDocuments,
onOpenSearch: showDocumentSearchPage,
bottom: PreferredSize(
child: _buildActions(connectedState.isConnected),
preferredSize: const Size.fromHeight(kTextTabBarHeight),
],
body: BlocBuilder<DocumentScannerCubit, List<File>>(
builder: (context, state) {
if (state.isEmpty) {
return SizedBox.expand(
child: Center(
child: _buildEmptyState(
connectedState.isConnected,
state,
),
),
);
} else {
return _buildImageGrid(state);
}
},
),
),
],
body: CustomScrollView(
slivers: [
if (state.isEmpty)
SliverFillViewport(
delegate: SliverChildListDelegate.fixed(
[_buildEmptyState(connectedState.isConnected)]),
)
else
_buildImageGrid(state)
],
),
);
},
@@ -237,36 +230,32 @@ class _ScannerPageState extends State<ScannerPage>
}
}
Widget _buildEmptyState(bool isConnected) {
return BlocBuilder<DocumentScannerCubit, List<File>>(
builder: (context, scans) {
if (scans.isNotEmpty) {
return _buildImageGrid(scans);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
S.of(context)!.noDocumentsScannedYet,
textAlign: TextAlign.center,
),
TextButton(
child: Text(S.of(context)!.scanADocument),
onPressed: () => _openDocumentScanner(context),
),
Text(S.of(context)!.or),
TextButton(
child: Text(S.of(context)!.uploadADocumentFromThisDevice),
onPressed: isConnected ? _onUploadFromFilesystem : null,
),
],
Widget _buildEmptyState(bool isConnected, List<File> scans) {
if (scans.isNotEmpty) {
return _buildImageGrid(scans);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
S.of(context)!.noDocumentsScannedYet,
textAlign: TextAlign.center,
),
),
);
},
TextButton(
child: Text(S.of(context)!.scanADocument),
onPressed: () => _openDocumentScanner(context),
),
Text(S.of(context)!.or),
TextButton(
child: Text(S.of(context)!.uploadADocumentFromThisDevice),
onPressed: isConnected ? _onUploadFromFilesystem : null,
),
],
),
),
);
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class SliverSearchBar extends StatelessWidget {
final bool floating;
final bool pinned;
const SliverSearchBar({
super.key,
this.floating = false,
this.pinned = false,
});
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
floating: floating,
pinned: pinned,
delegate: CustomizableSliverPersistentHeaderDelegate(
minExtent: 56 + 8,
maxExtent: 56 + 8,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SearchBar(
height: 56,
supportingText: S.of(context)!.searchDocuments,
onTap: () => showDocumentSearchPage(context),
leadingIcon: IconButton(
icon: const Icon(Icons.menu),
onPressed: Scaffold.of(context).openDrawer,
),
trailingIcon: IconButton(
icon: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
builder: (context, state) {
return CircleAvatar(
child: Text(state.information?.userInitials ?? ''),
);
},
),
onPressed: () {
showDialog(
context: context,
builder: (context) => const AccountSettingsDialog(),
);
},
),
),
),
),
);
}
}

View File

@@ -3,25 +3,25 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/labels_bloc_provider.dart';
import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart';
import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/document_details_route.dart';
@@ -44,18 +44,14 @@ class DocumentsPage extends StatefulWidget {
}
class _DocumentsPageState extends State<DocumentsPage>
with
SingleTickerProviderStateMixin,
DocumentPagingViewMixin<DocumentsPage, DocumentsCubit> {
with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle tabBarHandle =
SliverOverlapAbsorberHandle();
late final TabController _tabController;
@override
ScrollController get pagingScrollController =>
_nestedScrollViewKey.currentState?.innerController ?? ScrollController();
final GlobalKey<NestedScrollViewState> _nestedScrollViewKey = GlobalKey();
int _currentTab = 0;
bool _showBackToTopButton = false;
@override
void initState() {
@@ -73,32 +69,13 @@ class _DocumentsPageState extends State<DocumentsPage>
return [];
},
);
_tabController.addListener(_tabChangesListener);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_nestedScrollViewKey.currentState!.innerController
..addListener(_scrollExtentListener)
..addListener(shouldLoadMoreDocumentsListener);
});
}
void _tabChangesListener() {
setState(() => _currentTab = _tabController.index);
}
void _scrollExtentListener() {
if (pagingScrollController.position.pixels >
MediaQuery.of(context).size.height) {
if (!_showBackToTopButton) {
setState(() => _showBackToTopButton = true);
}
} else {
if (_showBackToTopButton) {
setState(() => _showBackToTopButton = false);
}
}
}
@override
void dispose() {
_tabController.dispose();
@@ -136,135 +113,145 @@ class _DocumentsPageState extends State<DocumentsPage>
}
},
builder: (context, connectivityState) {
return Scaffold(
drawer: const AppDrawer(),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount;
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: appliedFiltersCount > 0,
badgeContent: Text(
'$appliedFiltersCount',
style: const TextStyle(
color: Colors.white,
return SafeArea(
child: Scaffold(
drawer: const AppDrawer(),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount;
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: appliedFiltersCount > 0,
badgeContent: Text(
'$appliedFiltersCount',
style: const TextStyle(
color: Colors.white,
),
),
),
animationType: b.BadgeAnimationType.fade,
badgeColor: Colors.red,
child: _currentTab == 0
? FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
)
: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => _onCreateSavedView(state.filter),
animationType: b.BadgeAnimationType.fade,
badgeColor: Colors.red,
child: _currentTab == 0
? FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
)
: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => _onCreateSavedView(state.filter),
),
);
},
),
resizeToAvoidBottomInset: true,
body: WillPopScope(
onWillPop: () async {
if (context
.read<DocumentsCubit>()
.state
.selection
.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection();
}
return false;
},
child: Stack(
children: [
NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.selection.isNotEmpty) {
// Show selection app bar when selection mode is active
return DocumentSelectionSliverAppBar(
state: state);
}
return const SliverSearchBar(floating: true);
},
),
),
);
},
),
resizeToAvoidBottomInset: true,
body: WillPopScope(
onWillPop: () async {
if (context.read<DocumentsCubit>().state.selection.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection();
}
return false;
},
child: Stack(
children: [
NestedScrollView(
key: _nestedScrollViewKey,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
),
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.selection.isNotEmpty) {
return SliverAppBar(
floating: false,
SliverOverlapAbsorber(
handle: tabBarHandle,
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.selection.isNotEmpty) {
return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);
}
return SliverPersistentHeader(
pinned: true,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context
.read<DocumentsCubit>()
.resetSelection(),
),
title: Text(
"${state.selection.length} ${S.of(context)!.countSelected}",
),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _onDelete(state),
delegate:
CustomizableSliverPersistentHeaderDelegate(
minExtent: kTextTabBarHeight,
maxExtent: kTextTabBarHeight,
child: ColoredTabBar(
backgroundColor: Theme.of(context)
.colorScheme
.background,
tabBar: TabBar(
controller: _tabController,
tabs: [
Tab(text: S.of(context)!.documents),
Tab(text: S.of(context)!.views),
],
),
),
],
),
);
}
return SearchAppBar(
hintText: S.of(context)!.searchDocuments,
onOpenSearch: showDocumentSearchPage,
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: S.of(context)!.documents),
Tab(text: S.of(context)!.views),
],
),
);
},
},
),
),
],
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
if (metrics.maxScrollExtent == 0) {
return true;
}
final desiredTab =
(metrics.pixels / metrics.maxScrollExtent)
.round();
if (metrics.axis == Axis.horizontal &&
_currentTab != desiredTab) {
setState(() => _currentTab = desiredTab);
}
return false;
},
child: TabBarView(
controller: _tabController,
physics: context
.watch<DocumentsCubit>()
.state
.selection
.isNotEmpty
? const NeverScrollableScrollPhysics()
: null,
children: [
Builder(
builder: (context) {
return _buildDocumentsTab(
connectivityState,
context,
);
},
),
Builder(
builder: (context) {
return _buildSavedViewsTab(
connectivityState,
context,
);
},
),
],
),
),
],
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
if (metrics.maxScrollExtent == 0) {
return true;
}
final desiredTab =
(metrics.pixels / metrics.maxScrollExtent).round();
if (metrics.axis == Axis.horizontal &&
_currentTab != desiredTab) {
setState(() => _currentTab = desiredTab);
}
return false;
},
child: TabBarView(
controller: _tabController,
children: [
Builder(
builder: (context) {
return _buildDocumentsTab(
connectivityState,
context,
);
},
),
Builder(
builder: (context) {
return _buildSavedViewsTab(
connectivityState,
context,
);
},
),
],
),
),
),
if (_showBackToTopButton) _buildBackToTopAction(context),
],
],
),
),
),
);
@@ -273,53 +260,22 @@ class _DocumentsPageState extends State<DocumentsPage>
);
}
Widget _buildBackToTopAction(BuildContext context) {
return Transform.translate(
offset: const Offset(24, -24),
child: Align(
alignment: Alignment.bottomLeft,
child: ActionChip(
backgroundColor: Theme.of(context).colorScheme.primary,
side: BorderSide.none,
avatar: Icon(
Icons.expand_less,
color: Theme.of(context).colorScheme.onPrimary,
),
onPressed: () async {
await pagingScrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInExpo,
);
_nestedScrollViewKey.currentState?.outerController.jumpTo(0);
},
label: Text(
S.of(context)!.scrollToTop,
style: DefaultTextStyle.of(context).style.apply(
color: Theme.of(context).colorScheme.onPrimary,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
);
}
Widget _buildSavedViewsTab(
ConnectivityState connectivityState,
BuildContext context,
) {
return RefreshIndicator(
edgeOffset: kToolbarHeight + kTextTabBarHeight,
edgeOffset: kTextTabBarHeight,
onRefresh: _onReloadSavedViews,
notificationPredicate: (_) => connectivityState.isConnected,
child: CustomScrollView(
key: const PageStorageKey<String>("savedViews"),
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
handle: searchBarHandle,
),
SliverOverlapInjector(
handle: tabBarHandle,
),
const SavedViewList(),
],
@@ -332,46 +288,75 @@ class _DocumentsPageState extends State<DocumentsPage>
BuildContext context,
) {
return RefreshIndicator(
edgeOffset: kToolbarHeight + kTextTabBarHeight,
edgeOffset: kTextTabBarHeight,
onRefresh: _onReloadDocuments,
notificationPredicate: (_) => connectivityState.isConnected,
child: CustomScrollView(
key: const PageStorageKey<String>("documents"),
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
_buildViewActions(),
BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.hasLoaded && state.documents.isEmpty) {
return SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
onReset: context.read<DocumentsCubit>().resetFilter,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Listen for scroll notifications to load new data.
// Scroll controller does not work here due to nestedscrollview limitations.
final currState = context.read<DocumentsCubit>().state;
final max = notification.metrics.maxScrollExtent;
if (max == 0 ||
_currentTab != 0 ||
currState.isLoading ||
currState.isLastPageLoaded) {
return true;
}
final offset = notification.metrics.pixels;
if (offset >= max * 0.7) {
context
.read<DocumentsCubit>()
.loadMore()
.onError<PaperlessServerException>(
(error, stackTrace) => showErrorMessage(
context,
error,
stackTrace,
),
);
}
}
return false;
},
child: CustomScrollView(
key: const PageStorageKey<String>("documents"),
slivers: <Widget>[
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
_buildViewActions(),
BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.hasLoaded && state.documents.isEmpty) {
return SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
onReset: context.read<DocumentsCubit>().resetFilter,
),
);
}
return SliverAdaptiveDocumentsView(
viewType: state.viewType,
onTap: _openDetails,
onSelected:
context.read<DocumentsCubit>().toggleDocumentSelection,
hasInternetConnection: connectivityState.isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter,
onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter,
documents: state.documents,
hasLoaded: state.hasLoaded,
isLabelClickable: true,
isLoading: state.isLoading,
selectedDocumentIds: state.selectedIds,
);
},
),
],
return SliverAdaptiveDocumentsView(
viewType: state.viewType,
onTap: _openDetails,
onSelected:
context.read<DocumentsCubit>().toggleDocumentSelection,
hasInternetConnection: connectivityState.isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter,
onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter,
documents: state.documents,
hasLoaded: state.hasLoaded,
isLabelClickable: true,
isLoading: state.isLoading,
selectedDocumentIds: state.selectedIds,
);
},
),
],
),
),
);
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:provider/provider.dart';
class DocumentSelectionSliverAppBar extends StatelessWidget {
final DocumentsState state;
const DocumentSelectionSliverAppBar({super.key, required this.state});
@override
Widget build(BuildContext context) {
return SliverAppBar(
pinned: true,
title: Text(
S.of(context)!.countSelected(state.selection.length),
),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.read<DocumentsCubit>().resetSelection(),
),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) =>
BulkDeleteConfirmationDialog(state: state),
) ??
false;
if (shouldDelete) {
try {
await context
.read<DocumentsCubit>()
.bulkDelete(state.selection);
showSnackBar(
context,
S.of(context)!.documentsSuccessfullyDeleted,
);
context.read<DocumentsCubit>().resetSelection();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
},
),
],
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:paperless_mobile/features/document_search/view/document_search_p
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/documents_list_loading_widget.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/inbox/view/widgets/inbox_empty_widget.dart';
@@ -27,6 +28,9 @@ class InboxPage extends StatefulWidget {
class _InboxPageState extends State<InboxPage>
with DocumentPagingViewMixin<InboxPage, InboxCubit> {
final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle();
@override
final pagingScrollController = ScrollController();
final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
@@ -39,12 +43,6 @@ class _InboxPageState extends State<InboxPage>
@override
Widget build(BuildContext context) {
final safeAreaPadding = MediaQuery.of(context).padding;
final availableHeight = MediaQuery.of(context).size.height -
kToolbarHeight -
kBottomNavigationBarHeight -
safeAreaPadding.top -
safeAreaPadding.bottom;
return Scaffold(
drawer: const AppDrawer(),
floatingActionButton: BlocBuilder<InboxCubit, InboxState>(
@@ -68,99 +66,97 @@ class _InboxPageState extends State<InboxPage>
builder: (context, state) {
return SafeArea(
top: true,
child: Builder(
builder: (context) {
// Build a list of slivers alternating between SliverToBoxAdapter
// (group header) and a SliverList (inbox items).
final List<Widget> slivers = _groupByDate(state.documents)
.entries
.map(
(entry) => [
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerLeft,
child: ClipRRect(
borderRadius: BorderRadius.circular(32.0),
child: Text(
entry.key,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
).padded(),
),
).paddedOnly(top: 8.0),
),
SliverList(
delegate: SliverChildBuilderDelegate(
childCount: entry.value.length,
(context, index) {
if (index < entry.value.length - 1) {
return Column(
children: [
_buildListItem(
entry.value[index],
),
const Divider(
indent: 16,
endIndent: 16,
),
],
);
}
return _buildListItem(
entry.value[index],
);
},
),
),
],
)
.flattened
.toList()
..add(const SliverToBoxAdapter(child: SizedBox(height: 78)));
// edgeOffset: kToolbarHeight,
return RefreshIndicator(
edgeOffset: kToolbarHeight,
onRefresh: context.read<InboxCubit>().reload,
child: CustomScrollView(
physics: state.documents.isEmpty
? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(),
controller: pagingScrollController,
slivers: [
SearchAppBar(
hintText: S.of(context)!.searchDocuments,
onOpenSearch: showDocumentSearchPage,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: const SliverSearchBar(),
)
],
body: Builder(
builder: (context) {
if (!state.hasLoaded) {
return const DocumentsListLoadingWidget(); //TODO: Implement InboxLoadingWidget...
} else if (state.documents.isEmpty) {
return Center(
child: InboxEmptyWidget(
emptyStateRefreshIndicatorKey:
_emptyStateRefreshIndicatorKey,
),
if (state.documents.isEmpty)
SliverToBoxAdapter(
child: SizedBox(
height: availableHeight,
child: Center(
child: InboxEmptyWidget(
emptyStateRefreshIndicatorKey:
_emptyStateRefreshIndicatorKey,
),
);
} else {
return RefreshIndicator(
edgeOffset: kToolbarHeight,
onRefresh: context.read<InboxCubit>().reload,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: HintCard(
show: !state.isHintAcknowledged,
hintText:
S.of(context)!.swipeLeftToMarkADocumentAsSeen,
onHintAcknowledged: () =>
context.read<InboxCubit>().acknowledgeHint(),
),
),
)
else if (!state.hasLoaded)
DocumentsListLoadingWidget()
else
SliverToBoxAdapter(
child: HintCard(
show: !state.isHintAcknowledged,
hintText:
S.of(context)!.swipeLeftToMarkADocumentAsSeen,
onHintAcknowledged: () =>
context.read<InboxCubit>().acknowledgeHint(),
// Build a list of slivers alternating between SliverToBoxAdapter
// (group header) and a SliverList (inbox items).
..._groupByDate(state.documents)
.entries
.map(
(entry) => [
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerLeft,
child: ClipRRect(
borderRadius:
BorderRadius.circular(32.0),
child: Text(
entry.key,
style: Theme.of(context)
.textTheme
.bodySmall,
textAlign: TextAlign.center,
).padded(),
),
).paddedOnly(top: 8.0),
),
SliverList(
delegate: SliverChildBuilderDelegate(
childCount: entry.value.length,
(context, index) {
if (index < entry.value.length - 1) {
return Column(
children: [
_buildListItem(
entry.value[index],
),
const Divider(
indent: 16,
endIndent: 16,
),
],
);
}
return _buildListItem(
entry.value[index],
);
},
),
),
],
)
.flattened
.toList(),
const SliverToBoxAdapter(
child: SizedBox(height: 78),
),
),
...slivers,
],
),
);
},
],
),
);
}
},
),
),
);
},

View File

@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart';
@@ -29,6 +30,11 @@ class LabelsPage extends StatefulWidget {
class _LabelsPageState extends State<LabelsPage>
with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle tabBarHandle =
SliverOverlapAbsorberHandle();
late final TabController _tabController;
int _currentIndex = 0;
@@ -46,217 +52,212 @@ class _LabelsPageState extends State<LabelsPage>
length: 3,
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectedState) {
return Scaffold(
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
onPressed: [
_openAddCorrespondentPage,
_openAddDocumentTypePage,
_openAddTagPage,
_openAddStoragePathPage,
][_currentIndex],
child: Icon(Icons.add),
),
body: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
return SafeArea(
child: Scaffold(
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
onPressed: [
_openAddCorrespondentPage,
_openAddDocumentTypePage,
_openAddTagPage,
_openAddStoragePathPage,
][_currentIndex],
child: const Icon(Icons.add),
),
body: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: const SliverSearchBar(),
),
sliver: SearchAppBar(
hintText: S.of(context)!.searchDocuments,
onOpenSearch: showDocumentSearchPage,
bottom: TabBar(
SliverOverlapAbsorber(
handle: tabBarHandle,
sliver: SliverPersistentHeader(
pinned: true,
delegate: CustomizableSliverPersistentHeaderDelegate(
child: ColoredTabBar(
backgroundColor:
Theme.of(context).colorScheme.background,
tabBar: TabBar(
controller: _tabController,
tabs: [
Tab(
icon: Icon(
Icons.person_outline,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
Tab(
icon: Icon(
Icons.description_outlined,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
Tab(
icon: Icon(
Icons.label_outline,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
Tab(
icon: Icon(
Icons.folder_open,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
],
),
),
minExtent: kTextTabBarHeight,
maxExtent: kTextTabBarHeight),
),
),
],
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
if (metrics.maxScrollExtent == 0) {
return true;
}
final desiredTab =
((metrics.pixels / metrics.maxScrollExtent) *
(_tabController.length - 1))
.round();
if (metrics.axis == Axis.horizontal &&
_currentIndex != desiredTab) {
setState(() => _currentIndex = desiredTab);
}
return true;
},
child: RefreshIndicator(
edgeOffset: kToolbarHeight + kTextTabBarHeight,
notificationPredicate: (notification) =>
connectedState.isConnected,
onRefresh: () => [
context.read<LabelCubit<Correspondent>>(),
context.read<LabelCubit<DocumentType>>(),
context.read<LabelCubit<Tag>>(),
context.read<LabelCubit<StoragePath>>(),
][_currentIndex]
.reload(),
child: TabBarView(
controller: _tabController,
tabs: [
Tab(
icon: Icon(
Icons.person_outline,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
children: [
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<Correspondent>(
filterBuilder: (label) => DocumentFilter(
correspondent:
IdQueryParameter.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
onEdit: _openEditCorrespondentPage,
emptyStateActionButtonLabel:
S.of(context)!.addNewCorrespondent,
emptyStateDescription:
S.of(context)!.noCorrespondentsSetUp,
onAddNew: _openAddCorrespondentPage,
),
],
);
},
),
Tab(
icon: Icon(
Icons.description_outlined,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<DocumentType>(
filterBuilder: (label) => DocumentFilter(
documentType:
IdQueryParameter.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
onEdit: _openEditDocumentTypePage,
emptyStateActionButtonLabel:
S.of(context)!.addNewDocumentType,
emptyStateDescription:
S.of(context)!.noDocumentTypesSetUp,
onAddNew: _openAddDocumentTypePage,
),
],
);
},
),
Tab(
icon: Icon(
Icons.label_outline,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<Tag>(
filterBuilder: (label) => DocumentFilter(
tags: IdsTagsQuery.fromIds([label.id!]),
pageSize: label.documentCount ?? 0,
),
onEdit: _openEditTagPage,
leadingBuilder: (t) => CircleAvatar(
backgroundColor: t.color,
child: t.isInboxTag ?? false
? Icon(
Icons.inbox,
color: t.textColor,
)
: null,
),
emptyStateActionButtonLabel:
S.of(context)!.addNewTag,
emptyStateDescription:
S.of(context)!.noTagsSetUp,
onAddNew: _openAddTagPage,
),
],
);
},
),
Tab(
icon: Icon(
Icons.folder_open,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<StoragePath>(
onEdit: _openEditStoragePathPage,
filterBuilder: (label) => DocumentFilter(
storagePath:
IdQueryParameter.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
contentBuilder: (path) => Text(path.path),
emptyStateActionButtonLabel:
S.of(context)!.addNewStoragePath,
emptyStateDescription:
S.of(context)!.noStoragePathsSetUp,
onAddNew: _openAddStoragePathPage,
),
],
);
},
),
],
),
),
),
],
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
if (metrics.maxScrollExtent == 0) {
return true;
}
final desiredTab =
((metrics.pixels / metrics.maxScrollExtent) *
(_tabController.length - 1))
.round();
if (metrics.axis == Axis.horizontal &&
_currentIndex != desiredTab) {
setState(() => _currentIndex = desiredTab);
}
return true;
},
child: RefreshIndicator(
edgeOffset: kToolbarHeight + kTextTabBarHeight,
notificationPredicate: (notification) =>
connectedState.isConnected,
onRefresh: () => [
context.read<LabelCubit<Correspondent>>(),
context.read<LabelCubit<DocumentType>>(),
context.read<LabelCubit<Tag>>(),
context.read<LabelCubit<StoragePath>>(),
][_currentIndex]
.reload(),
child: TabBarView(
controller: _tabController,
children: [
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
LabelTabView<Correspondent>(
filterBuilder: (label) => DocumentFilter(
correspondent:
IdQueryParameter.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
onEdit: _openEditCorrespondentPage,
emptyStateActionButtonLabel:
S.of(context)!.addNewCorrespondent,
emptyStateDescription:
S.of(context)!.noCorrespondentsSetUp,
onAddNew: _openAddCorrespondentPage,
),
],
);
},
),
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
LabelTabView<DocumentType>(
filterBuilder: (label) => DocumentFilter(
documentType:
IdQueryParameter.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
onEdit: _openEditDocumentTypePage,
emptyStateActionButtonLabel:
S.of(context)!.addNewDocumentType,
emptyStateDescription:
S.of(context)!.noDocumentTypesSetUp,
onAddNew: _openAddDocumentTypePage,
),
],
);
},
),
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
LabelTabView<Tag>(
filterBuilder: (label) => DocumentFilter(
tags: IdsTagsQuery.fromIds([label.id!]),
pageSize: label.documentCount ?? 0,
),
onEdit: _openEditTagPage,
leadingBuilder: (t) => CircleAvatar(
backgroundColor: t.color,
child: t.isInboxTag ?? false
? Icon(
Icons.inbox,
color: t.textColor,
)
: null,
),
emptyStateActionButtonLabel:
S.of(context)!.addNewTag,
emptyStateDescription:
S.of(context)!.noTagsSetUp,
onAddNew: _openAddTagPage,
),
],
);
},
),
Builder(
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(context),
),
LabelTabView<StoragePath>(
onEdit: _openEditStoragePathPage,
filterBuilder: (label) => DocumentFilter(
storagePath:
IdQueryParameter.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
contentBuilder: (path) => Text(path.path),
emptyStateActionButtonLabel:
S.of(context)!.addNewStoragePath,
emptyStateDescription:
S.of(context)!.noStoragePathsSetUp,
onAddNew: _openAddStoragePathPage,
),
],
);
},
),
],
),
),
),
),
);

View File

@@ -27,6 +27,8 @@ class LabelText<T extends Label> extends StatelessWidget {
return Text(
state.labels[id]?.toString() ?? placeholder,
style: style,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),