Password Manager

Password Manager #

I do not recommend actually using it. The app you will build following this project is overly simplified and therefore not suitable for real world use. I'm using this as an example because I want to do something other than a TODO-list app.

See List of password managers

Password manager - password screen Password manager - vault screen

Project setup #

Create your project as usual.

flutter create password_manager

This project will work on all platforms supported by Flutter. But you are free to only create it for the platforms you actually care about.

Then install the following packages.

flutter pub add json_annotation dev:build_runner dev:json_serializable equatable logging logging_appenders shared_preferences cryptography flutter_bloc fast_immutable_collections

Some of the packages are only need for development that’s why some are prefixed with dev:.

Packages will be explained when we use them. But here is a quick overview.

PackageDescription
build_runnerRunner for programmatic generation of Dart code
json_serializableGenerates code to help with JSON serialization
json_annotationAnnotations that tell json_serializable how the JSON should look
equatableHelps make data-classes that support equality comparison, hashCode and toString
logginglogging library for Dart
logging_appenderssome appenders for logging
shared_preferencesLocal key-value storage that works on all platforms
cryptographyImplementation of many cryptographic algorithms
flutter_blocFlutter package for BLoC
fast_immutable_collectionsMakes it simple to work with immutable collections

To learn more about equality in Dart and the equatable package, watch this.

Models #

This time we will use code generation for JSON serialization/deserialization.

The short version of how it works, is that you add annotations to a plain Dart class. json_serializable use these annotations to generate code for serialization/deserialization.

You can read more about it on the package page or Flutter docs on JSON.

Model classes #

Add the following files.

lib/models/credential.dart

class Credential {
  final String name;
  final String username;
  final String password;

  const Credential({
    required this.name,
    required this.username,
    required this.password,
  });
}

lib/models/encrypted_vault.dart

class EncryptedVault {
  final List<int> salt;
  final List<int> nonce;
  final List<int> mac;
  final List<int> cipherText;

  EncryptedVault({
    required this.salt,
    required this.nonce,
    required this.mac,
    required this.cipherText,
  });
}

lib/models/open_vault.dart

class OpenVault {
  List<Credential> credentials;
  Key key;
  OpenVault({required this.credentials, required this.key});
}

Key will be defined later

About the classes #

Credential represents a credential for a service (username+password). It also got a name field, so you can tell what service the credentials are for.

A vault is a container for a list of credentials. We got two versions of a vault.

  • EncryptedVault contains encrypted credentials. The List<int> represents a sequence of bytes.
  • OpenVault contains unencrypted credentials. It even got a key in it.

Refining the models #

Equatable #

Later on, we need to be able to compare to instances of Credential for equality (a == b). So we might as well implement it now.

The default behavior you get in Dart, is that two object are equal if they are the same instance. It can, however, be changed by overriding the operator ==. When overriding it, one should also override the hashCode method. It can be super annoying to do manually. So, we will use the equatable package to help us.

Equatable also supports toString. However, we don’t want passwords in logs, so we override it manually.

Change Credential class, so it extends Equatable. Now, you just need to implement the prop method to return all instance variables.

Here is the full version:

import 'package:equatable/equatable.dart';

class Credential extends Equatable {
  final String name;
  final String username;
  final String password;

  const Credential({
    required this.name,
    required this.username,
    required this.password,
  });

  @override
  List<Object?> get props => [name, username, password];

  @override
  String toString() => "$runtimeType($name, $username, ***)";
}

Json serializable #

The Credential class needs to be serializable so it can be encrypted. And, to make storage management simpler, we will also make EncryptedVault serializable.

The unencrypted OpenVault is only meant to exist in memory for a short-period of time. We will never store credentials unencrypted, so there is no need to make it serializable.

Make Credential and EncryptedVault JSON serializable by adding @JsonSerializable() to the class definition. Example:

@JsonSerializable()
class Credential {
  // ...
}

By default List<int> will be serialized as an array of numbers. We can make it more compact by base64 encoding it instead.

Add a converter to lib/models/encrypted_vault.dart.

class Base64Converter implements JsonConverter<List<int>, String> {
  const Base64Converter();
  @override
  List<int> fromJson(String json) => base64Decode(json);

  @override
  String toJson(List<int> bytes) => base64Encode(bytes);
}

Now add @Base64Converter() on a new line above each of the instance variables in EncryptedVault. Example:

