diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7b0f0fb..f6b20e1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,10 +22,6 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "KAKAO_REST_API_KEY", getApiKey("KAKAO_REST_API_KEY")) - buildConfigField("String", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) - resValue("string", "KAKAO_API_KEY_MANIFEST", getApiKey("KAKAO_API_KEY_MANIFEST")) } buildTypes { @@ -48,7 +44,6 @@ android { buildFeatures { dataBinding = true viewBinding = true - buildConfig = true } } @@ -88,4 +83,6 @@ dependencies { implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.dagger:hilt-android:2.48.1") kapt("com.google.dagger:hilt-compiler:2.48.1") + + implementation(project(":build-config")) } diff --git a/app/src/main/java/com/kappzzang/jeongsan/JeongsanApplication.kt b/app/src/main/java/com/kappzzang/jeongsan/JeongsanApplication.kt index 2480cb6a..0260cbcc 100644 --- a/app/src/main/java/com/kappzzang/jeongsan/JeongsanApplication.kt +++ b/app/src/main/java/com/kappzzang/jeongsan/JeongsanApplication.kt @@ -3,6 +3,7 @@ package com.kappzzang.jeongsan import android.app.Application import android.util.Log import com.kakao.sdk.common.KakaoSdk +import com.kappzzang.jeongsan.build_config.BuildConfig import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp diff --git a/app/src/main/java/com/kappzzang/jeongsan/di/DispatcherModule.kt b/app/src/main/java/com/kappzzang/jeongsan/di/DispatcherModule.kt new file mode 100644 index 00000000..164c10b0 --- /dev/null +++ b/app/src/main/java/com/kappzzang/jeongsan/di/DispatcherModule.kt @@ -0,0 +1,16 @@ +package com.kappzzang.jeongsan.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@Module +@InstallIn(SingletonComponent::class) +object DispatcherModule { + + @Provides + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/build-config/.gitignore b/build-config/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/build-config/.gitignore @@ -0,0 +1 @@ +/build diff --git a/build-config/build.gradle.kts b/build-config/build.gradle.kts new file mode 100644 index 00000000..fd9c0274 --- /dev/null +++ b/build-config/build.gradle.kts @@ -0,0 +1,43 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("org.jlleitschuh.gradle.ktlint") +} + +fun getApiKey(key: String): String = gradleLocalProperties(rootDir, providers).getProperty(key) + +android { + compileSdk = 34 + namespace = "com.kappzzang.jeongsan.build_config" + + buildTypes { + debug { + buildConfigField("String", "KAKAO_REST_API_KEY", getApiKey("KAKAO_REST_API_KEY")) + buildConfigField("String", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) + buildConfigField("String", "KEYSTORE_NAME", getApiKey("KEYSTORE_NAME")) + resValue("string", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) + resValue("string", "KAKAO_API_KEY_MANIFEST", getApiKey("KAKAO_API_KEY_MANIFEST")) + + buildConfigField("String", "KAKAO_API_URL", getApiKey("KAKAO_API_URL")) + buildConfigField("String", "SERVICE_URL", getApiKey("SERVICE_URL")) + buildConfigField("String", "KAKAO_AUTH_URL", getApiKey("KAKAO_AUTH_URL")) + } + + release { + buildConfigField("String", "KAKAO_REST_API_KEY", getApiKey("KAKAO_REST_API_KEY")) + buildConfigField("String", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) + buildConfigField("String", "KEYSTORE_NAME", getApiKey("KEYSTORE_NAME")) + resValue("string", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) + resValue("string", "KAKAO_API_KEY_MANIFEST", getApiKey("KAKAO_API_KEY_MANIFEST")) + + buildConfigField("String", "KAKAO_API_URL", getApiKey("KAKAO_API_URL")) + buildConfigField("String", "SERVICE_URL", getApiKey("SERVICE_URL")) + buildConfigField("String", "KAKAO_AUTH_URL", getApiKey("KAKAO_AUTH_URL")) + } + } + buildFeatures { + buildConfig = true + } +} diff --git a/build.gradle.kts b/build.gradle.kts index ef217c7f..4491c2a6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,4 +23,8 @@ allprojects { } group = "com.kappzzang.jeongsan" + + afterEvaluate { + project.apply("$rootDir/gradle/common.gradle") + } } diff --git a/common/datastore/build.gradle.kts b/common/datastore/build.gradle.kts index 0f65868f..866c8f5e 100644 --- a/common/datastore/build.gradle.kts +++ b/common/datastore/build.gradle.kts @@ -8,40 +8,13 @@ plugins { android { namespace = "com.kappzzang.datastore" - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } } dependencies { - implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") } diff --git a/common/navigation/build.gradle.kts b/common/navigation/build.gradle.kts index 59a80239..5f595917 100644 --- a/common/navigation/build.gradle.kts +++ b/common/navigation/build.gradle.kts @@ -5,30 +5,11 @@ plugins { android { namespace = "com.kappzzang.jeongsan.navigation" - compileSdk = 34 defaultConfig { minSdk = 26 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" } } @@ -37,7 +18,4 @@ dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") } diff --git a/common/resource/build.gradle.kts b/common/resource/build.gradle.kts index e4be1a53..487d0b19 100644 --- a/common/resource/build.gradle.kts +++ b/common/resource/build.gradle.kts @@ -6,21 +6,6 @@ plugins { android { namespace = "com.kappzzang.jeongsan" - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "1.8" - } } dependencies { diff --git a/common/resource/src/main/res/values/dimen.xml b/common/resource/src/main/res/values/dimen.xml index ae04628b..5af2e8f5 100644 --- a/common/resource/src/main/res/values/dimen.xml +++ b/common/resource/src/main/res/values/dimen.xml @@ -63,4 +63,4 @@ 3dp 20sp - \ No newline at end of file + diff --git a/common/retrofit/build.gradle.kts b/common/retrofit/build.gradle.kts index 2c8abab9..d6550c4d 100644 --- a/common/retrofit/build.gradle.kts +++ b/common/retrofit/build.gradle.kts @@ -1,5 +1,3 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties - plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -8,33 +6,8 @@ plugins { id("com.google.dagger.hilt.android") } -fun getApiKey(key: String): String = gradleLocalProperties(rootDir, providers).getProperty(key) - android { namespace = "com.kappzzang.jeongsan" - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "KAKAO_API_URL", getApiKey("KAKAO_API_URL")) - buildConfigField("String", "SERVICE_URL", getApiKey("SERVICE_URL")) - buildConfigField("String", "KAKAO_AUTH_URL", getApiKey("KAKAO_AUTH_URL")) - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - - buildFeatures { - buildConfig = true - } } dependencies { @@ -44,4 +17,5 @@ dependencies { implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation(project(":build-config")) } diff --git a/common/retrofit/src/main/java/com/kappzzang/jeongsan/retrofit/RetrofitModule.kt b/common/retrofit/src/main/java/com/kappzzang/jeongsan/retrofit/RetrofitModule.kt index 15e62ca3..fb0e133e 100644 --- a/common/retrofit/src/main/java/com/kappzzang/jeongsan/retrofit/RetrofitModule.kt +++ b/common/retrofit/src/main/java/com/kappzzang/jeongsan/retrofit/RetrofitModule.kt @@ -1,6 +1,6 @@ package com.kappzzang.jeongsan.retrofit -import com.kappzzang.jeongsan.BuildConfig +import com.kappzzang.jeongsan.build_config.BuildConfig import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/data/build.gradle.kts b/data/build.gradle.kts index c2ec4b9e..ab496e6d 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,5 +1,3 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties - plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -10,25 +8,9 @@ plugins { android { namespace = "com.kappzzang.jeongsan" - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } } subprojects { - fun getApiKey(key: String): String = gradleLocalProperties(rootDir, providers).getProperty(key) apply { plugin("com.android.library") plugin("org.jetbrains.kotlin.android") @@ -47,6 +29,7 @@ subprojects { implementation(project(":common:util")) implementation(project(":domain")) implementation(project(":domain:group")) + implementation(project(":build-config")) // Test Dependencies testImplementation("org.assertj:assertj-core:3.25.3") @@ -58,30 +41,6 @@ subprojects { testImplementation("androidx.arch.core:core-testing:2.2.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.48.1") - } - - android { - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "KAKAO_REST_API_KEY", getApiKey("KAKAO_REST_API_KEY")) - buildConfigField("String", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) - buildConfigField("String", "KEYSTORE_NAME", getApiKey("KEYSTORE_NAME")) - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { - buildConfig = true - } + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3") } } diff --git a/data/expense/build.gradle.kts b/data/expense/build.gradle.kts index 6a12d57f..e8926c8a 100644 --- a/data/expense/build.gradle.kts +++ b/data/expense/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + kotlin("plugin.serialization") version "1.9.0" +} + android { namespace = "com.kappzzang.jeongsan.expense" } @@ -5,5 +9,6 @@ dependencies { implementation(project(":domain:expense")) implementation("androidx.room:room-ktx:2.6.1") implementation("com.kakao.sdk:v2-talk:2.20.6") + implementation(project(":domain:common-user")) testImplementation("androidx.room:room-testing:2.6.1") } diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/api/ReceiptRetrofitService.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/api/ReceiptRetrofitService.kt new file mode 100644 index 00000000..47da1b99 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/api/ReceiptRetrofitService.kt @@ -0,0 +1,50 @@ +package com.kappzzang.jeongsan.api + +import com.kappzzang.jeongsan.entity.ResponseWithExpenseIdDTO +import com.kappzzang.jeongsan.entity.SaveExpensePayloadDTO +import com.kappzzang.jeongsan.entity.expensedetail.ExpenseDetailEntity +import com.kappzzang.jeongsan.entity.expenselist.ExpenseListResponseDTO +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface ReceiptRetrofitService { + @POST("/api/receipts/{teamId}") + suspend fun saveExpense( + @Path(value = "teamId") groupId: String, + @Header("accessToken") jwt: String, + @Body body: SaveExpensePayloadDTO + ): Response + + @GET("/api/receipts/items/{expenseId}") + suspend fun getExpenseDetails( + @Path(value = "expenseId") expenseId: String, + @Header("accessToken") jwt: String + ): Response + + @POST("/api/expenses/personal/{teamId}/{expenseId}") + suspend fun updateExpenseDetails( + @Path(value = "teamId") groupId: String, + @Path(value = "expenseId") expenseId: String, + @Header("accessToken") jwt: String + ) + + @GET("/api/expenses/{teamId}") + suspend fun getExpenseList( + @Path(value = "teamId") groupId: String, + @Header("accessToken") jwt: String, + @Query("state") state: String, + @Query("isChecked") checked: Boolean + ): Response + + @GET("/api/expenses/{teamId}") + suspend fun getExpenseList( + @Path(value = "teamId") groupId: String, + @Header("accessToken") jwt: String, + @Query("state") state: String + ): Response +} diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseDetailRemoteDatasource.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseDetailRemoteDatasource.kt new file mode 100644 index 00000000..72095635 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseDetailRemoteDatasource.kt @@ -0,0 +1,16 @@ +package com.kappzzang.jeongsan.datasource + +import com.kappzzang.jeongsan.api.ReceiptRetrofitService +import com.kappzzang.jeongsan.entity.expensedetail.ExpenseDetailEntity +import javax.inject.Inject +import retrofit2.Response + +class ExpenseDetailRemoteDatasource @Inject constructor( + private val receiptRetrofitService: ReceiptRetrofitService +) { + suspend fun getExpenseDetail(expenseId: String, jwt: String): Response = + receiptRetrofitService.getExpenseDetails( + expenseId = expenseId, + jwt = jwt + ) +} diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListFakeDatasource.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListFakeDatasource.kt index 415138eb..45ced2cf 100644 --- a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListFakeDatasource.kt +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListFakeDatasource.kt @@ -1,6 +1,7 @@ package com.kappzzang.jeongsan.datasource import com.kappzzang.jeongsan.datasource.expense.ExpenseDatabase +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRoomEntity import com.kappzzang.jeongsan.mapper.ExpenseEntityMapper import com.kappzzang.jeongsan.model.ExpenseListResponse import com.kappzzang.jeongsan.model.ExpenseState @@ -40,7 +41,7 @@ class ExpenseListFakeDatasource @Inject constructor(private val expenseDatabase: } fun addExpense(receiptItem: ReceiptItem): String { - val expenseEntity = com.kappzzang.jeongsan.entity.ExpenseEntity( + val expenseEntity = ExpenseRoomEntity( name = receiptItem.title, totalPrice = receiptItem.expenseDetailItemList.sumOf { it.itemPrice * it.itemQuantity }, createdTime = Timestamp(System.currentTimeMillis()).toString(), diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListRemoteDatasource.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListRemoteDatasource.kt new file mode 100644 index 00000000..c018eb97 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/ExpenseListRemoteDatasource.kt @@ -0,0 +1,103 @@ +package com.kappzzang.jeongsan.datasource + +import com.kappzzang.jeongsan.api.ReceiptRetrofitService +import com.kappzzang.jeongsan.entity.ImageEntity +import com.kappzzang.jeongsan.entity.ResponseWithExpenseIdDTO +import com.kappzzang.jeongsan.entity.SaveExpensePayloadDTO +import com.kappzzang.jeongsan.entity.expenselist.ExpenseListResponseDTO +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRoomEntity +import com.kappzzang.jeongsan.mapper.ExpenseEntityMapper +import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.model.ReceiptItem +import com.kappzzang.jeongsan.util.DateConverter.formatToTransferString +import java.sql.Timestamp +import javax.inject.Inject +import retrofit2.Response + +class ExpenseListRemoteDatasource @Inject constructor( + private val receiptRetrofitService: ReceiptRetrofitService +) { + + private fun mapExpenseStateToDtoState(state: ExpenseState): String = when (state) { + ExpenseState.CONFIRMED -> "ongoing" + ExpenseState.NOT_CONFIRMED -> "ongoing" + ExpenseState.TRANSFER_PENDING -> "pending" + ExpenseState.TRANSFERED -> "completed" + } + + private fun checkNeedAdditionalQuery(state: ExpenseState): Boolean = + (state == ExpenseState.CONFIRMED || state == ExpenseState.NOT_CONFIRMED) + + private fun checkIsChecked(state: ExpenseState): Boolean = state == ExpenseState.CONFIRMED + + suspend fun getExpenseList( + expenseState: ExpenseState, + jwt: String, + groupId: String + ): Response { + val needAdditionalQuery = checkNeedAdditionalQuery(expenseState) + val result: Response = if (needAdditionalQuery) { + receiptRetrofitService.getExpenseList( + groupId = groupId, + jwt = jwt, + state = mapExpenseStateToDtoState(expenseState), + checked = checkIsChecked(expenseState) + ) + } else { + receiptRetrofitService.getExpenseList( + groupId = groupId, + jwt = jwt, + state = mapExpenseStateToDtoState(expenseState) + ) + } + + return result + } + + suspend fun addExpense( + receiptItem: ReceiptItem, + jwt: String, + groupId: String + ): Response { + val postBody = SaveExpensePayloadDTO( + title = receiptItem.title, + items = receiptItem.expenseDetailItemList.map { + ExpenseEntityMapper.mapReceiptDetailItemToExpenseItemEntity(it) + }, + paymentTime = receiptItem.paymentTime.formatToTransferString(), + image = ImageEntity( + name = "", + data = receiptItem.imageBase64 ?: "", + url = "", + format = IMAGE_FORMAT + ), + categoryId = CATEGORY_ID + ) + + val result = receiptRetrofitService.saveExpense( + groupId = groupId, + jwt = jwt, + body = postBody + ) + + return result + } + + fun addExpense(receiptItem: ReceiptItem): String { + val expenseEntity = ExpenseRoomEntity( + name = receiptItem.title, + totalPrice = receiptItem.expenseDetailItemList.sumOf { it.itemPrice * it.itemQuantity }, + createdTime = Timestamp(System.currentTimeMillis()).toString(), + categoryColor = receiptItem.categoryColor, + expenseState = ExpenseState.CONFIRMED.ordinal + ) + + // expenseDatabase.expenseDao().addExpense(expenseEntity) + return expenseEntity.id.toString() + } + + companion object { + const val IMAGE_FORMAT = "JPG" + const val CATEGORY_ID = 0L + } +} diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDao.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDao.kt index c6f04673..c81ef164 100644 --- a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDao.kt +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDao.kt @@ -4,25 +4,25 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query -import com.kappzzang.jeongsan.entity.ExpenseEntity +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRoomEntity @Dao interface ExpenseDao { @Insert - fun addExpense(expenseEntity: ExpenseEntity) + fun addExpense(expenseEntity: ExpenseRoomEntity) @Delete - fun deleteExpense(expenseEntity: ExpenseEntity) + fun deleteExpense(expenseEntity: ExpenseRoomEntity) @Query("SELECT * FROM `${ExpenseContract.ExpenseEntity.TABLE_NAME}` WHERE state = 0") - fun getConfirmedExpense(): List + fun getConfirmedExpense(): List @Query("SELECT * FROM `${ExpenseContract.ExpenseEntity.TABLE_NAME}` WHERE state = 1") - fun getNotConfirmedExpense(): List + fun getNotConfirmedExpense(): List @Query("SELECT * FROM `${ExpenseContract.ExpenseEntity.TABLE_NAME}` WHERE state = 2") - fun getPendingExpense(): List + fun getPendingExpense(): List @Query("SELECT * FROM `${ExpenseContract.ExpenseEntity.TABLE_NAME}` WHERE state = 3") - fun getTransferredExpense(): List + fun getTransferredExpense(): List } diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDatabase.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDatabase.kt index 5cef9386..04cdd55c 100644 --- a/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDatabase.kt +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/datasource/expense/ExpenseDatabase.kt @@ -5,7 +5,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteDatabase -import com.kappzzang.jeongsan.entity.ExpenseEntity +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRoomEntity import com.kappzzang.jeongsan.model.ExpenseState import java.sql.Timestamp import java.util.Date @@ -17,11 +17,11 @@ private val colorList = listOf("#87A2FF", "#FFD7C4", "#87A2FF", "#987D9A", "#987D9A", "#BB9AB1", "#BB9AB1") private val categoryNameList = listOf("커피", "편의점", "커피", "영화관", "영화관", "식당", "식당") -private fun makeFakeItemWithState(expenseState: ExpenseState, id: Int): ExpenseEntity { +private fun makeFakeItemWithState(expenseState: ExpenseState, id: Int): ExpenseRoomEntity { val adjustedIndex = (id + 1) * (ExpenseState.entries.indexOf(expenseState) + 1) - return ExpenseEntity( + return ExpenseRoomEntity( name = nameList[adjustedIndex % nameList.size], totalPrice = 1200 * adjustedIndex, image = "", @@ -34,7 +34,7 @@ private fun makeFakeItemWithState(expenseState: ExpenseState, id: Int): ExpenseE ) } -@Database(entities = [ExpenseEntity::class], version = 1) +@Database(entities = [ExpenseRoomEntity::class], version = 1) abstract class ExpenseDatabase : RoomDatabase() { abstract fun expenseDao(): ExpenseDao diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ExpenseItemEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ExpenseItemEntity.kt new file mode 100644 index 00000000..57c87ee7 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ExpenseItemEntity.kt @@ -0,0 +1,14 @@ +package com.kappzzang.jeongsan.entity + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ExpenseItemEntity( + @SerializedName("name") + val name: String, + @SerializedName("quantity") + val quantity: Int, + @SerializedName("unit_price") + val unitPrice: Int +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ImageEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ImageEntity.kt new file mode 100644 index 00000000..4996ea85 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ImageEntity.kt @@ -0,0 +1,16 @@ +package com.kappzzang.jeongsan.entity + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ImageEntity( + @SerializedName("format") + val format: String, + @SerializedName("url") + val url: String, + @SerializedName("data") + val data: String, + @SerializedName("name") + val name: String +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ResponseWithExpenseIdDTO.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ResponseWithExpenseIdDTO.kt new file mode 100644 index 00000000..a5db1a09 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ResponseWithExpenseIdDTO.kt @@ -0,0 +1,10 @@ +package com.kappzzang.jeongsan.entity + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseWithExpenseIdDTO( + @SerializedName("expense_id") + val expenseId: String +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/SaveExpensePayloadDTO.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/SaveExpensePayloadDTO.kt new file mode 100644 index 00000000..990d6054 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/SaveExpensePayloadDTO.kt @@ -0,0 +1,18 @@ +package com.kappzzang.jeongsan.entity + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class SaveExpensePayloadDTO( + @SerializedName("title") + val title: String, + @SerializedName("payment_time") + val paymentTime: String, + @SerializedName("category_id") + val categoryId: Long, + @SerializedName("image") + val image: ImageEntity, + @SerializedName("items") + val items: List +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expensedetail/ExpenseDetailEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expensedetail/ExpenseDetailEntity.kt new file mode 100644 index 00000000..deb13d0d --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expensedetail/ExpenseDetailEntity.kt @@ -0,0 +1,14 @@ +package com.kappzzang.jeongsan.entity.expensedetail + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ExpenseDetailEntity( + @SerializedName("title") + val title: String, + @SerializedName("imageUrl") + val imageUrl: String, + @SerializedName("items") + val detailItems: List +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expensedetail/ExpenseDetailItemEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expensedetail/ExpenseDetailItemEntity.kt new file mode 100644 index 00000000..07969837 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expensedetail/ExpenseDetailItemEntity.kt @@ -0,0 +1,18 @@ +package com.kappzzang.jeongsan.entity.expensedetail + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ExpenseDetailItemEntity( + @SerializedName("id") + val id: Long, + @SerializedName("name") + val name: String, + @SerializedName("quantity") + val quantity: Int, + @SerializedName("unitPrice") + val unitPrice: Int, + @SerializedName("consumedQuantity") + val quantityConsumed: Int +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/CategoryEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/CategoryEntity.kt new file mode 100644 index 00000000..059eca53 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/CategoryEntity.kt @@ -0,0 +1,12 @@ +package com.kappzzang.jeongsan.entity.expenselist + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class CategoryEntity( + @SerializedName("name") + val name: String, + @SerializedName("color") + val color: String +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseListResponseDTO.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseListResponseDTO.kt new file mode 100644 index 00000000..c736c121 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseListResponseDTO.kt @@ -0,0 +1,14 @@ +package com.kappzzang.jeongsan.entity.expenselist + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ExpenseListResponseDTO( + @SerializedName("expenseList") + val expenseList: List, + @SerializedName("checked") + val checked: Boolean, + @SerializedName("totalPrice") + val totalPrice: Long +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseRemoteEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseRemoteEntity.kt new file mode 100644 index 00000000..70f45415 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseRemoteEntity.kt @@ -0,0 +1,20 @@ +package com.kappzzang.jeongsan.entity.expenselist + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class ExpenseRemoteEntity( + @SerializedName("expenseId") + val id: Long, + @SerializedName("title") + val title: String, + @SerializedName("totalPrice") + val totalPrice: Int, + @SerializedName("createdAt") + val createdAt: String, + @SerializedName("state") + val state: String, + @SerializedName("category") + val category: CategoryEntity +) diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ExpenseEntity.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseRoomEntity.kt similarity index 93% rename from data/expense/src/main/java/com/kappzzang/jeongsan/entity/ExpenseEntity.kt rename to data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseRoomEntity.kt index e2f40309..a21d51e0 100644 --- a/data/expense/src/main/java/com/kappzzang/jeongsan/entity/ExpenseEntity.kt +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/entity/expenselist/ExpenseRoomEntity.kt @@ -1,4 +1,4 @@ -package com.kappzzang.jeongsan.entity +package com.kappzzang.jeongsan.entity.expenselist import androidx.room.ColumnInfo import androidx.room.Entity @@ -6,7 +6,7 @@ import androidx.room.PrimaryKey import com.kappzzang.jeongsan.datasource.expense.ExpenseContract @Entity(tableName = ExpenseContract.ExpenseEntity.TABLE_NAME) -class ExpenseEntity( +class ExpenseRoomEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = ExpenseContract.ExpenseEntity.COLUMN_ID) var id: Long = 0, @ColumnInfo(name = ExpenseContract.ExpenseEntity.COLUMN_NAME) var name: String = "", diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/mapper/ExpenseEntityMapper.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/mapper/ExpenseEntityMapper.kt index 4ac72253..ca188a1c 100644 --- a/data/expense/src/main/java/com/kappzzang/jeongsan/mapper/ExpenseEntityMapper.kt +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/mapper/ExpenseEntityMapper.kt @@ -1,20 +1,95 @@ package com.kappzzang.jeongsan.mapper -import com.kappzzang.jeongsan.entity.ExpenseEntity +import com.kappzzang.jeongsan.entity.ExpenseItemEntity +import com.kappzzang.jeongsan.entity.expensedetail.ExpenseDetailEntity +import com.kappzzang.jeongsan.entity.expensedetail.ExpenseDetailItemEntity +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRemoteEntity +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRoomEntity +import com.kappzzang.jeongsan.model.ExpenseDetailItem import com.kappzzang.jeongsan.model.ExpenseItem +import com.kappzzang.jeongsan.model.ExpenseItemWithCategory +import com.kappzzang.jeongsan.model.ExpenseItemWithDetails import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.model.ReceiptDetailItem import com.kappzzang.jeongsan.util.DateConverter object ExpenseEntityMapper { - fun mapExpenseEntityToModel(entity: ExpenseEntity): ExpenseItem = ExpenseItem( - id = entity.id.toString(), - name = entity.name, - categoryColor = entity.categoryColor, - price = entity.totalPrice, - expenseImageUrl = entity.image, - date = DateConverter.parseFromString(entity.createdTime), - payerMemberId = "", - payerName = "", - state = ExpenseState.entries[entity.expenseState] + fun mapExpenseEntityToModel(entity: ExpenseRoomEntity): ExpenseItemWithCategory = + ExpenseItemWithCategory( + item = ExpenseItem( + id = entity.id.toString(), + name = entity.name, + price = entity.totalPrice, + state = ExpenseState.entries[entity.expenseState] + ), + date = DateConverter.parseFromString(entity.createdTime), + categoryColor = entity.categoryColor + ) + + fun mapExpenseEntityToModel( + entity: ExpenseRemoteEntity, + checked: Boolean = false + ): ExpenseItemWithCategory = ExpenseItemWithCategory( + item = ExpenseItem( + id = entity.id.toString(), + name = entity.title, + price = entity.totalPrice, + state = mapExpenseStateToDomainState(entity.state, checked) + ), + date = DateConverter.parseFromString(entity.createdAt), + categoryColor = entity.category.color + ) + + fun mapDetailedExpenseEntityToModel( + entity: ExpenseDetailEntity, + expenseId: String, + state: ExpenseState + ): ExpenseItemWithDetails = ExpenseItemWithDetails( + item = ExpenseItem( + id = expenseId, + name = entity.title, + price = getSumOfAllDetailItems(entity.detailItems), + state = state + ), + expenseImageUrl = entity.imageUrl, + expenseDetails = entity.detailItems.map { + mapExpenseDetailEntityToModel(it) + } ) + + fun mapExpenseDetailEntityToModel(entity: ExpenseDetailItemEntity): ExpenseDetailItem = + ExpenseDetailItem( + selectedQuantity = entity.quantityConsumed, + itemQuantity = entity.quantity, + id = entity.id.toString(), + itemPrice = entity.unitPrice, + itemName = entity.name + ) + + fun mapReceiptDetailItemToExpenseItemEntity(model: ReceiptDetailItem): ExpenseItemEntity = + ExpenseItemEntity( + name = model.itemName, + quantity = model.itemQuantity, + unitPrice = model.itemPrice + ) + + private fun getSumOfAllDetailItems(detailItems: List): Int = + detailItems.sumOf { + it.unitPrice * it.quantity + } + + private fun mapExpenseStateToDomainState(state: String, checked: Boolean): ExpenseState { + val trimmed = state.lowercase().trim() + return when (trimmed) { + "pending" -> ExpenseState.TRANSFER_PENDING + "completed" -> ExpenseState.TRANSFERED + "ongoing" -> { + if (checked) ExpenseState.CONFIRMED else ExpenseState.NOT_CONFIRMED + } + + else -> { + throw IllegalStateException("Invalid Expense State") + } + } + } } diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseFakeRepositoryImpl.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseFakeRepositoryImpl.kt index bc2ae736..4d0a073e 100644 --- a/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseFakeRepositoryImpl.kt +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseFakeRepositoryImpl.kt @@ -1,17 +1,16 @@ package com.kappzzang.jeongsan.repositoryimpl import com.kappzzang.jeongsan.datasource.ExpenseListFakeDatasource +import com.kappzzang.jeongsan.model.ExpenseDetailItem import com.kappzzang.jeongsan.model.ExpenseItem +import com.kappzzang.jeongsan.model.ExpenseItemWithDetails import com.kappzzang.jeongsan.model.ExpenseListResponse import com.kappzzang.jeongsan.model.ExpenseState import com.kappzzang.jeongsan.repository.ExpenseRepository -import java.time.LocalDateTime import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -data class ExpenseListCachingKey(val expenseState: ExpenseState, val groupId: String) - class ExpenseFakeRepositoryImpl @Inject constructor( private val dataSource: ExpenseListFakeDatasource ) : ExpenseRepository { @@ -39,18 +38,20 @@ class ExpenseFakeRepositoryImpl @Inject constructor( ) } - override suspend fun getExpense(id: Long) = ExpenseItem( - id = "id", - name = "지출 이름입니당", - payerName = "돈 많은 사람", - payerMemberId = "id", - price = 15800, + override suspend fun getExpense(id: Long) = ExpenseItemWithDetails( + item = ExpenseItem( + id = id.toString(), + state = ExpenseState.NOT_CONFIRMED, + name = "지출 이름입니당", + price = 15800 + ), // 임시 지출 이미지 주소 (카카오테크 캠퍼스) expenseImageUrl = "https://www.kakaotechcampus.com/fileUpDownload/" + "download.do?p_savefile=gatepage_20230330053504999_1.png&p_realfile=" + "GNB+%EB%A1%9C%EA%B3%A0%28%EB%B3%B4%EB%9D%BC%29.png", - date = LocalDateTime.of(2024, 10, 14, 12, 30, 15, 0), - state = ExpenseState.NOT_CONFIRMED, - categoryColor = "#FFFFFF" + expenseDetails = listOf( + ExpenseDetailItem("", "1", 200, 1, 0), + ExpenseDetailItem("", "2", 300, 4, 1) + ) ) } diff --git a/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseListRepositoryImpl.kt b/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseListRepositoryImpl.kt new file mode 100644 index 00000000..b27f1582 --- /dev/null +++ b/data/expense/src/main/java/com/kappzzang/jeongsan/repositoryimpl/ExpenseListRepositoryImpl.kt @@ -0,0 +1,88 @@ +package com.kappzzang.jeongsan.repositoryimpl + +import com.kappzzang.jeongsan.datasource.ExpenseListRemoteDatasource +import com.kappzzang.jeongsan.entity.expenselist.ExpenseListResponseDTO +import com.kappzzang.jeongsan.mapper.ExpenseEntityMapper +import com.kappzzang.jeongsan.model.ExpenseDetailItem +import com.kappzzang.jeongsan.model.ExpenseItem +import com.kappzzang.jeongsan.model.ExpenseItemWithDetails +import com.kappzzang.jeongsan.model.ExpenseListResponse +import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.repository.ExpenseRepository +import com.kappzzang.jeongsan.repository.ServerAuthenticationRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +data class ExpenseListCachingKey(val expenseState: ExpenseState, val groupId: String) + +class ExpenseListRepositoryImpl @Inject constructor( + private val dataSource: ExpenseListRemoteDatasource, + private val auth: ServerAuthenticationRepository +) : ExpenseRepository { + + private val cachedData = HashMap() + + private fun getJwt(): String = auth.getSavedJwt() + private fun isJwtValid(jwt: String): Boolean = (jwt != "") + + private fun mapResponseBody(body: ExpenseListResponseDTO): ExpenseListResponse { + val expenses = body.expenseList.map { ExpenseEntityMapper.mapExpenseEntityToModel(it) } + return ExpenseListResponse( + totalExpenseToSend = body.totalPrice.toInt(), + expenseList = expenses, + totalPrice = body.totalPrice.toInt() + ) + } + + override fun getExpenseList( + groupId: String, + expenseState: ExpenseState + ): Flow = flow { + emit( + cachedData.getOrDefault( + ExpenseListCachingKey(expenseState, groupId), + ExpenseListResponse.emptyList() + ) + ) + + val jwt = getJwt() + if (!isJwtValid(jwt)) { + cachedData[ExpenseListCachingKey(expenseState, groupId)] = + ExpenseListResponse.emptyList() + } else { + val response = dataSource.getExpenseList( + expenseState, + groupId = groupId, + jwt = jwt + ) + + response.body()?.let { + cachedData[ExpenseListCachingKey(expenseState, groupId)] = mapResponseBody(it) + } + } + emit( + cachedData.getOrDefault( + ExpenseListCachingKey(expenseState, groupId), + ExpenseListResponse.emptyList() + ) + ) + } + + override suspend fun getExpense(id: Long) = ExpenseItemWithDetails( + item = ExpenseItem( + id = id.toString(), + state = ExpenseState.NOT_CONFIRMED, + name = "지출 이름입니당", + price = 15800 + ), + // 임시 지출 이미지 주소 (카카오테크 캠퍼스) + expenseImageUrl = "https://www.kakaotechcampus.com/fileUpDownload/" + + "download.do?p_savefile=gatepage_20230330053504999_1.png&p_realfile=" + + "GNB+%EB%A1%9C%EA%B3%A0%28%EB%B3%B4%EB%9D%BC%29.png", + expenseDetails = listOf( + ExpenseDetailItem("", "1", 200, 1, 0), + ExpenseDetailItem("", "2", 300, 4, 1) + ) + ) +} diff --git a/data/expense/src/test/java/com/kappzzang/jeongsan/ExpenseEntityMapperTest.kt b/data/expense/src/test/java/com/kappzzang/jeongsan/ExpenseRoomEntityMapperTest.kt similarity index 86% rename from data/expense/src/test/java/com/kappzzang/jeongsan/ExpenseEntityMapperTest.kt rename to data/expense/src/test/java/com/kappzzang/jeongsan/ExpenseRoomEntityMapperTest.kt index 0ca70d18..0df761cf 100644 --- a/data/expense/src/test/java/com/kappzzang/jeongsan/ExpenseEntityMapperTest.kt +++ b/data/expense/src/test/java/com/kappzzang/jeongsan/ExpenseRoomEntityMapperTest.kt @@ -1,12 +1,12 @@ package com.kappzzang.jeongsan -import com.kappzzang.jeongsan.entity.ExpenseEntity +import com.kappzzang.jeongsan.entity.expenselist.ExpenseRoomEntity import com.kappzzang.jeongsan.mapper.ExpenseEntityMapper import org.assertj.core.api.Assertions.assertThat import org.junit.Test -class ExpenseEntityMapperTest { - private fun getSampleEntity() = ExpenseEntity( +class ExpenseRoomEntityMapperTest { + private fun getSampleEntity() = ExpenseRoomEntity( id = 100, name = "name", expenseState = 0, diff --git a/data/user/src/main/java/com/kappzzang/jeongsan/datasource/KakaoAuthenticationDataSource.kt b/data/user/src/main/java/com/kappzzang/jeongsan/datasource/KakaoAuthenticationDataSource.kt index 9f842c67..c05787ba 100644 --- a/data/user/src/main/java/com/kappzzang/jeongsan/datasource/KakaoAuthenticationDataSource.kt +++ b/data/user/src/main/java/com/kappzzang/jeongsan/datasource/KakaoAuthenticationDataSource.kt @@ -1,9 +1,9 @@ package com.kappzzang.jeongsan.datasource import com.kappzzang.jeongsan.api.KakaoAuthRetrofitService +import com.kappzzang.jeongsan.build_config.BuildConfig import com.kappzzang.jeongsan.entity.KakaoRefreshTokenPayloadDTO import com.kappzzang.jeongsan.entity.KakaoRefreshTokenResponseDTO -import com.kappzzang.jeongsan.user.BuildConfig import javax.inject.Inject import retrofit2.Response diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 167ce4ea..545d13aa 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -8,21 +8,6 @@ plugins { android { namespace = "com.kappzzang.jeongsan" - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } } subprojects { @@ -52,22 +37,4 @@ subprojects { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.48.1") } - - android { - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - } } diff --git a/domain/common-user/src/main/java/com/kappzzang/jeongsan/repository/ServerAuthenticationRepository.kt b/domain/common-user/src/main/java/com/kappzzang/jeongsan/repository/ServerAuthenticationRepository.kt index 8acc1dec..f0c88add 100644 --- a/domain/common-user/src/main/java/com/kappzzang/jeongsan/repository/ServerAuthenticationRepository.kt +++ b/domain/common-user/src/main/java/com/kappzzang/jeongsan/repository/ServerAuthenticationRepository.kt @@ -6,4 +6,6 @@ interface ServerAuthenticationRepository { fun registerToServer(authData: AuthData) fun getJwtFromServer(authData: AuthData): AuthData + + fun getSavedJwt(): String } diff --git a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItem.kt b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItem.kt index 54de5fcc..cffcfb16 100644 --- a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItem.kt +++ b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItem.kt @@ -1,31 +1,14 @@ package com.kappzzang.jeongsan.model -import java.time.LocalDateTime - enum class ExpenseState { CONFIRMED, NOT_CONFIRMED, TRANSFER_PENDING, TRANSFERED } -data class ExpenseItem( - val id: String, - val name: String, - val payerName: String, - val payerMemberId: String, - val price: Int, - val expenseImageUrl: String, - val date: LocalDateTime, - val state: ExpenseState, - val categoryColor: String -) { +data class ExpenseItem(val id: String, val name: String, val price: Int, val state: ExpenseState) { companion object { val EMPTY = ExpenseItem( id = "", name = "", - payerName = "", - payerMemberId = "", price = 0, - expenseImageUrl = "", - date = LocalDateTime.now(), - state = ExpenseState.NOT_CONFIRMED, - categoryColor = "" + state = ExpenseState.NOT_CONFIRMED ) } } diff --git a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItemWithCategory.kt b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItemWithCategory.kt new file mode 100644 index 00000000..cd569359 --- /dev/null +++ b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItemWithCategory.kt @@ -0,0 +1,26 @@ +package com.kappzzang.jeongsan.model + +import java.time.LocalDateTime + +data class ExpenseItemWithCategory( + private val item: ExpenseItem, + val categoryColor: String, + val date: LocalDateTime +) { + val id + get() = item.id + val name + get() = item.name + val price + get() = item.price + val state + get() = item.state + + companion object { + val EMPTY = ExpenseItemWithCategory( + item = ExpenseItem.EMPTY, + categoryColor = "", + date = LocalDateTime.now() + ) + } +} diff --git a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItemWithDetails.kt b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItemWithDetails.kt new file mode 100644 index 00000000..e9895f5b --- /dev/null +++ b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseItemWithDetails.kt @@ -0,0 +1,25 @@ +package com.kappzzang.jeongsan.model + +data class ExpenseItemWithDetails( + private val item: ExpenseItem, + val expenseImageUrl: String, + val expenseDetails: List + +) { + val id + get() = item.id + val name + get() = item.name + val price + get() = item.price + val state + get() = item.state + + companion object { + val EMPTY = ExpenseItemWithDetails( + ExpenseItem.EMPTY, + "", + emptyList() + ) + } +} diff --git a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseListResponse.kt b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseListResponse.kt index 5d2efda0..cf2b65ba 100644 --- a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseListResponse.kt +++ b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ExpenseListResponse.kt @@ -3,7 +3,7 @@ package com.kappzzang.jeongsan.model data class ExpenseListResponse( val totalPrice: Int, val totalExpenseToSend: Int, - val expenseList: List + val expenseList: List ) { companion object { fun emptyList() = ExpenseListResponse(0, 0, listOf()) diff --git a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ReceiptItem.kt b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ReceiptItem.kt index d139e20b..fad3b92b 100644 --- a/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ReceiptItem.kt +++ b/domain/expense/src/main/java/com/kappzzang/jeongsan/model/ReceiptItem.kt @@ -1,8 +1,11 @@ package com.kappzzang.jeongsan.model +import java.time.LocalDateTime + data class ReceiptItem( val title: String, val categoryColor: String, val imageBase64: String?, - val expenseDetailItemList: List + val expenseDetailItemList: List, + val paymentTime: LocalDateTime = LocalDateTime.now() ) diff --git a/domain/expense/src/main/java/com/kappzzang/jeongsan/repository/ExpenseRepository.kt b/domain/expense/src/main/java/com/kappzzang/jeongsan/repository/ExpenseRepository.kt index ca9f7671..c8abf0b3 100644 --- a/domain/expense/src/main/java/com/kappzzang/jeongsan/repository/ExpenseRepository.kt +++ b/domain/expense/src/main/java/com/kappzzang/jeongsan/repository/ExpenseRepository.kt @@ -1,6 +1,6 @@ package com.kappzzang.jeongsan.repository -import com.kappzzang.jeongsan.model.ExpenseItem +import com.kappzzang.jeongsan.model.ExpenseItemWithDetails import com.kappzzang.jeongsan.model.ExpenseListResponse import com.kappzzang.jeongsan.model.ExpenseState import kotlinx.coroutines.flow.Flow @@ -14,5 +14,5 @@ interface ExpenseRepository { * @return 지출 목록 response flow */ fun getExpenseList(groupId: String, expenseState: ExpenseState): Flow - suspend fun getExpense(id: Long): ExpenseItem + suspend fun getExpense(id: Long): ExpenseItemWithDetails } diff --git a/gradle/common.gradle b/gradle/common.gradle new file mode 100644 index 00000000..53bef314 --- /dev/null +++ b/gradle/common.gradle @@ -0,0 +1,24 @@ +def hasLibraryPlugin = pluginManager.hasPlugin("com.android.library") +def hasApplicationPlugin = pluginManager.hasPlugin("com.android.application") + +if (hasLibraryPlugin || hasApplicationPlugin) { + android { + compileSdk = 34 + + defaultConfig { + minSdk = 26 + lint.targetSdk = 34 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 17de2ae3..fd6dd1bf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,3 +45,4 @@ include(":ui:data") project(":data").children.forEach { module -> module.name = "data-${module.name}" } include(":common:navigation") include(":common:retrofit") +include(":build-config") diff --git a/ui/addexpense/src/main/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModel.kt b/ui/addexpense/src/main/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModel.kt index 23df6e4b..73706713 100644 --- a/ui/addexpense/src/main/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModel.kt +++ b/ui/addexpense/src/main/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModel.kt @@ -12,6 +12,7 @@ import com.kappzzang.jeongsan.usecase.UploadExpenseUseCase import dagger.hilt.android.lifecycle.HiltViewModel import java.io.ByteArrayOutputStream import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,7 +21,8 @@ import kotlinx.coroutines.launch @HiltViewModel class AddExpenseViewModel @Inject constructor( - private val uploadExpenseUseCase: UploadExpenseUseCase + private val uploadExpenseUseCase: UploadExpenseUseCase, + private val ioDispatcher: CoroutineDispatcher ) : ViewModel() { private val _expenseItemList by lazy { MutableStateFlow( @@ -109,14 +111,14 @@ class AddExpenseViewModel @Inject constructor( } ) - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { uploadExpenseUseCase(receiptItem) } return true } - private fun convertBitmapToBase64(bitmap: Bitmap?): String? { + fun convertBitmapToBase64(bitmap: Bitmap?): String? { if (bitmap == null) { return null } diff --git a/ui/addexpense/src/test/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModelTest.kt b/ui/addexpense/src/test/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModelTest.kt new file mode 100644 index 00000000..f2da5c76 --- /dev/null +++ b/ui/addexpense/src/test/java/com/kappzzang/jeongsan/addexpense/AddExpenseViewModelTest.kt @@ -0,0 +1,195 @@ +package com.kappzzang.jeongsan.addexpense + +import android.graphics.Bitmap +import com.kappzzang.jeongsan.model.OcrDetailItem +import com.kappzzang.jeongsan.model.OcrResultResponse +import com.kappzzang.jeongsan.model.ReceiptItem +import com.kappzzang.jeongsan.usecase.UploadExpenseUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.unmockkAll +import java.time.LocalDateTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class AddExpenseViewModelTest { + + private val mockUploadExpenseUseCase = mockk() + private lateinit var viewModel: AddExpenseViewModel + + private val testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = spyk(AddExpenseViewModel(mockUploadExpenseUseCase, testDispatcher)) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + @Test + fun `영수증 모드인 경우 해당 속성을 반영하는지 확인`() = runTest { + // Given + val manualMode = AddExpenseViewModel.Companion.ManualMode.RECEIPT + + // When + viewModel.setManualMode(manualMode) + advanceUntilIdle() + + // Then + assertEquals(false, viewModel.manualMode.value) + assertEquals(true, viewModel.uploadedImage.value) + } + + @Test + fun `수기 입력 모드인 경우 해당 속성을 반영하는지 확인`() = runTest { + // Given + val testManualMode = AddExpenseViewModel.Companion.ManualMode.MANUAL + + // When + viewModel.setManualMode(testManualMode) + advanceUntilIdle() + + // Then + assertEquals(true, viewModel.manualMode.value) + assertEquals(false, viewModel.uploadedImage.value) + } + + @Test + fun `영수증 인식 정보를 잘 반영하는지 확인`() = runTest { + // Given + val testBitmap = mockk() + val testOcrResult = OcrResultResponse.OcrSuccess( + name = "Test Receipt", + paymentTime = LocalDateTime.now(), + detailItems = listOf( + OcrDetailItem("Test Item 1", 1000, 1), + OcrDetailItem("Test Item 2", 2000, 2) + ) + ) + + // When + viewModel.setInitialReceiptData(testBitmap, testOcrResult) + advanceUntilIdle() + + // Then + assertEquals(testBitmap, viewModel.expenseImageBitmap.value) + assertEquals(testOcrResult.name, viewModel.expenseName.value) + assertEquals(testOcrResult.detailItems.size + 1, viewModel.expenseItemList.value.size) + for (i in testOcrResult.detailItems.indices) { + assertEquals( + testOcrResult.detailItems[i].itemName, + viewModel.expenseItemList.value[i].itemName + ) + assertEquals( + testOcrResult.detailItems[i].itemPrice, + viewModel.expenseItemList.value[i].itemPrice + ) + assertEquals( + testOcrResult.detailItems[i].itemQuantity, + viewModel.expenseItemList.value[i].itemQuantity + ) + } + } + + @Test + fun `항목 추가를 진행할 때 정상적으로 늘어나는지 확인`() = runTest { + // Given + val initialItemSize = viewModel.expenseItemList.value.size + + // When + viewModel.addNewExpense() + advanceUntilIdle() + + // Then + val afterItemSize = viewModel.expenseItemList.value.size + assertEquals(initialItemSize + 1, afterItemSize) + assertEquals(false, viewModel.expenseItemList.value[afterItemSize - 2].isPlaceholder) + assertEquals(true, viewModel.expenseItemList.value[afterItemSize - 1].isPlaceholder) + } + + @Test + fun `항목 삭제를 진행할 때 정상적으로 삭제되는지 확인`() = runTest { + // Given + viewModel.addNewExpense() + advanceUntilIdle() + val initialItemSize = viewModel.expenseItemList.value.size + + // When + viewModel.removeExpense(0) + advanceUntilIdle() + + // Then + assertEquals(initialItemSize - 1, viewModel.expenseItemList.value.size) + } + + @Test + fun `빈 값들을 업로드 할때, 이를 잘 검사하는지 확인`() = runTest { + // Given + + // When + val result = viewModel.uploadExpense() + advanceUntilIdle() + + // Then + assertEquals(false, result) + } + + @Test + fun `유효한 값들을 업로드 할 때, 잘 실행하는지 확인`() = runTest { + // Given + val testBitmap = mockk() + val testBase64 = "test_base64" + every { viewModel.convertBitmapToBase64(any()) } returns testBase64 + coEvery { mockUploadExpenseUseCase(any()) } returns "test success" + + val testOcrResult = OcrResultResponse.OcrSuccess( + name = "Test Receipt", + paymentTime = LocalDateTime.now(), + detailItems = listOf( + OcrDetailItem("Test Item 1", 1000, 1), + OcrDetailItem("Test Item 2", 2000, 2) + ) + ) + viewModel.setInitialReceiptData(testBitmap, testOcrResult) + advanceUntilIdle() + + // When + val result = viewModel.uploadExpense() + advanceUntilIdle() + + // Then + assertEquals(true, result) + val receiptItemSlot = slot() + coVerify { mockUploadExpenseUseCase(capture(receiptItemSlot)) } + assertEquals(testOcrResult.name, receiptItemSlot.captured.title) + assertEquals(testBase64, receiptItemSlot.captured.imageBase64) + assertEquals( + testOcrResult.detailItems.size, + receiptItemSlot.captured.expenseDetailItemList.size + ) + assertEquals( + testOcrResult.detailItems[0].itemName, + receiptItemSlot.captured.expenseDetailItemList[0].itemName + ) + } +} diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index eb16ad41..15c1e0cd 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -1,5 +1,3 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties - plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -9,26 +7,7 @@ plugins { } android { - fun getApiKey(key: String): String = gradleLocalProperties(rootDir, providers).getProperty(key) - namespace = "com.kappzzang.jeongsan" - compileSdk = 34 - - defaultConfig { - minSdk = 26 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - resValue("string", "KAKAO_API_KEY", getApiKey("KAKAO_API_KEY")) - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } } subprojects { @@ -84,18 +63,10 @@ subprojects { androidTestImplementation("androidx.test:rules:1.6.1") androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1") androidTestImplementation("com.google.dagger:hilt-android-testing:2.48.1") + implementation(project(":build-config")) } android { - compileSdk = 34 - - defaultConfig { - minSdk = 26 - targetSdk = 34 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes { release { isMinifyEnabled = false @@ -105,18 +76,10 @@ subprojects { ) } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } buildFeatures { dataBinding = true viewBinding = true - buildConfig = true resValues = true } } diff --git a/ui/creategroup/src/test/java/com/kappzzang/jeongsan/creategroup/CreateGroupViewModelTest.kt b/ui/creategroup/src/test/java/com/kappzzang/jeongsan/creategroup/CreateGroupViewModelTest.kt new file mode 100644 index 00000000..2cd18d86 --- /dev/null +++ b/ui/creategroup/src/test/java/com/kappzzang/jeongsan/creategroup/CreateGroupViewModelTest.kt @@ -0,0 +1,154 @@ +package com.kappzzang.jeongsan.creategroup + +import com.kappzzang.jeongsan.data.MemberUIData +import com.kappzzang.jeongsan.usecase.SendInviteMessageUseCase +import com.kappzzang.jeongsan.usecase.UploadGroupInfoUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class CreateGroupViewModelTest { + + private val mockUploadGroupInfoUseCase = mockk() + private val mockSendInviteMessageUseCase = mockk() + private lateinit var viewModel: CreateGroupViewModel + + private val testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + coEvery { mockUploadGroupInfoUseCase(any()) } returns Unit + coEvery { mockSendInviteMessageUseCase(any(), any(), any()) } returns true + viewModel = CreateGroupViewModel(mockUploadGroupInfoUseCase, mockSendInviteMessageUseCase) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + @Test + fun `그룹 주제(이모지) 업데이트시 올바르게 반영하는지 확인`() = runTest { + // given + val testSubject = "😊" + + // when + viewModel.updateGroupSubject(testSubject) + advanceUntilIdle() + + // then + assertEquals(testSubject, viewModel.groupSubject.value) + } + + @Test + fun `그룹 멤버 리스트 업데이트시 올바르게 반영되는지 확인`() = runTest { + // given + val testMembers = listOf( + MemberUIData("1", "Alice", "test url1"), + MemberUIData("2", "Bob", "test url2") + ) + + // when + viewModel.updateGroupMemberList(testMembers) + advanceUntilIdle() + + // then + assertEquals(testMembers, viewModel.groupMemberList.value) + } + + @Test + fun `멤버 제거시 해당 위치의 멤버가 삭제되는지 확인`() = runTest { + // given + val testMembers = listOf( + MemberUIData("1", "Alice", "test url1"), + MemberUIData("2", "Bob", "test url2"), + MemberUIData("3", "Charlie", "test url3") + ) + viewModel.updateGroupMemberList(testMembers) + advanceUntilIdle() + + // when + viewModel.removeMember(1) // "Bob" 제거 + advanceUntilIdle() + + // then + val updatedList = viewModel.groupMemberList.value + assertEquals(testMembers.size - 1, updatedList.size) + assertEquals(testMembers[0].name, updatedList[0].name) + assertEquals(testMembers[2].name, updatedList[1].name) + } + + @Test + fun `그룹 정보가 유효하지 않으면 업로드가 진행되지 않는지 확인`() = runTest { + // given + + // when + val result = viewModel.uploadGroupInfo() + + // then + assertEquals(false, result) + coVerify(exactly = 0) { mockUploadGroupInfoUseCase(any()) } + } + + @Test + fun `그룹 정보가 유효하면 업로드가 잘 진행되는지 확인`() = runTest { + // given + val testGroupName = "Test Group" + val testGroupSubject = "✈️" + val testMembers = listOf( + MemberUIData("1", "Alice", "test url1"), + MemberUIData("2", "Bob", "test url2") + ) + viewModel.groupName.emit(testGroupName) + viewModel.updateGroupSubject(testGroupSubject) + viewModel.updateGroupMemberList(testMembers) + advanceUntilIdle() + + // when + val result = viewModel.uploadGroupInfo() + advanceUntilIdle() + + // then + assertEquals(true, result) + coVerify { mockUploadGroupInfoUseCase(any()) } + } + + @Test + fun `모든 멤버에게 초대 메시지를 전송하는지 확인`() = runTest { + // given + val testGroupId = "test_group_id" + val testGroupName = "Test Group" + val testMembers = listOf( + MemberUIData("1", "Alice", "test url1"), + MemberUIData("2", "Bob", "test url2") + ) + viewModel.groupName.emit(testGroupName) + viewModel.updateGroupMemberList(testMembers) + advanceUntilIdle() + + // when + viewModel.sendInviteMessageAll(testGroupId) + advanceUntilIdle() + + // then + for (member in testMembers) { + coVerify { mockSendInviteMessageUseCase(testGroupId, testGroupName, member.uuid) } + } + } +} diff --git a/ui/data/build.gradle.kts b/ui/data/build.gradle.kts index d4256d34..ccb054d5 100644 --- a/ui/data/build.gradle.kts +++ b/ui/data/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") - implementation(project(":domain:expense")) implementation(project(":domain:group")) testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseListViewUIData.kt b/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseListViewUIData.kt index 6980a733..22749129 100644 --- a/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseListViewUIData.kt +++ b/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseListViewUIData.kt @@ -1,10 +1,15 @@ package com.kappzzang.jeongsan.data -import com.kappzzang.jeongsan.model.ExpenseItem - data class ExpenseListViewUIData( val totalPriceText: String, val priceToSendText: String, - val groupNameText: String, - val expenseItems: List -) + val expenseItems: List +) { + companion object { + val emptyData = ExpenseListViewUIData( + "", + "", + emptyList() + ) + } +} diff --git a/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseUiItem.kt b/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseUiItem.kt new file mode 100644 index 00000000..573f2fae --- /dev/null +++ b/ui/data/src/main/java/com/kappzzang/jeongsan/data/ExpenseUiItem.kt @@ -0,0 +1,13 @@ +package com.kappzzang.jeongsan.data + +import java.time.LocalDateTime + +data class ExpenseUiItem( + val id: String, + val name: String, + val isFirstItem: Boolean, + val isLastItem: Boolean, + val price: String, + val date: LocalDateTime, + val categoryColor: String +) diff --git a/ui/data/src/main/java/com/kappzzang/jeongsan/data/ListViewItemPositionInfo.kt b/ui/data/src/main/java/com/kappzzang/jeongsan/data/ListViewItemPositionInfo.kt new file mode 100644 index 00000000..453b4dc0 --- /dev/null +++ b/ui/data/src/main/java/com/kappzzang/jeongsan/data/ListViewItemPositionInfo.kt @@ -0,0 +1,3 @@ +package com.kappzzang.jeongsan.data + +data class ListViewItemPositionInfo(val isFirstItem: Boolean, val isLastItem: Boolean) diff --git a/ui/expensedetail/src/main/java/com/kappzzang/jeongsan/expensedetail/ExpenseDetailViewModel.kt b/ui/expensedetail/src/main/java/com/kappzzang/jeongsan/expensedetail/ExpenseDetailViewModel.kt index 9d9ef464..508c2500 100644 --- a/ui/expensedetail/src/main/java/com/kappzzang/jeongsan/expensedetail/ExpenseDetailViewModel.kt +++ b/ui/expensedetail/src/main/java/com/kappzzang/jeongsan/expensedetail/ExpenseDetailViewModel.kt @@ -4,7 +4,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kappzzang.jeongsan.model.ExpenseDetailItem -import com.kappzzang.jeongsan.model.ExpenseItem +import com.kappzzang.jeongsan.model.ExpenseItemWithDetails import com.kappzzang.jeongsan.usecase.EditExpenseDetailUseCase import com.kappzzang.jeongsan.usecase.GetExpenseDetailUseCase import com.kappzzang.jeongsan.usecase.GetExpenseUseCase @@ -24,10 +24,10 @@ class ExpenseDetailViewModel @Inject constructor( ) : ViewModel() { private val _expenseDetailList = MutableStateFlow(listOf()) - private val _expense = MutableStateFlow(ExpenseItem.EMPTY) + private val _expense = MutableStateFlow(ExpenseItemWithDetails.EMPTY) val expenseDetailList: StateFlow> = _expenseDetailList.asStateFlow() - val expense: StateFlow = _expense.asStateFlow() + val expense: StateFlow = _expense.asStateFlow() init { initExpense() diff --git a/ui/expenselist/build.gradle.kts b/ui/expenselist/build.gradle.kts index 29134755..6d64b523 100644 --- a/ui/expenselist/build.gradle.kts +++ b/ui/expenselist/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { implementation("androidx.navigation:navigation-fragment-ktx:2.8.1") implementation("androidx.navigation:navigation-ui-ktx:2.8.1") implementation("nl.dionsegijn:konfetti-xml:2.0.4") + implementation("androidx.hilt:hilt-navigation-fragment:1.1.0") implementation(project(":domain:group")) implementation(project(":domain:expense")) implementation(project(":domain:ocr")) diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/CompleteExpenseListFragment.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/CompleteExpenseListFragment.kt index 83a338cd..4a7552d6 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/CompleteExpenseListFragment.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/CompleteExpenseListFragment.kt @@ -6,13 +6,17 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import com.kappzzang.jeongsan.expenselist.databinding.FragmentCompleteExpenseListBinding +import com.kappzzang.jeongsan.expenselist.viewmodel.CompleteExpenseListPageViewModel +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class CompleteExpenseListFragment : Fragment() { - private val viewModel: ExpenseListViewModel by activityViewModels() + private val activityViewModel: ExpenseListViewModel by activityViewModels() + private val viewModel: CompleteExpenseListPageViewModel by viewModels() private lateinit var binding: FragmentCompleteExpenseListBinding override fun onCreateView( @@ -31,9 +35,9 @@ class CompleteExpenseListFragment : Fragment() { // UI 확인을 위한 임시 코드 binding.completeExpenseListRecyclerview.adapter = ExpenseListAdapter { - viewModel.clickExpenseItem(it) + activityViewModel.clickExpenseItem(it) } binding.completeExpenseListRecyclerview.layoutManager = LinearLayoutManager(this.context) - viewModel.clickSentCompleteMenuButton() + viewModel.onFragmentStart(activityViewModel.groupId.value) } } diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListActivity.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListActivity.kt index 048e7050..fdae7b7c 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListActivity.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListActivity.kt @@ -23,6 +23,7 @@ import androidx.navigation.ui.setupWithNavController import com.kappzzang.jeongsan.expenselist.databinding.ActivityExpenseListBinding import com.kappzzang.jeongsan.expenselist.inviteinfo.InviteInfoDialogFragment import com.kappzzang.jeongsan.expenselist.sendmessage.SendMessageActivity +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListViewModel import com.kappzzang.jeongsan.intentcontract.AddExpenseContract import com.kappzzang.jeongsan.intentcontract.ExpenseListContract import com.kappzzang.jeongsan.intentcontract.ReceiptCameraContract @@ -84,7 +85,6 @@ class ExpenseListActivity : AppCompatActivity() { activityReceiptCameraLauncher = createReceiptCameraLauncher() - // TODO: 임시 연결용 코드 binding.requestExpenseFab.setOnClickListener { startActivity(Intent(this, SendMessageActivity::class.java)) } diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListAdapter.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListAdapter.kt index de84749e..da77e1b1 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListAdapter.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListAdapter.kt @@ -5,19 +5,22 @@ import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.kappzzang.jeongsan.data.ExpenseUiItem +import com.kappzzang.jeongsan.data.ListViewItemPositionInfo import com.kappzzang.jeongsan.expenselist.databinding.ItemExpenseBinding -import com.kappzzang.jeongsan.model.ExpenseItem import com.kappzzang.jeongsan.util.DateConverter.formatToExpenseDate class ExpenseListAdapter(private val onExpenseItemClickListener: (expenseId: String) -> Unit) : - ListAdapter( + ListAdapter( object : - DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ExpenseItem, newItem: ExpenseItem): Boolean = + DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ExpenseUiItem, newItem: ExpenseUiItem): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: ExpenseItem, newItem: ExpenseItem): Boolean = - oldItem == newItem + override fun areContentsTheSame( + oldItem: ExpenseUiItem, + newItem: ExpenseUiItem + ): Boolean = oldItem == newItem } ) { @@ -31,7 +34,7 @@ class ExpenseListAdapter(private val onExpenseItemClickListener: (expenseId: Str } } - fun bind(expenseItem: ExpenseItem) { + fun bind(expenseItem: ExpenseUiItem) { binding.categoryColorView.setBackgroundColor( android.graphics.Color.parseColor( expenseItem.categoryColor @@ -39,6 +42,10 @@ class ExpenseListAdapter(private val onExpenseItemClickListener: (expenseId: Str ) binding.expenseItem = expenseItem binding.expenseDate = expenseItem.date.formatToExpenseDate() + binding.positionInfo = ListViewItemPositionInfo( + isFirstItem = this.bindingAdapterPosition == 0, + isLastItem = this.bindingAdapter?.itemCount?.minus(1) == this.bindingAdapterPosition + ) } } diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListBindingAdapter.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListBindingAdapter.kt index 67ba78e7..f5b82e8d 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListBindingAdapter.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListBindingAdapter.kt @@ -1,7 +1,10 @@ package com.kappzzang.jeongsan.expenselist +import android.view.View import androidx.databinding.BindingAdapter import androidx.recyclerview.widget.RecyclerView +import com.kappzzang.jeongsan.expenselist.customview.CustomOutlineProvider +import com.kappzzang.jeongsan.expenselist.customview.ExpenseListItemBoxType import kotlinx.coroutines.flow.StateFlow object ExpenseListBindingAdapter { @@ -18,4 +21,31 @@ object ExpenseListBindingAdapter { ) } } + + @BindingAdapter( + value = ["clipRadius", "clipUpperCorner", "clipBottomCorner"], + requireAll = false + ) + @JvmStatic + fun bindCorners(view: View, radius: Float?, upperCorner: Boolean?, bottomCorner: Boolean?) { + val boxType = if (upperCorner == true) { + if (bottomCorner == true) { + ExpenseListItemBoxType.ALL_CORNERS + } else { + ExpenseListItemBoxType.TOP_CORNER + } + } else { + if (bottomCorner == true) { + ExpenseListItemBoxType.BOTTOM_CORNER + } else { + ExpenseListItemBoxType.NO_CORNERS + } + } + + view.outlineProvider = CustomOutlineProvider( + radius ?: view.resources.getDimension(R.dimen.expense_list_item_corner_radius), + boxType + ) + view.clipToOutline = true + } } diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListFragment.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListFragment.kt index f720f0d3..85dbefa7 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListFragment.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListFragment.kt @@ -6,13 +6,17 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import com.kappzzang.jeongsan.expenselist.databinding.FragmentExpenseListBinding +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListOnCalculationPageViewModel +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ExpenseListFragment : Fragment() { - private val viewModel: ExpenseListViewModel by activityViewModels() + private val activityViewModel: ExpenseListViewModel by activityViewModels() + private val viewModel: ExpenseListOnCalculationPageViewModel by viewModels() private lateinit var binding: FragmentExpenseListBinding override fun onCreateView( @@ -29,11 +33,12 @@ class ExpenseListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // UI 확인을 위한 임시 코드 binding.expenseListRecyclerview.adapter = ExpenseListAdapter { - viewModel.clickExpenseItem(it) + activityViewModel.clickExpenseItem(it) } + binding.expenseListRecyclerview.layoutManager = LinearLayoutManager(this.context) - viewModel.clickOnlyNotConfirmedExpensesChipButton() + + viewModel.onFragmentStart(activityViewModel.groupId.value) } } diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListViewModel.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListViewModel.kt deleted file mode 100644 index 5d567f51..00000000 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/ExpenseListViewModel.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.kappzzang.jeongsan.expenselist - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.kappzzang.jeongsan.data.ExpenseListViewUIData -import com.kappzzang.jeongsan.model.ExpenseListResponse -import com.kappzzang.jeongsan.model.ExpenseState -import com.kappzzang.jeongsan.usecase.GetCurrentGroupInfoUseCase -import com.kappzzang.jeongsan.usecase.GetExpenseListUseCase -import com.kappzzang.jeongsan.util.IntegerFormatter.formatDecimalSeparator -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.zip -import kotlinx.coroutines.launch - -@HiltViewModel -class ExpenseListViewModel @Inject constructor( - private val getCurrentGroupInfoUseCase: GetCurrentGroupInfoUseCase, - private val getExpenseListUseCase: GetExpenseListUseCase -) : ViewModel() { - - private var expenseListFetchingJob: Job? = null - private var _groupId = MutableStateFlow("") - - private val expenseList = - MutableStateFlow(ExpenseListResponse.emptyList()) - private val _groupName = MutableStateFlow("") - - private val _selectedExpense = MutableStateFlow("") - - private val _uiData by lazy { - combine( - expenseList, - _groupName - ) { expenseList, groupName -> - val totalPrice = expenseList.totalPrice - val priceToSend = expenseList.totalExpenseToSend - val items = expenseList.expenseList - - ExpenseListViewUIData( - totalPriceText = "${totalPrice.formatDecimalSeparator()}원", - priceToSendText = "${priceToSend.formatDecimalSeparator()}원", - groupNameText = groupName, - expenseItems = items - ) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000L), - ExpenseListViewUIData("", "", "", listOf()) - ) - } - - val groupName = _groupName.asStateFlow() - - val groupId = _groupId.asStateFlow() - - val uiData by lazy { - _uiData - } - - val selectedExpense = _selectedExpense.asStateFlow() - - private fun cancelPreviousJob() { - if (expenseListFetchingJob?.isCompleted != false) { - return - } - expenseListFetchingJob?.cancel() - } - - private fun fetchExpenseList(expenseState: ExpenseState) { - cancelPreviousJob() - expenseListFetchingJob = viewModelScope.launch(Dispatchers.IO) { - getExpenseListUseCase(_groupId.value, expenseState) - .collect { - expenseList.emit(it) - } - } - } - - // 미확인 + 확인 지출 모두 불러오기 - private fun fetchCalculatingExpenseList() { - cancelPreviousJob() - expenseListFetchingJob = viewModelScope.launch(Dispatchers.IO) { - getExpenseListUseCase(_groupId.value, ExpenseState.CONFIRMED).zip( - getExpenseListUseCase( - _groupId.value, - ExpenseState.NOT_CONFIRMED - ) - ) { confirmed, notConfirmed -> - ExpenseListResponse( - expenseList = confirmed.expenseList.toMutableList() + notConfirmed.expenseList, - totalPrice = confirmed.totalPrice + notConfirmed.totalPrice, - totalExpenseToSend = 0 - ) - }.collect { - expenseList.emit(it) - } - } - } - - private fun fetchGroupInfo() { - viewModelScope.launch(Dispatchers.IO) { - getCurrentGroupInfoUseCase(_groupId.value).map { - it.name - }.collect { - _groupName.emit(it) - } - } - } - - fun clickPendSendingMenuButton() { - fetchExpenseList(ExpenseState.TRANSFER_PENDING) - } - - fun clickOnCalculatingMenuButton() { - fetchExpenseList(ExpenseState.NOT_CONFIRMED) - } - - fun clickSentCompleteMenuButton() { - fetchExpenseList(ExpenseState.TRANSFERED) - } - - fun clickAllExpensesChipButton() { - fetchCalculatingExpenseList() - } - - fun clickOnlyNotConfirmedExpensesChipButton() { - fetchExpenseList(ExpenseState.NOT_CONFIRMED) - } - - fun clickOnlyConfirmedExpensesChipButton() { - fetchExpenseList(ExpenseState.CONFIRMED) - } - - fun updateGroupId(groupId: String) { - this._groupId.value = groupId - - fetchGroupInfo() - } - - fun clickExpenseItem(expenseId: String) { - _selectedExpense.value = expenseId - } -} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/PendingExpenseListFragment.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/PendingExpenseListFragment.kt index a0c49e8b..fe55a902 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/PendingExpenseListFragment.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/PendingExpenseListFragment.kt @@ -6,13 +6,17 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import com.kappzzang.jeongsan.expenselist.databinding.FragmentPendingExpenseListBinding +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListViewModel +import com.kappzzang.jeongsan.expenselist.viewmodel.PendingExpenseListPageViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class PendingExpenseListFragment : Fragment() { - private val viewModel: ExpenseListViewModel by activityViewModels() + private val activityViewModel: ExpenseListViewModel by activityViewModels() + private val viewModel: PendingExpenseListPageViewModel by viewModels() private lateinit var binding: FragmentPendingExpenseListBinding override fun onCreateView( @@ -30,10 +34,10 @@ class PendingExpenseListFragment : Fragment() { binding.viewModel = viewModel binding.lifecycleOwner = activity binding.pendingExpenseListRecyclerview.adapter = ExpenseListAdapter { - viewModel.clickExpenseItem(it) + activityViewModel.clickExpenseItem(it) } binding.pendingExpenseListRecyclerview.layoutManager = LinearLayoutManager(this.context) - viewModel.clickPendSendingMenuButton() + viewModel.onFragmentStart(activityViewModel.groupId.value) } } diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/customview/CustomOutlineProvider.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/customview/CustomOutlineProvider.kt new file mode 100644 index 00000000..29edfb72 --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/customview/CustomOutlineProvider.kt @@ -0,0 +1,63 @@ +package com.kappzzang.jeongsan.expenselist.customview + +import android.graphics.Outline +import android.util.TypedValue +import android.view.View +import android.view.ViewOutlineProvider + +enum class ExpenseListItemBoxType { TOP_CORNER, BOTTOM_CORNER, ALL_CORNERS, NO_CORNERS } + +class CustomOutlineProvider( + private val cornerRadiusDP: Float, + private val outlineType: ExpenseListItemBoxType +) : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val left = 0 + val top = 0 + val right = view.width + val bottom = view.height + + val cornerRadius = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + cornerRadiusDP, + view.resources.displayMetrics + ) + when (outlineType) { + ExpenseListItemBoxType.TOP_CORNER -> + outline.setRoundRect( + left, + top, + right, + bottom + cornerRadius.toInt(), + cornerRadius + ) + + ExpenseListItemBoxType.BOTTOM_CORNER -> + outline.setRoundRect( + left, + top - cornerRadius.toInt(), + right, + bottom, + cornerRadius + ) + + ExpenseListItemBoxType.NO_CORNERS -> + outline.setRoundRect( + left, + top, + right, + bottom, + 0f + ) + + ExpenseListItemBoxType.ALL_CORNERS -> + outline.setRoundRect( + left, + top, + right, + bottom, + cornerRadius + ) + } + } +} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/inviteinfo/InviteInfoDialogFragment.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/inviteinfo/InviteInfoDialogFragment.kt index 72867765..65755f3e 100644 --- a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/inviteinfo/InviteInfoDialogFragment.kt +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/inviteinfo/InviteInfoDialogFragment.kt @@ -13,8 +13,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager -import com.kappzzang.jeongsan.expenselist.ExpenseListViewModel import com.kappzzang.jeongsan.expenselist.databinding.FragmentInviteInfoDialogBinding +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListViewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/util/ExpenseUiItemMapper.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/util/ExpenseUiItemMapper.kt new file mode 100644 index 00000000..1b8b6c69 --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/util/ExpenseUiItemMapper.kt @@ -0,0 +1,28 @@ +package com.kappzzang.jeongsan.expenselist.util + +import com.kappzzang.jeongsan.data.ExpenseUiItem +import com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListPageViewModel.Companion.CURRENCY_POSTFIX +import com.kappzzang.jeongsan.model.ExpenseItemWithCategory +import com.kappzzang.jeongsan.util.IntegerFormatter.formatDecimalSeparator + +object ExpenseUiItemMapper { + internal fun mapToExpenseUiItem( + expenseItemList: List + ): List = expenseItemList.mapIndexed { index, item -> + ExpenseUiItem( + isFirstItem = index == 0, + isLastItem = index == expenseItemList.size - 1, + id = item.id, + name = item.name, + date = item.date, + categoryColor = item.categoryColor, + price = "${item.price.formatDecimalSeparator()} $CURRENCY_POSTFIX" + ) + } + + internal fun sortItemsByTime( + expenseItemList: List + ): List = expenseItemList.sortedByDescending { + it.date + } +} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/CompleteExpenseListPageViewModel.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/CompleteExpenseListPageViewModel.kt new file mode 100644 index 00000000..4de618a8 --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/CompleteExpenseListPageViewModel.kt @@ -0,0 +1,15 @@ +package com.kappzzang.jeongsan.expenselist.viewmodel + +import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.usecase.GetExpenseListUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CompleteExpenseListPageViewModel @Inject constructor( + getExpenseListUseCase: GetExpenseListUseCase +) : ExpenseListPageViewModel(getExpenseListUseCase) { + override fun fetchDefaultList(groupId: String) { + fetchExpenseList(ExpenseState.TRANSFERED, groupId) + } +} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListOnCalculationPageViewModel.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListOnCalculationPageViewModel.kt new file mode 100644 index 00000000..9f3b1c4b --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListOnCalculationPageViewModel.kt @@ -0,0 +1,53 @@ +package com.kappzzang.jeongsan.expenselist.viewmodel + +import androidx.lifecycle.viewModelScope +import com.kappzzang.jeongsan.model.ExpenseListResponse +import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.usecase.GetExpenseListUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.zip +import kotlinx.coroutines.launch + +@HiltViewModel +class ExpenseListOnCalculationPageViewModel @Inject constructor( + getExpenseListUseCase: GetExpenseListUseCase +) : ExpenseListPageViewModel(getExpenseListUseCase) { + override fun fetchDefaultList(groupId: String) { + fetchExpenseList(ExpenseState.NOT_CONFIRMED, groupId) + } + + // 미확인 + 확인 지출 모두 불러오기 + private fun fetchCalculatingExpenseList(groupId: String) { + cancelPreviousJob() + expenseListFetchingJob = viewModelScope.launch(Dispatchers.IO) { + getExpenseListUseCase(groupId, ExpenseState.CONFIRMED).zip( + getExpenseListUseCase( + groupId, + ExpenseState.NOT_CONFIRMED + ) + ) { confirmed, notConfirmed -> + ExpenseListResponse( + expenseList = confirmed.expenseList.toMutableList() + notConfirmed.expenseList, + totalPrice = confirmed.totalPrice + notConfirmed.totalPrice, + totalExpenseToSend = 0 + ) + }.collect { + expenseList.emit(it) + } + } + } + + fun clickAllExpensesChipButton() { + fetchCalculatingExpenseList(groupId.value) + } + + fun clickOnlyNotConfirmedExpensesChipButton() { + fetchExpenseList(ExpenseState.NOT_CONFIRMED, groupId.value) + } + + fun clickOnlyConfirmedExpensesChipButton() { + fetchExpenseList(ExpenseState.CONFIRMED, groupId.value) + } +} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListPageViewModel.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListPageViewModel.kt new file mode 100644 index 00000000..77c3fdbf --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListPageViewModel.kt @@ -0,0 +1,86 @@ +package com.kappzzang.jeongsan.expenselist.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kappzzang.jeongsan.data.ExpenseListViewUIData +import com.kappzzang.jeongsan.expenselist.util.ExpenseUiItemMapper.mapToExpenseUiItem +import com.kappzzang.jeongsan.expenselist.util.ExpenseUiItemMapper.sortItemsByTime +import com.kappzzang.jeongsan.model.ExpenseListResponse +import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.usecase.GetExpenseListUseCase +import com.kappzzang.jeongsan.util.IntegerFormatter.formatDecimalSeparator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +open class ExpenseListPageViewModel @Inject constructor( + protected val getExpenseListUseCase: GetExpenseListUseCase +) : ViewModel() { + + protected var expenseListFetchingJob: Job? = null + protected val expenseList = + MutableStateFlow(ExpenseListResponse.emptyList()) + protected val groupId = MutableStateFlow("") + + private val _uiData by lazy { + expenseList.map { expenseList -> + val totalPrice = expenseList.totalPrice + val priceToSend = expenseList.totalExpenseToSend + val items = expenseList.expenseList + + ExpenseListViewUIData( + totalPriceText = "${totalPrice.formatDecimalSeparator()}$CURRENCY_POSTFIX", + priceToSendText = "${priceToSend.formatDecimalSeparator()}$CURRENCY_POSTFIX", + expenseItems = mapToExpenseUiItem(sortItemsByTime(items)) + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + ExpenseListViewUIData.emptyData + ) + } + + val uiData by lazy { + _uiData + } + + protected fun cancelPreviousJob() { + if (expenseListFetchingJob?.isCompleted != false) { + return + } + expenseListFetchingJob?.cancel() + } + + protected fun fetchExpenseList(expenseState: ExpenseState, groupId: String) { + cancelPreviousJob() + expenseListFetchingJob = viewModelScope.launch(Dispatchers.IO) { + getExpenseListUseCase(groupId, expenseState) + .collect { + expenseList.emit(it) + } + } + } + + protected open fun fetchDefaultList(groupId: String) { + } + + fun onFragmentStart(groupId: String) { + if (this.groupId.value != groupId) { + this.groupId.value = groupId + fetchDefaultList(this.groupId.value) + } else { + return + } + } + + companion object { + const val CURRENCY_POSTFIX = "원" + } +} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListViewModel.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListViewModel.kt new file mode 100644 index 00000000..61b481c3 --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/ExpenseListViewModel.kt @@ -0,0 +1,47 @@ +package com.kappzzang.jeongsan.expenselist.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kappzzang.jeongsan.usecase.GetCurrentGroupInfoUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class ExpenseListViewModel @Inject constructor( + private val getCurrentGroupInfoUseCase: GetCurrentGroupInfoUseCase +) : ViewModel() { + private var _groupId = MutableStateFlow("") + private val _groupName = MutableStateFlow("") + + val groupName = _groupName.asStateFlow() + + val groupId = _groupId.asStateFlow() + + private val _selectedExpense = MutableStateFlow("") + val selectedExpense = _selectedExpense.asStateFlow() + + private fun fetchGroupInfo() { + viewModelScope.launch(Dispatchers.IO) { + getCurrentGroupInfoUseCase(_groupId.value).map { + it.name + }.collect { + _groupName.emit(it) + } + } + } + + fun updateGroupId(groupId: String) { + this._groupId.value = groupId + + fetchGroupInfo() + } + + fun clickExpenseItem(expenseId: String) { + _selectedExpense.value = expenseId + } +} diff --git a/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/PendingExpenseListPageViewModel.kt b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/PendingExpenseListPageViewModel.kt new file mode 100644 index 00000000..141c84b6 --- /dev/null +++ b/ui/expenselist/src/main/java/com/kappzzang/jeongsan/expenselist/viewmodel/PendingExpenseListPageViewModel.kt @@ -0,0 +1,15 @@ +package com.kappzzang.jeongsan.expenselist.viewmodel + +import com.kappzzang.jeongsan.model.ExpenseState +import com.kappzzang.jeongsan.usecase.GetExpenseListUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class PendingExpenseListPageViewModel @Inject constructor( + getExpenseListUseCase: GetExpenseListUseCase +) : ExpenseListPageViewModel(getExpenseListUseCase) { + override fun fetchDefaultList(groupId: String) { + fetchExpenseList(ExpenseState.TRANSFER_PENDING, groupId) + } +} diff --git a/common/resource/src/main/res/drawable/expense_list_bottom_nav.xml b/ui/expenselist/src/main/res/drawable/expense_list_bottom_nav.xml similarity index 100% rename from common/resource/src/main/res/drawable/expense_list_bottom_nav.xml rename to ui/expenselist/src/main/res/drawable/expense_list_bottom_nav.xml diff --git a/common/resource/src/main/res/drawable/expense_list_bottom_nav_color.xml b/ui/expenselist/src/main/res/drawable/expense_list_bottom_nav_color.xml similarity index 100% rename from common/resource/src/main/res/drawable/expense_list_bottom_nav_color.xml rename to ui/expenselist/src/main/res/drawable/expense_list_bottom_nav_color.xml diff --git a/common/resource/src/main/res/drawable/expense_list_chip_color.xml b/ui/expenselist/src/main/res/drawable/expense_list_chip_color.xml similarity index 100% rename from common/resource/src/main/res/drawable/expense_list_chip_color.xml rename to ui/expenselist/src/main/res/drawable/expense_list_chip_color.xml diff --git a/common/resource/src/main/res/drawable/expense_list_item_background.xml b/ui/expenselist/src/main/res/drawable/expense_list_item_background.xml similarity index 100% rename from common/resource/src/main/res/drawable/expense_list_item_background.xml rename to ui/expenselist/src/main/res/drawable/expense_list_item_background.xml diff --git a/ui/expenselist/src/main/res/layout/activity_expense_list.xml b/ui/expenselist/src/main/res/layout/activity_expense_list.xml index b9d19c3f..cbb44612 100644 --- a/ui/expenselist/src/main/res/layout/activity_expense_list.xml +++ b/ui/expenselist/src/main/res/layout/activity_expense_list.xml @@ -7,7 +7,7 @@ + type="com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListViewModel" /> + tools:text="@{viewModel.groupName}" /> - - - + type="com.kappzzang.jeongsan.expenselist.viewmodel.CompleteExpenseListPageViewModel" /> + + + + + app:layout_constraintTop_toBottomOf="@id/total_expense_explain_textview" + app:layout_constraintStart_toStartOf="parent"/> + type="com.kappzzang.jeongsan.expenselist.viewmodel.ExpenseListOnCalculationPageViewModel" /> + + + + + app:layout_constraintTop_toBottomOf="@id/total_expense_explain_textview"> + type="com.kappzzang.jeongsan.expenselist.viewmodel.PendingExpenseListPageViewModel" /> + + + + + app:layout_constraintTop_toBottomOf="@id/total_expense_explain_textview" /> + type="com.kappzzang.jeongsan.data.ExpenseUiItem" /> + + android:layout_marginBottom="1dp" + android:background="@color/white" + clipUpperCorner="@{positionInfo.firstItem}" + clipBottomCorner="@{positionInfo.lastItem}"> + + 10dp + 90dp + diff --git a/ui/expenselist/src/test/java/com/kappzzang/jeongsan/expenselist/sendmessage/SendMessageViewModelTest.kt b/ui/expenselist/src/test/java/com/kappzzang/jeongsan/expenselist/sendmessage/SendMessageViewModelTest.kt new file mode 100644 index 00000000..d7d79a1b --- /dev/null +++ b/ui/expenselist/src/test/java/com/kappzzang/jeongsan/expenselist/sendmessage/SendMessageViewModelTest.kt @@ -0,0 +1,79 @@ +package com.kappzzang.jeongsan.expenselist.sendmessage + +import com.kappzzang.jeongsan.model.TransferDetailItem +import com.kappzzang.jeongsan.usecase.GetTransferInfoUseCase +import com.kappzzang.jeongsan.usecase.SendTransferMessageUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class SendMessageViewModelTest { + + private val mockGetTransferInfoUseCase = mockk() + private val mockSendTransferMessageUseCase = mockk() + private lateinit var viewModel: SendMessageViewModel + + private val testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + coEvery { mockGetTransferInfoUseCase() } returns emptyList() + coEvery { mockSendTransferMessageUseCase(any()) } returns true + viewModel = SendMessageViewModel(mockGetTransferInfoUseCase, mockSendTransferMessageUseCase) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + @Test + fun `송금 정보가 올바르게 받아서 합계를 잘 계산하는지 확인`() = runTest { + // given + val expectedTransferInfo = listOf( + TransferDetailItem("1", "test item 1", 2024, "test image url 1"), + TransferDetailItem("2", "test item 2", 11, "test image url 2"), + TransferDetailItem("3", "test item 3", 1, "test image url 3") + ) + coEvery { mockGetTransferInfoUseCase() } returns expectedTransferInfo + + // when + viewModel = SendMessageViewModel(mockGetTransferInfoUseCase, mockSendTransferMessageUseCase) + advanceUntilIdle() + + // then + val expectedTotalPrice = 2036 + assertEquals(expectedTransferInfo, viewModel.transferInfo.value) + assertEquals(expectedTotalPrice, viewModel.totalPrice.value) + } + + @Test + fun `sendTransferMessage가 성공적으로 호출되는지 확인`() = runTest { + // given + val transferInfo = viewModel.transferInfo.value + + // when + val result = viewModel.sendTransferMessage() + advanceUntilIdle() + + // then + assertEquals(true, result) + coVerify { mockSendTransferMessageUseCase(transferInfo) } + } +} diff --git a/ui/main/src/main/java/com/kappzzang/jeongsan/main/MainPageViewModel.kt b/ui/main/src/main/java/com/kappzzang/jeongsan/main/MainPageViewModel.kt index c712163f..15ec8b90 100644 --- a/ui/main/src/main/java/com/kappzzang/jeongsan/main/MainPageViewModel.kt +++ b/ui/main/src/main/java/com/kappzzang/jeongsan/main/MainPageViewModel.kt @@ -9,7 +9,7 @@ import com.kappzzang.jeongsan.usecase.GetProgressingGroupUseCase import com.kappzzang.jeongsan.usecase.GetUserInfoUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -19,7 +19,8 @@ import kotlinx.coroutines.withContext class MainPageViewModel @Inject constructor( private val getProgressingGroupUseCase: GetProgressingGroupUseCase, private val getDoneGroupUseCase: GetDoneGroupUseCase, - private val getUserInfoUseCase: GetUserInfoUseCase + private val getUserInfoUseCase: GetUserInfoUseCase, + private val ioDispatcher: CoroutineDispatcher ) : ViewModel() { private val _userName = MutableStateFlow("") @@ -36,7 +37,7 @@ class MainPageViewModel @Inject constructor( loadGroupList() } - private fun loadUserInfo() { + fun loadUserInfo() { viewModelScope.launch { val userInfo = getUserInfoUseCase() _userName.value = userInfo?.name ?: "알 수 없음" @@ -46,7 +47,7 @@ class MainPageViewModel @Inject constructor( fun loadGroupList() { viewModelScope.launch { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val resultGroupList = mutableListOf() val progressingGroupList = getProgressingGroupUseCase() diff --git a/ui/main/src/test/java/com/kappzzang/jeongsan/main/MainPageViewModelTest.kt b/ui/main/src/test/java/com/kappzzang/jeongsan/main/MainPageViewModelTest.kt new file mode 100644 index 00000000..52d9b35b --- /dev/null +++ b/ui/main/src/test/java/com/kappzzang/jeongsan/main/MainPageViewModelTest.kt @@ -0,0 +1,155 @@ +package com.kappzzang.jeongsan.main + +import com.kappzzang.jeongsan.data.GroupViewItem +import com.kappzzang.jeongsan.model.GroupItem +import com.kappzzang.jeongsan.usecase.GetDoneGroupUseCase +import com.kappzzang.jeongsan.usecase.GetProgressingGroupUseCase +import com.kappzzang.jeongsan.usecase.GetUserInfoUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class MainPageViewModelTest { + + private val mockGetProgressingGroupUseCase = mockk() + private val mockGetDoneGroupUseCase = mockk() + private val mockGetUserInfoUseCase = mockk() + private lateinit var viewModel: MainPageViewModel + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + coEvery { mockGetProgressingGroupUseCase() } returns emptyList() + coEvery { mockGetDoneGroupUseCase() } returns emptyList() + coEvery { mockGetUserInfoUseCase() } returns null + viewModel = MainPageViewModel( + mockGetProgressingGroupUseCase, + mockGetDoneGroupUseCase, + mockGetUserInfoUseCase, + testDispatcher + ) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + @Test + fun `loadUserInfo가 정상적으로 정보를 가져오는지 확인`() = runTest { + // Given + val testUserName = "Test User" + val testProfileUrl = "http://this.is.test.profile.url" + coEvery { mockGetUserInfoUseCase() } returns mockk { + every { name } returns testUserName + every { profileUrl } returns testProfileUrl + } + + // When + viewModel.loadUserInfo() + advanceUntilIdle() + + // Then + assertEquals(testUserName, viewModel.userName.value) + assertEquals(testProfileUrl, viewModel.userProfileUrl.value) + } + + @Test + fun `진행중인 그룹만 존재하는 경우 loadGroupList 함수 테스트`() = runTest { + // Given + val testProgressingGroupList = listOf(mockk(), mockk()) + val testDoneGroupList = emptyList() + coEvery { mockGetProgressingGroupUseCase() } returns testProgressingGroupList + coEvery { mockGetDoneGroupUseCase() } returns testDoneGroupList + + // When + viewModel.loadGroupList() + advanceUntilIdle() + + // Then + assertEquals(testProgressingGroupList.size + 1, viewModel.groupList.value.size) + assertEquals(GroupViewItem.ProgressTitle, viewModel.groupList.value[0]) + for (i in testProgressingGroupList.indices) { + assertEquals( + GroupViewItem.Group(testProgressingGroupList[i]), + viewModel.groupList.value[i + 1] + ) + } + } + + @Test + fun `완료된 그룹만 존재하는 경우 loadGroupList 함수 테스트`() = runTest { + // Given + val testProgressingGroupList = emptyList() + val testDoneGroupList = listOf(mockk(), mockk(), mockk()) + coEvery { mockGetProgressingGroupUseCase() } returns testProgressingGroupList + coEvery { mockGetDoneGroupUseCase() } returns testDoneGroupList + + // When + viewModel.loadGroupList() + advanceUntilIdle() + + // Then + assertEquals(testDoneGroupList.size + 1, viewModel.groupList.value.size) + assertEquals(GroupViewItem.DoneTitle, viewModel.groupList.value[0]) + for (i in testDoneGroupList.indices) { + assertEquals( + GroupViewItem.Group(testDoneGroupList[i]), + viewModel.groupList.value[i + 1] + ) + } + } + + @Test + fun `진행중인 그룹과 완료된 그룹이 모두 존재하는 경우 loadGroupList 함수 테스트`() = runTest { + // Given + val testProgressingGroupList = listOf(mockk(), mockk()) + val testDoneGroupList = listOf(mockk(), mockk(), mockk()) + coEvery { mockGetProgressingGroupUseCase() } returns testProgressingGroupList + coEvery { mockGetDoneGroupUseCase() } returns testDoneGroupList + + // When + viewModel.loadGroupList() + advanceUntilIdle() + + // Then + assertEquals( + testProgressingGroupList.size + testDoneGroupList.size + 2, + viewModel.groupList.value.size + ) + assertEquals(GroupViewItem.ProgressTitle, viewModel.groupList.value[0]) + for (i in testProgressingGroupList.indices) { + assertEquals( + GroupViewItem.Group(testProgressingGroupList[i]), + viewModel.groupList.value[i + 1] + ) + } + assertEquals( + GroupViewItem.DoneTitle, + viewModel.groupList.value[testProgressingGroupList.size + 1] + ) + for (i in testDoneGroupList.indices) { + assertEquals( + GroupViewItem.Group(testDoneGroupList[i]), + viewModel.groupList.value[testProgressingGroupList.size + i + 2] + ) + } + } +}