WIP - Implemented similar documents view

This commit is contained in:
Anton Stubenbord
2023-01-22 01:17:52 +01:00
parent d4978172cf
commit b370fa4164
27 changed files with 476 additions and 381 deletions

View File

@@ -5,30 +5,45 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
class DocumentsListLoadingWidget extends StatelessWidget { class DocumentsListLoadingWidget extends StatelessWidget {
final List<Widget> above; final List<Widget> beforeWidgets;
final List<Widget> below; final List<Widget> afterWidgets;
static const tags = [" ", " ", " "];
static const titleLengths = <double>[double.infinity, 150.0, 200.0]; static const _tags = [" ", " ", " "];
static const correspondentLengths = <double>[200.0, 300.0, 150.0]; static const _titleLengths = <double>[double.infinity, 150.0, 200.0];
static const fontSize = 16.0; static const _correspondentLengths = <double>[200.0, 300.0, 150.0];
static const _fontSize = 16.0;
const DocumentsListLoadingWidget({ const DocumentsListLoadingWidget({
super.key, super.key,
this.above = const [], this.beforeWidgets = const [],
this.below = const [], this.afterWidgets = const [],
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( final _random = Random();
children: <Widget>[ return CustomScrollView(
...above, slivers: [
...List.generate(25, (idx) { SliverList(
final r = Random(idx); delegate: SliverChildListDelegate(beforeWidgets),
final tagCount = r.nextInt(tags.length + 1); ),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _buildFakeListItem(context, _random);
},
),
),
SliverList(delegate: SliverChildListDelegate(afterWidgets))
],
);
}
Widget _buildFakeListItem(BuildContext context, Random random) {
final tagCount = random.nextInt(_tags.length + 1);
final correspondentLength = final correspondentLength =
correspondentLengths[r.nextInt(correspondentLengths.length - 1)]; _correspondentLengths[random.nextInt(_correspondentLengths.length - 1)];
final titleLength = titleLengths[r.nextInt(titleLengths.length - 1)]; final titleLength = _titleLengths[random.nextInt(_titleLengths.length - 1)];
return Shimmer.fromColors( return Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]! ? Colors.grey[300]!
@@ -51,7 +66,7 @@ class DocumentsListLoadingWidget extends StatelessWidget {
title: Container( title: Container(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.symmetric(vertical: 2.0),
width: correspondentLength, width: correspondentLength,
height: fontSize, height: _fontSize,
color: Colors.white, color: Colors.white,
), ),
subtitle: Padding( subtitle: Padding(
@@ -62,7 +77,7 @@ class DocumentsListLoadingWidget extends StatelessWidget {
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.symmetric(vertical: 2.0),
height: fontSize, height: _fontSize,
width: titleLength, width: titleLength,
color: Colors.white, color: Colors.white,
), ),
@@ -71,7 +86,7 @@ class DocumentsListLoadingWidget extends StatelessWidget {
children: List.generate( children: List.generate(
tagCount, tagCount,
(index) => InputChip( (index) => InputChip(
label: Text(tags[r.nextInt(tags.length)]), label: Text(_tags[random.nextInt(_tags.length)]),
), ),
), ),
).paddedOnly(top: 4), ).paddedOnly(top: 4),
@@ -80,9 +95,5 @@ class DocumentsListLoadingWidget extends StatelessWidget {
), ),
), ),
); );
}).toList(),
...below,
],
);
} }
} }

View File

@@ -6,6 +6,7 @@ import 'package:paperless_mobile/generated/l10n.dart';
class HintCard extends StatelessWidget { class HintCard extends StatelessWidget {
final String hintText; final String hintText;
final double elevation; final double elevation;
final IconData hintIcon;
final VoidCallback? onHintAcknowledged; final VoidCallback? onHintAcknowledged;
final bool show; final bool show;
const HintCard({ const HintCard({
@@ -13,7 +14,8 @@ class HintCard extends StatelessWidget {
required this.hintText, required this.hintText,
this.onHintAcknowledged, this.onHintAcknowledged,
this.elevation = 1, this.elevation = 1,
required this.show, this.show = true,
this.hintIcon = Icons.tips_and_updates_outlined,
}); });
@override @override
@@ -31,7 +33,7 @@ class HintCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Icons.tips_and_updates_outlined, hintIcon,
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
).padded(), ).padded(),
Align( Align(
@@ -52,7 +54,7 @@ class HintCard extends StatelessWidget {
), ),
) )
else else
Padding(padding: EdgeInsets.only(bottom: 24)), const Padding(padding: EdgeInsets.only(bottom: 24)),
], ],
).padded(), ).padded(),
).padded(), ).padded(),

View File

@@ -14,6 +14,7 @@ import 'package:paperless_mobile/core/widgets/highlighted_text.dart';
import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/pages/similar_documents_view.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_edit_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_edit_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart';
@@ -23,6 +24,7 @@ import 'package:paperless_mobile/features/edit_document/cubit/edit_document_cubi
import 'package:paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart'; import 'package:paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart';
import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart'; import 'package:paperless_mobile/util.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -31,6 +33,7 @@ import 'package:badges/badges.dart' as b;
import '../../../../core/repository/state/impl/document_type_repository_state.dart'; import '../../../../core/repository/state/impl/document_type_repository_state.dart';
//TODO: Refactor this into several widgets
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final bool allowEdit; final bool allowEdit;
final bool isLabelClickable; final bool isLabelClickable;
@@ -48,6 +51,16 @@ class DocumentDetailsPage extends StatefulWidget {
} }
class _DocumentDetailsPageState extends State<DocumentDetailsPage> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
late Future<DocumentMetaData> _metaData;
@override
void initState() {
super.initState();
_metaData = context
.read<PaperlessDocumentsApi>()
.getMetaData(context.read<DocumentDetailsCubit>().state.document);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(
@@ -57,102 +70,11 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
return false; return false;
}, },
child: DefaultTabController( child: DefaultTabController(
length: 3, length: 4,
child: Scaffold( child: Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
floatingActionButton: widget.allowEdit floatingActionButton: widget.allowEdit ? _buildAppBar() : null,
? BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( bottomNavigationBar: _buildBottomAppBar(),
builder: (context, state) {
final _filteredSuggestions =
state.suggestions.documentDifference(state.document);
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
if (!connectivityState.isConnected) {
return Container();
}
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: _filteredSuggestions.hasSuggestions,
child: Tooltip(
message:
S.of(context).documentDetailsPageEditTooltip,
preferBelow: false,
verticalOffset: 40,
child: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
),
),
badgeContent: Text(
'${_filteredSuggestions.suggestionsCount}',
style: const TextStyle(
color: Colors.white,
),
),
badgeColor: Colors.red,
);
},
);
},
)
: null,
bottomNavigationBar:
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
return BottomAppBar(
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
final isConnected = connectivityState.isConnected;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
tooltip:
S.of(context).documentDetailsPageDeleteTooltip,
icon: const Icon(Icons.delete),
onPressed: widget.allowEdit && isConnected
? () => _onDelete(state.document)
: null,
).paddedSymmetrically(horizontal: 4),
Tooltip(
message:
S.of(context).documentDetailsPageDownloadTooltip,
child: DocumentDownloadButton(
document: state.document,
enabled: isConnected,
),
),
IconButton(
tooltip:
S.of(context).documentDetailsPagePreviewTooltip,
icon: const Icon(Icons.visibility),
onPressed: isConnected
? () => _onOpen(state.document)
: null,
).paddedOnly(right: 4.0),
IconButton(
tooltip: S
.of(context)
.documentDetailsPageOpenInSystemViewerTooltip,
icon: const Icon(Icons.open_in_new),
onPressed:
isConnected ? _onOpenFileInSystemViewer : null,
).paddedOnly(right: 4.0),
IconButton(
tooltip:
S.of(context).documentDetailsPageShareTooltip,
icon: const Icon(Icons.share),
onPressed: isConnected
? () => _onShare(state.document)
: null,
),
],
);
},
),
);
},
),
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [ headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar( SliverAppBar(
@@ -180,6 +102,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
backgroundColor: backgroundColor:
Theme.of(context).colorScheme.primaryContainer, Theme.of(context).colorScheme.primaryContainer,
tabBar: TabBar( tabBar: TabBar(
isScrollable: true,
tabs: [ tabs: [
Tab( Tab(
child: Text( child: Text(
@@ -208,6 +131,18 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
.onPrimaryContainer), .onPrimaryContainer),
), ),
), ),
Tab(
child: Text(
S
.of(context)
.documentDetailsPageTabSimilarDocumentsLabel,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
], ],
), ),
), ),
@@ -215,7 +150,12 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
], ],
body: BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( body: BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) { builder: (context, state) {
return TabBarView( return BlocProvider(
create: (context) => SimilarDocumentsCubit(
context.read(),
documentId: state.document.id,
),
child: TabBarView(
children: [ children: [
_buildDocumentOverview( _buildDocumentOverview(
state.document, state.document,
@@ -227,7 +167,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
_buildDocumentMetaDataView( _buildDocumentMetaDataView(
state.document, state.document,
), ),
_buildSimilarDocumentsView(),
], ],
),
).paddedSymmetrically(horizontal: 8); ).paddedSymmetrically(horizontal: 8);
}, },
), ),
@@ -237,6 +179,94 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
); );
} }
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState> _buildAppBar() {
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
final _filteredSuggestions =
state.suggestions.documentDifference(state.document);
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
if (!connectivityState.isConnected) {
return Container();
}
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: _filteredSuggestions.hasSuggestions,
child: Tooltip(
message: S.of(context).documentDetailsPageEditTooltip,
preferBelow: false,
verticalOffset: 40,
child: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
),
),
badgeContent: Text(
'${_filteredSuggestions.suggestionsCount}',
style: const TextStyle(
color: Colors.white,
),
),
badgeColor: Colors.red,
);
},
);
},
);
}
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState> _buildBottomAppBar() {
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
return BottomAppBar(
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
final isConnected = connectivityState.isConnected;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
tooltip: S.of(context).documentDetailsPageDeleteTooltip,
icon: const Icon(Icons.delete),
onPressed: widget.allowEdit && isConnected
? () => _onDelete(state.document)
: null,
).paddedSymmetrically(horizontal: 4),
Tooltip(
message: S.of(context).documentDetailsPageDownloadTooltip,
child: DocumentDownloadButton(
document: state.document,
enabled: isConnected,
),
),
IconButton(
tooltip: S.of(context).documentDetailsPagePreviewTooltip,
icon: const Icon(Icons.visibility),
onPressed:
isConnected ? () => _onOpen(state.document) : null,
).paddedOnly(right: 4.0),
IconButton(
tooltip: S
.of(context)
.documentDetailsPageOpenInSystemViewerTooltip,
icon: const Icon(Icons.open_in_new),
onPressed: isConnected ? _onOpenFileInSystemViewer : null,
).paddedOnly(right: 4.0),
IconButton(
tooltip: S.of(context).documentDetailsPageShareTooltip,
icon: const Icon(Icons.share),
onPressed:
isConnected ? () => _onShare(state.document) : null,
),
],
);
},
),
);
},
);
}
Future<void> _onEdit(DocumentModel document) async { Future<void> _onEdit(DocumentModel document) async {
{ {
final cubit = context.read<DocumentDetailsCubit>(); final cubit = context.read<DocumentDetailsCubit>();
@@ -306,7 +336,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
); );
} }
return FutureBuilder<DocumentMetaData>( return FutureBuilder<DocumentMetaData>(
future: context.read<PaperlessDocumentsApi>().getMetaData(document), future: _metaData,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -465,34 +495,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
child: TagsWidget( child: TagsWidget(
isClickable: widget.isLabelClickable, isClickable: widget.isLabelClickable,
tagIds: document.tags, tagIds: document.tags,
onTagSelected: (int tagId) {},
), ),
), ),
).paddedSymmetrically(vertical: 16), ).paddedSymmetrically(vertical: 16),
), ),
// _separator(),
// FutureBuilder<List<SimilarDocumentModel>>(
// future: getIt<DocumentRepository>().findSimilar(document.id),
// builder: (context, snapshot) {
// if (!snapshot.hasData) {
// return CircularProgressIndicator();
// }
// return ExpansionTile(
// tilePadding: const EdgeInsets.symmetric(horizontal: 8.0),
// title: Text(
// S.of(context).documentDetailsPageSimilarDocumentsLabel,
// style:
// Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
// ),
// children: snapshot.data!
// .map((e) => DocumentListItem(
// document: e,
// onTap: (doc) {},
// isSelected: false,
// isAtLeastOneSelected: false))
// .toList(),
// );
// }),
], ],
); );
} }
@@ -558,6 +564,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
' ' + ' ' +
suffixes[i]; suffixes[i];
} }
Widget _buildSimilarDocumentsView() {
return const SimilarDocumentsView();
}
} }
class _DetailsItem extends StatelessWidget { class _DetailsItem extends StatelessWidget {

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart';
import 'package:paperless_mobile/util.dart';
class SimilarDocumentsView extends StatefulWidget {
const SimilarDocumentsView({super.key});
@override
State<SimilarDocumentsView> createState() => _SimilarDocumentsViewState();
}
class _SimilarDocumentsViewState extends State<SimilarDocumentsView> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_listenForLoadNewData);
try {
context.read<SimilarDocumentsCubit>().initialize();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
@override
void dispose() {
_scrollController.removeListener(_listenForLoadNewData);
super.dispose();
}
void _listenForLoadNewData() async {
final currState = context.read<SimilarDocumentsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
try {
await context.read<SimilarDocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
@override
Widget build(BuildContext context) {
const earlyPreviewHintCard = HintCard(
hintIcon: Icons.construction,
hintText: "This view is still work in progress.",
);
return BlocBuilder<SimilarDocumentsCubit, SimilarDocumentsState>(
builder: (context, state) {
if (!state.hasLoaded) {
return const DocumentsListLoadingWidget(
beforeWidgets: [earlyPreviewHintCard],
);
}
if (state.documents.isEmpty) {
return DocumentsEmptyState(
state: state,
onReset: () => context.read<SimilarDocumentsCubit>().updateFilter(
filter: DocumentFilter.initial.copyWith(
moreLike: () =>
context.read<SimilarDocumentsCubit>().documentId,
),
),
);
}
return CustomScrollView(
controller: _scrollController,
slivers: [
const SliverToBoxAdapter(child: earlyPreviewHintCard),
SliverList(
delegate: SliverChildBuilderDelegate(
childCount: state.documents.length,
(context, index) => DocumentListItem(
document: state.documents[index],
enableHeroAnimation: false,
),
),
),
],
);
},
);
}
}

View File

@@ -66,13 +66,17 @@ class _DocumentsPageState extends State<DocumentsPage> {
..addListener(_listenForLoadNewData); ..addListener(_listenForLoadNewData);
} }
void _listenForLoadNewData() { void _listenForLoadNewData() async {
final currState = context.read<DocumentsCubit>().state; final currState = context.read<DocumentsCubit>().state;
if (_scrollController.offset >= if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.75 && _scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading && !currState.isLoading &&
!currState.isLastPageLoaded) { !currState.isLastPageLoaded) {
_loadNewPage(); try {
await context.read<DocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
} }
} }
@@ -353,8 +357,6 @@ class _DocumentsPageState extends State<DocumentsPage> {
.equals(previous.documents, current.documents) || .equals(previous.documents, current.documents) ||
previous.selectedIds != current.selectedIds, previous.selectedIds != current.selectedIds,
builder: (context, state) { builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController
if (state.hasLoaded && state.documents.isEmpty) { if (state.hasLoaded && state.documents.isEmpty) {
return DocumentsEmptyState( return DocumentsEmptyState(
state: state, state: state,
@@ -491,14 +493,6 @@ class _DocumentsPageState extends State<DocumentsPage> {
} }
} }
Future<void> _loadNewPage() async {
try {
await context.read<DocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
void _onSelected(DocumentModel model) { void _onSelected(DocumentModel model) {
context.read<DocumentsCubit>().toggleDocumentSelection(model); context.read<DocumentsCubit>().toggleDocumentSelection(model);
} }

View File

@@ -2,12 +2,11 @@ import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart'; import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget { class DocumentsEmptyState extends StatelessWidget {
final DocumentsState state; final DocumentsPagedState state;
final VoidCallback onReset; final VoidCallback onReset;
const DocumentsEmptyState({ const DocumentsEmptyState({
Key? key, Key? key,

View File

@@ -64,13 +64,6 @@ class AdaptiveDocumentsView extends StatelessWidget {
isSelected: state.selectedIds.contains(document.id), isSelected: state.selectedIds.contains(document.id),
onSelected: onSelected, onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty, isAtLeastOneSelected: state.selection.isNotEmpty,
isTagSelectedPredicate: (int tagId) {
return state.filter.tags is IdsTagsQuery
? (state.filter.tags as IdsTagsQuery)
.includedIds
.contains(tagId)
: false;
},
onTagSelected: onTagSelected, onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected, onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected, onDocumentTypeSelected: onDocumentTypeSelected,

View File

@@ -7,31 +7,32 @@ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.d
class DocumentListItem extends StatelessWidget { class DocumentListItem extends StatelessWidget {
static const _a4AspectRatio = 1 / 1.4142; static const _a4AspectRatio = 1 / 1.4142;
final DocumentModel document; final DocumentModel document;
final void Function(DocumentModel) onTap; final void Function(DocumentModel)? onTap;
final void Function(DocumentModel)? onSelected; final void Function(DocumentModel)? onSelected;
final bool isSelected; final bool isSelected;
final bool isAtLeastOneSelected; final bool isAtLeastOneSelected;
final bool isLabelClickable; final bool isLabelClickable;
final bool Function(int tagId) isTagSelectedPredicate;
final void Function(int tagId)? onTagSelected; final void Function(int tagId)? onTagSelected;
final void Function(int? correspondentId)? onCorrespondentSelected; final void Function(int? correspondentId)? onCorrespondentSelected;
final void Function(int? documentTypeId)? onDocumentTypeSelected; final void Function(int? documentTypeId)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected; final void Function(int? id)? onStoragePathSelected;
final bool enableHeroAnimation;
const DocumentListItem({ const DocumentListItem({
Key? key, Key? key,
required this.document, required this.document,
required this.onTap, this.onTap,
this.onSelected, this.onSelected,
required this.isSelected, this.isSelected = false,
required this.isAtLeastOneSelected, this.isAtLeastOneSelected = false,
this.isLabelClickable = true, this.isLabelClickable = true,
required this.isTagSelectedPredicate,
this.onTagSelected, this.onTagSelected,
this.onCorrespondentSelected, this.onCorrespondentSelected,
this.onDocumentTypeSelected, this.onDocumentTypeSelected,
this.onStoragePathSelected, this.onStoragePathSelected,
this.enableHeroAnimation = true,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -85,6 +86,7 @@ class DocumentListItem extends StatelessWidget {
id: document.id, id: document.id,
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
enableHero: enableHeroAnimation,
), ),
), ),
), ),
@@ -96,7 +98,7 @@ class DocumentListItem extends StatelessWidget {
if (isAtLeastOneSelected || isSelected) { if (isAtLeastOneSelected || isSelected) {
onSelected?.call(document); onSelected?.call(document);
} else { } else {
onTap(document); onTap?.call(document);
} }
} }
} }

View File

@@ -33,7 +33,7 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (!state.isLoaded) if (!state.isLoaded)
Expanded(child: const DocumentsListLoadingWidget()) const Expanded(child: DocumentsListLoadingWidget())
else else
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
@@ -59,10 +59,6 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
), ),
); );
}, },
isSelected: false,
isAtLeastOneSelected: false,
isTagSelectedPredicate: (_) => false,
onTagSelected: (int tag) {},
); );
}, },
), ),