@JsonSerializable()
class EncryptedVault {
  @Base64Converter()
  final List<int> salt;
  // ...
}

Code generation #

The code for toJson and fromJson can be generated based on the annotations you just added.

You can generate once with:

dart run build_runner build --delete-conflicting-outputs

And continuously with:

dart run build_runner watch --delete-conflicting-outputs

Try it out!

Nothing interested happened. It printed some stuff, that’s it.

[INFO] Generating build script completed, took 318ms
[INFO] Precompiling build script... completed, took 7.0s
[INFO] Building new asset graph completed, took 1.2s
[INFO] Checking for unexpected pre-existing outputs. completed, took 1ms
[INFO] Generating SDK summary completed, took 4.7s
[WARNING] source_gen:combining_builder on lib/models/encrypted_vault.dart:
encrypted_vault.g.dart must be included as a part directive in the input library with:
    part 'encrypted_vault.g.dart';
[WARNING] source_gen:combining_builder on lib/models/credentials.dart:
credentials.g.dart must be included as a part directive in the input library with:
    part 'credentials.g.dart';
[INFO] Running build completed, took 15.2s
[INFO] Caching finalized dependency graph completed, took 104ms
[INFO] Succeeded after 15.3s with 52 outputs (112 actions)

Let’s examine one of the warnings.

[WARNING] source_gen:combining_builder on lib/models/credentials.dart:
credentials.g.dart must be included as a part directive in the input library with:
    part 'credentials.g.dart';

Add part 'credential.g.dart'; right under the imports in lib/models/credential.dart. Now, try the code generation command again. This time it created a credential.g.dart file. The “g” is short for generated. If you open it up, you will find:

// GENERATED CODE - DO NOT MODIFY BY HAND

part of '../_bloc/credential.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

Credential _$CredentialFromJson(Map<String, dynamic> json) => Credential(
      name: json['name'] as String,
      username: json['username'] as String,
      password: json['password'] as String,
    );

Map<String, dynamic> _$CredentialToJson(Credential instance) =>
    <String, dynamic>{
      'name': instance.name,
      'username': instance.username,
      'password': instance.password,
    };

What we got are some helper methods that does JSON conversion to and from an instance of the class. You just have to add the following to Credentials:

  factory Credential.fromJson(Map<String, dynamic> json) =>
      _$CredentialFromJson(json);
  Map<String, dynamic> toJson() => _$CredentialToJson(this);

Practice by doing the same with lib/models/encrypted_vault.dart.

It might seem like a lot of work for very little. However, for a big project with many models that occasionally change, then this technique will save you a lot of hassle.

Why do you need to do so much work just to serialize something to JSON in Dart?

C# and some other programming languages that you might be familiar with. They got something called type introspection. In those programming languages you can write code that can inspect the fields of a type at runtime. This feature makes it possible to write a JSON serialization library that (mostly) just works with no additional configuration for any kind of object you through at it.

Type introspection adds overhead to the runtime . Since Dart is designed to declaratively write UI that updates with 60 FPS on commodity phones, the designers have decided against the added overhead.

Cryptography #

In all password managers, it’s important that the stored credentials are kept confidential. We can achieve the goal by only storing encrypted credentials.

The basic model is as follows:

  • Use Key derivation function (KDF) to derive an encryption key from a master-password.
  • The key is used to encrypt credentials before storage.
  • Credentials can be retrieved again by deriving an identical key given the same master-password.

Algorithms #

Our app will use cryptographic algorithms implemented in the cryptography package.

We will use Argon2id as KDF.

This code snippet is just an example. Don’t put it in your project.

import 'package:cryptography/cryptography.dart';

String masterPassword = askUserForMasterPassword();

KdfAlgorithm kdfAlgorithm = Argon2id(
  parallelism: 1,
  memory: 12288,
  iterations: 3,
  hashLength: 256 ~/ 8,
);

List<int> salt = List<int>.generate(32, (i) => SecureRandom.safe.nextInt(256));
SecretKey encryptionKey = await kdfAlgorithm.deriveKeyFromPassword(
    password: masterPassword, nonce: salt);

Settings for Argon2d are based on OWASP recommendations

We use AES-GCM for encryption.

Again, don’t put this in your project.

SecretBox secretBox = await AesGcm.with256bits()
  .encryptString(plainText, secretKey: encryptionKey);
List<int> cipherText = secretBox.cipherText;
List<int> nonce = secretBox.nonce;
List<int> mac = secretBox.mac.bytes;

