Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active January 23, 2024 18:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save PlugFox/5b152b12c45484ba2f52295f9351d3de to your computer and use it in GitHub Desktop.
Save PlugFox/5b152b12c45484ba2f52295f9351d3de to your computer and use it in GitHub Desktop.
Month/Year Input
/*
* Month/Year Input
* https://gist.github.com/PlugFox/5b152b12c45484ba2f52295f9351d3de
* https://dartpad.dev?id=5b152b12c45484ba2f52295f9351d3de
* Matiunin Mikhail <plugfox@gmail.com>, 23 January 2024
*/
// ignore_for_file: curly_braces_in_flow_control_structures
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runZonedGuarded<void>(
() => runApp(App(controller: ValueNotifier<DateTime?>(null))),
(error, stackTrace) => print('Top level exception: $error'),
);
class App extends StatelessWidget {
const App({
required this.controller,
super.key,
});
final ValueNotifier<DateTime?> controller;
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Month/Year Input',
theme: ThemeData.dark(),
home: Scaffold(
appBar: AppBar(
title: const Text('Month/Year Input'),
),
body: SafeArea(
child: Align(
alignment: const Alignment(0, -.25),
child: SizedBox(
width: 240,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Builder(
builder: (context) {
final now = DateTime.now();
return MonthPicker(
controller: controller,
label: 'Pick a month',
from: now.subtract(const Duration(days: 365 * 10)),
to: now,
);
},
),
const SizedBox(height: 16),
ValueListenableBuilder<DateTime?>(
valueListenable: controller,
builder: (context, dt, child) => Text(
switch (dt) {
null => 'No date selected',
DateTime dt =>
'Selected date: ${dt.month}/${dt.year}',
},
),
),
],
),
),
),
),
),
);
}
class MonthPicker extends StatefulWidget {
const MonthPicker({
required this.controller,
this.label,
this.from,
this.to,
super.key, // ignore: unused_element
});
final String? label;
final ValueNotifier<DateTime?> controller;
final DateTime? from;
final DateTime? to;
@override
State<MonthPicker> createState() => _MonthPickerState();
}
class _MonthPickerState extends State<MonthPicker> {
final FocusNode _focusNode = FocusNode();
static String format(DateTime dt) =>
'${dt.month.toString().padLeft(2, '0')}/${dt.year.toString().padLeft(4, '0')}';
late final TextEditingController _textController;
final ValueNotifier<bool?> _warning = ValueNotifier<bool?>(null);
@override
void initState() {
_textController = TextEditingController(
text: switch (widget.controller.value) {
null => null,
DateTime dt => format(dt),
});
_textController.addListener(_onTextChanged);
widget.controller.addListener(_onDateChanged);
super.initState();
}
@override
void didUpdateWidget(covariant MonthPicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.controller, widget.controller)) {
oldWidget.controller.removeListener(_onDateChanged);
widget.controller.addListener(_onDateChanged);
}
}
@override
void dispose() {
widget.controller.removeListener(_onDateChanged);
_textController.removeListener(_onTextChanged);
_textController.dispose();
super.dispose();
}
void _onTextChanged() {
if (!mounted) return;
final text = _textController.text;
if (text.isEmpty) {
// Nothing to check
widget.controller.value = null;
_warning.value = null;
return;
}
final numbers = text
.split(RegExp(r'[^\d]'))
.where((s) => s.isNotEmpty)
.map<int?>(int.tryParse)
.whereType<int>()
.toList(growable: false);
if (numbers.length != 2) {
// We got only month
widget.controller.value = null;
_warning.value = null;
return;
}
final <int>[month, year] = numbers;
if (year < 1000) {
// Year is still incomplete
widget.controller.value = null;
_warning.value = null;
return;
}
final dt = DateTime(year, month, 1);
if (dt.isBefore(widget.from ?? DateTime(0))) {
// Date is too old
widget.controller.value = null;
_warning.value = true;
HapticFeedback.mediumImpact().ignore();
return;
} else if (widget.to != null && dt.isAfter(widget.to!)) {
// Date is too new
widget.controller.value = null;
_warning.value = true;
HapticFeedback.mediumImpact().ignore();
return;
} else {
// Date is valid, save it
widget.controller.value = dt;
_warning.value = false;
}
}
void _onDateChanged() => scheduleMicrotask(() {
if (!mounted) return;
final dt = widget.controller.value;
if (dt == null) return;
final newText = format(dt);
if (newText == _textController.text) return;
_textController.text = newText;
});
@override
Widget build(BuildContext context) => TextField(
focusNode: _focusNode,
controller: _textController,
maxLines: 1,
expands: false,
keyboardType: TextInputType.datetime,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[0-9\/\.\-\s]')),
LengthLimitingTextInputFormatter(7),
_MonthYearInputFormatter(max: widget.to),
],
textInputAction: TextInputAction.done,
maxLength: 7,
textAlign: TextAlign.center,
decoration: InputDecoration(
isCollapsed: false,
isDense: false,
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(width: 1),
),
labelText: widget.label,
hintText: 'mm/yyyy',
helperText: null,
suffixIcon: ValueListenableBuilder<bool?>(
valueListenable: _warning,
builder: (context, warning, child) => switch (warning) {
true => const Icon(
Icons.warning,
color: Colors.red,
),
false => const Icon(
Icons.check,
color: Colors.green,
),
null => const Icon(
Icons.keyboard,
color: Colors.grey,
)
},
),
counter: const SizedBox.shrink(),
errorText: null,
helperMaxLines: 0,
errorMaxLines: 0,
),
);
}
class _MonthYearInputFormatter extends TextInputFormatter {
_MonthYearInputFormatter({
this.max, // ignore: unused_element
});
final DateTime? max;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
final l = newValue.text
.split(RegExp(r'[^\d]'))
.where((s) => s.isNotEmpty)
.map<({String s, int? n})>((s) => (s: s, n: int.tryParse(s)))
.whereType<({String s, int n})>()
.toList();
if (l.length > 2) return oldValue; // Too many splitters, should be only one
final m = l.firstOrNull;
// If month is empty, return empty string
if (m == null)
return const TextEditingValue(
text: '', selection: TextSelection.collapsed(offset: 0));
final y = l.length == 2 ? l.last : null;
// Check if month is valid
if (m.s.length > 2) {
final newText = '${m.s.substring(0, 2)}/${m.s.substring(2)}${y?.s ?? ''}';
// If month is too long, return only first two digits and other add to year
return formatEditUpdate(
oldValue,
TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: newText.length,
affinity: newValue.selection.affinity,
),
),
);
} else if (m.n > 12) {
// If month is bigger than 12, use it as year
final newText = '12/${y?.s ?? ''}';
return formatEditUpdate(
oldValue,
TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: newText.length,
affinity: newValue.selection.affinity,
),
),
);
}
if (y != null && y.n > 9999) return oldValue;
if (max case DateTime max) {
final dt = DateTime(y?.n ?? 0, m.n, 1);
if (dt.isAfter(max)) {
final newText = '${max.month.toString().padLeft(2, '0')}/${max.year}';
return TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: newText.length,
affinity: newValue.selection.affinity,
),
);
}
}
final newText = newValue.text.length > 2
? '${m.n.clamp(1, 12).toString().padLeft(2, '0')}/${y == null || y.s.isEmpty ? '' : y.n}'
: m.s;
if (newText == newValue.text) return newValue;
return TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: newText.length,
affinity: newValue.selection.affinity,
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment