Skip to main content

Flutter UI challenge: rotary passcode (static design)

· 46 min read

Retro rotary passcode input UI challenge made with Flutter. In the first part, the static design of the UI is implemented.

Header image - Rotary passcode Flutter UI challenge (static design)

Some time ago, I implemented the rotary passcode UI and shared the code with the Flutter community. I may not be the sharpest tool in the shed, and I also promised to create an in-depth tutorial on how I implemented it. In this article, I will share the first part of the tutorial, which will cover the static design of the rotary passcode UI.

tip

If you prefer video content, check out the video version of this article on YouTube.

Overview

This is the original Design challenge I found on Twitter:

Original tweet of the UI challenge

Initially, it was implemented using SwiftUI, but I decided to implement it using Flutter and hopefully learn something new. I will split this design challenge into two parts. The first one is static UI - simply, implementing all the visible layout elements. In the second part, I will cover the motion design part of the challenge - animations, gestures, transitions and other fancy eye candies visible on the screen.

Common UI elements

There are two input modes in this UI challenge - the rotary dial input and the simple passcode. The same screen is used in both cases, just the elements are different based on the selected input mode.

Common UI elements

What’s common between them is the header text and the mode switch button at the bottom right corner. Start with a simple project where the setPreferredOrientations ensures that the app can only be used in portrait mode.

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

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

runApp(const _App());
}

class _App extends StatelessWidget {
const _App();


Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Rotary Passcode',
home: Scaffold(),
);
}
}

For the app’s typography, we will use Google Fonts. Thus, add the missing project dependency to the pubspec.yaml file.

pubspec.yaml
dependencies:
flutter:
sdk: flutter
google_fonts: ^3.0.1

After going through the available Google Fonts, it seems that the Kanit font is a close enough option compared to the original design. Use it as the main text theme.

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

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

runApp(const _App());
}

class _App extends StatelessWidget {
const _App();


Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Rotary Passcode',
theme: Theme.of(context).copyWith(
textTheme: GoogleFonts.kanitTextTheme(),
),
home: const Scaffold(),
);
}
}

Implementing passcode input view

To start with, create an empty Stateful PasscodeInputView widget with the expectedCode property...

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

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return const Scaffold();
}
}

… and use it as a home page for the app.

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

import 'passcode_input_view.dart';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

runApp(const _App());
}

class _App extends StatelessWidget {
const _App();


Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Rotary Passcode',
theme: Theme.of(context).copyWith(
textTheme: GoogleFonts.kanitTextTheme(),
),
home: const PasscodeInputView(expectedCode: '6942'),
);
}
}

In the main view, add the SafeArea widget to ensure system elements not overlapping the UI elements.

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

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return const Scaffold(
body: SafeArea(
child: Placeholder(),
),
);
}
}

Then, add some spacing from the screen boundaries using the Padding widget.

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

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return const Scaffold(
body: SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Placeholder(),
),
),
);
}
}

For the layout, use a single Column widget. Make sure to use the CrossAxisAlignment.stretch alignment property so that the elements would take a maximum width in the column.

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

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Expanded(child: Placeholder()),
],
),
),
),
);
}
}

Now, add the header text and some spacing below it…

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

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
),
const SizedBox(height: 32.0),
const Expanded(child: Placeholder()),
],
),
),
),
);
}
}

… as well as style it with the displaySmall text style from our theme.

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

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
const Expanded(child: Placeholder()),
],
),
),
),
);
}
}

Here is what the header text looks like:

Header text implementation

Implementing input mode switch button

Next, create an input mode switch button that requires two properties. simpleInputMode indicates the current button state whether a rotary dial or a passcode input is used. The onModeChanged callback will be used on button click.

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

class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});

final bool simpleInputMode;
final VoidCallback onModeChanged;


Widget build(BuildContext context) {
return const Placeholder();
}
}

Add the button at the bottom of the passcode input view.

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

import 'widgets/input_mode_button.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
const Expanded(child: Placeholder()),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: true,
onModeChanged: () {},
),
),
],
),
),
),
);
}
}

To implement the button, a simple Text widget wrapped with the GestureDetector is more than enough. So, we wrap the placeholder first and pass the callback.

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

class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});

final bool simpleInputMode;
final VoidCallback onModeChanged;