Nonce is also known as initialization vector (IV). And MAC is a message authentication code/tag.

Protection #

It is often good practice to build an abstraction around external libraries/packages. It shields the rest of the application from API changes in the future versions of the package. And allows you to build an API for the functionality that better fits the domain.

First, we need a class for the encryption key that can be passed around in the application. We also want to limit access to the key. Our entire application to depend on the cryptography package. Sound like a difficult problem to solve, but it can actually be done pretty simple.

Add a new lib/infrastructure/protection.dart file with:

sealed class Key {
  void destroy();
}

class _Key extends Key {
  final SecretKey secretKey;
  final List<int> salt;

  _Key(this.secretKey, {required this.salt});

  @override
  void destroy() {
    secretKey.destroy();
    salt.setAll(0, List.filled(salt.length, 0));
  }
}

Sealed classes are abstract classes that cannot be extended outside their own package. See sealed class modifier.

It means that the only way to instantiate Key is through its _Key sub-class which isn’t accessible outside its own package. The only publicly available part of Key is its destroy() method. The application should call destroy when the key is no longer needed. In other words when we are closing the vault.

The cryptography package works with SecretBox class. It is just a container for cipher-text, nonce and salt. Add these extensions to the file, to easily convert between SecretBox and our EncryptedVault model class.

extension EncryptedVaultX on EncryptedVault {
  SecretBox toSecretBox() => SecretBox(cipherText, nonce: nonce, mac: Mac(mac));
}

extension SecretBoxX on SecretBox {
  EncryptedVault toEncryptedVault({required List<int> salt}) {
    return EncryptedVault(
      salt: salt,
      nonce: nonce,
      mac: mac.bytes,
      cipherText: cipherText,
    );
  }
}

Now for the actual vault protection layer. Add this to the same file.

class Protection {
  final KdfAlgorithm kdfAlgorithm;
  final Cipher cipher;

  Protection({required this.kdfAlgorithm, required this.cipher});

  Protection.sensibleDefaults()
      : kdfAlgorithm = Argon2id(
          parallelism: 1,
          memory: 12288,
          iterations: 3,
          hashLength: 256 ~/ 8,
        ),
        cipher = AesGcm.with256bits();

  Future<Key> createKey(String masterPassword) async {
    final salt = generateSalt();
    final secretKey = await kdfAlgorithm.deriveKeyFromPassword(
        password: masterPassword, nonce: salt);
    return _Key(secretKey, salt: salt);
  }

  List<int> generateSalt() =>
      List<int>.generate(32, (i) => SecureRandom.safe.nextInt(256));

  Future<Key> recreateKey(EncryptedVault vault, String masterPassword) async {
    final secretKey = await kdfAlgorithm.deriveKeyFromPassword(
      password: masterPassword,
      nonce: vault.salt,
    );
    return _Key(secretKey, salt: vault.salt);
  }

  Future<List<Credential>> decrypt(EncryptedVault encryptedVault, Key key) async {
    final jsonString = await cipher.decryptString(
      encryptedVault.toSecretBox(),
      secretKey: (key as _Key).secretKey,
    );
    final json = jsonDecode(jsonString) as List<dynamic>;
    return List<Credential>.from(json.map((e) => Credential.fromJson(e)));
  }

  Future<EncryptedVault> encrypt(OpenVault openVault) async {
    final key = (openVault.key as _Key);
    final encrypted = await cipher.encryptString(
        jsonEncode(openVault.credentials),
        secretKey: key.secretKey);
    return encrypted.toEncryptedVault(salt: key.salt);
  }
}

The Protection class can create an encryption key from a password. It can then encrypt and decrypt a vault with the key.

It can be instantiated with different algorithms, but also provides a Protection.sensibleDefaults() to instantiate it with some sensible defaults. Being able to change the algorithms allows you to instantiate it with a dummy implementation to speed up tests.

Storage #

We use SharedPreferences from shared_preferences package for storage. It provides a simple key-value API to store app data that works on all platforms. The underlying storage mechanism differs between platforms. See table:

PlatformLocation
AndroidSharedPreferences
iOSNSUserDefaults
LinuxIn the XDG_DATA_HOME directory
macOSNSUserDefaults
WebLocalStorage
WindowsIn the roaming AppData directory

Let’s build an small abstraction around it too. In lib/infrastructure/storage.dart, add:

class Storage {
  static const _key = 'data';
  final SharedPreferences _preferences;

