Improved search, changed saved view display

This commit is contained in:
Anton Stubenbord
2023-01-31 00:29:07 +01:00
parent b697dc7d8d
commit e9e9fdc336
27 changed files with 1549 additions and 1016 deletions

View File

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart';
import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/constants.dart';
@@ -58,11 +60,6 @@ class _SimilarDocumentsViewState extends State<SimilarDocumentsView> {
);
return BlocBuilder<SimilarDocumentsCubit, SimilarDocumentsState>(
builder: (context, state) {
if (!state.hasLoaded) {
return const DocumentsListLoadingWidget(
beforeWidgets: [earlyPreviewHintCard],
);
}
if (state.documents.isEmpty) {
return DocumentsEmptyState(
state: state,
@@ -74,16 +71,30 @@ class _SimilarDocumentsViewState extends State<SimilarDocumentsView> {
),
);
}
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivity) {
return CustomScrollView(
controller: _scrollController,
slivers: [
const SliverToBoxAdapter(child: earlyPreviewHintCard),
SliverAdaptiveDocumentsView(
documents: state.documents,
hasInternetConnection: connectivity.isConnected,
isLabelClickable: false,
isLoading: state.isLoading,
hasLoaded: state.hasLoaded,
),
SliverList(
delegate: SliverChildBuilderDelegate(
childCount: state.documents.length,
(context, index) => DocumentListItem(
document: state.documents[index],
enableHeroAnimation: false,
isLabelClickable: false,
isSelected: false,
isSelectionActive: false,
),
),
),
@@ -91,5 +102,7 @@ class _SimilarDocumentsViewState extends State<SimilarDocumentsView> {
);
},
);
},
);
}
}

View File

