David Tengeri David is a passionate software engineer who always tries to push his limits to learn new things. He loves to work with Flutter and is interested in web technologies as well.

Build a secure mobile banking app with Flutter

13 min read 3774

Build a Secure Mobile Banking App With Flutter

One of the most important aspects of a banking app is security. Everybody wants to keep their wealth in a safe place. In today’s world, money drives almost everything, and we always want access to our cash. Well, how can we do that? One of the most common methods is using the device we take everywhere: our phones.

So, if we want to make an application that manages the users’ money, we have to ensure it is as secure as possible. In this article, we will learn about the essential aspects of building secure mobile banking apps with Flutter and look at sample codes on how to achieve them using the Flutter framework.

Jump ahead:

Communicate securely using Flutter

Mobile banking applications often communicate with backend servers. One of the most important things to consider is using a secure layer for this communication. Luckily, HTTPS comes to the rescue and prevents others from sniffing the data we send and receive from our backend system.

HTTPS works the same way as the simple HTTP does, but inserts a secure layer between the TCP communication layer and the HTTPS protocols TLS or SSL.

Using HTTPS with Flutter

Secure Sockets Layer, SSL was the first definition of how messages can be secured in network communication. After a couple of iterations, Transport Layer Security TSL was defined and replaced SSL. TLS defines a secure communication method for client-server applications. It provides public and private key pairs to secure the messages between the client and the server.

When the client connects to the server, it will receive its public key. Every message that the client sends to the server is encrypted based on the public key. Only the server, which has the private key, can decrypt the messages. If somebody can sniff the network communication, they won’t be able to read the original content of the request.

How can we use this in our application? It is straightforward. We will only communicate with APIs that provide HTTPS entry points. That’s it; we are done, right? Well, unfortunately, not.

To be sure that an endpoint with HTTPS can be trusted, the server should provide a certificate signed by trusted certificate authorities. Browsers check this and decide whether the endpoint has a valid, trustable certificate. Based on this, they will show the user a warning.

When building Flutter applications that communicate with a server through HTTPS, we can rely on packages like http and dio. The only thing we need to do is to use a URL starting with HTTPS in our requests.

An example with dio should look like this:

var response = await Dio().get('https://www.google.com');

Sometimes, the backend system can use a self-signed certificate. This happens when you build your own backend and run it on your machine while developing. A self-signed certificate means it is signed by you, not by a trusted authority.

It is easy and free to create but does not provide security. So, you will get an exception when you want to communicate with such an API. You can bypass the check of the certificate validity by assigning a custom certificate callback to your HTTP client:

(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate  = (client) {
  client.badCertificateCallback=(X509Certificate cert, String host, int port){
    if(kDebugMode){ // Accept it only in debug mode.
      return true;
    }
    return false;
  };
};

Securely store data using Flutter packages

Not every application wants to store its data outside the device. If you want to keep everything on the user’s phone and still store the data securely, you will need some kind of encrypted data storage. It’s also helpful to have a place to store tokens, which provides access to your API. The following packages can help in Flutter:

Implementing the flutter_secure_storage package

The flutter_secure_storage package provides a key-value store where you can keep your little secrets, which will be encrypted. It is very similar to shared_preferences, with the additional security layer.

Let’s see an example:

Example of a Secure Mobile Banking Application with Flutter

The following widget displays and modifies our little secret:

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

/// This widget displays and modifies your little secret.
class MySecret extends StatelessWidget {
  const MySecret({
    super.key,
    required this.readMySecret,
    required this.changeMySecret,
    required this.deleteMySecret,
  });

  /// A callback that can read the secret.
  final Future<String?> Function() readMySecret;

  /// A callback that can change the value of the secret.
  final Future<void> Function() changeMySecret;

  /// A callback that can delete your secret.
  final Future<void> Function() deleteMySecret;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        children: [
          Text(
            'My secret',
            style: Theme.of(context).textTheme.headline3,
          ),
          FutureBuilder(
            future: readMySecret(),
            builder: (context, snapshot) {
              if (snapshot.connectionState == ConnectionState.done) {
                return Text(snapshot.data ?? 'Provide your little secret');
              }
              return const CircularProgressIndicator();
            },
          ),
          ElevatedButton(
            onPressed: changeMySecret,
            child: const Text('Change'),
          ),
          ElevatedButton(
            onPressed: deleteMySecret,
            child: const Text('Delete'),
          ),
        ],
      ),
    );
  }
}