  Storage._(this._preferences);

  bool get exits => _preferences.containsKey(_key);

  static Future<Storage> create() async {
    return Storage._(await SharedPreferences.getInstance());
  }

  Future<bool> save(EncryptedVault vault) =>
      _preferences.setString(_key, jsonEncode(vault.toJson()));

  EncryptedVault? load() {
    final json = _preferences.getString(_key);
    if (json == null) return null;
    return EncryptedVault.fromJson(jsonDecode(json));
  }

  delete() => _preferences.clear();
}

To simplify things, we just JSON encode the entire vault and store it as a single value.

SharedPreferences needs to be initialized before it can be used. It is done with SharedPreferences.getInstance(). This initialization is async, so it can’t be done in the constructor of Storage. We therefor use an async factory method to create an instance of it.

Core #

API #

We got our two infrastructure classes (Protection & Storage). Time to build a high-level API around them that expose the core functionality of our app.

lib/core/vault_api.dart

class VaultApi {
  final Storage _storage;
  final Protection _protector;

  VaultApi({required storage, required Protection protector})
      : _protector = protector,
        _storage = storage;

  bool get exists => _storage.exits;

  Future<OpenVault> create(
      String masterPassword) async {
    final key = await _protector.createKey(masterPassword);
    final vault = OpenVault(credentials: <Credential>[], key: key);
    await _storage.save(await _protector.encrypt(vault));
    return vault;
  }

  Future<OpenVault> open(String masterPassword) async {
    final vault = _storage.load();
    if (vault == null) throw VaultNotFoundFailure();
    final key = await _protector.recreateKey(vault, masterPassword);
    final credentials = await _protector.decrypt(vault, key);
    return OpenVault(credentials: credentials, key: key);
  }

  Future<bool> save(OpenVault vault) async {
    final encryptedVault = await _protector.encrypt(vault);
    return await _storage.save(encryptedVault);
  }
}

VaultNotFoundFailure will be defined in a bit.

MethodDescription
createCreates a vault that can only be opened with the given master-password.
saveProtect and store the given vault
openOpens the stored vault when given the same master-password that was used to create it.
existsCheck if a stored vault exists.

With protect (above), I mean encrypt with AES. With store, I mean JSON encode and save using SharedPreferences. Those are low-level details that we shouldn’t be concerned with at this level of abstraction.

Notice that create, open and save are all async.

State #

Now that we got all the fundamental application logic implemented, we can start how to manage state in the app.

The vault can either be open, closed, absent or transitioning between.

  • Open means that the vault exists unencrypted in the memory of our app.
  • Closed means a vault is only stored in an encrypted form.
  • Absent means that a vault hasn’t been created yet.

So, the status of our app can be either:

enum VaultStatus {
  open,
  closed,
  absent,
  opening,
  saving,
}

In addition, our app state can have any number of credentials. And, we also need a way to deal with any failures. So, the entire state of the app can be expressed with an instance of:

class VaultState extends Equatable {
  final IList<Credential> credentials;
  final VaultStatus status;
  final Failure? failure;
}

Failures should have a message describing what is wrong.

abstract class Failure implements Exception {
  String get message;
}

class OpenVaultFailure extends Failure {
  @override
  String get message => """
Unable to open vault.
Did you type the correct password?
  """;
}

class VaultNotFoundFailure extends Failure {
  @override
  String get message => "Vault not found.";
}

class SaveVaultFailure extends Failure {
  @override
  String get message => """
Unable to save vault.
Please try again or check logs.
""";
}

class UnknownVaultFailure extends Failure {
  @override
  String get message => """
An unknown error has occurred.
See log for details.
""";
}

Put the failures above in a file lib/core/failures.dart.

Then put the following in lib/core/vault_state.dart.

enum VaultStatus {
  open,
  closed,
  absent,
  opening,
  saving,
}

class VaultState extends Equatable {
  final IList<Credential> credentials;
  final VaultStatus status;
  final Failure? failure;

  const VaultState({
    required this.credentials,
    required this.status,
    this.failure,
  });

  VaultState.initial(bool exists)
      : credentials = <Credential>[].lock,
        status = exists ? VaultStatus.closed : VaultStatus.absent,
        failure = null;

  VaultState failed({VaultStatus? status, required Failure reason}) {
    return copyWith(status: status, failure: reason);
  }

