feat: Replaced old label form fields with full page search, removed badge from edit button in document details

This commit is contained in:
Anton Stubenbord
2023-04-07 18:04:56 +02:00
parent 79ccdd0946
commit 10d48e6a55
58 changed files with 3457 additions and 487 deletions

View File

@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenLabelForm<T extends Label> extends StatefulWidget {
final IdQueryParameter? initialValue;
final Map<int, T> options;
final Future<T?> Function(String? initialName)? onCreateNewLabel;
final bool showNotAssignedOption;
final bool showAnyAssignedOption;
final void Function({IdQueryParameter returnValue}) onSubmit;
final Widget leadingIcon;
final String? addNewLabelText;
FullscreenLabelForm({
super.key,
this.initialValue,
required this.options,
required this.onCreateNewLabel,
this.showNotAssignedOption = true,
this.showAnyAssignedOption = true,
required this.onSubmit,
required this.leadingIcon,
this.addNewLabelText,
}) : assert(
!(initialValue?.onlyAssigned ?? false) || showAnyAssignedOption,
),
assert(
!(initialValue?.onlyNotAssigned ?? false) || showNotAssignedOption,
),
assert((addNewLabelText != null) == (onCreateNewLabel != null));
@override
State<FullscreenLabelForm> createState() => _FullscreenLabelFormState();
}
class _FullscreenLabelFormState<T extends Label>
extends State<FullscreenLabelForm<T>> {
late bool _showClearIcon = false;
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_textEditingController.addListener(() => setState(() {
_showClearIcon = _textEditingController.text.isNotEmpty;
}));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
//Delay keyboard popup to ensure open animation is finished before.
Future.delayed(
const Duration(milliseconds: 200),
() => _focusNode.requestFocus(),
);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final options = _filterOptionsByQuery(_textEditingController.text);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.colorScheme.surface,
toolbarHeight: 72,
leading: BackButton(
color: theme.colorScheme.onSurface,
),
title: TextFormField(
focusNode: _focusNode,
controller: _textEditingController,
onFieldSubmitted: (value) {
FocusScope.of(context).unfocus();
final index = AutocompleteHighlightedOption.of(context);
final value = index.isNegative ? null : options.elementAt(index);
widget.onSubmit(returnValue: IdQueryParameter.fromId(value?.id));
},
autofocus: true,
style: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintStyle: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurfaceVariant,
),
icon: widget.leadingIcon,
hintText: _buildHintText(),
border: InputBorder.none,
),
textInputAction: TextInputAction.done,
),
actions: [
if (_showClearIcon)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_textEditingController.clear();
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Divider(
color: theme.colorScheme.outline,
),
),
),
body: Builder(
builder: (context) {
return Column(
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
final highlight =
AutocompleteHighlightedOption.of(context) == index;
if (highlight) {
SchedulerBinding.instance
.addPostFrameCallback((Duration timeStamp) {
Scrollable.ensureVisible(
context,
alignment: 0,
);
});
}
return _buildOptionWidget(option, highlight);
},
),
),
],
);
},
),
);
}
void _onCreateNewLabel() async {
final label = await widget.onCreateNewLabel!(_textEditingController.text);
if (label?.id != null) {
widget.onSubmit(
returnValue: IdQueryParameter.fromId(label!.id!),
);
}
}
///
/// Filters the options passed to this widget by the current [query] and
/// adds not-/any assigned options
///
Iterable<IdQueryParameter> _filterOptionsByQuery(String query) sync* {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
if (widget.initialValue == null) {
// If nothing is selected yet, show all options first.
for (final option in widget.options.values) {
yield IdQueryParameter.fromId(option.id);
}
if (widget.showNotAssignedOption) {
yield const IdQueryParameter.notAssigned();
}
if (widget.showAnyAssignedOption) {
yield const IdQueryParameter.anyAssigned();
}
} else {
// If an initial value is given, show not assigned first, which will be selected by default when pressing "done" on keyboard.
if (widget.showNotAssignedOption) {
yield const IdQueryParameter.notAssigned();
}
if (widget.showAnyAssignedOption) {
yield const IdQueryParameter.anyAssigned();
}
for (final option in widget.options.values) {
// Don't include the initial value in the selection
if (option.id == widget.initialValue?.id) {
continue;
}
yield IdQueryParameter.fromId(option.id);
}
}
} else {
// Show filtered options, if no matching option is found, always show not assigned and any assigned (if enabled) and proceed.
final matches = widget.options.values
.where((e) => e.name.trim().toLowerCase().contains(normalizedQuery));
if (matches.isNotEmpty) {
for (final match in matches) {
yield IdQueryParameter.fromId(match.id);
}
if (widget.showNotAssignedOption) {
yield const IdQueryParameter.notAssigned();
}
if (widget.showAnyAssignedOption) {
yield const IdQueryParameter.anyAssigned();
}
} else {
if (widget.showNotAssignedOption) {
yield const IdQueryParameter.notAssigned();
}
if (widget.showAnyAssignedOption) {
yield const IdQueryParameter.anyAssigned();
}
if (!(widget.showAnyAssignedOption || widget.showNotAssignedOption)) {
yield const IdQueryParameter.unset();
}
}
}
}
String? _buildHintText() {
if (widget.initialValue?.isSet ?? false) {
return widget.options[widget.initialValue!.id]?.name ?? 'undefined';
}
if (widget.initialValue?.onlyNotAssigned ?? false) {
return S.of(context)!.notAssigned;
}
if (widget.initialValue?.onlyAssigned ?? false) {
return S.of(context)!.anyAssigned;
}
return S.of(context)!.startTyping;
}
Widget _buildOptionWidget(IdQueryParameter option, bool highlight) {
void onTap() => widget.onSubmit(returnValue: option);
late final String title;
if (option.isSet) {
title = widget.options[option.id]!.name;
}
if (option.onlyNotAssigned) {
title = S.of(context)!.notAssigned;
}
if (option.onlyAssigned) {
title = S.of(context)!.anyAssigned;
}
if (option.isUnset) {
return Center(
child: Column(
children: [
Text(S.of(context)!.noItemsFound).padded(),
if (widget.onCreateNewLabel != null)
TextButton(
child: Text(widget.addNewLabelText!),
onPressed: _onCreateNewLabel,
),
],
),
);
}
return ListTile(
selected: highlight,
selectedTileColor: Theme.of(context).focusColor,
title: Text(title),
onTap: onTap,
);
}
}

