Setting Up Flutter with Stacked for Optimal Performance
Welcome back to the second installment of our technical deep dive into building an AI Bible chat app. In the first part, we introduced the concept and the technologies involved. Now, let’s roll up our sleeves and get into the nitty-gritty of setting up Flutter with the Stacked architecture.
Why Stacked
Stacked provides a clean architecture that separates UI and business logic, which is crucial for maintainability and scalability. It’s especially beneficial for complex state management and dependency injection, ensuring that our app remains performant and responsive. To me, one special use of Stacked is the elimination of boilerplate code and unnecessary repetitive logic.
Initializing the Project
First, we set up our Flutter environment to use Stacked CLI
Install Stacked CLI
dart pub global activate stacked_cli
You might see an instruction asking you to add a pub path to your system or user path environment of your PC or Mac, please do so.
Ensure you have the latest version of Flutter installed and your favorite IDE ready to go.
Create a new Flutter project leveraging Stacked CLI
stacked create app companion --description MyApp --org com.myapp
The above line tells Stacked CLI to create a new Flutter project called "companion", with description, "MyApp", and company domain, which is a unique identifier of your app, in case you decide to publish on the app stores "com.myapp"
Sit, watch, and marvel at how Stacked CLI creates your Flutter app with necessary starter dependencies, starter views, view-models, and test classes. What more can a productive mobile engineer ask for?
You can add new views, services, dialogs, bottomsheets, widgets, and all come with their view-model and test classes.
stacked create view bible_chat
stacked create service bible_chat
stacked create dialog history_dialog
Once you make changes to your models, modify any view, let's say your view now accepts a parameter, instead of executing the command:
flutter pub run build_runner build --delete-conflicting-outputs
You can run stacked generate.
stacked generate
Preparing the Bible Data
Our app’s core functionality revolves around providing users with scripture passages. To do this, we need a comprehensive dataset that includes the books of the Bible, chapters, verses, and various translations in different languages. "I have reasons I didn't engage the Gemini AI for this task"
The bible data will be initialised and used on the start of the project in a very fast way.
Here's a sample JSON of our Bible data placed in assets/data/app_bible.json in our project
{
"versions": [
{
"name": "King James Vversion",
"label": "KJV"
},
{
"name": "New King James Vversion",
"label": "NKJV"
}
],
"languages": [
"English",
"Spanish",
"French",
"Italian",
"Portuguese",
"Pidgin",
"Igbo",
"Yoruba",
"Hausa"
],
"books": [
{
"name": "Genesis",
"chapters": [
{
"number": 1,
"verses": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
},
{
"number": 2,
"verses": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
}
]
},
{
"name": "Exodus",
"chapters": [
{
"number": 1,
"verses": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
},
{
"number": 2,
"verses": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
}
]
}
]
}
Here's what our Bible data service looks like
class BibleDataService {
Map<String, dynamic> _bibleData = {};
Future<Map<String, dynamic>?> readJson() async {
try {
final String response =
await rootBundle.loadString('assets/data/app_bible.json');
_bibleData = await jsonDecode(response);
return _bibleData;
} catch (e) {
return null;
}
}
List<String> getBooks() {
return _bibleData['books']
.map((book) => book['name'])
.whereType<String>()
.toList();
}
List<int> getChapters(String bookName) {
final book =
_bibleData['books'].firstWhere((book) => book['name'] == bookName);
return book['chapters']
.map((chapter) => chapter['number'])
.whereType<int>()
.toList();
}
List<int> getVerses(String bookName, int chapterNumber) {
final book =
_bibleData['books'].firstWhere((book) => book['name'] == bookName);
final chapter = book['chapters']
.firstWhere((chapter) => chapter['number'] == chapterNumber);
return chapter['verses'].cast<int>();
}
List<String> getVersions() {
return _bibleData['versions']
.map((version) => version['label'])
.whereType<String>()
.toList();
}
List<String> getLanguages() {
return _bibleData['languages']
.map((language) => language)
.whereType<String>()
.toList();
}
}
Note: You could create models that mock this same exact data, making it even more easier.
Chat Models for a seamless chat experience
We need models to represent conversations and sessions in our app, so the user can have continuous conversations with Gemini AI and access the history of conversations with Gemini AI in our app.
conversation.dart
class Conversation {
int? id;
String? conversationId;
String? role;
String? message;
DateTime? timestamp;
Conversation({
this.id,
this.conversationId,
this.role,
this.message,
this.timestamp,
});
Conversation.fromJson(Map<String, dynamic> json) {
id = json['id'];
conversationId = json['conversationId'];
role = json['role'];
message = json['message'];
timestamp = DateTime.parse(json['timestamp']);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['conversationId'] = conversationId;
data['role'] = role;
data['message'] = message;
data['timestamp'] = timestamp?.toIso8601String();
return data;
}
factory Conversation.fromMap(Map<String, dynamic> map) {
return Conversation(
id: map['id'],
conversationId: map['conversationId'],
role: map['role'],
message: map['message'],
timestamp: DateTime.parse(map['timestamp']),
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'conversationId': conversationId,
'role': role,
'message': message,
'timestamp': timestamp?.toIso8601String(),
};
}
}
session.dart
class Session {
String? sessionId;
String? conversationId;
String? title;
DateTime? timestamp;
Session({
this.sessionId,
this.conversationId,
this.title,
this.timestamp,
});
Session.fromJson(Map<String, dynamic> json) {
sessionId = json['sessionId'];
conversationId = json['conversationId'];
title = json['title'];
timestamp = DateTime.parse(json['timestamp']);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['sessionId'] = sessionId;
data['conversationId'] = conversationId;
data['title'] = title;
data['timestamp'] = timestamp?.toIso8601String();
return data;
}
factory Session.fromMap(Map<String, dynamic> map) {
return Session(
sessionId: map['sessionId'],
conversationId: map['conversationId'],
title: map['title'],
timestamp: DateTime.parse(map['timestamp']),
);
}
Map<String, dynamic> toMap() {
return {
'sessionId': sessionId,
'conversationId': conversationId,
'title': title,
'timestamp': timestamp?.toIso8601String(),
};
}
}
session_conversations.dart (To identify group of conversations with the associated session)
class SessionConversations {
Session? session;
List<Conversation>? conversations;
SessionConversations({
this.session,
this.conversations,
});
SessionConversations.fromJson(Map<String, dynamic> json) {
session = json['session'];
if (json['conversations'] != null) {
conversations = <Conversation>[];
json['conversations'].forEach((v) {
conversations!.add(Conversation.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['session'] = session;
if (conversations != null) {
data['conversations'] =
conversations!.map((conversation) => conversation.toJson()).toList();
}
return data;
}
factory SessionConversations.fromMap(Map<String, dynamic> map) {
return SessionConversations(
session: map['session'],
conversations: List<Conversation>.from(map['conversations']
?.map((conversation) => Conversation.fromMap(conversation))),
);
}
Map<String, dynamic> toMap() {
return {
'session': session,
'conversations':
conversations?.map((conversation) => conversation.toMap()).toList(),
};
}
}
sessions.dart (History of all conversations by the user)
class Sessions {
List<SessionConversations>? sessions;
Sessions({
this.sessions,
});
Sessions.fromJson(Map<String, dynamic> json) {
if (json['sessions'] != null) {
sessions = <SessionConversations>[];
json['conversations'].forEach((v) {
sessions!.add(SessionConversations.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
if (sessions != null) {
data['sessions'] =
sessions!.map((sessions) => sessions.toJson()).toList();
}
return data;
}
factory Sessions.fromMap(Map<String, dynamic> map) {
return Sessions(
sessions: List<SessionConversations>.from(map['sessions']
?.map((sessions) => SessionConversations.fromMap(sessions))),
);
}
Map<String, dynamic> toMap() {
return {
'sessions': sessions?.map((sessions) => sessions.toMap()).toList(),
};
}
}
Local Storage with sqflite
With our models in place, we can now set up sqflite for local storage. This will allow users to access history of conversations even when offline, and also pickup from where they left off.
1_bible_chat.sql (assets/sql/1_bible_chat.sql)
CREATE TABLE session (
sessionId TEXT PRIMARY KEY,
conversationId TEXT UNIQUE,
title TEXT,
timestamp DATETIME
);
CREATE TABLE conversation (
id INTEGER PRIMARY KEY,
conversationId TEXT,
role VARCHAR(50),
message TEXT,
timestamp DATETIME,
FOREIGN KEY (conversationId) REFERENCES session(conversationId)
);
A glance at our database service class. You are to have other classes that will involve fetching and inserting data into the database
class DatabaseService {
final _logger = getLogger('DatabaseService');
final _databaseMigrationService = locator<DatabaseMigrationService>();
late final Database _database;
final String _sessionTable = 'session';
final String _conversationTable = 'conversation';
Future<void> init() async {
_logger.i('Initializing database');
final directory = await getApplicationDocumentsDirectory();
_database = await openDatabase(
'${directory.path}/bible_chat',
version: 1,
);
try {
_logger.i('Creating database tables');
// Apply migration on every start
await _databaseMigrationService.runMigration(
_database,
migrationFiles: [
'1_bible_chat.sql',
],
verbose: true,
);
_logger.i('Database tables created');
} catch (e, s) {
_logger.v('Error creating database tables', e, s);
}
}
Future<void> createConversation(Conversation conversation) async {
_logger.i('storing conversation data');
try {
await _database.insert(
_conversationTable,
conversation.toMap(),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
_logger.i('Conversation data stored');
} catch (e) {
_logger.e('error trying to store a conversation data');
}
}
Future<void> createSession(Session session) async {
_logger.i('storing session data');
try {
await _database.insert(
_sessionTable,
session.toMap(),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
_logger.i('Session data stored');
} catch (e) {
_logger.e('error trying to store a session data');
}
}
}
Conclusion
In this article, we’ve set up our Flutter project with the Stacked architecture and prepared our Bible data for use within the app. We’ve also implemented local storage using sqflite, ensuring users enjoy a seamless offline experience.
Stay tuned for the next part, where we’ll dive into integrating the Gemini AI SDK to bring our chat app to life with intelligent scripture search capabilities.
Remember, the journey of learning and development is continuous. As we build and improve our app, we’re also refining our skills and pushing the boundaries of what’s possible with technology.
Here's a link to the previous article https://dev.to/apow/building-an-ai-powered-bible-chat-app-a-technical-journey-with-gemini-ai-sdk-and-flutter-3mil
I will be updating this article with a link to the next article titled: Integrating AI with Grace: The Gemini SDK and Flutter - Part 3
Download the APK app sample (arm64) here
Top comments (0)