Widget build(BuildContext context) {
return GestureDetector(
onTap: onModeChanged,
child: const Placeholder(),
);
}
}

Based on the current button mode, provide the corresponding upper-cased label…

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

class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});

final bool simpleInputMode;
final VoidCallback onModeChanged;


Widget build(BuildContext context) {
return GestureDetector(
onTap: onModeChanged,
child: Text(
(simpleInputMode ? 'Original' : 'Simplify').toUpperCase(),
),
);
}
}

… and apply the headlineSmall text style from the theme data.

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

class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});

final bool simpleInputMode;
final VoidCallback onModeChanged;


Widget build(BuildContext context) {
return GestureDetector(
onTap: onModeChanged,
child: Text(
(simpleInputMode ? 'Original' : 'Simplify').toUpperCase(),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(color: Colors.black, fontWeight: FontWeight.bold),
),
);
}
}

And here is the result of the implemented common UI elements:

Common UI elements implementation

All the basic shareable elements are implemented. Now comes the fun part - different input types.

Passcode input

Let’s create an abstract class for the global constants.

constants.dart
abstract class Constants {}

First, define all the input values used in the UI.

constants.dart
abstract class Constants {
static const inputValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
}

Then, define constants for the rotary dial component.

constants.dart
abstract class Constants {
static const inputValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

static const rotaryRingPadding = 4.0;
static const rotaryRingWidth = 80.0;
}

Finally, add the padding value for a single dial number…

constants.dart
abstract class Constants {
static const inputValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

static const rotaryRingPadding = 4.0;
static const rotaryRingWidth = 80.0;

static const dialNumberPadding = 8.0;
}

… and use it to calculate the dial number radius in the UI.

constants.dart
abstract class Constants {
static const inputValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

static const rotaryRingPadding = 4.0;
static const rotaryRingWidth = 80.0;

static const dialNumberPadding = 8.0;
static const dialNumberRadius =
rotaryRingWidth / 2 - (rotaryRingPadding + dialNumberPadding);
}

For the passcode input, individual dial numbers are positioned evenly in the 4x3 grid. Start with the placeholder widget…

passcode_input.dart
import 'package:flutter/widgets.dart';

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return const Placeholder();
}
}

… and use it in the PasscodeInputView.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {

Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
const Expanded(
child: PasscodeInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: true,
onModeChanged: () {},
),
),
],
),
),
),
);
}
}

The passcode input consists of four rows - add a Column widget to hold them…

passcode_input.dart
import 'package:flutter/widgets.dart';

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return Column(
children: const [],
);
}
}

… and create three empty Row widgets. The fourth row will contain only a single dial number, we will add it to the Column a bit later.

passcode_input.dart
import 'package:flutter/widgets.dart';

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < 3; i++)
Row(
children: const [],
),
],
);
}
}

Each row (except the last one) contains three dial numbers. For now, use the Text widget just to render the corresponding values from constants.

passcode_input.dart
import 'package:flutter/widgets.dart';

import '../../constants.dart';

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < 3; i++)
Row(
children: [
for (var j = 0; j < 3; j++)
Text(Constants.inputValues[i * 3 + j].toString()),
],
),
],
);
}
}

As mentioned before, add the last digit separately to the Column.

passcode_input.dart
import 'package:flutter/widgets.dart';

import '../../constants.dart';

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < 3; i++)
Row(
children: [
for (var j = 0; j < 3; j++)
Text(Constants.inputValues[i * 3 + j].toString()),
],
),
Text(Constants.inputValues.last.toString()),
],
);
}
}

Also, do not forget to space the elements evenly both, in vertical and horizontal directions.

passcode_input.dart
import 'package:flutter/widgets.dart';

import '../../constants.dart';

const _alignment = MainAxisAlignment.spaceEvenly;

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return Column(
mainAxisAlignment: _alignment,
children: [
for (var i = 0; i < 3; i++)
Row(
mainAxisAlignment: _alignment,
children: [
for (var j = 0; j < 3; j++)
Text(Constants.inputValues[i * 3 + j].toString()),
],
),
Text(Constants.inputValues.last.toString()),
],
);
}
}

The current prototype looks like this:

Passcode input prototype

Implementing dial number component

Now, it’s time to give a shape to a single dial number. Fun fact - the exact same component is used for both, passcode and rotary dial input modes.

The same dial number component used in both input views

First, create a base DialNumber widget that accepts the input number.

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

class DialNumber extends StatelessWidget {
const DialNumber(
this.number, {
super.key,
});

final int number;


Widget build(BuildContext context) {
return Text('$number');
}
}

As usual, replace the placeholder Text elements with the new component.

passcode_input.dart
import 'package:flutter/widgets.dart';

import '../../constants.dart';
import '../dial_number.dart';

const _alignment = MainAxisAlignment.spaceEvenly;

class PasscodeInput extends StatelessWidget {
const PasscodeInput({super.key});


Widget build(BuildContext context) {
return Column(
mainAxisAlignment: _alignment,
children: [
for (var i = 0; i < 3; i++)
Row(
mainAxisAlignment: _alignment,
children: [
for (var j = 0; j < 3; j++)
DialNumber(Constants.inputValues[i * 3 + j]),
],
),
DialNumber(Constants.inputValues.last),
],
);
}
}

Then, give a circular shape to the input with a black background.

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

class DialNumber extends StatelessWidget {
const DialNumber(
this.number, {
super.key,
});

final int number;


Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Text('$number'),
);
}
}

Luckily, the specific size of our input is already calculated so use it directly from the constants file.

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

import '../constants.dart';

class DialNumber extends StatelessWidget {
const DialNumber(
this.number, {
super.key,
});

final int number;


Widget build(BuildContext context) {
return Container(
height: Constants.dialNumberRadius * 2,
width: Constants.dialNumberRadius * 2,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Text('$number'),
);
}
}

Then, center the label…

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

import '../constants.dart';

class DialNumber extends StatelessWidget {
const DialNumber(
this.number, {
super.key,
});

final int number;


Widget build(BuildContext context) {
return Container(
height: Constants.dialNumberRadius * 2,
width: Constants.dialNumberRadius * 2,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Text('$number'),
);
}
}

… and also apply the headlineMedium text style to it.

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

import '../constants.dart';

class DialNumber extends StatelessWidget {
const DialNumber(
this.number, {
super.key,
});

final int number;


Widget build(BuildContext context) {
return Container(
height: Constants.dialNumberRadius * 2,
width: Constants.dialNumberRadius * 2,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Text(
'$number',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
),
);
}
}

Here is the result of the dial number component:

Dial number implementation

That’s all for the passcode input mode. Easy, right? Now, it’s time for the essential part of this tutorial - the rotary dial input.

Rotary dial input

As usual, start with a placeholder widget.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
return const Placeholder();
}
}

To be able to switch between different input modes, we need to track the currently active mode - the simpleInputMode flag indicates just that.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
var _simpleInputMode = false;


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
const Expanded(
child: PasscodeInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: true,
onModeChanged: () {},
),
),
],
),
),
),
);
}
}

Switching between different modes is as simple as toggling this flag - add the onModeChanged method.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
var _simpleInputMode = false;

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
const Expanded(
child: PasscodeInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: true,
onModeChanged: () {},
),
),
],
),
),
),
);
}
}

Then, pass the flag and mode changed callback to the InputModeButton instead of hardcoded values.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
var _simpleInputMode = false;

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
const Expanded(
child: PasscodeInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}

Also, let’s not forget to use the correct input mode based on the simpleInputMode flag value - PasscodeInput when the value is true, and the RotaryDialInput otherwise.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
var _simpleInputMode = false;

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Expanded(
child: _simpleInputMode
? const PasscodeInput()
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}

To implement the RotaryDialInput, we need to know the view constraints to size the component properly. Once you hear anything about the view constraints, probably you need a LayoutBuilder widget.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return const Placeholder();
},
);
}
}

From the constraints, get the maximum width of the view and use it to set the size of the component. Since the RotaryDialInput is circular, the height and width properties are equal.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);

return const Placeholder();
},
);
}
}

Multiple custom painters are needed to draw the input component on the screen. Let’s use an empty one for now and pass the size value.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);

return CustomPaint(
size: size,
);
},
);
}
}

Implementing rotary dial ring background

The first shape is a black background ring of the rotary dial input. First, create a boilerplate painter extending the CustomPainter class.

rotary_dial_background_painter.dart
import 'package:flutter/rendering.dart';