  VaultState ok({
    IList<Credential>? credentials,
    required VaultStatus status,
  }) {
    return copyWith(credentials: credentials, status: status, failure: null);
  }

  VaultState copyWith({
    IList<Credential>? credentials,
    VaultStatus? status,
    Failure? failure,
  }) {
    return VaultState(
      credentials: credentials ?? this.credentials,
      status: status ?? this.status,
      failure: failure,
    );
  }

  @override
  List<Object?> get props => [credentials, status];
}

Notice that I’ve added some extra methods. The state is immutable, so when we want to express a new state, we make a copy of the old with some fields having a different value. The added methods are there for convenience, making it easier to copy with changes.

It is also Equatable. Meaning we can compare if two states are equivalent. The UI will be rebuild when previousState != newState.

Notice the type IList<Credential>. The “I” is for immutable (not interface). The type comes from the fast_immutable_collections package.

Why immutability is important? #

To answer, let’s first look at the meaning behind the word.

  • Mutable: able to mutate/change
  • Immutable: not able to mutate/change

Using only immutable state prevents us from changing state all over the place thereby making it difficult to debug the app. However, having an app that doesn’t react to user input because its state can’t be changed is not fun. So, we need state changes to happen somewhere. That somewhere is in a bloc or cubit.

Cubit #

Start of by adding following to lib/core/vault_cubit.dart:

class VaultCubit extends Cubit<VaultState> {
  final VaultApi api;
  Key? _key;

  VaultCubit(this.api) : super(VaultState.initial(api.exists));
}

It takes a VaultApi as constructor parameter. Then calls the constructor on the super class with an initial state based on whether a stored vault exists.

(_key will be explained in a moment.)

Add methods to the VaultCubit class, one by one as I explain them.

Create vault #

  Future<void> createVault(String masterPassword) async {
    // If an vault is absent then allow creating one.
    // We shouldn't allow accidentally override all stored passwords.
    assert(state.status == VaultStatus.absent);

    // We start by emitting an "opening" state.
    // It can be used to show a spinner in UI.
    emit(state.ok(status: VaultStatus.opening));

    try {
      // Ask api to create a new vault that can be opened with the given master
      // password.
      final vault = await api.create(masterPassword);
      // The key shouldn't be accessible through the UI, so we store it in a
      // private instance variable.
      _key = vault.key;

      // Emit "open" state with credentials converted to IList (immutable list).
      emit(state.ok(
        credentials: vault.credentials.lock,
        status: VaultStatus.open,
      ));
    } catch (e) {
      // If something goes wrong we emit new "absent" state with a generic
      // failure.
      emit(state.failed(
        status: VaultStatus.absent,
        reason: UnknownVaultFailure(),
      ));
      // Forward details to `addError` so a BlocObserver can log it.
      addError(e);
    }
  }
A Cubit would normally not have any instance variables (other than through its constructor). Doing it here is a compromise, as I don't want the key anyway near UI.

Open vault #

  Future<void> openVault(String masterPassword) async {
    // It doesn't make sense to attempt to open a vault if it is absent.
    assert(state.status == VaultStatus.closed);

    // Emit "opening" so UI can show a spinner (or some other indicator).
    emit(state.ok(status: VaultStatus.opening));
    try {
      // Attempt to open the stored vault.
      // It will throw an exception if `masterPassword` is wrong.
      final vault = await api.open(masterPassword);

      // The key shouldn't be accessible through the UI, so we store it in a
      // private instance variable.
      _key = vault.key;

      // Emit "open" state with credentials converted to IList (immutable list).
      emit(state.ok(
        credentials: vault.credentials.lock,
        status: VaultStatus.open,
      ));
    } catch (e) {
      // If something goes wrong we emit new "absent" state with a specialized
      // failure message.
      emit(state.failed(
        status: VaultStatus.closed,
        reason: OpenVaultFailure(),
      ));
      // Forward details to `addError` so a BlocObserver can log it.
      addError(e);
    }
  }

Add credential #

  Future<void> addCredential(Credential credential) async {
    // Requires that the vault have opened.
    assert(state.status == VaultStatus.open);

    // Emit "saving" so UI can show an indication.
    emit(state.ok(status: VaultStatus.saving));
    try {
      // "unlock" (getting mutable copy) credentials.
      // Then add the new credential.
      final credentials = state.credentials.unlock..add(credential);

      // Save the new credentials immediately.
        await api.save(OpenVault(credentials: credentials, key: _key!));

      // "lock" (get immutable copy) credentials and emit it as a new "open"
      // state.
      emit(state.ok(
        credentials: credentials.lock,
        status: VaultStatus.open,
      ));
    } catch (e) {
      // Transition back to "open" state if something goes wrong.
      emit(state.failed(
        status: VaultStatus.open,
        reason: SaveVaultFailure(),
      ));
      addError(e);
    }
  }