View File

@@ -1,6 +1,10 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
///
/// Base state for all blocs/cubits using a paged view of documents.
/// [T] is the return type of the API call.
///
abstract class DocumentsPagedState extends Equatable { abstract class DocumentsPagedState extends Equatable {
final bool hasLoaded; final bool hasLoaded;
final bool isLoading; final bool isLoading;

View File

@@ -0,0 +1,28 @@
import 'package:bloc/bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
part 'similar_documents_state.dart';
class SimilarDocumentsCubit extends Cubit<SimilarDocumentsState>
with DocumentsPagingMixin<SimilarDocumentsState> {
final int documentId;
@override
final PaperlessDocumentsApi api;
SimilarDocumentsCubit(
this.api, {
required this.documentId,
}) : super(const SimilarDocumentsState());
Future<void> initialize() async {
if (!state.hasLoaded) {
await updateFilter(
filter: state.filter.copyWith(moreLike: () => documentId),
);
emit(state.copyWith(hasLoaded: true));
}
}
}

View File

@@ -0,0 +1,47 @@
part of 'similar_documents_cubit.dart';
class SimilarDocumentsState extends DocumentsPagedState {
const SimilarDocumentsState({
super.filter,
super.hasLoaded,
super.isLoading,
super.value,
});
@override
List<Object> get props => [
filter,
hasLoaded,
isLoading,
value,
];
@override
SimilarDocumentsState copyWithPaged({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
}) {
return copyWith(
hasLoaded: hasLoaded,
isLoading: isLoading,
value: value,
filter: filter,
);
}
SimilarDocumentsState copyWith({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
}) {
return SimilarDocumentsState(
hasLoaded: hasLoaded ?? this.hasLoaded,
isLoading: isLoading ?? this.isLoading,
value: value ?? this.value,
filter: filter ?? this.filter,
);
}
}

View File

@@ -90,6 +90,8 @@
"@documentDetailsPageTabMetaDataLabel": {}, "@documentDetailsPageTabMetaDataLabel": {},
"documentDetailsPageTabOverviewLabel": "Přehled", "documentDetailsPageTabOverviewLabel": "Přehled",
"@documentDetailsPageTabOverviewLabel": {}, "@documentDetailsPageTabOverviewLabel": {},
"documentDetailsPageTabSimilarDocumentsLabel": "Similar Documents",
"@documentDetailsPageTabSimilarDocumentsLabel": {},
"documentDocumentTypePropertyLabel": "Typ dokumentu", "documentDocumentTypePropertyLabel": "Typ dokumentu",
"@documentDocumentTypePropertyLabel": {}, "@documentDocumentTypePropertyLabel": {},
"documentDownloadSuccessMessage": "Dokument úspěšně stažen.", "documentDownloadSuccessMessage": "Dokument úspěšně stažen.",

View File

@@ -90,6 +90,8 @@
"@documentDetailsPageTabMetaDataLabel": {}, "@documentDetailsPageTabMetaDataLabel": {},
"documentDetailsPageTabOverviewLabel": "Übersicht", "documentDetailsPageTabOverviewLabel": "Übersicht",
"@documentDetailsPageTabOverviewLabel": {}, "@documentDetailsPageTabOverviewLabel": {},
"documentDetailsPageTabSimilarDocumentsLabel": "Ähnliche Dokumente",
"@documentDetailsPageTabSimilarDocumentsLabel": {},
"documentDocumentTypePropertyLabel": "Dokumenttyp", "documentDocumentTypePropertyLabel": "Dokumenttyp",
"@documentDocumentTypePropertyLabel": {}, "@documentDocumentTypePropertyLabel": {},
"documentDownloadSuccessMessage": "Dokument erfolgreich heruntergeladen.", "documentDownloadSuccessMessage": "Dokument erfolgreich heruntergeladen.",

View File

@@ -90,6 +90,8 @@
"@documentDetailsPageTabMetaDataLabel": {}, "@documentDetailsPageTabMetaDataLabel": {},
"documentDetailsPageTabOverviewLabel": "Overview", "documentDetailsPageTabOverviewLabel": "Overview",
"@documentDetailsPageTabOverviewLabel": {}, "@documentDetailsPageTabOverviewLabel": {},
"documentDetailsPageTabSimilarDocumentsLabel": "Similar Documents",
"@documentDetailsPageTabSimilarDocumentsLabel": {},
"documentDocumentTypePropertyLabel": "Document Type", "documentDocumentTypePropertyLabel": "Document Type",
"@documentDocumentTypePropertyLabel": {}, "@documentDocumentTypePropertyLabel": {},
"documentDownloadSuccessMessage": "Document successfully downloaded.", "documentDownloadSuccessMessage": "Document successfully downloaded.",

View File

@@ -90,6 +90,8 @@
"@documentDetailsPageTabMetaDataLabel": {}, "@documentDetailsPageTabMetaDataLabel": {},
"documentDetailsPageTabOverviewLabel": "Genel bakış", "documentDetailsPageTabOverviewLabel": "Genel bakış",
"@documentDetailsPageTabOverviewLabel": {}, "@documentDetailsPageTabOverviewLabel": {},
"documentDetailsPageTabSimilarDocumentsLabel": "Similar Documents",
"@documentDetailsPageTabSimilarDocumentsLabel": {},
"documentDocumentTypePropertyLabel": "Döküman tipi", "documentDocumentTypePropertyLabel": "Döküman tipi",
"@documentDocumentTypePropertyLabel": {}, "@documentDocumentTypePropertyLabel": {},
"documentDownloadSuccessMessage": "Döküman başarıyla indirildi.", "documentDownloadSuccessMessage": "Döküman başarıyla indirildi.",

View File

@@ -1,3 +1,2 @@
export 'document_model_json_converter.dart'; export 'document_model_json_converter.dart';
export 'similar_document_model_json_converter.dart';
export 'date_range_query_json_converter.dart'; export 'date_range_query_json_converter.dart';

View File

@@ -1,15 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
class SimilarDocumentModelJsonConverter
extends JsonConverter<SimilarDocumentModel, Map<String, dynamic>> {
@override
SimilarDocumentModel fromJson(Map<String, dynamic> json) {
return SimilarDocumentModel.fromJson(json);
}
@override
Map<String, dynamic> toJson(SimilarDocumentModel object) {
return object.toJson();
}
}

View File

@@ -33,6 +33,9 @@ class DocumentFilter extends Equatable {
final DateRangeQuery modified; final DateRangeQuery modified;
final TextQuery query; final TextQuery query;
/// Query documents similar to the document with this id.
final int? moreLike;
const DocumentFilter({ const DocumentFilter({
this.documentType = const IdQueryParameter.unset(), this.documentType = const IdQueryParameter.unset(),
this.correspondent = const IdQueryParameter.unset(), this.correspondent = const IdQueryParameter.unset(),
@@ -47,6 +50,7 @@ class DocumentFilter extends Equatable {
this.added = const UnsetDateRangeQuery(), this.added = const UnsetDateRangeQuery(),
this.created = const UnsetDateRangeQuery(), this.created = const UnsetDateRangeQuery(),
this.modified = const UnsetDateRangeQuery(), this.modified = const UnsetDateRangeQuery(),
this.moreLike,
}); });
bool get forceExtendedQuery { bool get forceExtendedQuery {
@@ -77,6 +81,10 @@ class DocumentFilter extends Equatable {
), ),
); );
} }
if (moreLike != null) {
params.add(MapEntry('more_like_id', moreLike.toString()));
}
// Reverse ordering can also be encoded using &reverse=1 // Reverse ordering can also be encoded using &reverse=1
// Merge query params // Merge query params
final queryParams = groupBy(params, (e) => e.key).map( final queryParams = groupBy(params, (e) => e.key).map(
@@ -107,7 +115,7 @@ class DocumentFilter extends Equatable {
DateRangeQuery? created, DateRangeQuery? created,
DateRangeQuery? modified, DateRangeQuery? modified,
TextQuery? query, TextQuery? query,
int? selectedViewId, int? Function()? moreLike,
}) { }) {
final newFilter = DocumentFilter( final newFilter = DocumentFilter(
pageSize: pageSize ?? this.pageSize, pageSize: pageSize ?? this.pageSize,
@@ -123,6 +131,7 @@ class DocumentFilter extends Equatable {
added: added ?? this.added, added: added ?? this.added,
created: created ?? this.created, created: created ?? this.created,
modified: modified ?? this.modified, modified: modified ?? this.modified,
moreLike: moreLike != null ? moreLike.call() : this.moreLike,
); );
if (query?.queryType != QueryType.extended && if (query?.queryType != QueryType.extended &&
newFilter.forceExtendedQuery) { newFilter.forceExtendedQuery) {

View File

@@ -48,6 +48,7 @@ DocumentFilter _$DocumentFilterFromJson(Map<String, dynamic> json) =>
? const UnsetDateRangeQuery() ? const UnsetDateRangeQuery()
: const DateRangeQueryJsonConverter() : const DateRangeQueryJsonConverter()
.fromJson(json['modified'] as Map<String, dynamic>), .fromJson(json['modified'] as Map<String, dynamic>),
moreLike: json['moreLike'] as int?,
); );
Map<String, dynamic> _$DocumentFilterToJson(DocumentFilter instance) => Map<String, dynamic> _$DocumentFilterToJson(DocumentFilter instance) =>
@@ -65,6 +66,7 @@ Map<String, dynamic> _$DocumentFilterToJson(DocumentFilter instance) =>
'added': const DateRangeQueryJsonConverter().toJson(instance.added), 'added': const DateRangeQueryJsonConverter().toJson(instance.added),
'modified': const DateRangeQueryJsonConverter().toJson(instance.modified), 'modified': const DateRangeQueryJsonConverter().toJson(instance.modified),
'query': instance.query.toJson(), 'query': instance.query.toJson(),
'moreLike': instance.moreLike,
}; };
const _$SortFieldEnumMap = { const _$SortFieldEnumMap = {

View File

@@ -3,6 +3,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/src/converters/local_date_time_json_converter.dart'; import 'package:paperless_api/src/converters/local_date_time_json_converter.dart';
import 'package:paperless_api/src/models/search_hit.dart';
part 'document_model.g.dart'; part 'document_model.g.dart';
@@ -37,6 +38,12 @@ class DocumentModel extends Equatable {
final String originalFileName; final String originalFileName;
final String? archivedFileName; final String? archivedFileName;
@JsonKey(
name: '__search_hit__',
includeIfNull: false,
)
final SearchHit? searchHit;
const DocumentModel({ const DocumentModel({
required this.id, required this.id,
required this.title, required this.title,
@@ -51,6 +58,7 @@ class DocumentModel extends Equatable {
required this.originalFileName, required this.originalFileName,
this.archivedFileName, this.archivedFileName,
this.storagePath, this.storagePath,
this.searchHit,
}); });
factory DocumentModel.fromJson(Map<String, dynamic> json) => factory DocumentModel.fromJson(Map<String, dynamic> json) =>

View File

@@ -25,10 +25,13 @@ DocumentModel _$DocumentModelFromJson(Map<String, dynamic> json) =>
originalFileName: json['original_file_name'] as String, originalFileName: json['original_file_name'] as String,
archivedFileName: json['archived_file_name'] as String?, archivedFileName: json['archived_file_name'] as String?,
storagePath: json['storage_path'] as int?, storagePath: json['storage_path'] as int?,
searchHit: json['__search_hit__'] == null
? null
: SearchHit.fromJson(json['__search_hit__'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$DocumentModelToJson(DocumentModel instance) => Map<String, dynamic> _$DocumentModelToJson(DocumentModel instance) {
<String, dynamic>{ final val = <String, dynamic>{
'id': instance.id, 'id': instance.id,
'title': instance.title, 'title': instance.title,
'content': instance.content, 'content': instance.content,
@@ -43,3 +46,13 @@ Map<String, dynamic> _$DocumentModelToJson(DocumentModel instance) =>
'original_file_name': instance.originalFileName, 'original_file_name': instance.originalFileName,
'archived_file_name': instance.archivedFileName, 'archived_file_name': instance.archivedFileName,
}; };
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('__search_hit__', instance.searchHit);
return val;
}

View File

@@ -21,7 +21,6 @@ export 'paperless_server_exception.dart';
export 'paperless_server_information_model.dart'; export 'paperless_server_information_model.dart';
export 'paperless_server_statistics_model.dart'; export 'paperless_server_statistics_model.dart';
export 'saved_view_model.dart'; export 'saved_view_model.dart';
export 'similar_document_model.dart';
export 'task/task.dart'; export 'task/task.dart';
export 'task/task_status.dart'; export 'task/task_status.dart';
export 'field_suggestions.dart'; export 'field_suggestions.dart';

View File

@@ -1,36 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/src/converters/local_date_time_json_converter.dart';
import 'package:paperless_api/src/models/document_model.dart';
import 'package:paperless_api/src/models/search_hit.dart';
part 'similar_document_model.g.dart';
@LocalDateTimeJsonConverter()
@JsonSerializable()
class SimilarDocumentModel extends DocumentModel {
@JsonKey(name: '__search_hit__')
final SearchHit searchHit;
const SimilarDocumentModel({
required super.id,
required super.title,
required super.documentType,
required super.correspondent,
required super.created,
required super.modified,
required super.added,
required super.originalFileName,
required this.searchHit,
super.archiveSerialNumber,
super.archivedFileName,
super.content,
super.storagePath,
super.tags,
});
factory SimilarDocumentModel.fromJson(Map<String, dynamic> json) =>
_$SimilarDocumentModelFromJson(json);
@override
Map<String, dynamic> toJson() => _$SimilarDocumentModelToJson(this);
}

View File

@@ -1,50 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'similar_document_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimilarDocumentModel _$SimilarDocumentModelFromJson(
Map<String, dynamic> json) =>
SimilarDocumentModel(
id: json['id'] as int,
title: json['title'] as String,
documentType: json['documentType'] as int?,
correspondent: json['correspondent'] as int?,
created: const LocalDateTimeJsonConverter()
.fromJson(json['created'] as String),
modified: const LocalDateTimeJsonConverter()
.fromJson(json['modified'] as String),
added:
const LocalDateTimeJsonConverter().fromJson(json['added'] as String),
originalFileName: json['originalFileName'] as String,
searchHit:
SearchHit.fromJson(json['__search_hit__'] as Map<String, dynamic>),
archiveSerialNumber: json['archiveSerialNumber'] as int?,
archivedFileName: json['archivedFileName'] as String?,
content: json['content'] as String?,
storagePath: json['storagePath'] as int?,
tags: (json['tags'] as List<dynamic>?)?.map((e) => e as int) ??
const <int>[],
);
Map<String, dynamic> _$SimilarDocumentModelToJson(
SimilarDocumentModel instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'content': instance.content,
'tags': instance.tags.toList(),
'documentType': instance.documentType,
'correspondent': instance.correspondent,
'storagePath': instance.storagePath,
'created': const LocalDateTimeJsonConverter().toJson(instance.created),
'modified': const LocalDateTimeJsonConverter().toJson(instance.modified),
'added': const LocalDateTimeJsonConverter().toJson(instance.added),
'archiveSerialNumber': instance.archiveSerialNumber,
'originalFileName': instance.originalFileName,
'archivedFileName': instance.archivedFileName,
'__search_hit__': instance.searchHit,
};

View File

@@ -18,7 +18,6 @@ abstract class PaperlessDocumentsApi {
Future<int> findNextAsn(); Future<int> findNextAsn();
Future<PagedSearchResult<DocumentModel>> findAll(DocumentFilter filter); Future<PagedSearchResult<DocumentModel>> findAll(DocumentFilter filter);
Future<DocumentModel?> find(int id); Future<DocumentModel?> find(int id);
Future<List<SimilarDocumentModel>> findSimilar(int docId);
Future<int> delete(DocumentModel doc); Future<int> delete(DocumentModel doc);
Future<DocumentMetaData> getMetaData(DocumentModel document); Future<DocumentMetaData> getMetaData(DocumentModel document);
Future<Iterable<int>> bulkAction(BulkAction action); Future<Iterable<int>> bulkAction(BulkAction action);

View File

@@ -241,27 +241,6 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
} }
} }
@override
Future<List<SimilarDocumentModel>> findSimilar(int docId) async {
try {
final response =
await client.get("/api/documents/?more_like=$docId&pageSize=10");
if (response.statusCode == 200) {
return (await compute(
PagedSearchResult<SimilarDocumentModel>.fromJsonSingleParam,
PagedSearchResultJsonSerializer(
response.data,
SimilarDocumentModelJsonConverter(),
),
))
.results;
}
throw const PaperlessServerException(ErrorCode.similarQueryError);
} on DioError catch (err) {
throw err.error;
}
}
@override @override
Future<FieldSuggestions> findSuggestions(DocumentModel document) async { Future<FieldSuggestions> findSuggestions(DocumentModel document) async {
try { try {