class RotaryDialBackgroundPainter extends CustomPainter {
const RotaryDialBackgroundPainter();


void paint(Canvas canvas, Size size) {}


bool shouldRepaint(RotaryDialBackgroundPainter oldDelegate) => false;
}

In the input component, pass it as a painter object to the CustomPaint widget.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

import 'rotary_dial_background_painter.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);

return CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
);
},
);
}
}

As mentioned previously, to create a RotaryDialInput widget, we will use multiple painter objects stacked on top of each. Thus, add the Stack widget and align all the children widgets in the center.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

import 'rotary_dial_background_painter.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);

return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
],
);
},
);
}
}

Since we will draw custom components on a blank canvas, a useful util method to have is calculating the center Offset value from the Size object.

utils.dart
import 'dart:ui';

extension SizeX on Size {
Offset get centerOffset => Offset(width / 2, height / 2);
}

The rotary dial background is a simple black ring. So define the painter (in other words, the styling used to draw a shape on a canvas) as a black stroke of a specific width from the constants.

rotary_dial_background_painter.dart
import 'package:flutter/rendering.dart';

import '../../constants.dart';

class RotaryDialBackgroundPainter extends CustomPainter {
const RotaryDialBackgroundPainter();


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;
final paint = Paint()
..color = const Color.fromRGBO(0, 0, 0, 1.0)
..strokeWidth = ringWidth
..style = PaintingStyle.stroke;
}


bool shouldRepaint(RotaryDialBackgroundPainter oldDelegate) => false;
}

Then, draw the shape on the screen by using the canvas.drawArc() method. As a first parameter, pass the rectangle that should contain the given arc. Then, to draw a full circle set the startAngle to 0 and the sweepAngle to a full circle value in radians.

rotary_dial_background_painter.dart
import 'dart:math' as math;

import 'package:flutter/rendering.dart';

import '../../constants.dart';
import '../../utils.dart';

class RotaryDialBackgroundPainter extends CustomPainter {
const RotaryDialBackgroundPainter();


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;
final paint = Paint()
..color = const Color.fromRGBO(0, 0, 0, 1.0)
..strokeWidth = ringWidth
..style = PaintingStyle.stroke;

canvas.drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
0,
math.pi * 2.0,
false,
paint,
);
}


bool shouldRepaint(RotaryDialBackgroundPainter oldDelegate) => false;
}

Here is the result of the implemented rotary dial background:

Dial number implementation

Positioning dial numbers

Next, position the dial numbers around the arc. For that, we need to calculate the dial number distance from the center which is identical for all the dial numbers, because… something something circle and geometry.

rotary_dial_input.dart
import 'package:flutter/widgets.dart';

import '../../constants.dart';
import 'rotary_dial_background_painter.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
const inputValues = Constants.inputValues;

return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;

return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
],
);
},
);
}
}

Then, iterate over the list of input values and position them around the arc with the Transform.translate method and pass a directional offset.

rotary_dial_input.dart
import 'dart:math' as math;

import 'package:flutter/widgets.dart';

import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
const inputValues = Constants.inputValues;

return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;

return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
],
);
},
);
}
}

The question remains, what the duck is a directional offset and how it’s calculated!?

Directional offset explained

From the documentation, Offset.fromDirection creates an offset from its direction and distance. The distance value is straightforward, we use the dialNumberDistanceFromCenter value. It’s a bit more entertaining with the direction.

As we see, the direction is provided in radians clockwise. However, in our case, the dial numbers are positioned counter-clockwise, thus we use the minus sign in the direction calculations.

To position the dial numbers properly, we need to calculate the specific direction for each of them. Thus, we split each quadrant into three equal parts or π/6 radians each - that’s where we get the multiplier in the formula.

Lastly, we calculate the direction for each i value in the loop. Here is the result:

Positioned dial numbers

Implementing rotary dial component

The last missing piece is the moving upper arc part of the rotary dial input. Again, use a CustomPaint widget and pass the same size value.

rotary_dial_input.dart
import 'dart:math' as math;

import 'package:flutter/widgets.dart';

import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
const inputValues = Constants.inputValues;

return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;

return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
CustomPaint(
size: size,
),
],
);
},
);
}
}

The base foreground painter is very similar to the background one, just pass the numberRadiusFromCenter that will be used to cut out the holes needed for dial numbers.

rotary_dial_foreground_painter.dart
import 'package:flutter/rendering.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

