feat: Migrations, new saved views interface

This commit is contained in:
Anton Stubenbord
2023-09-19 01:50:02 +02:00
parent 2e8144700f
commit f3560f00ea
31 changed files with 1745 additions and 376 deletions

View File

@@ -1,17 +1,11 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/widgets/paperless_logo.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/settings/view/settings_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AppDrawer extends StatelessWidget {
@@ -61,27 +55,40 @@ class AppDrawer extends StatelessWidget {
),
ListTile(
dense: true,
leading: Padding(
padding: const EdgeInsets.only(left: 3),
child: SvgPicture.asset(
'assets/images/bmc-logo.svg',
width: 24,
height: 24,
),
leading: const Icon(Icons.favorite_outline),
title: Text(S.of(context)!.donate),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.favorite),
title: Text(S.of(context)!.donate),
content: Text(
S.of(context)!.donationDialogContent,
),
actions: const [
Text("~ Anton"),
],
),
);
},
),
ListTile(
dense: true,
leading: SvgPicture.asset(
"assets/images/github-mark.svg",
color: Theme.of(context).colorScheme.onBackground,
height: 24,
width: 24,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context)!.donateCoffee),
const Icon(
Icons.open_in_new,
size: 16,
)
],
title: Text(S.of(context)!.sourceCode),
trailing: const Icon(
Icons.open_in_new,
size: 16,
),
onTap: () {
launchUrlString(
"https://www.buymeacoffee.com/astubenbord",
"https://github.com/astubenbord/paperless-mobile",
mode: LaunchMode.externalApplication,
);
},

View File

