2023-07-28

[Flutter] Enhancement of Masamune's DB linkage function

Hello. I'm Masaru Hirose.

I am constantly updating Masamune, an integration framework that runs on Flutter.

Hello. My name is Masaru Hirose.
https://medium.comhttps://medium.com
title

This time, I would like to introduce an update that could significantly change the app development experience.

masamune

An application development framework centered on automatic code generation using build_runner.
https://pub.devhttps://pub.dev
title
Building system for masamune framework. Automatic creation of models, themes, pages, and translation data.
https://pub.devhttps://pub.dev
title

Introduction

Masamune framework is a framework on Flutter that uses "Code generation by CLI" and "Automatic code generation by build_runnner" to reduce the amount of code writing as much as possible and increase stability and speed of application development.

As for the database, I use a NoSQL database based on Firestore and provide a mechanism that allows switching between the runtime DB, the terminal local DB and Firestore with just one line of update.

Previously, I provided a mechanism to replace all DBs in an app by switching ModelAdapter,

but starting from Masamune Ver. 2.3.4 it is now possible to set a ModelAdapter for each data model.

In other words, you can have different databases for different user data, content data, and associated data, and even link them together without awareness.

Let's take you step-by-step through what can be done.

Type and location of data

Data handled by the application should be divided into the following three categories depending on where the data is placed.

  1. Data defined in the app
    • Fixed data list
      • Category
      • Gender
      • Age-group
      • Area
    • In-app settings
      • Maximum number of characters to post
      • Registration limit for favorites
    • Often in code with enum or const.
  2. Data stored locally on the terminal
    • Data to be viewed by you only
      • Memorandum data
      • Schedule
    • Data to store the status of the application
      • Have the user completed the tutorial?
      • Trophy Data
      • Volume (sound) volume
      • App Theme
    • Often saved as a file in the terminal or in SharedPreferences
  3. Data stored in remote DB
    • Data to be shared with other users
      • User Profile Data
      • Article data from blogs, etc.
      • Communication data such as direct messages
    • Data Persistence
      • Enable "data to be viewed only by you" of "data stored locally on the terminal" to be viewed on a different terminal or even if you change the terminal.
    • Stored in Firestore, RDB, etc.

These will vary depending on the requirements of the application.

(For example, if categories are not fixed but can be added or deleted freely by users or administrators, they need to be stored in a remote DB.)

In Masamune framework, it can be handled as 1 -> RuntimeDatabase, 2 -> LocalDatabase, 3 -> FirestoreDatabase.

Relationships between different DBs

Consider the following data.

  • Article data (use FirestoreDatabase to share with all users)
    • Article name
    • Article content
  • Category data (use RuntimeDatabase for fixed data in app)
    • Category name
  • User data (use FirestoreDatabase to share with all users)
    • User name
    • Icon
  • App configuration data (use LocalDatabase for in-app settings)
    • App theme
    • Have you completed the tutorial?

The following relationships can be set up at this time.

  • Article data (use FirestoreDatabase to share with all users)
    • Article name
    • Article content
    • Reference to category data.
  • Category data (use RuntimeDatabase for fixed data in app)
    • Category name
  • User data (use FirestoreDatabase to share with all users)
    • User name
    • Icon
    • Reference to application configuration data.
  • App configuration data (use LocalDatabase for in-app settings)
    • App theme
    • Have you completed the tutorial?

The Masamune framework executes the load() method when loading specific data, which also loads the data contained in the reference.

In other words, simply loading the article data not only reads the data stored in Firestore, but also the category data defined on the accompanying Runtime, which can be read together and treated as the same data.

In the same way, user data stored in Firestore and application settings data stored locally can be loaded together as user-owned data.

Database migration

For example, if a user wants to be able to add or delete categories that have been set as fixed, it is usually necessary to create a separate category table and implement a separate process to read/write data to/from it. This is a modification to a certain extent.

In the Masamune framework, if categories are defined in advance in the RuntimeDatabase, a single line change is all that is required.

Also, if you change the DB, you need to migrate the previous data to the new database, but you don't need to worry about that if you use RuntimeDatabase to LocalDatabase or FirestoreDatabase.

Let's actually do it!

Now I would like to proceed with the actual implementation.

First, create a project with katana create.

katana create net.mathru.modeltest

Wait a few moments and the project file will be created.

Next, let's create various data models.

# Article Data
katana code collection post
# Category Data
katana code collection category
# User data
katana code collection user
# Application setting data
katana code document prefs