Don’t forget to pass an instance of the painter in the rotary dial input view.

rotary_dial_input.dart
import 'dart:math' as math;

import 'package:flutter/widgets.dart';

import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';

class RotaryDialInput extends StatelessWidget {
const RotaryDialInput({super.key});


Widget build(BuildContext context) {
const inputValues = Constants.inputValues;

return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;

return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
),
),
],
);
},
);
}
}

To make this painter work, define some more constants - yay. A helpful one is the position of the first dial number.

constants.dart
import 'dart:math' as math;

abstract class Constants {
static const inputValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

static const rotaryRingPadding = 4.0;
static const rotaryRingWidth = 80.0;

static const dialNumberPadding = 8.0;
static const dialNumberRadius =
rotaryRingWidth / 2 - (rotaryRingPadding + dialNumberPadding);
static const firstDialNumberPosition = math.pi / 3;
}

Then, calculate the maximum angle or the position of the last dial number and the sweep angle or the length of the arc. Oh, and to make it even more complex, these values are inverted a bit to fit the canvas.drawArc method. You see, usually, radians are calculated from the positive x-axis going counter-clockwise. But while using the drawArc method, positive angles are going clockwise around the oval. Improvise, adapt, overcome.

constants.dart
import 'dart:math' as math;

abstract class Constants {
static const inputValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

static const rotaryRingPadding = 4.0;
static const rotaryRingWidth = 80.0;

static const dialNumberPadding = 8.0;
static const dialNumberRadius =
rotaryRingWidth / 2 - (rotaryRingPadding + dialNumberPadding);
static const firstDialNumberPosition = math.pi / 3;

static const maxRotaryRingAngle = math.pi * 7 / 4;
static const maxRotaryRingSweepAngle = math.pi / 2 * 3;
}

Similarly, as with the background painter, create a painter object - just the width is a bit smaller this time.

rotary_dial_foreground_painter.dart
import 'package:flutter/rendering.dart';

import '../../constants.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;

final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;
}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

Then, create the foreground arc by using the already calculated constants.

rotary_dial_foreground_painter.dart
import 'package:flutter/rendering.dart';

import '../../constants.dart';
import '../../utils.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;

final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;

canvas.drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
Constants.firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);
}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

We need 10 cutouts for the dial numbers. So start from the calculated first dial number position and go around the arc clockwise this time. For each hole, draw a circle of the same radius as the dial number and apply the clear blend mode for it.

rotary_dial_foreground_painter.dart
import 'dart:math' as math;

import 'package:flutter/rendering.dart';

import '../../constants.dart';
import '../../utils.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;

final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;

canvas.drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
Constants.firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);

for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);

canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}
}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

At the moment, dial numbers are not visible. It’s because we are using blend modes, and when you hear anything about blend modes, well, it’s more or less black magic. Here is the proof:

Blends modes == dark magic

However, the thing that I know for sure is that you need to use the saveLayer method to draw everything as a single group and not as individual pieces. This is exactly what we need - we do not want to draw holes on top of the arc but rather cut them out as a single piece.

Save layer documentation

For this, save the layer before drawing an arc so that all the subsequent canvas calls will be appended to the same group.

rotary_dial_foreground_painter.dart
import 'dart:math' as math;

import 'package:flutter/rendering.dart';

import '../../constants.dart';
import '../../utils.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;

final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;

canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
Constants.firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);

for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);

canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}
}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

Then, call the canvas.restore() method at the end to pop the whole layer from the stack.

rotary_dial_foreground_painter.dart
import 'dart:math' as math;

import 'package:flutter/rendering.dart';

import '../../constants.dart';
import '../../utils.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;

final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;

canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
Constants.firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);

for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);

canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}

canvas.restore();
}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

Finally, draw a little white circle which will be used as a dial stop.

rotary_dial_foreground_painter.dart
import 'dart:math' as math;

import 'package:flutter/rendering.dart';

import '../../constants.dart';
import '../../utils.dart';

class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
});

final double numberRadiusFromCenter;


void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;

final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;

canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
Constants.firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);

for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);

canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}

canvas.drawCircle(
size.centerOffset +
Offset.fromDirection(math.pi / 6, numberRadiusFromCenter),
ringWidth / 6,
Paint()..color = const Color.fromRGBO(255, 255, 255, 1.0),
);

