Reworked inbox, added more information and allow suggestions to be directly clicked

This commit is contained in:
Anton Stubenbord
2023-01-15 01:05:57 +01:00
parent 21462c0463
commit 31b6335f95
13 changed files with 551 additions and 210 deletions

View File

@@ -1,16 +1,64 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.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/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart';
class InboxCubit extends HydratedCubit<InboxState> {
final LabelRepository<Tag, TagRepositoryState> _tagsRepository;
final LabelRepository<Correspondent, CorrespondentRepositoryState>
_correspondentRepository;
final LabelRepository<DocumentType, DocumentTypeRepositoryState>
_documentTypeRepository;
final PaperlessDocumentsApi _documentsApi;
InboxCubit(this._tagsRepository, this._documentsApi)
: super(const InboxState());
final List<StreamSubscription> _subscriptions = [];
InboxCubit(
this._tagsRepository,
this._documentsApi,
this._correspondentRepository,
this._documentTypeRepository,
) : super(
InboxState(
availableCorrespondents:
_correspondentRepository.current?.values ?? {},
availableDocumentTypes:
_documentTypeRepository.current?.values ?? {},
availableTags: _tagsRepository.current?.values ?? {},
),
) {
_subscriptions.add(
_tagsRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(availableTags: event!.values));
}
}),
);
_subscriptions.add(
_correspondentRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(
availableCorrespondents: event!.values,
));
}
}),
);
_subscriptions.add(
_documentTypeRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(availableDocumentTypes: event!.values));
}
}),
);
}
///
/// Fetches inbox tag ids and loads the inbox items (documents).
@@ -135,6 +183,39 @@ class InboxCubit extends HydratedCubit<InboxState> {
}
}
Future<void> updateDocument(DocumentModel document) async {
final updatedDocument = await _documentsApi.update(document);
emit(
state.copyWith(
inboxItems: state.inboxItems.map(
(e) => e.id == document.id ? updatedDocument : e,
),
),
);
}
Future<void> deleteDocument(DocumentModel document) async {
int deletedId = await _documentsApi.delete(document);
emit(
state.copyWith(
inboxItems: state.inboxItems.where(
(element) => element.id != deletedId,
),
),
);
}
void loadSuggestions() {
Future.wait(state.inboxItems
.whereNot((doc) => state.suggestions.containsKey(doc.id))
.map((e) => _documentsApi.findSuggestions(e))).then((results) {
emit(state.copyWith(suggestions: {
...state.suggestions,
for (var r in results) r.documentId!: r
}));
});
}
void acknowledgeHint() {
emit(state.copyWith(isHintAcknowledged: true));
}
@@ -148,4 +229,12 @@ class InboxCubit extends HydratedCubit<InboxState> {
Map<String, dynamic> toJson(InboxState state) {
return state.toJson();
}
@override
Future<void> close() {
_subscriptions.forEach((element) {
element.cancel();
});
return super.close();
}
}

View File

