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
@@ -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/extensions/flutter_extensions.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/documents/view/pages/document_edit_page.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/tags/view/widgets/tags_widget.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/util.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';
//TODO: Refactor this into several widgets
class DocumentDetailsPage extends StatefulWidget {
final bool allowEdit;
final bool isLabelClickable;
@@ -48,6 +51,16 @@ class DocumentDetailsPage extends StatefulWidget {
}
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
Widget build(BuildContext context) {
return WillPopScope(
@@ -57,102 +70,11 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
return false;
},
child: DefaultTabController(
length: 3,
length: 4,
child: Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
floatingActionButton: widget.allowEdit
? 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,
);
},
);
},
)
: 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,
),
],
);
},
),
);
},
),
floatingActionButton: widget.allowEdit ? _buildAppBar() : null,
bottomNavigationBar: _buildBottomAppBar(),
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
@@ -180,6 +102,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
tabBar: TabBar(
isScrollable: true,
tabs: [
Tab(
child: Text(
@@ -208,6 +131,18 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
.onPrimaryContainer),
),
),
Tab(
child: Text(
S
.of(context)
.documentDetailsPageTabSimilarDocumentsLabel,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
],
),
),
@@ -215,19 +150,26 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
],
body: BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
return TabBarView(
children: [
_buildDocumentOverview(
state.document,
),
_buildDocumentContentView(
state.document,
state,
),
_buildDocumentMetaDataView(
state.document,
),
],
return BlocProvider(
create: (context) => SimilarDocumentsCubit(
context.read(),
documentId: state.document.id,
),
child: TabBarView(
children: [
_buildDocumentOverview(
state.document,
),
_buildDocumentContentView(
state.document,
state,
),
_buildDocumentMetaDataView(
state.document,
),
_buildSimilarDocumentsView(),
],
),
).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 {
{
final cubit = context.read<DocumentDetailsCubit>();
@@ -306,7 +336,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
);
}
return FutureBuilder<DocumentMetaData>(
future: context.read<PaperlessDocumentsApi>().getMetaData(document),
future: _metaData,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
@@ -465,34 +495,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
child: TagsWidget(
isClickable: widget.isLabelClickable,
tagIds: document.tags,
onTagSelected: (int tagId) {},
),
),
).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];
}
Widget _buildSimilarDocumentsView() {
return const SimilarDocumentsView();
}
}
class _DetailsItem extends StatelessWidget {
@@ -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,
),
),
),
],
);
},
);
}
}
@@ -66,13 +66,17 @@ class _DocumentsPageState extends State<DocumentsPage> {
..addListener(_listenForLoadNewData);
}
void _listenForLoadNewData() {
void _listenForLoadNewData() async {
final currState = context.read<DocumentsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading &&
!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) ||
previous.selectedIds != current.selectedIds,
builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController
if (state.hasLoaded && state.documents.isEmpty) {
return DocumentsEmptyState(
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) {
context.read<DocumentsCubit>().toggleDocumentSelection(model);
}
@@ -2,12 +2,11 @@ import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget {
final DocumentsState state;
final DocumentsPagedState state;
final VoidCallback onReset;
const DocumentsEmptyState({
Key? key,
@@ -64,13 +64,6 @@ class AdaptiveDocumentsView extends StatelessWidget {
isSelected: state.selectedIds.contains(document.id),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
isTagSelectedPredicate: (int tagId) {
return state.filter.tags is IdsTagsQuery
? (state.filter.tags as IdsTagsQuery)
.includedIds
.contains(tagId)
: false;
},
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
@@ -7,31 +7,32 @@ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.d
class DocumentListItem extends StatelessWidget {
static const _a4AspectRatio = 1 / 1.4142;
final DocumentModel document;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel)? onTap;
final void Function(DocumentModel)? onSelected;
final bool isSelected;
final bool isAtLeastOneSelected;
final bool isLabelClickable;
final bool Function(int tagId) isTagSelectedPredicate;
final void Function(int tagId)? onTagSelected;
final void Function(int? correspondentId)? onCorrespondentSelected;
final void Function(int? documentTypeId)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
final bool enableHeroAnimation;
const DocumentListItem({
Key? key,
required this.document,
required this.onTap,
this.onTap,
this.onSelected,
required this.isSelected,
required this.isAtLeastOneSelected,
this.isSelected = false,
this.isAtLeastOneSelected = false,
this.isLabelClickable = true,
required this.isTagSelectedPredicate,
this.onTagSelected,
this.onCorrespondentSelected,
this.onDocumentTypeSelected,
this.onStoragePathSelected,
this.enableHeroAnimation = true,
}) : super(key: key);
@override
@@ -85,6 +86,7 @@ class DocumentListItem extends StatelessWidget {
id: document.id,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: enableHeroAnimation,
),
),
),
@@ -96,7 +98,7 @@ class DocumentListItem extends StatelessWidget {
if (isAtLeastOneSelected || isSelected) {
onSelected?.call(document);
} else {
onTap(document);
onTap?.call(document);
}
}
}
@@ -33,7 +33,7 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
style: Theme.of(context).textTheme.bodySmall,
),
if (!state.isLoaded)
Expanded(child: const DocumentsListLoadingWidget())
const Expanded(child: DocumentsListLoadingWidget())
else
Expanded(
child: ListView.builder(
@@ -59,10 +59,6 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
),
);
},
isSelected: false,
isAtLeastOneSelected: false,
isTagSelectedPredicate: (_) => false,
onTagSelected: (int tag) {},
);
},
),
@@ -1,6 +1,10 @@
import 'package:equatable/equatable.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 {
final bool hasLoaded;
final bool isLoading;
@@ -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));
}
}
}
@@ -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,
);
}
}