Building a Robust Sample App with Room Database and Ktor HTTP Client

Syed Ovais Akhtar
11 min readAug 14, 2024

--

In the ever-evolving landscape of mobile app development, leveraging the right tools and technologies can significantly streamline the process and enhance performance. This article explores how to create a sample application that integrates Room, a powerful database library for Android, with Ktor, a versatile HTTP client framework. By combining these two technologies, developers can build a robust, efficient, and scalable app that handles local data storage and network communication seamlessly. Whether you’re looking to strengthen your Android development skills or simply interested in modern, effective practices, this guide provides a comprehensive walkthrough of setting up and utilizing Room and Ktor in a sample app. Join us as we dive into the essentials of building a sample app with these tools, and discover how they can help you craft a more efficient and dynamic mobile experience.

Let’s deep dive into our implementation

Build Gradle and Version Catalog

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}

android {
namespace = "com.ovais.roomdemo"
compileSdk = 34

defaultConfig {
applicationId = "com.ovais.roomdemo"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}

dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.room.runtime)
kapt(libs.androidx.room.compiler)
implementation(libs.hilt.android)
kapt(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.room.ktx)
testImplementation(libs.androidx.room.testing)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.timber)
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" apply false
id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false
}
[versions]
agp = "8.4.1"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
ktorClientAndroid = "2.3.3"
ktorClientContentNegotiation = "2.3.3"
ktorClientCore = "2.3.3"
ktorClientLogging = "2.3.2"
ktorSerializationKotlinxJson = "2.3.3"
lifecycleRuntimeKtx = "2.8.4"
activityCompose = "1.9.1"
composeBom = "2023.08.00"
roomRuntime = "2.6.1"
hiltAndroid = "2.48"
hiltNavigationCompose = "1.2.0"
timber = "5.0.1"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomRuntime" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClientContentNegotiation" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientLogging" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorSerializationKotlinxJson" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

Android Manifest

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".core.app.RoomDemoApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:fullBackupOnly="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RoomDemo"
tools:targetApi="31">
<activity
android:name=".core.app.RoomDemoActivity"
android:exported="true"
android:theme="@style/Theme.RoomDemo">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

Core DI Modules

Core Module


@InstallIn(SingletonComponent::class)
@Module
interface CoreModule {

@Binds
fun bindToastManager(
default: ToastManagerImpl
): ToastManager

@Binds
fun bindNetworkConnectivityProvider(
default: NetworkConnectivityProviderImpl
): NetworkConnectivityProvider

@Binds
fun bindNetworkManager(
default: NetworkManagerImpl
): NetworkManager

@Binds
fun bindScopeManager(
default: ScopeManagerImpl
): ScopeManager

@Binds
fun bindDispatcherProvider(
default: DispatcherProviderImpl
): DispatcherProvider
}

Database Module


@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {

@Singleton
@Provides
fun providesNotesDatabase(@ApplicationContext context: Context) =
Room.databaseBuilder(
context,
NotesDatabase::class.java, "notes_db"
).fallbackToDestructiveMigration().build()

@Singleton
@Provides
fun providesNotesDao(database: NotesDatabase): NotesDao = database.notesDao()
}

Http Module (Ktor)


@InstallIn(SingletonComponent::class)
@Module
object HttpModule {

@Singleton
@Provides
fun providesHttpClient(): HttpClient {
return HttpClient(Android) {
install(Logging) {
level = LogLevel.ALL
}
install(DefaultRequest) {
url("https://service.apikeeda.com/api/v1/")
header(HttpHeaders.ContentType, ContentType.Application.Json)
header("x-apikeeda-key", "i1723573569641ynj192539562tf")
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}

@Singleton
@Provides
fun provideApiService(httpClient: HttpClient): NotesApiService = NotesApiServiceImpl(httpClient)
}

Note: Base URL and API Keys must be secured through C++ or any other technique

Application Container


@HiltAndroidApp
class RoomDemoApplication : Application() {

override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
}
}

Single Activity


@AndroidEntryPoint
class RoomDemoActivity : ComponentActivity() {
@Inject
lateinit var toastManager: ToastManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RoomDemoTheme {
Scaffold {
NotesScreen(it) { error ->
toastManager.show(error)
}
}
}
}
}
}

