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

4
.gitignore vendored
View File

@@ -63,4 +63,6 @@ untranslated_messages.txt
#lakos generated files
**/dot_images/*
docker/
docker/
crowdin_credentials.yml

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class CustomizableSliverPersistentHeaderDelegate
extends SliverPersistentHeaderDelegate {
@override
final double minExtent;
@override
final double maxExtent;
final Widget child;
CustomizableSliverPersistentHeaderDelegate({
required this.child,
required this.minExtent,
required this.maxExtent,
});
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return child;
}
@override
bool shouldRebuild(CustomizableSliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}

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

View File

@@ -526,7 +526,7 @@
"@connectionSuccessfulylEstablished": {},
"hostCouldNotBeResolved": "Adresa nemohla být rozpoznána. Zkontrolujte prosím adresu serveru a své internetové připojení.",
"@hostCouldNotBeResolved": {},
"serverAddress": "'Adresa serveru",
"serverAddress": "Adresa serveru",
"@serverAddress": {},
"invalidAddress": "Neplatná adresa",
"@invalidAddress": {},

View File

@@ -672,8 +672,8 @@
"@grid": {},
"list": "Liste",
"@list": {},
"remove": "Remove",
"removeQueryFromSearchHistory": "Remove query from search history?",
"remove": "Entfernen",
"removeQueryFromSearchHistory": "Aus Suchverlauf entfernen?",
"dynamicColorScheme": "Dynamisch",
"@dynamicColorScheme": {},
"classicColorScheme": "Klassisch",

View File

@@ -1,55 +1,55 @@
{
"developedBy": "Developed by {name}",
"developedBy": "Développé par {name}",
"@developedBy": {
"placeholders": {
"name": {}
}
},
"addAnotherAccount": "Add another account",
"addAnotherAccount": "Ajouter un autre compte",
"@addAnotherAccount": {},
"account": "Account",
"account": "Compte",
"@account": {},
"addCorrespondent": "New Correspondent",
"addCorrespondent": "Nouveau correspondant",
"@addCorrespondent": {
"description": "Title when adding a new correspondent"
},
"addDocumentType": "New Document Type",
"addDocumentType": "Nouveau type de document",
"@addDocumentType": {
"description": "Title when adding a new document type"
},
"addStoragePath": "New Storage Path",
"addStoragePath": "Nouveau chemin de stockage",
"@addStoragePath": {
"description": "Title when adding a new storage path"
},
"addTag": "New Tag",
"addTag": "Nouvelle étiquette",
"@addTag": {
"description": "Title when adding a new tag"
},
"aboutThisApp": "About this app",
"aboutThisApp": "À propos de cette application",
"@aboutThisApp": {
"description": "Label for about this app tile displayed in the drawer"
},
"loggedInAs": "Logged in as {name}",
"loggedInAs": "Connecté en tant que {name}",
"@loggedInAs": {
"placeholders": {
"name": {}
}
},
"disconnect": "Disconnect",
"disconnect": "Se déconnecter",
"@disconnect": {
"description": "Logout button label"
},
"reportABug": "Report a Bug",
"reportABug": "Signaler un bug",
"@reportABug": {},
"settings": "Settings",
"settings": "Paramètres",
"@settings": {},
"authenticateOnAppStart": "Authenticate on app start",
"authenticateOnAppStart": "S'authentifier au démarrage de l'application",
"@authenticateOnAppStart": {
"description": "Description of the biometric authentication settings tile"
},
"biometricAuthentication": "Biometric authentication",
"biometricAuthentication": "Authentification biométrique",
"@biometricAuthentication": {},
"authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}",
"authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authentifiez-vous pour activer l'authentification biométrique} disable{Authentifiez-vous pour désactiver l'authentification biométrique} other{}}",
"@authenticateToToggleBiometricAuthentication": {
"placeholders": {
"mode": {}
@@ -57,171 +57,171 @@
},
"documents": "Documents",
"@documents": {},
"inbox": "Inbox",
"inbox": "Boîte de réception",
"@inbox": {},
"labels": "Labels",
"labels": "Étiquettes",
"@labels": {},
"scanner": "Scanner",
"scanner": "Scanneur",
"@scanner": {},
"startTyping": "Start typing...",
"startTyping": "Commencez à écrire...",
"@startTyping": {},
"doYouReallyWantToDeleteThisView": "Do you really want to delete this view?",
"doYouReallyWantToDeleteThisView": "Voulez-vous vraiment supprimer cette vue enregistrée ?",
"@doYouReallyWantToDeleteThisView": {},
"deleteView": "Delete view ",
"deleteView": "Supprimer la vue enregistrée ",
"@deleteView": {},
"addedAt": "Added at",
"addedAt": "Date dajout",
"@addedAt": {},
"archiveSerialNumber": "Archive Serial Number",
"archiveSerialNumber": "Numéro de série de larchive",
"@archiveSerialNumber": {},
"asn": "ASN",
"asn": "NSA",
"@asn": {},
"correspondent": "Correspondent",
"correspondent": "Correspondant",
"@correspondent": {},
"createdAt": "Created at",
"createdAt": "Date de création",
"@createdAt": {},
"documentSuccessfullyDeleted": "Document successfully deleted.",
"documentSuccessfullyDeleted": "Le document a bien été supprimé.",
"@documentSuccessfullyDeleted": {},
"assignAsn": "Assign ASN",
"assignAsn": "Assigner un NSA",
"@assignAsn": {},
"deleteDocumentTooltip": "Delete",
"deleteDocumentTooltip": "Supprimer",
"@deleteDocumentTooltip": {
"description": "Tooltip shown for the delete button on details page"
},
"downloadDocumentTooltip": "Download",
"downloadDocumentTooltip": "Télécharger",
"@downloadDocumentTooltip": {
"description": "Tooltip shown for the download button on details page"
},
"editDocumentTooltip": "Edit",
"editDocumentTooltip": "Modifier",
"@editDocumentTooltip": {
"description": "Tooltip shown for the edit button on details page"
},
"loadFullContent": "Load full content",
"loadFullContent": "Charger tout le contenu",
"@loadFullContent": {},
"noAppToDisplayPDFFilesFound": "No app to display PDF files found!",
"noAppToDisplayPDFFilesFound": "Aucune application trouvée pour afficher les fichiers PDF !",
"@noAppToDisplayPDFFilesFound": {},
"openInSystemViewer": "Open in system viewer",
"openInSystemViewer": "Ouvrir dans le lecteur système",
"@openInSystemViewer": {},
"couldNotOpenFilePermissionDenied": "Could not open file: Permission denied.",
"couldNotOpenFilePermissionDenied": "Impossible d'ouvrir le fichier : permission refusée.",
"@couldNotOpenFilePermissionDenied": {},
"previewTooltip": "Preview",
"previewTooltip": "Prévisualisation",
"@previewTooltip": {
"description": "Tooltip shown for the preview button on details page"
},
"shareTooltip": "Share",
"shareTooltip": "Partager",
"@shareTooltip": {
"description": "Tooltip shown for the share button on details page"
},
"similarDocuments": "Similar Documents",
"similarDocuments": "Documents similaires",
"@similarDocuments": {
"description": "Label shown in the tabbar on details page"
},
"content": "Content",
"content": "Contenu",
"@content": {
"description": "Label shown in the tabbar on details page"
},
"metaData": "Meta Data",
"metaData": "Métadonnées",
"@metaData": {
"description": "Label shown in the tabbar on details page"
},
"overview": "Overview",
"overview": "Vue densemble",
"@overview": {
"description": "Label shown in the tabbar on details page"
},
"documentType": "Document Type",
"documentType": "Type de document",
"@documentType": {},
"archivedPdf": "Archived (pdf)",
"archivedPdf": "Archive (PDF)",
"@archivedPdf": {
"description": "Option to chose when downloading a document"
},
"chooseFiletype": "Choose filetype",
"chooseFiletype": "Choisissez le type de fichier",
"@chooseFiletype": {},
"original": "Original",
"@original": {
"description": "Option to chose when downloading a document"
},
"documentSuccessfullyDownloaded": "Document successfully downloaded.",
"documentSuccessfullyDownloaded": "Document téléchargé avec succès.",
"@documentSuccessfullyDownloaded": {},
"suggestions": "Suggestions: ",
"suggestions": "Suggestions : ",
"@suggestions": {},
"editDocument": "Edit Document",
"editDocument": "Modifier le document",
"@editDocument": {},
"advanced": "Advanced",
"@advanced": {},
"apply": "Apply",
"apply": "Appliquer",
"@apply": {},
"extended": "Extended",
"@extended": {},
"titleAndContent": "Title & Content",
"titleAndContent": "Titre & contenu",
"@titleAndContent": {},
"title": "Title",
"title": "Titre",
"@title": {},
"reset": "Reset",
"reset": "Réinitialiser",
"@reset": {},
"filterDocuments": "Filter Documents",
"filterDocuments": "Filtrer les documents",
"@filterDocuments": {
"description": "Title of the document filter"
},
"originalMD5Checksum": "Original MD5-Checksum",
"originalMD5Checksum": "Somme de contrôle MD5 de l'original",
"@originalMD5Checksum": {},
"mediaFilename": "Media Filename",
"mediaFilename": "Nom de fichier du média",
"@mediaFilename": {},
"originalFileSize": "Original File Size",
"originalFileSize": "Taille de fichier de l'original",
"@originalFileSize": {},
"originalMIMEType": "Original MIME-Type",
"originalMIMEType": "Type mime de l'original",
"@originalMIMEType": {},
"modifiedAt": "Modified at",
"modifiedAt": "Date de modification",
"@modifiedAt": {},
"preview": "Preview",
"preview": "Prévisualisation",
"@preview": {
"description": "Title of the document preview page"
},
"scanADocument": "Scan a document",
"scanADocument": "Scanner un document",
"@scanADocument": {},
"noDocumentsScannedYet": "No documents scanned yet.",
"noDocumentsScannedYet": "Aucun document scanné pour le moment.",
"@noDocumentsScannedYet": {},
"or": "or",
"or": "ou",
"@or": {
"description": "Used on the scanner page between both main actions when no scans have been captured."
},
"deleteAllScans": "Delete all scans",
"deleteAllScans": "Supprimer tous les scans",
"@deleteAllScans": {},
"uploadADocumentFromThisDevice": "Upload a document from this device",
"uploadADocumentFromThisDevice": "Charger un document depuis cet appareil",
"@uploadADocumentFromThisDevice": {
"description": "Button label on scanner page"
},
"noMatchesFound": "No matches found.",
"noMatchesFound": "Aucune correspondance trouvée.",
"@noMatchesFound": {
"description": "Displayed when no documents were found in the document search."
},
"removeFromSearchHistory": "Remove from search history?",
"removeFromSearchHistory": "Supprimer de l'historique des recherches ?",
"@removeFromSearchHistory": {},
"results": "Results",
"results": "Résultats",
"@results": {
"description": "Label displayed above search results in document search."
},
"searchDocuments": "Search documents",
"searchDocuments": "Rechercher des documents",
"@searchDocuments": {},
"resetFilter": "Reset filter",
"resetFilter": "Réinitialiser le filtre",
"@resetFilter": {},
"lastMonth": "Last Month",
"lastMonth": "Le mois dernier",
"@lastMonth": {},
"last7Days": "Last 7 Days",
"last7Days": "Les 7 derniers jours",
"@last7Days": {},
"last3Months": "Last 3 Months",
"last3Months": "Les 3 derniers mois",
"@last3Months": {},
"lastYear": "Last Year",
"lastYear": "L'année dernière",
"@lastYear": {},
"search": "Search",
"search": "Recherche",
"@search": {},
"documentsSuccessfullyDeleted": "Documents successfully deleted.",
"documentsSuccessfullyDeleted": "Les documents ont bien été supprimés.",
"@documentsSuccessfullyDeleted": {},
"thereSeemsToBeNothingHere": "There seems to be nothing here...",
"@thereSeemsToBeNothingHere": {},
"oops": "Oops.",
"oops": "Oups.",
"@oops": {},
"newDocumentAvailable": "New document available!",
"newDocumentAvailable": "Nouveau document disponible !",
"@newDocumentAvailable": {},
"orderBy": "Order By",
"orderBy": "Trier par",
"@orderBy": {},
"thisActionIsIrreversibleDoYouWishToProceedAnyway": "This action is irreversible. Do you wish to proceed anyway?",
"@thisActionIsIrreversibleDoYouWishToProceedAnyway": {},
@@ -244,17 +244,17 @@
"@storagePath": {},
"prepareDocument": "Prepare document",
"@prepareDocument": {},
"tags": "Tags",
"tags": "Étiquettes",
"@tags": {},
"documentSuccessfullyUpdated": "Document successfully updated.",
"documentSuccessfullyUpdated": "Le document a bien été modifié.",
"@documentSuccessfullyUpdated": {},
"fileName": "File Name",
"fileName": "Nom du fichier",
"@fileName": {},
"synchronizeTitleAndFilename": "Synchronize title and filename",
"synchronizeTitleAndFilename": "Synchroniser le titre et le nom du fichier",
"@synchronizeTitleAndFilename": {},
"reload": "Reload",
"reload": "Recharger",
"@reload": {},
"documentSuccessfullyUploadedProcessing": "Document successfully uploaded, processing...",
"documentSuccessfullyUploadedProcessing": "Le document a bien été chargé, traitement en cours...",
"@documentSuccessfullyUploadedProcessing": {},
"deleteLabelWarningText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?",
"@deleteLabelWarningText": {},
@@ -282,7 +282,7 @@
"@youAreCurrentlyOffline": {},
"couldNotAssignArchiveSerialNumber": "Could not assign archive serial number.",
"@couldNotAssignArchiveSerialNumber": {},
"couldNotDeleteDocument": "Could not delete document, please try again.",
"couldNotDeleteDocument": "Impossible de supprimer le document, veuillez réessayer.",
"@couldNotDeleteDocument": {},
"couldNotLoadDocuments": "Could not load documents, please try again.",
"@couldNotLoadDocuments": {},
@@ -294,7 +294,7 @@
"@couldNotLoadDocumentTypes": {},
"couldNotUpdateDocument": "Could not update document, please try again.",
"@couldNotUpdateDocument": {},
"couldNotUploadDocument": "Could not upload document, please try again.",
"couldNotUploadDocument": "Impossible de charger le document, veuillez réessayer.",
"@couldNotUploadDocument": {},
"invalidCertificateOrMissingPassphrase": "Invalid certificate or missing passphrase, please try again",
"@invalidCertificateOrMissingPassphrase": {},
@@ -416,11 +416,11 @@
"@select": {},
"saveChanges": "Save changes",
"@saveChanges": {},
"upload": "Upload",
"upload": "Charger le document",
"@upload": {},
"youreOffline": "You're offline.",
"@youreOffline": {},
"deleteDocument": "Delete document",
"deleteDocument": "Supprimer le document",
"@deleteDocument": {
"description": "Used as an action label on each inbox item"
},
@@ -658,7 +658,7 @@
"@filterTags": {},
"inboxTag": "Inbox-Tag",
"@inboxTag": {},
"uploadInferValuesHint": "If you specify values for these fields, your paperless instance will not automatically derive a value. If you want these values to be automatically populated by your server, leave the fields blank.",
"uploadInferValuesHint": "Si vous spécifiez des valeurs pour ces champs, votre instance Paperless ne dérivera pas automatiquement une valeur. Si vous voulez que ces valeurs soient automatiquement remplies par votre serveur, laissez les champs vides.",
"@uploadInferValuesHint": {},
"useTheConfiguredBiometricFactorToAuthenticate": "Use the configured biometric factor to authenticate and unlock your documents.",
"@useTheConfiguredBiometricFactorToAuthenticate": {},

View File

@@ -298,7 +298,7 @@
"@couldNotUploadDocument": {},
"invalidCertificateOrMissingPassphrase": "Invalid certificate or missing passphrase, please try again",
"@invalidCertificateOrMissingPassphrase": {},
"couldNotLoadSavedViews": "Could not load views.",
"couldNotLoadSavedViews": "Could not load saved views.",
"@couldNotLoadSavedViews": {},
"aClientCertificateWasExpectedButNotSent": "A client certificate was expected but not sent. Please provide a valid client certificate.",
"@aClientCertificateWasExpectedButNotSent": {},
@@ -488,7 +488,7 @@
"@noStoragePathsSetUp": {},
"storagePaths": "Storage Paths",
"@storagePaths": {},
"addNewTag": "Dodaj nowy tag",
"addNewTag": "Add new tag",
"@addNewTag": {},
"noTagsSetUp": "You don't seem to have any tags set up.",
"@noTagsSetUp": {},