Let’s use the widget above and configure it with the flutter_secure_storage functionalities:

 // secure_storage_demo.dart
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:secure_banking_app/change_secret_dialog.dart';
import 'package:secure_banking_app/my_secret.dart';

const _mySecretValueKey = 'mySecretValue';

/// Displays the usage of the flutter_secure_storage example.
class SecureStorageDemo extends StatefulWidget {
  const SecureStorageDemo({super.key});

  @override
  State<SecureStorageDemo> createState() => _SecureStorageDemoState();
}

class _SecureStorageDemoState extends State<SecureStorageDemo> {
  /// Create a new instance of the secure storage
  late final _secureStorage = const FlutterSecureStorage();

  /// Changes the current secret value by showing an edit dialog
  Future<void> _changeMySecret() async {
    final scaffoldMessenger = ScaffoldMessenger.of(context);

    /// Read the actual value
    final currentSecret = await _secureStorage.read(key: _mySecretValueKey);
    // Display a dialog with a text field where the user can change their secret.
    final newSecret = await showDialog(
      context: context,
      builder: (context) => ChangeSecretDialog(
        currentSecret: currentSecret,
      ),
    );
    if (newSecret != null) {
      // Save the secret value in the secure storage.
      await _secureStorage.write(key: _mySecretValueKey, value: newSecret);
      setState(() {});
      scaffoldMessenger.showSnackBar(
        const SnackBar(
          content: Text(
            'Your little secret is updated.',
          ),
        ),
      );
    }
  }

  /// Removes the current value.
  Future<void> _deleteMySecret() async {
    final scaffoldMessenger = ScaffoldMessenger.of(context);
    // Deletes a key from the secure storage.
    await _secureStorage.delete(key: _mySecretValueKey);
    setState(() {});
    scaffoldMessenger.showSnackBar(
      const SnackBar(
        content: Text(
          'Your little secret is removed.',
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MySecret(
      readMySecret: () => _secureStorage.read(key: _mySecretValueKey),
      changeMySecret: _changeMySecret,
      deleteMySecret: _deleteMySecret,
    );
  }
}

It is very easy to use the library; you just have to import it and create a new instance to the secure storage:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

late final secureStorage = const FlutterSecureStorage();

When you want to access a value in the secure storage saved under a particular key, you can call:

final currentSecret = await secureStorage.read(key: _mySecretValueKey);

To change it, you can write a new value. Beware, if the value is null, it will delete the key from the storage:

await secureStorage.write(key: _mySecretValueKey, value: newSecret);

Or you can remove your little secret from the storage by:

await secureStorage.delete(key: _mySecretValueKey);

Using the Flutter biometric_storage package

The biometric_storage provides a very similar approach with some additional features. It can store small data similar to the flutter_secure_storage package but optionally secures it with a biometric lock. The user can only access the data in the storage if they unlock it with their fingerprint, face id, pin, or pattern. You’ll need to follow the installation steps for each platform to use the package in your app.

We can reuse the MySecret widget to display and modify the secrets in our biometric storage. We just need to create the callbacks for the operations:

// biometric_storage_demo.dart
import 'package:biometric_storage/biometric_storage.dart';
import 'package:flutter/material.dart';
import 'package:secure_banking_app/my_secret.dart';

import 'change_secret_dialog.dart';

const _myStorageName = 'mystorage';

/// Displays the content of the secret storage and provides operations to
/// change its value.
class BiometricStorageDemo extends StatelessWidget {
  const BiometricStorageDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      // Let's check whether the device supports the biometric storage.
      future: BiometricStorage().canAuthenticate(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.data != CanAuthenticateResponse.success) {
            return const Center(
              child: Text('Can not use biometric storage'),
            );
          }
          // We have the functionality, show the content.
          return _BiometricStorageProvider(
            builder: (secureStorage) => _BiometricStorageDemoPage(
              secureStorage: secureStorage,
            ),
          );
        }
        return const Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }
}

/// Provides a secure storage through its builder.
class _BiometricStorageProvider extends StatelessWidget {
  const _BiometricStorageProvider({
    Key? key,
    required this.builder,
  }) : super(key: key);

  /// Bulder function that provides access to the secure storage.
  final Widget Function(BiometricStorageFile) builder;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: BiometricStorage().getStorage(_myStorageName),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.done &&
              snapshot.hasData) {
            return builder(snapshot.data!);
          }
          return const Center(
            child: CircularProgressIndicator(),
          );
        });
  }
}

/// Displays the content of the secure storage and operations to manipulate
/// it.
class _BiometricStorageDemoPage extends StatefulWidget {
  const _BiometricStorageDemoPage({
    super.key,
    required this.secureStorage,
  });

