DEV Community

Cover image for Flutter & Faith: Crafting an AI Bible Chat App with Stacked Architecture - Part 2
Victor Ihedioha
Victor Ihedioha

Posted on

Flutter & Faith: Crafting an AI Bible Chat App with Stacked Architecture - Part 2

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

You can run stacked generate.

stacked generate
Enter fullscreen mode Exit fullscreen mode

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]
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

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(),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)