@@ -1,6 +1,7 @@
import 'package:badges/badges.dart' as b;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
@@ -13,6 +14,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/documents_empty
import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_views_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
@@ -45,29 +47,13 @@ class _DocumentsPageState extends State<DocumentsPage>
with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle savedViewsHandle =
SliverOverlapAbsorberHandle();
late final TabController _tabController;
int _currentTab = 0;
bool get hasSelectedViewChanged {
final cubit = context.watch<DocumentsCubit>();
final savedViewCubit = context.watch<SavedViewCubit>();
final activeView = savedViewCubit.state.maybeMap(
loaded: (state) {
if (cubit.state.filter.selectedView != null) {
return state.savedViews[cubit.state.filter.selectedView!];
}
return null;
},
orElse: () => null,
);
final viewHasChanged = activeView != null &&
activeView.toDocumentFilter() != cubit.state.filter;
return viewHasChanged;
}
final _savedViewsExpansionController = ExpansionTileController();
@override
void initState() {
super.initState();
@@ -177,21 +163,14 @@ class _DocumentsPageState extends State<DocumentsPage>
animationType: b.BadgeAnimationType.fade,
badgeColor: Colors.red,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: (_currentTab == 0)
? FloatingActionButton(
heroTag: "fab_documents_page_filter",
child:
const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
)
: FloatingActionButton(
heroTag: "fab_documents_page_filter",
child: const Icon(Icons.add),
onPressed: () =>
_onCreateSavedView(state.filter),
),
),
duration: const Duration(milliseconds: 250),
child: Builder(builder: (context) {
return FloatingActionButton(
heroTag: "fab_documents_page_filter",
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
);
})),
),
],
),
@@ -236,7 +215,10 @@ class _DocumentsPageState extends State<DocumentsPage>
SliverOverlapAbsorber(
handle: savedViewsHandle,
sliver: SliverPinnedHeader(
child: _buildViewActions(),
child: Material(
child: _buildViewActions(),
elevation: 4,
),
),
),
// SliverOverlapAbsorber(
@@ -288,70 +270,13 @@ class _DocumentsPageState extends State<DocumentsPage>
}
return false;
},
child: TabBarView(
controller: _tabController,
physics: context
.watch<DocumentsCubit>()
.state
.selection
.isNotEmpty
? const NeverScrollableScrollPhysics()
: null,
children: [
Builder(
builder: (context) {
return _buildDocumentsTab(
connectivityState,
context,
);
},
),
if (context
.watch<LocalUserAccount>()
.paperlessUser
.canViewSavedViews)
Builder(
builder: (context) {
return _buildSavedViewsTab(
connectivityState,
context,
);
},
),
],
),
),
),
AnimatedOpacity(
opacity: hasSelectedViewChanged ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.only(bottom: 24),
child: Material(
borderRadius: BorderRadius.circular(24),
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.9),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
onTap: () {},
child: Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
"Update selected view",
style: Theme.of(context).textTheme.labelLarge,
),
),
),
),
child: _buildDocumentsTab(
connectivityState,
context,
),
),
),
_buildSavedViewChangedIndicator(),
],
),
),
@@ -362,29 +287,82 @@ class _DocumentsPageState extends State<DocumentsPage>
);
}
Widget _buildSavedViewsTab(
ConnectivityState connectivityState,
BuildContext context,
) {
return RefreshIndicator(
edgeOffset: kTextTabBarHeight,
onRefresh: _onReloadSavedViews,
notificationPredicate: (_) => connectivityState.isConnected,
child: CustomScrollView(
key: const PageStorageKey<String>("savedViews"),
slivers: [
SliverOverlapInjector(
handle: searchBarHandle,
Widget _buildSavedViewChangedIndicator() {
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
final savedViewCubit = context.watch<SavedViewCubit>();
final activeView = savedViewCubit.state.maybeMap(
loaded: (savedViewState) {
if (state.filter.selectedView != null) {
return savedViewState.savedViews[state.filter.selectedView!];
}
return null;
},
orElse: () => null,
);
final viewHasChanged =
activeView != null && activeView.toDocumentFilter() != state.filter;
return AnimatedScale(
scale: viewHasChanged ? 1 : 0,
alignment: Alignment.bottomCenter,
duration: const Duration(milliseconds: 300),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.only(bottom: 24),
child: Material(
borderRadius: BorderRadius.circular(24),
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.9),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
onTap: () async {
await _updateCurrentSavedView();
setState(() {});
},
child: Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
"Update selected view",
style: Theme.of(context).textTheme.labelLarge,
),
),
),
),
),
),
SliverOverlapInjector(
handle: savedViewsHandle,
),
const SavedViewList(),
],
),
);
},
);
}
// Widget _buildSavedViewsTab(
// ConnectivityState connectivityState,
// BuildContext context,
// ) {
// return RefreshIndicator(
// edgeOffset: kTextTabBarHeight,
// onRefresh: _onReloadSavedViews,
// notificationPredicate: (_) => connectivityState.isConnected,
// child: CustomScrollView(
// key: const PageStorageKey<String>("savedViews"),
// slivers: [
// SliverOverlapInjector(
// handle: searchBarHandle,
// ),
// SliverOverlapInjector(
// handle: savedViewsHandle,
// ),
// const SavedViewList(),
// ],
// ),
// );
// }
Widget _buildDocumentsTab(
ConnectivityState connectivityState,
BuildContext context,
@@ -393,6 +371,10 @@ class _DocumentsPageState extends State<DocumentsPage>
onNotification: (notification) {
// Listen for scroll notifications to load new data.
// Scroll controller does not work here due to nestedscrollview limitations.
final offset = notification.metrics.pixels;
if (offset > 128 && _savedViewsExpansionController.isExpanded) {
_savedViewsExpansionController.collapse();
}
final currState = context.read<DocumentsCubit>().state;
final max = notification.metrics.maxScrollExtent;
@@ -403,7 +385,6 @@ class _DocumentsPageState extends State<DocumentsPage>
return false;
}
final offset = notification.metrics.pixels;
if (offset >= max * 0.7) {
context
.read<DocumentsCubit>()
@@ -420,7 +401,7 @@ class _DocumentsPageState extends State<DocumentsPage>
return false;
},
child: RefreshIndicator(
edgeOffset: kTextTabBarHeight,
edgeOffset: kTextTabBarHeight + 2,
onRefresh: _onReloadDocuments,
notificationPredicate: (_) => connectivityState.isConnected,
child: CustomScrollView(
@@ -434,6 +415,7 @@ class _DocumentsPageState extends State<DocumentsPage>
builder: (context, state) {
return SliverToBoxAdapter(
child: SavedViewsWidget(
controller: _savedViewsExpansionController,
onViewSelected: (view) {
final cubit = context.read<DocumentsCubit>();
if (state.filter.selectedView == view.id) {
@@ -449,6 +431,22 @@ class _DocumentsPageState extends State<DocumentsPage>
showSnackBar(context,
"Saved view successfully updated."); //TODO: INTL
},
onDeleteView: (view) async {
HapticFeedback.mediumImpact();
final shouldRemove = await showDialog(
context: context,
builder: (context) =>
ConfirmDeleteSavedViewDialog(view: view),
);
if (shouldRemove) {
final documentsCubit = context.read<DocumentsCubit>();
context.read<SavedViewCubit>().remove(view);
if (documentsCubit.state.filter.selectedView ==
view.id) {
documentsCubit.resetFilter();
}
}
},
filter: state.filter,
),
);
@@ -722,16 +720,10 @@ class _DocumentsPageState extends State<DocumentsPage>
Future<void> _onReloadDocuments() 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();
} on PaperlessApiException 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();
await Future.wait([
context.read<DocumentsCubit>().reload(),
context.read<SavedViewCubit>().reload(),
]);
} on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
@@ -754,22 +746,37 @@ class _DocumentsPageState extends State<DocumentsPage>
if (viewHasChanged) {
final discardChanges = await showDialog(
context: context,
builder: (context) => SavedViewChangedDialog(),
builder: (context) => const SavedViewChangedDialog(),
);
if (discardChanges == true) {
cubit.resetFilter();
// Reset
} else if (discardChanges == false) {
final newView = activeView.copyWith(
filterRules: FilterRule.fromFilter(cubit.state.filter),
);
final savedViewCubit2 = context.read<SavedViewCubit>();
await savedViewCubit2.update(newView);
showSnackBar(context, "Saved view successfully updated.");
_updateCurrentSavedView();
}
} else {
cubit.resetFilter();
}
}
Future<void> _updateCurrentSavedView() async {
final savedViewCubit = context.read<SavedViewCubit>();
final cubit = context.read<DocumentsCubit>();
final activeView = savedViewCubit.state.maybeMap(
loaded: (state) {
if (cubit.state.filter.selectedView != null) {
return state.savedViews[cubit.state.filter.selectedView!];
}
return null;
},
orElse: () => null,
);
if (activeView == null) return;
final newView = activeView.copyWith(
filterRules: FilterRule.fromFilter(cubit.state.filter),
);
await savedViewCubit.update(newView);
showSnackBar(context, "Saved view successfully updated.");
}
}

View File

@@ -31,92 +31,90 @@ class DocumentListItem extends DocumentItem {
@override
Widget build(BuildContext context) {
final labels = context.watch<LabelRepository>().state;
return Material(
child: ListTile(
tileColor: backgroundColor,
dense: true,
selected: isSelected,
onTap: () => _onTap(),
selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
onLongPress: () => onSelected?.call(document),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Row(
children: [
AbsorbPointer(
absorbing: isSelectionActive,
child: CorrespondentWidget(
isClickable: isLabelClickable,
correspondent: context
.watch<LabelRepository>()
.state
.correspondents[document.correspondent],
onSelected: onCorrespondentSelected,
),
return ListTile(
tileColor: backgroundColor,
dense: true,
selected: isSelected,
onTap: () => _onTap(),
selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
onLongPress: onSelected != null ? () => onSelected!(document) : null,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Row(
children: [
AbsorbPointer(
absorbing: isSelectionActive,
child: CorrespondentWidget(
isClickable: isLabelClickable,
correspondent: context
.watch<LabelRepository>()
.state
.correspondents[document.correspondent],
onSelected: onCorrespondentSelected,
),
],
),
Text(
document.title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
AbsorbPointer(
absorbing: isSelectionActive,
child: TagsWidget(
isClickable: isLabelClickable,
tags: document.tags
.where((e) => labels.tags.containsKey(e))
.map((e) => labels.tags[e]!)
.toList(),
onTagSelected: (id) => onTagSelected?.call(id),
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: RichText(
maxLines: 1,
],
),
Text(
document.title,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: DateFormat.yMMMd().format(document.created),
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.grey),
children: document.documentType != null
? [
const TextSpan(text: '\u30FB'),
TextSpan(
text: labels.documentTypes[document.documentType]?.name,
recognizer: onDocumentTypeSelected != null
? (TapGestureRecognizer()
..onTap = () => onDocumentTypeSelected!(
document.documentType))
: null,
),
]
: null,
maxLines: 1,
),
AbsorbPointer(
absorbing: isSelectionActive,
child: TagsWidget(
isClickable: isLabelClickable,
tags: document.tags
.where((e) => labels.tags.containsKey(e))
.map((e) => labels.tags[e]!)
.toList(),
onTagSelected: (id) => onTagSelected?.call(id),
),
),
),
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: _a4AspectRatio,
child: GestureDetector(
child: DocumentPreview(
document: document,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: enableHeroAnimation,
),
),
),
contentPadding: const EdgeInsets.all(8.0),
],
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: DateFormat.yMMMd().format(document.created),
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.grey),
children: document.documentType != null
? [
const TextSpan(text: '\u30FB'),
TextSpan(
text: labels.documentTypes[document.documentType]?.name,
recognizer: onDocumentTypeSelected != null
? (TapGestureRecognizer()
..onTap = () =>
onDocumentTypeSelected!(document.documentType))
: null,
),
]
: null,
),
),
),
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: _a4AspectRatio,
child: GestureDetector(
child: DocumentPreview(
document: document,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: enableHeroAnimation,
),
),
),
contentPadding: const EdgeInsets.all(8.0),
);
}

View File

@@ -16,12 +16,12 @@ class SavedViewChangedDialog extends StatelessWidget {
actionsOverflowButtonSpacing: 8,
actions: [
const DialogCancelButton(),
TextButton(
child: Text(S.of(context)!.saveChanges),
onPressed: () {
Navigator.pop(context, false);
},
),
// TextButton(
// child: Text(S.of(context)!.saveChanges),
// onPressed: () {
// Navigator.pop(context, false);
// },
// ),
DialogConfirmButton(
label: S.of(context)!.resetFilter,
style: DialogConfirmButtonStyle.danger,

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:paperless_api/paperless_api.dart';
import 'dart:math';
class SavedViewChip extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart';
class SavedViewChip extends StatefulWidget {
final SavedView view;
final void Function(SavedView view) onViewSelected;
final void Function(SavedView vie) onUpdateView;
final void Function(SavedView view) onUpdateView;
final void Function(SavedView view) onDeleteView;
final bool selected;
final bool hasChanged;
@@ -16,28 +21,146 @@ class SavedViewChip extends StatelessWidget {
required this.selected,
required this.hasChanged,
required this.onUpdateView,
required this.onDeleteView,
});
@override
State<SavedViewChip> createState() => _SavedViewChipState();
}
class _SavedViewChipState extends State<SavedViewChip>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_animation = _animationController.drive(Tween(begin: 0, end: 1));
}
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Badge(
smallSize: 12,
alignment: const AlignmentDirectional(1.1, -1.2),
backgroundColor: Colors.red,
isLabelVisible: hasChanged,
child: FilterChip(
avatar: Icon(
Icons.saved_search,
color: Theme.of(context).colorScheme.onSurface,
var colorScheme = Theme.of(context).colorScheme;
final effectiveBackgroundColor = widget.selected
? colorScheme.secondaryContainer
: colorScheme.surfaceVariant;
final effectiveForegroundColor = widget.selected
? colorScheme.onSecondaryContainer
: colorScheme.onSurfaceVariant;
final expandedChild = Row(
children: [
IconButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.edit,
color: effectiveForegroundColor,
),
onPressed: () {
EditSavedViewRoute(widget.view).push(context);
},
),
IconButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.delete,
color: colorScheme.error,
),
onPressed: () async {
widget.onDeleteView(widget.view);
},
),
],
);
return Material(
color: effectiveBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: colorScheme.outline,
),
),
child: InkWell(
enableFeedback: true,
borderRadius: BorderRadius.circular(8),
onTap: () => widget.onViewSelected(widget.view),
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
_buildCheckmark(effectiveForegroundColor),
_buildLabel(context, effectiveForegroundColor)
.paddedSymmetrically(
horizontal: 12,
vertical: 0,
),
],
).paddedOnly(left: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: _isExpanded ? expandedChild : const SizedBox.shrink(),
),
_buildTrailing(effectiveForegroundColor),
],
),
),
showCheckmark: false,
selectedColor: Theme.of(context).colorScheme.primaryContainer,
selected: selected,
label: Text(view.name),
onSelected: (_) {
onViewSelected(view);
},
),
);
}
Widget _buildTrailing(Color effectiveForegroundColor) {
return IconButton(
icon: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value * pi,
child: Icon(
_isExpanded ? Icons.close : Icons.chevron_right,
color: effectiveForegroundColor,
),
);
},
),
onPressed: () {
if (_isExpanded) {
_animationController.reverse();
} else {
_animationController.forward();
}
setState(() {
_isExpanded = !_isExpanded;
});
},
);
}
Widget _buildLabel(BuildContext context, Color effectiveForegroundColor) {
return Text(
widget.view.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: effectiveForegroundColor),
);
}
Widget _buildCheckmark(Color effectiveForegroundColor) {
return AnimatedSize(
duration: const Duration(milliseconds: 300),
child: widget.selected
? Icon(Icons.check, color: effectiveForegroundColor)
: const SizedBox.shrink(),
);
}
}