  final BiometricStorageFile secureStorage;

  @override
  State<_BiometricStorageDemoPage> createState() =>
      _BiometricStorageDemoPageState();
}

class _BiometricStorageDemoPageState extends State<_BiometricStorageDemoPage> {
  /// Changes the current secret value by showing an edit dialog
  Future<void> _changeMySecret() async {
    final scaffoldMessenger = ScaffoldMessenger.of(context);

    /// Read the actual value
    final currentSecret = await widget.secureStorage.read();
    // Display a dialog with a text field where the user can change their secret.
    final newSecret = await showDialog(
      context: context,
      builder: (context) => ChangeSecretDialog(
        currentSecret: currentSecret,
      ),
    );
    if (newSecret != null) {
      // Save the secret value in the secure storage.
      await widget.secureStorage.write(newSecret);
      setState(() {});
      scaffoldMessenger.showSnackBar(
        const SnackBar(
          content: Text(
            'Your little secret is updated.',
          ),
        ),
      );
    }
  }

  /// Removes the current value.
  Future<void> _deleteMySecret() async {
    final scaffoldMessenger = ScaffoldMessenger.of(context);
    // Deletes a key from the secure storage.
    await widget.secureStorage.delete();
    setState(() {});
    scaffoldMessenger.showSnackBar(
      const SnackBar(
        content: Text(
          'Your little secret is removed.',
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MySecret(
      readMySecret: widget.secureStorage.read,
      changeMySecret: _changeMySecret,
      deleteMySecret: _deleteMySecret,
    );
  }
}

The package handles secrets in a file-based way. It can create an encrypted file that is protected by your biometrics, and you can put any string into it. If you want to store multiple values, you have to create various storages. This differs from the flutter_secure_storage package, where you can add multiple values with different keys to the store.

The first thing we should do is to check whether the device has any support for biometric authentication with BiometricStorage().canAuthenticate(), // Check whether the device supports biometric auth.

If the device supports it, we need to create a new storage using BiometricStorage().getStorage(_myStorageName). This will create a myStorageName file encrypted and protected by biometrics. You can save any string into this storage. If you need other types, you have to provide the conversion from and to type String.



You can read the data from the storage with await secureStorage.read() and change the value with write() await widget.secureStorage.write(newSecret);.

If you don’t want to keep your secret, you can remove it by using await widget.secureStorage.delete();.

Integrating Hive into your Flutter application

The packages mentioned before are great when we only want to store a small amount of data, like tokens or passwords. But sometimes, we need a way to store more than that. if we’re going to encrypt all of our data, we can use the hive database in our application.

Hive is a key-value database that provides fast access to your data and is written in dart. It is very easy to integrate it into a Flutter application.

Hive works with boxes which are similar to Map structures. Once you create a box, you can put and get the values based on its key. A box can contain any type of value, but you can restrict it to only one type:

var box = await Hive.openBox('account');
await box.put('balance', 42.0);
double balance = box.get('balance');
await box.close();

Hive can also work with custom objects and provides a code generator to make your life easier:

@HiveType(typeId: 0)
class Account extends HiveObject {

  @HiveField(0)
  double balance;
}

final account = Account();
account.balance = 42.0;
box.add(account); // Hive assigns a key automatically

account.balance = 21.0;
account.save();

You can tell Hive to encrypt the content of a box when you open it:

final encryptedBox= await Hive.openBox(
  'account', 
  encryptionCipher: HiveAesCipher('encryption key'),
);

Hive will only encrypt the data, not the keys. You will need to use the same encryption key because Hive does not check it when it opens the box. You can generate a new one with the following:

final key = Hive.generateSecureKey();

Since you need to have the same key even after the application is closed and opened again, you will need to store it securely. You can use, for example, the flutter_secure_storage for it.

Let’s see a complete example:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_flutter/hive_flutter.dart';

const _boxName = 'account';
const _encryptionKey = 'boxEncryptionKey';
const _balanceKey = 'balance';

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

  /// Opens the Hive box
  Future<void> _openBox() async {
    // Initialize Hive for Flutter
    await Hive.initFlutter();
    // Read the key used for encryption.
    const secureStorage = FlutterSecureStorage();
    String? encryptionKey = await secureStorage.read(key: _encryptionKey);
    if (encryptionKey == null) {
      // Create a new key if it does not exists.
      final key = Hive.generateSecureKey();
      // Add it to the secure storage.
      await secureStorage.write(
        key: _encryptionKey,
        value: base64UrlEncode(key),
      );
    }
    // Read the key from the secure storage.
    final key = await secureStorage.read(key: _encryptionKey);
    final encryptionKeyBytes = base64Url.decode(key!);
    // Open the box with the encryption key.
    await Hive.openBox<double>(
      _boxName,
      encryptionCipher: HiveAesCipher(encryptionKeyBytes),
    );
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _openBox(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return const _HiveDemoContent();
        }
        return const Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }
}

/// Displays the balance and modifies it.
class _HiveDemoContent extends StatelessWidget {
  const _HiveDemoContent({super.key});

  @override
  Widget build(BuildContext context) {
    // ValueListenableBuilder reacts to changes in the given box.
    // It will run its builder method when any value in the box changes.
    return ValueListenableBuilder(
      valueListenable: Hive.box<double>(_boxName).listenable(),
      builder: (context, box, child) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Balance',
              style: Theme.of(context).textTheme.headline3,
            ),
            // Get the current value of the balance from the box.
            Text(
              box.get(_balanceKey, defaultValue: 0.0).toString(),
            ),
            ElevatedButton(
              onPressed: () {
                // Get the balance value from the box.
                final balance = box.get(_balanceKey, defaultValue: 0.0);
                // Update it with the new value.
                box.put(_balanceKey, balance! + 5.0);
              },
              child: const Text('Add \$5'),
            )
          ],
        ),
      ),
    );
  }
}