Close vault #

  void closeVault() {
    // Destroy key.
    // User would have to open with same master-password to access credentials
    // again.
    _key?.destroy();
    // "closed" state with empty credentials.
    emit(state.ok(
      credentials: <Credential>[].lock,
      status: VaultStatus.closed,
    ));
  }

Auto close when idle #

As an extra security mechanism, we want the vault to automatically close after it has been idle for a while.

This is actually really easy to do. There is a onChange method that gets called each time a new state is emitted. All we need is a timer that gets reset each “open” state change.

Add to the top of VaultCubit:

  static const closeAfter = Duration(minutes: 1);
  Timer? _timer;

Then override onChange.

  @override
  void onChange(Change<VaultState> change) {
    super.onChange(change);
    if (change.nextState.status == VaultStatus.open) {
      _timer?.cancel();
      _timer = Timer(closeAfter, closeVault);
    }
  }

We want closeAfter to be short enough that a malicious person can’t get access to credentials if the device is suddenly left unattended. But it should also be long enough that it doesn’t annoy the user.

Observability #

Another neat thing you can do with BLoC/Cubit is to register an observer which is an object that gets called each time the state changes (or on errors).

Add this to a file somewhere:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logging/logging.dart';

class LoggerBlocObserver extends BlocObserver {
  final log = Logger('LoggerBlocObserver');
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    log.log(Level.INFO, '${bloc.runtimeType} $change');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    log.log(Level.WARNING, '${bloc.runtimeType} $error $stackTrace');
    super.onError(bloc, error, stackTrace);
  }
}

It simply logs all state changes and errors using the logging package. This is also why Key isn’t part of the state object.

In the top of your main method you do:

  PrintAppender(formatter: const ColorFormatter()).attachToLogger(Logger.root);
  Bloc.observer = LoggerBlocObserver();

First, we are setting the root logger to just print the logged record (with colors). When we start having beta testers project (hypothetically), then we will reconfigure it to log to a server. That way we will be able to automatically capture details on any errors.

Next, the LoggerBlocObserver is registered with the bloc library.

UI #

Main #

Start by making the main function async.

Then await the creation on Storage:

  WidgetsFlutterBinding.ensureInitialized();
  final storage = await Storage.create();

Next, wrap your app with a BlocProvider like:

  runApp(BlocProvider(
    create: (context) => VaultCubit(
      VaultApi(protector: Protection.sensibleDefaults(), storage: storage),
    ),
    child: const MyApp(),
  ));

The provider allows widgets throughout the app to access VaultCubit and its state.

MyApp #

Replace MyApp with:

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'PasswordManager',
      home: BlocListener<VaultCubit, VaultState>(
          listenWhen: (previous, current) => current.failure != null,
          listener: (context, state) {
            ScaffoldMessenger.of(context)
                .showSnackBar(SnackBar(content: Text(state.failure!.message)));
          },
          child: const PasswordScreen()),
    );
  }
}

A BlocLister allows you to execute something, that should happen only once for every state change. We are going to use it to display a SnackBar message. See BlocListener docs.

Remove the MyHomePage widget. We don’t need the demo app.

Android Studio will give some red lines while typing in the screens. Either live with it for a while, or out-comment the offending lines along, then fix imports at the end.

Password screen #

The first thing the user will be presented with is the password screen (for master-password).

lib/ui/password_screen.dart

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Enter your master password"),
        centerTitle: true,
      ),
      body: BlocConsumer<VaultCubit, VaultState>(
        listenWhen: (previous, current) => current.status == VaultStatus.open,
        listener: (context, state) {
          Navigator.of(context).push(
            MaterialPageRoute(builder: (context) => const VaultScreen()),
          );
        },
        builder: (context, state) {
          return switch (state.status) {
            VaultStatus.absent => PasswordForm(
                onSubmit: (password) =>
                    context.read<VaultCubit>().createVault(password),
                buttonText: "Create",
              ),
            VaultStatus.closed => PasswordForm(
                onSubmit: (password) =>
                    context.read<VaultCubit>().openVault(password),
                buttonText: "Open",
              ),
            _ => const Center(child: CircularProgressIndicator.adaptive()),
          };
        },
      ),
    );
  }
}

