mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-09 10:08:00 -06:00
Hooked notifications to status changes on document upload - some refactorings
This commit is contained in:
@@ -52,7 +52,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
log("[DocumentsCubit] load");
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final result = await _api.find(state.filter);
|
||||
final result = await _api.findAll(state.filter);
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
hasLoaded: true,
|
||||
@@ -67,11 +67,13 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
log("[DocumentsCubit] reload");
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final result = await _api.find(state.filter.copyWith(page: 1));
|
||||
final filter = state.filter.copyWith(page: 1);
|
||||
final result = await _api.findAll(filter);
|
||||
emit(state.copyWith(
|
||||
hasLoaded: true,
|
||||
value: [result],
|
||||
isLoading: false,
|
||||
filter: filter,
|
||||
));
|
||||
} finally {
|
||||
emit(state.copyWith(isLoading: false));
|
||||
@@ -81,7 +83,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
Future<void> _bulkReloadDocuments() async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final result = await _api.find(
|
||||
final result = await _api.findAll(
|
||||
state.filter.copyWith(
|
||||
page: 1,
|
||||
pageSize: state.documents.length,
|
||||
@@ -106,7 +108,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
|
||||
try {
|
||||
final result = await _api.find(newFilter);
|
||||
final result = await _api.findAll(newFilter);
|
||||
emit(
|
||||
DocumentsState(
|
||||
hasLoaded: true,
|
||||
@@ -129,7 +131,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
log("[DocumentsCubit] updateFilter");
|
||||
try {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
final result = await _api.find(filter.copyWith(page: 1));
|
||||
final result = await _api.findAll(filter.copyWith(page: 1));
|
||||
|
||||
emit(
|
||||
DocumentsState(
|
||||
@@ -163,7 +165,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
|
||||
void toggleDocumentSelection(DocumentModel model) {
|
||||
log("[DocumentsCubit] toggleSelection");
|
||||
if (state.selection.contains(model)) {
|
||||
if (state.selectedIds.contains(model.id)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selection: state.selection
|
||||
@@ -183,6 +185,12 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
emit(state.copyWith(selection: []));
|
||||
}
|
||||
|
||||
@override
|
||||
void onChange(Change<DocumentsState> change) {
|
||||
super.onChange(change);
|
||||
log("[DocumentsCubit] state changed: ${change.currentState.selection.map((e) => e.id).join(",")} to ${change.nextState.selection.map((e) => e.id).join(",")}");
|
||||
}
|
||||
|
||||
void reset() {
|
||||
log("[DocumentsCubit] reset");
|
||||
emit(const DocumentsState());
|
||||
@@ -196,7 +204,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
|
||||
if (filter == null) {
|
||||
return;
|
||||
}
|
||||
final results = await _api.find(filter.copyWith(page: 1));
|
||||
final results = await _api.findAll(filter.copyWith(page: 1));
|
||||
emit(
|
||||
DocumentsState(
|
||||
filter: filter,
|
||||
|
||||
@@ -23,6 +23,8 @@ class DocumentsState extends Equatable {
|
||||
this.selectedSavedViewId,
|
||||
});
|
||||
|
||||
List<int> get selectedIds => selection.map((e) => e.id).toList();
|
||||
|
||||
int get currentPageNumber {
|
||||
return filter.page;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
@@ -15,14 +16,18 @@ import 'package:paperless_mobile/features/edit_document/cubit/edit_document_cubi
|
||||
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
|
||||
import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart';
|
||||
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
|
||||
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
|
||||
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
|
||||
class DocumentEditPage extends StatefulWidget {
|
||||
final FieldSuggestions suggestions;
|
||||
const DocumentEditPage({
|
||||
Key? key,
|
||||
required this.suggestions,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -74,14 +79,17 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
_buildTitleFormField(state.document.title).padded(),
|
||||
_buildCreatedAtFormField(state.document.created).padded(),
|
||||
_buildDocumentTypeFormField(
|
||||
state.document.documentType, state.documentTypes)
|
||||
.padded(),
|
||||
state.document.documentType,
|
||||
state.documentTypes,
|
||||
).padded(),
|
||||
_buildCorrespondentFormField(
|
||||
state.document.correspondent, state.correspondents)
|
||||
.padded(),
|
||||
state.document.correspondent,
|
||||
state.correspondents,
|
||||
).padded(),
|
||||
_buildStoragePathFormField(
|
||||
state.document.storagePath, state.storagePaths)
|
||||
.padded(),
|
||||
state.document.storagePath,
|
||||
state.storagePaths,
|
||||
).padded(),
|
||||
TagFormField(
|
||||
initialValue:
|
||||
IdsTagsQuery.included(state.document.tags.toList()),
|
||||
@@ -99,58 +107,101 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
}
|
||||
|
||||
Widget _buildStoragePathFormField(
|
||||
int? initialId, Map<int, StoragePath> options) {
|
||||
return LabelFormField<StoragePath>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
|
||||
create: (context) => context
|
||||
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(),
|
||||
child: AddStoragePathPage(initalValue: initialValue),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
|
||||
labelOptions: options,
|
||||
initialValue: IdQueryParameter.fromId(initialId),
|
||||
name: fkStoragePath,
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
int? initialId,
|
||||
Map<int, StoragePath> options,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
LabelFormField<StoragePath>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<StoragePath, StoragePathRepositoryState>>(),
|
||||
child: AddStoragePathPage(initalValue: initialValue),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
|
||||
labelOptions: options,
|
||||
initialValue: IdQueryParameter.fromId(initialId),
|
||||
name: fkStoragePath,
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
),
|
||||
if (widget.suggestions.hasSuggestedStoragePaths)
|
||||
_buildSuggestionsSkeleton<int>(
|
||||
suggestions: widget.suggestions.storagePaths,
|
||||
itemBuilder: (context, itemData) => ActionChip(
|
||||
label: Text(options[itemData]!.name),
|
||||
onPressed: () => _formKey.currentState?.fields[fkStoragePath]
|
||||
?.didChange((IdQueryParameter.fromId(itemData))),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCorrespondentFormField(
|
||||
int? initialId, Map<int, Correspondent> options) {
|
||||
return LabelFormField<Correspondent>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
|
||||
child: AddCorrespondentPage(initialName: initialValue),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
|
||||
labelOptions: options,
|
||||
initialValue: IdQueryParameter.fromId(initialId),
|
||||
name: fkCorrespondent,
|
||||
prefixIcon: const Icon(Icons.person_outlined),
|
||||
return Column(
|
||||
children: [
|
||||
LabelFormField<Correspondent>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
|
||||
child: AddCorrespondentPage(initialName: initialValue),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
|
||||
labelOptions: options,
|
||||
initialValue: IdQueryParameter.fromId(initialId),
|
||||
name: fkCorrespondent,
|
||||
prefixIcon: const Icon(Icons.person_outlined),
|
||||
),
|
||||
if (widget.suggestions.hasSuggestedCorrespondents)
|
||||
_buildSuggestionsSkeleton<int>(
|
||||
suggestions: widget.suggestions.correspondents,
|
||||
itemBuilder: (context, itemData) => ActionChip(
|
||||
label: Text(options[itemData]!.name),
|
||||
onPressed: () => _formKey.currentState?.fields[fkCorrespondent]
|
||||
?.didChange((IdQueryParameter.fromId(itemData))),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentTypeFormField(
|
||||
int? initialId, Map<int, DocumentType> options) {
|
||||
return LabelFormField<DocumentType>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider(
|
||||
create: (context) => context
|
||||
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
|
||||
child: AddDocumentTypePage(
|
||||
initialName: currentInput,
|
||||
int? initialId,
|
||||
Map<int, DocumentType> options,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
LabelFormField<DocumentType>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
|
||||
child: AddDocumentTypePage(
|
||||
initialName: currentInput,
|
||||
),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
|
||||
initialValue: IdQueryParameter.fromId(initialId),
|
||||
labelOptions: options,
|
||||
name: fkDocumentType,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
|
||||
initialValue: IdQueryParameter.fromId(initialId),
|
||||
labelOptions: options,
|
||||
name: fkDocumentType,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
if (widget.suggestions.hasSuggestedDocumentTypes)
|
||||
_buildSuggestionsSkeleton<int>(
|
||||
suggestions: widget.suggestions.documentTypes,
|
||||
itemBuilder: (context, itemData) => ActionChip(
|
||||
label: Text(options[itemData]!.name),
|
||||
onPressed: () => _formKey.currentState?.fields[fkDocumentType]
|
||||
?.didChange(IdQueryParameter.fromId(itemData)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,16 +249,56 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
}
|
||||
|
||||
Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) {
|
||||
return FormBuilderDateTimePicker(
|
||||
inputType: InputType.date,
|
||||
name: fkCreatedDate,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
label: Text(S.of(context).documentCreatedPropertyLabel),
|
||||
),
|
||||
initialValue: initialCreatedAtDate,
|
||||
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FormBuilderDateTimePicker(
|
||||
inputType: InputType.date,
|
||||
name: fkCreatedDate,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
label: Text(S.of(context).documentCreatedPropertyLabel),
|
||||
),
|
||||
initialValue: initialCreatedAtDate,
|
||||
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
),
|
||||
if (widget.suggestions.hasSuggestedDates)
|
||||
_buildSuggestionsSkeleton<DateTime>(
|
||||
suggestions: widget.suggestions.dates,
|
||||
itemBuilder: (context, itemData) => ActionChip(
|
||||
label: Text(DateFormat.yMd().format(itemData)),
|
||||
onPressed: () => _formKey.currentState?.fields[fkCreatedDate]
|
||||
?.didChange(itemData),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionsSkeleton<T>({
|
||||
required Iterable<T> suggestions,
|
||||
required ItemBuilder<T> itemBuilder,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Suggestions: ",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, index) =>
|
||||
itemBuilder(context, suggestions.elementAt(index)),
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(width: 4.0),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import 'package:badges/badges.dart' as b;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
|
||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.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/document_details_page.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/documents/view/widgets/documents_empty_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/list/adaptive_documents_view.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/new_items_loading_widget.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/selection/documents_page_app_bar.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
|
||||
import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart';
|
||||
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart';
|
||||
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
|
||||
class DocumentFilterIntent {
|
||||
@@ -42,9 +45,11 @@ class DocumentsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DocumentsPageState extends State<DocumentsPage> {
|
||||
final _pagingController = PagingController<int, DocumentModel>(
|
||||
firstPageKey: 1,
|
||||
);
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
double _offset = 0;
|
||||
double _last = 0;
|
||||
|
||||
static const double _savedViewWidgetHeight = 78 + 16;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,12 +60,36 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
_pagingController.addPageRequestListener(_loadNewPage);
|
||||
_scrollController
|
||||
..addListener(_listenForScrollChanges)
|
||||
..addListener(_listenForLoadDataTrigger);
|
||||
}
|
||||
|
||||
void _listenForLoadDataTrigger() {
|
||||
final currState = context.read<DocumentsCubit>().state;
|
||||
if (_scrollController.offset >=
|
||||
_scrollController.position.maxScrollExtent &&
|
||||
!currState.isLoading &&
|
||||
!currState.isLastPageLoaded) {
|
||||
_loadNewPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _listenForScrollChanges() {
|
||||
final current = _scrollController.offset;
|
||||
_offset += _last - current;
|
||||
|
||||
if (_offset <= -_savedViewWidgetHeight) _offset = -_savedViewWidgetHeight;
|
||||
if (_offset >= 0) _offset = 0;
|
||||
_last = current;
|
||||
if (_offset <= 0 && _offset >= -_savedViewWidgetHeight) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pagingController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -78,6 +107,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
},
|
||||
builder: (context, connectivityState) {
|
||||
const linearProgressIndicatorHeight = 4.0;
|
||||
return Scaffold(
|
||||
drawer: BlocProvider.value(
|
||||
value: context.read<AuthenticationCubit>(),
|
||||
@@ -85,16 +115,65 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
afterInboxClosed: () => context.read<DocumentsCubit>().reload(),
|
||||
),
|
||||
),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(
|
||||
kToolbarHeight + linearProgressIndicatorHeight,
|
||||
),
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return AppBar(
|
||||
title: Text(
|
||||
"${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})",
|
||||
),
|
||||
actions: [
|
||||
const SortDocumentsButton(),
|
||||
BlocBuilder<ApplicationSettingsCubit,
|
||||
ApplicationSettingsState>(
|
||||
builder: (context, settingsState) => IconButton(
|
||||
icon: Icon(
|
||||
settingsState.preferredViewType == ViewType.grid
|
||||
? Icons.list
|
||||
: Icons.grid_view_rounded,
|
||||
),
|
||||
onPressed: () {
|
||||
// Reset saved view widget position as scroll offset will be reset anyway.
|
||||
setState(() {
|
||||
_offset = 0;
|
||||
_last = 0;
|
||||
});
|
||||
final cubit =
|
||||
context.read<ApplicationSettingsCubit>();
|
||||
cubit.setViewType(
|
||||
cubit.state.preferredViewType.toggle());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize:
|
||||
const Size.fromHeight(linearProgressIndicatorHeight),
|
||||
child: state.isLoading
|
||||
? const LinearProgressIndicator()
|
||||
: const SizedBox(height: 4.0),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
final appliedFiltersCount = state.filter.appliedFiltersCount;
|
||||
return Badge.count(
|
||||
//TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd
|
||||
alignment: const AlignmentDirectional(44, -4),
|
||||
isLabelVisible: appliedFiltersCount > 0,
|
||||
count: state.filter.appliedFiltersCount,
|
||||
backgroundColor: Colors.red,
|
||||
textColor: Colors.white,
|
||||
return b.Badge(
|
||||
position: b.BadgePosition.topEnd(top: -12, end: -6),
|
||||
showBadge: appliedFiltersCount > 0,
|
||||
badgeContent: Text(
|
||||
'$appliedFiltersCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
animationType: b.BadgeAnimationType.fade,
|
||||
badgeColor: Theme.of(context).colorScheme.error,
|
||||
child: FloatingActionButton(
|
||||
child: const Icon(Icons.filter_alt_outlined),
|
||||
onPressed: _openDocumentFilter,
|
||||
@@ -103,12 +182,71 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
},
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: _buildBody(connectivityState),
|
||||
body: WillPopScope(
|
||||
onWillPop: () async {
|
||||
if (context.read<DocumentsCubit>().state.selection.isNotEmpty) {
|
||||
context.read<DocumentsCubit>().resetSelection();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
notificationPredicate: (_) => connectivityState.isConnected,
|
||||
child: BlocBuilder<TaskStatusCubit, TaskStatusState>(
|
||||
builder: (context, taskState) {
|
||||
return Stack(
|
||||
children: [
|
||||
_buildBody(connectivityState),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _offset,
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: SavedViewSelectionWidget(
|
||||
height: _savedViewWidgetHeight,
|
||||
currentFilter: state.filter,
|
||||
enabled: state.selection.isEmpty &&
|
||||
connectivityState.isConnected,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (taskState.task != null &&
|
||||
taskState.isSuccess &&
|
||||
!taskState.task!.acknowledged)
|
||||
_buildNewDocumentAvailableButton(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Align _buildNewDocumentAvailableButton(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: FilledButton(
|
||||
style: ButtonStyle(
|
||||
backgroundColor:
|
||||
MaterialStatePropertyAll(Theme.of(context).colorScheme.error),
|
||||
),
|
||||
child: Text("New document available!"),
|
||||
onPressed: () {
|
||||
context.read<TaskStatusCubit>().acknowledgeCurrentTask();
|
||||
context.read<DocumentsCubit>().reload();
|
||||
},
|
||||
).paddedOnly(bottom: 24, left: 24),
|
||||
);
|
||||
}
|
||||
|
||||
void _openDocumentFilter() async {
|
||||
final draggableSheetController = DraggableScrollableController();
|
||||
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>(
|
||||
@@ -160,88 +298,45 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDocumentCount(int count) {
|
||||
return count > 99 ? "99+" : count.toString();
|
||||
}
|
||||
|
||||
Widget _buildBody(ConnectivityState connectivityState) {
|
||||
final isConnected = connectivityState == ConnectivityState.connected;
|
||||
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
|
||||
builder: (context, settings) {
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
buildWhen: (previous, current) => !const ListEquality()
|
||||
.equals(previous.documents, current.documents),
|
||||
buildWhen: (previous, current) =>
|
||||
!const ListEquality()
|
||||
.equals(previous.documents, current.documents) ||
|
||||
previous.selectedIds != current.selectedIds,
|
||||
builder: (context, state) {
|
||||
// Some ugly tricks to make it work with bloc, update pageController
|
||||
_pagingController.value = PagingState(
|
||||
itemList: state.documents,
|
||||
nextPageKey: state.nextPageNumber,
|
||||
);
|
||||
|
||||
late Widget child;
|
||||
switch (settings.preferredViewType) {
|
||||
case ViewType.list:
|
||||
child = DocumentListView(
|
||||
state: state,
|
||||
onTap: _openDetails,
|
||||
onSelected: _onSelected,
|
||||
pagingController: _pagingController,
|
||||
hasInternetConnection: isConnected,
|
||||
onTagSelected: _addTagToFilter,
|
||||
onCorrespondentSelected: _addCorrespondentToFilter,
|
||||
onDocumentTypeSelected: _addDocumentTypeToFilter,
|
||||
onStoragePathSelected: _addStoragePathToFilter,
|
||||
);
|
||||
break;
|
||||
case ViewType.grid:
|
||||
child = DocumentGridView(
|
||||
state: state,
|
||||
onTap: _openDetails,
|
||||
onSelected: _onSelected,
|
||||
pagingController: _pagingController,
|
||||
hasInternetConnection: isConnected,
|
||||
onTagSelected: _addTagToFilter,
|
||||
onCorrespondentSelected: _addCorrespondentToFilter,
|
||||
onDocumentTypeSelected: _addDocumentTypeToFilter,
|
||||
onStoragePathSelected: _addStoragePathToFilter,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.hasLoaded && state.documents.isEmpty) {
|
||||
child = SliverToBoxAdapter(
|
||||
child: DocumentsEmptyState(
|
||||
state: state,
|
||||
onReset: () {
|
||||
context.read<DocumentsCubit>().resetFilter();
|
||||
context.read<DocumentsCubit>().unselectView();
|
||||
},
|
||||
),
|
||||
return DocumentsEmptyState(
|
||||
state: state,
|
||||
onReset: () {
|
||||
context.read<DocumentsCubit>().resetFilter();
|
||||
context.read<DocumentsCubit>().unselectView();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
notificationPredicate: (_) => isConnected,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
DocumentsPageAppBar(
|
||||
isOffline: connectivityState != ConnectivityState.connected,
|
||||
actions: [
|
||||
const SortDocumentsButton(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
settings.preferredViewType == ViewType.grid
|
||||
? Icons.list
|
||||
: Icons.grid_view,
|
||||
),
|
||||
onPressed: () => context
|
||||
.read<ApplicationSettingsCubit>()
|
||||
.setViewType(
|
||||
settings.preferredViewType.toggle(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
return AdaptiveDocumentsView(
|
||||
viewType: settings.preferredViewType,
|
||||
state: state,
|
||||
scrollController: _scrollController,
|
||||
onTap: _openDetails,
|
||||
onSelected: _onSelected,
|
||||
hasInternetConnection: isConnected,
|
||||
onTagSelected: _addTagToFilter,
|
||||
onCorrespondentSelected: _addCorrespondentToFilter,
|
||||
onDocumentTypeSelected: _addDocumentTypeToFilter,
|
||||
onStoragePathSelected: _addStoragePathToFilter,
|
||||
pageLoadingWidget: const NewItemsLoadingWidget(),
|
||||
beforeItems: const SizedBox(height: _savedViewWidgetHeight),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -355,15 +450,9 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNewPage(int pageKey) async {
|
||||
final documentsCubit = context.read<DocumentsCubit>();
|
||||
final pageCount = documentsCubit.state
|
||||
.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
|
||||
if (pageCount <= pageKey + 1) {
|
||||
_pagingController.nextPageKey = null;
|
||||
}
|
||||
Future<void> _loadNewPage() async {
|
||||
try {
|
||||
await documentsCubit.loadMore();
|
||||
await context.read<DocumentsCubit>().loadMore();
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
@@ -376,8 +465,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
Future<void> _onRefresh() async {
|
||||
try {
|
||||
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
|
||||
await context.read<DocumentsCubit>().reload();
|
||||
await context.read<SavedViewCubit>().reload();
|
||||
context.read<DocumentsCubit>().reload();
|
||||
context.read<SavedViewCubit>().reload();
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.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/documents/view/widgets/grid/document_grid_item.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
class DocumentGridView extends StatelessWidget {
|
||||
final void Function(DocumentModel model) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final PagingController<int, DocumentModel> pagingController;
|
||||
final DocumentsState state;
|
||||
final bool hasInternetConnection;
|
||||
final void Function(int tagId) onTagSelected;
|
||||
final void Function(int correspondentId) onCorrespondentSelected;
|
||||
final void Function(int correspondentId) onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
|
||||
const DocumentGridView({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.pagingController,
|
||||
required this.state,
|
||||
required this.onSelected,
|
||||
required this.hasInternetConnection,
|
||||
required this.onTagSelected,
|
||||
required this.onCorrespondentSelected,
|
||||
required this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PagedSliverGrid<int, DocumentModel>(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
),
|
||||
pagingController: pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
itemBuilder: (context, item, index) {
|
||||
return DocumentGridItem(
|
||||
document: item,
|
||||
onTap: onTap,
|
||||
isSelected: state.selection.contains(item),
|
||||
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,
|
||||
);
|
||||
},
|
||||
noItemsFoundIndicatorBuilder: (context) =>
|
||||
const DocumentsListLoadingWidget(), //TODO: Replace with grid loading widget
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class DocumentGridItem extends StatelessWidget {
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final bool isAtLeastOneSelected;
|
||||
final bool Function(int tagId) isTagSelectedPredicate;
|
||||
final void Function(int tagId) onTagSelected;
|
||||
final void Function(int tagId)? onTagSelected;
|
||||
|
||||
const DocumentGridItem({
|
||||
Key? key,
|
||||
@@ -57,9 +57,11 @@ class DocumentGridItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CorrespondentWidget(
|
||||
correspondentId: document.correspondent),
|
||||
correspondentId: document.correspondent,
|
||||
),
|
||||
DocumentTypeWidget(
|
||||
documentTypeId: document.documentType),
|
||||
documentTypeId: document.documentType,
|
||||
),
|
||||
Text(
|
||||
document.title,
|
||||
maxLines: document.tags.isEmpty ? 3 : 2,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
|
||||
class AdaptiveDocumentsView extends StatelessWidget {
|
||||
final ViewType viewType;
|
||||
final Widget beforeItems;
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final ScrollController scrollController;
|
||||
final DocumentsState state;
|
||||
final bool hasInternetConnection;
|
||||
final bool isLabelClickable;
|
||||
final void Function(int id)? onTagSelected;
|
||||
final void Function(int? id)? onCorrespondentSelected;
|
||||
final void Function(int? id)? onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
final Widget pageLoadingWidget;
|
||||
|
||||
const AdaptiveDocumentsView({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.scrollController,
|
||||
required this.state,
|
||||
required this.onSelected,
|
||||
required this.hasInternetConnection,
|
||||
this.isLabelClickable = true,
|
||||
this.onTagSelected,
|
||||
this.onCorrespondentSelected,
|
||||
this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
required this.pageLoadingWidget,
|
||||
required this.beforeItems,
|
||||
required this.viewType,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: beforeItems),
|
||||
if (viewType == ViewType.list) _buildListView() else _buildGridView(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
SliverList _buildListView() {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: state.documents.length,
|
||||
(context, index) {
|
||||
final document = state.documents.elementAt(index);
|
||||
return LabelRepositoriesProvider(
|
||||
child: DocumentListItem(
|
||||
isLabelClickable: isLabelClickable,
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
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,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView() {
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 178,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
),
|
||||
itemCount: state.documents.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (state.hasLoaded &&
|
||||
state.isLoading &&
|
||||
index == state.documents.length) {
|
||||
return Center(child: pageLoadingWidget);
|
||||
}
|
||||
final document = state.documents.elementAt(index);
|
||||
return DocumentGridItem(
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
|
||||
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
|
||||
import 'package:paperless_mobile/core/widgets/offline_widget.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/documents/view/widgets/list/document_list_item.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
class DocumentListView extends StatelessWidget {
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
|
||||
final PagingController<int, DocumentModel> pagingController;
|
||||
final DocumentsState state;
|
||||
final bool hasInternetConnection;
|
||||
final bool isLabelClickable;
|
||||
final void Function(int id)? onTagSelected;
|
||||
final void Function(int? id)? onCorrespondentSelected;
|
||||
final void Function(int? id)? onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
|
||||
const DocumentListView({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.pagingController,
|
||||
required this.state,
|
||||
required this.onSelected,
|
||||
required this.hasInternetConnection,
|
||||
this.isLabelClickable = true,
|
||||
this.onTagSelected,
|
||||
this.onCorrespondentSelected,
|
||||
this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PagedSliverList<int, DocumentModel>(
|
||||
pagingController: pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
animateTransitions: true,
|
||||
itemBuilder: (context, document, index) {
|
||||
return LabelRepositoriesProvider(
|
||||
child: DocumentListItem(
|
||||
isLabelClickable: isLabelClickable,
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: state.selection.contains(document),
|
||||
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,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
),
|
||||
);
|
||||
},
|
||||
noItemsFoundIndicatorBuilder: (context) => hasInternetConnection
|
||||
? const DocumentsListLoadingWidget()
|
||||
: const OfflineWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class DocumentListItem extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
child: ListTile(
|
||||
trailing: Text("${document.id}"),
|
||||
dense: true,
|
||||
selected: isSelected,
|
||||
onTap: () => _onTap(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class NewItemsLoadingWidget extends StatelessWidget {
|
||||
const NewItemsLoadingWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
}
|
||||
@@ -138,3 +138,19 @@ class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
|
||||
return count > 99 ? "99+" : count.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollListener extends ChangeNotifier {
|
||||
double top = 0;
|
||||
double _last = 0;
|
||||
|
||||
ScrollListener.initialise(ScrollController controller, [double height = 56]) {
|
||||
controller.addListener(() {
|
||||
final current = controller.offset;
|
||||
top += _last - current;
|
||||
if (top <= -height) top = -height;
|
||||
if (top >= 0) top = 0;
|
||||
_last = current;
|
||||
if (top <= 0 && top >= -height) notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user