The critical part here is how you open your box. The _openBox() method tries to read the key we used previously for the encryption by using the flutter_secure_storage package. If the key does not exist, we create a new one and save it in the storage. Once we have the key, we open the box. If the box is open, we can start to use it. The ValueListenableBuilder can watch a box for changes and update the UI through its builder method. All the data we put in the box will be encrypted so nobody can read the physical file that stores the box’s content on the file system.

Secure your Flutter app by locking your application

When managing sensitive data, for example, managing the user’s money, it is essential to not allow anybody to enter the application. Storing its data in an encrypted way makes it safe to not allow reading the data outside of the application. You can use the device’s biometric lock to prevent access to the app’s features. You can add such authentication to your Flutter app by using the local_auth package. Follow the platform-specific installation instructions of the package before you start to use it:

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

/// Displays sensitive information only after the user has authenticated with
/// biometrics.
class LocalAuthDemo extends StatefulWidget {
  const LocalAuthDemo({super.key});

  @override
  State<LocalAuthDemo> createState() => _LocalAuthDemoState();
}

class _LocalAuthDemoState extends State<LocalAuthDemo> {
  final auth = LocalAuthentication();

  /// Check whether the device supports authentication
  Future<bool> _isLocalAuthAvailableOnDevice() async {
    /// Check whether the device is capable of using biometrics
    final isSupported = await auth.isDeviceSupported();
    if (!isSupported) {
      return false;
    }
    try {
      /// Check whether it is configured on the device.
      return auth.canCheckBiometrics;
    } on PlatformException catch (e) {
      return false;
    }
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _isLocalAuthAvailableOnDevice(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasData && snapshot.data!) {
            return _LocalAuthenticator(auth: auth);
          }
          return const Center(
            child: Text(
              'Can not authenticate with biometrics. PLease enable it on your device.',
            ),
          );
        }
        return const Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }
}

/// Does the authentication and displays the sensitive information based on its
/// result
class _LocalAuthenticator extends StatelessWidget {
  const _LocalAuthenticator({
    super.key,
    required this.auth,
  });

  final LocalAuthentication auth;

  /// Authenticate the user with biometrics. The method will be chosen by
  /// the OS.
  Future<bool> _authenticate() => auth.authenticate(
        localizedReason: 'Please auth yourself',
        options: const AuthenticationOptions(
          stickyAuth: true,
        ),
      );

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _authenticate(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.data!) {
            return const Center(
              child: Text('Welcome! You can see your sensitive data.'),
            );
          }
          return const Center(
            child:
                Text('Sorry, but you are not allowed to access to this page.'),
          );
        }
        return const Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }
}

When using the local_auth package, we have to check whether the device is capable of biometric authentication and is configured correctly:

// Check whether the device is capable of using biometrics
final isSupported = await auth.isDeviceSupported();
// ...
try {
  /// Check whether it is configured on the device.
  return auth.canCheckBiometrics;
} on PlatformException catch (e) {
  return false;
}

Once we know we can use biometrics, we can authenticate the user with the authenticate() function:

await authenticate(
  localizedReason: 'Please auth yourself',
  options: const AuthenticationOptions(
    stickyAuth: true,
  ),
);

Hide sensitive information to secure your Flutter app

Another critical aspect of a secure application is to hide sensitive data when the app is not running. Usually, there is a preview of the application when the user switches among the running apps. With the secure_application package, you can add an overlay to your app to hide its content when the app is in the background.

In addition to the overlay, the package will lock different parts of the application, so when the user comes back, they need to unlock it. The unlock mechanism can be implemented by us, for example, with local_auth or with any type of authentication flow. Once we can grant access to the user, we can unlock the hidden content. Let’s see it in action:

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

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

  @override
  Widget build(BuildContext context) {
    // Wrap the application and mark it as a secure one.
    return SecureApplication(
      nativeRemoveDelay: 800,
      // This callback method will be called when the app becomes available again
      // and its content was hidden with an overlay. We can provide logic to
      // unlock the content again. Here we can use any auth logic, for example
      // biometrics with the local_auth package.
      onNeedUnlock: (secureApplicationStateNotifier) {
        print(
          'Needs to be unlocked. You can use any auth method, e.g local_auth to unlock the content',
        );
        return null;
      },
      child: _SecureApplicationContent(),
    );
  }
}

class _SecureApplicationContent extends StatefulWidget {
  const _SecureApplicationContent({super.key});

  @override
  State<_SecureApplicationContent> createState() =>
      _SecureApplicationContentState();
}

class _SecureApplicationContentState extends State<_SecureApplicationContent> {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Enable the feautre. This will add overlay when our app goes background.
    SecureApplicationProvider.of(context)?.secure();
  }

  @override
  Widget build(BuildContext context) {
    // Wrap the sensitive part with SecureGate.
    // This will hide the sensitive part until we unlock the content.
    return SecureGate(
      blurr: 60,
      opacity: 0.8,
      // The content of this builder will be displayed after our app
      // comes back to foreground and its content was hidden with the overlay.
      lockedBuilder: (context, secureApplicationController) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Content is locked!'),
            ElevatedButton(
              onPressed: () {
                // Unlock the content manually.
                secureApplicationController?.unlock();
              },
              child: Text('Unlock'),
            ),
          ],
        ),
      ),
      child: const Center(
        child: Text(
          'Your sensitive data',
        ),
      ),
    );
  }
}

The secure_application package provides two valuable widgets. The first one is the SecureApplication, which is responsible for managing the overlay and providing a controller to manage the state of the security status. The other is SecureGate which hides its children when the content is locked. This happens when the application comes back from the background.

You first have to enable the security feature by calling the secure() method on the controller:

SecureApplicationProvider.of(context)?.secure();

You can access the controller in the descendant widgets of the SecureApplication. Once it is enabled, it will take care of the overlay automatically.

You must unlock the secured content through the controller to reveal sensitive data. This can be done in the onNeedUnlock callback of the SecureApplication:

SecureApplication(
      // This callback method will be called when the app becomes available again
      // and its content was hidden with an overlay. We can provide logic to
      // unlock the content again. Here we can use any auth logic, for example
      // biometrics with the local_auth package.
      onNeedUnlock: (secureApplicationStateNotifier) {
        // Use any authentication method.
      },
      child: _SecureApplicationContent(),
    );

In this callback, you can implement any authentication method that fits your application. You can use the local_auth to authenticate the user based on biometrics or check whether the stored JWT token is still valid.

The SecureGate widget has two essential attributes:

SecureGate(
      // The content of this builder will be displayed after our app
      // comes back to the foreground and its content was hidden with the overlay.
      lockedBuilder: (context, secureApplicationController) {
        // Return the widgets shown when it is locked.
        // Unlock the app through the controller.
      },
      child: ...,
    );

The child attribute holds the sensitive data. It will be hidden if the content is locked. The lockedBuilder callback returns what the user should see in this case. You can manually unlock the content through the secureApplicationController parameter.

Summary

In this post, I tried to collect the different security requirements needed when implementing a Flutter app that works with sensitive data, like a mobile banking app. It is important to make it hard to access the content, so we need to always use HTTPS when communicating with a server. When we store data locally, we should try to encrypt it and only allow access once the user verifies themself.

Another important aspect is to hide sensitive data while the user is switching between the running applications. Fortunately, the great community of Flutter already made packages that we can easily integrate into our app to make it more secure.

All the source code is available here.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
David Tengeri David is a passionate software engineer who always tries to push his limits to learn new things. He loves to work with Flutter and is interested in web technologies as well.

Leave a Reply