View File

@@ -1,195 +1,164 @@
import 'dart:developer';
import 'package:animations/animations.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_type_ahead.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/labels/view/widgets/fullscreen_label_form.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
///
/// Form field allowing to select labels (i.e. correspondent, documentType)
/// [T] is the label type (e.g. [DocumentType], [Correspondent], ...), [R] is the return type (e.g. [CorrespondentQuery], ...).
/// [T] is the label type (e.g. [DocumentType], [Correspondent], ...)
///
class LabelFormField<T extends Label> extends StatefulWidget {
class LabelFormField<T extends Label> extends StatelessWidget {
final Widget prefixIcon;
final Map<int, T> labelOptions;
final FormBuilderState? formBuilderState;
final Map<int, T> options;
final IdQueryParameter? initialValue;
final String name;
final String textFieldLabel;
final String labelText;
final FormFieldValidator? validator;
final Widget Function(String initialName)? labelCreationWidgetBuilder;
final bool notAssignedSelectable;
final Widget Function(String? initialName)? addLabelPageBuilder;
final void Function(IdQueryParameter?)? onChanged;
final bool showNotAssignedOption;
final bool showAnyAssignedOption;
final List<T> suggestions;
final String? addLabelText;
const LabelFormField({
Key? key,
required this.name,
required this.labelOptions,
this.validator,
this.initialValue,
required this.textFieldLabel,
this.labelCreationWidgetBuilder,
required this.formBuilderState,
required this.options,
required this.labelText,
required this.prefixIcon,
this.notAssignedSelectable = true,
this.initialValue,
this.validator,
this.addLabelPageBuilder,
this.onChanged,
this.showNotAssignedOption = true,
this.showAnyAssignedOption = true,
this.suggestions = const [],
this.addLabelText,
}) : super(key: key);
@override
State<LabelFormField<T>> createState() => _LabelFormFieldState<T>();
}
class _LabelFormFieldState<T extends Label> extends State<LabelFormField<T>> {
bool _showCreationSuffixIcon = false;
late bool _showClearSuffixIcon;
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_showClearSuffixIcon =
widget.labelOptions.containsKey(widget.initialValue?.id);
_textEditingController = TextEditingController(
text: widget.labelOptions[widget.initialValue?.id]?.name ?? '',
)..addListener(() {
setState(() {
_showCreationSuffixIcon = widget.labelOptions.values
.where(
(item) => item.name.toLowerCase().startsWith(
_textEditingController.text.toLowerCase(),
),
)
.isEmpty;
});
setState(() =>
_showClearSuffixIcon = _textEditingController.text.isNotEmpty);
});
String _buildText(BuildContext context, IdQueryParameter? value) {
if (value?.isSet ?? false) {
return options[value!.id]?.name ?? 'undefined';
} else if (value?.onlyNotAssigned ?? false) {
return S.of(context)!.notAssigned;
} else if (value?.onlyAssigned ?? false) {
return S.of(context)!.anyAssigned;
}
return '';
}
@override
Widget build(BuildContext context) {
final isEnabled = widget.labelOptions.values.fold<bool>(
false,
(previousValue, element) =>
previousValue || (element.documentCount ?? 0) > 0) ||
widget.labelCreationWidgetBuilder != null;
return FormBuilderTypeAhead<IdQueryParameter>(
final isEnabled = options.values.any((e) => (e.documentCount ?? 0) > 0) ||
addLabelPageBuilder != null;
return FormBuilderField<IdQueryParameter>(
name: name,
initialValue: initialValue,
onChanged: onChanged,
enabled: isEnabled,
noItemsFoundBuilder: (context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
S.of(context)!.noItemsFound,
textAlign: TextAlign.center,
style:
TextStyle(color: Theme.of(context).disabledColor, fontSize: 18.0),
),
),
loadingBuilder: (context) => Container(),
initialValue: widget.initialValue ?? const IdQueryParameter.unset(),
name: widget.name,
suggestionsBoxDecoration: SuggestionsBoxDecoration(
elevation: 4.0,
shadowColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
itemBuilder: (context, suggestion) => ListTile(
title: Text(
widget.labelOptions[suggestion.id]?.name ??
S.of(context)!.notAssigned,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// tileColor: Theme.of(context).colorScheme.surfaceVariant,
dense: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
suggestionsCallback: (pattern) {
final List<IdQueryParameter> suggestions = widget.labelOptions.entries
.where(
(entry) =>
widget.labelOptions[entry.key]!.name
.toLowerCase()
.contains(pattern.toLowerCase()) ||
pattern.isEmpty,
)
.where(
(entry) =>
widget.labelCreationWidgetBuilder != null ||
(entry.value.documentCount ?? 0) > 0,
)
.map((entry) => IdQueryParameter.fromId(entry.key))
.toList();
if (widget.notAssignedSelectable) {
suggestions.insert(0, const IdQueryParameter.notAssigned());
}
return suggestions;
},
onChanged: (value) {
setState(() => _showClearSuffixIcon = value?.isSet ?? false);
widget.onChanged?.call(value);
},
controller: _textEditingController,
decoration: InputDecoration(
prefixIcon: widget.prefixIcon,
label: Text(widget.textFieldLabel),
hintText: S.of(context)!.startTyping,
suffixIcon: _buildSuffixIcon(context),
),
selectionToTextTransformer: (suggestion) {
if (suggestion == const IdQueryParameter.notAssigned()) {
return S.of(context)!.notAssigned;
}
return widget.labelOptions[suggestion.id]?.name ?? "";
},
direction: AxisDirection.up,
onSuggestionSelected: (suggestion) =>
widget.formBuilderState?.fields[widget.name]?.didChange(suggestion),
);
}
builder: (field) {
final controller = TextEditingController(
text: _buildText(context, field.value),
);
final displayedSuggestions =
suggestions.whereNot((e) => e.id == field.value?.id).toList();
Widget? _buildSuffixIcon(BuildContext context) {
if (_showCreationSuffixIcon && widget.labelCreationWidgetBuilder != null) {
return IconButton(
onPressed: () async {
FocusScope.of(context).unfocus();
final createdLabel = await showDialog<T>(
context: context,
builder: (context) => widget.labelCreationWidgetBuilder!(
_textEditingController.text,
return Column(
children: [
OpenContainer<IdQueryParameter>(
middleColor: Theme.of(context).colorScheme.background,
closedColor: Theme.of(context).colorScheme.background,
openColor: Theme.of(context).colorScheme.background,
closedShape: InputBorder.none,
openElevation: 0,
closedElevation: 0,
closedBuilder: (context, openForm) => Container(
margin: const EdgeInsets.only(top: 4),
child: TextField(
controller: controller,
onTap: openForm,
readOnly: true,
enabled: isEnabled,
decoration: InputDecoration(
prefixIcon: prefixIcon,
labelText: labelText,
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () =>
field.didChange(const IdQueryParameter.unset()),
)
: null,
),
),
),
openBuilder: (context, closeForm) => FullscreenLabelForm<T>(
addNewLabelText: addLabelText,
leadingIcon: prefixIcon,
onCreateNewLabel: addLabelPageBuilder != null
? (initialName) {
return Navigator.of(context).push<T>(
MaterialPageRoute(
builder: (context) =>
addLabelPageBuilder!(initialName),
),
);
}
: null,
options: options,
onSubmit: closeForm,
initialValue: field.value,
showAnyAssignedOption: showAnyAssignedOption,
showNotAssignedOption: showNotAssignedOption,
),
onClosed: (data) {
if (data != null) {
field.didChange(data);
}
},
),
);
if (createdLabel != null) {
// If new label has been created, set form field value and text of this form field and unfocus keyboard (we assume user is done).
widget.formBuilderState?.fields[widget.name]
?.didChange(IdQueryParameter.fromId(createdLabel.id));
_textEditingController.text = createdLabel.name;
} else {
_reset();
}
},
icon: const Icon(
Icons.new_label,
),
);
}
if (_showClearSuffixIcon) {
return IconButton(
icon: const Icon(Icons.clear),
onPressed: _reset,
);
}
return null;
}
void _reset() {
widget.formBuilderState?.fields[widget.name]?.didChange(
const IdQueryParameter.unset(),
if (displayedSuggestions.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.suggestions,
style: Theme.of(context).textTheme.bodySmall,
),
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: displayedSuggestions.length,
itemBuilder: (context, index) {
final suggestion =
displayedSuggestions.elementAt(index);
return ColoredChipWrapper(
child: ActionChip(
label: Text(suggestion.name),
onPressed: () => field.didChange(
IdQueryParameter.fromId(suggestion.id),
),
),
);
},
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: 4.0),
),
),
],
).padded(),
],
);
},
);
_textEditingController.clear();
}
}