Android Bible App — Architecture Guide
Stack: Kotlin · Jetpack Compose · Room · Koin · Coroutines/Flow · Gradle Kotlin DSL (Compile SDK 35)
🏗️ Layer Architecture
┌─────────────────────────────────────────────────┐
│ UI Layer (Jetpack Compose) │
│ BibleScreen.kt, ModulePickerScreen.kt, etc. │
│ State: collectAsStateWithLifecycle() │
└────────────────────┬────────────────────────────┘
│ UiState (sealed/data class)
┌────────────────────▼────────────────────────────┐
│ ViewModel Layer │
│ BibleViewModel, ModuleViewModel, etc. │
│ viewModelScope + StateFlow │
└────────────────────┬────────────────────────────┘
│ Domain Models (pure data)
┌────────────────────▼────────────────────────────┐
│ Repository Layer │
│ BibleRepository, ModuleRepository │
│ Coordinates Room ↔ Engine │
└──────────┬──────────────────────┬───────────────┘
│ │
┌──────────▼──────────┐ ┌────────▼──────────────┐
│ Room (Installed │ │ Engine Layer │
│ Modules, Notes, │ │ SimpleFileOffline │
│ Bookmarks) │ │ BibleEngine.kt │
│ ModuleDao.kt │ │ MySwordFormatParser │
└─────────────────────┘ └───────────────────────┘
Hard Rules:
- No DAO calls directly in a
@Composable— always via ViewModel - No
Contextin ViewModel (useAndroidViewModelonly if truly needed) - No Compose imports in Repository or Engine
- Engine parsers have zero Android framework dependencies
📁 Project Structure Convention
app/
├── src/main/
│ ├── java/com/yourpackage/bible/
│ │ ├── data/
│ │ │ ├── db/
│ │ │ │ ├── AppDatabase.kt Room database
│ │ │ │ ├── dao/ModuleDao.kt DAO interfaces
│ │ │ │ └── entity/ModuleEntity.kt Entities
│ │ │ ├── engine/
│ │ │ │ ├── SimpleFileOfflineBibleEngine.kt
│ │ │ │ └── MySwordFormatParser.kt
│ │ │ └── repository/
│ │ │ ├── BibleRepository.kt
│ │ │ └── ModuleRepository.kt
│ │ ├── di/
│ │ │ └── PlatformSwordKoinModule.kt All Koin modules
│ │ ├── ui/
│ │ │ ├── bible/
│ │ │ │ ├── BibleScreen.kt Composable
│ │ │ │ └── BibleViewModel.kt ViewModel
│ │ │ ├── modules/
│ │ │ │ ├── ModulePickerScreen.kt
│ │ │ │ └── ModuleViewModel.kt
│ │ │ └── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ └── MainActivity.kt
│ └── res/
│ └── values/strings.xml PT-BR strings
├── build.gradle.kts
└── ...
gradle/
└── libs.versions.toml ← Version catalog (single source of truth)
🔧 Koin DI Convention (PlatformSwordKoinModule.kt)
Structure Rules:
// PlatformSwordKoinModule.kt
val databaseModule = module {
single {
Room.databaseBuilder(androidContext(), AppDatabase::class.java, "bible_app.db")
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
single { get<AppDatabase>().moduleDao() }
single { get<AppDatabase>().bookmarkDao() }
}
val engineModule = module {
// Engine is a factory — each call gets a fresh instance for a specific file
factory { (filePath: String) -> SimpleFileOfflineBibleEngine(filePath) }
// Parser is stateless — single instance is fine
single { MySwordFormatParser() }
}
val repositoryModule = module {
single { BibleRepository(get(), get()) }
single { ModuleRepository(get(), get()) }
}
val viewModelModule = module {
viewModel { BibleViewModel(get(), get()) }
viewModel { (filePath: String) -> ModuleViewModel(get(), filePath) }
}
// Application.kt startKoin
startKoin {
androidContext(this@App)
modules(databaseModule, engineModule, repositoryModule, viewModelModule)
}
Scope Decision Guide:
Use single { } |
Use factory { } |
Use viewModel { } |
|---|---|---|
| Room database | Per-file engine | Screen ViewModels |
| DAOs | Per-request objects | Scoped to nav entry |
| Repositories | Test fakes | |
| Format parser |
📊 Room Schema Rules
Migrations (MANDATORY for production)
// AppDatabase.kt
@Database(
entities = [ModuleEntity::class, BookmarkEntity::class, NoteEntity::class],
version = 2, // Increment on schema change
exportSchema = true // Required! Exports to /schemas/
)
abstract class AppDatabase : RoomDatabase() {
abstract fun moduleDao(): ModuleDao
}
// Companion object
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE modules ADD COLUMN language TEXT NOT NULL DEFAULT 'pt'")
}
}
}
Schema export config in build.gradle.kts:
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
🎨 Compose UI Conventions
UiState Pattern (MANDATORY)
// For each screen, define a sealed class
sealed class BibleUiState {
object Loading : BibleUiState()
data class Success(
val currentModule: ModuleEntity,
val verses: List<ParsedVerse>,
val currentBook: Int,
val currentChapter: Int,
val availableModules: List<ModuleEntity>
) : BibleUiState()
data class Error(val message: String) : BibleUiState()
object NoModulesInstalled : BibleUiState()
}
Screen Anatomy (Standard Structure)
@Composable
fun BibleScreen(
viewModel: BibleViewModel = koinViewModel(),
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is BibleUiState.Loading -> BibleLoadingContent()
is BibleUiState.Success -> BibleContent(state, viewModel::onAction, modifier)
is BibleUiState.Error -> BibleErrorContent(state.message)
is BibleUiState.NoModulesInstalled -> NoModulesContent()
}
}
Bible Text Rendering
// Use AnnotatedString for rich text — avoid WebView
@Composable
fun VerseText(verse: ParsedVerse, fontSize: TextUnit = 16.sp) {
val annotatedString = remember(verse) {
buildAnnotatedString {
verse.segments.forEach { segment ->
when (segment) {
is TextSegment.Plain -> append(segment.text)
is TextSegment.Bold -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(segment.text)
}
is TextSegment.RedLetter -> withStyle(SpanStyle(color = Color.Red)) {
append(segment.text)
}
// ... etc
}
}
}
}
Text(text = annotatedString, fontSize = fontSize)
}
📦 Gradle Version Catalog (libs.versions.toml)
How to Add a New Library:
# Step 1: Add version in [versions]
[versions]
room = "2.6.1"
koin = "3.5.3"
compose-bom = "2024.02.00"
# Step 2: Add library in [libraries]
[libraries]
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-ksp = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
# Step 3: Reference in build.gradle.kts
[plugins]
ksp = { id = "com.google.devtools.ksp", version = "1.9.22-1.0.17" }
// build.gradle.kts (app)
dependencies {
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.ksp)
implementation(libs.koin.android)
implementation(libs.koin.compose)
}
Rule: Never use a version string directly in build.gradle.kts. Always add to catalog first.
🔗 Compose Navigation Setup
// NavGraph.kt
@Composable
fun BibleNavGraph(navController: NavHostController) {
NavHost(navController, startDestination = "bible") {
composable("bible") {
BibleScreen()
}
composable(
route = "modules",
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down) }
) {
ModulePickerScreen(onModuleSelected = { navController.popBackStack() })
}
composable("search") { SearchScreen() }
}
}
✅ Feature Addition Checklist
When adding a new feature (e.g., Bookmarks, Notes, Daily Verse):
- Entity: New
@Entityclass with@PrimaryKey - DAO: Interface with
Flow<>for observations,suspendfor writes - Database: Register entity in
@Database(entities = [...]), increment version, addMigration - Repository: New repository class, inject DAO via Koin
- Koin Module: Register in appropriate Koin module (single/factory/viewModel)
- ViewModel: New ViewModel with
StateFlow<UiState>, useviewModelScope - UiState: Sealed class covering Loading/Success/Error
- Screen:
@ComposableusingcollectAsStateWithLifecycle() - Navigation: Route added to
NavGraph - Strings: PT-BR strings in
strings.xml(no hardcoded text) - Build:
./gradlew assembleDebugpasses