@@ -66,38 +66,11 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
emit(const DocumentsState());
}
Future<void> selectView(int id) async {
emit(state.copyWith(isLoading: true));
try {
final filter =
_savedViewRepository.current?.values[id]?.toDocumentFilter();
if (filter == null) {
return;
}
final results = await api.findAll(filter.copyWith(page: 1));
emit(
DocumentsState(
filter: filter,
hasLoaded: true,
isLoading: false,
selectedSavedViewId: id,
value: [results],
),
);
} finally {
emit(state.copyWith(isLoading: false));
}
}
Future<Iterable<String>> autocomplete(String query) async {
final res = await api.autocomplete(query);
return res;
}
void unselectView() {
emit(state.copyWith(selectedSavedViewId: () => null));
}
@override
DocumentsState? fromJson(Map<String, dynamic> json) {
return DocumentsState.fromJson(json);

View File

@@ -3,14 +3,11 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
class DocumentsState extends PagedDocumentsState {
final int? selectedSavedViewId;
@JsonKey(ignore: true)
final List<DocumentModel> selection;
const DocumentsState({
this.selection = const [],
this.selectedSavedViewId,
super.value = const [],
super.filter = const DocumentFilter(),
super.hasLoaded = false,
@@ -25,7 +22,6 @@ class DocumentsState extends PagedDocumentsState {
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
List<DocumentModel>? selection,
int? Function()? selectedSavedViewId,
}) {
return DocumentsState(
hasLoaded: hasLoaded ?? this.hasLoaded,
@@ -33,9 +29,6 @@ class DocumentsState extends PagedDocumentsState {
value: value ?? this.value,
filter: filter ?? this.filter,
selection: selection ?? this.selection,
selectedSavedViewId: selectedSavedViewId != null
? selectedSavedViewId.call()
: this.selectedSavedViewId,
);
}
@@ -46,7 +39,6 @@ class DocumentsState extends PagedDocumentsState {
value,
selection,
isLoading,
selectedSavedViewId,
];
Map<String, dynamic> toJson() {
@@ -54,7 +46,6 @@ class DocumentsState extends PagedDocumentsState {
'hasLoaded': hasLoaded,
'isLoading': isLoading,
'filter': filter.toJson(),
'selectedSavedViewId': selectedSavedViewId,
'value':
value.map((e) => e.toJson(DocumentModelJsonConverter())).toList(),
};
@@ -65,7 +56,6 @@ class DocumentsState extends PagedDocumentsState {
return DocumentsState(
hasLoaded: json['hasLoaded'],
isLoading: json['isLoading'],
selectedSavedViewId: json['selectedSavedViewId'],
value: (json['value'] as List<dynamic>)
.map((e) =>
PagedSearchResult.fromJsonT(e, DocumentModelJsonConverter()))

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:badges/badges.dart' as b;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@@ -5,35 +7,26 @@ import 'package:flutter_bloc/flutter_bloc.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/core/translation/sort_field_localization_mapper.dart';
import 'package:paperless_mobile/core/widgets/app_options_popup_menu.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.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_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/document_search_delegate.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_bar.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/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.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/bulk_delete_confirmation_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart';
import 'package:paperless_mobile/features/documents/view/widgets/view_actions.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/saved_view/view/add_saved_view_page.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart';
import 'package:paperless_mobile/features/search/view/document_search_page.dart';
import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/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/helpers/format_helpers.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class DocumentFilterIntent {
@@ -46,6 +39,7 @@ class DocumentFilterIntent {
});
}
//TODO: Refactor this
class DocumentsPage extends StatefulWidget {
const DocumentsPage({Key? key}) : super(key: key);
@@ -53,56 +47,38 @@ class DocumentsPage extends StatefulWidget {
State<DocumentsPage> createState() => _DocumentsPageState();
}
class _DocumentsPageState extends State<DocumentsPage> {
final ScrollController _scrollController = ScrollController();
double _offset = 0;
double _last = 0;
class _DocumentsPageState extends State<DocumentsPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
static const double _savedViewWidgetHeight = 80 + 16;
int _currentTab = 0;
@override
void initState() {
super.initState();
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: 0,
);
try {
context.read<DocumentsCubit>().reload();
context.read<SavedViewCubit>().reload();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
_scrollController
..addListener(_listenForScrollChanges)
..addListener(_listenForLoadNewData);
_tabController.addListener(_listenForTabChanges);
}
void _listenForLoadNewData() async {
final currState = context.read<DocumentsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
try {
await context.read<DocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
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(() {});
}
void _listenForTabChanges() {
setState(() {
_currentTab = _tabController.index;
});
}
@override
void dispose() {
_scrollController.dispose();
_tabController.dispose();
super.dispose();
}
@@ -140,149 +116,204 @@ class _DocumentsPageState extends State<DocumentsPage> {
},
builder: (context, connectivityState) {
return Scaffold(
// appBar: PreferredSize(
// preferredSize: const Size.fromHeight(
// kToolbarHeight,
// ),
// child: BlocBuilder<DocumentsCubit, DocumentsState>(
// builder: (context, state) {
// if (state.selection.isEmpty) {
// return DocumentSearchBar();
// // return AppBar(
// // title: Text(S.of(context).documentsPageTitle +
// // " (${formatMaxCount(state.documents.length)})"),
// // actions: [
// // IconButton(
// // icon: const Icon(Icons.search),
// // onPressed: () {
// // showMaterial3Search(
// // context: context,
// // delegate: DocumentSearchDelegate(
// // DocumentSearchCubit(context.read()),
// // searchFieldStyle:
// // Theme.of(context).textTheme.bodyLarge,
// // hintText: "Search documents", //TODO: INTL
// // ),
// // );
// // },
// // ),
// // const SortDocumentsButton(),
// // const AppOptionsPopupMenu(
// // displayedActions: [
// // AppPopupMenuEntries.documentsSelectListView,
// // AppPopupMenuEntries.documentsSelectGridView,
// // AppPopupMenuEntries.divider,
// // AppPopupMenuEntries.openAboutThisAppDialog,
// // AppPopupMenuEntries.reportBug,
// // AppPopupMenuEntries.openSettings,
// // ],
// // ),
// // ],
// // );
// } else {
// return AppBar(
// leading: IconButton(
// icon: const Icon(Icons.close),
// onPressed: () =>
// context.read<DocumentsCubit>().resetSelection(),
// ),
// title: Text(
// '${state.selection.length} ${S.of(context).documentsSelectedText}'),
// actions: [
// IconButton(
// icon: const Icon(Icons.delete),
// onPressed: () => _onDelete(context, state),
// ),
// ],
// );
// }
// },
// ),
// ),
// floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
// builder: (context, state) {
// final appliedFiltersCount = state.filter.appliedFiltersCount;
// 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: Colors.red,
// child: FloatingActionButton(
// child: const Icon(Icons.filter_alt_outlined),
// onPressed: _openDocumentFilter,
// ),
// );
// },
// ),
resizeToAvoidBottomInset: true,
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
floating: true,
pinned: true,
snap: true,
title: SearchBar(
height: kToolbarHeight - 2,
supportingText: "Search documents",
onTap: () {
showDocumentSearchPage(context);
},
leadingIcon: Icon(Icons.menu),
trailingIcon: CircleAvatar(
child: Text("A"),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount;
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: Colors.red,
child: _currentTab == 0
? FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
)
];
: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => _onCreateSavedView(state.filter),
),
);
},
),
resizeToAvoidBottomInset: true,
body: WillPopScope(
onWillPop: () async {
if (context
.read<DocumentsCubit>()
.state
.selection
.isNotEmpty) {
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 _buildBody(connectivityState);
// return Stack(
// children: [
// 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,
// ),
// );
// },
// ),
// ),
// ],
// );
child: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
),
sliver: SearchAppBar(
onOpenSearch: showDocumentSearchPage,
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: [
Tab(text: S.of(context).documentsPageTitle),
Tab(text: S.of(context).savedViewsLabel),
],
),
),
),
],
body: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
final desiredTab =
(metrics.pixels / metrics.maxScrollExtent).round();
if (metrics.axis == Axis.horizontal &&
_currentTab != desiredTab) {
setState(() => _currentTab = desiredTab);
}
return true;
},
child: NotificationListener<ScrollMetricsNotification>(
onNotification: (notification) {
// Listen for scroll notifications to load new data.
// Scroll controller does not work here due to nestedscrollview limitations.
final currState = context.read<DocumentsCubit>().state;
final max = notification.metrics.maxScrollExtent;
if (max == 0 ||
_currentTab != 0 ||
currState.isLoading ||
currState.isLastPageLoaded) {
return true;
}
final offset = notification.metrics.pixels;
if (offset >= max * 0.7) {
context
.read<DocumentsCubit>()
.loadMore()
.onError<PaperlessServerException>(
(error, stackTrace) => showErrorMessage(
context,
error,
stackTrace,
),
);
}
return true;
},
child: TabBarView(
controller: _tabController,
children: [
Builder(
builder: (context) {
return RefreshIndicator(
edgeOffset: kToolbarHeight,
onRefresh: _onReloadDocuments,
notificationPredicate: (_) =>
connectivityState.isConnected,
child: CustomScrollView(
key: const PageStorageKey<String>("documents"),
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(
context),
),
BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) =>
!const ListEquality().equals(
previous.documents,
current.documents,
) ||
previous.selectedIds !=
current.selectedIds,
builder: (context, state) {
if (state.hasLoaded &&
state.documents.isEmpty) {
return SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
onReset: () {
context
.read<DocumentsCubit>()
.resetFilter();
},
),
);
}
return BlocBuilder<
ApplicationSettingsCubit,
ApplicationSettingsState>(
builder: (context, settings) {
return SliverAdaptiveDocumentsView(
viewType:
settings.preferredViewType,
onTap: _openDetails,
onSelected: context
.read<DocumentsCubit>()
.toggleDocumentSelection,
hasInternetConnection:
connectivityState.isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected:
_addCorrespondentToFilter,
onDocumentTypeSelected:
_addDocumentTypeToFilter,
onStoragePathSelected:
_addStoragePathToFilter,
documents: state.documents,
hasLoaded: state.hasLoaded,
isLabelClickable: true,
isLoading: state.isLoading,
selectedDocumentIds:
state.selectedIds,
);
},
);
},
),
],
),
);
},
),
Builder(
builder: (context) {
return RefreshIndicator(
edgeOffset: kToolbarHeight,
onRefresh: _onReloadSavedViews,
notificationPredicate: (_) =>
connectivityState.isConnected,
child: CustomScrollView(
key: const PageStorageKey<String>("savedViews"),
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView
.sliverOverlapAbsorberHandleFor(
context),
),
const SavedViewList(),
],
),
);
},
),
],
),
),
),
),
@@ -293,28 +324,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
);
}
BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>
_buildViewTypeButton() {
return 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());
},
),
);
}
//TODO: Add app bar...
void _onDelete(BuildContext context, DocumentsState documentsState) async {
final shouldDelete = await showDialog<bool>(
context: context,
@@ -338,6 +348,25 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
}
void _onCreateSavedView(DocumentFilter filter) async {
final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (context) => LabelsBlocProvider(
child: AddSavedViewPage(
currentFilter: filter,
),
),
),
);
if (newView != null) {
try {
await context.read<SavedViewCubit>().add(newView);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
void _openDocumentFilter() async {
final draggableSheetController = DraggableScrollableController();
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>(
@@ -373,12 +402,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
try {
if (filterIntent.shouldReset) {
await context.read<DocumentsCubit>().resetFilter();
context.read<DocumentsCubit>().unselectView();
} else {
if (filterIntent.filter !=
context.read<DocumentsCubit>().state.filter) {
context.read<DocumentsCubit>().unselectView();
}
await context
.read<DocumentsCubit>()
.updateFilter(filter: filterIntent.filter!);
@@ -389,75 +413,11 @@ 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) ||
previous.selectedIds != current.selectedIds,
builder: (context, state) {
if (state.hasLoaded && state.documents.isEmpty) {
return DocumentsEmptyState(
state: state,
onReset: () {
context.read<DocumentsCubit>().resetFilter();
context.read<DocumentsCubit>().unselectView();
},
);
}
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: SizedBox(
height: kToolbarHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortDocumentsButton(),
IconButton(
icon: Icon(
settings.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view_rounded,
),
onPressed: () =>
context.read<ApplicationSettingsCubit>().setViewType(
settings.preferredViewType.toggle(),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _openDetails(DocumentModel document) async {
final potentiallyUpdatedModel =
await Navigator.of(context).push<DocumentModel?>(
final updatedModel = await Navigator.of(context).push<DocumentModel?>(
_buildDetailsPageRoute(document),
);
if (potentiallyUpdatedModel != document) {
if (updatedModel != document) {
context.read<DocumentsCubit>().reload();
}
}
@@ -558,15 +518,19 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
}
void _onSelected(DocumentModel model) {
context.read<DocumentsCubit>().toggleDocumentSelection(model);
}
Future<void> _onRefresh() async {
Future<void> _onReloadDocuments() async {
try {
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
context.read<DocumentsCubit>().reload();
context.read<SavedViewCubit>().reload();
await context.read<DocumentsCubit>().reload();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
Future<void> _onReloadSavedViews() async {
try {
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
await context.read<SavedViewCubit>().reload();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}

View File

@@ -0,0 +1,232 @@
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/view/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_grid_item.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
abstract class AdaptiveDocumentsView extends StatelessWidget {
final List<DocumentModel> documents;
final bool isLoading;
final bool hasLoaded;
final bool enableHeroAnimation;
final List<int> selectedDocumentIds;
final ViewType viewType;
final void Function(DocumentModel)? onTap;
final void Function(DocumentModel)? onSelected;
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 AdaptiveDocumentsView({
super.key,
this.selectedDocumentIds = const [],
required this.documents,
this.onTap,
this.onSelected,
this.viewType = ViewType.list,
required this.hasInternetConnection,
required this.isLabelClickable,
this.onTagSelected,
this.onCorrespondentSelected,
this.onDocumentTypeSelected,
this.onStoragePathSelected,
required this.isLoading,
required this.hasLoaded,
this.enableHeroAnimation = true,
});
}
class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView {
const SliverAdaptiveDocumentsView({
super.key,
required super.documents,
required super.hasInternetConnection,
required super.isLabelClickable,
super.onCorrespondentSelected,
super.onDocumentTypeSelected,
super.onStoragePathSelected,
super.onSelected,
super.onTagSelected,
super.onTap,
super.selectedDocumentIds,
super.viewType,
required super.isLoading,
required super.hasLoaded,
});
@override
Widget build(BuildContext context) {
switch (viewType) {
case ViewType.grid:
return _buildGridView();
case ViewType.list:
return _buildListView();
}
}
Widget _buildListView() {
if (!hasLoaded && isLoading) {
return const DocumentsListLoadingWidget();
}
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: documents.length,
(context, index) {
final document = documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
),
);
},
),
);
}
Widget _buildGridView() {
if (!hasLoaded && isLoading) {
return const DocumentsListLoadingWidget();
}
return SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1 / 2,
),
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents.elementAt(index);
return DocumentGridItem(
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
isLabelClickable: isLabelClickable,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
);
},
);
}
}
class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
final ScrollController? scrollController;
const DefaultAdaptiveDocumentsView({
super.key,
required super.documents,
required super.hasInternetConnection,
required super.isLabelClickable,
required super.isLoading,
required super.hasLoaded,
super.onCorrespondentSelected,
super.onDocumentTypeSelected,
super.onStoragePathSelected,
super.onSelected,
super.onTagSelected,
super.onTap,
this.scrollController,
super.selectedDocumentIds,
super.viewType,
super.enableHeroAnimation = true,
});
@override
Widget build(BuildContext context) {
switch (viewType) {
case ViewType.grid:
return _buildGridView();
case ViewType.list:
return _buildListView();
}
}
Widget _buildListView() {
if (!hasLoaded && isLoading) {
return const CustomScrollView(slivers: [
DocumentsListLoadingWidget(),
]);
}
return ListView.builder(
controller: scrollController,
primary: false,
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
),
);
},
);
}
Widget _buildGridView() {
if (!hasLoaded && isLoading) {
return const CustomScrollView(
slivers: [
DocumentsListLoadingWidget(),
],
); //TODO: Build grid skeleton
}
return GridView.builder(
controller: scrollController,
primary: false,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1 / 2,
),
itemCount: documents.length,
itemBuilder: (context, index) {
final document = documents.elementAt(index);
return DocumentGridItem(
document: document,
onTap: onTap,
isSelected: selectedDocumentIds.contains(document.id),
onSelected: onSelected,
isSelectionActive: selectedDocumentIds.isNotEmpty,
isLabelClickable: isLabelClickable,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
enableHeroAnimation: enableHeroAnimation,
);
},
);
}
}

View File

@@ -7,11 +7,11 @@ import 'package:paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget {
final PagedDocumentsState state;
final VoidCallback onReset;
final VoidCallback? onReset;
const DocumentsEmptyState({
Key? key,
required this.state,
required this.onReset,
this.onReset,
}) : super(key: key);
@override
@@ -20,7 +20,7 @@ class DocumentsEmptyState extends StatelessWidget {
child: EmptyState(
title: S.of(context).documentsPageEmptyStateOopsText,
subtitle: S.of(context).documentsPageEmptyStateNothingHereText,
bottomChild: state.filter != DocumentFilter.initial
bottomChild: state.filter != DocumentFilter.initial && onReset != null
? TextButton(
onPressed: onReset,
child: Text(

View File

@@ -5,37 +5,23 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:shimmer/shimmer.dart';
class DocumentsListLoadingWidget extends StatelessWidget {
final List<Widget> beforeWidgets;
final List<Widget> afterWidgets;
static const _tags = [" ", " ", " "];
static const _titleLengths = <double>[double.infinity, 150.0, 200.0];
static const _correspondentLengths = <double>[200.0, 300.0, 150.0];
static const _fontSize = 16.0;
const DocumentsListLoadingWidget({
super.key,
this.beforeWidgets = const [],
this.afterWidgets = const [],
const DocumentsListLoadingWidget({super.key
});
@override
Widget build(BuildContext context) {
final _random = Random();
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildListDelegate(beforeWidgets),
),
SliverList(
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _buildFakeListItem(context, _random);
},
),
),
SliverList(delegate: SliverChildListDelegate(afterWidgets))
],
);
}

View File

@@ -1,38 +1,35 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.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/labels/tags/view/widgets/tags_widget.dart';
import 'package:intl/intl.dart';
class DocumentGridItem extends StatelessWidget {
final DocumentModel document;
final bool isSelected;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final bool isAtLeastOneSelected;
final bool Function(int tagId) isTagSelectedPredicate;
final void Function(int tagId)? onTagSelected;
class DocumentGridItem extends DocumentItem {
const DocumentGridItem({
Key? key,
required this.document,
required this.onTap,
required this.onSelected,
required this.isSelected,
required this.isAtLeastOneSelected,
required this.isTagSelectedPredicate,
required this.onTagSelected,
}) : super(key: key);
super.key,
required super.document,
required super.isSelected,
required super.isSelectionActive,
required super.isLabelClickable,
super.onCorrespondentSelected,
super.onDocumentTypeSelected,
super.onSelected,
super.onStoragePathSelected,
super.onTagSelected,
super.onTap,
required super.enableHeroAnimation,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
onLongPress: () => onSelected(document),
onLongPress: onSelected != null ? () => onSelected!(document) : null,
child: AbsorbPointer(
absorbing: isAtLeastOneSelected,
absorbing: isSelectionActive,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
@@ -48,6 +45,7 @@ class DocumentGridItem extends StatelessWidget {
child: DocumentPreview(
id: document.id,
borderRadius: 12.0,
enableHero: enableHeroAnimation,
),
),
Expanded(
@@ -94,10 +92,10 @@ class DocumentGridItem extends StatelessWidget {
}
void _onTap() {
if (isAtLeastOneSelected || isSelected) {
onSelected(document);
if (isSelectionActive || isSelected) {
onSelected?.call(document);
} else {
onTap(document);
onTap?.call(document);
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
abstract class DocumentItem extends StatelessWidget {
final DocumentModel document;
final void Function(DocumentModel)? onTap;
final void Function(DocumentModel)? onSelected;
final bool isSelected;
final bool isSelectionActive;
final bool isLabelClickable;
final bool enableHeroAnimation;
final void Function(int tagId)? onTagSelected;
final void Function(int? correspondentId)? onCorrespondentSelected;
final void Function(int? documentTypeId)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
const DocumentItem({
super.key,
required this.document,
this.onTap,
this.onSelected,
required this.isSelected,
required this.isSelectionActive,
required this.isLabelClickable,
this.onTagSelected,
this.onCorrespondentSelected,
this.onDocumentTypeSelected,
this.onStoragePathSelected,
required this.enableHeroAnimation,
});
}

View File

@@ -1,39 +1,26 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart';
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
class DocumentListItem extends StatelessWidget {
class DocumentListItem extends DocumentItem {
static const _a4AspectRatio = 1 / 1.4142;
final DocumentModel document;
final void Function(DocumentModel)? onTap;
final void Function(DocumentModel)? onSelected;
final bool isSelected;
final bool isAtLeastOneSelected;
final bool isLabelClickable;
final void Function(int tagId)? onTagSelected;
final void Function(int? correspondentId)? onCorrespondentSelected;
final void Function(int? documentTypeId)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
final bool enableHeroAnimation;
const DocumentListItem({
Key? key,
required this.document,
this.onTap,
this.onSelected,
this.isSelected = false,
this.isAtLeastOneSelected = false,
this.isLabelClickable = true,
this.onTagSelected,
this.onCorrespondentSelected,
this.onDocumentTypeSelected,
this.onStoragePathSelected,
this.enableHeroAnimation = true,
}) : super(key: key);
super.key,
required super.document,
required super.isSelected,
required super.isSelectionActive,
required super.isLabelClickable,
super.onCorrespondentSelected,
super.onDocumentTypeSelected,
super.onSelected,
super.onStoragePathSelected,
super.onTagSelected,
super.onTap,
super.enableHeroAnimation = true,
});
@override
Widget build(BuildContext context) {
@@ -50,7 +37,7 @@ class DocumentListItem extends StatelessWidget {
Row(
children: [
AbsorbPointer(
absorbing: isAtLeastOneSelected,
absorbing: isSelectionActive,
child: CorrespondentWidget(
isClickable: isLabelClickable,
correspondentId: document.correspondent,
@@ -69,7 +56,7 @@ class DocumentListItem extends StatelessWidget {
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: AbsorbPointer(
absorbing: isAtLeastOneSelected,
absorbing: isSelectionActive,
child: TagsWidget(
isClickable: isLabelClickable,
tagIds: document.tags,
@@ -95,7 +82,7 @@ class DocumentListItem extends StatelessWidget {
}
void _onTap() {
if (isAtLeastOneSelected || isSelected) {
if (isSelectionActive || isSelected) {
onSelected?.call(document);
} else {
onTap?.call(document);

View File

@@ -1,114 +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/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 DocumentsState state;
final ViewType viewType;
final Widget? beforeItems;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final ScrollController scrollController;
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,
this.beforeItems,
required this.viewType,
});
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(child: beforeItems),
if (viewType == ViewType.list) _buildListView() else _buildGridView(),
if (state.hasLoaded && state.isLoading)
SliverToBoxAdapter(child: pageLoadingWidget),
],
);
}
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,
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
),
);
},
),
);
}
Widget _buildGridView() {
return SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
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,
);
},
);
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.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 'text_query_form_field.dart';
class DocumentFilterForm extends StatefulWidget {
static const fkCorrespondent = DocumentModel.correspondentKey;
static const fkDocumentType = DocumentModel.documentTypeKey;
static const fkStoragePath = DocumentModel.storagePathKey;
static const fkQuery = "query";
static const fkCreatedAt = DocumentModel.createdKey;
static const fkAddedAt = DocumentModel.addedKey;
static DocumentFilter assembleFilter(
GlobalKey<FormBuilderState> formKey, DocumentFilter initialFilter) {
formKey.currentState?.save();
final v = formKey.currentState!.value;
return DocumentFilter(
correspondent:
v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ??
DocumentFilter.initial.correspondent,
documentType: v[DocumentFilterForm.fkDocumentType] as IdQueryParameter? ??
DocumentFilter.initial.documentType,
storagePath: v[DocumentFilterForm.fkStoragePath] as IdQueryParameter? ??
DocumentFilter.initial.storagePath,
tags:
v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags,
query: v[DocumentFilterForm.fkQuery] as TextQuery? ??
DocumentFilter.initial.query,
created: (v[DocumentFilterForm.fkCreatedAt] as DateRangeQuery),
added: (v[DocumentFilterForm.fkAddedAt] as DateRangeQuery),
asnQuery: initialFilter.asnQuery,
page: 1,
pageSize: initialFilter.pageSize,
sortField: initialFilter.sortField,
sortOrder: initialFilter.sortOrder,
);
}
final Widget? header;
final GlobalKey<FormBuilderState> formKey;
final DocumentFilter initialFilter;
final ScrollController? scrollController;
final EdgeInsets padding;
const DocumentFilterForm({
super.key,
this.header,
required this.formKey,
required this.initialFilter,
this.scrollController,
this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
});
@override
State<DocumentFilterForm> createState() => _DocumentFilterFormState();
}
class _DocumentFilterFormState extends State<DocumentFilterForm> {
late bool _allowOnlyExtendedQuery;
@override
void initState() {
super.initState();
_allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery;
}
@override
Widget build(BuildContext context) {
return FormBuilder(
key: widget.formKey,
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
if (widget.header != null) widget.header!,
..._buildFormFieldList(),
SliverToBoxAdapter(
child: SizedBox(
height: 32,
),
),
],
),
);
}
List<Widget> _buildFormFieldList() {
return [
_buildQueryFormField(),
Align(
alignment: Alignment.centerLeft,
child: Text(
S.of(context).documentFilterAdvancedLabel,
style: Theme.of(context).textTheme.bodySmall,
),
),
FormBuilderExtendedDateRangePicker(
name: DocumentFilterForm.fkCreatedAt,
initialValue: widget.initialFilter.created,
labelText: S.of(context).documentCreatedPropertyLabel,
onChanged: (_) {
_checkQueryConstraints();
},
),
FormBuilderExtendedDateRangePicker(
name: DocumentFilterForm.fkAddedAt,
initialValue: widget.initialFilter.added,
labelText: S.of(context).documentAddedPropertyLabel,
onChanged: (_) {
_checkQueryConstraints();
},
),
_buildCorrespondentFormField(),
_buildDocumentTypeFormField(),
_buildStoragePathFormField(),
_buildTagsFormField(),
]
.map((w) => SliverPadding(
padding: widget.padding,
sliver: SliverToBoxAdapter(child: w),
))
.toList();
}
void _checkQueryConstraints() {
final filter =
DocumentFilterForm.assembleFilter(widget.formKey, widget.initialFilter);
if (filter.forceExtendedQuery) {
setState(() => _allowOnlyExtendedQuery = true);
final queryField =
widget.formKey.currentState?.fields[DocumentFilterForm.fkQuery];
queryField?.didChange(
(queryField.value as TextQuery?)
?.copyWith(queryType: QueryType.extended),
);
} else {
setState(() => _allowOnlyExtendedQuery = false);
}
}
Widget _buildDocumentTypeFormField() {
return BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
builder: (context, state) {
return LabelFormField<DocumentType>(
formBuilderState: widget.formKey.currentState,
name: DocumentFilterForm.fkDocumentType,
labelOptions: state.labels,
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
initialValue: widget.initialFilter.documentType,
prefixIcon: const Icon(Icons.description_outlined),
);
},
);
}
Widget _buildCorrespondentFormField() {
return BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent>(
formBuilderState: widget.formKey.currentState,
name: DocumentFilterForm.fkCorrespondent,
labelOptions: state.labels,
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
initialValue: widget.initialFilter.correspondent,
prefixIcon: const Icon(Icons.person_outline),
);
},
);
}
Widget _buildStoragePathFormField() {
return BlocBuilder<LabelCubit<StoragePath>, LabelState<StoragePath>>(
builder: (context, state) {
return LabelFormField<StoragePath>(
formBuilderState: widget.formKey.currentState,
name: DocumentFilterForm.fkStoragePath,
labelOptions: state.labels,
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
initialValue: widget.initialFilter.storagePath,
prefixIcon: const Icon(Icons.folder_outlined),
);
},
);
}
Widget _buildQueryFormField() {
return TextQueryFormField(
name: DocumentFilterForm.fkQuery,
onlyExtendedQueryAllowed: _allowOnlyExtendedQuery,
initialValue: widget.initialFilter.query,
);
}
BlocBuilder<LabelCubit<Tag>, LabelState<Tag>> _buildTagsFormField() {
return BlocBuilder<LabelCubit<Tag>, LabelState<Tag>>(
builder: (context, state) {
return TagFormField(
name: DocumentModel.tagsKey,
initialValue: widget.initialFilter.tags,
allowCreation: false,
selectableOptions: state.labels,
);
},
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/text_query_form_field.dart';
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
@@ -32,22 +33,14 @@ class DocumentFilterPanel extends StatefulWidget {
}
class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
static const fkCorrespondent = DocumentModel.correspondentKey;
static const fkDocumentType = DocumentModel.documentTypeKey;
static const fkStoragePath = DocumentModel.storagePathKey;
static const fkQuery = "query";
static const fkCreatedAt = DocumentModel.createdKey;
static const fkAddedAt = DocumentModel.addedKey;
final _formKey = GlobalKey<FormBuilderState>();
late bool _allowOnlyExtendedQuery;
double _heightAnimationValue = 0;
@override
void initState() {
super.initState();
_allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery;
widget.draggableSheetController.addListener(animateTitleByDrag);
}
@@ -106,19 +99,18 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
),
),
resizeToAvoidBottomInset: true,
body: FormBuilder(
key: _formKey,
child: _buildFormList(context),
body: DocumentFilterForm(
formKey: _formKey,
scrollController: widget.scrollController,
initialFilter: widget.initialFilter,
header: _buildPanelHeader(),
),
),
);
}
Widget _buildFormList(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
slivers: [
SliverAppBar(
Widget _buildPanelHeader() {
return SliverAppBar(
pinned: true,
automaticallyImplyLeading: false,
toolbarHeight: kToolbarHeight + 22,
@@ -131,7 +123,7 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
Opacity(
opacity: 1 - _heightAnimationValue,
child: Padding(
padding: EdgeInsets.only(bottom: 11),
padding: const EdgeInsets.only(bottom: 11),
child: _buildDragHandle(),
),
),
@@ -148,8 +140,7 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
),
),
Padding(
padding:
EdgeInsets.only(left: _heightAnimationValue * 48),
padding: EdgeInsets.only(left: _heightAnimationValue * 48),
child: Text(S.of(context).documentFilterTitle),
),
],
@@ -158,48 +149,9 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
],
),
),
),
..._buildFormFieldList(),
],
);
}
List<Widget> _buildFormFieldList() {
return [
_buildQueryFormField().paddedSymmetrically(vertical: 8, horizontal: 16),
Align(
alignment: Alignment.centerLeft,
child: Text(
S.of(context).documentFilterAdvancedLabel,
style: Theme.of(context).textTheme.bodySmall,
),
).paddedSymmetrically(vertical: 8, horizontal: 16),
FormBuilderExtendedDateRangePicker(
name: fkCreatedAt,
initialValue: widget.initialFilter.created,
labelText: S.of(context).documentCreatedPropertyLabel,
onChanged: (_) {
_checkQueryConstraints();
},
).paddedSymmetrically(vertical: 8, horizontal: 16),
FormBuilderExtendedDateRangePicker(
name: fkAddedAt,
initialValue: widget.initialFilter.added,
labelText: S.of(context).documentAddedPropertyLabel,
onChanged: (_) {
_checkQueryConstraints();
},
).paddedSymmetrically(vertical: 8, horizontal: 16),
_buildCorrespondentFormField()
.paddedSymmetrically(vertical: 8, horizontal: 16),
_buildDocumentTypeFormField()
.paddedSymmetrically(vertical: 8, horizontal: 16),
_buildStoragePathFormField()
.paddedSymmetrically(vertical: 8, horizontal: 16),
_buildTagsFormField().padded(16),
].map((w) => SliverToBoxAdapter(child: w)).toList();
}
Container _buildDragHandle() {
return Container(
// According to m3 spec https://m3.material.io/components/bottom-sheets/specs
@@ -212,19 +164,6 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
);
}
BlocBuilder<LabelCubit<Tag>, LabelState<Tag>> _buildTagsFormField() {
return BlocBuilder<LabelCubit<Tag>, LabelState<Tag>>(
builder: (context, state) {
return TagFormField(
name: DocumentModel.tagsKey,
initialValue: widget.initialFilter.tags,
allowCreation: false,
selectableOptions: state.labels,
);
},
);
}
void _resetFilter() async {
FocusScope.of(context).unfocus();
Navigator.pop(
@@ -233,102 +172,13 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
);
}
Widget _buildDocumentTypeFormField() {
return BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
builder: (context, state) {
return LabelFormField<DocumentType>(
formBuilderState: _formKey.currentState,
name: fkDocumentType,
labelOptions: state.labels,
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
initialValue: widget.initialFilter.documentType,
prefixIcon: const Icon(Icons.description_outlined),
);
},
);
}
Widget _buildCorrespondentFormField() {
return BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent>(
formBuilderState: _formKey.currentState,
name: fkCorrespondent,
labelOptions: state.labels,
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
initialValue: widget.initialFilter.correspondent,
prefixIcon: const Icon(Icons.person_outline),
);
},
);
}
Widget _buildStoragePathFormField() {
return BlocBuilder<LabelCubit<StoragePath>, LabelState<StoragePath>>(
builder: (context, state) {
return LabelFormField<StoragePath>(
formBuilderState: _formKey.currentState,
name: fkStoragePath,
labelOptions: state.labels,
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
initialValue: widget.initialFilter.storagePath,
prefixIcon: const Icon(Icons.folder_outlined),
);
},
);
}
Widget _buildQueryFormField() {
return TextQueryFormField(
name: fkQuery,
onlyExtendedQueryAllowed: _allowOnlyExtendedQuery,
initialValue: widget.initialFilter.query,
);
}
void _onApplyFilter() async {
_formKey.currentState?.save();
if (_formKey.currentState?.validate() ?? false) {
DocumentFilter newFilter = _assembleFilter();
DocumentFilter newFilter =
DocumentFilterForm.assembleFilter(_formKey, widget.initialFilter);
FocusScope.of(context).unfocus();
Navigator.pop(context, DocumentFilterIntent(filter: newFilter));
}
}
DocumentFilter _assembleFilter() {
_formKey.currentState?.save();
final v = _formKey.currentState!.value;
return DocumentFilter(
correspondent: v[fkCorrespondent] as IdQueryParameter? ??
DocumentFilter.initial.correspondent,
documentType: v[fkDocumentType] as IdQueryParameter? ??
DocumentFilter.initial.documentType,
storagePath: v[fkStoragePath] as IdQueryParameter? ??
DocumentFilter.initial.storagePath,
tags:
v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags,
query: v[fkQuery] as TextQuery? ?? DocumentFilter.initial.query,
created: (v[fkCreatedAt] as DateRangeQuery),
added: (v[fkAddedAt] as DateRangeQuery),
asnQuery: widget.initialFilter.asnQuery,
page: 1,
pageSize: widget.initialFilter.pageSize,
sortField: widget.initialFilter.sortField,
sortOrder: widget.initialFilter.sortOrder,
);
}
void _checkQueryConstraints() {
final filter = _assembleFilter();
if (filter.forceExtendedQuery) {
setState(() => _allowOnlyExtendedQuery = true);
final queryField = _formKey.currentState?.fields[fkQuery];
queryField?.didChange(
(queryField.value as TextQuery?)
?.copyWith(queryType: QueryType.extended),
);
} else {
setState(() => _allowOnlyExtendedQuery = false);
}
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
class ViewActions extends StatelessWidget {
const ViewActions({super.key});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortDocumentsButton(),
BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settings) {
final cubit = context.read<ApplicationSettingsCubit>();
switch (settings.preferredViewType) {
case ViewType.grid:
return IconButton(
icon: const Icon(Icons.list),
onPressed: () =>
cubit.setViewType(settings.preferredViewType.toggle()),
);
case ViewType.list:
return IconButton(
icon: const Icon(Icons.grid_view_rounded),
onPressed: () =>
cubit.setViewType(settings.preferredViewType.toggle()),
);
}
},
)
],
);
}
}