View File

@@ -1,57 +1,144 @@
import 'package:flutter/widgets.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/widgets/hint_card.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart';
class SavedViewsWidget extends StatelessWidget {
class SavedViewsWidget extends StatefulWidget {
final void Function(SavedView view) onViewSelected;
final void Function(SavedView view) onUpdateView;
final void Function(SavedView view) onDeleteView;
final DocumentFilter filter;
final ExpansionTileController? controller;
const SavedViewsWidget({
super.key,
required this.onViewSelected,
required this.filter,
required this.onUpdateView,
required this.onDeleteView,
this.controller,
});
@override
State<SavedViewsWidget> createState() => _SavedViewsWidgetState();
}
class _SavedViewsWidgetState extends State<SavedViewsWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_animation = _animationController.drive(Tween(begin: 0, end: 0.5));
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(
top: 12,
left: 16,
right: 16,
),
height: 50,
child: BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return state.maybeWhen(
loaded: (savedViews) {
if (savedViews.isEmpty) {
return Text("No saved views");
}
return ListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final view = savedViews.values.elementAt(index);
return SavedViewChip(
view: view,
onUpdateView: onUpdateView,
onViewSelected: onViewSelected,
selected: filter.selectedView != null &&
view.id == filter.selectedView,
hasChanged: filter.selectedView == view.id &&
filter != view.toDocumentFilter(),
return PageStorage(
bucket: PageStorageBucket(),
child: ExpansionTile(
controller: widget.controller,
tilePadding: const EdgeInsets.only(left: 8),
trailing: RotationTransition(
turns: _animation,
child: const Icon(Icons.expand_more),
).paddedOnly(right: 8),
onExpansionChanged: (isExpanded) {
if (isExpanded) {
_animationController.forward();
} else {
_animationController.reverse().then((value) => setState(() {}));
}
},
title: Text(
S.of(context)!.views,
style: Theme.of(context).textTheme.labelLarge,
),
leading: Icon(
Icons.saved_search,
color: Theme.of(context).colorScheme.primary,
).padded(),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return state.map(
initial: (_) => const Placeholder(),
loading: (_) => const Placeholder(),
loaded: (value) {
if (value.savedViews.isEmpty) {
return Text(S.of(context)!.noItemsFound)
.paddedOnly(left: 16);
}
return Container(
margin: EdgeInsets.only(top: 16),
height: kMinInteractiveDimension,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) => true,
child: CustomScrollView(
scrollDirection: Axis.horizontal,
slivers: [
const SliverToBoxAdapter(
child: SizedBox(width: 12),
),
SliverList.separated(
itemBuilder: (context, index) {
final view =
value.savedViews.values.elementAt(index);
final isSelected =
(widget.filter.selectedView ?? -1) == view.id;
return SavedViewChip(
view: view,
onViewSelected: widget.onViewSelected,
selected: isSelected,
hasChanged: isSelected &&
view.toDocumentFilter() != widget.filter,
onUpdateView: widget.onUpdateView,
onDeleteView: widget.onDeleteView,
);
},
separatorBuilder: (context, index) =>
const SizedBox(width: 8),
itemCount: value.savedViews.length,
),
const SliverToBoxAdapter(
child: SizedBox(width: 12),
),
],
),
),
);
},
itemCount: savedViews.length,
error: (_) => const Placeholder(),
);
},
error: () => Text("Error loading saved views"),
orElse: () => Placeholder(),
);
},
),
Align(
alignment: Alignment.centerRight,
child: Tooltip(
message: "Create from current filter", //TODO: INTL
child: TextButton.icon(
onPressed: () {
CreateSavedViewRoute(widget.filter).push(context);
},
icon: const Icon(Icons.add),
label: Text(S.of(context)!.newView),
),
).padded(4),
),
],
),
);
}

