diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4138d5ba..f7e77992 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,4 +40,5 @@ dependencies { implementation(libs.kakao.user) implementation(projects.presentation) -} \ No newline at end of file + implementation(projects.core.data) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e76a7103..3c408875 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Piece" @@ -32,10 +33,12 @@ android:exported="true"> + - diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..85e7ad8e --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/build-logic/src/main/java/com/puzzle/build/logic/TestAndroid.kt b/build-logic/src/main/java/com/puzzle/build/logic/TestAndroid.kt index 58291218..43440561 100644 --- a/build-logic/src/main/java/com/puzzle/build/logic/TestAndroid.kt +++ b/build-logic/src/main/java/com/puzzle/build/logic/TestAndroid.kt @@ -15,6 +15,10 @@ internal fun Project.configureJUnitAndroid() { unitTests.all { it.useJUnitPlatform() } } + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + val libs = extensions.libs dependencies { "androidTestImplementation"(libs.findLibrary("androidx.test.ext").get()) diff --git a/core/common/.gitignore b/core/common/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 00000000..492e1cf0 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("piece.kotlin.library") + id("piece.kotlin.hilt") +} + +dependencies { + implementation(libs.coroutines.core) +} diff --git a/core/common/src/main/java/com/puzzle/common/TimeUtil.kt b/core/common/src/main/java/com/puzzle/common/TimeUtil.kt new file mode 100644 index 00000000..2df910d2 --- /dev/null +++ b/core/common/src/main/java/com/puzzle/common/TimeUtil.kt @@ -0,0 +1,18 @@ +package com.puzzle.common + +import java.time.LocalDateTime +import java.time.format.DateTimeParseException + +/** + * String?을 LocalDateTime으로 변환합니다. + * + * - 문자열이 null인 경우, [LocalDateTime.MIN]을 반환합니다. + * - 잘못된 형식으로 인해 파싱에 실패할 경우, [LocalDateTime.MIN]을 반환합니다. + */ +fun String?.parseDateTime(): LocalDateTime { + return try { + this?.let { LocalDateTime.parse(it) } ?: LocalDateTime.MIN + } catch (e: DateTimeParseException) { + LocalDateTime.MIN + } +} diff --git a/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt b/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt new file mode 100644 index 00000000..c7a765f4 --- /dev/null +++ b/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt @@ -0,0 +1,47 @@ +package com.puzzle.common + +import org.junit.Assert.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class TimeUtilTest { + + @Test + fun `올바른 형식의 문자열을 LocalDateTime으로 변환할 수 있다`() { + // given + val dateTimeString = "2024-06-01T00:00:00" + val expected = LocalDateTime.parse(dateTimeString) + + // when + val actual = dateTimeString.parseDateTime() + + // then + assertEquals(expected, actual) + } + + @Test + fun `null 값을 파싱하려고 할 경우 LocalDateTime_MIN을 반환한다`() { + // given + val nullString: String? = null + val expected = LocalDateTime.MIN + + // when + val actual = nullString.parseDateTime() + + // then + assertEquals(expected, actual) + } + + @Test + fun `형식에 맞지 않은 문자열을 파싱하려고 할 경우 LocalDateTime_MIN을 반환한다`() { + // given + val invalidString = "invalid-date-format" + val expected = LocalDateTime.MIN + + // when + val actual = invalidString.parseDateTime() + + // then + assertEquals(expected, actual) + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index f299343f..e903b3cc 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -10,4 +10,5 @@ android { dependencies { implementation(projects.core.domain) implementation(projects.core.network) -} \ No newline at end of file + implementation(projects.core.database) +} diff --git a/core/data/src/androidTest/java/com/puzzle/data/ExampleInstrumentedTest.kt b/core/data/src/androidTest/java/com/puzzle/data/ExampleInstrumentedTest.kt deleted file mode 100644 index 60180280..00000000 --- a/core/data/src/androidTest/java/com/puzzle/data/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.puzzle.data - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.puzzle.data.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/core/data/src/main/java/com/puzzle/data/di/DataModule.kt b/core/data/src/main/java/com/puzzle/data/di/DataModule.kt index c52cbaff..bba9932e 100644 --- a/core/data/src/main/java/com/puzzle/data/di/DataModule.kt +++ b/core/data/src/main/java/com/puzzle/data/di/DataModule.kt @@ -1,7 +1,9 @@ package com.puzzle.data.di import com.puzzle.data.repository.AuthRepositoryImpl +import com.puzzle.data.repository.TermsRepositoryImpl import com.puzzle.domain.repository.AuthRepository +import com.puzzle.domain.repository.TermsRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -18,4 +20,10 @@ abstract class DataModule { abstract fun bindsAuthRepository( authRepositoryImpl: AuthRepositoryImpl, ): AuthRepository -} \ No newline at end of file + + @Binds + @Singleton + abstract fun bindsTermsRepository( + termsRepositoryImpl: TermsRepositoryImpl, + ): TermsRepository +} diff --git a/core/data/src/main/java/com/puzzle/data/repository/TermsRepositoryImpl.kt b/core/data/src/main/java/com/puzzle/data/repository/TermsRepositoryImpl.kt new file mode 100644 index 00000000..225a74ed --- /dev/null +++ b/core/data/src/main/java/com/puzzle/data/repository/TermsRepositoryImpl.kt @@ -0,0 +1,38 @@ +package com.puzzle.data.repository + +import com.puzzle.database.model.terms.TermEntity +import com.puzzle.database.source.term.LocalTermDataSource +import com.puzzle.domain.model.terms.Term +import com.puzzle.domain.repository.TermsRepository +import com.puzzle.network.model.UNKNOWN_INT +import com.puzzle.network.source.TermDataSource +import javax.inject.Inject + +class TermsRepositoryImpl @Inject constructor( + private val termDataSource: TermDataSource, + private val localTermDataSource: LocalTermDataSource, +) : TermsRepository { + override suspend fun loadTerms(): Result = runCatching { + val terms = termDataSource.loadTerms() + .getOrThrow() + .toDomain() + .filter { it.termId != UNKNOWN_INT } + + val termsEntity = terms.map { + TermEntity( + id = it.termId, + title = it.title, + content = it.content, + required = it.required, + startDate = it.startDate, + ) + } + + localTermDataSource.clearAndInsertTerms(termsEntity) + } + + override suspend fun getTerms(): Result> = runCatching { + localTermDataSource.getTerms() + .map { it.toDomain() } + } +} diff --git a/core/data/src/test/java/com/puzzle/data/repository/TermsRepositoryImplTest.kt b/core/data/src/test/java/com/puzzle/data/repository/TermsRepositoryImplTest.kt new file mode 100644 index 00000000..35d9df38 --- /dev/null +++ b/core/data/src/test/java/com/puzzle/data/repository/TermsRepositoryImplTest.kt @@ -0,0 +1,106 @@ +package com.puzzle.data.repository + +import com.puzzle.database.source.term.LocalTermDataSource +import com.puzzle.network.model.UNKNOWN_INT +import com.puzzle.network.model.terms.LoadTermsResponse +import com.puzzle.network.model.terms.TermResponse +import com.puzzle.network.source.TermDataSource +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TermsRepositoryImplTest { + + private lateinit var termDataSource: TermDataSource + private lateinit var localTermDataSource: LocalTermDataSource + private lateinit var termsRepository: TermsRepositoryImpl + + @BeforeEach + fun setUp() { + termDataSource = mockk() + localTermDataSource = mockk() + termsRepository = TermsRepositoryImpl(termDataSource, localTermDataSource) + } + + @Test + fun `약관을 새로 갱신할 경우 id값이 올바르게 내려오지 않은 약관은 무시한다`() = runTest { + // given + val invalidTerm = TermResponse( + termId = UNKNOWN_INT, + title = "Invalid", + content = "Invalid Content", + required = false, + startDate = "2024-06-01T00:00:00", + ) + val validTerm = TermResponse( + termId = 1, + title = "Valid", + content = "Valid Content", + required = true, + startDate = "2024-06-01T00:00:00", + ) + + coEvery { termDataSource.loadTerms() } returns + Result.success(LoadTermsResponse(listOf(invalidTerm, validTerm))) + coEvery { localTermDataSource.clearAndInsertTerms(any()) } just Runs + + // when + val result = termsRepository.loadTerms() + + // then + assertTrue(result.isSuccess) + coVerify(exactly = 1) { + localTermDataSource.clearAndInsertTerms( + match { + it.size == 1 && it.first().id == validTerm.termId + } + ) + } + } + + @Test + fun `갱신한 데이터는 로컬 데이터베이스에 저장한다`() = runTest { + // given + val validTerms = listOf( + TermResponse( + termId = 1, + title = "Valid1", + content = "Content1", + required = true, + startDate = "2024-06-01T00:00:00" + ), + TermResponse( + termId = 2, + title = "Valid2", + content = "Content2", + required = false, + startDate = "2024-06-01T00:00:00" + ) + ) + + coEvery { termDataSource.loadTerms() } returns Result.success(LoadTermsResponse(validTerms)) + coEvery { localTermDataSource.clearAndInsertTerms(any()) } just Runs + + // when + termsRepository.loadTerms() + + // then + coVerify(exactly = 1) { + localTermDataSource.clearAndInsertTerms( + match { + it.size == validTerms.size && it.all { entity -> + validTerms.any { term -> + term.termId == entity.id && term.title == entity.title + } + } + } + ) + } + } +} diff --git a/core/database/.gitignore b/core/database/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/database/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 00000000..6a86ca5f --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("piece.android.library") + id("piece.android.hilt") +} + +android { + namespace = "com.puzzle.database" +} + +dependencies { + implementation(projects.core.domain) + implementation(projects.core.common) + + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) +} diff --git a/core/database/consumer-rules.pro b/core/database/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/database/proguard-rules.pro b/core/database/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/database/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/database/src/androidTest/java/com/puzzle/database/dao/TermsDaoTest.kt b/core/database/src/androidTest/java/com/puzzle/database/dao/TermsDaoTest.kt new file mode 100644 index 00000000..260fb6df --- /dev/null +++ b/core/database/src/androidTest/java/com/puzzle/database/dao/TermsDaoTest.kt @@ -0,0 +1,89 @@ +package com.puzzle.database.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.puzzle.common.parseDateTime +import com.puzzle.database.PieceDatabase +import com.puzzle.database.model.terms.TermEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TermsDaoTest { + private lateinit var termsDao: TermsDao + private lateinit var db: PieceDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder( + context = context, + klass = PieceDatabase::class.java + ).build() + termsDao = db.termsDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun 약관을_삽입하고_조회할_수_있다() = runTest { + // given + val expected = listOf( + TermEntity(1, "이용약관", "내용1", true, "2024-06-01T00:00:00".parseDateTime()), + TermEntity(2, "개인정보처리방침", "내용2", false, "2024-06-01T00:00:00".parseDateTime()) + ) + + // when + termsDao.insertTerms(*expected.toTypedArray()) + val actual = termsDao.getTerms() + + // then + assertEquals(expected, actual) + } + + @Test + fun 약관을_모두_삭제할_수_있다() = runTest { + // given + val terms = listOf( + TermEntity(1, "이용약관", "내용1", true, "2024-06-01T00:00:00".parseDateTime()), + TermEntity(2, "개인정보처리방침", "내용2", false, "2024-06-01T00:00:00".parseDateTime()) + ) + termsDao.insertTerms(*terms.toTypedArray()) + + // when + termsDao.clearTerms() + val actual = termsDao.getTerms() + + // then + val expected = emptyList() + assertEquals(expected, actual) + } + + @Test + fun 이전_약관을_삭제하고_새로운_약관을_삽입할_수_있다() = runTest { + // given + val oldTerms = listOf( + TermEntity(1, "이전 약관", "이전 내용", true, "2024-06-01T00:00:00".parseDateTime()) + ) + termsDao.insertTerms(*oldTerms.toTypedArray()) + + // when + val expected = listOf( + TermEntity(2, "새로운 약관", "새로운 내용", false, "2024-06-01T00:00:00".parseDateTime()) + ) + termsDao.clearAndInsertTerms(*expected.toTypedArray()) + val actual = termsDao.getTerms() + + // then + assertEquals(expected, actual) + } +} diff --git a/core/database/src/main/AndroidManifest.xml b/core/database/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/database/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/database/src/main/java/com/puzzle/database/PieceDatabase.kt b/core/database/src/main/java/com/puzzle/database/PieceDatabase.kt new file mode 100644 index 00000000..52685244 --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/PieceDatabase.kt @@ -0,0 +1,21 @@ +package com.puzzle.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.puzzle.database.converter.PieceConverters +import com.puzzle.database.dao.TermsDao +import com.puzzle.database.model.terms.TermEntity + +@Database( + entities = [TermEntity::class], + version = 1, +) +@TypeConverters(PieceConverters::class) +internal abstract class PieceDatabase : RoomDatabase() { + abstract fun termsDao(): TermsDao + + companion object { + internal const val NAME = "piece-database" + } +} diff --git a/core/database/src/main/java/com/puzzle/database/converter/PieceConverters.kt b/core/database/src/main/java/com/puzzle/database/converter/PieceConverters.kt new file mode 100644 index 00000000..5cb2f1df --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/converter/PieceConverters.kt @@ -0,0 +1,17 @@ +package com.puzzle.database.converter + +import androidx.room.TypeConverter +import com.puzzle.common.parseDateTime +import java.time.LocalDateTime + +class PieceConverters { + @TypeConverter + fun fromLocalDate(date: LocalDateTime?): String? { + return date?.toString() + } + + @TypeConverter + fun toLocalDate(dateString: String?): LocalDateTime? { + return dateString?.parseDateTime() + } +} diff --git a/core/database/src/main/java/com/puzzle/database/dao/TermsDao.kt b/core/database/src/main/java/com/puzzle/database/dao/TermsDao.kt new file mode 100644 index 00000000..abed2014 --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/dao/TermsDao.kt @@ -0,0 +1,26 @@ +package com.puzzle.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.puzzle.database.model.terms.TermEntity + +@Dao +interface TermsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTerms(vararg terms: TermEntity) + + @Query(value = "SELECT * FROM term") + suspend fun getTerms(): List + + @Query(value = "DELETE FROM term") + suspend fun clearTerms() + + @Transaction + suspend fun clearAndInsertTerms(vararg terms: TermEntity) { + clearTerms() + insertTerms(*terms) + } +} diff --git a/core/database/src/main/java/com/puzzle/database/di/DaosModule.kt b/core/database/src/main/java/com/puzzle/database/di/DaosModule.kt new file mode 100644 index 00000000..53c31113 --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/di/DaosModule.kt @@ -0,0 +1,17 @@ +package com.puzzle.database.di + +import com.puzzle.database.PieceDatabase +import com.puzzle.database.dao.TermsDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object DaosModule { + @Provides + fun providesTermsDao( + database: PieceDatabase, + ): TermsDao = database.termsDao() +} diff --git a/core/database/src/main/java/com/puzzle/database/di/DatabaseModule.kt b/core/database/src/main/java/com/puzzle/database/di/DatabaseModule.kt new file mode 100644 index 00000000..457905c2 --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/di/DatabaseModule.kt @@ -0,0 +1,25 @@ +package com.puzzle.database.di + +import android.content.Context +import androidx.room.Room +import com.puzzle.database.PieceDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DatabaseModule { + @Provides + @Singleton + fun providesPieceDatabase( + @ApplicationContext context: Context, + ): PieceDatabase = Room.databaseBuilder( + context, + PieceDatabase::class.java, + PieceDatabase.NAME, + ).build() +} diff --git a/core/database/src/main/java/com/puzzle/database/model/terms/TermEntity.kt b/core/database/src/main/java/com/puzzle/database/model/terms/TermEntity.kt new file mode 100644 index 00000000..89182334 --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/model/terms/TermEntity.kt @@ -0,0 +1,25 @@ +package com.puzzle.database.model.terms + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.puzzle.domain.model.terms.Term +import java.time.LocalDateTime + +@Entity(tableName = "term") +data class TermEntity( + @PrimaryKey + @ColumnInfo(name = "id") val id: Int, + val title: String, + val content: String, + val required: Boolean, + @ColumnInfo(name = "start_date") val startDate: LocalDateTime, +) { + fun toDomain() = Term( + termId = id, + title = title, + content = content, + required = required, + startDate = startDate, + ) +} diff --git a/core/database/src/main/java/com/puzzle/database/source/term/LocalTermDataSource.kt b/core/database/src/main/java/com/puzzle/database/source/term/LocalTermDataSource.kt new file mode 100644 index 00000000..3c9da99e --- /dev/null +++ b/core/database/src/main/java/com/puzzle/database/source/term/LocalTermDataSource.kt @@ -0,0 +1,15 @@ +package com.puzzle.database.source.term + +import com.puzzle.database.dao.TermsDao +import com.puzzle.database.model.terms.TermEntity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalTermDataSource @Inject constructor( + private val termsDao: TermsDao, +) { + suspend fun getTerms() = termsDao.getTerms() + suspend fun clearAndInsertTerms(terms: List) = + termsDao.clearAndInsertTerms(*terms.toTypedArray()) +} diff --git a/core/data/src/test/java/com/puzzle/data/ExampleUnitTest.kt b/core/database/src/test/java/com/puzzle/database/ExampleUnitTest.kt similarity index 91% rename from core/data/src/test/java/com/puzzle/data/ExampleUnitTest.kt rename to core/database/src/test/java/com/puzzle/database/ExampleUnitTest.kt index bd567504..b15a51f3 100644 --- a/core/data/src/test/java/com/puzzle/data/ExampleUnitTest.kt +++ b/core/database/src/test/java/com/puzzle/database/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.puzzle.data +package com.puzzle.database import org.junit.Test diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Check.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Check.kt new file mode 100644 index 00000000..aff087b5 --- /dev/null +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Check.kt @@ -0,0 +1,138 @@ +package com.puzzle.designsystem.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.puzzle.designsystem.R +import com.puzzle.designsystem.foundation.PieceTheme + +@Composable +fun PieceCheck( + checked: Boolean, + onCheckedChange: () -> Unit, + modifier: Modifier = Modifier, +) { + Image( + painter = painterResource(R.drawable.ic_check), + contentDescription = "", + colorFilter = ColorFilter.tint( + color = if (checked) PieceTheme.colors.primaryDefault + else PieceTheme.colors.light1, + ), + modifier = modifier + .size(20.dp) + .clickable { onCheckedChange() }, + ) +} + +@Composable +fun PieceCheckList( + checked: Boolean, + label: String, + onCheckedChange: () -> Unit, + modifier: Modifier = Modifier, + arrowEnabled: Boolean = false, + containerColor: Color = PieceTheme.colors.white, + onArrowClick: () -> Unit = {}, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(52.dp) + .clip(RoundedCornerShape(8.dp)) + .background(containerColor), + ) { + PieceCheck( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.padding(start = 14.dp, end = 12.dp), + ) + + Text( + text = label, + style = PieceTheme.typography.bodyMR, + color = PieceTheme.colors.black, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + + if (arrowEnabled) { + Image( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = "", + modifier = Modifier + .padding(end = 14.dp) + .size(24.dp) + .clickable { onArrowClick() }, + ) + } + } +} + +@Preview +@Composable +fun PreviewPieceCheck() { + PieceTheme { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PieceCheck( + checked = true, + onCheckedChange = {}, + ) + + PieceCheck( + checked = false, + onCheckedChange = {}, + ) + } + } +} + +@Preview +@Composable +fun PreviewPieceCheckList() { + PieceTheme { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .background(PieceTheme.colors.black) + .padding(20.dp), + ) { + PieceCheckList( + checked = true, + label = "약관 전체 동의", + onCheckedChange = {}, + containerColor = PieceTheme.colors.light3, + modifier = Modifier.fillMaxWidth(), + ) + + PieceCheckList( + checked = false, + arrowEnabled = true, + label = "약관 전체 동의", + onCheckedChange = {}, + onArrowClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt index 6588ede2..5305d2cf 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt @@ -102,7 +102,7 @@ fun PieceSubCloseTopBar( title: String, onCloseClick: () -> Unit, modifier: Modifier = Modifier, - showCloseButton: Boolean = true, + closeButtonEnabled: Boolean = true, contentColor: Color = PieceTheme.colors.black, ) { Box(modifier = modifier.fillMaxWidth()) { @@ -113,7 +113,7 @@ fun PieceSubCloseTopBar( modifier = Modifier.align(Alignment.Center), ) - if (showCloseButton) { + if (closeButtonEnabled) { Image( painter = painterResource(R.drawable.ic_close), contentDescription = "닫기 버튼", diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Typography.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Typography.kt index 2fd62025..ff976358 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Typography.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Typography.kt @@ -29,6 +29,13 @@ private val PretendardMedium = FontFamily( ), ) +private val PretendardRegular = FontFamily( + Font( + resId = R.font.pretendard_medium, + weight = FontWeight.Normal, + ), +) + @Immutable data class PieceTypography( val headingXLSB: TextStyle = TextStyle( @@ -67,7 +74,7 @@ data class PieceTypography( lineHeight = 24.sp, ), val bodyMR: TextStyle = TextStyle( - fontFamily = PretendardMedium, + fontFamily = PretendardRegular, fontSize = 16.sp, lineHeight = 24.sp, ), diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_right.xml b/core/designsystem/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 00000000..c82291c8 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_check.xml b/core/designsystem/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..29c39a1d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/domain/src/main/java/com/puzzle/domain/model/terms/Term.kt b/core/domain/src/main/java/com/puzzle/domain/model/terms/Term.kt new file mode 100644 index 00000000..e0896524 --- /dev/null +++ b/core/domain/src/main/java/com/puzzle/domain/model/terms/Term.kt @@ -0,0 +1,11 @@ +package com.puzzle.domain.model.terms + +import java.time.LocalDateTime + +data class Term( + val termId: Int, + val title: String, + val content: String, + val required: Boolean, + val startDate: LocalDateTime, +) diff --git a/core/domain/src/main/java/com/puzzle/domain/repository/TermsRepository.kt b/core/domain/src/main/java/com/puzzle/domain/repository/TermsRepository.kt new file mode 100644 index 00000000..4649741e --- /dev/null +++ b/core/domain/src/main/java/com/puzzle/domain/repository/TermsRepository.kt @@ -0,0 +1,8 @@ +package com.puzzle.domain.repository + +import com.puzzle.domain.model.terms.Term + +interface TermsRepository { + suspend fun loadTerms(): Result + suspend fun getTerms(): Result> +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 5955643c..01868a59 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("piece.android.library") id("piece.android.hilt") @@ -6,19 +8,22 @@ plugins { android { namespace = "com.puzzle.network" + val localProperties = Properties() + localProperties.load(project.rootProject.file("local.properties").bufferedReader()) + buildTypes { debug { buildConfigField( "String", "PIECE_BASE_URL", - "\"${properties["PIECE_DEV_BASE_URL"]}\"", + "\"${localProperties["PIECE_DEV_BASE_URL"]}\"", ) } release { buildConfigField( "String", "PIECE_BASE_URL", - "\"${properties["PIECE_PROD_BASE_URL"]}\"", + "\"${localProperties["PIECE_PROD_BASE_URL"]}\"", ) } } @@ -30,6 +35,7 @@ android { dependencies { implementation(projects.core.domain) + implementation(projects.core.common) implementation(libs.retrofit.core) implementation(libs.retrofit.kotlin.serialization) diff --git a/core/network/src/main/java/com/puzzle/network/adapter/PieceCallAdapter.kt b/core/network/src/main/java/com/puzzle/network/adapter/PieceCallAdapter.kt index 65b8c290..2d42936b 100644 --- a/core/network/src/main/java/com/puzzle/network/adapter/PieceCallAdapter.kt +++ b/core/network/src/main/java/com/puzzle/network/adapter/PieceCallAdapter.kt @@ -18,7 +18,7 @@ import javax.inject.Singleton class PieceCallAdapterFactory @Inject constructor() : CallAdapter.Factory() { override fun get( type: Type, - annotations: Array, + annotations: Array, retrofit: Retrofit ): CallAdapter<*, *>? { // 반환 타입의 최상위 객체가 Result 객체인지 확인, 아닐 경우 null 반환 @@ -68,13 +68,13 @@ private class PieceCall( return Result.failure( HttpResponseException( status = HttpResponseStatus.create(code()), - msg = "", // Todo + msg = "일시적인 서버 에러입니다. 계속해서 반복될 경우 문의 하기를 이용해주세요.", ) ) } ?: return Result.failure( HttpResponseException( status = HttpResponseStatus.create(-1), - msg = "알 수 없는 에러입니다." + msg = "일시적인 서버 에러입니다. 계속해서 반복될 경우 문의 하기를 이용해주세요." ) ) } @@ -91,4 +91,4 @@ private class PieceCall( override fun request(): Request = delegate.request() override fun timeout(): Timeout = delegate.timeout() override fun cancel() = delegate.cancel() -} \ No newline at end of file +} diff --git a/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt b/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt index 691c4f21..58ff540b 100644 --- a/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt +++ b/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt @@ -1,21 +1,26 @@ package com.puzzle.network.api +import com.puzzle.network.model.ApiResponse import com.puzzle.network.model.auth.LoginOauthRequest import com.puzzle.network.model.auth.LoginOauthResponse import com.puzzle.network.model.auth.RequestAuthCodeRequest import com.puzzle.network.model.auth.VerifyAuthCodeRequest import com.puzzle.network.model.auth.VerifyAuthCodeResponse -import retrofit2.Response +import com.puzzle.network.model.terms.LoadTermsResponse import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.POST interface PieceApi { @POST("/api/login/oauth") - suspend fun loginOauth(@Body loginOauthRequest: LoginOauthRequest): Result + suspend fun loginOauth(@Body loginOauthRequest: LoginOauthRequest): Result> @POST("/api/register/sms/auth/code") - suspend fun requestAuthCode(@Body requestAuthCodeRequest: RequestAuthCodeRequest): Result + suspend fun requestAuthCode(@Body requestAuthCodeRequest: RequestAuthCodeRequest): Result> @POST("/api/register/sms/auth/code/verify") - suspend fun verifyAuthCode(@Body verifyAuthCodeRequest: VerifyAuthCodeRequest): Result + suspend fun verifyAuthCode(@Body verifyAuthCodeRequest: VerifyAuthCodeRequest): Result> + + @GET("/api/terms") + suspend fun loadTerms(): Result> } diff --git a/core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt b/core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt index 8833b9f8..e32c9e95 100644 --- a/core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt +++ b/core/network/src/main/java/com/puzzle/network/di/RetrofitModule.kt @@ -19,7 +19,9 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object RetrofitModule { - private val json = Json { + @Singleton + @Provides + fun provideJson(): Json = Json { ignoreUnknownKeys = true } @@ -39,7 +41,8 @@ object RetrofitModule { @Singleton @Provides - fun providesAuthApi( + fun providesPieceApi( + json: Json, okHttpClient: OkHttpClient, callAdapterFactory: PieceCallAdapterFactory, ): PieceApi = Retrofit.Builder() diff --git a/core/network/src/main/java/com/puzzle/network/model/ApiResponse.kt b/core/network/src/main/java/com/puzzle/network/model/ApiResponse.kt new file mode 100644 index 00000000..5654c8e3 --- /dev/null +++ b/core/network/src/main/java/com/puzzle/network/model/ApiResponse.kt @@ -0,0 +1,26 @@ +package com.puzzle.network.model + +import com.puzzle.domain.model.error.HttpResponseException +import com.puzzle.domain.model.error.HttpResponseStatus +import kotlinx.serialization.Serializable + +@Serializable +data class ApiResponse( + val status: String?, + val message: String?, + val data: T?, +) + +internal fun Result>.unwrapData(): Result { + return this.map { response -> + response.data ?: return Result.failure( + HttpResponseException( + status = HttpResponseStatus.InternalError, + msg = "일시적인 서버 에러입니다. 계속해서 반복될 경우 문의 하기를 이용해주세요.", + ) + ) + } +} + +const val UNKNOWN_INT = -1 +const val UNKNOWN_STRING = "UNKNOWN" diff --git a/core/network/src/main/java/com/puzzle/network/model/auth/LoginOauthResponse.kt b/core/network/src/main/java/com/puzzle/network/model/auth/LoginOauthResponse.kt index 90161c89..843d8fb4 100644 --- a/core/network/src/main/java/com/puzzle/network/model/auth/LoginOauthResponse.kt +++ b/core/network/src/main/java/com/puzzle/network/model/auth/LoginOauthResponse.kt @@ -4,15 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class LoginOauthResponse( - val status: String?, - val message: String?, - val data: Data?, -) { - @Serializable - data class Data( - val smsVerified: Boolean?, - val registerCompleted: Boolean?, - val accessToken: String?, - val refreshToken: String?, - ) -} + val smsVerified: Boolean?, + val registerCompleted: Boolean?, + val accessToken: String?, + val refreshToken: String?, +) diff --git a/core/network/src/main/java/com/puzzle/network/model/auth/RequestAuthCodeResponse.kt b/core/network/src/main/java/com/puzzle/network/model/auth/RequestAuthCodeResponse.kt deleted file mode 100644 index c608ef15..00000000 --- a/core/network/src/main/java/com/puzzle/network/model/auth/RequestAuthCodeResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.puzzle.network.model.auth - -import kotlinx.serialization.Serializable - -@Serializable -data class RequestAuthCodeResponse( - val status: String?, - val message: String?, - val data: String?, -) diff --git a/core/network/src/main/java/com/puzzle/network/model/auth/VerifyAuthCodeResponse.kt b/core/network/src/main/java/com/puzzle/network/model/auth/VerifyAuthCodeResponse.kt index 49b3d287..2acf64d6 100644 --- a/core/network/src/main/java/com/puzzle/network/model/auth/VerifyAuthCodeResponse.kt +++ b/core/network/src/main/java/com/puzzle/network/model/auth/VerifyAuthCodeResponse.kt @@ -4,15 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class VerifyAuthCodeResponse( - val status: String?, - val message: String?, - val data: Data?, -) { - @Serializable - data class Data( - val smsVerified: Boolean?, - val registerCompleted: Boolean?, - val accessToken: String?, - val refreshToken: String?, - ) -} + val smsVerified: Boolean?, + val registerCompleted: Boolean?, + val accessToken: String?, + val refreshToken: String?, +) diff --git a/core/network/src/main/java/com/puzzle/network/model/terms/LoadTermsResponse.kt b/core/network/src/main/java/com/puzzle/network/model/terms/LoadTermsResponse.kt new file mode 100644 index 00000000..e2b6cfa4 --- /dev/null +++ b/core/network/src/main/java/com/puzzle/network/model/terms/LoadTermsResponse.kt @@ -0,0 +1,32 @@ +package com.puzzle.network.model.terms + +import com.puzzle.common.parseDateTime +import com.puzzle.domain.model.terms.Term +import com.puzzle.network.model.UNKNOWN_INT +import com.puzzle.network.model.UNKNOWN_STRING +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +data class LoadTermsResponse( + val responses: List?, +) { + fun toDomain() = responses?.map { it.toDomain() } ?: emptyList() +} + +@Serializable +data class TermResponse( + val termId: Int?, + val title: String?, + val content: String?, + val required: Boolean?, + val startDate: String?, +) { + fun toDomain(): Term = Term( + termId = termId ?: UNKNOWN_INT, + title = title ?: UNKNOWN_STRING, + content = content ?: UNKNOWN_STRING, + required = required ?: false, + startDate = startDate?.parseDateTime() ?: LocalDateTime.MIN + ) +} diff --git a/core/network/src/main/java/com/puzzle/network/source/AuthDataSource.kt b/core/network/src/main/java/com/puzzle/network/source/AuthDataSource.kt index e79faa6b..fb402bc7 100644 --- a/core/network/src/main/java/com/puzzle/network/source/AuthDataSource.kt +++ b/core/network/src/main/java/com/puzzle/network/source/AuthDataSource.kt @@ -7,6 +7,7 @@ import com.puzzle.network.model.auth.LoginOauthResponse import com.puzzle.network.model.auth.RequestAuthCodeRequest import com.puzzle.network.model.auth.VerifyAuthCodeRequest import com.puzzle.network.model.auth.VerifyAuthCodeResponse +import com.puzzle.network.model.unwrapData import javax.inject.Inject import javax.inject.Singleton @@ -20,10 +21,11 @@ class AuthDataSource @Inject constructor( providerName = provider.apiValue, token = token, ) - ) + ).unwrapData() - suspend fun requestAuthCode(phoneNumber: String): Result = + suspend fun requestAuthCode(phoneNumber: String): Result = pieceApi.requestAuthCode(RequestAuthCodeRequest(phoneNumber)) + .unwrapData() suspend fun verifyAuthCode(phoneNumber: String, code: String): Result = pieceApi.verifyAuthCode( @@ -31,5 +33,5 @@ class AuthDataSource @Inject constructor( phoneNumber = phoneNumber, code = code, ) - ) + ).unwrapData() } diff --git a/core/network/src/main/java/com/puzzle/network/source/TermDataSource.kt b/core/network/src/main/java/com/puzzle/network/source/TermDataSource.kt new file mode 100644 index 00000000..1f9b78ec --- /dev/null +++ b/core/network/src/main/java/com/puzzle/network/source/TermDataSource.kt @@ -0,0 +1,14 @@ +package com.puzzle.network.source + +import com.puzzle.network.api.PieceApi +import com.puzzle.network.model.unwrapData +import com.puzzle.network.model.terms.LoadTermsResponse +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TermDataSource @Inject constructor( + private val pieceApi: PieceApi, +) { + suspend fun loadTerms(): Result = pieceApi.loadTerms().unwrapData() +} diff --git a/feature/auth/src/androidTest/java/com/puzzle/auth/ExampleInstrumentedTest.kt b/feature/auth/src/androidTest/java/com/puzzle/auth/ExampleInstrumentedTest.kt deleted file mode 100644 index 95428875..00000000 --- a/feature/auth/src/androidTest/java/com/puzzle/auth/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.puzzle.auth - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.auth.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationDetailScreen.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationDetailScreen.kt new file mode 100644 index 00000000..408dd8ce --- /dev/null +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationDetailScreen.kt @@ -0,0 +1,8 @@ +package com.puzzle.auth.graph.registration + +import androidx.compose.runtime.Composable + +@Composable +internal fun RegistrationDetailScreen() { + +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationScreen.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationScreen.kt index 6226dfc6..23015919 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationScreen.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationScreen.kt @@ -1,11 +1,29 @@ package com.puzzle.auth.graph.registration import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel +import com.puzzle.auth.graph.registration.contract.RegistrationIntent +import com.puzzle.auth.graph.registration.contract.RegistrationSideEffect import com.puzzle.auth.graph.registration.contract.RegistrationState +import com.puzzle.designsystem.component.PieceCheckList +import com.puzzle.designsystem.component.PieceSolidButton +import com.puzzle.designsystem.component.PieceSubBackTopBar +import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.navigation.NavigationEvent @Composable internal fun RegistrationRoute( @@ -15,14 +33,84 @@ internal fun RegistrationRoute( RegistrationScreen( state = state, + checkAllTerms = { viewModel.onIntent(RegistrationIntent.CheckAllTerms) }, + checkTerm = { viewModel.onIntent(RegistrationIntent.CheckTerm(it)) }, + navigate = { event -> viewModel.onSideEffect(RegistrationSideEffect.Navigate(event)) } ) } @Composable private fun RegistrationScreen( state: RegistrationState, + checkAllTerms: () -> Unit, + checkTerm: (Int) -> Unit, + navigate: (NavigationEvent) -> Unit, ) { - Column() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + PieceSubBackTopBar( + title = "", + onBackClick = { navigate(NavigationEvent.NavigateUp) }, + modifier = Modifier.height(60.dp), + ) + Text( + text = buildAnnotatedString { + append("Piece의\n") + + withStyle(style = SpanStyle(color = PieceTheme.colors.primaryDefault)) { + append("이용약관") + } + + append("을 확인해 주세요") + }, + style = PieceTheme.typography.headingLSB, + color = PieceTheme.colors.black, + modifier = Modifier.padding(top = 20.dp), + ) + + Spacer( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + PieceCheckList( + checked = state.allTermsAgreed, + label = "약관 전체 동의", + containerColor = PieceTheme.colors.light3, + onCheckedChange = checkAllTerms, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) + + state.terms.forEach { termInfo -> + PieceCheckList( + checked = state.termsCheckedInfo.getOrDefault(termInfo.termId, false), + arrowEnabled = true, + label = termInfo.title, + onCheckedChange = { checkTerm(termInfo.termId) }, + onArrowClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer( + modifier = Modifier + .fillMaxWidth() + .weight(2f), + ) + + PieceSolidButton( + label = "다음", + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + ) } -} \ No newline at end of file +} diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationViewModel.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationViewModel.kt index 2bae953a..8579cf4b 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationViewModel.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/RegistrationViewModel.kt @@ -4,17 +4,94 @@ import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.hilt.AssistedViewModelFactory import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory +import com.puzzle.auth.graph.registration.contract.RegistrationIntent +import com.puzzle.auth.graph.registration.contract.RegistrationSideEffect +import com.puzzle.auth.graph.registration.contract.RegistrationSideEffect.Navigate import com.puzzle.auth.graph.registration.contract.RegistrationState +import com.puzzle.domain.model.error.ErrorHelper +import com.puzzle.domain.repository.TermsRepository import com.puzzle.navigation.NavigationHelper import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch class RegistrationViewModel @AssistedInject constructor( @Assisted initialState: RegistrationState, + private val termsRepository: TermsRepository, private val navigationHelper: NavigationHelper, + private val errorHelper: ErrorHelper, ) : MavericksViewModel(initialState) { + private val intents = Channel(BUFFERED) + private val sideEffects = Channel(BUFFERED) + + init { + fetchTerms() + + intents.receiveAsFlow() + .onEach(::processIntent) + .launchIn(viewModelScope) + + sideEffects.receiveAsFlow() + .onEach(::handleSideEffect) + .launchIn(viewModelScope) + } + + internal fun onIntent(intent: RegistrationIntent) = viewModelScope.launch { + intents.send(intent) + } + + internal fun onSideEffect(sideEffect: RegistrationSideEffect) = viewModelScope.launch { + sideEffects.send(sideEffect) + } + + private fun processIntent(intent: RegistrationIntent) { + when (intent) { + is RegistrationIntent.CheckTerm -> checkTerm(intent.termId) + is RegistrationIntent.CheckAllTerms -> checkAllTerms() + } + } + + private fun handleSideEffect(sideEffect: RegistrationSideEffect) { + when (sideEffect) { + is Navigate -> navigationHelper.navigate(sideEffect.navigationEvent) + } + } + + private fun fetchTerms() = viewModelScope.launch { + termsRepository.getTerms().onSuccess { + setState { copy(terms = it) } + }.onFailure { errorHelper.sendError(it) } + } + + private fun checkTerm(termId: Int) = setState { + val updatedTermsCheckedInfo = termsCheckedInfo.toMutableMap().apply { + this[termId] = !(this[termId] ?: false) + } + + copy(termsCheckedInfo = updatedTermsCheckedInfo) + } + + private fun checkAllTerms() = setState { + if (allTermsAgreed) { + copy(termsCheckedInfo = mutableMapOf()) + } else { + val updatedTermsCheckedInfo = termsCheckedInfo.toMutableMap() + + terms.forEach { termInfo -> + updatedTermsCheckedInfo[termInfo.termId] = true + } + + copy(termsCheckedInfo = updatedTermsCheckedInfo) + } + } + @AssistedFactory interface Factory : AssistedViewModelFactory { override fun create(state: RegistrationState): RegistrationViewModel diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationIntent.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationIntent.kt index 72a182d3..bdaf85d0 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationIntent.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationIntent.kt @@ -1,3 +1,6 @@ package com.puzzle.auth.graph.registration.contract -sealed class RegistrationIntent \ No newline at end of file +sealed class RegistrationIntent { + data object CheckAllTerms : RegistrationIntent() + data class CheckTerm(val termId: Int) : RegistrationIntent() +} diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationSideEffect.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationSideEffect.kt index 286396f9..cad6aa7f 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationSideEffect.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationSideEffect.kt @@ -1,3 +1,7 @@ package com.puzzle.auth.graph.registration.contract -sealed class RegistrationSideEffect \ No newline at end of file +import com.puzzle.navigation.NavigationEvent + +sealed class RegistrationSideEffect { + data class Navigate(val navigationEvent: NavigationEvent) : RegistrationSideEffect() +} diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationState.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationState.kt index ec85d52d..2dc7c451 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationState.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/registration/contract/RegistrationState.kt @@ -1,7 +1,11 @@ package com.puzzle.auth.graph.registration.contract import com.airbnb.mvrx.MavericksState +import com.puzzle.domain.model.terms.Term data class RegistrationState( - val a: Boolean = false, -) : MavericksState \ No newline at end of file + val terms: List = emptyList(), + val termsCheckedInfo: MutableMap = mutableMapOf(), +) : MavericksState { + val allTermsAgreed = terms.all { termsCheckedInfo.getOrDefault(it.termId, false) } +} diff --git a/feature/auth/src/test/java/com/puzzle/auth/ExampleUnitTest.kt b/feature/auth/src/test/java/com/puzzle/auth/ExampleUnitTest.kt deleted file mode 100644 index 1214d72a..00000000 --- a/feature/auth/src/test/java/com/puzzle/auth/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.puzzle.auth - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/feature/matching/src/androidTest/java/com/puzzle/matching/ExampleInstrumentedTest.kt b/feature/matching/src/androidTest/java/com/puzzle/matching/ExampleInstrumentedTest.kt deleted file mode 100644 index 431e3e19..00000000 --- a/feature/matching/src/androidTest/java/com/puzzle/matching/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.puzzle.matching - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.matching.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailRoute.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailRoute.kt index ff849695..5036f469 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailRoute.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailRoute.kt @@ -178,7 +178,7 @@ private fun MatchingDetailScreen( PieceSubCloseTopBar( title = state.currentPage.title, onCloseClick = onCloseClick, - showCloseButton = !(showDialog && dialogType == DialogType.PROFILE_IMAGE_DETAIL), + closeButtonEnabled = !(showDialog && dialogType == DialogType.PROFILE_IMAGE_DETAIL), modifier = Modifier .fillMaxWidth() .height(topBarHeight) diff --git a/feature/mypage/src/androidTest/java/com/puzzle/mypage/ExampleInstrumentedTest.kt b/feature/mypage/src/androidTest/java/com/puzzle/mypage/ExampleInstrumentedTest.kt deleted file mode 100644 index 5c8e1c43..00000000 --- a/feature/mypage/src/androidTest/java/com/puzzle/mypage/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.puzzle.mypage - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.mypage.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/feature/setting/src/androidTest/java/com/puzzle/setting/ExampleInstrumentedTest.kt b/feature/setting/src/androidTest/java/com/puzzle/setting/ExampleInstrumentedTest.kt deleted file mode 100644 index 8d6f6fc1..00000000 --- a/feature/setting/src/androidTest/java/com/puzzle/setting/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.puzzle.setting - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.etc.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index aec575af..0c81f9fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5514cf41..e4cd6575 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,25 +19,21 @@ androidxNavigation = "2.8.3" androidxFragment = "1.8.4" # https://developer.android.com/jetpack/androidx/releases/constraintlayout androidxConstraintlayout = "2.1.4" -# https://developer.android.com/jetpack/androidx/releases/core\ +# https://developer.android.com/jetpack/androidx/releases/core androidxSplashscreen = "1.0.1" - -## Compose +# https://developer.android.com/training/data-storage/room +androidxRoom = "2.6.1" # https://developer.android.com/develop/ui/compose/bom/bom-mapping androidxComposeBom = "2024.12.01" # https://developer.android.com/jetpack/androidx/releases/navigation androidxComposeNavigation = "2.8.4" -## Amplitude -# https://amplitude.com/docs/sdks/analytics/android -amplitudeAnalytics = "1.16.8" - ## Material material = "1.12.0" ## Kotlin Symbol Processing # https://github.com/google/ksp/ -ksp = "2.0.0-1.0.22" +ksp = "2.1.0-1.0.29" ## Hilt # https://github.com/google/dagger/releases @@ -55,29 +51,24 @@ retrofit = "2.11.0" ## Kotlin # https://github.com/JetBrains/kotlin -kotlin = "2.0.0" +kotlin = "2.1.0" # https://github.com/Kotlin/kotlinx.serialization kotlinxSerializationJson = "1.7.0" - -## Coroutine # https://github.com/Kotlin/kotlinx.coroutines -coroutine = "1.9.0-RC" +kotlinxCoroutine = "1.9.0-RC" ## Test # https://github.com/junit-team/junit4 junit4 = "4.13.2" junitJupiter = "5.8.2" - -# https://developer.android.com/jetpack/androidx/releases/test -androidxTestRunner = "1.6.2" -androidxTestExt = "1.2.1" -androidxEspresso = "3.6.1" - # https://mockk.io/ mockk = "1.13.11" - # https://github.com/pinterest/ktlint ktlint = "12.1.1" +# https://developer.android.com/jetpack/androidx/releases/test +androidxTestRunner = "1.6.2" +androidxTestExt = "1.2.1" +androidxEspresso = "3.6.1" ## firebase googleServices = "4.4.2" @@ -87,18 +78,18 @@ messaging = "24.1.0" # https://coil-kt.github.io/coil/#jetpack-compose coil = "2.7.0" - # https://airbnb.io/lottie/#/android-compose lottie-compose = "6.5.2" - +# https://github.com/skydoves/Cloudy +cloudy = "0.2.4" # https://developers.kakao.com/docs/latest/ko/android/getting-started#apply-sdk kakao = "2.20.6" - # https://airbnb.io/mavericks/#/setup mavericks = "3.0.9" -# https://github.com/skydoves/Cloudy -cloudy = "0.2.4" +## Amplitude +# https://amplitude.com/docs/sdks/analytics/android +amplitudeAnalytics = "1.16.8" [libraries] android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -127,6 +118,13 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" } compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidxRoom" } + +coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutine" } +coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutine" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutine" } + material = { group = "com.google.android.material", name = "material", version.ref = "material" } hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } @@ -153,10 +151,6 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } mockk-agent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockk" } -coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } -coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutine" } -coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } - firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } diff --git a/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt b/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt index 63e18a98..f7a06df4 100644 --- a/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt +++ b/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.puzzle.common.event.EventHelper import com.puzzle.domain.model.error.ErrorHelper import com.puzzle.domain.model.error.HttpResponseException +import com.puzzle.domain.repository.TermsRepository import com.puzzle.navigation.NavigationHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -12,6 +13,7 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( + private val termsRepository: TermsRepository, internal val navigationHelper: NavigationHelper, internal val eventHelper: EventHelper, private val errorHelper: ErrorHelper, @@ -19,6 +21,7 @@ class MainViewModel @Inject constructor( init { handleError() + loadTerms() } private fun handleError() = viewModelScope.launch { @@ -33,4 +36,10 @@ class MainViewModel @Inject constructor( } } } + + private fun loadTerms() = viewModelScope.launch { + termsRepository.loadTerms().onFailure { + errorHelper.sendError(it) + } + } } diff --git a/presentation/src/main/java/com/puzzle/presentation/ui/App.kt b/presentation/src/main/java/com/puzzle/presentation/ui/App.kt index 001275e2..caccf04c 100644 --- a/presentation/src/main/java/com/puzzle/presentation/ui/App.kt +++ b/presentation/src/main/java/com/puzzle/presentation/ui/App.kt @@ -34,6 +34,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.puzzle.common.ui.NoRippleInteractionSource +import com.puzzle.designsystem.R import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.navigation.AuthGraph import com.puzzle.navigation.MatchingGraph @@ -41,7 +42,6 @@ import com.puzzle.navigation.MatchingGraphDest.MatchingDetailRoute import com.puzzle.navigation.MyPageRoute import com.puzzle.navigation.Route import com.puzzle.navigation.SettingRoute -import com.puzzle.designsystem.R import com.puzzle.presentation.navigation.AppNavHost import com.puzzle.presentation.navigation.TopLevelDestination import kotlin.reflect.KClass @@ -59,6 +59,7 @@ fun App( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = PieceTheme.colors.white, bottomBar = { if (currentDestination?.shouldHideBottomNavigation() == false) { AppBottomBar( diff --git a/settings.gradle.kts b/settings.gradle.kts index 41c4c480..c74ff13e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,14 +24,18 @@ dependencyResolutionManagement { rootProject.name = "Piece" include(":app") -include(":feature:auth") -include(":feature:mypage") -include(":feature:matching") -include(":feature:setting") + include(":core:domain") include(":core:designsystem") include(":core:data") include(":core:network") include(":core:navigation") include(":core:common-ui") +include(":core:database") +include(":core:common") + +include(":feature:auth") +include(":feature:mypage") +include(":feature:matching") +include(":feature:setting") include(":presentation")