mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-06 13:15:49 -06:00
feat: Add improved date input
This commit is contained in:
BIN
assets/fonts/RobotoMono-Regular.ttf
Normal file
BIN
assets/fonts/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
@@ -1,41 +1,26 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:extended_masked_text/extended_masked_text.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_mobile/core/extensions/flutter_extensions.dart';
|
||||
import 'package:synchronized/extension.dart';
|
||||
|
||||
final class NeighbourAwareDateInputSegmentControls
|
||||
with LinkedListEntry<NeighbourAwareDateInputSegmentControls> {
|
||||
final FocusNode node;
|
||||
final TextEditingController controller;
|
||||
final int position;
|
||||
final String format;
|
||||
final DateTime? initialDate;
|
||||
|
||||
NeighbourAwareDateInputSegmentControls({
|
||||
required this.node,
|
||||
required this.controller,
|
||||
required this.format,
|
||||
this.initialDate,
|
||||
required this.position,
|
||||
});
|
||||
}
|
||||
import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
|
||||
/// A localized, segmented date input field.
|
||||
class FormBuilderLocalizedDatePicker extends StatefulWidget {
|
||||
final String name;
|
||||
final Locale locale;
|
||||
final String labelText;
|
||||
final Widget? prefixIcon;
|
||||
final DateTime? initialValue;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
final Locale locale;
|
||||
|
||||
/// If set to true, the field will not throw any validation errors when empty.
|
||||
final bool allowUnset;
|
||||
|
||||
const FormBuilderLocalizedDatePicker({
|
||||
super.key,
|
||||
@@ -46,6 +31,7 @@ class FormBuilderLocalizedDatePicker extends StatefulWidget {
|
||||
required this.locale,
|
||||
required this.labelText,
|
||||
this.prefixIcon,
|
||||
this.allowUnset = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -59,8 +45,9 @@ class _FormBuilderLocalizedDatePickerState
|
||||
late final String _format;
|
||||
|
||||
final _textFieldControls =
|
||||
LinkedList<NeighbourAwareDateInputSegmentControls>();
|
||||
|
||||
LinkedList<_NeighbourAwareDateInputSegmentControls>();
|
||||
String? _error;
|
||||
bool _temporarilyDisableListeners = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -78,29 +65,44 @@ class _FormBuilderLocalizedDatePickerState
|
||||
final initialText = widget.initialValue != null
|
||||
? DateFormat(formatString).format(widget.initialValue!)
|
||||
: null;
|
||||
final item = NeighbourAwareDateInputSegmentControls(
|
||||
final controls = _NeighbourAwareDateInputSegmentControls(
|
||||
node: FocusNode(debugLabel: formatString),
|
||||
controller: TextEditingController(text: initialText),
|
||||
format: formatString,
|
||||
position: i,
|
||||
type: _DateInputSegment.fromPattern(formatString),
|
||||
);
|
||||
item.controller.addListener(() {
|
||||
if (item.controller.text.length == item.format.length) {
|
||||
// _textFieldControls.elementAt(i).next?.node.requestFocus();
|
||||
// _textFieldControls.elementAt(i).next?.controller.selection =
|
||||
// const TextSelection.collapsed(offset: 0);
|
||||
// return;
|
||||
_textFieldControls.add(controls);
|
||||
controls.controller.addListener(() {
|
||||
if (_temporarilyDisableListeners) {
|
||||
return;
|
||||
}
|
||||
if (controls.controller.selection.isCollapsed &&
|
||||
controls.controller.text.length == controls.format.length) {
|
||||
controls.next?.node.requestFocus();
|
||||
}
|
||||
});
|
||||
item.node.addListener(() {
|
||||
if (item.node.hasFocus) {
|
||||
item.controller.selection = const TextSelection.collapsed(offset: 0);
|
||||
controls.node.addListener(() {
|
||||
if (_temporarilyDisableListeners || !controls.node.hasFocus) {
|
||||
return;
|
||||
}
|
||||
controls.controller.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: controls.controller.text.length,
|
||||
);
|
||||
});
|
||||
_textFieldControls.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (var controls in _textFieldControls) {
|
||||
controls.node.dispose();
|
||||
controls.controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawKeyboardListener(
|
||||
@@ -123,27 +125,50 @@ class _FormBuilderLocalizedDatePickerState
|
||||
}
|
||||
},
|
||||
child: FormBuilderField<DateTime>(
|
||||
name: widget.name,
|
||||
initialValue: widget.initialValue,
|
||||
validator: (value) {
|
||||
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!);
|
||||
}
|
||||
_temporarilyDisableListeners = false;
|
||||
|
||||
final error = _validateDate(value);
|
||||
setState(() {
|
||||
_error = error;
|
||||
});
|
||||
|
||||
if (value?.isBefore(widget.firstDate) ?? false) {
|
||||
return "Date must be before " +
|
||||
setState(() => _error = "Date must be after " +
|
||||
DateFormat.yMd(widget.locale.toString())
|
||||
.format(widget.firstDate);
|
||||
.format(widget.firstDate) +
|
||||
".");
|
||||
return;
|
||||
}
|
||||
if (value?.isAfter(widget.lastDate) ?? false) {
|
||||
return "Date must be after " +
|
||||
setState(() => _error = "Date must be before " +
|
||||
DateFormat.yMd(widget.locale.toString())
|
||||
.format(widget.lastDate);
|
||||
.format(widget.lastDate) +
|
||||
".");
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
name: widget.name,
|
||||
initialValue: widget.initialValue,
|
||||
builder: (field) {
|
||||
return SizedBox(
|
||||
height: 56,
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_textFieldControls.first.node.requestFocus();
|
||||
},
|
||||
child: InputDecorator(
|
||||
textAlignVertical: TextAlignVertical.bottom,
|
||||
decoration: InputDecoration(
|
||||
errorText: _error,
|
||||
labelText: widget.labelText,
|
||||
prefixIcon: widget.prefixIcon,
|
||||
suffixIcon: Row(
|
||||
@@ -168,11 +193,11 @@ class _FormBuilderLocalizedDatePickerState
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
field.didChange(null);
|
||||
for (var c in _textFieldControls) {
|
||||
c.controller.clear();
|
||||
}
|
||||
_textFieldControls.first.node.requestFocus();
|
||||
field.didChange(null);
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
@@ -182,16 +207,9 @@ class _FormBuilderLocalizedDatePickerState
|
||||
child: Row(
|
||||
children: [
|
||||
for (var s in _textFieldControls) ...[
|
||||
SizedBox(
|
||||
width: switch (s.format) {
|
||||
== "dd" => 32,
|
||||
== "MM" => 32,
|
||||
== "yyyy" => 48,
|
||||
_ => 0,
|
||||
},
|
||||
IntrinsicWidth(
|
||||
child: _buildDateSegmentInput(s, context, field),
|
||||
),
|
||||
if (s.position < 2) Text(_separator).paddedOnly(right: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -202,6 +220,26 @@ class _FormBuilderLocalizedDatePickerState
|
||||
);
|
||||
}
|
||||
|
||||
String? _validateDate(DateTime? date) {
|
||||
if (widget.allowUnset && date == null) {
|
||||
return null;
|
||||
}
|
||||
if (date == null) {
|
||||
return S.of(context)!.thisFieldIsRequired;
|
||||
}
|
||||
if (date.isBefore(widget.firstDate)) {
|
||||
final formattedDateHint =
|
||||
DateFormat.yMd(widget.locale.toString()).format(widget.firstDate);
|
||||
return "Date must be after $formattedDateHint.";
|
||||
}
|
||||
if (date.isAfter(widget.lastDate)) {
|
||||
final formattedDateHint =
|
||||
DateFormat.yMd(widget.locale.toString()).format(widget.lastDate);
|
||||
return "Date must be before $formattedDateHint.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _updateInputsWithDate(DateTime date) {
|
||||
final components = _format.split(_separator);
|
||||
for (int i = 0; i < components.length; i++) {
|
||||
@@ -212,38 +250,69 @@ class _FormBuilderLocalizedDatePickerState
|
||||
}
|
||||
|
||||
Widget _buildDateSegmentInput(
|
||||
NeighbourAwareDateInputSegmentControls controls,
|
||||
_NeighbourAwareDateInputSegmentControls controls,
|
||||
BuildContext context,
|
||||
FormFieldState<DateTime> 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();
|
||||
},
|
||||
// onTap: () {
|
||||
// controls.controller.clear();
|
||||
// },
|
||||
canRequestFocus: true,
|
||||
style: const TextStyle(fontFamily: 'RobotoMono'),
|
||||
keyboardType: TextInputType.datetime,
|
||||
textInputAction: TextInputAction.done,
|
||||
textInputAction:
|
||||
controls.position < 2 ? TextInputAction.next : TextInputAction.done,
|
||||
controller: controls.controller,
|
||||
focusNode: _textFieldControls.elementAt(controls.position).node,
|
||||
maxLength: controls.format.length,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
enableInteractiveSelection: false,
|
||||
onChanged: (value) {
|
||||
if (value.length == controls.format.length && field.value != null) {
|
||||
final number = int.tryParse(value);
|
||||
if (number == null) {
|
||||
return;
|
||||
}
|
||||
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),
|
||||
};
|
||||
field.didChange(newValue);
|
||||
}
|
||||
},
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
ReplacingTextFormatter(),
|
||||
RangeLimitedInputFormatter(
|
||||
1,
|
||||
switch (controls.type) {
|
||||
_DateInputSegment.day => 31,
|
||||
_DateInputSegment.month => 12,
|
||||
_DateInputSegment.year => 9999,
|
||||
},
|
||||
),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
suffixIcon: controls.position < 2
|
||||
? Text(
|
||||
_separator,
|
||||
style: const TextStyle(fontFamily: 'RobotoMono'),
|
||||
).paddedSymmetrically(horizontal: 2)
|
||||
: null,
|
||||
suffixIconConstraints: const BoxConstraints.tightFor(),
|
||||
fillColor: Colors.blue.values[controls.position],
|
||||
counterText: '',
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: controls.format,
|
||||
hintStyle: const TextStyle(fontFamily: "RobotoMono"),
|
||||
border: Theme.of(context).inputDecorationTheme.border?.copyWith(
|
||||
borderSide: const BorderSide(
|
||||
width: 0,
|
||||
@@ -255,31 +324,64 @@ class _FormBuilderLocalizedDatePickerState
|
||||
}
|
||||
}
|
||||
|
||||
class ReplacingTextFormatter extends TextInputFormatter {
|
||||
enum _DateInputSegment {
|
||||
day,
|
||||
month,
|
||||
year;
|
||||
|
||||
static _DateInputSegment fromPattern(String pattern) {
|
||||
final char = pattern.characters.first;
|
||||
return switch (char) {
|
||||
'd' => day,
|
||||
'M' => month,
|
||||
'y' => year,
|
||||
_ => throw ArgumentError.value(pattern),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final class _NeighbourAwareDateInputSegmentControls
|
||||
with LinkedListEntry<_NeighbourAwareDateInputSegmentControls> {
|
||||
final FocusNode node;
|
||||
final TextEditingController controller;
|
||||
final int position;
|
||||
final String format;
|
||||
final _DateInputSegment type;
|
||||
|
||||
_NeighbourAwareDateInputSegmentControls({
|
||||
required this.node,
|
||||
required this.controller,
|
||||
required this.format,
|
||||
required this.position,
|
||||
required this.type,
|
||||
});
|
||||
}
|
||||
|
||||
class RangeLimitedInputFormatter extends TextInputFormatter {
|
||||
RangeLimitedInputFormatter(
|
||||
this.minimum,
|
||||
this.maximum,
|
||||
) : assert(minimum < maximum);
|
||||
|
||||
final int minimum;
|
||||
final int maximum;
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final oldOffset = oldValue.selection.baseOffset;
|
||||
final newOffset = newValue.selection.baseOffset;
|
||||
final replacement = newValue.text.substring(oldOffset, newOffset);
|
||||
print(
|
||||
"DBG: Received ${oldValue.text} -> ${newValue.text}. New char = $replacement");
|
||||
if (oldOffset < newOffset) {
|
||||
final oldText = oldValue.text;
|
||||
final newText = oldText.replaceRange(
|
||||
oldOffset,
|
||||
newOffset,
|
||||
newValue.text.substring(oldOffset, newOffset),
|
||||
);
|
||||
print("DBG: Replacing $oldText -> $newText");
|
||||
return newValue.copyWith(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newOffset),
|
||||
if (newValue.text.length < 2) {
|
||||
return newValue;
|
||||
}
|
||||
var value = int.parse(newValue.text);
|
||||
final lastCharacter = newValue.text.characters.last;
|
||||
if (value < minimum || value > maximum) {
|
||||
return TextEditingValue(
|
||||
text: lastCharacter,
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
);
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ class AppDrawer extends StatelessWidget {
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://github.com/astubenbord/paperless-mobile/issues/new',
|
||||
'https://github.com/astubenbord/paperless-mobile/issues/new?assignees=astubenbord&labels=bug%2Ctriage&projects=&template=bug-report.yml&title=%5BBug%5D%3A+',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -413,28 +413,16 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
|
||||
Widget _buildCreatedAtFormField(
|
||||
DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) {
|
||||
// return FormBuilderLocalizedDatePicker(
|
||||
// name: fkCreatedDate,
|
||||
// initialValue: initialCreatedAtDate,
|
||||
// labelText: S.of(context)!.createdAt,
|
||||
// firstDate: DateTime(1970, 1, 1),
|
||||
// lastDate: DateTime.now(),
|
||||
// locale: Localizations.localeOf(context),
|
||||
// prefixIcon: Icon(Icons.calendar_today),
|
||||
// );
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FormBuilderDateTimePicker(
|
||||
inputType: InputType.date,
|
||||
FormBuilderLocalizedDatePicker(
|
||||
name: fkCreatedDate,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
label: Text(S.of(context)!.createdAt),
|
||||
),
|
||||
initialValue: initialCreatedAtDate,
|
||||
format: DateFormat.yMMMMd(Localizations.localeOf(context).toString()),
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
labelText: S.of(context)!.createdAt,
|
||||
firstDate: DateTime(1970, 1, 1),
|
||||
lastDate: DateTime.now(),
|
||||
locale: Localizations.localeOf(context),
|
||||
prefixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
if (filteredSuggestions?.hasSuggestedDates ?? false)
|
||||
_buildSuggestionsSkeleton<DateTime>(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
class LocalDateTimeJsonConverter extends JsonConverter<DateTime, String> {
|
||||
@@ -11,6 +10,6 @@ class LocalDateTimeJsonConverter extends JsonConverter<DateTime, String> {
|
||||
|
||||
@override
|
||||
String toJson(DateTime object) {
|
||||
return object.toIso8601String();
|
||||
return object.toUtc().toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
30
pubspec.yaml
30
pubspec.yaml
@@ -155,33 +155,15 @@ flutter:
|
||||
- test/fixtures/document_types/
|
||||
- assets/changelogs/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/custom-fonts/#from-packages
|
||||
fonts:
|
||||
- family: RobotoMono
|
||||
fonts:
|
||||
- asset: assets/fonts/RobotoMono-Regular.ttf
|
||||
|
||||
|
||||
|
||||
flutter_native_splash:
|
||||
image: assets/logos/paperless_logo_green.png
|
||||
color: "#f9f9f9"
|
||||
|
||||
image_dark: assets/logos/paperless_logo_white.png
|
||||
color_dark: "#181818"
|
||||
|
||||
Reference in New Issue
Block a user