View File

@@ -3,7 +3,7 @@ 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/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.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/list/document_list_item.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart';
import 'package:paperless_mobile/features/linked_documents/bloc/linked_documents_cubit.dart';
import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.dart';
import 'package:paperless_mobile/generated/l10n.dart';
@@ -48,14 +50,17 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
),
body: BlocBuilder<LinkedDocumentsCubit, LinkedDocumentsState>(
builder: (context, state) {
if (!state.hasLoaded) {
return const DocumentsListLoadingWidget();
}
return ListView.builder(
itemCount: state.documents.length,
itemBuilder: (context, index) => DocumentListItem(
document: state.documents[index],
),
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivity) {
return DefaultAdaptiveDocumentsView(
scrollController: _scrollController,
documents: state.documents,
hasInternetConnection: connectivity.isConnected,
isLabelClickable: false,
isLoading: state.isLoading,
hasLoaded: state.hasLoaded,
);
},
);
},
),

View File

@@ -0,0 +1,20 @@
import 'package:bloc/bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
part 'saved_view_details_state.dart';
class SavedViewDetailsCubit extends Cubit<SavedViewDetailsState>
with PagedDocumentsMixin {
@override
final PaperlessDocumentsApi api;
final SavedView savedView;
SavedViewDetailsCubit(
this.api, {
required this.savedView,
}) : super(const SavedViewDetailsState()) {
updateFilter(filter: savedView.toDocumentFilter());
}
}