App configuration data is created in documents, not collections.

Set up the fields for each model.

// models/post.dart

/// Value for model.
@freezed
@formValue
@immutable
@CollectionModelPath("post")
class PostModel with _$PostModel {
  const factory PostModel({
    required String title,
    required String text,
    @refParam CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

  ~~~~
}
// models/category.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("category")
class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~
}
// models/user.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("user")
class UserModel with _$UserModel {
  const factory UserModel({
    required String name,
    ModelImageUri? icon,
    @refParam PrefsModelRef? prefs,
  }) = _UserModel;
  const UserModel._();

  ~~~~
}
// models/prefs.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the document.
@DocumentModelPath("app/prefs")
class PrefsModel with _$PrefsModel {
  const factory PrefsModel({
    String? theme,
    @Default(false) bool isFinishTutorial,
  }) = _PrefsModel;
  const PrefsModel._();

  ~~~~
}

The following command will perform automatic generation by build_runner.

katana code generate

Firestore will be used, so please refer to the following article to enable Firestore.

Create a FirestoreModelAdapter by specifying FirebaseOptions for each platform to be written in const.

// main.dart

~~~~~

const firestoreModelAdapter = FirestoreModelAdapter(
  iosOptions: DefaultFirebaseOptions.ios,
  androidOptions: DefaultFirebaseOptions.android,
);

In addition, define a RuntimeModelAdapter and a LocalModelAdapter.

Pre-add category data to the RuntimeModelAdapter.

// main.dart

~~~~~

const localModelAdapter = LocalModelAdapter();
const runtimeModelAdapter = RuntimeModelAdapter(
  initialValue: [
    CategoryModelDataCollection(
      {
        "beauty_and_health": CategoryModel(
          name: "Beauty & Health",
        ),
        "it_and_programming": CategoryModel(
          name: "IT & Programming",
        ),
        "business": CategoryModel(
          name: "Business",
        ),
      },
    ),
  ],
);

You will specify a ModelAdapter for each model.

// models/post.dart

/// Value for model.
@freezed
@formValue
@immutable
@CollectionModelPath(
  "post",
  adapter: firestoreModelAdapter,
)
class PostModel with _$PostModel {
  const factory PostModel({
    required String title,
    required String text,
    @refParam CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

  ~~~~
}
// models/category.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
  "category",
  adapter: runtimeModelAdapter,
)
class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~
}
// models/user.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
  "user",
  adapter: firestoreModelAdapter,
)
class UserModel with _$UserModel {
  const factory UserModel({
    required String name,
    ModelImageUri? icon,
    @refParam PrefsModelRef? prefs,
  }) = _UserModel;
  const UserModel._();

  ~~~~
}
// models/prefs.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the document.
@DocumentModelPath(
  "app/prefs",
  adapter: localModelAdapter,
)
class PrefsModel with _$PrefsModel {
  const factory PrefsModel({
    String? theme,
    @Default(false) bool isFinishTutorial,
  }) = _PrefsModel;
  const PrefsModel._();

  ~~~~
}

Now that the adapter has been changed, automatic generation by build_runner is performed again.

katana code generate

This completes the implementation on the data model side.

Create a page to review the data.

In this case, for the purpose of reviewing data, I will put it all on one page, but in reality, it will be like creating a profile page or an article page.

Edit pages/home.dart.

// pages/home.dart

// ignore: unused_import, unnecessary_import
import 'package:flutter/material.dart';
// ignore: unused_import, unnecessary_import
import 'package:masamune/masamune.dart';
import 'package:masamune_universal_ui/masamune_universal_ui.dart';
import 'package:modeltest/models/category.dart';
import 'package:modeltest/models/post.dart';
import 'package:modeltest/models/prefs.dart';
import 'package:modeltest/models/user.dart';

// ignore: unused_import, unnecessary_import
import '/main.dart';

part 'home.page.dart';

@immutable
@PagePath("/")
class HomePage extends PageScopedWidget {
  const HomePage({
    super.key,
  });

  /// Used to transition to the HomePage screen.
  ///
  /// ```dart
  /// router.push(HomePage.query(parameters));    // Push page to HomePage.
  /// router.replace(HomePage.query(parameters)); // Replace page to HomePage.
  /// ```
  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    const userId = "user1";
    final user = ref.model(UserModel.document(userId))..load();
    final posts = ref.model(PostModel.collection())..load();
    final categories = ref.model(CategoryModel.collection())..load();