@@ -5,17 +5,24 @@ import 'package:json_annotation/json_annotation.dart';
part 'inbox_state.g.dart';
@JsonSerializable()
@JsonSerializable(
ignoreUnannotated: true,
)
class InboxState with EquatableMixin {
@JsonKey(ignore: true)
final bool isLoaded;
@JsonKey(ignore: true)
final Iterable<int> inboxTags;
@JsonKey(ignore: true)
final Iterable<DocumentModel> inboxItems;
final Map<int, Tag> availableTags;
final Map<int, DocumentType> availableDocumentTypes;
final Map<int, Correspondent> availableCorrespondents;
final Map<int, FieldSuggestions> suggestions;
@JsonKey()
final bool isHintAcknowledged;
const InboxState({
@@ -23,6 +30,10 @@ class InboxState with EquatableMixin {
this.inboxTags = const [],
this.inboxItems = const [],
this.isHintAcknowledged = false,
this.availableTags = const {},
this.availableDocumentTypes = const {},
this.availableCorrespondents = const {},
this.suggestions = const {},
});
@override
@@ -31,6 +42,10 @@ class InboxState with EquatableMixin {
inboxTags,
inboxItems,
isHintAcknowledged,
availableTags,
availableDocumentTypes,
availableCorrespondents,
suggestions,
];
InboxState copyWith({
@@ -38,12 +53,22 @@ class InboxState with EquatableMixin {
Iterable<int>? inboxTags,
Iterable<DocumentModel>? inboxItems,
bool? isHintAcknowledged,
Map<int, Tag>? availableTags,
Map<int, Correspondent>? availableCorrespondents,
Map<int, DocumentType>? availableDocumentTypes,
Map<int, FieldSuggestions>? suggestions,
}) {
return InboxState(
isLoaded: isLoaded ?? this.isLoaded,
inboxItems: inboxItems ?? this.inboxItems,
inboxTags: inboxTags ?? this.inboxTags,
isHintAcknowledged: isHintAcknowledged ?? this.isHintAcknowledged,
availableCorrespondents:
availableCorrespondents ?? this.availableCorrespondents,
availableDocumentTypes:
availableDocumentTypes ?? this.availableDocumentTypes,
availableTags: availableTags ?? this.availableTags,
suggestions: suggestions ?? this.suggestions,
);
}

View File

@@ -24,7 +24,6 @@ class InboxPage extends StatefulWidget {
class _InboxPageState extends State<InboxPage> {
final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
@override
void initState() {
super.initState();
@@ -40,9 +39,8 @@ class _InboxPageState extends State<InboxPage> {
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(14),
child: BlocBuilder<InboxCubit, InboxState>(
actions: [
BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Align(
alignment: Alignment.centerRight,
@@ -59,8 +57,8 @@ class _InboxPageState extends State<InboxPage> {
),
);
},
).paddedSymmetrically(horizontal: 8.0),
),
).paddedSymmetrically(horizontal: 8)
],
),
floatingActionButton: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
@@ -79,7 +77,11 @@ class _InboxPageState extends State<InboxPage> {
);
},
),
body: BlocBuilder<InboxCubit, InboxState>(
body: BlocConsumer<InboxCubit, InboxState>(
listenWhen: (previous, current) =>
!previous.isLoaded && current.isLoaded,
listener: (context, state) =>
context.read<InboxCubit>().loadSuggestions(),
builder: (context, state) {
if (!state.isLoaded) {
return const DocumentsListLoadingWidget();
@@ -121,7 +123,8 @@ class _InboxPageState extends State<InboxPage> {
],
)
.flattened
.toList();
.toList()
..add(const SliverToBoxAdapter(child: SizedBox(height: 78)));
return RefreshIndicator(
onRefresh: () => context.read<InboxCubit>().initializeInbox(),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
@@ -8,116 +7,35 @@ import 'package:paperless_mobile/core/repository/state/impl/document_type_reposi
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/document_details_page.dart';
import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart';
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.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/generated/l10n.dart';
import 'package:badges/badges.dart' as b;
import 'package:paperless_mobile/extensions/string_extensions.dart';
class InboxItem extends StatelessWidget {
class InboxItem extends StatefulWidget {
static const _a4AspectRatio = 1 / 1.4142;
final void Function(DocumentModel model) onDocumentUpdated;
final DocumentModel document;
const InboxItem({
super.key,
required this.document,
required this.onDocumentUpdated,
});
@override
State<InboxItem> createState() => _InboxItemState();
}
class _InboxItemState extends State<InboxItem> {
bool _isAsnAssignLoading = false;
@override
Widget build(BuildContext context) {
return ListTile(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntrinsicHeight(
child: Wrap(
direction: Axis.horizontal,
children: [
Row(
children: [
Text(
document.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge,
)
],
),
Row(
children: [],
),
],
),
),
],
),
isThreeLine: true,
leading: AspectRatio(
aspectRatio: _a4AspectRatio,
child: DocumentPreview(
id: document.id,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: false,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_outline,
size: Theme.of(context).textTheme.bodySmall?.fontSize,
),
Flexible(
child: LabelText<Correspondent, CorrespondentRepositoryState>(
id: document.correspondent,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.description_outlined,
size: Theme.of(context).textTheme.bodySmall?.fontSize,
),
Flexible(
child: LabelText<DocumentType, DocumentTypeRepositoryState>(
id: document.documentType,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
TagsWidget(
tagIds: document.tags,
isMultiLine: false,
isClickable: false,
isSelectedPredicate: (_) => false,
showShortNames: true,
dense: true,
),
],
),
trailing: document.archiveSerialNumber != null
? Text(
document.archiveSerialNumber!.toString(),
style: Theme.of(context).textTheme.bodySmall,
)
: null,
return GestureDetector(
onTap: () async {
final returnedDocument = await Navigator.push<DocumentModel?>(
context,
@@ -125,7 +43,7 @@ class InboxItem extends StatelessWidget {
builder: (context) => BlocProvider(
create: (context) => DocumentDetailsCubit(
context.read<PaperlessDocumentsApi>(),
document,
widget.document,
),
child: const LabelRepositoriesProvider(
child: DocumentDetailsPage(
@@ -136,9 +54,232 @@ class InboxItem extends StatelessWidget {
),
);
if (returnedDocument != null) {
onDocumentUpdated(returnedDocument);
widget.onDocumentUpdated(returnedDocument);
}
},
child: Container(
padding: const EdgeInsets.all(4),
height: 180,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Row(
children: [
AspectRatio(
aspectRatio: InboxItem._a4AspectRatio,
child: DocumentPreview(
id: widget.document.id,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: false,
),
),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
const Spacer(),
_buildCorrespondent(context),
_buildDocumentType(context),
const Spacer(),
_buildTags(),
],
).padded(),
),
],
),
),
SizedBox(
height: 48,
child: _buildActions(context),
),
],
).padded(),
),
);
}
Widget _buildActions(BuildContext context) {
final chipShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
);
final actions = [
_buildAssignAsnAction(chipShape, context),
const SizedBox(width: 4.0),
ActionChip(
avatar: const Icon(Icons.delete_outline),
shape: chipShape,
label: const Text("Delete document"),
onPressed: () async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) =>
DeleteDocumentConfirmationDialog(document: widget.document),
) ??
false;
if (shouldDelete) {
context.read<InboxCubit>().deleteDocument(widget.document);
}
},
),
];
return BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return ListView(
scrollDirection: Axis.horizontal,
children: [
...actions,
if (state.suggestions[widget.document.id] != null) ...[
SizedBox(width: 4),
..._buildSuggestionChips(
chipShape,
state.suggestions[widget.document.id]!,
state,
)
]
],
);
},
);
}
ActionChip _buildAssignAsnAction(
RoundedRectangleBorder chipShape,
BuildContext context,
) {
final hasAsn = widget.document.archiveSerialNumber != null;
return ActionChip(
avatar: _isAsnAssignLoading
? const CircularProgressIndicator()
: hasAsn
? null
: const Icon(Icons.archive_outlined),
shape: chipShape,
label: hasAsn
? Text(
'${S.of(context).documentArchiveSerialNumberPropertyShortLabel} #${widget.document.archiveSerialNumber}',
)
: const Text("Assign ASN"),
onPressed: !hasAsn
? () {
setState(() {
_isAsnAssignLoading = true;
});
context
.read<InboxCubit>()
.assignAsn(widget.document)
.whenComplete(
() => setState(() => _isAsnAssignLoading = false),
);
}
: null,
);
}
TagsWidget _buildTags() {
return TagsWidget(
tagIds: widget.document.tags,
isMultiLine: false,
isClickable: false,
isSelectedPredicate: (_) => false,
showShortNames: true,
dense: true,
);
}
Row _buildDocumentType(BuildContext context) {
return _buildTextWithLeadingIcon(
Icon(
Icons.description_outlined,
size: Theme.of(context).textTheme.bodyMedium?.fontSize,
),
LabelText<DocumentType, DocumentTypeRepositoryState>(
id: widget.document.documentType,
style: Theme.of(context).textTheme.bodyMedium,
placeholder: "-",
),
);
}
Row _buildCorrespondent(BuildContext context) {
return _buildTextWithLeadingIcon(
Icon(
Icons.person_outline,
size: Theme.of(context).textTheme.bodyMedium?.fontSize,
),
LabelText<Correspondent, CorrespondentRepositoryState>(
id: widget.document.correspondent,
style: Theme.of(context).textTheme.bodyMedium,
placeholder: "-",
),
);
}
Text _buildTitle() {
return Text(
widget.document.title,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: Theme.of(context).textTheme.titleSmall,
);
}
Row _buildTextWithLeadingIcon(Icon icon, Widget child) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
icon,
const SizedBox(width: 2),
Flexible(
child: child,
),
],
);
}
List<Widget> _buildSuggestionChips(
OutlinedBorder chipShape,
FieldSuggestions suggestions,
InboxState state,
) {
return [
...suggestions.correspondents
.map(
(e) => ActionChip(
avatar: const Icon(Icons.person_outline),
shape: chipShape,
label: Text(state.availableCorrespondents[e]?.name ?? ''),
onPressed: () {
context
.read<InboxCubit>()
.updateDocument(widget.document.copyWith(
correspondent: e,
overwriteCorrespondent: true,
));
},
),
)
.toList(),
...suggestions.documentTypes
.map(
(e) => ActionChip(
avatar: const Icon(Icons.description_outlined),
shape: chipShape,
label: Text(state.availableDocumentTypes[e]?.name ?? ''),
onPressed: () {
context
.read<InboxCubit>()
.updateDocument(widget.document.copyWith(
documentType: e,
overwriteDocumentType: true,
));
},
),
)
.toList(),
];
}
}