View File

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

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart';
import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
@@ -17,21 +20,13 @@ class _AddSavedViewPageState extends State<AddSavedViewPage> {
static const fkShowOnDashboard = 'show_on_dashboard';
static const fkShowInSidebar = 'show_in_sidebar';
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
final _savedViewFormKey = GlobalKey<FormBuilderState>();
final _filterFormKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).savedViewCreateNewLabel),
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Tooltip(
child: const Icon(Icons.info_outline),
message: S.of(context).savedViewCreateTooltipText,
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
@@ -40,8 +35,60 @@ class _AddSavedViewPageState extends State<AddSavedViewPage> {
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: FormBuilder(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
FormBuilder(
key: _savedViewFormKey,
child: Column(
children: [
FormBuilderTextField(
name: _AddSavedViewPageState.fkName,
validator: FormBuilderValidators.required(),
decoration: InputDecoration(
label: Text(S.of(context).savedViewNameLabel),
),
),
FormBuilderCheckbox(
name: _AddSavedViewPageState.fkShowOnDashboard,
initialValue: false,
title: Text(S.of(context).savedViewShowOnDashboardLabel),
),
FormBuilderCheckbox(
name: _AddSavedViewPageState.fkShowInSidebar,
initialValue: false,
title: Text(S.of(context).savedViewShowInSidebarLabel),
),
],
),
),
Divider(),
Text(
"Review filter",
style: Theme.of(context).textTheme.bodyLarge,
).padded(),
Flexible(
child: DocumentFilterForm(
padding: const EdgeInsets.symmetric(vertical: 8),
formKey: _filterFormKey,
initialFilter: widget.currentFilter,
),
),
],
),
),
);
}
Padding _buildOld(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
FormBuilder(
key: _savedViewFormKey,
child: Expanded(
child: ListView(
children: [
FormBuilderTextField(
@@ -65,19 +112,25 @@ class _AddSavedViewPageState extends State<AddSavedViewPage> {
),
),
),
],
),
);
}
void _onCreate(BuildContext context) {
if (_formKey.currentState?.saveAndValidate() ?? false) {
if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) {
Navigator.pop(
context,
SavedView.fromDocumentFilter(
DocumentFilterForm.assembleFilter(
_filterFormKey,
widget.currentFilter,
name: _formKey.currentState?.value[fkName] as String,
),
name: _savedViewFormKey.currentState?.value[fkName] as String,
showOnDashboard:
_formKey.currentState?.value[fkShowOnDashboard] as bool,
showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool,
_savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool,
showInSidebar:
_savedViewFormKey.currentState?.value[fkShowInSidebar] as bool,
),
);
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_details_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_page.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class SavedViewList extends StatelessWidget {
const SavedViewList({super.key});
@override
Widget build(BuildContext context) {
final savedViewCubit = context.read<SavedViewCubit>();
return BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
if (state.value.isEmpty) {
return Text(
S.of(context).savedViewsEmptyStateText,
textAlign: TextAlign.center,
).padded();
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final view = state.value.values.elementAt(index);
return ListTile(
title: Text(view.name),
subtitle: Text(
"${view.filterRules.length} filter(s) set"), //TODO: INTL w/ placeholder
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SavedViewDetailsCubit(
context.read(),
savedView: view,
),
),
BlocProvider.value(value: savedViewCubit),
],
child: SavedViewPage(
onDelete: savedViewCubit.remove,
),
),
),
);
},
);
},
childCount: state.value.length,
),
);
},
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/view_actions.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_details_cubit.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class SavedViewPage extends StatefulWidget {
final Future<void> Function(SavedView savedView) onDelete;
const SavedViewPage({
super.key,
required this.onDelete,
});
@override
State<SavedViewPage> createState() => _SavedViewPageState();
}
class _SavedViewPageState extends State<SavedViewPage> {
final _scrollController = ScrollController();
ViewType _viewType = ViewType.list;
SavedView get _savedView => context.read<SavedViewDetailsCubit>().savedView;
@override
void initState() {
super.initState();
_scrollController.addListener(_listenForLoadNewData);
}
void _listenForLoadNewData() async {
final currState = context.read<SavedViewDetailsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.7 &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
try {
await context.read<SavedViewDetailsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: BlocBuilder<SavedViewDetailsCubit, SavedViewDetailsState>(
builder: (context, state) {
return Text(_savedView.name);
},
),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) =>
ConfirmDeleteSavedViewDialog(view: _savedView),
) ??
false;
if (shouldDelete) {
await widget.onDelete(_savedView);
Navigator.pop(context);
}
},
),
IconButton(
icon: Icon(
_viewType == ViewType.list ? Icons.grid_view_rounded : Icons.list,
),
onPressed: () => setState(() => _viewType = _viewType.toggle()),
),
],
),
body: BlocBuilder<SavedViewDetailsCubit, SavedViewDetailsState>(
builder: (context, state) {
if (state.hasLoaded && state.documents.isEmpty) {
return DocumentsEmptyState(state: state);
}
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivity) {
return CustomScrollView(
controller: _scrollController,
slivers: [
SliverAdaptiveDocumentsView(
documents: state.documents,
hasInternetConnection: connectivity.isConnected,
isLabelClickable: false,
isLoading: state.isLoading,
hasLoaded: state.hasLoaded,
onTap: _onOpenDocumentDetails,
viewType: _viewType,
),
],
);
},
);
},
),
);
}
void _onOpenDocumentDetails(DocumentModel document) async {
final updatedDocument = await Navigator.push<DocumentModel>(
context,
MaterialPageRoute(
builder: (_) => BlocProvider(
create: (context) => DocumentDetailsCubit(
context.read<PaperlessDocumentsApi>(),
document,
),
child: const LabelRepositoriesProvider(
child: DocumentDetailsPage(),
),
),
),
);
if (updatedDocument != document) {
// Reload in case document was edited and might not fulfill filter criteria of saved view anymore
context.read<SavedViewDetailsCubit>().reload();
}
}
}

