Flutter Timestamp input by TextField

eye-catch Dart and Flutter

I posted the following article before to get Date and Timestamp.

But sometimes it’s better to have a simple UI. I wanted text fields to input hours/minutes/seconds.

Go the my GitHub repository if you need the full code.

Sponsored links

Put three TextField on the same Row

Let’s make the layout first. We need to put three TextField with two colon. However, the following error occurs if we put TextField in Row widget.

The following assertion was thrown during performLayout():
An InputDecorator, which is typically created by a TextField, cannot have an unbounded width.
This happens when the parent widget does not provide a finite width constraint. For example, if the
InputDecorator is contained by a Row, then its width must be constrained. An Expanded widget or a
SizedBox can be used to constrain the width of the InputDecorator or the TextField that contains it.

The solution is described in the error. TextField needs to be wrapped by SizedBox with width for example.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class TimeInputByTextField extends HookConsumerWidget {
  TimeInputByTextField({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          title: Text("Time input by TextField"),
        ),
        body: Center(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(
                width: 50,  // width needs to be specified
                child: generateTextField(),
              ),
              Text(":"),
              SizedBox(
                width: 50,
                child: generateTextField(),
              ),
              Text(":"),
              SizedBox(
                width: 50,
                child: generateTextField(),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget generateTextField() {
    return TextField();
  }
}

The same logic is needed for the three TextField. So I defined a method that returns TextField. We can define the desired TextField behavior only once in this method.

Sponsored links

Set controller created by Flutter Hooks and control the input value

We need a controller respectively to get the input value. To make it easier, I use Flutter Hooks.

Create TextEditingController by Flutter Hooks

When a user inputs a value, the data must be checked. The check can be done in the listener of a controller.

The listener has to be added in useEffect() and it has to be removed when the widget is rebuilt to avoid adding the same listener multiple times.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class TimeInputByTextField extends HookConsumerWidget {
  TimeInputByTextField({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Add Controllers here
    final hourController = useTextEditingController(text: "00");
    final minController = useTextEditingController(text: "00");
    final secController = useTextEditingController(text: "00");

    useEffect(() {
      final hourListener = () => formatTime(controller: hourController);
      final minListener = () => formatTime(controller: minController, hasLimit: true);
      final secListener = () => formatTime(controller: secController, hasLimit: true);

      hourController.addListener(hourListener);
      minController.addListener(minListener);
      secController.addListener(secListener);

      // This is called when it's disposed or rebuild
      return () {
        hourController.removeListener(hourListener);
        minController.removeListener(minListener);
        secController.removeListener(secListener);
      };
    });

    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          title: Text("Time input by TextField"),
        ),
        body: Center(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(
                width: 50,
                child: generateTextField(hourController),
              ),
              Text(":"),
              SizedBox(
                width: 50,
                child: generateTextField(minController),
              ),
              Text(":"),
              SizedBox(
                width: 50,
                child: generateTextField(secController),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget generateTextField(TextEditingController controller) {
    return TextField(
      controller: controller,
    );
  }

  void formatTime({
    required TextEditingController controller,
    hasLimit = false,
  }) {
    // logic here...
  }
}

Number constraint input for TextField

The value must be number only. It must be defined in TextField. The following two configurations are enough to do. If the app is used on a mobile device, setting keyboardType might be enough. However, non-number value might be given if a user uses copy and paste. It’s robuster to configure inputFormatters.

// Properties in TextField
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],

Always 2 digits and reinput when it has focus

I want to achieve the following two things.

  1. It’s possible to input a new value without removing the existing value if a TextField has focus again.
  2. It should be shown with zero padding if a user gives only one value.

Let’s consider the first specification. The max length is 2 for all the 3 TextField. TextField has maxLength property but if we set it to 2, it’s impossible to give a new value again until the text is removed. So, the value needs to be cut when the length is 3. In addition, the cursor has to be at the end of the text when it has focus.

The second one is easy. If a user gives only one value and presses enter, 0 needs to be added to the value.

This is the code that I tried first.

void formatTime({
  required TextEditingController controller,
  hasLimit = false,
}) {
  if (controller.text.isEmpty) {
    controller.text = "00";
  }

  if (controller.text.length == 3) {
    controller.text = controller.text[2];
    // move the cursor at the end
    controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
    return;
  }
  final num = int.parse(controller.text);
  if (controller.text.length == 2) {
    return;
  }
  if (controller.text.length == 1 && hasLimit && num > 5) {
    controller.text = controller.text.padLeft(2, "0");
  }
}

The cursor needs to be set correctly when the text is modified. The cursor is located at the left side of the new value for some reason.

This code looks to be working but formatTime() is called twice when controller.text.length == 3. The listener is triggered when something is updated. It updates selection property. Therefore, it’s called twice.

Let’s check whether the value is updated or not.

void formatTime(
  WidgetRef ref, {
  required StateProvider<String> lastProvider,
  required TextEditingController controller,
  hasLimit = false,
}) {
  // check whether the value is updated or not
  if (ref.read(lastProvider.notifier).state == controller.text) {
    return;
  }
  if (controller.text.isEmpty) {
    controller.text = "00";
  }
  // update the last value
  ref.read(lastProvider.notifier).state = controller.text;
  if (controller.text.length == 3) {
    controller.text = controller.text[2];
    controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
    return;
  }
  final num = int.parse(controller.text);
  if (controller.text.length == 2) {
    return;
  }
  if (controller.text.length == 1 && hasLimit && num > 5) {
    controller.text = controller.text.padLeft(2, "0");
  }
}

We still need to do with the cursor position and the padding. When TextField is tap, the cursor position might not be at the end. We need to set it correctly when the widget has focus.

When a user gives only one number and enter, zero padding doesn’t work. The logic needs to be added onSubmitted. It’s not perfect because paddingZero() is not triggered when a user taps another TextField. I haven’t found the solution for it. I tried to add the logic to onTapOutside but it’s not triggered when another TextField is tapped.

Widget generateTextField(TextEditingController controller) {
  return TextField(
    controller: controller,
    keyboardType: TextInputType.number,
    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
    onTap: () => controller.selection = TextSelection.fromPosition(
      TextPosition(offset: controller.text.length),
    ),
    onSubmitted: (value) => paddingZero(controller),
    onTapOutside: (event) => paddingZero(controller),
    maxLength: 3,
  );
}

void paddingZero(TextEditingController controller) {
  if (controller.text.length == 1) {
    controller.text = controller.text.padLeft(2, "0");
  }
}

Move the focus to the next TextField

If the following is configured in TextField, focus moves to the next when input is completed.

textInputAction: TextInputAction.next,

But it’s not enough for my case. When TextField is used for minutes or seconds, the range is 0 – 59. The focus should be moved to the TextField for seconds if a user gives

  • 6, 7, 8, or 9 for the first input
  • two digits

on TextField for minutes. If it’s on TextField for seconds, the focus should be unfocused.

A focus can be moved to the desired widget by passing FocusNode and calling requestFocus.

void formatTime(
  BuildContext context,
  WidgetRef ref, {
  required StateProvider<String> lastProvider,
  required TextEditingController controller,
  FocusNode? nextFocus,
  hasLimit = false,
}) {
  if (ref.read(lastProvider.notifier).state == controller.text) {
    return;
  }
  if (controller.text.isEmpty) {
    controller.text = "00";
  }
  ref.read(lastProvider.notifier).state = controller.text;
  if (controller.text.length == 3) {
    controller.text = controller.text[2];
    controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
    return;
  }
  final num = int.parse(controller.text);
  if (controller.text.length == 2) {
    // added here
    focusOrUnfocus(context, nextFocus);
    return;
  }
  if (controller.text.length == 1 && hasLimit && num > 5) {
    controller.text = controller.text.padLeft(2, "0");
    // added here
    focusOrUnfocus(context, nextFocus);
  }
}

void focusOrUnfocus(BuildContext context, FocusNode? nextFocus) {
  if (nextFocus != null) {
    FocusScope.of(context).requestFocus(nextFocus);
  } else {
    FocusScope.of(context).unfocus();
  }
}

To make it work as expected, we need to create FocusNode and pass it correctly.

  Widget build(BuildContext context, WidgetRef ref) {
    final hourFocus = useFocusNode();
    final minFocus = useFocusNode();
    final secFocus = useFocusNode();

    // omit the code ...

    useEffect(() {
      // move to minutes
      final hourListener =
          () => formatTime(context, ref, lastProvider: hourLastProvider, controller: hourController, nextFocus: minFocus);
      // move to seconds
      final minListener = () => formatTime(context, ref,
          lastProvider: minLastProvider, controller: minController, nextFocus: secFocus, hasLimit: true);
      // unfocus
      final secListener =
          () => formatTime(context, ref, lastProvider: secLastProvider, controller: secController, hasLimit: true);

          // omit the code ...
    });

    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          title: Text("Time input by TextField"),
        ),
        body: Center(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(
                width: 50,
                child: generateTextField(hourController, hourFocus),
              ),
              Text(":"),
              SizedBox(
                width: 50,
                child: generateTextField(minController, minFocus),
              ),
              Text(":"),
              SizedBox(
                width: 50,
                child: generateTextField(secController, secFocus),
              ),
            ],
          ),
        ),
      ),
    );
  }

Related articles

Check the following post if you don’t know how to use Riverpod.

Comments

Copied title and URL