Database Layer


@Database(entities = [NotesData::class], version = 2, exportSchema = false)
abstract class NotesDatabase : RoomDatabase() {
abstract fun notesDao(): NotesDao
}


@Dao
interface NotesDao {
@Query("SELECT * FROM notes")
fun getAll(): List<NotesData>

@Query("DELETE FROM notes")
fun deleteAll()

@Query("DELETE FROM notes WHERE note_uuid = :noteId")
fun deleteNote(noteId: String)

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(notes: List<NotesData>)

@Insert
fun insert(note:NotesData)
}

API Service Layer & Response


sealed class Result<T>(val data: T? = null, val error: String? = null) {
class Success<T>(data: T) : Result<T>(data = data)
class Error<T>(error: String) : Result<T>(error = error)
}

interface NotesApiService {
suspend fun getNotes(): Result<GetNotesResponse>
suspend fun addNote(param: AddNoteParam): Result<AddNotesResponse>
suspend fun deleteNote(noteId: String): Result<DeleteNoteResponse>
}

class NotesApiServiceImpl(private val httpClient: HttpClient) : NotesApiService {
override suspend fun getNotes(): Result<GetNotesResponse> {
return try {
Result.Success(httpClient.get("notes").body<GetNotesResponse>())
} catch (e: Exception) {
Timber.e(e)
Result.Error(e.message.toString())
}
}

override suspend fun addNote(param: AddNoteParam): Result<AddNotesResponse> {
return try {
val response = httpClient.post("notes") {
setBody(param)
}.bodyAsText()
Timber.i(response)
Result.Success(Json.decodeFromString<AddNotesResponse>(response))
} catch (e: Exception) {
Timber.e(e)
Result.Error(e.message.toString())
}
}

override suspend fun deleteNote(noteId: String): Result<DeleteNoteResponse> {
return try {
val response = httpClient.delete("notes/$noteId").bodyAsText()
Timber.i(response)
Result.Success(Json.decodeFromString<DeleteNoteResponse>(response))
} catch (e: Exception) {
Timber.e(e)
Result.Error(e.message.toString())
}
}
}

Utilities

Dispatcher


interface DispatcherProvider {
val io: CoroutineDispatcher
val main: CoroutineDispatcher
}

class DispatcherProviderImpl @Inject constructor() : DispatcherProvider {
override val io: CoroutineDispatcher
get() = Dispatchers.IO
override val main: CoroutineDispatcher
get() = Dispatchers.Main
}

Scope Manager


fun interface ScopeManager {
operator fun invoke(dispatcher: CoroutineDispatcher): CoroutineScope
}

class ScopeManagerImpl @Inject constructor() : ScopeManager {
override fun invoke(dispatcher: CoroutineDispatcher): CoroutineScope {
return CoroutineScope(Job() + dispatcher)
}
}

Network Manager


private const val NETWORK_ERROR_TAG = "Network Connectivity State:"

interface NetworkManager : Provider<Flow<NetworkStatus>>

class NetworkManagerImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val connectivityProvider: NetworkConnectivityProvider
) : NetworkManager {
private val connectivityManager by lazy {
context.getSystemService(ConnectivityManager::class.java) as ConnectivityManager
}

override fun get(): Flow<NetworkStatus> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Timber.i("$NETWORK_ERROR_TAG ${NetworkStatus.Connected}")
trySend(NetworkStatus.Connected)
}

override fun onLost(network: Network) {
trySend(NetworkStatus.Lost)
Timber.i("$NETWORK_ERROR_TAG ${NetworkStatus.Lost}")
super.onLost(network)
}
}
val request =
NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
trySend(if (connectivityProvider.get()) NetworkStatus.Connected else NetworkStatus.Lost)
connectivityManager.registerNetworkCallback(request, callback)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}

interface NetworkConnectivityProvider : Provider<Boolean>

class NetworkConnectivityProviderImpl @Inject constructor(
@ApplicationContext private val context: Context
) : NetworkConnectivityProvider {

override fun get(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetworkInfo = connectivityManager.activeNetwork
activeNetworkInfo?.let { network ->
val networkCapabilities = connectivityManager.getNetworkCapabilities(network)
return (
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
?: false || networkCapabilities?.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) ?: false
)
}
return false
}
}


sealed interface NetworkStatus {
data object Connected : NetworkStatus
data object Lost : NetworkStatus
}

Toast Manager


interface ToastManager {
fun show(message: String)
fun show(@StringRes message: Int)
}

class ToastManagerImpl @Inject constructor(
@ApplicationContext private val context: Context
) : ToastManager {
override fun show(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}

override fun show(message: Int) {
Toast.makeText(context, context.getString(message), Toast.LENGTH_SHORT).show()
}
}

Let’s Implement a sample feature now :)

Sample App

Presentation Layer — Home

Compose UI


@Composable
fun NotesScreen(
paddingValues: PaddingValues,
viewModel: NotesViewModel = hiltViewModel(),
onError: (String) -> Unit
) {
val notesList by viewModel.notesUiData.collectAsState()
val errorMessage by viewModel.errorState.collectAsState()
if (errorMessage.isNotEmpty()) {
onError(errorMessage)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray.copy(alpha = 0.5f))
.padding(paddingValues)
) {
var canAddNote by remember {
mutableStateOf(false)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Welcome",
style = Typography.bodyLarge,
modifier = Modifier.padding(all = 4.dp)
)
Button(onClick = {
canAddNote = true

}) {
Text(text = "Add note")
}
}
if (canAddNote) {
AddNoteBottomSheet(
onError = onError,
onDismiss = {
viewModel.getLatestNotes()
canAddNote = false
}
)
}
LazyColumn {
items(notesList) {
SingleNote(notesUiData = it) {
viewModel.onDeleteNote(it.noteId)
}
}
}
}
}


private const val DELETE_ICON_CONTENT_DESCRIPTION = "DELETE_ICON_CONTENT_DESCRIPTION"

@Composable
fun SingleNote(
notesUiData: NotesUiData,
onDeleteNote: (NotesUiData) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(
all = 8.dp
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = notesUiData.title,
modifier = Modifier.padding(
end = 8.dp,
top = 8.dp
)
)
Image(
painter = painterResource(
id = R.drawable.ic_delete
),
contentDescription = DELETE_ICON_CONTENT_DESCRIPTION,
modifier = Modifier
.padding(
end = 8.dp,
top = 8.dp
)
.width(24.dp)
.height(24.dp)
.clickable {
onDeleteNote(notesUiData)
}
)
}
Text(
text = notesUiData.description,
modifier = Modifier
.fillMaxWidth()
.padding(
end = 8.dp,
top = 8.dp,
bottom = 8.dp
)
)
}

}

View Model


@HiltViewModel
class NotesViewModel @Inject constructor(
private val getNotesUseCase: GetNotesUseCase,
private val deleteNoteUseCase: DeleteNoteUseCase
) : ViewModel() {
private val _errorState by lazy { MutableStateFlow("") }
val errorState: StateFlow<String> get() = _errorState
private val _notesUiData by lazy {
MutableStateFlow<List<NotesUiData>>(listOf())
}
val notesUiData: StateFlow<List<NotesUiData>>
get() = _notesUiData


init {
fetchNotes()
}

private fun fetchNotes() {
viewModelScope.launch {
when (val result = getNotesUseCase()) {
is NotesResult.Success -> {
_notesUiData.value = result.notes.map {
NotesUiData(
title = it.title ?: "",
description = it.description ?: "",
createdAt = it.date ?: "",
noteId = it.noteId ?: ""
)
}
}

is NotesResult.Failure -> {
_errorState.update { result.message }
}
}
}
}

fun getLatestNotes() {
fetchNotes()
}
fun onDeleteNote(noteId: String) {
viewModelScope.launch {
when (val result = deleteNoteUseCase(noteId)) {
is NoteDeletionResult.Deleted -> {
fetchNotes()
}

is NoteDeletionResult.Failure -> {
_errorState.update { result.message }
}
}
}
}
}

Domain Layer — Home


fun interface GetNotesUseCase {
suspend operator fun invoke(): NotesResult
}

class GetNotesUseCaseImpl @Inject constructor(
private val notesRepository: NotesRepository
) : GetNotesUseCase {
override suspend fun invoke(): NotesResult {
return notesRepository.getNotes()
}
}

sealed interface NotesResult {
data class Success(val notes: List<NotesData>) : NotesResult
data class Failure(val message: String) : NotesResult
}

fun interface DeleteNoteUseCase {
suspend operator fun invoke(noteId: String): NoteDeletionResult
}

class DeleteNoteUseCaseImpl @Inject constructor(
private val notesRepository: NotesRepository
) : DeleteNoteUseCase {
override suspend fun invoke(noteId: String): NoteDeletionResult {
return notesRepository.deleteNote(noteId)
}
}

sealed interface NoteDeletionResult {
data object Deleted : NoteDeletionResult
data class Failure(val message: String) : NoteDeletionResult
}

Data Layer — Home

Repository


interface NotesRepository {
suspend fun getNotes(): NotesResult
suspend fun deleteNote(noteId: String): NoteDeletionResult
suspend fun addNote(param: AddNoteParam): AddNoteResult
}

class NotesRepositoryImpl @Inject constructor(
private val scopeManager: ScopeManager,
private val dispatcherProvider: DispatcherProvider,
private val networkManager: NetworkManager,
private val apiService: NotesApiService,
private val notesDao: NotesDao
) : NotesRepository {

override suspend fun getNotes(): NotesResult {
return suspendCancellableCoroutine { continuation ->
scopeManager(dispatcherProvider.io).launch {
networkManager.get().collectLatest { status ->
when (status) {
is NetworkStatus.Connected -> {
continuation.resume(NotesResult.Success(getNotesFromRemoteService()))
}

is NetworkStatus.Lost -> {
continuation.resume(NotesResult.Success(getNotesFromDatabase()))
}
}
}
}
}
}

override suspend fun deleteNote(noteId: String): NoteDeletionResult {
return suspendCancellableCoroutine { continuation ->
scopeManager(dispatcherProvider.io).launch {
networkManager.get().collectLatest { state ->
if (state is NetworkStatus.Connected) {
continuation.resume(deleteNoteFromServer(noteId))
} else {
notesDao.deleteNote(noteId)
continuation.resume(NoteDeletionResult.Deleted)
}
}
}
}
}

private suspend fun deleteNoteFromServer(noteId: String): NoteDeletionResult {
return when (val result = apiService.deleteNote(noteId)) {
is Result.Success -> {
notesDao.deleteNote(noteId)
NoteDeletionResult.Deleted
}

is Result.Error -> {
val error = result.data?.message.toString()
Timber.e(error)
notesDao.deleteNote(noteId)
NoteDeletionResult.Failure(error)
}
}
}

override suspend fun addNote(param: AddNoteParam): AddNoteResult {
return suspendCancellableCoroutine { continuation ->
scopeManager(dispatcherProvider.io).launch {
networkManager.get().collectLatest { state ->
if (state is NetworkStatus.Connected) {
continuation.resume(addNoteToRemoteServer(param))
} else {
val noteData = listOf(
NotesData(
version = 0,
noteId = UUID.randomUUID().toString(),
date = param.date,
title = param.title,
description = param.description
)
)
addNotesToDatabase(noteData)
continuation.resume(AddNoteResult.Added)
}
}
}
}
}

private suspend fun addNoteToRemoteServer(param: AddNoteParam): AddNoteResult {
return when (val result = apiService.addNote(param)) {
is Result.Success -> {
addNotesToDatabase(result.data?.allNotes.orEmpty())
AddNoteResult.Added
}

is Result.Error -> AddNoteResult.Failure(result.error.toString())
}
}

private fun addNotesToDatabase(notes: List<NotesData>) {
notesDao.insertAll(notes)
}

private suspend fun getNotesFromRemoteService(): List<NotesData> {
return when (val result = apiService.getNotes()) {
is Result.Success -> {
val notes = result.data?.allNotes.orEmpty()
addNotesToDatabase(notes)
notes
}

is Result.Error -> getNotesFromDatabase()
}
}

private fun getNotesFromDatabase(): List<NotesData> {
return notesDao.getAll()
}
}
@Serializable
data class NotesUiData(
val title: String,
val description: String,
val createdAt: String,
val noteId: String
)

