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

@@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart';
@@ -22,12 +23,15 @@ import 'package:paperless_mobile/features/labels/correspondent/view/widgets/corr
import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
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/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';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:badges/badges.dart' as b; import 'package:badges/badges.dart' as b;
import '../../../../core/repository/state/impl/document_type_repository_state.dart';
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final bool allowEdit; final bool allowEdit;
final bool isLabelClickable; final bool isLabelClickable;
@@ -284,7 +288,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
content: document.archiveSerialNumber != null content: document.archiveSerialNumber != null
? Text(document.archiveSerialNumber.toString()) ? Text(document.archiveSerialNumber.toString())
: TextButton.icon( : TextButton.icon(
icon: const Icon(Icons.archive), icon: const Icon(Icons.archive_outlined),
label: Text(S label: Text(S
.of(context) .of(context)
.documentDetailsPageAssignAsnButtonLabel), .documentDetailsPageAssignAsnButtonLabel),
@@ -369,37 +373,35 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
return ListView( return ListView(
children: [ children: [
_DetailsItem( _DetailsItem(
label: S.of(context).documentTitlePropertyLabel,
content: HighlightedText( content: HighlightedText(
text: document.title, text: document.title,
highlights: widget.titleAndContentQueryString?.split(" ") ?? [], highlights: widget.titleAndContentQueryString?.split(" ") ?? [],
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
label: S.of(context).documentTitlePropertyLabel,
).paddedOnly(bottom: 16), ).paddedOnly(bottom: 16),
_DetailsItem.text( _DetailsItem.text(
DateFormat.yMMMd().format(document.created), DateFormat.yMMMMd().format(document.created),
context: context, context: context,
label: S.of(context).documentCreatedPropertyLabel, label: S.of(context).documentCreatedPropertyLabel,
).paddedSymmetrically(vertical: 16), ).paddedSymmetrically(vertical: 16),
Visibility( Visibility(
visible: document.documentType != null, visible: document.documentType != null,
child: _DetailsItem( child: _DetailsItem(
content: DocumentTypeWidget(
textStyle: Theme.of(context).textTheme.bodyLarge,
isClickable: widget.isLabelClickable,
documentTypeId: document.documentType,
),
label: S.of(context).documentDocumentTypePropertyLabel, label: S.of(context).documentDocumentTypePropertyLabel,
content: LabelText<DocumentType, DocumentTypeRepositoryState>(
style: Theme.of(context).textTheme.bodyLarge,
id: document.documentType,
),
).paddedSymmetrically(vertical: 16), ).paddedSymmetrically(vertical: 16),
), ),
Visibility( Visibility(
visible: document.correspondent != null, visible: document.correspondent != null,
child: _DetailsItem( child: _DetailsItem(
label: S.of(context).documentCorrespondentPropertyLabel, label: S.of(context).documentCorrespondentPropertyLabel,
content: CorrespondentWidget( content: LabelText<Correspondent, CorrespondentRepositoryState>(
textStyle: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
isClickable: widget.isLabelClickable, id: document.correspondent,
correspondentId: document.correspondent,
), ),
).paddedSymmetrically(vertical: 16), ).paddedSymmetrically(vertical: 16),
), ),
@@ -521,8 +523,11 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
class _DetailsItem extends StatelessWidget { class _DetailsItem extends StatelessWidget {
final String label; final String label;
final Widget content; final Widget content;
const _DetailsItem({Key? key, required this.label, required this.content}) const _DetailsItem({
: super(key: key); Key? key,
required this.label,
required this.content,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -545,7 +550,10 @@ class _DetailsItem extends StatelessWidget {
String text, { String text, {
required this.label, required this.label,
required BuildContext context, required BuildContext context,
}) : content = Text(text, style: Theme.of(context).textTheme.bodyLarge); }) : content = Text(
text,
style: Theme.of(context).textTheme.bodyLarge,
);
} }
class ColoredTabBar extends Container implements PreferredSizeWidget { class ColoredTabBar extends Container implements PreferredSizeWidget {

View File

@@ -19,6 +19,7 @@ import 'package:paperless_mobile/features/document_upload/cubit/document_upload_
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart';
import 'package:paperless_mobile/features/home/view/route_description.dart';
import 'package:paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart'; import 'package:paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart';
import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart'; import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart';
@@ -145,6 +146,71 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final destinations = [
RouteDescription(
icon: const Icon(Icons.description_outlined),
selectedIcon: Icon(
Icons.description,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context).bottomNavDocumentsPageLabel,
),
RouteDescription(
icon: const Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(
Icons.document_scanner,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context).bottomNavScannerPageLabel,
),
RouteDescription(
icon: const Icon(Icons.sell_outlined),
selectedIcon: Icon(
Icons.sell,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context).bottomNavLabelsPageLabel,
),
// RouteDescription(
// icon: const Icon(Icons.inbox_outlined),
// selectedIcon: Icon(
// Icons.inbox,
// color: Theme.of(context).colorScheme.primary,
// ),
// label: S.of(context).bottomNavInboxPageLabel,
// ),
// RouteDescription(
// icon: const Icon(Icons.settings_outlined),
// selectedIcon: Icon(
// Icons.settings,
// color: Theme.of(context).colorScheme.primary,
// ),
// label: S.of(context).appDrawerSettingsLabel,
// ),
];
final routes = <Widget>[
MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => DocumentsCubit(
context.read<PaperlessDocumentsApi>(),
context.read<SavedViewRepository>(),
),
),
BlocProvider(
create: (context) => SavedViewCubit(
context.read<SavedViewRepository>(),
),
),
],
child: const DocumentsPage(),
),
BlocProvider.value(
value: _scannerCubit,
child: const ScannerPage(),
),
const LabelsPage(),
];
return MultiBlocListener( return MultiBlocListener(
listeners: [ listeners: [
BlocListener<ConnectivityCubit, ConnectivityState>( BlocListener<ConnectivityCubit, ConnectivityState>(
@@ -172,96 +238,35 @@ class _HomePageState extends State<HomePage> {
return Scaffold( return Scaffold(
key: rootScaffoldKey, key: rootScaffoldKey,
drawer: const InfoDrawer(), drawer: const InfoDrawer(),
body: Row(children: [ body: Row(
children: [
NavigationRail( NavigationRail(
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: [ destinations: destinations
NavigationRailDestination( .map((e) => e.toNavigationRailDestination())
icon: const Icon(Icons.description_outlined), .toList(),
selectedIcon: Icon(
Icons.description,
color: Theme.of(context).colorScheme.primary,
),
label: Text(S.of(context).bottomNavDocumentsPageLabel),
),
NavigationRailDestination(
icon: const Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(
Icons.document_scanner,
color: Theme.of(context).colorScheme.primary,
),
label: Text(S.of(context).bottomNavScannerPageLabel),
),
NavigationRailDestination(
icon: const Icon(Icons.sell_outlined),
selectedIcon: Icon(
Icons.sell,
color: Theme.of(context).colorScheme.primary,
),
label: Text(S.of(context).bottomNavLabelsPageLabel),
),
],
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged, onDestinationSelected: _onNavigationChanged,
), ),
const VerticalDivider(thickness: 1, width: 1), const VerticalDivider(thickness: 1, width: 1),
Expanded( Expanded(
child: [ child: routes[_currentIndex],
MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => DocumentsCubit(
context.read<PaperlessDocumentsApi>(),
context.read<SavedViewRepository>(),
),
),
BlocProvider(
create: (context) => SavedViewCubit(
context.read<SavedViewRepository>(),
),
), ),
], ],
child: const DocumentsPage(),
), ),
BlocProvider.value(
value: _scannerCubit,
child: const ScannerPage(),
),
const LabelsPage(),
][_currentIndex]),
]),
); );
} }
return Scaffold( return Scaffold(
key: rootScaffoldKey, key: rootScaffoldKey,
bottomNavigationBar: BottomNavBar( bottomNavigationBar: NavigationBar(
elevation: 4.0,
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onNavigationChanged: _onNavigationChanged, onDestinationSelected: _onNavigationChanged,
destinations:
destinations.map((e) => e.toNavigationDestination()).toList(),
), ),
drawer: const InfoDrawer(), drawer: const InfoDrawer(),
body: [ body: routes[_currentIndex],
MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => DocumentsCubit(
context.read<PaperlessDocumentsApi>(),
context.read<SavedViewRepository>(),
),
),
BlocProvider(
create: (context) => SavedViewCubit(
context.read<SavedViewRepository>(),
),
),
],
child: const DocumentsPage(),
),
BlocProvider.value(
value: _scannerCubit,
child: const ScannerPage(),
),
const LabelsPage(),
][_currentIndex],
); );
}, },
), ),

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class RouteDescription {
final String label;
final Icon icon;
final Icon selectedIcon;
RouteDescription({
required this.label,
required this.icon,
required this.selectedIcon,
});
NavigationDestination toNavigationDestination() {
return NavigationDestination(
label: label,
icon: icon,
selectedIcon: selectedIcon,
);
}
NavigationRailDestination toNavigationRailDestination() {
return NavigationRailDestination(
label: Text(label),
icon: icon,
selectedIcon: selectedIcon,
);
}
BottomNavigationBarItem toBottomNavigationBarItem() {
return BottomNavigationBarItem(
label: label,
icon: icon,
activeIcon: selectedIcon,
);
}
}

View File

@@ -42,6 +42,22 @@ class BottomNavBar extends StatelessWidget {
), ),
label: S.of(context).bottomNavLabelsPageLabel, label: S.of(context).bottomNavLabelsPageLabel,
), ),
NavigationDestination(
icon: const Icon(Icons.inbox_outlined),
selectedIcon: Icon(
Icons.inbox,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context).bottomNavInboxPageLabel,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: Icon(
Icons.settings,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context).appDrawerSettingsLabel,
),
], ],
); );
} }