    // Describes the structure of the page.
    return UniversalScaffold(
      appBar: UniversalAppBar(title: Text(l().appTitle)),
      body: UniversalListView(
        children: [
          const ListTile(
            title: Text(
              "User Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          if (user.value != null)
            ListTile(
              title: Text(user.value?.name ?? ""),
              subtitle: Text(user.value?.prefs?.value?.theme ?? ""),
              trailing: FormSwitch(
                initialValue:
                    user.value?.prefs?.value?.isFinishTutorial ?? false,
                onChanged: (value) async {
                  await user.value?.prefs?.save(
                    user.value?.prefs?.value?.copyWith(isFinishTutorial: value),
                  );
                },
              ),
            )
          else
            ListTile(
              leading: const Icon(Icons.add),
              title: const Text("Create user data"),
              onTap: () async {
                final prefs = ref.model(PrefsModel.document());
                await prefs.load().showIndicator(context);
                if (prefs.value == null) {
                  await prefs.save(const PrefsModel()).showIndicator(context);
                }
                await user
                    .save(
                      UserModel(
                        name: "User at ${DateTime.now().yyyyMMddHHmmss()}",
                        prefs: prefs,
                      ),
                    )
                    .showIndicator(context);
              },
            ),
          const Divider(),
          const ListTile(
            title: Text(
              "Post Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...posts.mapListenable((post) {
            return ListTile(
              title: Text(post.value?.title ?? ""),
              subtitle: Text(post.value?.category?.value?.name ?? ""),
            );
          }),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...categories.map((category) {
            return ListTile(
              leading: const Icon(Icons.add),
              title: Text(category.value?.name ?? ""),
              onTap: () async {
                final newPost = posts.create();
                await newPost
                    .save(
                      PostModel(
                        title: "Post at ${DateTime.now().yyyyMMddHHmmss()}",
                        text: "",
                        category: category,
                      ),
                    )
                    .showIndicator(context);
              },
            );
          }),
        ],
      ),
    );
  }
}

When the application is built, the following screen is launched.

image block

Tap Create user data to create user data.

image block

When you open Firestore, this is how the data is stored.

image block

If checked, it will be written locally and the data will be persistent.

The check remains checked even after the application is started again.

image block

Tap each category to create a post.

image block

Check Firestore and you will find the following data stored.

You can see that on the Firestore, only the reference path is stored in the category field, but the app is able to pick up the name data from that path and display the category name as well.

image block

Finally, let's move the category to Firestore Management.

Open main.dart and port the RuntimeModelAdapter category setting to FirestoreModelAdapter.

// main.dart

~~~~~~

const firestoreModelAdapter = FirestoreModelAdapter(
  iosOptions: DefaultFirebaseOptions.ios,
  androidOptions: DefaultFirebaseOptions.android,
  initialValue: [
    CategoryModelInitialCollection(
      {
        "beauty_and_health": CategoryModel(
          name: "Beauty & Health",
        ),
        "it_and_programming": CategoryModel(
          name: "IT & Programming",
        ),
        "business": CategoryModel(
          name: "Business",
        ),
      },
    ),
  ],
);
const localModelAdapter = LocalModelAdapter();
const runtimeModelAdapter = RuntimeModelAdapter();

Change adapter in models/category.dart to firestoreModelAdapter.

// models/category.dart

~~~~~~

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
  "category",
  adapter: firebaseModelAdapter,
)
class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~~~
}

When You build and start the program, You see that You can get the category names as before.

image block

Let's add data to Firestore to try it out.

image block

Restart the application and you will see that the data has been reflected.

image block

Conclusion

This new feature allows you to write code that specifies the data source in detail.

The following various benefits will be created.

  • Creation of mockups → shortened server implementation
  • Centralized management of all data
  • Securely specify data by defining data schemes

I made it for my own use, but if you think it fits your implementation philosophy, by all means, use it!

Also, I releasing the source here, so issues and PullRequests are welcome!

If you have any further work requests, please contact me directly through my Twitter or website!

Offers app development and apps using Flutter and Unity. Includes information on music and videos created by the company. Distribution of images and video materials. We also accept orders for work.
https://mathru.nethttps://mathru.net
title

GitHub Sponsors

Sponsors are always welcome. Thank you for your support!

Developed the katana/masamune framework, which has dramatically improved the overall efficiency of Flutter-based application development.
https://github.comhttps://github.com
title