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

@@ -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),