View File

@@ -1,218 +1,218 @@
import 'dart:math';
// import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart';
import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:shimmer/shimmer.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_bloc/flutter_bloc.dart';
// import 'package:paperless_api/paperless_api.dart';
// import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
// import 'package:paperless_mobile/extensions/flutter_extensions.dart';
// import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
// import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
// import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
// import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
// import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart';
// import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
// import 'package:paperless_mobile/generated/l10n.dart';
// import 'package:paperless_mobile/helpers/message_helpers.dart';
// import 'package:paperless_mobile/constants.dart';
// import 'package:shimmer/shimmer.dart';
class SavedViewSelectionWidget extends StatelessWidget {
final DocumentFilter currentFilter;
const SavedViewSelectionWidget({
Key? key,
required this.height,
required this.enabled,
required this.currentFilter,
}) : super(key: key);
// class SavedViewSelectionWidget extends StatelessWidget {
// final DocumentFilter currentFilter;
// const SavedViewSelectionWidget({
// Key? key,
// required this.height,
// required this.enabled,
// required this.currentFilter,
// }) : super(key: key);
final double height;
final bool enabled;
// final double height;
// final bool enabled;
@override
Widget build(BuildContext context) {
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
final hasInternetConnection = connectivityState.isConnected;
return SizedBox(
height: height,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
if (!state.hasLoaded) {
return _buildLoadingWidget(context);
}
if (state.value.isEmpty) {
return Text(S.of(context).savedViewsEmptyStateText);
}
return SizedBox(
height: 38,
child: ListView.separated(
itemCount: state.value.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final view = state.value.values.elementAt(index);
return GestureDetector(
onLongPress: hasInternetConnection
? () => _onDelete(context, view)
: null,
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, docState) {
final view = state.value.values.toList()[index];
return FilterChip(
label: Text(
view.name,
),
selected:
view.id == docState.selectedSavedViewId,
onSelected: enabled && hasInternetConnection
? (isSelected) =>
_onSelected(isSelected, context, view)
: null,
);
},
),
);
},
separatorBuilder: (context, index) => const SizedBox(
width: 4.0,
),
),
);
},
),
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).savedViewsLabel,
style: Theme.of(context).textTheme.titleSmall,
),
BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) =>
previous.filter != current.filter,
builder: (context, docState) {
return TextButton.icon(
icon: const Icon(Icons.add),
onPressed: (enabled &&
state.hasLoaded &&
hasInternetConnection)
? () =>
_onCreatePressed(context, docState.filter)
: null,
label: Text(S.of(context).savedViewCreateNewLabel),
);
},
),
],
);
},
),
],
).padded(),
);
},
);
}
// @override
// Widget build(BuildContext context) {
// return BlocBuilder<ConnectivityCubit, ConnectivityState>(
// builder: (context, connectivityState) {
// final hasInternetConnection = connectivityState.isConnected;
// return SizedBox(
// height: height,
// child: Column(
// mainAxisAlignment: MainAxisAlignment.start,
// crossAxisAlignment: CrossAxisAlignment.start,
// mainAxisSize: MainAxisSize.min,
// children: [
// BlocBuilder<SavedViewCubit, SavedViewState>(
// builder: (context, state) {
// if (!state.hasLoaded) {
// return _buildLoadingWidget(context);
// }
// if (state.value.isEmpty) {
// return Text(S.of(context).savedViewsEmptyStateText);
// }
// return SizedBox(
// height: 38,
// child: ListView.separated(
// itemCount: state.value.length,
// scrollDirection: Axis.horizontal,
// itemBuilder: (context, index) {
// final view = state.value.values.elementAt(index);
// return GestureDetector(
// onLongPress: hasInternetConnection
// ? () => _onDelete(context, view)
// : null,
// child: BlocBuilder<DocumentsCubit, DocumentsState>(
// builder: (context, docState) {
// final view = state.value.values.toList()[index];
// return FilterChip(
// label: Text(
// view.name,
// ),
// selected:
// view.id == docState.selectedSavedViewId,
// onSelected: enabled && hasInternetConnection
// ? (isSelected) =>
// _onSelected(isSelected, context, view)
// : null,
// );
// },
// ),
// );
// },
// separatorBuilder: (context, index) => const SizedBox(
// width: 4.0,
// ),
// ),
// );
// },
// ),
// BlocBuilder<SavedViewCubit, SavedViewState>(
// builder: (context, state) {
// return Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// children: [
// Text(
// S.of(context).savedViewsLabel,
// style: Theme.of(context).textTheme.titleSmall,
// ),
// BlocBuilder<DocumentsCubit, DocumentsState>(
// buildWhen: (previous, current) =>
// previous.filter != current.filter,
// builder: (context, docState) {
// return TextButton.icon(
// icon: const Icon(Icons.add),
// onPressed: (enabled &&
// state.hasLoaded &&
// hasInternetConnection)
// ? () =>
// _onCreatePressed(context, docState.filter)
// : null,
// label: Text(S.of(context).savedViewCreateNewLabel),
// );
// },
// ),
// ],
// );
// },
// ),
// ],
// ).padded(),
// );
// },
// );
// }
Widget _buildLoadingWidget(BuildContext context) {
return SizedBox(
height: 38,
width: MediaQuery.of(context).size.width,
child: Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]!
: Colors.grey[900]!,
highlightColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]!
: Colors.grey[600]!,
child: ListView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
children: [
FilterChip(
label: const SizedBox(width: 32),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 64),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 100),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 32),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 48),
onSelected: (_) {},
),
],
),
),
);
}
// Widget _buildLoadingWidget(BuildContext context) {
// return SizedBox(
// height: 38,
// width: MediaQuery.of(context).size.width,
// child: Shimmer.fromColors(
// baseColor: Theme.of(context).brightness == Brightness.light
// ? Colors.grey[300]!
// : Colors.grey[900]!,
// highlightColor: Theme.of(context).brightness == Brightness.light
// ? Colors.grey[100]!
// : Colors.grey[600]!,
// child: ListView(
// scrollDirection: Axis.horizontal,
// physics: const NeverScrollableScrollPhysics(),
// children: [
// FilterChip(
// label: const SizedBox(width: 32),
// onSelected: (_) {},
// ),
// const SizedBox(width: 4.0),
// FilterChip(
// label: const SizedBox(width: 64),
// onSelected: (_) {},
// ),
// const SizedBox(width: 4.0),
// FilterChip(
// label: const SizedBox(width: 100),
// onSelected: (_) {},
// ),
// const SizedBox(width: 4.0),
// FilterChip(
// label: const SizedBox(width: 32),
// onSelected: (_) {},
// ),
// const SizedBox(width: 4.0),
// FilterChip(
// label: const SizedBox(width: 48),
// onSelected: (_) {},
// ),
// ],
// ),
// ),
// );
// }
void _onCreatePressed(BuildContext context, DocumentFilter filter) async {
final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (context) => AddSavedViewPage(
currentFilter: filter,
),
),
);
if (newView != null) {
try {
await context.read<SavedViewCubit>().add(newView);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
// void _onCreatePressed(BuildContext context, DocumentFilter filter) async {
// final newView = await Navigator.of(context).push<SavedView?>(
// MaterialPageRoute(
// builder: (context) => AddSavedViewPage(
// currentFilter: filter,
// ),
// ),
// );
// if (newView != null) {
// try {
// await context.read<SavedViewCubit>().add(newView);
// } on PaperlessServerException catch (error, stackTrace) {
// showErrorMessage(context, error, stackTrace);
// }
// }
// }
void _onSelected(
bool selectionIntent,
BuildContext context,
SavedView view,
) async {
if (selectionIntent) {
context.read<DocumentsCubit>().selectView(view.id!);
} else {
context.read<DocumentsCubit>().unselectView();
context.read<DocumentsCubit>().resetFilter();
}
}
// void _onSelected(
// bool selectionIntent,
// BuildContext context,
// SavedView view,
// ) async {
// if (selectionIntent) {
// context.read<DocumentsCubit>().selectView(view.id!);
// } else {
// context.read<DocumentsCubit>().unselectView();
// context.read<DocumentsCubit>().resetFilter();
// }
// }
void _onDelete(BuildContext context, SavedView view) async {
{
final delete = await showDialog<bool>(
context: context,
builder: (context) => ConfirmDeleteSavedViewDialog(view: view),
) ??
false;
if (delete) {
try {
context.read<SavedViewCubit>().remove(view);
if (context.read<DocumentsCubit>().state.selectedSavedViewId ==
view.id) {
await context.read<DocumentsCubit>().resetFilter();
}
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
}
}
// void _onDelete(BuildContext context, SavedView view) async {
// {
// final delete = await showDialog<bool>(
// context: context,
// builder: (context) => ConfirmDeleteSavedViewDialog(view: view),
// ) ??
// false;
// if (delete) {
// try {
// context.read<SavedViewCubit>().remove(view);
// if (context.read<DocumentsCubit>().state.selectedSavedViewId ==
// view.id) {
// await context.read<DocumentsCubit>().resetFilter();
// }
// } on PaperlessServerException catch (error, stackTrace) {
// showErrorMessage(context, error, stackTrace);
// }
// }
// }
// }
// }

View File

@@ -1,13 +1,10 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
import 'package:paperless_mobile/features/search/cubit/document_search_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/search/cubit/document_search_state.dart';
import 'package:paperless_mobile/generated/l10n.dart';
Future<void> showDocumentSearchPage(BuildContext context) {
@@ -48,28 +45,33 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintStyle: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurfaceVariant,
),
hintText: "Search documents",
hintText: "Search documents", //TODO: INTL
border: InputBorder.none,
),
controller: _queryController,
onChanged: context.read<DocumentSearchCubit>().suggest,
onSubmitted: context.read<DocumentSearchCubit>().search,
textInputAction: TextInputAction.search,
onSubmitted: (query) {
FocusScope.of(context).unfocus();
context.read<DocumentSearchCubit>().search(query);
},
),
actions: [
IconButton(
color: theme.colorScheme.onSurfaceVariant,
icon: Icon(Icons.clear),
icon: const Icon(Icons.clear),
onPressed: () {
context.read<DocumentSearchCubit>().reset();
_queryController.clear();
},
)
).padded(),
],
bottom: PreferredSize(
preferredSize: Size.fromHeight(1),
preferredSize: const Size.fromHeight(1),
child: Divider(
color: theme.colorScheme.outline,
),
@@ -103,7 +105,7 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text(historyMatches[index]),
leading: Icon(Icons.history),
leading: const Icon(Icons.history),
onTap: () => _selectSuggestion(historyMatches[index]),
),
childCount: historyMatches.length,
@@ -120,7 +122,7 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text(suggestions[index]),
leading: Icon(Icons.search),
leading: const Icon(Icons.search),
onTap: () => _selectSuggestion(suggestions[index]),
),
childCount: suggestions.length,
@@ -135,27 +137,21 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
S.of(context).documentSearchResults,
style: Theme.of(context).textTheme.labelSmall,
).padded();
if (state.isLoading) {
return DocumentsListLoadingWidget(
beforeWidgets: [header],
);
}
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: header),
if (state.hasLoaded && !state.isLoading && state.documents.isEmpty)
SliverToBoxAdapter(
child: Center(child: Text("No documents found.")),
const SliverToBoxAdapter(
child: Center(child: Text("No documents found.")), //TODO: INTL
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => DocumentListItem(
document: state.documents[index],
),
childCount: state.documents.length,
),
),
SliverAdaptiveDocumentsView(
documents: state.documents,
hasInternetConnection: true,
isLabelClickable: false,
isLoading: state.isLoading,
hasLoaded: state.hasLoaded,
)
],
);
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
typedef OpenSearchCallback = void Function(BuildContext context);
class SearchAppBar extends StatefulWidget with PreferredSizeWidget {
final PreferredSizeWidget? bottom;
final OpenSearchCallback onOpenSearch;
final Color? backgroundColor;
const SearchAppBar({
super.key,
required this.onOpenSearch,
this.bottom,
this.backgroundColor,
});
@override
State<SearchAppBar> createState() => _SearchAppBarState();
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _SearchAppBarState extends State<SearchAppBar> {
@override
Widget build(BuildContext context) {
return SliverAppBar(
floating: true,
pinned: true,
snap: true,
backgroundColor: widget.backgroundColor,
title: SearchBar(
height: kToolbarHeight - 8,
supportingText: "Search documents",
onTap: () => widget.onOpenSearch(context),
leadingIcon: IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
trailingIcon: IconButton(
icon: const CircleAvatar(
child: Text("A"),
),
onPressed: () {},
),
).paddedOnly(top: 4, bottom: 4),
bottom: widget.bottom,
);
}
}