├── app ├── .gitignore ├── src │ └── main │ │ ├── res │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── values │ │ │ ├── themes.xml │ │ │ ├── colors.xml │ │ │ └── strings.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ └── drawable │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ └── ro │ │ │ └── alexmamo │ │ │ └── firestorecleanarchitecture │ │ │ ├── FirestoreCleanArchitectureApp.kt │ │ │ ├── domain │ │ │ ├── model │ │ │ │ ├── Book.kt │ │ │ │ └── Response.kt │ │ │ └── repository │ │ │ │ └── BookListRepository.kt │ │ │ ├── presentation │ │ │ ├── book_list │ │ │ │ ├── components │ │ │ │ │ ├── TitleText.kt │ │ │ │ │ ├── AuthorText.kt │ │ │ │ │ ├── TopBar.kt │ │ │ │ │ ├── AuthorTextField.kt │ │ │ │ │ ├── AddBookFloatingActionButton.kt │ │ │ │ │ ├── EmptyBookListContent.kt │ │ │ │ │ ├── TitleTextField.kt │ │ │ │ │ ├── BookCard.kt │ │ │ │ │ ├── BookListContent.kt │ │ │ │ │ ├── AddBookAlertDialog.kt │ │ │ │ │ └── EditableBookCard.kt │ │ │ │ ├── BookListViewModel.kt │ │ │ │ └── BookListScreen.kt │ │ │ └── MainActivity.kt │ │ │ ├── components │ │ │ ├── ActionButton.kt │ │ │ ├── LoadingIndicator.kt │ │ │ └── ActionIconButton.kt │ │ │ ├── core │ │ │ └── Utils.kt │ │ │ ├── di │ │ │ └── AppModule.kt │ │ │ └── data │ │ │ └── repository │ │ │ └── BookListRepositoryImpl.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle.kts ├── .idea ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── kotlinc.xml ├── vcs.xml ├── deploymentTargetDropDown.xml ├── compiler.xml ├── migrations.xml ├── misc.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── runConfigurations.xml └── inspectionProfiles │ └── Project_Default.xml ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .gitignore ├── settings.gradle ├── gradle.properties ├── README.md ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /google-services.json -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexMamo/FirestoreCleanArchitectureApp/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Feb 13 10:41:17 EET 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/FirestoreCleanArchitectureApp.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class FirestoreCleanArchitectureApp : Application() -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/domain/model/Book.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.domain.model 2 | 3 | import com.google.firebase.firestore.Exclude 4 | 5 | data class Book( 6 | val author: String? = null, 7 | @get:Exclude 8 | var id: String, 9 | val title: String? = null 10 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "FirestoreCleanArchitecture" 16 | include ':app' -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/domain/model/Response.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.domain.model 2 | 3 | sealed class Response { 4 | data object Idle : Response() 5 | 6 | data object Loading : Response() 7 | 8 | data class Success( 9 | val data: T? 10 | ) : Response() 11 | 12 | data class Failure( 13 | val e: Exception? 14 | ) : Response() 15 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/TitleText.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.unit.sp 7 | 8 | @Composable 9 | fun TitleText( 10 | title: String 11 | ) { 12 | Text( 13 | text = title, 14 | color = Color.DarkGray, 15 | fontSize = 25.sp 16 | ) 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/components/ActionButton.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.components 2 | 3 | import androidx.compose.material.Button 4 | import androidx.compose.material.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.res.stringResource 7 | 8 | @Composable 9 | fun ActionButton( 10 | onActionButtonClick: () -> Unit, 11 | resourceId: Int 12 | ) { 13 | Button( 14 | onClick = onActionButtonClick 15 | ) { 16 | Text( 17 | text = stringResource( 18 | id = resourceId 19 | ) 20 | ) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/components/LoadingIndicator.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.CircularProgressIndicator 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | 10 | @Composable 11 | fun LoadingIndicator() { 12 | Box( 13 | modifier = Modifier.fillMaxSize(), 14 | contentAlignment = Alignment.Center 15 | ){ 16 | CircularProgressIndicator() 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.BookListScreen 8 | 9 | @AndroidEntryPoint 10 | class MainActivity : ComponentActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | setContent { 14 | BookListScreen() 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/AuthorText.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.text.style.TextDecoration 7 | import androidx.compose.ui.unit.sp 8 | 9 | @Composable 10 | fun AuthorText( 11 | author: String 12 | ) { 13 | Text( 14 | text = "by $author", 15 | color = Color.DarkGray, 16 | fontSize = 12.sp, 17 | textDecoration = TextDecoration.Underline 18 | ) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/TopBar.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.material.TopAppBar 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.res.stringResource 7 | import ro.alexmamo.firestorecleanarchitecture.R 8 | 9 | @Composable 10 | fun TopBar() { 11 | TopAppBar ( 12 | title = { 13 | Text( 14 | text = stringResource( 15 | id = R.string.app_name 16 | ) 17 | ) 18 | } 19 | ) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/core/Utils.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.core 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import android.widget.Toast 6 | 7 | const val TAG = "AppTag" 8 | const val AUTHOR_FIELD = "author" 9 | const val ID_FIELD = "id" 10 | const val TITLE_FIELD = "title" 11 | const val NO_BOOK_AUTHOR = "No book author" 12 | const val NO_BOOK_TITLE = "No book title" 13 | 14 | fun logErrorMessage( 15 | errorMessage: String 16 | ) = Log.e(TAG, errorMessage) 17 | 18 | fun showToastMessage( 19 | context: Context, 20 | message: String 21 | ) = Toast.makeText(context, message, Toast.LENGTH_LONG).show() -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/components/ActionIconButton.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.components 2 | 3 | import androidx.compose.material.Icon 4 | import androidx.compose.material.IconButton 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | import androidx.compose.ui.res.stringResource 8 | 9 | @Composable 10 | fun ActionIconButton( 11 | onActionIconButtonClick: () -> Unit, 12 | imageVector: ImageVector, 13 | resourceId: Int 14 | ) { 15 | IconButton( 16 | onClick = onActionIconButtonClick 17 | ) { 18 | Icon( 19 | imageVector = imageVector, 20 | contentDescription = stringResource( 21 | id = resourceId 22 | ) 23 | ) 24 | } 25 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/domain/repository/BookListRepository.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.domain.repository 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Book 5 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Response 6 | 7 | typealias BookListResponse = Response> 8 | typealias AddBookResponse = Response 9 | typealias UpdateBookResponse = Response 10 | typealias DeleteBookResponse = Response 11 | 12 | interface BookListRepository { 13 | fun getBookList(): Flow 14 | 15 | suspend fun addBook(book: Map): AddBookResponse 16 | 17 | suspend fun updateBook(bookUpdates: Map): UpdateBookResponse 18 | 19 | suspend fun deleteBook(bookId: String): DeleteBookResponse 20 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/AuthorTextField.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.material.OutlinedTextField 4 | import androidx.compose.material.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.res.stringResource 7 | import androidx.compose.ui.text.input.TextFieldValue 8 | import ro.alexmamo.firestorecleanarchitecture.R 9 | 10 | @Composable 11 | fun AuthorTextField( 12 | author: TextFieldValue, 13 | onAuthorChange: (TextFieldValue) -> Unit 14 | ) { 15 | OutlinedTextField( 16 | value = author, 17 | onValueChange = onAuthorChange, 18 | placeholder = { 19 | Text( 20 | text = stringResource( 21 | id = R.string.book_author 22 | ) 23 | ) 24 | }, 25 | singleLine = true 26 | ) 27 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.di 2 | 3 | import com.google.firebase.firestore.FirebaseFirestore 4 | import com.google.firebase.firestore.ktx.firestore 5 | import com.google.firebase.ktx.Firebase 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import ro.alexmamo.firestorecleanarchitecture.data.repository.BookListRepositoryImpl 11 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.BookListRepository 12 | 13 | const val BOOKS = "books" 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object AppModule { 18 | @Provides 19 | fun provideFirebaseFirestore() = Firebase.firestore 20 | 21 | @Provides 22 | fun provideBookListRepository( 23 | db: FirebaseFirestore 24 | ): BookListRepository = BookListRepositoryImpl( 25 | booksRef = db.collection(BOOKS) 26 | ) 27 | } -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | FirestoreCleanArchitecture 4 | 5 | "The book list is empty." 6 | "Add book" 7 | 8 | "Add" 9 | "Update" 10 | "Cancel" 11 | 12 | "Edit" 13 | Delete 14 | 15 | "Open add book dialog." 16 | 17 | "Type a book title…" 18 | "Type a book author…" 19 | 20 | "filed cannot be empty or contain only whitespace." 21 | "operation successfully performed." 22 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/AddBookFloatingActionButton.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.material.FloatingActionButton 4 | import androidx.compose.material.Icon 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Add 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.res.stringResource 10 | import ro.alexmamo.firestorecleanarchitecture.R 11 | 12 | @Composable 13 | fun AddBookFloatingActionButton( 14 | onAddBookFloatingActionButtonClick: () -> Unit 15 | ) { 16 | FloatingActionButton( 17 | backgroundColor = MaterialTheme.colors.primary, 18 | onClick = onAddBookFloatingActionButtonClick 19 | ) { 20 | Icon( 21 | imageVector = Icons.Default.Add, 22 | contentDescription = stringResource( 23 | id = R.string.open_add_book_dialog 24 | ) 25 | ) 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/EmptyBookListContent.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.sp 13 | import ro.alexmamo.firestorecleanarchitecture.R 14 | 15 | @Composable 16 | fun EmptyBookListContent( 17 | innerPadding: PaddingValues 18 | ) { 19 | Box( 20 | modifier = Modifier.fillMaxSize().padding(innerPadding), 21 | contentAlignment = Alignment.Center, 22 | ){ 23 | Text( 24 | text = stringResource( 25 | id = R.string.empty_book_list_text 26 | ), 27 | fontSize = 18.sp 28 | ) 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/TitleTextField.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.material.OutlinedTextField 4 | import androidx.compose.material.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.focus.FocusRequester 10 | import androidx.compose.ui.focus.focusRequester 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.text.input.TextFieldValue 13 | import ro.alexmamo.firestorecleanarchitecture.R 14 | 15 | @Composable 16 | fun TitleTextField( 17 | title: TextFieldValue, 18 | onTitleChange: (TextFieldValue) -> Unit 19 | ) { 20 | val focusRequester = remember { FocusRequester() } 21 | 22 | LaunchedEffect(Unit) { 23 | focusRequester.requestFocus() 24 | } 25 | 26 | OutlinedTextField( 27 | modifier = Modifier.focusRequester(focusRequester), 28 | value = title, 29 | onValueChange = onTitleChange, 30 | placeholder = { 31 | Text( 32 | text = stringResource( 33 | id = R.string.book_title 34 | ) 35 | ) 36 | }, 37 | singleLine = true 38 | ) 39 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | alias(libs.plugins.devtools.ksp) 6 | alias(libs.plugins.google.services) 7 | alias(libs.plugins.hilt.android) 8 | } 9 | 10 | android { 11 | namespace = "ro.alexmamo.firestorecleanarchitecture" 12 | compileSdk = 35 13 | 14 | defaultConfig { 15 | applicationId = "ro.alexmamo.firestorecleanarchitecture" 16 | minSdk = 26 17 | targetSdk = 35 18 | versionCode = 1 19 | versionName = "1.0" 20 | 21 | testInstrumentationRunner = "ro.alexmamo.roomjetpackcompose.HiltTestRunner" 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility = JavaVersion.VERSION_21 26 | targetCompatibility = JavaVersion.VERSION_21 27 | } 28 | kotlinOptions { 29 | jvmTarget = JavaVersion.VERSION_21.toString() 30 | } 31 | buildFeatures { 32 | compose = true 33 | } 34 | composeOptions { 35 | kotlinCompilerExtensionVersion = libs.versions.compose.get() 36 | } 37 | } 38 | 39 | dependencies { 40 | //Compose 41 | implementation(platform(libs.compose.bom)) 42 | implementation(libs.compose.material) 43 | implementation(libs.compose.material.icons) 44 | implementation(libs.activity.compose) 45 | implementation(libs.viewmodel.compose) 46 | //Hilt 47 | implementation(libs.hilt) 48 | ksp(libs.hilt.compiler) 49 | //Firebase 50 | implementation(libs.firebase.firestore) 51 | } -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | gradle = "8.8.2" 3 | kotlin = "2.1.10" 4 | ksp = "2.1.10-1.0.30" 5 | googleServices = "4.4.2" 6 | hilt = "2.55" 7 | composeBom = "2025.03.00" 8 | compose = "1.5.15" 9 | activityCompose = "1.10.1" 10 | viewmodelCompose = "2.8.7" 11 | firebaseFirestore = "25.1.2" 12 | 13 | [plugins] 14 | android-application = { id = "com.android.application", version.ref = "gradle" } 15 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 16 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 17 | devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 18 | google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } 19 | hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 20 | 21 | [libraries] 22 | compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } 23 | compose-material = { module = "androidx.compose.material:material" } 24 | compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } 25 | activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } 26 | viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "viewmodelCompose" } 27 | hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } 28 | hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } 29 | firebase-firestore = { module = "com.google.firebase:firebase-firestore", version.ref = "firebaseFirestore" } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/BookCard.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.Card 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Delete 12 | import androidx.compose.material.icons.filled.Edit 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import ro.alexmamo.firestorecleanarchitecture.R 17 | import ro.alexmamo.firestorecleanarchitecture.components.ActionIconButton 18 | import ro.alexmamo.firestorecleanarchitecture.core.NO_BOOK_AUTHOR 19 | import ro.alexmamo.firestorecleanarchitecture.core.NO_BOOK_TITLE 20 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Book 21 | 22 | @Composable 23 | fun BookCard( 24 | book: Book, 25 | onEditBook: () -> Unit, 26 | onDeleteBook: () -> Unit 27 | ) { 28 | Card( 29 | modifier = Modifier.fillMaxWidth().padding( 30 | start = 8.dp, 31 | top = 4.dp, 32 | end = 8.dp, 33 | bottom = 4.dp 34 | ), 35 | shape = MaterialTheme.shapes.small, 36 | elevation = 3.dp 37 | ) { 38 | Row( 39 | modifier = Modifier.fillMaxWidth().padding(8.dp) 40 | ) { 41 | Column { 42 | TitleText( 43 | title = book.title ?: NO_BOOK_TITLE 44 | ) 45 | AuthorText( 46 | author = book.author ?: NO_BOOK_AUTHOR 47 | ) 48 | } 49 | Spacer( 50 | modifier = Modifier.weight(1f) 51 | ) 52 | ActionIconButton( 53 | onActionIconButtonClick = onEditBook, 54 | imageVector = Icons.Default.Edit, 55 | resourceId = R.string.edit_icon 56 | ) 57 | ActionIconButton( 58 | onActionIconButtonClick = onDeleteBook, 59 | imageVector = Icons.Default.Delete, 60 | resourceId = R.string.delete_icon 61 | ) 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/data/repository/BookListRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.data.repository 2 | 3 | import com.google.firebase.firestore.CollectionReference 4 | import com.google.firebase.firestore.DocumentSnapshot 5 | import kotlinx.coroutines.channels.awaitClose 6 | import kotlinx.coroutines.flow.callbackFlow 7 | import kotlinx.coroutines.tasks.await 8 | import ro.alexmamo.firestorecleanarchitecture.core.AUTHOR_FIELD 9 | import ro.alexmamo.firestorecleanarchitecture.core.ID_FIELD 10 | import ro.alexmamo.firestorecleanarchitecture.core.TITLE_FIELD 11 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Book 12 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Response 13 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.BookListRepository 14 | 15 | class BookListRepositoryImpl( 16 | private val booksRef: CollectionReference 17 | ): BookListRepository { 18 | override fun getBookList() = callbackFlow { 19 | val listener = booksRef.orderBy(TITLE_FIELD).addSnapshotListener { bookListSnapshot, e -> 20 | val bookListResponse = if (bookListSnapshot != null) { 21 | val bookList = bookListSnapshot.map { bookSnapshot -> 22 | bookSnapshot.toBook() 23 | } 24 | Response.Success(bookList) 25 | } else { 26 | Response.Failure(e) 27 | } 28 | trySend(bookListResponse) 29 | } 30 | awaitClose { 31 | listener.remove() 32 | } 33 | } 34 | 35 | override suspend fun addBook(book: Map) = try { 36 | booksRef.add(book).await() 37 | Response.Success(Unit) 38 | } catch (e: Exception) { 39 | Response.Failure(e) 40 | } 41 | 42 | override suspend fun updateBook(bookUpdates: Map) = try { 43 | val bookId = bookUpdates.getValue(ID_FIELD) 44 | booksRef.document(bookId).update(bookUpdates).await() 45 | Response.Success(Unit) 46 | } catch (e: Exception) { 47 | Response.Failure(e) 48 | } 49 | 50 | override suspend fun deleteBook(bookId: String) = try { 51 | booksRef.document(bookId).delete().await() 52 | Response.Success(Unit) 53 | } catch (e: Exception) { 54 | Response.Failure(e) 55 | } 56 | } 57 | 58 | fun DocumentSnapshot.toBook() = Book( 59 | author = getString(AUTHOR_FIELD), 60 | id = id, 61 | title = getString(TITLE_FIELD) 62 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FirestoreCleanArchitectureApp 2 | FirestoreCleanArchitectureApp is an app built with [Kotlin][1] and [Cloud Firestore][2] that displays data in real-time using [Android Architecture Components][3] and the MVVM Architecture Pattern. For the UI it uses Jetpack Compose, Android's modern toolkit for building native UI. 3 | 4 | ![alt text](https://miro.medium.com/max/352/1*HaVOC4vTFXgqTdoGS5yLwQ.png) 5 | 6 | Below you can find the docs for each tehnology that is used in this app: 7 | 8 | ## Firebase Products: 9 | * [Cloud Firestore][2] 10 | 11 | ## Android Architecture Components: 12 | * [ViewModel][5] 13 | 14 | ## Dependency Injection: 15 | * [Hilt for Android][6] 16 | 17 | ## Asynchronous Programming: 18 | * [Kotlin Coroutines][7] 19 | * [Asynchronous Flow][8] 20 | 21 | ## Other Android Components: 22 | * [Jetpack Compose][9] 23 | 24 | --- 25 | 26 | This repo represents the code for following article writen on the Medium publication: 27 | 28 | * [How to make a clean architecture Android app, using MVVM, Firestore, and Jetpack Compose?][10] 29 | 30 | See it also on youtube: 31 | 32 | * https://youtu.be/zvQ4vH2dC78 33 | 34 | If you download or clone this repo, in order to make it work, you should follow the instructions given in the official documentation regarding on [how to add Firebase to your project][11]. 35 | 36 | **License** 37 | --- 38 | The code in this project is licensed under the Apache License 2.0. 39 | 40 | Copyright 2018 Google LLC 41 | 42 | Licensed under the Apache License, Version 2.0 (the "License"); 43 | you may not use this file except in compliance with the License. 44 | You may obtain a copy of the License at 45 | 46 | https://www.apache.org/licenses/LICENSE-2.0 47 | 48 | Unless required by applicable law or agreed to in writing, software 49 | distributed under the License is distributed on an "AS IS" BASIS, 50 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 51 | See the License for the specific language governing permissions and 52 | limitations under the License. 53 | 54 | **Disclaimer** 55 | --- 56 | * This is not an officially supported Google product. 57 | 58 | [1]: https://kotlinlang.org/ 59 | [2]: https://firebase.google.com/docs/firestore 60 | [3]: https://developer.android.com/topic/libraries/architecture 61 | [5]: https://developer.android.com/topic/libraries/architecture/viewmodel 62 | [6]: https://developer.android.com/training/dependency-injection/hilt-android 63 | [7]: https://kotlinlang.org/docs/coroutines-overview.html 64 | [8]: https://kotlinlang.org/docs/flow.html 65 | [9]: https://developer.android.com/jetpack/compose 66 | [10]: https://medium.com/firebase-tips-tricks/how-to-make-a-clean-architecture-android-app-using-mvvm-firestore-and-jetpack-compose-abdb5e02a2d8 67 | [11]: https://firebase.google.com/docs/android/setup 68 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/BookListViewModel.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import kotlinx.coroutines.launch 10 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Response 11 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.AddBookResponse 12 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.BookListRepository 13 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.BookListResponse 14 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.DeleteBookResponse 15 | import ro.alexmamo.firestorecleanarchitecture.domain.repository.UpdateBookResponse 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class BookListViewModel @Inject constructor( 20 | private val repo: BookListRepository 21 | ): ViewModel() { 22 | private val _bookListState = MutableStateFlow(Response.Loading) 23 | val bookListState: StateFlow = _bookListState.asStateFlow() 24 | 25 | private val _addBookState = MutableStateFlow(Response.Idle) 26 | val addBookState: StateFlow = _addBookState.asStateFlow() 27 | 28 | private val _updateBookState = MutableStateFlow(Response.Idle) 29 | val updateBookState: StateFlow = _updateBookState.asStateFlow() 30 | 31 | private val _deleteBookState = MutableStateFlow(Response.Idle) 32 | val deleteBookState: StateFlow = _deleteBookState.asStateFlow() 33 | 34 | init { 35 | getBookList() 36 | } 37 | 38 | private fun getBookList() = viewModelScope.launch { 39 | repo.getBookList().collect { response -> 40 | _bookListState.value = response 41 | } 42 | } 43 | 44 | fun addBook(book: Map) = viewModelScope.launch { 45 | _addBookState.value = Response.Loading 46 | _addBookState.value = repo.addBook(book) 47 | } 48 | 49 | fun resetAddBookState() { 50 | _addBookState.value = Response.Idle 51 | } 52 | 53 | fun updateBook(bookUpdates: Map) = viewModelScope.launch { 54 | _updateBookState.value = Response.Loading 55 | _updateBookState.value = repo.updateBook(bookUpdates) 56 | } 57 | 58 | fun resetUpdateBookState() { 59 | _updateBookState.value = Response.Idle 60 | } 61 | 62 | fun deleteBook(bookId: String) = viewModelScope.launch { 63 | _deleteBookState.value = Response.Loading 64 | _deleteBookState.value = repo.deleteBook(bookId) 65 | } 66 | 67 | fun resetDeleteBookState() { 68 | _deleteBookState.value = Response.Idle 69 | } 70 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/BookListContent.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.items 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import ro.alexmamo.firestorecleanarchitecture.core.AUTHOR_FIELD 15 | import ro.alexmamo.firestorecleanarchitecture.core.ID_FIELD 16 | import ro.alexmamo.firestorecleanarchitecture.core.TITLE_FIELD 17 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Book 18 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.BookField 19 | 20 | const val NON_EXISTENT_BOOK_ID = "NO_ID" 21 | 22 | @Composable 23 | fun BookListContent( 24 | innerPadding: PaddingValues, 25 | bookList: List, 26 | onUpdateBook: (Map) -> Unit, 27 | onInvalidBookField: (BookField) -> Unit, 28 | onDeleteBook: (String) -> Unit 29 | ) { 30 | var editBookId by remember { mutableStateOf(NON_EXISTENT_BOOK_ID) } 31 | 32 | LazyColumn( 33 | modifier = Modifier.fillMaxSize().padding(innerPadding) 34 | ) { 35 | items( 36 | items = bookList, 37 | key = { book -> 38 | book.id 39 | } 40 | ) { book -> 41 | if (editBookId != book.id) { 42 | BookCard( 43 | book = book, 44 | onEditBook = { 45 | editBookId = book.id 46 | }, 47 | onDeleteBook = { 48 | onDeleteBook(book.id) 49 | editBookId = NON_EXISTENT_BOOK_ID 50 | } 51 | ) 52 | } else { 53 | EditableBookCard( 54 | book = book, 55 | onUpdateBook = { title, author -> 56 | val bookUpdates = mutableMapOf() 57 | if (book.title != title) { 58 | bookUpdates[TITLE_FIELD] = title 59 | } 60 | if (book.author != author) { 61 | bookUpdates[AUTHOR_FIELD] = author 62 | } 63 | if (bookUpdates.isNotEmpty()) { 64 | bookUpdates[ID_FIELD] = book.id 65 | onUpdateBook(bookUpdates) 66 | } 67 | editBookId = NON_EXISTENT_BOOK_ID 68 | }, 69 | onInvalidBookField = onInvalidBookField, 70 | onCancel = { 71 | editBookId = NON_EXISTENT_BOOK_ID 72 | } 73 | ) 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/AddBookAlertDialog.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.material.AlertDialog 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.text.input.TextFieldValue 16 | import androidx.compose.ui.unit.dp 17 | import ro.alexmamo.firestorecleanarchitecture.R 18 | import ro.alexmamo.firestorecleanarchitecture.components.ActionButton 19 | import ro.alexmamo.firestorecleanarchitecture.core.AUTHOR_FIELD 20 | import ro.alexmamo.firestorecleanarchitecture.core.TITLE_FIELD 21 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.BookField 22 | import kotlin.String 23 | 24 | const val EMPTY_STRING = "" 25 | 26 | @Composable 27 | fun AddBookAlertDialog( 28 | onAddBook: (book: Map) -> Unit, 29 | onInvalidBookField: (BookField) -> Unit, 30 | onAddBookDialogCancel: () -> Unit 31 | ) { 32 | var title by remember { mutableStateOf(TextFieldValue(EMPTY_STRING)) } 33 | var author by remember { mutableStateOf(TextFieldValue(EMPTY_STRING)) } 34 | 35 | AlertDialog( 36 | onDismissRequest = onAddBookDialogCancel, 37 | title = { 38 | Text( 39 | text = stringResource( 40 | id = R.string.add_book 41 | ) 42 | ) 43 | }, 44 | text = { 45 | Column { 46 | TitleTextField( 47 | title = title, 48 | onTitleChange = { newTitle -> 49 | title = newTitle 50 | } 51 | ) 52 | Spacer( 53 | modifier = Modifier.height(16.dp) 54 | ) 55 | AuthorTextField( 56 | author = author, 57 | onAuthorChange = { newAuthor -> 58 | author = newAuthor 59 | } 60 | ) 61 | } 62 | }, 63 | confirmButton = { 64 | ActionButton( 65 | onActionButtonClick = { 66 | val isTitleValid = title.text.isNotBlank() 67 | val isAuthorValid = author.text.isNotBlank() 68 | if (!isTitleValid) { 69 | onInvalidBookField(BookField.TITLE) 70 | return@ActionButton 71 | } 72 | if (!isAuthorValid) { 73 | onInvalidBookField(BookField.AUTHOR) 74 | return@ActionButton 75 | } 76 | val book = mapOf( 77 | AUTHOR_FIELD to author.text, 78 | TITLE_FIELD to title.text 79 | ) 80 | onAddBook(book) 81 | onAddBookDialogCancel() 82 | }, 83 | resourceId = R.string.add_button 84 | ) 85 | }, 86 | dismissButton = { 87 | ActionButton( 88 | onActionButtonClick = onAddBookDialogCancel, 89 | resourceId = R.string.cancel_button 90 | ) 91 | } 92 | ) 93 | } -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 59 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/components/EditableBookCard.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.MaterialTheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.TextRange 19 | import androidx.compose.ui.text.input.TextFieldValue 20 | import androidx.compose.ui.unit.dp 21 | import ro.alexmamo.firestorecleanarchitecture.R 22 | import ro.alexmamo.firestorecleanarchitecture.components.ActionButton 23 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Book 24 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.BookField 25 | 26 | @Composable 27 | fun EditableBookCard( 28 | book: Book, 29 | onUpdateBook: (String, String) -> Unit, 30 | onInvalidBookField: (BookField) -> Unit, 31 | onCancel: () -> Unit 32 | ) { 33 | val bookTitle = book.title ?: EMPTY_STRING 34 | var title by remember { mutableStateOf(TextFieldValue( 35 | text = bookTitle, 36 | selection = TextRange(bookTitle.length) 37 | )) } 38 | val authorTitle = book.author ?: EMPTY_STRING 39 | var author by remember { mutableStateOf(TextFieldValue( 40 | text = authorTitle, 41 | selection = TextRange(authorTitle.length) 42 | )) } 43 | 44 | Card( 45 | modifier = Modifier.fillMaxWidth().padding( 46 | start = 8.dp, 47 | top = 4.dp, 48 | end = 8.dp, 49 | bottom = 4.dp 50 | ), 51 | shape = MaterialTheme.shapes.small, 52 | elevation = 3.dp 53 | ) { 54 | Column( 55 | modifier = Modifier.padding(8.dp) 56 | ) { 57 | TitleTextField( 58 | title = title, 59 | onTitleChange = { newTitle -> 60 | title = newTitle 61 | } 62 | ) 63 | Spacer( 64 | modifier = Modifier.height(8.dp) 65 | ) 66 | AuthorTextField( 67 | author = author, 68 | onAuthorChange = { newAuthor -> 69 | author = newAuthor 70 | } 71 | ) 72 | Row { 73 | ActionButton( 74 | onActionButtonClick = onCancel, 75 | resourceId = R.string.cancel_button 76 | ) 77 | Spacer( 78 | modifier = Modifier.width(8.dp) 79 | ) 80 | ActionButton( 81 | onActionButtonClick = { 82 | val isTitleValid = title.text.isNotBlank() 83 | val isAuthorValid = author.text.isNotBlank() 84 | if (!isTitleValid) { 85 | onInvalidBookField(BookField.TITLE) 86 | } else if (!isAuthorValid) { 87 | onInvalidBookField(BookField.AUTHOR) 88 | } else { 89 | onUpdateBook(title.text, author.text) 90 | } 91 | }, 92 | resourceId = R.string.update_button 93 | ) 94 | } 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/java/ro/alexmamo/firestorecleanarchitecture/presentation/book_list/BookListScreen.kt: -------------------------------------------------------------------------------- 1 | package ro.alexmamo.firestorecleanarchitecture.presentation.book_list 2 | 3 | import androidx.compose.material.Scaffold 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.runtime.setValue 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 13 | import androidx.lifecycle.viewmodel.compose.viewModel 14 | import ro.alexmamo.firestorecleanarchitecture.R 15 | import ro.alexmamo.firestorecleanarchitecture.components.LoadingIndicator 16 | import ro.alexmamo.firestorecleanarchitecture.core.logErrorMessage 17 | import ro.alexmamo.firestorecleanarchitecture.core.showToastMessage 18 | import ro.alexmamo.firestorecleanarchitecture.domain.model.Response 19 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components.AddBookAlertDialog 20 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components.AddBookFloatingActionButton 21 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components.BookListContent 22 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components.EmptyBookListContent 23 | import ro.alexmamo.firestorecleanarchitecture.presentation.book_list.components.TopBar 24 | 25 | @Composable 26 | fun BookListScreen( 27 | viewModel: BookListViewModel = viewModel() 28 | ) { 29 | val context = LocalContext.current 30 | var openAddBookDialog by remember { mutableStateOf(false) } 31 | val bookListResponse by viewModel.bookListState.collectAsStateWithLifecycle() 32 | val addBookResponse by viewModel.addBookState.collectAsStateWithLifecycle() 33 | val updateBookResponse by viewModel.updateBookState.collectAsStateWithLifecycle() 34 | val deleteBookResponse by viewModel.deleteBookState.collectAsStateWithLifecycle() 35 | val invalidBookFieldMessage = stringResource(R.string.invalid_book_field_message) 36 | val bookActionMessage = stringResource(R.string.book_action_message) 37 | 38 | Scaffold( 39 | topBar = { 40 | TopBar() 41 | }, 42 | floatingActionButton = { 43 | AddBookFloatingActionButton( 44 | onAddBookFloatingActionButtonClick = { 45 | openAddBookDialog = true 46 | } 47 | ) 48 | } 49 | ) { innerPadding -> 50 | when(val bookListResponse = bookListResponse) { 51 | is Response.Idle -> {} 52 | is Response.Loading -> LoadingIndicator() 53 | is Response.Success -> bookListResponse.data?.let { bookList -> 54 | if (bookList.isEmpty()) { 55 | EmptyBookListContent( 56 | innerPadding = innerPadding 57 | ) 58 | } else { 59 | BookListContent( 60 | innerPadding = innerPadding, 61 | bookList = bookList, 62 | onUpdateBook = viewModel::updateBook, 63 | onInvalidBookField = { bookField -> 64 | showToastMessage(context, "$bookField $invalidBookFieldMessage") 65 | }, 66 | onDeleteBook = viewModel::deleteBook 67 | ) 68 | } 69 | } 70 | is Response.Failure -> bookListResponse.e?.message?.let { errorMessage -> 71 | LaunchedEffect(errorMessage) { 72 | logErrorMessage(errorMessage) 73 | showToastMessage(context, errorMessage) 74 | } 75 | } 76 | } 77 | } 78 | 79 | if (openAddBookDialog) { 80 | AddBookAlertDialog( 81 | onAddBook = viewModel::addBook, 82 | onInvalidBookField = { bookField -> 83 | showToastMessage(context, "$bookField $invalidBookFieldMessage") 84 | }, 85 | onAddBookDialogCancel = { 86 | openAddBookDialog = false 87 | } 88 | ) 89 | } 90 | 91 | when(val addBookResponse = addBookResponse) { 92 | is Response.Idle -> {} 93 | is Response.Loading -> LoadingIndicator() 94 | is Response.Success -> LaunchedEffect(Unit) { 95 | showToastMessage(context, "${BookAction.ADD} $bookActionMessage") 96 | viewModel.resetAddBookState() 97 | } 98 | is Response.Failure -> addBookResponse.e?.message?.let { errorMessage -> 99 | LaunchedEffect(errorMessage) { 100 | logErrorMessage(errorMessage) 101 | showToastMessage(context, errorMessage) 102 | viewModel.resetAddBookState() 103 | } 104 | } 105 | } 106 | 107 | when(val updateBookResponse = updateBookResponse) { 108 | is Response.Idle -> {} 109 | is Response.Loading -> LoadingIndicator() 110 | is Response.Success -> LaunchedEffect(Unit) { 111 | showToastMessage(context, "${BookAction.UPDATE} $bookActionMessage") 112 | viewModel.resetUpdateBookState() 113 | } 114 | is Response.Failure -> updateBookResponse.e?.message?.let { errorMessage -> 115 | LaunchedEffect(errorMessage) { 116 | logErrorMessage(errorMessage) 117 | showToastMessage(context, errorMessage) 118 | viewModel.resetUpdateBookState() 119 | } 120 | } 121 | } 122 | 123 | when(val deleteBookResponse = deleteBookResponse) { 124 | is Response.Idle -> {} 125 | is Response.Loading -> LoadingIndicator() 126 | is Response.Success -> LaunchedEffect(Unit) { 127 | showToastMessage(context, "${BookAction.DELETE} $bookActionMessage") 128 | viewModel.resetDeleteBookState() 129 | } 130 | is Response.Failure -> deleteBookResponse.e?.message?.let { errorMessage -> 131 | LaunchedEffect(errorMessage) { 132 | logErrorMessage(errorMessage) 133 | showToastMessage(context, errorMessage) 134 | viewModel.resetDeleteBookState() 135 | } 136 | } 137 | } 138 | } 139 | 140 | enum class BookAction() { 141 | ADD, 142 | UPDATE, 143 | DELETE 144 | } 145 | 146 | enum class BookField() { 147 | TITLE, 148 | AUTHOR 149 | } --------------------------------------------------------------------------------