View File

@@ -16,7 +16,7 @@ class ConfirmDeleteSavedViewDialog extends StatelessWidget {
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
S.of(context)!.deleteView + view.name + "?",
S.of(context)!.deleteView(view.name),
softWrap: true,
),
content: Text(S.of(context)!.doYouReallyWantToDeleteThisView),

View File

@@ -37,9 +37,16 @@ class AddTagPage extends StatelessWidget {
.withOpacity(1.0),
readOnly: true,
),
FormBuilderCheckbox(
FormBuilderField<bool>(
name: Tag.isInboxTagKey,
title: Text(S.of(context)!.inboxTag),
initialValue: false,
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.inboxTag),
onChanged: (value) => field.didChange(value),
);
},
),
],
),

View File

@@ -38,10 +38,16 @@ class EditTagPage extends StatelessWidget {
colorPickerType: ColorPickerType.materialPicker,
readOnly: true,
),
FormBuilderCheckbox(
initialValue: tag.isInboxTag,
FormBuilderField<bool>(
name: Tag.isInboxTagKey,
title: Text(S.of(context)!.inboxTag),
initialValue: tag.isInboxTag,
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.inboxTag),
onChanged: (value) => field.didChange(value),
);
},
),
],
),

View File

@@ -137,10 +137,16 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
initialValue: widget.initialValue?.match,
onChanged: (val) => setState(() => _errors = {}),
),
FormBuilderCheckbox(
FormBuilderField<bool>(
name: Label.isInsensitiveKey,
initialValue: widget.initialValue?.isInsensitive ?? true,
title: Text(S.of(context)!.caseIrrelevant),
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.caseIrrelevant),
onChanged: (value) => field.didChange(value),
);
},
),
...widget.additionalFields,
].padded(),

