Hello. I'm Masaru Hirose.
I am constantly updating Masamune, an integration framework that runs on Flutter.
This time, I would like to introduce an update that could significantly change the app development experience.
masamune
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.
-
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
orconst
.
-
Fixed data list
-
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
-
Data to be viewed by you only
-
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.
-
Data to be shared with other users
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.
Tap Create user data
to create user data.
When you open Firestore, this is how the data is stored.
If checked, it will be written locally and the data will be persistent.
The check remains checked even after the application is started again.
Tap each category to create a post.
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.
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.
Let's add data to Firestore to try it out.
Restart the application and you will see that the data has been reflected.
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!
GitHub Sponsors
Sponsors are always welcome. Thank you for your support!