Let’s build a add simple add note feature

Presentation — Add Note


@Composable
fun AddNoteBottomSheet(
viewModel: AddNotesViewModel = hiltViewModel(),
onError: (String) -> Unit,
onDismiss: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }

when (uiState) {
is AddNoteUiState.Failure -> {
onError((uiState as AddNoteUiState.Failure).message)
}

is AddNoteUiState.Added -> {
onDismiss()
}

else -> Unit
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(Color.White)
.wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Add Note")
Spacer(modifier = Modifier.height(16.dp))

TextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))

TextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
maxLines = 3
)
Spacer(modifier = Modifier.height(16.dp))

Button(
onClick = {
viewModel.addNote(title, description)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text("Add")
}
}
}

@HiltViewModel
class AddNotesViewModel @Inject constructor(
private val addNotesNoteUseCase: AddNoteUseCase
) : ViewModel() {
private val _uiState by lazy { MutableStateFlow<AddNoteUiState>(AddNoteUiState.Idle) }
val uiState: StateFlow<AddNoteUiState>
get() = _uiState

fun addNote(title: String, description: String) {
viewModelScope.launch {
when (val result = addNotesNoteUseCase(
AddNoteParam(
title,
description,
Date().toString()
)
)) {
is AddNoteResult.Added -> {
_uiState.update { AddNoteUiState.Added }
}

is AddNoteResult.Failure -> {
_uiState.update { AddNoteUiState.Failure(result.message) }
}
}
}
}
}
sealed interface AddNoteUiState {
data object Added : AddNoteUiState
data class Failure(val message: String) : AddNoteUiState
data object Idle : AddNoteUiState
}

Domain Layer — Add Note


fun interface AddNoteUseCase {
suspend operator fun invoke(param: AddNoteParam): AddNoteResult
}

class AddNoteUseCaseImpl @Inject constructor(
private val notesRepository: NotesRepository
) : AddNoteUseCase {
override suspend fun invoke(param: AddNoteParam): AddNoteResult {
return notesRepository.addNote(param)
}
}

DTO

 
@Serializable
data class AddNoteParam(
val title: String,
val description: String,
val date: String
)


sealed interface AddNoteResult {
data object Added: AddNoteResult
data class Failure(val message: String) : AddNoteResult
}

Conclusion

In conclusion, integrating Ktor HTTP client, Room database, and modern architectural principles like Dependency Injection, Clean Architecture, and Single Activity Architecture sets a solid foundation for building scalable and maintainable Android applications. Through this article, we’ve demonstrated how to utilize these tools to create a sample app with fundamental features such as adding, deleting, and retrieving notes.

By adopting Ktor for network operations, Room for local data persistence, and implementing Dependency Injection to manage dependencies, you streamline development and improve the testability and modularity of your code. Clean Architecture principles ensure a separation of concerns, making your app more adaptable to changes and easier to maintain. The Single Activity Architecture simplifies navigation and state management, leading to a more cohesive user experience.

Overall, this approach not only enhances the efficiency and structure of your app but also aligns with best practices in Android development. As you continue to build and expand upon these foundational features, you’ll find that these technologies and principles provide a robust framework for developing high-quality, reliable applications.

Feel free to connect with me on @syedovaiss(LinkedIn) and check out my projects on @syedovaiss(GitHub) for more insights and updates on my work!

--

--

Syed Ovais Akhtar
Syed Ovais Akhtar

Written by Syed Ovais Akhtar

Senior Software Engineer - Mobile

No responses yet