View File

@@ -55,9 +55,17 @@ class _LandingPageState extends State<LandingPage> {
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 0, 8),
sliver: SliverToBoxAdapter(
child: Text(
"Saved Views",
style: Theme.of(context).textTheme.titleMedium,
child: Row(
children: [
Icon(
Icons.saved_search,
color: Theme.of(context).colorScheme.primary,
).paddedOnly(right: 8),
Text(
S.of(context)!.views,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
),
@@ -75,7 +83,7 @@ class _LandingPageState extends State<LandingPage> {
children: [
Text(
"There are no saved views to show on your dashboard.", //TODO: INTL
),
).padded(),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
@@ -89,6 +97,7 @@ class _LandingPageState extends State<LandingPage> {
itemBuilder: (context, index) {
return SavedViewPreview(
savedView: dashboardViews.elementAt(index),
expanded: index == 0,
);
},
itemCount: dashboardViews.length,

View File

@@ -29,7 +29,7 @@ class ExpansionCard extends StatelessWidget {
),
),
child: ExpansionTile(
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
initiallyExpanded: initiallyExpanded,
title: title,
children: [content],

View File

@@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
const _fkName = 'name';
const _fkShowOnDashboard = 'show_on_dashboard';
const _fkShowInSidebar = 'show_in_sidebar';
class AddSavedViewPage extends StatefulWidget {
final DocumentFilter? initialFilter;
const AddSavedViewPage({
@@ -17,12 +23,7 @@ class AddSavedViewPage extends StatefulWidget {
}
class _AddSavedViewPageState extends State<AddSavedViewPage> {
static const fkName = 'name';
static const fkShowOnDashboard = 'show_on_dashboard';
static const fkShowInSidebar = 'show_in_sidebar';
final _savedViewFormKey = GlobalKey<FormBuilderState>();
final _filterFormKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -46,7 +47,7 @@ class _AddSavedViewPageState extends State<AddSavedViewPage> {
child: Column(
children: [
FormBuilderTextField(
name: _AddSavedViewPageState.fkName,
name: _fkName,
validator: (value) {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
@@ -57,41 +58,53 @@ class _AddSavedViewPageState extends State<AddSavedViewPage> {
label: Text(S.of(context)!.name),
),
),
FormBuilderCheckbox(
name: _AddSavedViewPageState.fkShowOnDashboard,
FormBuilderField<bool>(
name: _fkShowOnDashboard,
initialValue: false,
title: Text(S.of(context)!.showOnDashboard),
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.showOnDashboard),
onChanged: (value) => field.didChange(value),
);
},
),
FormBuilderCheckbox(
name: _AddSavedViewPageState.fkShowInSidebar,
FormBuilderField<bool>(
name: _fkShowInSidebar,
initialValue: false,
title: Text(S.of(context)!.showInSidebar),
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.showInSidebar),
onChanged: (value) => field.didChange(value),
);
},
),
],
),
),
const Divider(),
],
),
),
);
}
void _onCreate(BuildContext context) {
void _onCreate(BuildContext context) async {
if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) {
// context.pop(
// SavedView.fromDocumentFilter(
// DocumentFilterForm.assembleFilter(
// _filterFormKey,
// widget.currentFilter,
// ),
// name: _savedViewFormKey.currentState?.value[fkName] as String,
// showOnDashboard:
// _savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool,
// showInSidebar:
// _savedViewFormKey.currentState?.value[fkShowInSidebar] as bool,
// ),
// );
final cubit = context.read<SavedViewCubit>();
var savedView = SavedView.fromDocumentFilter(
widget.initialFilter ?? const DocumentFilter(),
name: _savedViewFormKey.currentState?.value[_fkName] as String,
showOnDashboard:
_savedViewFormKey.currentState?.value[_fkShowOnDashboard] as bool,
showInSidebar:
_savedViewFormKey.currentState?.value[_fkShowInSidebar] as bool,
);
final router = GoRouter.of(context);
await cubit.add(
savedView,
);
router.pop();
}
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
const _fkName = 'name';
const _fkShowOnDashboard = 'show_on_dashboard';
const _fkShowInSidebar = 'show_in_sidebar';
class EditSavedViewPage extends StatefulWidget {
final SavedView savedView;
const EditSavedViewPage({
super.key,
required this.savedView,
});
@override
State<EditSavedViewPage> createState() => _EditSavedViewPageState();
}
class _EditSavedViewPageState extends State<EditSavedViewPage> {
final _savedViewFormKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context)!.editView),
),
floatingActionButton: FloatingActionButton.extended(
heroTag: "fab_edit_saved_view_page",
icon: const Icon(Icons.save),
onPressed: () => _onCreate(context),
label: Text(S.of(context)!.saveChanges),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
FormBuilder(
key: _savedViewFormKey,
child: Column(
children: [
FormBuilderTextField(
initialValue: widget.savedView.name,
name: _fkName,
validator: (value) {
if (value?.trim().isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
}
return null;
},
decoration: InputDecoration(
label: Text(S.of(context)!.name),
),
),
FormBuilderField<bool>(
name: _fkShowOnDashboard,
initialValue: widget.savedView.showOnDashboard,
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.showOnDashboard),
onChanged: (value) => field.didChange(value),
);
},
),
FormBuilderField<bool>(
name: _fkShowInSidebar,
initialValue: widget.savedView.showInSidebar,
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.showInSidebar),
onChanged: (value) => field.didChange(value),
);
},
),
],
),
),
],
),
),
);
}
void _onCreate(BuildContext context) async {
if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) {
final cubit = context.read<SavedViewCubit>();
var savedView = widget.savedView.copyWith(
name: _savedViewFormKey.currentState!.value[_fkName],
showInSidebar: _savedViewFormKey.currentState!.value[_fkShowInSidebar],
showOnDashboard:
_savedViewFormKey.currentState!.value[_fkShowOnDashboard],
);
final router = GoRouter.of(context);
await cubit.update(savedView);
router.pop();
}
}
}