Here, we will use another bloc related widget. That is the BlockConsumer. It works like a BlockLister, but it also takes builder function as a parameter, which can build a child tree based on the state of a Cubit. In this case that will be our VaultCubit.

There are two variations. Either a stored vault is absent, in which case a new can be created with master password. Or it is closed, in which case the user can open it by entering the same master password that was used to create it. In either case, the state will transition to “open”, which makes the listener navigate to another screen.

We also need to define the form.

class PasswordForm extends StatefulWidget {
  final Function(String password) onSubmit;
  final String buttonText;

  const PasswordForm({
    super.key,
    required this.onSubmit,
    required this.buttonText,
  });

  @override
  State<PasswordForm> createState() => _PasswordFormState();
}

class _PasswordFormState extends State<PasswordForm> {
  final _formKey = GlobalKey<FormState>();
  final _passwordController = TextEditingController();

  void _handleSubmit() {
    if (!_formKey.currentState!.validate()) return;
    widget.onSubmit(_passwordController.text);
  }

  String? _passwordValidator(String? value) {
    const minLength = 8;
    final invalid = value == null || value.length < minLength;
    return invalid ? "Must be at least $minLength" : null;
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Form(
        key: _formKey,
        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          const Padding(padding: EdgeInsets.symmetric(vertical: 16)),
          const Text("Password"),
          TextFormField(
            controller: _passwordController,
            validator: _passwordValidator,
            obscureText: true,
            keyboardType: TextInputType.visiblePassword,
            onChanged: (newValue) => _formKey.currentState!.validate(),
          ),
          const Padding(padding: EdgeInsets.symmetric(vertical: 16)),
          Center(
            child: ElevatedButton(
              onPressed: _handleSubmit,
              child: Text(widget.buttonText),
            ),
          ),
        ]),
      ),
    );
  }
}

The method _passwordValidator shows an error if the entered password is less than 8 characters.

_handleSubmit checks validation invoking onSubmit callback.

Vault screen #

This is the screen that gets shown when the vault has been opened.

lib/ui/vault_screen.dart

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

  void _addNewCredential(BuildContext context) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => const CredentialScreen(),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      onPopInvoked: (didPop) => context.read<VaultCubit>().closeVault(),
      child: Scaffold(
        appBar: AppBar(title: const Text("Your Vault")),
        body: BlocConsumer<VaultCubit, VaultState>(
          listenWhen: (previous, current) =>
              current.status == VaultStatus.closed,
          listener: (context, state) => Navigator.pop(context),
          builder: (context, state) => CredentialList(credentials: state.credentials),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () => _addNewCredential(context),
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}

PopScope allows overriding the behavior when back navigation is invoked. Either by gesture or the back button. We override it here to close the vault.

We also see another BlocConsumer. Here the listenerWhen and listener will make sure the navigation is popped once the vault has been closed. The builder returns a CredentialsList widget (which we will define in a moment).

We also have a button to push a new screen for adding credentials.

class CredentialList extends StatelessWidget {
  const CredentialList({
    super.key,
    required this.credentials,
  });

  final IList<Credential> credentials;

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemBuilder: (context, index) => CredentialListTile(credential: credentials[index]),
      separatorBuilder: (context, index) => const Divider(),
      itemCount: credentials.length,
    );
  }
}


class CredentialListTile extends StatelessWidget {
  const CredentialListTile({
    super.key,
    required this.credential,
  });

  final Credential credential;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(credential.name),
      subtitle: Text(credential.username),
      trailing: PopupMenuButton<MenuAction>(
        onSelected: (MenuAction action) => action.call(),
        itemBuilder: (BuildContext context) => <PopupMenuEntry<MenuAction>>[
          PopupMenuItem<MenuAction>(
            value: () => Clipboard.setData(ClipboardData(text: credential.username)),
            child: const Text('Copy username'),
          ),
          PopupMenuItem<MenuAction>(
            value: () => Clipboard.setData(ClipboardData(text: credential.password)),
            child: const Text('Copy password'),
          ),
          PopupMenuItem<MenuAction>(
            value: () {
              // TODO remove credential
            },
            child: const Text('Remove'),
          ),
        ],
      ),
      onTap: () => Navigator.of(context).push(MaterialPageRoute(
        builder: (context) => CredentialScreen(existingCredential: credential),
      )),
    );
  }
}