View File

@@ -307,8 +307,10 @@ class _InfoDrawerState extends State<InfoDrawer> {
builder: (_) => LabelRepositoriesProvider( builder: (_) => LabelRepositoriesProvider(
child: BlocProvider( child: BlocProvider(
create: (context) => InboxCubit( create: (context) => InboxCubit(
context.read<LabelRepository<Tag, TagRepositoryState>>(), context.read(),
context.read<PaperlessDocumentsApi>(), context.read(),
context.read(),
context.read(),
)..initializeInbox(), )..initializeInbox(),
child: const InboxPage(), child: const InboxPage(),
), ),

View File

@@ -1,16 +1,64 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.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/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart';
class InboxCubit extends HydratedCubit<InboxState> { class InboxCubit extends HydratedCubit<InboxState> {
final LabelRepository<Tag, TagRepositoryState> _tagsRepository; final LabelRepository<Tag, TagRepositoryState> _tagsRepository;
final LabelRepository<Correspondent, CorrespondentRepositoryState>
_correspondentRepository;
final LabelRepository<DocumentType, DocumentTypeRepositoryState>
_documentTypeRepository;
final PaperlessDocumentsApi _documentsApi; final PaperlessDocumentsApi _documentsApi;
InboxCubit(this._tagsRepository, this._documentsApi) final List<StreamSubscription> _subscriptions = [];
: super(const InboxState());
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). /// 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() { void acknowledgeHint() {
emit(state.copyWith(isHintAcknowledged: true)); emit(state.copyWith(isHintAcknowledged: true));
} }
@@ -148,4 +229,12 @@ class InboxCubit extends HydratedCubit<InboxState> {
Map<String, dynamic> toJson(InboxState state) { Map<String, dynamic> toJson(InboxState state) {
return state.toJson(); 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'; part 'inbox_state.g.dart';
@JsonSerializable() @JsonSerializable(
ignoreUnannotated: true,
)
class InboxState with EquatableMixin { class InboxState with EquatableMixin {
@JsonKey(ignore: true)
final bool isLoaded; final bool isLoaded;
@JsonKey(ignore: true)
final Iterable<int> inboxTags; final Iterable<int> inboxTags;
@JsonKey(ignore: true)
final Iterable<DocumentModel> inboxItems; 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; final bool isHintAcknowledged;
const InboxState({ const InboxState({
@@ -23,6 +30,10 @@ class InboxState with EquatableMixin {
this.inboxTags = const [], this.inboxTags = const [],
this.inboxItems = const [], this.inboxItems = const [],
this.isHintAcknowledged = false, this.isHintAcknowledged = false,
this.availableTags = const {},
this.availableDocumentTypes = const {},
this.availableCorrespondents = const {},
this.suggestions = const {},
}); });
@override @override
@@ -31,6 +42,10 @@ class InboxState with EquatableMixin {
inboxTags, inboxTags,
inboxItems, inboxItems,
isHintAcknowledged, isHintAcknowledged,
availableTags,
availableDocumentTypes,
availableCorrespondents,
suggestions,
]; ];
InboxState copyWith({ InboxState copyWith({
@@ -38,12 +53,22 @@ class InboxState with EquatableMixin {
Iterable<int>? inboxTags, Iterable<int>? inboxTags,
Iterable<DocumentModel>? inboxItems, Iterable<DocumentModel>? inboxItems,
bool? isHintAcknowledged, bool? isHintAcknowledged,
Map<int, Tag>? availableTags,
Map<int, Correspondent>? availableCorrespondents,
Map<int, DocumentType>? availableDocumentTypes,
Map<int, FieldSuggestions>? suggestions,
}) { }) {
return InboxState( return InboxState(
isLoaded: isLoaded ?? this.isLoaded, isLoaded: isLoaded ?? this.isLoaded,
inboxItems: inboxItems ?? this.inboxItems, inboxItems: inboxItems ?? this.inboxItems,
inboxTags: inboxTags ?? this.inboxTags, inboxTags: inboxTags ?? this.inboxTags,
isHintAcknowledged: isHintAcknowledged ?? this.isHintAcknowledged, 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> { class _InboxPageState extends State<InboxPage> {
final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>(); final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -40,9 +39,8 @@ class _InboxPageState extends State<InboxPage> {
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
bottom: PreferredSize( actions: [
preferredSize: const Size.fromHeight(14), BlocBuilder<InboxCubit, InboxState>(
child: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) { builder: (context, state) {
return Align( return Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@@ -59,8 +57,8 @@ class _InboxPageState extends State<InboxPage> {
), ),
); );
}, },
).paddedSymmetrically(horizontal: 8.0), ).paddedSymmetrically(horizontal: 8)
), ],
), ),
floatingActionButton: BlocBuilder<InboxCubit, InboxState>( floatingActionButton: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) { 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) { builder: (context, state) {
if (!state.isLoaded) { if (!state.isLoaded) {
return const DocumentsListLoadingWidget(); return const DocumentsListLoadingWidget();
@@ -121,7 +123,8 @@ class _InboxPageState extends State<InboxPage> {
], ],
) )
.flattened .flattened
.toList(); .toList()
..add(const SliverToBoxAdapter(child: SizedBox(height: 78)));
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => context.read<InboxCubit>().initializeInbox(), onRefresh: () => context.read<InboxCubit>().initializeInbox(),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.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/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.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/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/document_details_page.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/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.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/inbox/bloc/state/inbox_state.dart';
import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_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/generated/l10n.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; static const _a4AspectRatio = 1 / 1.4142;
final void Function(DocumentModel model) onDocumentUpdated; final void Function(DocumentModel model) onDocumentUpdated;
final DocumentModel document; final DocumentModel document;
const InboxItem({ const InboxItem({
super.key, super.key,
required this.document, required this.document,
required this.onDocumentUpdated, required this.onDocumentUpdated,
}); });
@override
State<InboxItem> createState() => _InboxItemState();
}
class _InboxItemState extends State<InboxItem> {
bool _isAsnAssignLoading = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return GestureDetector(
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,
onTap: () async { onTap: () async {
final returnedDocument = await Navigator.push<DocumentModel?>( final returnedDocument = await Navigator.push<DocumentModel?>(
context, context,
@@ -125,7 +43,7 @@ class InboxItem extends StatelessWidget {
builder: (context) => BlocProvider( builder: (context) => BlocProvider(
create: (context) => DocumentDetailsCubit( create: (context) => DocumentDetailsCubit(
context.read<PaperlessDocumentsApi>(), context.read<PaperlessDocumentsApi>(),
document, widget.document,
), ),
child: const LabelRepositoriesProvider( child: const LabelRepositoriesProvider(
child: DocumentDetailsPage( child: DocumentDetailsPage(
@@ -136,9 +54,232 @@ class InboxItem extends StatelessWidget {
), ),
); );
if (returnedDocument != null) { 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(),
];
}
} }

View File

@@ -194,7 +194,8 @@ class _TagFormFieldState extends State<TagFormField> {
Wrap( Wrap(
alignment: WrapAlignment.start, alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start, runAlignment: WrapAlignment.start,
spacing: 8.0, spacing: 4.0,
runSpacing: 4.0,
children: ((field.value as IdsTagsQuery).queries) children: ((field.value as IdsTagsQuery).queries)
.map( .map(
(query) => _buildTag( (query) => _buildTag(

View File

@@ -4,6 +4,7 @@ part 'field_suggestions.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake) @JsonSerializable(fieldRename: FieldRename.snake)
class FieldSuggestions { class FieldSuggestions {
final int? documentId;
final Iterable<int> correspondents; final Iterable<int> correspondents;
final Iterable<int> tags; final Iterable<int> tags;
final Iterable<int> documentTypes; final Iterable<int> documentTypes;
@@ -11,6 +12,7 @@ class FieldSuggestions {
final Iterable<DateTime> dates; final Iterable<DateTime> dates;
const FieldSuggestions({ const FieldSuggestions({
this.documentId,
this.correspondents = const [], this.correspondents = const [],
this.tags = const [], this.tags = const [],
this.documentTypes = const [], this.documentTypes = const [],
@@ -38,6 +40,15 @@ class FieldSuggestions {
(storagePaths.isNotEmpty ? 1 : 0) + (storagePaths.isNotEmpty ? 1 : 0) +
(dates.isNotEmpty ? 1 : 0); (dates.isNotEmpty ? 1 : 0);
FieldSuggestions forDocumentId(int id) => FieldSuggestions(
documentId: id,
correspondents: correspondents,
dates: dates,
documentTypes: documentTypes,
tags: tags,
storagePaths: storagePaths,
);
factory FieldSuggestions.fromJson(Map<String, dynamic> json) => factory FieldSuggestions.fromJson(Map<String, dynamic> json) =>
_$FieldSuggestionsFromJson(json); _$FieldSuggestionsFromJson(json);

View File

@@ -8,6 +8,7 @@ part of 'field_suggestions.dart';
FieldSuggestions _$FieldSuggestionsFromJson(Map<String, dynamic> json) => FieldSuggestions _$FieldSuggestionsFromJson(Map<String, dynamic> json) =>
FieldSuggestions( FieldSuggestions(
documentId: json['document_id'] as int?,
correspondents: correspondents:
(json['correspondents'] as List<dynamic>?)?.map((e) => e as int) ?? (json['correspondents'] as List<dynamic>?)?.map((e) => e as int) ??
const [], const [],
@@ -25,6 +26,7 @@ FieldSuggestions _$FieldSuggestionsFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$FieldSuggestionsToJson(FieldSuggestions instance) => Map<String, dynamic> _$FieldSuggestionsToJson(FieldSuggestions instance) =>
<String, dynamic>{ <String, dynamic>{
'document_id': instance.documentId,
'correspondents': instance.correspondents.toList(), 'correspondents': instance.correspondents.toList(),
'tags': instance.tags.toList(), 'tags': instance.tags.toList(),
'document_types': instance.documentTypes.toList(), 'document_types': instance.documentTypes.toList(),

View File

@@ -259,7 +259,8 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
final response = final response =
await client.get("/api/documents/${document.id}/suggestions/"); await client.get("/api/documents/${document.id}/suggestions/");
if (response.statusCode == 200) { if (response.statusCode == 200) {
return FieldSuggestions.fromJson(response.data); return FieldSuggestions.fromJson(response.data)
.forDocumentId(document.id);
} }
throw const PaperlessServerException(ErrorCode.suggestionsQueryError); throw const PaperlessServerException(ErrorCode.suggestionsQueryError);
} on DioError catch (err) { } on DioError catch (err) {