View File

@@ -12,9 +12,11 @@ import 'package:provider/provider.dart';
class SavedViewPreview extends StatelessWidget {
final SavedView savedView;
final bool expanded;
const SavedViewPreview({
super.key,
required this.savedView,
required this.expanded,
});
@override
@@ -24,7 +26,7 @@ class SavedViewPreview extends StatelessWidget {
SavedViewPreviewCubit(context.read(), savedView)..initialize(),
builder: (context, child) {
return ExpansionCard(
initiallyExpanded: true,
initiallyExpanded: expanded,
title: Text(savedView.name),
content: BlocBuilder<SavedViewPreviewCubit, SavedViewPreviewState>(
builder: (context, state) {
@@ -33,7 +35,7 @@ class SavedViewPreview extends StatelessWidget {
return Column(
children: [
if (documents.isEmpty)
Text("This view is empty.").padded()
Text("This view does not match any documents.").padded()
else
for (final document in documents)
DocumentListItem(
@@ -45,6 +47,7 @@ class SavedViewPreview extends StatelessWidget {
DocumentDetailsRoute($extra: document)
.push(context);
},
onSelected: null,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -64,7 +67,8 @@ class SavedViewPreview extends StatelessWidget {
],
);
},
error: () => const Text("Error loading preview"), //TODO: INTL
error: () =>
const Text("Could not load saved view."), //TODO: INTL
orElse: () => const Padding(
padding: EdgeInsets.all(8.0),
child: Center(child: CircularProgressIndicator()),

View File

@@ -15,9 +15,10 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
static const _languageOptions = {
'en': LanguageOption('English', true),
'de': LanguageOption('Deutsch', true),
'es': LanguageOption("Español", true),
'fr': LanguageOption('Français', true),
'cs': LanguageOption('Česky', true),
'tr': LanguageOption('Türkçe', true),
'fr': LanguageOption('Français', true),
'pl': LanguageOption('Polska', true),
'ca': LanguageOption('Catalan', true),
};