typedef MenuAction = void Function();

Here we have a PopupMenuButton that gives options to copy username or password to Clipboard.

Credential screen #

The credentials screen allowing user to add new credentials.

lib/ui/credential_screen.dart

class CredentialScreen extends StatefulWidget {
  final Credential? existingCredential;

  const CredentialScreen({super.key, this.existingCredential});

  @override
  State<CredentialScreen> createState() => _CredentialScreenState();
}

class _CredentialScreenState extends State<CredentialScreen> {
  late final TextEditingController _nameCtrl;
  late final TextEditingController _usernameCtrl;
  late final TextEditingController _passwordCtrl;
  var showPassword = false;

  @override
  void initState() {
    super.initState();
    _nameCtrl = TextEditingController(text: widget.existingCredential?.name);
    _usernameCtrl = TextEditingController(text: widget.existingCredential?.username);
    _passwordCtrl = TextEditingController(text: widget.existingCredential?.password);
  }

  @override
  void dispose() {
    _nameCtrl.dispose();
    _usernameCtrl.dispose();
    _passwordCtrl.dispose();
    super.dispose();
  }

  void save() {
    final vault = context.read<VaultCubit>();
    final credential = Credential(
      name: _nameCtrl.text,
      username: _usernameCtrl.text,
      password: _passwordCtrl.text,
    );
    if (widget.existingCredential == null) {
      vault.addCredential(credential);
    } else {
      // TODO update existing credential
    }
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Credential")),
      body: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          children: [
            NameField(controller: _nameCtrl),
            UsernameField(controller: _usernameCtrl),
            PasswordField(controller: _passwordCtrl),
            const Padding(padding: EdgeInsets.symmetric(vertical: 16)),
            SaveButton(onSave: save),
          ],
        ),
      ),
    );
  }
}

class UsernameField extends StatelessWidget {
  const UsernameField({super.key, required this.controller});

  final TextEditingController controller;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      decoration: const InputDecoration(label: Text("Username")),
    );
  }
}

class NameField extends StatelessWidget {
  final TextEditingController controller;

  const NameField({super.key, required this.controller});

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      decoration: const InputDecoration(label: Text("Name/Site")),
    );
  }
}

class PasswordField extends StatefulWidget {
  final TextEditingController controller;

  const PasswordField({super.key, required this.controller});

  @override
  State<PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  bool showPassword = false;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Expanded(
          child: TextFormField(
            controller: widget.controller,
            obscureText: !showPassword,
            decoration: const InputDecoration(label: Text("Password")),
          ),
        ),
        const Padding(padding: EdgeInsets.symmetric(horizontal: 8)),
        IconButton.outlined(
          onPressed: () {
            setState(() => showPassword = !showPassword);
          },
          icon: Icon(showPassword ? Icons.visibility : Icons.visibility_off),
        ),
        const Padding(padding: EdgeInsets.symmetric(horizontal: 8)),
        IconButton.outlined(
          onPressed: () {
            // TODO generate a random password
          },
          icon: const Icon(Icons.casino),
        ),
        const Padding(padding: EdgeInsets.symmetric(horizontal: 8)),
      ],
    );
  }
}

class SaveButton extends StatelessWidget {
  final Function() onSave;

  const SaveButton({super.key, required this.onSave});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<VaultCubit, VaultState>(builder: (context, state) {
      if (state.status == VaultStatus.saving) {
        return const CircularProgressIndicator();
      } else {
        return ElevatedButton(
          onPressed: onSave,
          child: const Text("Save"),
        );
      }
    });
  }
}

It is “just” a simple form with 3 input fields. One for name, username and password.

The password has a toggle for visibility (obscured by default).

When “Save” button is pressed and the fields pass validation, it will call addCredential on the VaultCubit instance with the text values from input fields. The “Save” button gets disabled while saving.

Closing thoughts #

You’re close to have created a toy password manager.

There are still some important features missing. See if you can implement those in the challenges below.

Challenges #

Searching for TODO might give you some hints

Generate password #

People are bad at inventing good passwords, so you should add functionality to generate passwords.

Remove credential #

Allow the user to clean up old accounts by providing a function to remove credentials.

Maybe you should present a dialog or something to make sure they don’t remove credentials by accident.

Update credential #

Add functionality for updating credentials.

You should be able to reuse most of CredentialScreen.