canvas.restore();
}


bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter;
}

That’s it - both of the input views are finished. The good news is that it was the most complex part of the static UI.

Rotary input view implementation

The only missing piece of the design is the passcode digits indicator.

Passcode digits indicator

In both modes, the same passcode digits component is used, just its alignment is a bit different. And sizing. And maybe spacing. However, trust me, it IS truly the same component. I can prove it to you 🫡

Start with an ugly rectangle as a placeholder.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
var _simpleInputMode = false;

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: Container(height: 36.0, color: Colors.black),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? const PasscodeInput()
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}

Then, create a base widget that requires two properties - the passcode digit values or the current user input and the input mode flag.

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

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<dynamic> passcodeDigitValues;
final bool simpleInputMode;


Widget build(BuildContext context) {
return Container(height: 36.0, color: Colors.black);
}
}

The passcode digit indicators will have different background and font colors based on the current state of the animation. Thus, create a dedicated model to track the state and use it instead of the dynamic type for the passcode digit values list.

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

class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});

final Color backgroundColor;
final Color fontColor;
final int? value;
}

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;


Widget build(BuildContext context) {
return Container(height: 36.0, color: Colors.black);
}
}

In the main view, store a list of the current input values.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
late final List<PasscodeDigit> _passcodeDigitValues;

var _simpleInputMode = false;

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: Container(height: 36.0, color: Colors.black),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? const PasscodeInput()
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}

In the initiState method, set the initial values and the corresponding properties for them.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
late final List<PasscodeDigit> _passcodeDigitValues;

var _simpleInputMode = false;


void initState() {
super.initState();

_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
}

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: Container(height: 36.0, color: Colors.black),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? const PasscodeInput()
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}

Then, replace the placeholder Container with the actual PasscodeDigits widget.

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

import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';

const _padding = 16.0;

class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});

final String expectedCode;


State<PasscodeInputView> createState() => _PasscodeInputViewState();
}

class _PasscodeInputViewState extends State<PasscodeInputView> {
late final List<PasscodeDigit> _passcodeDigitValues;

var _simpleInputMode = false;


void initState() {
super.initState();

_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
}

void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? const PasscodeInput()
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}

One more side quest. A useful util to have in any app is an addBetween method that inserts a specific separator widget between each element in the list. This will be used later for the UI.

utils.dart
import 'dart:ui';

extension SizeX on Size {
Offset get centerOffset => Offset(width / 2, height / 2);
}

extension ListExtension<T> on List<T> {
List<T> addBetween(T separator) {
if (length <= 1) {
return toList();
}

final newItems = <T>[];

for (int i = 0; i < length - 1; i++) {
newItems.add(this[i]);
newItems.add(separator);
}
newItems.add(this[length - 1]);

return newItems;
}
}

Before implementing the actual component, define some constants since the widget sizing is a bit different based on the selected input mode.

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

const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;

class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});

final Color backgroundColor;
final Color fontColor;
final int? value;
}

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;


Widget build(BuildContext context) {
return Container(height: 36.0, color: Colors.black);
}
}

First, set the default height of the widget.

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

const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;

class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});

final Color backgroundColor;
final Color fontColor;
final int? value;
}

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;


Widget build(BuildContext context) {
return const SizedBox(
height: _passcodeDigitSizeBig,
);
}
}

Then, add an empty Row to position the elements properly.

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

const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;

class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});

final Color backgroundColor;
final Color fontColor;
final int? value;
}

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;


Widget build(BuildContext context) {
return SizedBox(
height: _passcodeDigitSizeBig,
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [],
),
);
}
}

For each digit in the row, create a placeholder container…

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

const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;

class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});

final Color backgroundColor;
final Color fontColor;
final int? value;
}

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;


Widget build(BuildContext context) {
return SizedBox(
height: _passcodeDigitSizeBig,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < passcodeDigitValues.length; i++)
Container(
height: _passcodeDigitSizeBig,
width: _passcodeDigitSizeBig,
color: Colors.black,
)
],
),
);
}
}

… and add some spacing between the elements based on the selected input mode.

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

import '../../utils.dart';

const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;

class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});

final Color backgroundColor;
final Color fontColor;
final int? value;
}

class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});

final List<PasscodeDigit>