DEV3 Technologies home

Phone number login flow

Two-step phone sign-in with a small state machine, resend countdown and clear error handling around any backend.

FlutterAuthenticationflutterloginphonestatemachine

Code

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

abstract class PhoneAuthApi {
  Future<void> requestCode(String phone);
  Future<void> verifyCode(String phone, String code); // throws on a wrong code
}

enum LoginStep { phone, code }

class PhoneLoginController extends ChangeNotifier {
  PhoneLoginController(this._api);
  final PhoneAuthApi _api;

  LoginStep step = LoginStep.phone;
  bool busy = false;
  String? error;
  String phone = '';
  int secondsLeft = 0;
  Timer? _timer;

  Future<void> sendCode(String number) async {
    if (busy || secondsLeft > 0) return;
    await _guard(() async {
      phone = number.trim();
      await _api.requestCode(phone);
      step = LoginStep.code;
      _startCountdown();
    });
  }

  Future<bool> submit(String code) async {
    var ok = false;
    await _guard(() async {
      await _api.verifyCode(phone, code);
      ok = true;
    });
    return ok;
  }

  void editNumber() {
    step = LoginStep.phone;
    error = null;
    notifyListeners();
  }

  Future<void> _guard(Future<void> Function() job) async {
    busy = true;
    error = null;
    notifyListeners();
    try {
      await job();
    } catch (_) {
      error = 'Something went wrong. Check the details and try again.';
    } finally {
      busy = false;
      notifyListeners();
    }
  }

  void _startCountdown([int from = 30]) {
    secondsLeft = from;
    _timer?.cancel();
    _timer = Timer.periodic(const Duration(seconds: 1), (t) {
      secondsLeft--;
      if (secondsLeft <= 0) t.cancel();
      notifyListeners();
    });
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }
}

class PhoneLoginView extends StatelessWidget {
  PhoneLoginView({super.key, required this.c, required this.onSignedIn});
  final PhoneLoginController c;
  final VoidCallback onSignedIn;
  final _field = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: c,
      builder: (context, _) {
        final inCode = c.step == LoginStep.code;
        return Padding(
          padding: const EdgeInsets.all(24),
          child: Column(mainAxisSize: MainAxisSize.min, children: [
            TextField(
              controller: _field,
              keyboardType: TextInputType.phone,
              decoration: InputDecoration(
                labelText: inCode ? 'Code sent to ${c.phone}' : 'Phone number',
                errorText: c.error,
              ),
            ),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: c.busy
                  ? null
                  : () async {
                      final text = _field.text;
                      _field.clear();
                      if (!inCode) return c.sendCode(text);
                      if (await c.submit(text)) onSignedIn();
                    },
              child: Text(inCode ? 'Verify' : 'Send code'),
            ),
            if (inCode) ...[
              TextButton(
                onPressed: c.secondsLeft > 0 || c.busy ? null : () => c.sendCode(c.phone),
                child: Text(c.secondsLeft > 0 ? 'Resend in ${c.secondsLeft}s' : 'Resend code'),
              ),
              TextButton(onPressed: c.editNumber, child: const Text('Change number')),
            ],
          ]),
        );
      },
    );
  }
}

How it works

What it solves

Phone sign-in is two screens of state: ask for a number, then ask for the code, with resend rules in between. Writing it inline in a widget leads to scattered booleans and double submissions.

How it works

PhoneLoginController models the flow as a LoginStep enum plus busy, error and a resend countdown. All backend calls go through _guard, which sets busy, clears the old error, catches failures into one friendly message, and always resets busy. The abstract PhoneAuthApi keeps the controller independent from any HTTP client, so you can unit test it with a fake.

The view is a single field reused for both steps. After a code is sent, a one second Timer.periodic drives the countdown and the resend button re-enables at zero.

Adapting it

Implement PhoneAuthApi with your service and navigate in onSignedIn. Combine with the OTP boxes snippet by swapping the text field for that widget in the code step. Normalise the number to international format before calling the API.

Pitfalls

  • The field is cleared on every submit here; keep the number in the controller if you want to show it again.
  • Rate limits belong on the server; the client countdown is only a courtesy.
  • Dispose the controller to stop the timer, or it fires after the screen is gone.

How to use it

Copy the file to lib/auth/, implement PhoneAuthApi, and show the view.

final c = PhoneLoginController(MyPhoneAuthApi());
PhoneLoginView(c: c, onSignedIn: () => Navigator.of(context).pushReplacementNamed('/home'));

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets