feat: Add improved date input, fix bugs, restructurings

This commit is contained in:
Anton Stubenbord
2023-10-20 17:28:54 +02:00
parent 18e178b644
commit 652abb6945
32 changed files with 840 additions and 775 deletions

View File

@@ -1,4 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
extension DocumentModelIterableExtension on Iterable<DocumentModel> {
@@ -16,3 +18,8 @@ extension DocumentModelIterableExtension on Iterable<DocumentModel> {
return whereNot((element) => element.id == document.id);
}
}
extension SessionAwareDownloadIdExtension on DocumentModel {
String buildThumbnailUrl(BuildContext context) =>
context.read<PaperlessDocumentsApi>().getThumbnailUrl(id);
}

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository_state.dart';
import 'package:paperless_mobile/core/repository/persistent_repository.dart';
@@ -11,14 +10,12 @@ class LabelRepository extends PersistentRepository<LabelRepositoryState> {
LabelRepository(this._api) : super(const LabelRepositoryState());
Future<void> initialize() async {
await Future.wait([
findAllCorrespondents(),
findAllDocumentTypes(),
findAllStoragePaths(),
findAllTags(),
]);
await Future.wait([
findAllCorrespondents(),
findAllDocumentTypes(),
findAllStoragePaths(),
findAllTags(),
]);
}
Future<Tag> createTag(Tag object) async {

View File

@@ -1,3 +1,5 @@
// ignore_for_file: invalid_use_of_protected_member
import 'dart:collection';
import 'package:collection/collection.dart';
@@ -9,6 +11,39 @@ import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FormDateTime {
final int? day;
final int? month;
final int? year;
FormDateTime({this.day, this.month, this.year});
FormDateTime.fromDateTime(DateTime date)
: day = date.day,
month = date.month,
year = date.year;
FormDateTime copyWith({int? day, int? month, int? year}) {
return FormDateTime(
day: day ?? this.day,
month: month ?? this.month,
year: year ?? this.year,
);
}
bool get isComplete => day != null && month != null && year != null;
DateTime? toDateTime() {
if (day == null && month == null && year == null) {
return null;
}
if (!isComplete) {
throw ArgumentError.notNull("day, month and year must be set together");
}
return DateTime(year!, month!, day!);
}
}
/// A localized, segmented date input field.
class FormBuilderLocalizedDatePicker extends StatefulWidget {
final String name;
@@ -124,42 +159,35 @@ class _FormBuilderLocalizedDatePickerState
}
}
},
child: FormBuilderField<DateTime>(
child: FormBuilderField<FormDateTime>(
name: widget.name,
validator: _validateDate,
onChanged: (value) {
// We have to temporarily disable our listeners on the TextEditingController here
// since otherwise the listeners get notified of the change and
// the fields get focused and highlighted/selected (as defined in the
// listeners above).
_temporarilyDisableListeners = true;
for (var control in _textFieldControls) {
control.controller.text = DateFormat(control.format).format(value!);
assert(!widget.allowUnset && value != null);
if (value == null) {
return;
}
_temporarilyDisableListeners = false;
// When the change is requested from external sources, such as calling
// field.didChange(value), then we want to update the text fields individually
// without causing the either field to gain focus (as defined above).
final isChangeRequestedFromOutside =
_textFieldControls.none((element) => element.node.hasFocus);
if (isChangeRequestedFromOutside) {
_updateInputsWithDate(value, disableListeners: true);
}
// Imitate the functionality of the validator function in "normal" form fields.
// The error is shown on the outer decorator as if this was a regular text input.
// Errors are cleared after the next user interaction.
final error = _validateDate(value);
setState(() {
_error = error;
});
if (value?.isBefore(widget.firstDate) ?? false) {
setState(() => _error = "Date must be after " +
DateFormat.yMd(widget.locale.toString())
.format(widget.firstDate) +
".");
return;
}
if (value?.isAfter(widget.lastDate) ?? false) {
setState(() => _error = "Date must be before " +
DateFormat.yMd(widget.locale.toString())
.format(widget.lastDate) +
".");
return;
}
},
autovalidateMode: AutovalidateMode.onUserInteraction,
name: widget.name,
initialValue: widget.initialValue,
initialValue: widget.initialValue != null
? FormDateTime.fromDateTime(widget.initialValue!)
: null,
builder: (field) {
return GestureDetector(
onTap: () {
@@ -170,7 +198,6 @@ class _FormBuilderLocalizedDatePickerState
decoration: InputDecoration(
errorText: _error,
labelText: widget.labelText,
prefixIcon: widget.prefixIcon,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -179,28 +206,33 @@ class _FormBuilderLocalizedDatePickerState
onPressed: () async {
final selectedDate = await showDatePicker(
context: context,
initialDate: widget.initialValue ?? DateTime.now(),
initialDate:
field.value?.toDateTime() ?? DateTime.now(),
firstDate: widget.firstDate,
lastDate: widget.lastDate,
initialEntryMode: DatePickerEntryMode.calendarOnly,
);
if (selectedDate != null) {
_updateInputsWithDate(selectedDate);
field.didChange(selectedDate);
FocusScope.of(context).unfocus();
final formDate =
FormDateTime.fromDateTime(selectedDate);
_temporarilyDisableListeners = true;
_updateInputsWithDate(formDate);
field.didChange(formDate);
_temporarilyDisableListeners = false;
}
},
),
IconButton(
onPressed: () {
for (var c in _textFieldControls) {
c.controller.clear();
}
_textFieldControls.first.node.requestFocus();
field.didChange(null);
},
icon: const Icon(Icons.clear),
),
if (widget.allowUnset)
IconButton(
onPressed: () {
for (var c in _textFieldControls) {
c.controller.clear();
}
_textFieldControls.first.node.requestFocus();
field.didChange(null);
},
icon: const Icon(Icons.clear),
),
],
).paddedOnly(right: 4),
),
@@ -220,19 +252,26 @@ class _FormBuilderLocalizedDatePickerState
);
}
String? _validateDate(DateTime? date) {
String? _validateDate(FormDateTime? date) {
if (widget.allowUnset && date == null) {
return null;
}
if (date == null) {
return S.of(context)!.thisFieldIsRequired;
}
if (date.isBefore(widget.firstDate)) {
final d = date.toDateTime();
if (d == null) {
return S.of(context)!.thisFieldIsRequired;
}
if (d.day != date.day && d.month != date.month && d.year != date.year) {
return "Invalid date.";
}
if (d.isBefore(widget.firstDate)) {
final formattedDateHint =
DateFormat.yMd(widget.locale.toString()).format(widget.firstDate);
return "Date must be after $formattedDateHint.";
}
if (date.isAfter(widget.lastDate)) {
if (d.isAfter(widget.lastDate)) {
final formattedDateHint =
DateFormat.yMd(widget.locale.toString()).format(widget.lastDate);
return "Date must be before $formattedDateHint.";
@@ -240,30 +279,31 @@ class _FormBuilderLocalizedDatePickerState
return null;
}
void _updateInputsWithDate(DateTime date) {
final components = _format.split(_separator);
for (int i = 0; i < components.length; i++) {
final formatString = components[i];
final value = DateFormat(formatString).format(date);
_textFieldControls.elementAt(i).controller.text = value;
void _updateInputsWithDate(
FormDateTime date, {
bool disableListeners = false,
}) {
if (disableListeners) {
_temporarilyDisableListeners = true;
}
for (var controls in _textFieldControls) {
final value = DateFormat(controls.format).format(date.toDateTime()!);
controls.controller.text = value;
}
_temporarilyDisableListeners = false;
}
Widget _buildDateSegmentInput(
_NeighbourAwareDateInputSegmentControls controls,
BuildContext context,
FormFieldState<DateTime> field,
FormFieldState<FormDateTime> field,
) {
return TextFormField(
onFieldSubmitted: (value) {
if (value.length < controls.format.length) {
controls.controller.text = value.padLeft(controls.format.length, '0');
}
_textFieldControls
.elementAt(controls.position)
.next
?.node
.requestFocus();
controls.next?.node.requestFocus();
},
style: const TextStyle(fontFamily: 'RobotoMono'),
keyboardType: TextInputType.datetime,
@@ -275,17 +315,18 @@ class _FormBuilderLocalizedDatePickerState
maxLengthEnforcement: MaxLengthEnforcement.enforced,
enableInteractiveSelection: false,
onChanged: (value) {
if (value.length == controls.format.length && field.value != null) {
if (value.length == controls.format.length) {
final number = int.tryParse(value);
if (number == null) {
return;
}
final fieldValue = field.value ?? FormDateTime();
final newValue = switch (controls.type) {
_DateInputSegment.day => field.value!.copyWith(day: number),
_DateInputSegment.month => field.value!.copyWith(month: number),
_DateInputSegment.year => field.value!.copyWith(year: number),
_DateInputSegment.day => fieldValue.copyWith(day: number),
_DateInputSegment.month => fieldValue.copyWith(month: number),
_DateInputSegment.year => fieldValue.copyWith(year: number),
};
field.didChange(newValue);
field.setValue(newValue);
}
},
inputFormatters: [
@@ -299,6 +340,12 @@ class _FormBuilderLocalizedDatePickerState
},
),
],
onEditingComplete: () {
if (field.value != null) {
_updateInputsWithDate(field.value!, disableListeners: true);
}
FocusScope.of(context).unfocus();
},
decoration: InputDecoration(
isDense: true,
suffixIcon: controls.position < 2