diff --git a/.github/workflows/android-cd.yml b/.github/workflows/android-cd.yml index 9ecb0f95..e82c41fd 100644 --- a/.github/workflows/android-cd.yml +++ b/.github/workflows/android-cd.yml @@ -38,16 +38,16 @@ jobs: - name: Generate google-services.json run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > ./app/google-services.json - - name: Extract Version Name from ApplicationConstants.kt + - name: Extract Version Name from libs.versions.toml run: | set -euo pipefail - VERSION=$(grep "VERSION_NAME" build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt | sed -E 's/.*VERSION_NAME\s*=\s*"([^"]+)".*/\1/') + VERSION=$(grep "versionName" gradle/libs.versions.toml | sed -E 's/.*versionName\s*=\s*"([^"]+)".*/\1/') if [[ -z "$VERSION" ]]; then - echo "Error: ApplicationConstants.kt에서 VERSION_NAME 값을 추출하지 못했습니다." >&2 + echo "Error: toml에서 versionName 값을 추출하지 못했습니다." >&2 exit 1 fi echo "version=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "Version extracted from ApplicationConstants.kt: v${VERSION}" + echo "Version extracted from toml: v${VERSION}" id: extract_version - name: Generate Firebase Release Note diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b32018e..49baa4fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,6 +88,7 @@ dependencies { projects.feature.settings, projects.feature.splash, projects.feature.webview, + projects.feature.edit, libs.androidx.activity.compose, libs.androidx.startup, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1443cd8d..7a93c3d6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,18 @@ + + + + + diff --git a/core/designsystem/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml similarity index 100% rename from core/designsystem/src/main/res/xml/backup_rules.xml rename to app/src/main/res/xml/backup_rules.xml diff --git a/core/designsystem/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from core/designsystem/src/main/res/xml/data_extraction_rules.xml rename to app/src/main/res/xml/data_extraction_rules.xml diff --git a/core/designsystem/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml similarity index 100% rename from core/designsystem/src/main/res/xml/network_security_config.xml rename to app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 00000000..4a2cfe93 --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 9d71f0f6..d31a051b 100644 --- a/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -3,6 +3,7 @@ import com.ninecraft.booket.convention.ApplicationConstants import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.configureAndroid +import com.ninecraft.booket.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure @@ -19,9 +20,9 @@ internal class AndroidApplicationConventionPlugin : Plugin { configureAndroid(this) defaultConfig { - targetSdk = ApplicationConstants.TARGET_SDK - versionName = ApplicationConstants.VERSION_NAME - versionCode = ApplicationConstants.VERSION_CODE + targetSdk = libs.versions.targetSdk.get().toInt() + versionName = libs.versions.versionName.get() + versionCode = libs.versions.versionCode.get().toInt() } } } diff --git a/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 39888c94..b430d6b9 100644 --- a/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -2,10 +2,10 @@ import com.android.build.gradle.LibraryExtension import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.configureAndroid +import com.ninecraft.booket.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -import com.ninecraft.booket.convention.ApplicationConstants internal class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { @@ -19,7 +19,7 @@ internal class AndroidLibraryConventionPlugin : Plugin { configureAndroid(this) defaultConfig.apply { - targetSdk = ApplicationConstants.TARGET_SDK + targetSdk = libs.versions.targetSdk.get().toInt() } } } diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt index 75b05af8..b02d0b99 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt @@ -8,10 +8,10 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, *>) { extension.apply { - compileSdk = ApplicationConstants.COMPILE_SDK + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - minSdk = ApplicationConstants.MIN_SDK + minSdk = libs.versions.minSdk.get().toInt() } compileOptions { diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt index 7dd72860..fc25dc55 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt @@ -3,11 +3,6 @@ package com.ninecraft.booket.convention import org.gradle.api.JavaVersion internal object ApplicationConstants { - const val MIN_SDK = 28 - const val TARGET_SDK = 35 - const val COMPILE_SDK = 35 - const val VERSION_CODE = 3 - const val VERSION_NAME = "1.0.0" const val JAVA_VERSION_INT = 17 val javaVersion = JavaVersion.VERSION_17 } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 35666a1b..ba851fff 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -9,6 +9,14 @@ plugins { android { namespace = "com.ninecraft.booket.core.common" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "PACKAGE_NAME", "\"${libs.versions.packageName.get()}\"") + } } dependencies { @@ -18,6 +26,8 @@ dependencies { libs.kotlinx.collections.immutable, + platform(libs.firebase.bom), + libs.firebase.analytics, libs.logger, ) } diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/AnalyticsHelper.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/AnalyticsHelper.kt new file mode 100644 index 00000000..fde333f1 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/AnalyticsHelper.kt @@ -0,0 +1,25 @@ +package com.ninecraft.booket.core.common.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.logEvent +import com.orhanobut.logger.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AnalyticsHelper @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) { + + fun logScreenView(screenName: String) { + Logger.d("Analytics - Screen View: $screenName") + firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { + param(FirebaseAnalytics.Param.SCREEN_NAME, screenName) + } + } + + fun logEvent(eventName: String) { + Logger.d("Analytics - Event: $eventName") + firebaseAnalytics.logEvent(eventName) {} + } +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsModule.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsModule.kt new file mode 100644 index 00000000..c08fc156 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsModule.kt @@ -0,0 +1,21 @@ +package com.ninecraft.booket.core.common.analytics.di + +import com.google.firebase.Firebase +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.analytics +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AnalyticsModule { + + @Provides + @Singleton + fun provideFirebaseAnalytics(): FirebaseAnalytics { + return Firebase.analytics + } +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt index a6cefa89..270ea48e 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt @@ -1,7 +1,9 @@ package com.ninecraft.booket.core.common.constants +import androidx.annotation.StringRes + data class ErrorDialogSpec( val message: String, - val buttonLabel: String, + @StringRes val buttonLabelResId: Int, val action: () -> Unit, ) diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt new file mode 100644 index 00000000..d1fa560b --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt @@ -0,0 +1,69 @@ +package com.ninecraft.booket.core.common.extensions + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.ninecraft.booket.core.common.BuildConfig +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import com.orhanobut.logger.Logger +import java.io.File + +@Suppress("TooGenericExceptionCaught") +fun Context.externalShareForBitmap(bitmap: ImageBitmap) { + try { + val file = File(bitmap.saveToDisk(this)) + val uri = FileProvider.getUriForFile(this, "$packageName.provider", file) + + ShareCompat.IntentBuilder(this) + .setStream(uri) + .setType("image/png") + .startChooser() + } catch (e: Exception) { + Logger.e("[externalShareFoBitmap] message: ${e.message}") + } +} + +@Suppress("TooGenericExceptionCaught") +fun Context.saveImageToGallery(bitmap: ImageBitmap) { + try { + val fileName = "reed_record_${System.currentTimeMillis()}.png" + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, "image/png") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val imageUri = + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + imageUri?.let { uri -> + contentResolver.openOutputStream(uri)?.use { outputStream -> + bitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentValues.clear() + contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) + contentResolver.update(uri, contentValues, null, null) + } + } + } catch (e: Exception) { + Logger.e("Failed to save image to gallery: ${e.message}") + } +} + +fun Context.openPlayStore() { + val intent = + Intent(Intent.ACTION_VIEW, "market://details?id=${BuildConfig.PACKAGE_NAME}".toUri()) + startActivity(intent) +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/ImageBitmap.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/ImageBitmap.kt new file mode 100644 index 00000000..f6dc1608 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/ImageBitmap.kt @@ -0,0 +1,21 @@ +package com.ninecraft.booket.core.common.extensions + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import java.io.File +import java.io.FileOutputStream + +fun ImageBitmap.saveToDisk(context: Context): String { + val fileName = "shared_image_${System.currentTimeMillis()}.png" + val cachePath = File(context.cacheDir, "images").also { it.mkdirs() } + val file = File(cachePath, fileName) + val outputStream = FileOutputStream(file) + + asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, outputStream) + outputStream.flush() + outputStream.close() + + return file.absolutePath +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt index 703d47c6..019befb7 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Modifier.kt @@ -6,6 +6,9 @@ import androidx.compose.material3.ripple import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.Role import com.ninecraft.booket.core.common.utils.MultipleEventsCutter @@ -45,3 +48,9 @@ fun Modifier.clickableSingle( interactionSource = remember { MutableInteractionSource() }, ) } + +fun Modifier.captureToGraphicsLayer(graphicsLayer: GraphicsLayer) = + this.drawWithContent { + graphicsLayer.record { this@drawWithContent.drawContent() } + drawLayer(graphicsLayer) + } diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/VersionUtils.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/VersionUtils.kt new file mode 100644 index 00000000..c91a6811 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/VersionUtils.kt @@ -0,0 +1,43 @@ +package com.ninecraft.booket.core.common.util + +import com.orhanobut.logger.Logger + +/** + * 두 버전을 비교하는 함수 + * + * @param version1 첫 번째 버전 (예: "1.2.3") + * @param version2 두 번째 버전 (예: "1.1.0") + * @return 양수면 version1 > version2, 음수면 version1 < version2, 0이면 같음 + * + * 버전 형식: "메이저.마이너.패치" (예: 1.2.3) + * 비교 순서: 메이저 → 마이너 → 패치 버전 순으로 비교 + */ +fun compareVersions(version1: String, version2: String): Int { + Logger.d("compareVersions: version1: $version1, version2: $version2") + + if (!Regex("""^\d+\.\d+\.\d+$""").matches(version1)) return 0 + if (!Regex("""^\d+\.\d+\.\d+$""").matches(version2)) return 0 + + val v1 = version1.split('.').map { it.toInt() } + val v2 = version2.split('.').map { it.toInt() } + + // 메이저 버전 비교 + if (v1[0] != v2[0]) return v1[0] - v2[0] + + // 마이너 버전 비교 + if (v1[1] != v2[1]) return v1[1] - v2[1] + + // 패치 버전 비교 + return v1[2] - v2[2] +} + +/** + * 현재 앱 버전이 최소 요구 버전보다 낮은지 확인하는 함수 + * + * @param currentVersion 현재 앱의 버전 (예: "1.0.0") + * @param minVersion 최소 요구 버전 (Firebase Remote Config에서 가져온 값) + * @return true면 강제 업데이트 필요 (현재 버전 < 최소 요구 버전), false면 업데이트 불필요 + */ +fun isUpdateRequired(currentVersion: String, minVersion: String): Boolean { + return compareVersions(currentVersion, minVersion) < 0 +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt index 0920c555..ed84d6e8 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt @@ -1,5 +1,7 @@ package com.ninecraft.booket.core.common.utils +import androidx.annotation.StringRes +import com.ninecraft.booket.core.common.R import com.ninecraft.booket.core.common.constants.ErrorDialogSpec import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.event.ErrorEvent @@ -17,7 +19,7 @@ import java.net.UnknownHostException fun handleException( exception: Throwable, onError: (String) -> Unit, - onLoginRequired: () -> Unit, + onLoginRequired: () -> Unit = {}, ) { when { exception is HttpException && exception.code() == 401 -> { @@ -46,11 +48,13 @@ fun handleException( fun postErrorDialog( errorScope: ErrorScope, exception: Throwable, + @StringRes buttonLabelResId: Int = R.string.confirm, action: () -> Unit = {}, ) { val spec = buildDialog( scope = errorScope, exception = exception, + buttonLabelResId = buttonLabelResId, action = action, ) @@ -60,6 +64,7 @@ fun postErrorDialog( private fun buildDialog( scope: ErrorScope, exception: Throwable, + @StringRes buttonLabelResId: Int, action: () -> Unit, ): ErrorDialogSpec { val message = when { @@ -92,7 +97,7 @@ private fun buildDialog( } } - return ErrorDialogSpec(message = message, buttonLabel = "확인", action = action) + return ErrorDialogSpec(message = message, buttonLabelResId = buttonLabelResId, action = action) } @Suppress("TooGenericExceptionCaught") diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml index 71ff450b..45506ca4 100644 --- a/core/common/src/main/res/values/strings.xml +++ b/core/common/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ 독서 완료 페이지순 최신 등록순 + 확인 diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt index 6e922b56..b29faf03 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt @@ -17,7 +17,7 @@ interface BookRepository { start: Int, ): Result - suspend fun removeBookRecentSearch(query: String) + suspend fun deleteBookRecentSearch(query: String) suspend fun getBookDetail(isbn13: String): Result @@ -38,9 +38,11 @@ interface BookRepository { size: Int, ): Result - suspend fun removeLibraryRecentSearch(query: String) + suspend fun deleteLibraryRecentSearch(query: String) suspend fun getHome(): Result suspend fun getSeedsStats(userBookId: String): Result + + suspend fun deleteBook(userBookId: String): Result } diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RecordRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RecordRepository.kt index 99dc8bf6..753bc76b 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RecordRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RecordRepository.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.core.data.api.repository +import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.model.RecordRegisterModel import com.ninecraft.booket.core.model.ReadingRecordsModel import com.ninecraft.booket.core.model.RecordDetailModel @@ -23,4 +24,16 @@ interface RecordRepository { suspend fun getRecordDetail( readingRecordId: String, ): Result + + suspend fun editRecord( + readingRecordId: String, + pageNumber: Int, + quote: String, + emotionTags: List, + review: String, + ): Result + + suspend fun deleteRecord( + readingRecordId: String, + ): Result } diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RemoteConfigRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RemoteConfigRepository.kt new file mode 100644 index 00000000..7a9dba2e --- /dev/null +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RemoteConfigRepository.kt @@ -0,0 +1,6 @@ +package com.ninecraft.booket.core.data.api.repository + +interface RemoteConfigRepository { + suspend fun getLatestVersion(): Result + suspend fun shouldUpdate(): Result +} diff --git a/core/data/impl/build.gradle.kts b/core/data/impl/build.gradle.kts index cd5c5777..f894b8ed 100644 --- a/core/data/impl/build.gradle.kts +++ b/core/data/impl/build.gradle.kts @@ -8,6 +8,14 @@ plugins { android { namespace = "com.ninecraft.booket.core.data.impl" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "APP_VERSION", "\"${libs.versions.versionName.get()}\"") + } } dependencies { @@ -18,6 +26,8 @@ dependencies { projects.core.model, projects.core.network, + platform(libs.firebase.bom), + libs.firebase.remote.config, libs.logger, ) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt new file mode 100644 index 00000000..2a6f28a5 --- /dev/null +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt @@ -0,0 +1,29 @@ +package com.ninecraft.booket.core.data.impl.di + +import com.google.firebase.Firebase +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings +import com.ninecraft.booket.core.data.impl.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal object FirebaseModule { + @Singleton + @Provides + fun provideRemoteConfig(): FirebaseRemoteConfig { + return Firebase.remoteConfig.apply { + val configSettings by lazy { + remoteConfigSettings { + minimumFetchIntervalInSeconds = if (BuildConfig.DEBUG) 0 else 60 + } + } + setConfigSettingsAsync(configSettings) + } + } +} diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt index 900bbf73..013d61de 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt @@ -3,10 +3,12 @@ package com.ninecraft.booket.core.data.impl.di import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.data.api.repository.RecordRepository +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.data.impl.repository.DefaultAuthRepository import com.ninecraft.booket.core.data.impl.repository.DefaultBookRepository import com.ninecraft.booket.core.data.impl.repository.DefaultRecordRepository +import com.ninecraft.booket.core.data.impl.repository.DefaultRemoteConfigRepository import com.ninecraft.booket.core.data.impl.repository.DefaultUserRepository import dagger.Binds import dagger.Module @@ -33,4 +35,8 @@ internal abstract class RepositoryModule { @Binds @Singleton abstract fun bindRecordRepository(defaultRecordRepository: DefaultRecordRepository): RecordRepository + + @Binds + @Singleton + abstract fun bindRemoteConfigRepository(defaultRemoteConfigRepository: DefaultRemoteConfigRepository): RemoteConfigRepository } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt index 451da2ac..cf64e2f2 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt @@ -30,8 +30,8 @@ internal class DefaultBookRepository @Inject constructor( result } - override suspend fun removeBookRecentSearch(query: String) { - bookRecentSearchDataSource.removeRecentSearch(query) + override suspend fun deleteBookRecentSearch(query: String) { + bookRecentSearchDataSource.deleteRecentSearch(query) } override suspend fun getBookDetail(isbn13: String) = runSuspendCatching { @@ -53,8 +53,8 @@ internal class DefaultBookRepository @Inject constructor( result } - override suspend fun removeLibraryRecentSearch(query: String) { - libraryRecentSearchDataSource.removeRecentSearch(query) + override suspend fun deleteLibraryRecentSearch(query: String) { + libraryRecentSearchDataSource.deleteRecentSearch(query) } override suspend fun getHome() = runSuspendCatching { @@ -64,4 +64,8 @@ internal class DefaultBookRepository @Inject constructor( override suspend fun getSeedsStats(userBookId: String) = runSuspendCatching { service.getSeedsStats(userBookId).toModel() } + + override suspend fun deleteBook(userBookId: String) = runSuspendCatching { + service.deleteBook(userBookId) + } } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRecordRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRecordRepository.kt index 99773448..410769d0 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRecordRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRecordRepository.kt @@ -3,6 +3,7 @@ package com.ninecraft.booket.core.data.impl.repository import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.data.impl.mapper.toModel +import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.network.request.RecordRegisterRequest import com.ninecraft.booket.core.network.service.ReedService import javax.inject.Inject @@ -32,4 +33,18 @@ class DefaultRecordRepository @Inject constructor( override suspend fun getRecordDetail(readingRecordId: String) = runSuspendCatching { service.getRecordDetail(readingRecordId).toModel() } + + override suspend fun editRecord( + readingRecordId: String, + pageNumber: Int, + quote: String, + emotionTags: List, + review: String, + ): Result = runSuspendCatching { + service.editRecord(readingRecordId, RecordRegisterRequest(pageNumber, quote, emotionTags, review)).toModel() + } + + override suspend fun deleteRecord(readingRecordId: String): Result = runSuspendCatching { + service.deleteRecord(readingRecordId) + } } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRemoteConfigRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRemoteConfigRepository.kt new file mode 100644 index 00000000..ea9733f6 --- /dev/null +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRemoteConfigRepository.kt @@ -0,0 +1,46 @@ +package com.ninecraft.booket.core.data.impl.repository + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.get +import com.ninecraft.booket.core.common.util.isUpdateRequired +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository +import com.ninecraft.booket.core.data.impl.BuildConfig +import com.orhanobut.logger.Logger +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume + +class DefaultRemoteConfigRepository @Inject constructor( + private val remoteConfig: FirebaseRemoteConfig, +) : RemoteConfigRepository { + override suspend fun getLatestVersion(): Result = suspendCancellableCoroutine { continuation -> + remoteConfig.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val latestVersion = remoteConfig[KEY_LATEST_VERSION].asString() + Logger.d("LatestVersion: $latestVersion") + continuation.resume(Result.success(latestVersion)) + } else { + Logger.e(task.exception, "getLatestVersion failed") + continuation.resume(Result.failure(task.exception ?: Exception("Unknown error"))) + } + } + } + + override suspend fun shouldUpdate(): Result = suspendCancellableCoroutine { continuation -> + remoteConfig.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val minVersion = remoteConfig[KEY_MIN_VERSION].asString() + val currentVersion = BuildConfig.APP_VERSION + continuation.resume(Result.success(isUpdateRequired(currentVersion, minVersion))) + } else { + Logger.e(task.exception, "shouldUpdate: getMinVersion failed") + continuation.resume(Result.failure(task.exception ?: Exception("Unknown error"))) + } + } + } + + companion object { + private const val KEY_LATEST_VERSION = "LatestVersion" + private const val KEY_MIN_VERSION = "MinVersion" + } +} diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/RecentSearchDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/RecentSearchDataSource.kt index 9425c12c..72606554 100644 --- a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/RecentSearchDataSource.kt +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/RecentSearchDataSource.kt @@ -5,6 +5,6 @@ import kotlinx.coroutines.flow.Flow interface RecentSearchDataSource { val recentSearches: Flow> suspend fun addRecentSearch(query: String) - suspend fun removeRecentSearch(query: String) + suspend fun deleteRecentSearch(query: String) suspend fun clearRecentSearches() } diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt index 9dab0359..2694b2a9 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt @@ -67,7 +67,7 @@ class DefaultBookRecentSearchDataSource @Inject constructor( } @Suppress("TooGenericExceptionCaught") - override suspend fun removeRecentSearch(query: String) { + override suspend fun deleteRecentSearch(query: String) { dataStore.edit { prefs -> val currentSearches = prefs[BOOK_RECENT_SEARCHES]?.let { jsonString -> try { diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt index e90c2e70..b666c888 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt @@ -67,7 +67,7 @@ class DefaultLibraryRecentSearchDataSource @Inject constructor( } @Suppress("TooGenericExceptionCaught") - override suspend fun removeRecentSearch(query: String) { + override suspend fun deleteRecentSearch(query: String) { dataStore.edit { prefs -> val currentSearches = prefs[LIBRARY_RECENT_SEARCHES]?.let { jsonString -> try { @@ -76,7 +76,7 @@ class DefaultLibraryRecentSearchDataSource @Inject constructor( Logger.e(e, "Failed to deserialize recent searches for removal") mutableListOf() } catch (e: Exception) { - Logger.e(e, "Unexpected error while removing recent search") + Logger.e(e, "Unexpected error while deleting recent search") mutableListOf() } } ?: mutableListOf() diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonSizeStyle.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonSizeStyle.kt index 2bce40a1..7f0510b4 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonSizeStyle.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonSizeStyle.kt @@ -19,7 +19,7 @@ val largeButtonStyle: ButtonSizeStyle @Composable get() = ButtonSizeStyle( paddingValues = PaddingValues( horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing3, + vertical = 14.dp, ), radius = ReedTheme.radius.sm, textStyle = ReedTheme.typography.body1Medium, @@ -36,7 +36,7 @@ val mediumButtonStyle: ButtonSizeStyle radius = ReedTheme.radius.sm, textStyle = ReedTheme.typography.label1Medium, iconSpacing = ReedTheme.spacing.spacing1, - iconSize = 24.dp, + iconSize = 22.dp, ) val smallButtonStyle: ButtonSizeStyle @@ -48,7 +48,7 @@ val smallButtonStyle: ButtonSizeStyle radius = ReedTheme.radius.xs, textStyle = ReedTheme.typography.label1Medium, iconSpacing = ReedTheme.spacing.spacing1, - iconSize = 22.dp, + iconSize = 18.dp, ) val largeRoundedButtonStyle: ButtonSizeStyle @@ -72,7 +72,7 @@ val mediumRoundedButtonStyle: ButtonSizeStyle radius = ReedTheme.radius.full, textStyle = ReedTheme.typography.label1Medium, iconSpacing = ReedTheme.spacing.spacing1, - iconSize = 24.dp, + iconSize = 22.dp, ) val smallRoundedButtonStyle: ButtonSizeStyle @@ -84,5 +84,5 @@ val smallRoundedButtonStyle: ButtonSizeStyle radius = ReedTheme.radius.full, textStyle = ReedTheme.typography.label1Medium, iconSpacing = ReedTheme.spacing.spacing1, - iconSize = 22.dp, + iconSize = 18.dp, ) diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Typography.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Typography.kt index c53e170c..9882936e 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Typography.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Typography.kt @@ -17,6 +17,10 @@ val pretendardFamily = FontFamily( Font(R.font.pretendard_semi_bold, FontWeight.SemiBold, FontStyle.Normal), ) +val hakgyoansimFamily = FontFamily( + Font(R.font.hakgyoansim_santteutbatang_m, FontWeight.Medium, FontStyle.Normal), +) + private val defaultLineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, @@ -73,4 +77,13 @@ data class ReedTypography( // Caption val caption1Regular: TextStyle = style(12, 16, -0.12f, FontWeight.Normal), val caption2Regular: TextStyle = style(11, 14, -0.11f, FontWeight.Normal), + + val quoteMedium: TextStyle = TextStyle( + fontFamily = hakgyoansimFamily, + lineHeightStyle = defaultLineHeightStyle, + fontSize = 18.sp, + lineHeight = 28.sp, + letterSpacing = (-0.27f).sp, + fontWeight = FontWeight.Medium, + ), ) diff --git a/core/designsystem/src/main/res/drawable/ic_download.xml b/core/designsystem/src/main/res/drawable/ic_download.xml new file mode 100644 index 00000000..c2e05988 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_download.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_share_1.xml b/core/designsystem/src/main/res/drawable/ic_share_1.xml new file mode 100644 index 00000000..f669e045 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_share_1.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_share_2.xml b/core/designsystem/src/main/res/drawable/ic_share_2.xml new file mode 100644 index 00000000..1c4f1ccf --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_share_2.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_trash.xml b/core/designsystem/src/main/res/drawable/ic_trash.xml new file mode 100644 index 00000000..42fb136d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_trash.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/font/hakgyoansim_santteutbatang_m.ttf b/core/designsystem/src/main/res/font/hakgyoansim_santteutbatang_m.ttf new file mode 100644 index 00000000..205f1967 Binary files /dev/null and b/core/designsystem/src/main/res/font/hakgyoansim_santteutbatang_m.ttf differ diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookUpsertModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookUpsertModel.kt index 40843cef..617f1d11 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookUpsertModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookUpsertModel.kt @@ -1,8 +1,5 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable - -@Stable data class BookUpsertModel( val userBookId: String, val userId: String, diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/ReadingRecordsModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/ReadingRecordsModel.kt index f60f2dee..1c6356e1 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/ReadingRecordsModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/ReadingRecordsModel.kt @@ -1,5 +1,7 @@ package com.ninecraft.booket.core.model +import androidx.compose.runtime.Stable + data class ReadingRecordsModel( val lastPage: Boolean = true, val totalResults: Int = 0, @@ -8,6 +10,7 @@ data class ReadingRecordsModel( val readingRecords: List = emptyList(), ) +@Stable data class ReadingRecordModel( val id: String = "", val userBookId: String = "", diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenInterceptor.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenInterceptor.kt index ad8329cf..a3544d49 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenInterceptor.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenInterceptor.kt @@ -7,7 +7,6 @@ import okhttp3.Response import javax.inject.Inject internal class TokenInterceptor @Inject constructor( - @Suppress("unused") private val tokenDataSource: TokenDataSource, ) : Interceptor { diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt index b807df70..f2b0b280 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt @@ -11,6 +11,7 @@ import com.ninecraft.booket.core.network.response.BookUpsertResponse import com.ninecraft.booket.core.network.response.HomeResponse import com.ninecraft.booket.core.network.response.LibraryResponse import com.ninecraft.booket.core.network.response.LoginResponse +import com.ninecraft.booket.core.network.response.ReadingRecord import com.ninecraft.booket.core.network.response.ReadingRecordsResponse import com.ninecraft.booket.core.network.response.RecordDetailResponse import com.ninecraft.booket.core.network.response.RecordRegisterResponse @@ -21,6 +22,7 @@ import com.ninecraft.booket.core.network.response.UserProfileResponse import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -78,6 +80,11 @@ interface ReedService { @Query("sort") sort: String = "CREATED_DATE_DESC", ): LibraryResponse + @DELETE("api/v1/books/my-library/{userBookId}") + suspend fun deleteBook( + @Path("userBookId") userBookId: String, + ) + // Reading-records endpoints (auth required) @POST("api/v1/reading-records/{userBookId}") suspend fun postRecord( @@ -103,6 +110,17 @@ interface ReedService { @Path("readingRecordId") readingRecordId: String, ): RecordDetailResponse + @PATCH("api/v1/reading-records/{readingRecordId}") + suspend fun editRecord( + @Path("readingRecordId") readingRecordId: String, + @Body recordRegisterRequest: RecordRegisterRequest, + ): ReadingRecord + + @DELETE("api/v1/reading-records/{readingRecordId}") + suspend fun deleteRecord( + @Path("readingRecordId") readingRecordId: String, + ) + // Home (auth required) @GET("api/v1/home") suspend fun getHome( diff --git a/core/ocr/build.gradle.kts b/core/ocr/build.gradle.kts index e3a83b9a..4d70a87e 100644 --- a/core/ocr/build.gradle.kts +++ b/core/ocr/build.gradle.kts @@ -1,19 +1,37 @@ @file:Suppress("INLINE_FROM_HIGHER_PLATFORM") +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + + plugins { alias(libs.plugins.booket.android.library) + alias(libs.plugins.booket.android.retrofit) alias(libs.plugins.booket.android.hilt) } android { namespace = "com.ninecraft.booket.core.ocr" + + defaultConfig { + buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY")) + } + + buildFeatures { + buildConfig = true + } } dependencies { implementations( + projects.core.common, + libs.logger, libs.androidx.camera.core, libs.google.mlkit.text.recognition.korean, ) } + +fun getApiKey(propertyKey: String): String { + return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) +} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt new file mode 100644 index 00000000..538095db --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt @@ -0,0 +1,45 @@ +package com.ninecraft.booket.core.ocr.analyzer + +import android.net.Uri +import android.util.Base64 +import com.ninecraft.booket.core.common.utils.runSuspendCatching +import com.ninecraft.booket.core.ocr.BuildConfig +import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest +import com.ninecraft.booket.core.ocr.model.CloudVisionRequest +import com.ninecraft.booket.core.ocr.model.CloudVisionResponse +import com.ninecraft.booket.core.ocr.model.Feature +import com.ninecraft.booket.core.ocr.model.ImageContext +import com.ninecraft.booket.core.ocr.model.VisionImage +import com.ninecraft.booket.core.ocr.service.CloudVisionService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +class CloudOcrRecognizer @Inject constructor( + private val service: CloudVisionService, +) { + suspend fun recognizeText(imageUri: Uri): Result = runSuspendCatching { + withContext(Dispatchers.IO) { + val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.") + val file = File(filePath) + val byte = file.readBytes() + val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP) + + val request = CloudVisionRequest( + requests = listOf( + AnnotateImageRequest( + image = VisionImage(base64Image), + features = listOf(Feature(type = "TEXT_DETECTION")), + imageContext = ImageContext(languageHints = null), + ), + ), + ) + + service.batchAnnotateImage( + apiKey = BuildConfig.CLOUD_VISION_API_KEY, + body = request, + ) + } + } +} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt new file mode 100644 index 00000000..c38717b5 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt @@ -0,0 +1,75 @@ +package com.ninecraft.booket.core.ocr.di + +import com.ninecraft.booket.core.ocr.BuildConfig +import com.ninecraft.booket.core.ocr.service.CloudVisionService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +private const val BASE_URL = "https://vision.googleapis.com/" +private const val MaxTimeoutMillis = 15_000L + +private val jsonRule = Json { + // 기본값도 JSON에 포함하여 직렬화 + encodeDefaults = true + // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) + ignoreUnknownKeys = true + // JSON을 보기 좋게 들여쓰기하여 포맷팅 + prettyPrint = true + // 엄격하지 않은 파싱 (따옴표 없는 키, 후행 쉼표 등 허용) + isLenient = true +} + +private val jsonConverterFactory = jsonRule.asConverterFactory("application/json".toMediaType()) + +@Module +@InstallIn(SingletonComponent::class) +object CloudVisionNetworkModule { + + @Provides + @Singleton + @CloudVisionOkHttp + fun provideOkHttp(): OkHttpClient { + val log = HttpLoggingInterceptor().apply { + redactHeader("X-Goog-Api-Key") + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.NONE + } + } + return OkHttpClient.Builder() + .addInterceptor(log) + .connectTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .writeTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) + .build() + } + + @Provides + @Singleton + @CloudVisionRetrofit + fun provideRetrofit( + @CloudVisionOkHttp okHttpClient: OkHttpClient, + ): Retrofit { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(jsonConverterFactory) + .build() + } + + @Provides + @Singleton + fun provideVisionApi(@CloudVisionRetrofit retrofit: Retrofit): CloudVisionService = + retrofit.create(CloudVisionService::class.java) +} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt new file mode 100644 index 00000000..b79d4ea1 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkQualifier.kt @@ -0,0 +1,11 @@ +package com.ninecraft.booket.core.ocr.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CloudVisionOkHttp + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CloudVisionRetrofit diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt new file mode 100644 index 00000000..b3d0c730 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionRequest.kt @@ -0,0 +1,30 @@ +package com.ninecraft.booket.core.ocr.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CloudVisionRequest( + val requests: List, +) + +@Serializable +data class AnnotateImageRequest( + val image: VisionImage, + val features: List, + val imageContext: ImageContext? = null, +) + +@Serializable +data class VisionImage( + val content: String, +) + +@Serializable +data class Feature( + val type: String = "TEXT_DETECTION", +) + +@Serializable +data class ImageContext( + val languageHints: List? = null, +) diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt new file mode 100644 index 00000000..71c5f52f --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/model/CloudVisionResponse.kt @@ -0,0 +1,18 @@ +package com.ninecraft.booket.core.ocr.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CloudVisionResponse( + val responses: List, +) + +@Serializable +data class AnnotateImageResponse( + val fullTextAnnotation: FullTextAnnotation? = null, +) + +@Serializable +data class FullTextAnnotation( + val text: String? = null, +) diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt new file mode 100644 index 00000000..3ee87c14 --- /dev/null +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/service/CloudVisionService.kt @@ -0,0 +1,15 @@ +package com.ninecraft.booket.core.ocr.service + +import com.ninecraft.booket.core.ocr.model.CloudVisionRequest +import com.ninecraft.booket.core.ocr.model.CloudVisionResponse +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +interface CloudVisionService { + @POST("v1/images:annotate") + suspend fun batchAnnotateImage( + @Header("X-Goog-Api-Key") apiKey: String, + @Body body: CloudVisionRequest, + ): CloudVisionResponse +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7a80a06e..9e0af88a 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { projects.core.common, libs.compose.keyboard.state, + libs.compose.effects, libs.logger, ) } diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt index 3e4f4edd..be8a59c2 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/ReedScaffold.kt @@ -22,13 +22,13 @@ fun ReedScaffold( content: @Composable (PaddingValues) -> Unit, ) { Scaffold( + modifier = modifier.keyboardHide(), topBar = topBar, bottomBar = bottomBar, snackbarHost = snackbarHost, floatingActionButton = floatingActionButton, containerColor = containerColor, contentWindowInsets = contentWindowInsets, - modifier = modifier.keyboardHide(), ) { innerPadding -> content(innerPadding) } diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/InfinityLazyColumn.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/InfinityLazyColumn.kt index f91453f6..6805f4fc 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/InfinityLazyColumn.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/InfinityLazyColumn.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -35,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.skydoves.compose.effects.RememberedEffect // 기기에서 평균적으로 한 화면에 보이는 아이템 개수 private const val LIMIT_COUNT = 6 @@ -72,7 +72,7 @@ fun InfinityLazyColumn( @SuppressLint("ComposableNaming") @Composable private fun LazyListState.onLoadMore( - limitCount: Int = 6, + limitCount: Int = LIMIT_COUNT, loadOnBottom: Boolean = true, action: () -> Unit, ) { @@ -82,7 +82,7 @@ private fun LazyListState.onLoadMore( } } - LaunchedEffect(reached) { + RememberedEffect(reached) { if (reached && layoutInfo.totalItemsCount > limitCount) action() } } diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt index c24ee2a6..d82b136c 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt @@ -70,7 +70,10 @@ fun ReedDialog( textAlign = TextAlign.Center, style = ReedTheme.typography.headline1SemiBold, ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + + if (!description.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + } } description?.let { Text( diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt index 8f0b96df..083b39db 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt @@ -50,17 +50,21 @@ fun ReedErrorUi( @ComponentPreview @Composable private fun ReedNetworkErrorUiPreview() { - ReedErrorUi( - exception = java.io.IOException("네트워크 오류"), - onRetryClick = {}, - ) + ReedTheme { + ReedErrorUi( + exception = java.io.IOException("네트워크 오류"), + onRetryClick = {}, + ) + } } @ComponentPreview @Composable private fun ReedServerErrorUiPreview() { - ReedErrorUi( - exception = Exception("알 수 없는 문제"), - onRetryClick = {}, - ) + ReedTheme { + ReedErrorUi( + exception = Exception("알 수 없는 문제"), + onRetryClick = {}, + ) + } } diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt new file mode 100644 index 00000000..3adbc6b0 --- /dev/null +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt @@ -0,0 +1,33 @@ +package com.ninecraft.booket.core.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.ninecraft.booket.core.common.extensions.noRippleClickable +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +fun ReedLoadingIndicator( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .noRippleClickable {}, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } +} + +@ComponentPreview +@Composable +private fun ReedLoadingIndicatorPreview() { + ReedTheme { + ReedLoadingIndicator() + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 67994c9b..ff80b470 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ 더 이상 결과가 없습니다 - 다시 시도 + 다시 시도하기 네트워크 연결이 불안정합니다.\n인터넷 연결을 확인해주세요 알 수 없는 문제가 발생했어요.\n다시 시도해주세요 diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt index 91b3f662..1525a760 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt @@ -7,10 +7,9 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.constants.BookStatus -import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.utils.handleException -import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.model.BookDetailModel @@ -19,13 +18,18 @@ import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.ui.component.FooterState import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.LoginScreen +import com.ninecraft.booket.feature.screens.RecordCardScreen import com.ninecraft.booket.feature.screens.RecordDetailScreen +import com.ninecraft.booket.feature.screens.RecordEditScreen import com.ninecraft.booket.feature.screens.RecordScreen +import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs +import com.ninecraft.booket.feature.screens.extensions.delayedGoTo import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -44,10 +48,13 @@ class BookDetailPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val bookRepository: BookRepository, private val recordRepository: RecordRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { companion object { private const val PAGE_SIZE = 20 private const val START_INDEX = 0 + private const val BOOK_DELETE = "library_book_delete" + private const val BOOK_DELETE_COMPLETE = "library_book_delete_complete" } private fun getRecordComparator(sortType: RecordSort): Comparator { @@ -71,57 +78,64 @@ class BookDetailPresenter @AssistedInject constructor( var currentBookStatus by rememberRetained { mutableStateOf(BookStatus.READING) } var selectedBookStatus by rememberRetained { mutableStateOf(BookStatus.READING) } var currentRecordSort by rememberRetained { mutableStateOf(RecordSort.PAGE_NUMBER_ASC) } + var selectedRecordInfo by rememberRetained { mutableStateOf(ReadingRecordModel()) } var isBookUpdateBottomSheetVisible by rememberRetained { mutableStateOf(false) } var isRecordSortBottomSheetVisible by rememberRetained { mutableStateOf(false) } + var isRecordMenuBottomSheetVisible by rememberRetained { mutableStateOf(false) } + var isRecordDeleteDialogVisible by rememberRetained { mutableStateOf(false) } + var isDetailMenuBottomSheetVisible by rememberRetained { mutableStateOf(false) } + var isBookDeleteDialogVisible by rememberRetained { mutableStateOf(false) } var sideEffect by rememberRetained { mutableStateOf(null) } @Suppress("TooGenericExceptionCaught") - suspend fun initialLoad() { + fun initialLoad() { uiState = UiState.Loading - try { - coroutineScope { - val bookDetailDef = async { bookRepository.getBookDetail(screen.isbn13).getOrThrow() } - val seedsDef = async { bookRepository.getSeedsStats(screen.userBookId).getOrThrow() } - val readingRecordsDef = async { - recordRepository.getReadingRecords( - userBookId = screen.userBookId, - sort = currentRecordSort.value, - page = START_INDEX, - size = PAGE_SIZE, - ).getOrThrow() - } - val detail = bookDetailDef.await() - val seeds = seedsDef.await() - val records = readingRecordsDef.await() + scope.launch { + try { + coroutineScope { + val bookDetailDeferred = async { bookRepository.getBookDetail(screen.isbn13).getOrThrow() } + val seedsDeferred = async { bookRepository.getSeedsStats(screen.userBookId).getOrThrow() } + val readingRecordsDeferred = async { + recordRepository.getReadingRecords( + userBookId = screen.userBookId, + sort = currentRecordSort.value, + page = START_INDEX, + size = PAGE_SIZE, + ).getOrThrow() + } + val detail = bookDetailDeferred.await() + val seeds = seedsDeferred.await() + val records = readingRecordsDeferred.await() - bookDetail = detail - currentBookStatus = BookStatus.fromValue(detail.userBookStatus) ?: BookStatus.BEFORE_READING - selectedBookStatus = currentBookStatus - seedsStates = seeds.categories.toImmutableList() - readingRecords = records.readingRecords.toPersistentList() - readingRecordsTotalCount = records.totalResults + bookDetail = detail + currentBookStatus = BookStatus.fromValue(detail.userBookStatus) ?: BookStatus.BEFORE_READING + selectedBookStatus = currentBookStatus + seedsStates = seeds.categories.toImmutableList() + readingRecords = records.readingRecords.toPersistentList() + readingRecordsTotalCount = records.totalResults - isLastPage = records.lastPage - currentStartIndex = START_INDEX + isLastPage = records.lastPage + currentStartIndex = START_INDEX - uiState = UiState.Success - } - } catch (e: Throwable) { - uiState = UiState.Error(e) + uiState = UiState.Success + } + } catch (e: Throwable) { + uiState = UiState.Error(e) - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = BookDetailSideEffect.ShowToast(message) - } + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = BookDetailSideEffect.ShowToast(message) + } - handleException( - exception = e, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) + handleException( + exception = e, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } } } @@ -134,11 +148,6 @@ class BookDetailPresenter @AssistedInject constructor( isBookUpdateBottomSheetVisible = false } .onFailure { exception -> - postErrorDialog( - errorScope = ErrorScope.BOOK_REGISTER, - exception = exception, - ) - val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = BookDetailSideEffect.ShowToast(message) @@ -182,6 +191,53 @@ class BookDetailPresenter @AssistedInject constructor( } } + fun deleteRecord(readingRecordId: String, onSuccess: () -> Unit) { + scope.launch { + recordRepository.deleteRecord(readingRecordId = readingRecordId) + .onSuccess { + onSuccess() + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = BookDetailSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } + } + + fun deleteBook(userBookId: String, onSuccess: () -> Unit) { + scope.launch { + bookRepository.deleteBook(userBookId = userBookId) + .onSuccess { + analyticsHelper.logEvent(BOOK_DELETE_COMPLETE) + onSuccess() + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = BookDetailSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } + } + LaunchedEffect(Unit) { initialLoad() } @@ -230,10 +286,100 @@ class BookDetailPresenter @AssistedInject constructor( isRecordSortBottomSheetVisible = false } + is BookDetailUiEvent.OnRecordMenuClick -> { + selectedRecordInfo = event.selectedRecordInfo + isRecordMenuBottomSheetVisible = true + } + + is BookDetailUiEvent.OnRecordMenuBottomSheetDismiss -> { + isRecordMenuBottomSheetVisible = false + } + + is BookDetailUiEvent.OnRecordDeleteDialogDismiss -> { + isRecordDeleteDialogVisible = false + } + + is BookDetailUiEvent.OnShareRecordClick -> { + isRecordMenuBottomSheetVisible = false + scope.launch { + navigator.delayedGoTo( + RecordCardScreen( + quote = selectedRecordInfo.quote, + bookTitle = selectedRecordInfo.bookTitle, + emotionTag = selectedRecordInfo.emotionTags[0], + ), + ) + } + } + + is BookDetailUiEvent.OnEditRecordClick -> { + isRecordMenuBottomSheetVisible = false + navigator.goTo( + RecordEditScreen( + RecordEditArgs( + id = selectedRecordInfo.id, + pageNumber = selectedRecordInfo.pageNumber, + quote = selectedRecordInfo.quote, + review = selectedRecordInfo.review, + emotionTags = selectedRecordInfo.emotionTags, + bookTitle = selectedRecordInfo.bookTitle, + bookPublisher = selectedRecordInfo.bookPublisher, + bookCoverImageUrl = selectedRecordInfo.bookCoverImageUrl, + author = selectedRecordInfo.author, + ), + ), + ) + } + + is BookDetailUiEvent.OnDeleteRecordClick -> { + isRecordMenuBottomSheetVisible = false + isRecordDeleteDialogVisible = true + analyticsHelper.logEvent(BOOK_DELETE) + } + + is BookDetailUiEvent.OnDeleteRecord -> { + isRecordDeleteDialogVisible = false + deleteRecord( + readingRecordId = selectedRecordInfo.id, + onSuccess = { + readingRecords = readingRecords + .filterNot { it.id == selectedRecordInfo.id } + .toPersistentList() + }, + ) + } + is BookDetailUiEvent.OnRecordItemClick -> { navigator.goTo(RecordDetailScreen(event.recordId)) } + is BookDetailUiEvent.OnDetailMenuClick -> { + isDetailMenuBottomSheetVisible = true + } + + is BookDetailUiEvent.OnDetailMenuBottomSheetDismiss -> { + isDetailMenuBottomSheetVisible = false + } + + is BookDetailUiEvent.OnDeleteBookClick -> { + isDetailMenuBottomSheetVisible = false + isBookDeleteDialogVisible = true + } + + is BookDetailUiEvent.OnDeleteDialogDismiss -> { + isBookDeleteDialogVisible = false + } + + is BookDetailUiEvent.OnDeleteBook -> { + isBookDeleteDialogVisible = false + deleteBook( + userBookId = screen.userBookId, + onSuccess = { + navigator.pop() + }, + ) + } + is BookDetailUiEvent.OnLoadMore -> { if (uiState != UiState.Loading && footerState !is FooterState.Loading && !isLastPage) { loadMoreReadingRecords(startIndex = currentStartIndex + 1) @@ -248,6 +394,10 @@ class BookDetailPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(screen.name) + } + return BookDetailUiState( uiState = uiState, footerState = footerState, @@ -260,6 +410,11 @@ class BookDetailPresenter @AssistedInject constructor( currentBookStatus = currentBookStatus, selectedBookStatus = selectedBookStatus, currentRecordSort = currentRecordSort, + selectedRecordInfo = selectedRecordInfo, + isRecordMenuBottomSheetVisible = isRecordMenuBottomSheetVisible, + isRecordDeleteDialogVisible = isRecordDeleteDialogVisible, + isDetailMenuBottomSheetVisible = isDetailMenuBottomSheetVisible, + isBookDeleteDialogVisible = isBookDeleteDialogVisible, sideEffect = sideEffect, eventSink = ::handleEvent, ) diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt index 35ec50ee..0c1a6d7d 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -30,26 +29,29 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.constants.BookStatus -import com.ninecraft.booket.core.common.extensions.toFormattedDate import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.component.ReedDivider import com.ninecraft.booket.core.designsystem.component.button.ReedButton import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle -import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle +import com.ninecraft.booket.core.designsystem.component.button.mediumButtonStyle import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.model.BookDetailModel import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter -import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.core.ui.component.ReedErrorUi +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator +import com.ninecraft.booket.core.ui.component.ReedTopAppBar import com.ninecraft.booket.feature.detail.R import com.ninecraft.booket.feature.detail.book.component.BookItem import com.ninecraft.booket.feature.detail.book.component.BookUpdateBottomSheet import com.ninecraft.booket.feature.detail.book.component.CollectedSeeds +import com.ninecraft.booket.feature.detail.book.component.DetailMenuBottomSheet import com.ninecraft.booket.feature.detail.book.component.ReadingRecordsHeader import com.ninecraft.booket.feature.detail.book.component.RecordItem import com.ninecraft.booket.feature.detail.book.component.RecordSortBottomSheet +import com.ninecraft.booket.feature.detail.record.component.RecordMenuBottomSheet import com.ninecraft.booket.feature.screens.BookDetailScreen import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent @@ -66,6 +68,7 @@ internal fun BookDetailUi( ) { val bookUpdateBottomSheetState = rememberModalBottomSheetState() val recordSortBottomSheetState = rememberModalBottomSheetState() + val recordMenuBottomSheetState = rememberModalBottomSheetState() val coroutineScope = rememberCoroutineScope() HandleBookDetailSideEffects( @@ -125,6 +128,67 @@ internal fun BookDetailUi( }, ) } + + if (state.isRecordMenuBottomSheetVisible) { + RecordMenuBottomSheet( + onDismissRequest = { + state.eventSink(BookDetailUiEvent.OnRecordMenuBottomSheetDismiss) + }, + sheetState = recordMenuBottomSheetState, + onShareRecordClick = { + state.eventSink(BookDetailUiEvent.OnShareRecordClick) + }, + onEditRecordClick = { + coroutineScope.launch { + recordMenuBottomSheetState.hide() + state.eventSink(BookDetailUiEvent.OnEditRecordClick) + } + }, + onDeleteRecordClick = { + state.eventSink(BookDetailUiEvent.OnDeleteRecordClick) + }, + ) + } + + if (state.isRecordDeleteDialogVisible) { + ReedDialog( + title = stringResource(R.string.record_delete_dialog_title), + confirmButtonText = stringResource(R.string.record_delete_dialog_delete), + onConfirmRequest = { + state.eventSink(BookDetailUiEvent.OnDeleteRecord) + }, + dismissButtonText = stringResource(R.string.record_delete_dialog_cancel), + onDismissRequest = { + state.eventSink(BookDetailUiEvent.OnRecordDeleteDialogDismiss) + }, + ) + } + + if (state.isDetailMenuBottomSheetVisible) { + DetailMenuBottomSheet( + onDismissRequest = { + state.eventSink(BookDetailUiEvent.OnDetailMenuBottomSheetDismiss) + }, + sheetState = recordMenuBottomSheetState, + onDeleteBookClick = { + state.eventSink(BookDetailUiEvent.OnDeleteBookClick) + }, + ) + } + + if (state.isBookDeleteDialogVisible) { + ReedDialog( + title = stringResource(R.string.record_delete_dialog_title), + confirmButtonText = stringResource(R.string.record_delete_dialog_delete), + onConfirmRequest = { + state.eventSink(BookDetailUiEvent.OnDeleteBook) + }, + dismissButtonText = stringResource(R.string.record_delete_dialog_cancel), + onDismissRequest = { + state.eventSink(BookDetailUiEvent.OnDeleteDialogDismiss) + }, + ) + } } @Composable @@ -137,12 +201,7 @@ internal fun BookDetailContent( when (state.uiState) { is UiState.Idle -> {} is UiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } + ReedLoadingIndicator() } is UiState.Success -> { @@ -156,11 +215,16 @@ internal fun BookDetailContent( }, ) { item { - ReedBackTopAppBar( - title = "", - onBackClick = { + ReedTopAppBar( + startIconRes = designR.drawable.ic_chevron_left, + startIconOnClick = { state.eventSink(BookDetailUiEvent.OnBackClick) }, + endIconRes = designR.drawable.ic_more_vertical, + endIconDescription = "More Vertical Icon", + endIconOnClick = { + state.eventSink(BookDetailUiEvent.OnDetailMenuClick) + }, ) } @@ -181,7 +245,7 @@ internal fun BookDetailContent( BookStatus.fromValue(state.bookDetail.userBookStatus)?.getDisplayNameRes() ?: BookStatus.BEFORE_READING.getDisplayNameRes(), ), - sizeStyle = largeButtonStyle, + sizeStyle = mediumButtonStyle, colorStyle = ReedButtonColorStyle.SECONDARY, modifier = Modifier.widthIn(min = 98.dp), trailingIcon = { @@ -199,7 +263,7 @@ internal fun BookDetailContent( state.eventSink(BookDetailUiEvent.OnRegisterRecordButtonClick) }, text = stringResource(R.string.register_book_record), - sizeStyle = largeButtonStyle, + sizeStyle = mediumButtonStyle, colorStyle = ReedButtonColorStyle.PRIMARY, modifier = Modifier.weight(1f), ) @@ -257,10 +321,10 @@ internal fun BookDetailContent( ) { index -> val record = state.readingRecords[index] RecordItem( - quote = record.quote, - emotionTags = record.emotionTags.toImmutableList(), - pageNumber = record.pageNumber, - createdAt = record.createdAt.toFormattedDate(), + recordInfo = record, + onRecordMenuClick = { recordInfo -> + state.eventSink(BookDetailUiEvent.OnRecordMenuClick(recordInfo)) + }, modifier = Modifier .padding( start = ReedTheme.spacing.spacing5, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt index 09ee36b8..550d0ee1 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.util.UUID +@Immutable sealed interface UiState { data object Idle : UiState data object Loading : UiState @@ -33,8 +34,13 @@ data class BookDetailUiState( val currentBookStatus: BookStatus = BookStatus.BEFORE_READING, val selectedBookStatus: BookStatus = BookStatus.BEFORE_READING, val currentRecordSort: RecordSort = RecordSort.PAGE_NUMBER_ASC, + val selectedRecordInfo: ReadingRecordModel = ReadingRecordModel(), val isBookUpdateBottomSheetVisible: Boolean = false, val isRecordSortBottomSheetVisible: Boolean = false, + val isRecordMenuBottomSheetVisible: Boolean = false, + val isRecordDeleteDialogVisible: Boolean = false, + val isDetailMenuBottomSheetVisible: Boolean = false, + val isBookDeleteDialogVisible: Boolean = false, val sideEffect: BookDetailSideEffect? = null, val eventSink: (BookDetailUiEvent) -> Unit, ) : CircuitUiState { @@ -63,6 +69,18 @@ sealed interface BookDetailUiEvent : CircuitUiEvent { data object OnBookStatusUpdateButtonClick : BookDetailUiEvent data object OnRecordSortBottomSheetDismiss : BookDetailUiEvent data class OnRecordSortItemSelected(val sortType: RecordSort) : BookDetailUiEvent + data class OnRecordMenuClick(val selectedRecordInfo: ReadingRecordModel) : BookDetailUiEvent + data object OnRecordMenuBottomSheetDismiss : BookDetailUiEvent + data object OnRecordDeleteDialogDismiss : BookDetailUiEvent + data object OnShareRecordClick : BookDetailUiEvent + data object OnEditRecordClick : BookDetailUiEvent + data object OnDeleteRecordClick : BookDetailUiEvent + data object OnDeleteRecord : BookDetailUiEvent + data object OnDetailMenuClick : BookDetailUiEvent + data object OnDetailMenuBottomSheetDismiss : BookDetailUiEvent + data object OnDeleteBookClick : BookDetailUiEvent + data object OnDeleteDialogDismiss : BookDetailUiEvent + data object OnDeleteBook : BookDetailUiEvent data class OnRecordItemClick(val recordId: String) : BookDetailUiEvent data object OnLoadMore : BookDetailUiEvent data object OnRetryClick : BookDetailUiEvent diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookItem.kt index da452ed5..9925556a 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookItem.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookItem.kt @@ -94,6 +94,7 @@ internal fun BookItem( ) } } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing05)) Text( text = bookDetail.pubDate.formatPublishYear(), color = ReedTheme.colors.contentTertiary, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt index d324c67a..35b6554f 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.component.button.ReedButton @@ -140,7 +141,7 @@ fun RowScope.BookStatusItem( interactionSource = remember { MutableInteractionSource() }, onClick = onClick, ) - .padding(vertical = ReedTheme.spacing.spacing3), + .padding(vertical = 14.dp), contentAlignment = Alignment.Center, ) { Text( diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/DetailMenuBottomSheet.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/DetailMenuBottomSheet.kt new file mode 100644 index 00000000..ee394a62 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/DetailMenuBottomSheet.kt @@ -0,0 +1,108 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.ninecraft.booket.core.common.extensions.noRippleClickable +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedBottomSheet +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.core.designsystem.R as designR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DetailMenuBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + onDeleteBookClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ReedBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + ) { + Column( + modifier = modifier + .padding(top = ReedTheme.spacing.spacing5), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DetailMenuItem( + iconResId = designR.drawable.ic_trash, + iconDescription = "Trash Icon", + label = stringResource(R.string.book_delete), + color = ReedTheme.colors.contentError, + onClick = { onDeleteBookClick() }, + ) + } + } +} + +@Composable +private fun DetailMenuItem( + iconResId: Int, + iconDescription: String, + label: String, + color: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable { + onClick() + } + .padding( + vertical = ReedTheme.spacing.spacing5, + horizontal = ReedTheme.spacing.spacing6, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(iconResId), + contentDescription = iconDescription, + tint = color, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing3)) + Text( + text = label, + color = color, + style = ReedTheme.typography.body1Medium, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ComponentPreview +@Composable +private fun ChoiceBottomSheetPreview() { + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + DetailMenuBottomSheet( + onDismissRequest = {}, + sheetState = sheetState, + onDeleteBookClick = {}, + ) +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/ReadingRecordsHeader.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/ReadingRecordsHeader.kt index 658d672a..57c25d97 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/ReadingRecordsHeader.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/ReadingRecordsHeader.kt @@ -46,12 +46,14 @@ internal fun ReadingRecordsHeader( } Row( modifier = Modifier.clickable { onReadingRecordClick() }, + verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(currentRecordSort.getDisplayNameRes()), color = ReedTheme.colors.contentSecondary, style = ReedTheme.typography.label1Medium, ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) Icon( imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_down), contentDescription = "Dropdown Icon", diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt index c8af70a1..4b0b8d3b 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt @@ -2,6 +2,7 @@ package com.ninecraft.booket.feature.detail.book.component import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -13,26 +14,29 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon 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.vector.ImageVector import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.extensions.toFormattedDate import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.feature.detail.R -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import com.ninecraft.booket.core.designsystem.R as designR @Composable internal fun RecordItem( - quote: String, - emotionTags: ImmutableList, - pageNumber: Int, - createdAt: String, + recordInfo: ReadingRecordModel, + onRecordMenuClick: (ReadingRecordModel) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -47,8 +51,39 @@ internal fun RecordItem( bottom = ReedTheme.spacing.spacing4, ), ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(getEmotionImageResourceByDisplayName(recordInfo.emotionTags[0])), + contentDescription = "Emotion Graphic", + modifier = Modifier + .size(ReedTheme.spacing.spacing8) + .clip(CircleShape) + .background(ReedTheme.colors.basePrimary), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Text( + text = "#${recordInfo.emotionTags[0]}", + color = ReedTheme.colors.contentBrand, + style = ReedTheme.typography.body1SemiBold, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_more_vertical), + contentDescription = "More Vertical Icon", + modifier = Modifier + .size(ReedTheme.spacing.spacing5) + .clickable { + onRecordMenuClick(recordInfo) + }, + tint = ReedTheme.colors.contentTertiary, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) Text( - text = "\"$quote\"", + text = "\"${recordInfo.quote}\"", color = ReedTheme.colors.contentSecondary, overflow = TextOverflow.Ellipsis, maxLines = 4, @@ -59,31 +94,17 @@ internal fun RecordItem( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Image( - painter = painterResource(getEmotionImageResourceByDisplayName(emotionTags[0])), - contentDescription = "Emotion Graphic", - modifier = Modifier - .size(40.dp) - .clip(CircleShape), + Text( + text = recordInfo.createdAt.toFormattedDate(), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, ) - Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) - Column { - Text( - text = "#${emotionTags[0]}", - color = ReedTheme.colors.contentBrand, - style = ReedTheme.typography.label1SemiBold, - ) - Text( - text = createdAt, - color = ReedTheme.colors.contentTertiary, - style = ReedTheme.typography.caption1Regular, - ) - } Spacer(modifier = Modifier.weight(1f)) Text( - text = "${pageNumber}p", - color = ReedTheme.colors.contentBrand, + text = "${recordInfo.pageNumber}p", + color = ReedTheme.colors.contentTertiary, style = ReedTheme.typography.body2Medium, + fontStyle = FontStyle.Italic, ) } } @@ -104,10 +125,13 @@ fun getEmotionImageResourceByDisplayName(displayName: String): Int { private fun RecordItemPreview() { ReedTheme { RecordItem( - quote = "", - emotionTags = persistentListOf(), - pageNumber = 12, - createdAt = "2025.06.25", + recordInfo = ReadingRecordModel( + quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", + emotionTags = persistentListOf("따뜻함"), + pageNumber = 12, + createdAt = "2025.06.25", + ), + onRecordMenuClick = {}, ) } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt index 6f4fa36f..98220e8e 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectable import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue @@ -24,6 +25,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.ui.component.ReedBottomSheet @@ -52,15 +54,13 @@ internal fun RecordSortBottomSheet( ) { Column( modifier = modifier - .padding( - start = ReedTheme.spacing.spacing5, - top = ReedTheme.spacing.spacing5, - end = ReedTheme.spacing.spacing5, - ), + .padding(top = ReedTheme.spacing.spacing5), horizontalAlignment = Alignment.CenterHorizontally, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing5), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( @@ -77,12 +77,12 @@ internal fun RecordSortBottomSheet( }, ) } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center, ) { - recordSortItems.forEach { item -> + recordSortItems.forEachIndexed { index, item -> RecordSortItem( item = item, selected = item == currentRecordSort, @@ -91,7 +91,16 @@ internal fun RecordSortBottomSheet( onItemSelected(item) } }, + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), ) + + if (index < recordSortItems.lastIndex) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = ReedTheme.colors.dividerSm, + ) + } } } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt index cd283966..ac9d84d1 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt @@ -52,14 +52,14 @@ internal fun SeedItem( Text( text = emotion.name.displayName, color = emotion.name.toTextColor(), - style = ReedTheme.typography.body2Medium, + style = ReedTheme.typography.label2SemiBold, ) } Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) Text( text = "${emotion.count}", color = ReedTheme.colors.contentSecondary, - style = ReedTheme.typography.body2Medium, + style = ReedTheme.typography.label2Regular, ) } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardPresenter.kt new file mode 100644 index 00000000..19958164 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardPresenter.kt @@ -0,0 +1,93 @@ +package com.ninecraft.booket.feature.detail.card + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.feature.screens.RecordCardScreen +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.components.ActivityRetainedComponent + +class RecordCardPresenter @AssistedInject constructor( + @Assisted private val screen: RecordCardScreen, + @Assisted private val navigator: Navigator, + private val analyticsHelper: AnalyticsHelper, +) : Presenter { + + companion object { + private const val RECORD_CARD_SAVE = "record_card_save" + private const val RECORD_CARD_SHARE = "record_card_share" + } + + @Composable + override fun present(): RecordCardUiState { + var isLoading by rememberRetained { mutableStateOf(false) } + var isCapturing by rememberRetained { mutableStateOf(false) } + var isSharing by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } + + fun handleEvent(event: RecordCardUiEvent) { + when (event) { + is RecordCardUiEvent.InitSideEffect -> { + sideEffect = null + } + + is RecordCardUiEvent.OnBackClick -> { + navigator.pop() + } + + is RecordCardUiEvent.OnSaveClick -> { + isCapturing = true + } + + is RecordCardUiEvent.OnShareClick -> { + isSharing = true + } + + is RecordCardUiEvent.SaveRecordCard -> { + isCapturing = false + analyticsHelper.logEvent(RECORD_CARD_SAVE) + sideEffect = RecordCardSideEffect.SaveImage(event.bitmap) + } + + is RecordCardUiEvent.ShareRecordCard -> { + isSharing = false + analyticsHelper.logEvent(RECORD_CARD_SHARE) + sideEffect = RecordCardSideEffect.ShareImage(event.bitmap) + } + } + } + + ImpressionEffect { + analyticsHelper.logScreenView(screen.name) + } + + return RecordCardUiState( + isLoading = isLoading, + quote = screen.quote, + bookTitle = screen.bookTitle, + emotionTag = screen.emotionTag, + isCapturing = isCapturing, + isSharing = isSharing, + sideEffect = sideEffect, + eventSink = ::handleEvent, + ) + } +} + +@CircuitInject(RecordCardScreen::class, ActivityRetainedComponent::class) +@AssistedFactory +fun interface Factory { + fun create( + screen: RecordCardScreen, + navigator: Navigator, + ): RecordCardPresenter +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardSideEffect.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardSideEffect.kt new file mode 100644 index 00000000..2a98cee7 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardSideEffect.kt @@ -0,0 +1,55 @@ +package com.ninecraft.booket.feature.detail.card + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.platform.LocalContext +import com.ninecraft.booket.core.common.extensions.externalShareForBitmap +import com.ninecraft.booket.core.common.extensions.saveImageToGallery +import com.ninecraft.booket.feature.detail.R +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleRecordCardSideEffects( + state: RecordCardUiState, + recordCardGraphicsLayer: GraphicsLayer, + eventSink: (RecordCardUiEvent) -> Unit, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is RecordCardSideEffect.ShowToast -> { + Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + } + + is RecordCardSideEffect.SaveImage -> { + context.saveImageToGallery(state.sideEffect.bitmap) + Toast.makeText(context, context.getString(R.string.save_image_complete), Toast.LENGTH_SHORT).show() + } + + is RecordCardSideEffect.ShareImage -> { + context.externalShareForBitmap(state.sideEffect.bitmap) + } + + else -> {} + } + + if (state.sideEffect != null) { + eventSink(RecordCardUiEvent.InitSideEffect) + } + } + + LaunchedEffect(state.isCapturing) { + if (state.isCapturing) { + eventSink(RecordCardUiEvent.SaveRecordCard(recordCardGraphicsLayer.toImageBitmap())) + } + } + + LaunchedEffect(state.isSharing) { + if (state.isSharing) { + eventSink(RecordCardUiEvent.ShareRecordCard(recordCardGraphicsLayer.toImageBitmap())) + } + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUi.kt new file mode 100644 index 00000000..97e39ca7 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUi.kt @@ -0,0 +1,157 @@ +package com.ninecraft.booket.feature.detail.card + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +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.rememberGraphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.extensions.captureToGraphicsLayer +import com.ninecraft.booket.core.designsystem.DevicePreview +import com.ninecraft.booket.core.designsystem.component.button.ReedButton +import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle +import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedTopAppBar +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.feature.detail.card.component.RecordCard +import com.ninecraft.booket.feature.screens.RecordCardScreen +import com.slack.circuit.codegen.annotations.CircuitInject +import dagger.hilt.android.components.ActivityRetainedComponent +import com.ninecraft.booket.core.designsystem.R as designR + +@CircuitInject(RecordCardScreen::class, ActivityRetainedComponent::class) +@Composable +internal fun RecordCardUi( + state: RecordCardUiState, + modifier: Modifier = Modifier, +) { + val recordCardGraphicsLayer = rememberGraphicsLayer() + + HandleRecordCardSideEffects( + state = state, + recordCardGraphicsLayer = recordCardGraphicsLayer, + eventSink = state.eventSink, + ) + + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = White, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ReedTopAppBar( + startIconRes = designR.drawable.ic_chevron_left, + startIconDescription = "Back Icon", + startIconOnClick = { + state.eventSink(RecordCardUiEvent.OnBackClick) + }, + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = ReedTheme.spacing.spacing5) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RecordCard( + quote = state.quote, + bookTitle = state.bookTitle, + emotionTag = state.emotionTag, + modifier = Modifier + .padding(top = ReedTheme.spacing.spacing5) + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .captureToGraphicsLayer(recordCardGraphicsLayer), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Text( + text = stringResource(R.string.share_impressive_quote), + color = ReedTheme.colors.contentSecondary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.label1Medium, + ) + Spacer(modifier = Modifier.height(50.dp)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + bottom = ReedTheme.spacing.spacing4, + ), + ) { + ReedButton( + onClick = { + state.eventSink(RecordCardUiEvent.OnSaveClick) + }, + text = stringResource(R.string.save_image), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.weight(1f), + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_save), + contentDescription = "Save Icon", + tint = Color.Unspecified, + ) + }, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + ReedButton( + onClick = { + state.eventSink(RecordCardUiEvent.OnShareClick) + }, + text = stringResource(R.string.share_card), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier.weight(1f), + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_share), + contentDescription = "Share Icon", + tint = Color.Unspecified, + ) + }, + ) + } + } + } +} + +@DevicePreview +@Composable +private fun RecordCardUiPreview() { + ReedTheme { + RecordCardUi( + state = RecordCardUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUiState.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUiState.kt new file mode 100644 index 00000000..c9880356 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUiState.kt @@ -0,0 +1,39 @@ +package com.ninecraft.booket.feature.detail.card + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.ImageBitmap +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import java.util.UUID + +data class RecordCardUiState( + val isLoading: Boolean = false, + val quote: String = "", + val bookTitle: String = "", + val author: String = "", + val emotionTag: String = "", + val isCapturing: Boolean = false, + val isSharing: Boolean = false, + val sideEffect: RecordCardSideEffect? = null, + val eventSink: (RecordCardUiEvent) -> Unit, +) : CircuitUiState + +@Immutable +sealed interface RecordCardSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : RecordCardSideEffect + + data class SaveImage(val bitmap: ImageBitmap) : RecordCardSideEffect + data class ShareImage(val bitmap: ImageBitmap) : RecordCardSideEffect +} + +sealed interface RecordCardUiEvent : CircuitUiEvent { + data object InitSideEffect : RecordCardUiEvent + data object OnBackClick : RecordCardUiEvent + data object OnSaveClick : RecordCardUiEvent + data object OnShareClick : RecordCardUiEvent + data class SaveRecordCard(val bitmap: ImageBitmap) : RecordCardUiEvent + data class ShareRecordCard(val bitmap: ImageBitmap) : RecordCardUiEvent +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/component/RecordCard.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/component/RecordCard.kt new file mode 100644 index 00000000..2f9b7bcc --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/component/RecordCard.kt @@ -0,0 +1,97 @@ +package com.ninecraft.booket.feature.detail.card.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.EmotionTag +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.feature.detail.R + +@Composable +internal fun RecordCard( + quote: String, + bookTitle: String, + emotionTag: String, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + Image( + painter = painterResource(getEmotionCardImage(emotionTag)), + contentDescription = "Record Card Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = ReedTheme.spacing.spacing8), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing16)) + Text( + text = quote, + modifier = Modifier.fillMaxWidth(), + color = ReedTheme.colors.contentPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 7, + style = ReedTheme.typography.quoteMedium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "『$bookTitle』", + color = ReedTheme.colors.contentPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = ReedTheme.typography.quoteMedium.copy( + fontSize = 16.sp, + lineHeight = 16.sp * 1.5f, + letterSpacing = 16.sp * 0.02f, + ), + ) + } + } + } +} + +private fun getEmotionCardImage(emotionTag: String): Int { + return when (emotionTag) { + EmotionTag.WARMTH.label -> R.drawable.img_record_card_warm + EmotionTag.JOY.label -> R.drawable.img_record_card_joy + EmotionTag.SADNESS.label -> R.drawable.img_record_card_sad + EmotionTag.INSIGHT.label -> R.drawable.img_record_card_insight + else -> R.drawable.img_record_card_warm + } +} + +@ComponentPreview +@Composable +private fun RecordCardPreview() { + ReedTheme { + RecordCard( + quote = "이 세상에 집이라 이름 붙일 수 없는 것이 있다면 그건 바로 여기, 내가 앉아 있는 이곳일 것이다.", + bookTitle = "샤이닝", + emotionTag = EmotionTag.WARMTH.label, + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt index 9b65abef..f5921683 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt @@ -1,21 +1,27 @@ package com.ninecraft.booket.feature.detail.record import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.model.RecordDetailModel import com.ninecraft.booket.feature.screens.LoginScreen +import com.ninecraft.booket.feature.screens.RecordCardScreen import com.ninecraft.booket.feature.screens.RecordDetailScreen +import com.ninecraft.booket.feature.screens.RecordEditScreen +import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs +import com.ninecraft.booket.feature.screens.extensions.delayedGoTo import com.orhanobut.logger.Logger +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -26,13 +32,21 @@ class RecordDetailPresenter @AssistedInject constructor( @Assisted private val screen: RecordDetailScreen, @Assisted private val navigator: Navigator, private val repository: RecordRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { + companion object { + private const val RECORD_DELETE = "record_delete" + private const val RECORD_DELETE_COMPLETE = "record_delete_complete" + } + @Composable override fun present(): RecordDetailUiState { val scope = rememberCoroutineScope() var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var recordDetailInfo by rememberRetained { mutableStateOf(RecordDetailModel()) } + var isRecordMenuBottomSheetVisible by rememberRetained { mutableStateOf(false) } + var isRecordDeleteDialogVisible by rememberRetained { mutableStateOf(false) } var sideEffect by rememberRetained { mutableStateOf(null) } fun getRecordDetail(readingRecordId: String) { @@ -62,36 +76,126 @@ class RecordDetailPresenter @AssistedInject constructor( } } + fun deleteRecord(readingRecordId: String, onSuccess: () -> Unit) { + scope.launch { + repository.deleteRecord(readingRecordId = readingRecordId) + .onSuccess { + analyticsHelper.logEvent(RECORD_DELETE_COMPLETE) + onSuccess() + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = RecordDetailSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } + } + fun handleEvent(event: RecordDetailUiEvent) { when (event) { - RecordDetailUiEvent.OnCloseClicked -> { + is RecordDetailUiEvent.OnCloseClick -> { navigator.pop() } - RecordDetailUiEvent.onRetryClick -> { + is RecordDetailUiEvent.OnRetryClick -> { getRecordDetail(screen.recordId) } + + is RecordDetailUiEvent.OnRecordMenuClick -> { + isRecordMenuBottomSheetVisible = true + } + + is RecordDetailUiEvent.OnRecordMenuBottomSheetDismiss -> { + isRecordMenuBottomSheetVisible = false + } + + is RecordDetailUiEvent.OnRecordDeleteDialogDismiss -> { + isRecordDeleteDialogVisible = false + } + + is RecordDetailUiEvent.OnShareRecordClick -> { + isRecordMenuBottomSheetVisible = false + scope.launch { + navigator.delayedGoTo( + RecordCardScreen( + quote = recordDetailInfo.quote, + bookTitle = recordDetailInfo.bookTitle, + emotionTag = recordDetailInfo.emotionTags[0], + ), + ) + } + } + + is RecordDetailUiEvent.OnEditRecordClick -> { + isRecordMenuBottomSheetVisible = false + navigator.goTo( + RecordEditScreen( + RecordEditArgs( + id = recordDetailInfo.id, + pageNumber = recordDetailInfo.pageNumber, + quote = recordDetailInfo.quote, + review = recordDetailInfo.review, + emotionTags = recordDetailInfo.emotionTags, + bookTitle = recordDetailInfo.bookTitle, + bookPublisher = recordDetailInfo.bookPublisher, + bookCoverImageUrl = recordDetailInfo.bookCoverImageUrl, + author = recordDetailInfo.author, + ), + ), + ) + } + + is RecordDetailUiEvent.OnDeleteRecordClick -> { + analyticsHelper.logEvent(RECORD_DELETE) + isRecordMenuBottomSheetVisible = false + isRecordDeleteDialogVisible = true + } + + is RecordDetailUiEvent.OnDelete -> { + isRecordDeleteDialogVisible = false + deleteRecord( + readingRecordId = screen.recordId, + onSuccess = { + navigator.pop() + }, + ) + } } } - LaunchedEffect(Unit) { + RememberedEffect(Unit) { getRecordDetail(screen.recordId) } + ImpressionEffect { + analyticsHelper.logScreenView(screen.name) + } + return RecordDetailUiState( uiState = uiState, recordDetailInfo = recordDetailInfo, + isRecordMenuBottomSheetVisible = isRecordMenuBottomSheetVisible, + isRecordDeleteDialogVisible = isRecordDeleteDialogVisible, sideEffect = sideEffect, eventSink = ::handleEvent, ) } -} -@CircuitInject(RecordDetailScreen::class, ActivityRetainedComponent::class) -@AssistedFactory -fun interface Factory { - fun create( - screen: RecordDetailScreen, - navigator: Navigator, - ): RecordDetailPresenter + @CircuitInject(RecordDetailScreen::class, ActivityRetainedComponent::class) + @AssistedFactory + fun interface Factory { + fun create( + screen: RecordDetailScreen, + navigator: Navigator, + ): RecordDetailPresenter + } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt index 2c32d626..ebd2bea1 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt @@ -1,53 +1,50 @@ package com.ninecraft.booket.feature.detail.record -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row 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.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.ComponentPreview -import com.ninecraft.booket.core.designsystem.component.NetworkImage import com.ninecraft.booket.core.designsystem.component.ReedDivider import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.core.model.RecordDetailModel import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.core.ui.component.ReedErrorUi +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.core.ui.component.ReedTopAppBar import com.ninecraft.booket.feature.detail.R -import com.ninecraft.booket.feature.detail.record.component.QuoteBox -import com.ninecraft.booket.feature.detail.record.component.ReviewBox +import com.ninecraft.booket.feature.detail.record.component.BookItem +import com.ninecraft.booket.feature.detail.record.component.QuoteItem +import com.ninecraft.booket.feature.detail.record.component.RecordMenuBottomSheet +import com.ninecraft.booket.feature.detail.record.component.ReviewItem import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.coroutines.launch import com.ninecraft.booket.core.designsystem.R as designR +@OptIn(ExperimentalMaterial3Api::class) @CircuitInject(RecordDetailScreen::class, ActivityRetainedComponent::class) @Composable internal fun RecordDetailUi( state: RecordDetailUiState, modifier: Modifier = Modifier, ) { + val recordMenuBottomSheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + HandleRecordDetailSideEffects( state = state, ) @@ -67,98 +64,77 @@ internal fun RecordDetailUi( startIconRes = designR.drawable.ic_close, startIconDescription = "Close Icon", startIconOnClick = { - state.eventSink(RecordDetailUiEvent.OnCloseClicked) + state.eventSink(RecordDetailUiEvent.OnCloseClick) + }, + endIconRes = designR.drawable.ic_more_vertical, + endIconDescription = "More Vertical Icon", + endIconOnClick = { + state.eventSink(RecordDetailUiEvent.OnRecordMenuClick) }, ) - ReviewDetailContent(state = state) + RecordDetailContent(state = state) } } + + if (state.isRecordMenuBottomSheetVisible) { + RecordMenuBottomSheet( + onDismissRequest = { + state.eventSink(RecordDetailUiEvent.OnRecordMenuBottomSheetDismiss) + }, + sheetState = recordMenuBottomSheetState, + onShareRecordClick = { + state.eventSink(RecordDetailUiEvent.OnShareRecordClick) + }, + onEditRecordClick = { + coroutineScope.launch { + recordMenuBottomSheetState.hide() + state.eventSink(RecordDetailUiEvent.OnEditRecordClick) + } + }, + onDeleteRecordClick = { + state.eventSink(RecordDetailUiEvent.OnDeleteRecordClick) + }, + ) + } + + if (state.isRecordDeleteDialogVisible) { + ReedDialog( + title = stringResource(R.string.record_delete_dialog_title), + confirmButtonText = stringResource(R.string.record_delete_dialog_delete), + onConfirmRequest = { + state.eventSink(RecordDetailUiEvent.OnDelete) + }, + dismissButtonText = stringResource(R.string.record_delete_dialog_cancel), + onDismissRequest = { + state.eventSink(RecordDetailUiEvent.OnRecordDeleteDialogDismiss) + }, + ) + } } @Composable -private fun ReviewDetailContent( +private fun RecordDetailContent( state: RecordDetailUiState, modifier: Modifier = Modifier, ) { when (state.uiState) { is UiState.Idle -> {} is UiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } + ReedLoadingIndicator() } is UiState.Success -> { - Row( - modifier = modifier - .fillMaxWidth() - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - NetworkImage( - imageUrl = state.recordDetailInfo.bookCoverImageUrl, - contentDescription = "Book CoverImage", - modifier = Modifier - .padding(end = ReedTheme.spacing.spacing4) - .width(46.dp) - .height(68.dp) - .clip(RoundedCornerShape(size = ReedTheme.radius.xs)), - placeholder = painterResource(designR.drawable.ic_placeholder), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = state.recordDetailInfo.bookTitle, - color = ReedTheme.colors.contentPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = ReedTheme.typography.body1SemiBold, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val authorMaxWidth = maxWidth * 0.7f - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = state.recordDetailInfo.author, - color = ReedTheme.colors.contentTertiary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = ReedTheme.typography.label1Medium, - modifier = Modifier.widthIn(max = authorMaxWidth), - ) - Spacer(Modifier.width(ReedTheme.spacing.spacing1)) - VerticalDivider( - modifier = Modifier.height(14.dp), - thickness = 1.dp, - color = ReedTheme.colors.contentTertiary, - ) - Spacer(Modifier.width(ReedTheme.spacing.spacing1)) - Text( - text = state.recordDetailInfo.bookPublisher, - color = ReedTheme.colors.contentTertiary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = ReedTheme.typography.label1Medium, - modifier = Modifier.weight(1f, fill = false), - ) - } - } - } - } + BookItem( + imageUrl = state.recordDetailInfo.bookCoverImageUrl, + bookTitle = state.recordDetailInfo.bookTitle, + author = state.recordDetailInfo.author, + publisher = state.recordDetailInfo.bookPublisher, + ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) ReedDivider() Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(horizontal = ReedTheme.spacing.spacing5), ) { @@ -168,7 +144,7 @@ private fun ReviewDetailContent( style = ReedTheme.typography.body1Medium, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - QuoteBox( + QuoteItem( quote = state.recordDetailInfo.quote, page = state.recordDetailInfo.pageNumber, ) @@ -179,7 +155,7 @@ private fun ReviewDetailContent( style = ReedTheme.typography.body1Medium, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReviewBox( + ReviewItem( emotion = state.recordDetailInfo.emotionTags.getOrNull(0) ?: "", createdAt = state.recordDetailInfo.createdAt, review = state.recordDetailInfo.review, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt index a472fc1b..07ff909a 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt @@ -6,6 +6,7 @@ import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.util.UUID +@Immutable sealed interface UiState { data object Idle : UiState data object Loading : UiState @@ -16,6 +17,8 @@ sealed interface UiState { data class RecordDetailUiState( val uiState: UiState = UiState.Idle, val recordDetailInfo: RecordDetailModel = RecordDetailModel(), + val isRecordMenuBottomSheetVisible: Boolean = false, + val isRecordDeleteDialogVisible: Boolean = false, val sideEffect: RecordDetailSideEffect? = null, val eventSink: (RecordDetailUiEvent) -> Unit, ) : CircuitUiState @@ -29,6 +32,13 @@ sealed interface RecordDetailSideEffect { } sealed interface RecordDetailUiEvent : CircuitUiEvent { - data object OnCloseClicked : RecordDetailUiEvent - data object onRetryClick : RecordDetailUiEvent + data object OnCloseClick : RecordDetailUiEvent + data object OnRetryClick : RecordDetailUiEvent + data object OnRecordMenuClick : RecordDetailUiEvent + data object OnRecordMenuBottomSheetDismiss : RecordDetailUiEvent + data object OnRecordDeleteDialogDismiss : RecordDetailUiEvent + data object OnShareRecordClick : RecordDetailUiEvent + data object OnEditRecordClick : RecordDetailUiEvent + data object OnDeleteRecordClick : RecordDetailUiEvent + data object OnDelete : RecordDetailUiEvent } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/BookItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/BookItem.kt new file mode 100644 index 00000000..98e6d98d --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/BookItem.kt @@ -0,0 +1,110 @@ +package com.ninecraft.booket.feature.detail.record.component + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +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.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.R +import com.ninecraft.booket.core.designsystem.component.NetworkImage +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +internal fun BookItem( + imageUrl: String, + bookTitle: String, + author: String, + publisher: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + imageUrl = imageUrl, + contentDescription = "Book CoverImage", + modifier = Modifier + .padding(end = ReedTheme.spacing.spacing4) + .width(46.dp) + .height(68.dp) + .clip(RoundedCornerShape(size = ReedTheme.radius.xs)), + placeholder = painterResource(R.drawable.ic_placeholder), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = bookTitle, + color = ReedTheme.colors.contentPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.body1SemiBold, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val authorMaxWidth = maxWidth * 0.7f + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = author, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.widthIn(max = authorMaxWidth), + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + VerticalDivider( + modifier = Modifier.height(14.dp), + thickness = 1.dp, + color = ReedTheme.colors.contentTertiary, + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = publisher, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + } + } +} + +@ComponentPreview +@Composable +private fun BookItemPreview() { + ReedTheme { + BookItem( + imageUrl = "", + bookTitle = "여름은 오래 그곳에 남아", + author = "마쓰이에 마사시", + publisher = "비채", + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteBox.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt similarity index 93% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteBox.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt index 73043ac4..2e1158f1 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteBox.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt @@ -9,12 +9,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme @Composable -fun QuoteBox( +internal fun QuoteItem( quote: String, page: Int, modifier: Modifier = Modifier, @@ -43,6 +44,7 @@ fun QuoteBox( color = ReedTheme.colors.contentBrand, textAlign = TextAlign.End, style = ReedTheme.typography.label1Medium, + fontStyle = FontStyle.Italic, ) } } @@ -52,7 +54,7 @@ fun QuoteBox( @Composable private fun QuoteBoxPreview() { ReedTheme { - QuoteBox( + QuoteItem( quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", page = 99, ) diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/RecordMenuBottomSheet.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/RecordMenuBottomSheet.kt new file mode 100644 index 00000000..e89f88d1 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/RecordMenuBottomSheet.kt @@ -0,0 +1,137 @@ +package com.ninecraft.booket.feature.detail.record.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.ninecraft.booket.core.common.extensions.noRippleClickable +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedBottomSheet +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.core.designsystem.R as designR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RecordMenuBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + onShareRecordClick: () -> Unit, + onEditRecordClick: () -> Unit, + onDeleteRecordClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ReedBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + ) { + Column( + modifier = modifier + .padding(top = ReedTheme.spacing.spacing5), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RecordMenuItem( + iconResId = designR.drawable.ic_share_2, + iconDescription = "Share Icon", + label = stringResource(R.string.record_detail_share), + color = ReedTheme.colors.contentPrimary, + onClick = { onShareRecordClick() }, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = ReedTheme.border.border1, + color = ReedTheme.colors.dividerSm, + ) + RecordMenuItem( + iconResId = designR.drawable.ic_edit_3, + iconDescription = "Edit Icon", + label = stringResource(R.string.record_detail_edit), + color = ReedTheme.colors.contentPrimary, + onClick = { onEditRecordClick() }, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = ReedTheme.border.border1, + color = ReedTheme.colors.dividerSm, + ) + RecordMenuItem( + iconResId = designR.drawable.ic_trash, + iconDescription = "Trash Icon", + label = stringResource(R.string.record_detail_delete), + color = ReedTheme.colors.contentError, + onClick = { onDeleteRecordClick() }, + ) + } + } +} + +@Composable +private fun RecordMenuItem( + iconResId: Int, + iconDescription: String, + label: String, + color: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable { + onClick() + } + .padding( + vertical = ReedTheme.spacing.spacing5, + horizontal = ReedTheme.spacing.spacing6, + ), + ) { + Icon( + imageVector = ImageVector.vectorResource(iconResId), + contentDescription = iconDescription, + tint = color, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing3)) + Text( + text = label, + color = color, + style = ReedTheme.typography.body1Medium, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ComponentPreview +@Composable +private fun ChoiceBottomSheetPreview() { + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + RecordMenuBottomSheet( + onDismissRequest = {}, + sheetState = sheetState, + onShareRecordClick = {}, + onDeleteRecordClick = {}, + onEditRecordClick = {}, + ) +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt similarity index 95% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt index 42aa6103..8c2fa6e5 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt @@ -24,7 +24,7 @@ import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.feature.detail.book.component.getEmotionImageResourceByDisplayName @Composable -fun ReviewBox( +internal fun ReviewItem( emotion: String, createdAt: String, review: String, @@ -52,7 +52,8 @@ fun ReviewBox( contentDescription = "Emotion Graphic", modifier = Modifier .size(ReedTheme.spacing.spacing10) - .clip(CircleShape), + .clip(CircleShape) + .background(ReedTheme.colors.basePrimary), ) Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) Text( @@ -81,7 +82,7 @@ fun ReviewBox( @Composable private fun ReviewBoxPreview() { ReedTheme { - ReviewBox( + ReviewItem( emotion = "따뜻함", review = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다", createdAt = "2025.06.25", diff --git a/feature/detail/src/main/res/drawable/ic_save.xml b/feature/detail/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..319f5ea9 --- /dev/null +++ b/feature/detail/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/detail/src/main/res/drawable/ic_share.xml b/feature/detail/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000..fcb8180b --- /dev/null +++ b/feature/detail/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/detail/src/main/res/drawable/img_record_card_insight.webp b/feature/detail/src/main/res/drawable/img_record_card_insight.webp new file mode 100644 index 00000000..231b4d6c Binary files /dev/null and b/feature/detail/src/main/res/drawable/img_record_card_insight.webp differ diff --git a/feature/detail/src/main/res/drawable/img_record_card_joy.webp b/feature/detail/src/main/res/drawable/img_record_card_joy.webp new file mode 100644 index 00000000..8b5f4b1c Binary files /dev/null and b/feature/detail/src/main/res/drawable/img_record_card_joy.webp differ diff --git a/feature/detail/src/main/res/drawable/img_record_card_sad.webp b/feature/detail/src/main/res/drawable/img_record_card_sad.webp new file mode 100644 index 00000000..b5ecf58b Binary files /dev/null and b/feature/detail/src/main/res/drawable/img_record_card_sad.webp differ diff --git a/feature/detail/src/main/res/drawable/img_record_card_warm.webp b/feature/detail/src/main/res/drawable/img_record_card_warm.webp new file mode 100644 index 00000000..f25d06af Binary files /dev/null and b/feature/detail/src/main/res/drawable/img_record_card_warm.webp differ diff --git a/feature/detail/src/main/res/values/strings.xml b/feature/detail/src/main/res/values/strings.xml index 6cdc4748..49889711 100644 --- a/feature/detail/src/main/res/values/strings.xml +++ b/feature/detail/src/main/res/values/strings.xml @@ -10,4 +10,15 @@ 내가 모은 씨앗 첫 기록을 남겨 보세요!\n나만의 아카이브를 만들 수 있어요. 독서 기록 추가 + 공유하기 + 수정하기 + 삭제하기 + 삭제하면 기록을 복구할 수 없어요.\n정말 삭제하시겠어요? + 삭제 + 취소 + 도서 삭제하기 + 이미지 저장 + 이미지를 저장했습니다! + 카드 공유 + 인상 깊은 문장을\n공유해보세요! diff --git a/feature/edit/.gitignore b/feature/edit/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/edit/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/edit/build.gradle.kts b/feature/edit/build.gradle.kts new file mode 100644 index 00000000..092b4849 --- /dev/null +++ b/feature/edit/build.gradle.kts @@ -0,0 +1,23 @@ +@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") + +plugins { + alias(libs.plugins.booket.android.feature) + alias(libs.plugins.booket.kotlin.library.serialization) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "com.ninecraft.booket.feature.edit" +} + +ksp { + arg("circuit.codegen.mode", "hilt") +} + +dependencies { + implementations( + libs.kotlinx.collections.immutable, + + libs.logger, + ) +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditPresenter.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditPresenter.kt new file mode 100644 index 00000000..d97243a5 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditPresenter.kt @@ -0,0 +1,67 @@ +package com.ninecraft.booket.feature.edit.emotion + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.designsystem.EmotionTag +import com.ninecraft.booket.feature.screens.EmotionEditScreen +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.collections.immutable.toPersistentList + +class EmotionEditPresenter @AssistedInject constructor( + @Assisted private val screen: EmotionEditScreen, + @Assisted private val navigator: Navigator, +) : Presenter { + @Composable + override fun present(): EmotionEditUiState { + var selectedEmotion by rememberRetained { mutableStateOf(screen.emotion) } + val emotionTags by rememberRetained { mutableStateOf(EmotionTag.entries.toPersistentList()) } + val isEditButtonEnabled by remember { + derivedStateOf { + selectedEmotion != screen.emotion + } + } + + fun handleEvent(event: EmotionEditUiEvent) { + when (event) { + is EmotionEditUiEvent.OnBackClick -> { + navigator.pop() + } + + is EmotionEditUiEvent.OnSelectEmotion -> { + selectedEmotion = event.emotion + } + + is EmotionEditUiEvent.OnEditButtonClick -> { + navigator.pop(result = EmotionEditScreen.Result(selectedEmotion)) + } + } + } + + return EmotionEditUiState( + selectedEmotion = selectedEmotion, + emotionTags = emotionTags, + isEditButtonEnabled = isEditButtonEnabled, + eventSink = ::handleEvent, + ) + } +} + +@CircuitInject(EmotionEditScreen::class, ActivityRetainedComponent::class) +@AssistedFactory +fun interface Factory { + fun create( + screen: EmotionEditScreen, + navigator: Navigator, + ): EmotionEditPresenter +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditUi.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditUi.kt new file mode 100644 index 00000000..0ddc68c2 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditUi.kt @@ -0,0 +1,177 @@ +package com.ninecraft.booket.feature.edit.emotion + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.extensions.clickableSingle +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.EmotionTag +import com.ninecraft.booket.core.designsystem.component.button.ReedButton +import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle +import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.feature.edit.R +import com.ninecraft.booket.feature.screens.EmotionEditScreen +import com.slack.circuit.codegen.annotations.CircuitInject +import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.collections.immutable.toPersistentList + +@CircuitInject(EmotionEditScreen::class, ActivityRetainedComponent::class) +@Composable +internal fun EmotionEditUi( + state: EmotionEditUiState, + modifier: Modifier = Modifier, +) { + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = White, + ) { innerPadding -> + Column( + modifier = modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ReedBackTopAppBar( + onBackClick = { + state.eventSink(EmotionEditUiEvent.OnBackClick) + }, + ) + EmotionEditContent(state = state) + } + } +} + +@Composable +private fun EmotionEditContent( + state: EmotionEditUiState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing4, + end = ReedTheme.spacing.spacing5, + ), + ) { + Text( + text = stringResource(R.string.edit_emotion_title), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.heading1Bold, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + Text( + text = stringResource(R.string.edit_emotion_description), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing3), + horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing3), + content = { + items(state.emotionTags) { tag -> + EmotionItem( + emotionTag = tag, + onClick = { + state.eventSink(EmotionEditUiEvent.OnSelectEmotion(tag.label)) + }, + isSelected = state.selectedEmotion == tag.label, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + ) + ReedButton( + onClick = { + state.eventSink(EmotionEditUiEvent.OnEditButtonClick) + }, + colorStyle = ReedButtonColorStyle.PRIMARY, + sizeStyle = largeButtonStyle, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = ReedTheme.spacing.spacing4), + enabled = state.isEditButtonEnabled, + text = stringResource(R.string.edit_emotion_edit), + ) + } +} + +@Composable +private fun EmotionItem( + emotionTag: EmotionTag, + onClick: () -> Unit, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .height(214.dp) + .background( + color = ReedTheme.colors.bgTertiary, + shape = RoundedCornerShape(ReedTheme.radius.md), + ) + .then( + if (isSelected) Modifier.border( + width = 2.dp, + color = ReedTheme.colors.borderBrand, + shape = RoundedCornerShape(ReedTheme.radius.md), + ) + else Modifier, + ) + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .clickableSingle { + onClick() + }, + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(emotionTag.graphic), + contentDescription = "Emotion Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } +} + +@ComponentPreview +@Composable +private fun EmotionEditUiPreview() { + ReedTheme { + val emotionTags = EmotionTag.entries.toPersistentList() + + EmotionEditUi( + state = EmotionEditUiState( + emotionTags = emotionTags, + eventSink = {}, + ), + ) + } +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditUiState.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditUiState.kt new file mode 100644 index 00000000..9849988e --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/EmotionEditUiState.kt @@ -0,0 +1,20 @@ +package com.ninecraft.booket.feature.edit.emotion + +import com.ninecraft.booket.core.designsystem.EmotionTag +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class EmotionEditUiState( + val selectedEmotion: String = "", + val isEditButtonEnabled: Boolean = false, + val emotionTags: ImmutableList = persistentListOf(), + val eventSink: (EmotionEditUiEvent) -> Unit, +) : CircuitUiState + +sealed interface EmotionEditUiEvent : CircuitUiEvent { + data object OnBackClick : EmotionEditUiEvent + data class OnSelectEmotion(val emotion: String) : EmotionEditUiEvent + data object OnEditButtonClick : EmotionEditUiEvent +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/HandleRecordEditSideEffect.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/HandleRecordEditSideEffect.kt new file mode 100644 index 00000000..33f99673 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/HandleRecordEditSideEffect.kt @@ -0,0 +1,23 @@ +package com.ninecraft.booket.feature.edit.record + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleRecordEditSideEffects( + state: RecordEditUiState, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is RecordEditSideEffect.ShowToast -> { + Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + } + + null -> {} + } + } +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditPresenter.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditPresenter.kt new file mode 100644 index 00000000..f420ff45 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditPresenter.kt @@ -0,0 +1,170 @@ +package com.ninecraft.booket.feature.edit.record + +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.data.api.repository.RecordRepository +import com.ninecraft.booket.feature.screens.EmotionEditScreen +import com.ninecraft.booket.feature.screens.LoginScreen +import com.ninecraft.booket.feature.screens.RecordEditScreen +import com.orhanobut.logger.Logger +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.foundation.rememberAnsweringNavigator +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.coroutines.launch + +class RecordEditPresenter @AssistedInject constructor( + @Assisted private val screen: RecordEditScreen, + @Assisted private val navigator: Navigator, + private val repository: RecordRepository, + private val analyticsHelper: AnalyticsHelper, +) : Presenter { + + companion object { + private const val MAX_PAGE = 4032 + private const val RECORD_EDIT = "record_edit_save" + private const val RECORD_EDIT_SAVE = "record_edit_save" + } + + @Composable + override fun present(): RecordEditUiState { + val scope = rememberCoroutineScope() + var recordInfo by rememberRetained { mutableStateOf(screen.recordInfo) } + val recordPageState = rememberTextFieldState(recordInfo.pageNumber.toString()) + val recordQuoteState = rememberTextFieldState(recordInfo.quote) + val recordImpressionState = rememberTextFieldState(recordInfo.review) + val isPageError by remember { + derivedStateOf { + val page = recordPageState.text.toString().toIntOrNull() ?: 0 + page > MAX_PAGE + } + } + val hasChanges by remember { + derivedStateOf { + val pageChanged = recordPageState.text.toString() != recordInfo.pageNumber.toString() + val quoteChanged = recordQuoteState.text.toString() != recordInfo.quote + val impressionChanged = recordImpressionState.text.toString() != recordInfo.review + val emotionChanged = recordInfo.emotionTags != screen.recordInfo.emotionTags + pageChanged || quoteChanged || impressionChanged || emotionChanged + } + } + val isSaveButtonEnabled by remember { + derivedStateOf { + recordPageState.text.isNotEmpty() && + recordQuoteState.text.isNotEmpty() && + recordImpressionState.text.isNotEmpty() && + !isPageError && + hasChanges + } + } + var sideEffect by rememberRetained { mutableStateOf(null) } + + val emotionEditNavigator = rememberAnsweringNavigator(navigator) { result -> + recordInfo = recordInfo.copy(emotionTags = listOf(result.emotion)) + } + + fun editRecord( + readingRecordId: String, + pageNumber: Int, + quote: String, + emotionTags: List, + impression: String, + onSuccess: () -> Unit = {}, + ) { + scope.launch { + repository.editRecord( + readingRecordId = readingRecordId, + pageNumber = pageNumber, + quote = quote, + emotionTags = emotionTags, + review = impression, + ).onSuccess { + analyticsHelper.logEvent(RECORD_EDIT_SAVE) + onSuccess() + }.onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = RecordEditSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } + } + + fun handleEvent(event: RecordEditUiEvent) { + when (event) { + RecordEditUiEvent.OnCloseClick -> { + navigator.pop() + } + + RecordEditUiEvent.OnClearClick -> { + recordPageState.clearText() + } + + RecordEditUiEvent.OnEmotionEditClick -> { + val emotion = recordInfo.emotionTags.firstOrNull() ?: "" + emotionEditNavigator.goTo(EmotionEditScreen(emotion)) + } + + RecordEditUiEvent.OnSaveButtonClick -> { + editRecord( + readingRecordId = recordInfo.id, + pageNumber = recordPageState.text.toString().toIntOrNull() ?: 0, + quote = recordQuoteState.text.toString(), + emotionTags = recordInfo.emotionTags, + impression = recordImpressionState.text.toString(), + onSuccess = { + navigator.pop() + }, + ) + } + } + } + + ImpressionEffect { + analyticsHelper.logScreenView(RECORD_EDIT) + } + + return RecordEditUiState( + recordInfo = recordInfo, + recordPageState = recordPageState, + recordQuoteState = recordQuoteState, + recordImpressionState = recordImpressionState, + isPageError = isPageError, + isSaveButtonEnabled = isSaveButtonEnabled, + sideEffect = sideEffect, + eventSink = ::handleEvent, + ) + } + + @CircuitInject(RecordEditScreen::class, ActivityRetainedComponent::class) + @AssistedFactory + fun interface Factory { + fun create( + screen: RecordEditScreen, + navigator: Navigator, + ): RecordEditPresenter + } +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditUi.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditUi.kt new file mode 100644 index 00000000..0a7972c2 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditUi.kt @@ -0,0 +1,235 @@ +package com.ninecraft.booket.feature.edit.record + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.button.ReedButton +import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle +import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle +import com.ninecraft.booket.core.designsystem.component.textfield.ReedRecordTextField +import com.ninecraft.booket.core.designsystem.component.textfield.digitOnlyInputTransformation +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedTopAppBar +import com.ninecraft.booket.feature.edit.R +import com.ninecraft.booket.feature.edit.record.component.BookItem +import com.ninecraft.booket.feature.screens.RecordEditScreen +import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs +import com.slack.circuit.codegen.annotations.CircuitInject +import dagger.hilt.android.components.ActivityRetainedComponent +import com.ninecraft.booket.core.designsystem.R as designR + +@CircuitInject(RecordEditScreen::class, ActivityRetainedComponent::class) +@Composable +internal fun RecordEditUi( + state: RecordEditUiState, + modifier: Modifier = Modifier, +) { + HandleRecordEditSideEffects( + state = state, + ) + + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = White, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.ime), + ) { innerPadding -> + Column( + modifier = modifier + .fillMaxSize() + .padding(innerPadding) + .imePadding(), + ) { + ReedTopAppBar( + title = stringResource(R.string.edit_record_title), + startIconRes = designR.drawable.ic_close, + startIconDescription = "Close Icon", + startIconOnClick = { + state.eventSink(RecordEditUiEvent.OnCloseClick) + }, + ) + RecordEditContent(state = state) + } + } +} + +@Composable +private fun ColumnScope.RecordEditContent(state: RecordEditUiState) { + BookItem( + imageUrl = state.recordInfo.bookCoverImageUrl, + bookTitle = state.recordInfo.bookTitle, + author = state.recordInfo.author, + publisher = state.recordInfo.bookPublisher, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = ReedTheme.border.border1, + color = ReedTheme.colors.dividerSm, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = ReedTheme.spacing.spacing5) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Text( + text = stringResource(R.string.edit_record_page_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordPageState, + recordHintRes = R.string.edit_record_page_hint, + inputTransformation = digitOnlyInputTransformation, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine, + isError = state.isPageError, + errorMessage = stringResource(R.string.edit_record_page_input_error), + onClear = { + state.eventSink(RecordEditUiEvent.OnClearClick) + }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Text( + text = stringResource(R.string.edit_record_quote_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordQuoteState, + recordHintRes = R.string.edit_record_quote_hint, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + ), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Text( + text = stringResource(R.string.edit_record_impression_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordImpressionState, + recordHintRes = R.string.edit_record_impression_hint, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + ), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.edit_record_emotion_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.clickable { + state.eventSink(RecordEditUiEvent.OnEmotionEditClick) + }, + ) { + val emotion = state.recordInfo.emotionTags.firstOrNull() ?: "" + + Text( + text = emotion, + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_right), + contentDescription = "Chevron Right Icon", + tint = ReedTheme.colors.contentSecondary, + ) + } + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing16)) + } + ReedButton( + onClick = { + state.eventSink(RecordEditUiEvent.OnSaveButtonClick) + }, + text = stringResource(R.string.edit_record_save), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + enabled = state.isSaveButtonEnabled, + ) +} + +@ComponentPreview +@Composable +private fun RecordEditUiPreview() { + ReedTheme { + RecordEditUi( + state = RecordEditUiState( + recordInfo = RecordEditArgs( + id = "", + pageNumber = 33, + quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", + review = "감동적이었다.", + emotionTags = listOf("따뜻함"), + bookTitle = "여름은 오래 그곳에 남아", + bookPublisher = "비채", + bookCoverImageUrl = "", + author = "마쓰이에 마사시", + ), + eventSink = {}, + ), + ) + } +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditUiState.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditUiState.kt new file mode 100644 index 00000000..7094fa0b --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/RecordEditUiState.kt @@ -0,0 +1,34 @@ +package com.ninecraft.booket.feature.edit.record + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable +import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import java.util.UUID + +data class RecordEditUiState( + val recordInfo: RecordEditArgs, + val recordPageState: TextFieldState = TextFieldState(), + val recordQuoteState: TextFieldState = TextFieldState(), + val recordImpressionState: TextFieldState = TextFieldState(), + val isPageError: Boolean = false, + val isSaveButtonEnabled: Boolean = false, + val sideEffect: RecordEditSideEffect? = null, + val eventSink: (RecordEditUiEvent) -> Unit, +) : CircuitUiState + +@Immutable +sealed interface RecordEditSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : RecordEditSideEffect +} + +sealed interface RecordEditUiEvent : CircuitUiEvent { + data object OnCloseClick : RecordEditUiEvent + data object OnClearClick : RecordEditUiEvent + data object OnEmotionEditClick : RecordEditUiEvent + data object OnSaveButtonClick : RecordEditUiEvent +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/component/BookItem.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/component/BookItem.kt new file mode 100644 index 00000000..f6507f70 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/component/BookItem.kt @@ -0,0 +1,110 @@ +package com.ninecraft.booket.feature.edit.record.component + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +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.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.R +import com.ninecraft.booket.core.designsystem.component.NetworkImage +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +internal fun BookItem( + imageUrl: String, + bookTitle: String, + author: String, + publisher: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + imageUrl = imageUrl, + contentDescription = "Book CoverImage", + modifier = Modifier + .padding(end = ReedTheme.spacing.spacing4) + .width(46.dp) + .height(68.dp) + .clip(RoundedCornerShape(size = ReedTheme.radius.xs)), + placeholder = painterResource(R.drawable.ic_placeholder), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = bookTitle, + color = ReedTheme.colors.contentPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.body1SemiBold, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val authorMaxWidth = maxWidth * 0.7f + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = author, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.widthIn(max = authorMaxWidth), + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + VerticalDivider( + modifier = Modifier.height(14.dp), + thickness = 1.dp, + color = ReedTheme.colors.contentTertiary, + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = publisher, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + } + } +} + +@ComponentPreview +@Composable +private fun BookItemPreview() { + ReedTheme { + BookItem( + imageUrl = "", + bookTitle = "여름은 오래 그곳에 남아", + author = "마쓰이에 마사시", + publisher = "비채", + ) + } +} diff --git a/feature/edit/src/main/res/values/strings.xml b/feature/edit/src/main/res/values/strings.xml new file mode 100644 index 00000000..bd99c9a7 --- /dev/null +++ b/feature/edit/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + 독서 기록 수정 + 책 페이지 + 문장 기록 + 감상평 + 감정 + 기록하고 싶은 페이지를 작성해보세요 + 해당 책의 마지막 페이지 수를 초과했습니다 + 기록하고 싶은 문장을 작성해보세요 + 내용을 입력해주세요. + 저장하기 + 문장에 대해 어떤 감정이 드셨나요? + 대표 감정을 한 가지 선택해주세요 + 수정하기 + diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt index 7fa8440a..dbfc9623 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt @@ -1,11 +1,11 @@ package com.ninecraft.booket.feature.home import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.RecentBookModel import com.ninecraft.booket.feature.screens.BookDetailScreen @@ -13,10 +13,12 @@ import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.RecordScreen import com.ninecraft.booket.feature.screens.SearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -28,6 +30,7 @@ import kotlinx.coroutines.launch class HomePresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: BookRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { @Composable @@ -85,10 +88,14 @@ class HomePresenter @AssistedInject constructor( } } - LaunchedEffect(true) { + RememberedEffect(true) { loadHomeContent() } + ImpressionEffect { + analyticsHelper.logScreenView(HomeScreen.name) + } + return HomeUiState( uiState = uiState, recentBooks = recentBooks, diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt index 7ddc1ab8..82221807 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt @@ -17,10 +17,8 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator 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.res.stringResource @@ -30,6 +28,7 @@ import com.ninecraft.booket.core.designsystem.theme.HomeBg import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.ReedErrorUi +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.home.component.BookCard import com.ninecraft.booket.feature.home.component.EmptyBookCard import com.ninecraft.booket.feature.home.component.HomeBanner @@ -98,12 +97,7 @@ internal fun HomeContent( when (state.uiState) { is UiState.Idle -> {} is UiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } + ReedLoadingIndicator() } is UiState.Success -> { @@ -126,7 +120,7 @@ internal fun HomeContent( onBookRegisterClick = { state.eventSink(HomeUiEvent.OnBookRegisterClick) }, - modifier = Modifier.padding(ReedTheme.spacing.spacing5), + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), ) } else { val pagerState = rememberPagerState(pageCount = { state.recentBooks.size }) @@ -190,6 +184,7 @@ private fun HomePreview() { ReedTheme { HomeUi( state = HomeUiState( + uiState = UiState.Success, eventSink = {}, ), ) diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt index ca353cde..9b3115ee 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt @@ -9,6 +9,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.util.UUID +@Immutable sealed interface UiState { data object Idle : UiState data object Loading : UiState diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/BookCard.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/BookCard.kt index 05b11ec3..75851327 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/BookCard.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/BookCard.kt @@ -158,14 +158,14 @@ fun BookCard( } .padding( horizontal = ReedTheme.spacing.spacing3, - vertical = ReedTheme.spacing.spacing2, + vertical = 9.dp, ), verticalAlignment = Alignment.CenterVertically, ) { Image( painter = painterResource(R.drawable.img_seed_count), contentDescription = "Seed Count Image", - modifier = Modifier.size(32.dp), + modifier = Modifier.size(ReedTheme.spacing.spacing7), ) Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) Text( @@ -224,8 +224,8 @@ fun EmptyBookCard( shape = RoundedCornerShape(ReedTheme.radius.sm), ) .border( - width = 1.dp, - color = ReedTheme.colors.borderSecondary, + width = ReedTheme.border.border1, + color = ReedTheme.colors.borderPrimary, shape = RoundedCornerShape(ReedTheme.radius.sm), ) .padding( @@ -253,7 +253,7 @@ fun EmptyBookCard( color = ReedTheme.colors.contentTertiary, style = ReedTheme.typography.label1Medium, ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) ReedButton( onClick = { onBookRegisterClick() diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeBanner.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeBanner.kt index 5ce5ed1b..3b140709 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeBanner.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeBanner.kt @@ -73,9 +73,11 @@ fun HomeBanner( color = ReedTheme.colors.contentBrand, style = ReedTheme.typography.body2Medium, ) + Spacer(modifier = Modifier.size(ReedTheme.spacing.spacing1)) Icon( imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Chevron Right Icon", + modifier = Modifier.size(ReedTheme.spacing.spacing5), tint = ReedTheme.colors.contentBrand, ) } diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index cc15f77a..58420e85 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -10,5 +10,4 @@ 기록하기 책 정보를 가져오는데 실패했어요 - 다시 시도 diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt index 40dbedb3..25b609cc 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt @@ -1,12 +1,12 @@ package com.ninecraft.booket.feature.library import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState @@ -15,10 +15,12 @@ import com.ninecraft.booket.feature.screens.LibraryScreen import com.ninecraft.booket.feature.screens.LibrarySearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.orhanobut.logger.Logger +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -30,6 +32,7 @@ import kotlinx.coroutines.launch class LibraryPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: BookRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { companion object { private const val PAGE_SIZE = 20 @@ -151,7 +154,7 @@ class LibraryPresenter @AssistedInject constructor( } } - LaunchedEffect(Unit) { + RememberedEffect(Unit) { filterLibraryBooks( status = currentFilter.getApiValue(), page = START_INDEX, @@ -159,6 +162,10 @@ class LibraryPresenter @AssistedInject constructor( ) } + ImpressionEffect { + analyticsHelper.logScreenView(LibraryScreen.name) + } + return LibraryUiState( uiState = uiState, footerState = footerState, diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt index 32be5462..ed1f0512 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt @@ -10,12 +10,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -24,6 +24,7 @@ import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedErrorUi +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.library.component.FilterChipGroup import com.ninecraft.booket.feature.library.component.LibraryBookItem import com.ninecraft.booket.feature.library.component.LibraryHeader @@ -97,6 +98,7 @@ internal fun LibraryContent( state.eventSink(LibraryUiEvent.OnFilterClick(status)) }, ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) when (state.uiState) { is UiState.Idle -> { @@ -104,12 +106,7 @@ internal fun LibraryContent( } is UiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } + ReedLoadingIndicator() } is UiState.Success -> { @@ -168,12 +165,14 @@ private fun EmptyResult() { Text( text = stringResource(R.string.library_empty_book_title), color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, style = ReedTheme.typography.headline1SemiBold, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) Text( text = stringResource(R.string.library_empty_book_description), color = ReedTheme.colors.contentSecondary, + textAlign = TextAlign.Center, style = ReedTheme.typography.body1Medium, ) } diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt index 829a4a43..83675e2a 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt @@ -10,6 +10,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +@Immutable sealed interface UiState { data object Idle : UiState data object Loading : UiState diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/FilterChip.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/FilterChip.kt index fe0fe4f4..0e9f1a65 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/FilterChip.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/FilterChip.kt @@ -30,7 +30,9 @@ fun FilterChip( modifier: Modifier = Modifier, ) { val chipColor = if (isSelected) ReedTheme.colors.bgPrimary else ReedTheme.colors.basePrimary - val textColor = if (isSelected) White else ReedTheme.colors.contentSecondary + val labelColor = if (isSelected) White else ReedTheme.colors.contentSecondary + val countColor = if (isSelected) White else ReedTheme.colors.contentTertiary + val labelStyle = if (isSelected) ReedTheme.typography.label1SemiBold else ReedTheme.typography.label1Medium Box( modifier = modifier @@ -59,13 +61,13 @@ fun FilterChip( Row(verticalAlignment = Alignment.CenterVertically) { Text( text = stringResource(option.getDisplayNameRes()), - color = textColor, - style = ReedTheme.typography.label1SemiBold, + color = labelColor, + style = labelStyle, ) Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) Text( text = "$count", - color = textColor, + color = countColor, style = ReedTheme.typography.label1SemiBold, ) } diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt index 522cb880..1fbc323f 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.feature.screens.HomeScreen @@ -15,6 +16,7 @@ import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -25,8 +27,13 @@ class LoginPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val authRepository: AuthRepository, private val userRepository: UserRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { + companion object { + private const val EVENT_ERROR_LOGIN = "error_login" + } + @Composable override fun present(): LoginUiState { val scope = rememberCoroutineScope() @@ -60,6 +67,7 @@ class LoginPresenter @AssistedInject constructor( is LoginUiEvent.LoginFailure -> { isLoading = false + analyticsHelper.logEvent(EVENT_ERROR_LOGIN) sideEffect = LoginSideEffect.ShowToast(event.message) } @@ -72,6 +80,7 @@ class LoginPresenter @AssistedInject constructor( navigateAfterLogin() }.onFailure { exception -> exception.message?.let { Logger.e(it) } + analyticsHelper.logEvent(EVENT_ERROR_LOGIN) sideEffect = exception.message?.let { LoginSideEffect.ShowToast(it) } @@ -84,6 +93,10 @@ class LoginPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(LoginScreen.name) + } + return LoginUiState( isLoading = isLoading, sideEffect = sideEffect, diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUi.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUi.kt index d6ce3c93..c56d466f 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUi.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUi.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,6 +29,7 @@ import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.screens.LoginScreen import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent @@ -102,10 +102,7 @@ internal fun LoginUi( } if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = ReedTheme.colors.contentBrand, - ) + ReedLoadingIndicator() } } } diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementPresenter.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementPresenter.kt index b2603c6a..cd68ea36 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementPresenter.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementPresenter.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.constants.WebViewConstants import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.feature.screens.HomeScreen @@ -17,6 +18,7 @@ import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -28,6 +30,7 @@ import kotlinx.coroutines.launch class TermsAgreementPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val userRepository: UserRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { @Composable @@ -82,6 +85,10 @@ class TermsAgreementPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(TermsAgreementScreen.name) + } + return TermsAgreementUiState( isAllAgreed = isAllAgreed, agreedTerms = agreedTerms, diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUi.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUi.kt index 3d3068d8..f017165f 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUi.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUi.kt @@ -59,7 +59,7 @@ internal fun TermsAgreementUi( color = ReedTheme.colors.contentPrimary, style = ReedTheme.typography.title2SemiBold, ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing7)) Row( modifier = Modifier .fillMaxWidth() diff --git a/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt b/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt index 4ee9010b..93ad9fcc 100644 --- a/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt +++ b/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.ninecraft.booket.core.common.constants.ErrorDialogSpec import com.ninecraft.booket.core.common.event.ErrorEvent @@ -70,7 +71,8 @@ class MainActivity : ComponentActivity() { dialogSpec.value?.let { spec -> ReedDialog( description = spec.message, - confirmButtonText = spec.buttonLabel, + confirmButtonText = stringResource(spec.buttonLabelResId), + onConfirmRequest = { spec.action() dialogSpec.value = null diff --git a/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingPresenter.kt b/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingPresenter.kt index 020328f3..92efa53b 100644 --- a/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingPresenter.kt +++ b/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingPresenter.kt @@ -4,12 +4,14 @@ package com.ninecraft.booket.feature.onboarding import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OnboardingScreen import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -21,6 +23,7 @@ const val ONBOARDING_STEPS_COUNT = 3 class OnboardingPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: UserRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { @Composable @@ -47,6 +50,10 @@ class OnboardingPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(OnboardingScreen.name) + } + return OnboardingUiState( pagerState = pagerState, eventSink = ::handleEvent, diff --git a/feature/record/build.gradle.kts b/feature/record/build.gradle.kts index c6354e6e..8b49d90b 100644 --- a/feature/record/build.gradle.kts +++ b/feature/record/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { libs.androidx.camera.lifecycle, libs.androidx.camera.view, + libs.compose.keyboard.state, libs.logger, ) } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/CustomTooltipBox.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/CustomTooltipBox.kt new file mode 100644 index 00000000..80cc30b2 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/CustomTooltipBox.kt @@ -0,0 +1,69 @@ +package com.ninecraft.booket.feature.record.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +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.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.feature.record.R + +@Composable +internal fun CustomTooltipBox( + @StringRes messageResId: Int, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .shadow(ReedTheme.radius.xs, RoundedCornerShape(ReedTheme.radius.xs), clip = false) + .background( + ReedTheme.colors.contentPrimary, + RoundedCornerShape(ReedTheme.radius.xs), + ) + .padding( + horizontal = ReedTheme.spacing.spacing3, + vertical = ReedTheme.spacing.spacing2, + ), + ) { + Text( + text = stringResource(messageResId), + color = ReedTheme.colors.contentInverse, + style = ReedTheme.typography.label2Regular, + ) + } + Box( + Modifier + .padding(start = 2.dp) + .size(ReedTheme.spacing.spacing3) + .offset( + x = (-10).dp, + ) + .graphicsLayer { + rotationZ = 45f + shadowElevation = 8.dp.toPx() + clip = true + } + .background(ReedTheme.colors.contentPrimary), + ) + } +} + +@ComponentPreview +@Composable +private fun CustomTooltipBoxPreview() { + ReedTheme { + CustomTooltipBox(messageResId = R.string.scan_tooltip_message) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt new file mode 100644 index 00000000..8b9e8569 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/HandleOcrSideEffects.kt @@ -0,0 +1,23 @@ +package com.ninecraft.booket.feature.record.ocr + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleOcrSideEffects( + state: OcrUiState, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is OcrSideEffect.ShowToast -> { + Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + } + + null -> {} + } + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt index 0105759a..86291fc4 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt @@ -1,50 +1,93 @@ package com.ninecraft.booket.feature.record.ocr +import android.net.Uri import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import com.ninecraft.booket.core.ocr.analyzer.LiveTextAnalyzer +import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.ocr.analyzer.CloudOcrRecognizer +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.feature.screens.OcrScreen +import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch class OcrPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, - private val liveTextAnalyzer: LiveTextAnalyzer.Factory, + private val recognizer: CloudOcrRecognizer, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { + companion object { + private const val RECORD_OCR_SENTENCE = "record_OCR_sentence" + } + @Composable override fun present(): OcrUiState { + val scope = rememberCoroutineScope() var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) } var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) } - var sentenceList by rememberRetained { mutableStateOf(emptyList().toPersistentList()) } + var sentenceList by rememberRetained { mutableStateOf(persistentListOf()) } var recognizedText by rememberRetained { mutableStateOf("") } var selectedIndices by rememberRetained { mutableStateOf(setOf()) } var mergedSentence by rememberRetained { mutableStateOf("") } var isTextDetectionFailed by rememberRetained { mutableStateOf(false) } var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) } - - val analyzer = rememberRetained { - liveTextAnalyzer.create( - onTextDetected = { text -> - recognizedText = text - }, - ) - } - - DisposableEffect(Unit) { - onDispose { - analyzer.cancel() + var isLoading by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } + + fun recognizeText(imageUri: Uri) { + scope.launch { + try { + isLoading = true + recognizer.recognizeText(imageUri) + .onSuccess { + val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty() + recognizedText = text + + if (text.isNotBlank()) { + isTextDetectionFailed = false + val sentences = text + .split("\n") + .map { it.trim() } + .filter { it.isNotEmpty() } + + sentenceList = sentences.toPersistentList() + currentUi = OcrUi.RESULT + analyticsHelper.logScreenView(RECORD_OCR_SENTENCE) + } else { + isTextDetectionFailed = true + } + } + .onFailure { exception -> + isTextDetectionFailed = true + + val handleErrorMessage = { message: String -> + Logger.e("Cloud Vision API Error: ${exception.message}") + sideEffect = OcrSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = {}, + ) + } + } finally { + isLoading = false + } } } @@ -62,24 +105,20 @@ class OcrPresenter @AssistedInject constructor( isPermissionDialogVisible = false } - is OcrUiEvent.OnFrameReceived -> { - analyzer.analyze(event.imageProxy) + is OcrUiEvent.OnCaptureStart -> { + isLoading = true } - is OcrUiEvent.OnCaptureButtonClick -> { - if (recognizedText.isEmpty()) { - isTextDetectionFailed = true - } else { - isTextDetectionFailed = false + is OcrUiEvent.OnCaptureFailed -> { + isLoading = false + sideEffect = OcrSideEffect.ShowToast("이미지 캡처에 실패했어요") + Logger.e("ImageCaptureException: ${event.exception.message}") + } - val sentences = recognizedText - .split("\n") - .map { it.trim() } - .filter { it.isNotEmpty() } - sentenceList = persistentListOf(*sentences.toTypedArray()) + is OcrUiEvent.OnImageCaptured -> { + isTextDetectionFailed = false - currentUi = OcrUi.RESULT - } + recognizeText(event.imageUri) } is OcrUiEvent.OnReCaptureButtonClick -> { @@ -88,7 +127,7 @@ class OcrPresenter @AssistedInject constructor( is OcrUiEvent.OnSelectionConfirmed -> { mergedSentence = selectedIndices - .sorted().joinToString(" ") { sentenceList[it] } + .sorted().joinToString("") { sentenceList[it] } navigator.pop(result = OcrScreen.OcrResult(mergedSentence)) } @@ -112,6 +151,10 @@ class OcrPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(OcrScreen.name) + } + return OcrUiState( currentUi = currentUi, isPermissionDialogVisible = isPermissionDialogVisible, @@ -119,6 +162,8 @@ class OcrPresenter @AssistedInject constructor( selectedIndices = selectedIndices, isTextDetectionFailed = isTextDetectionFailed, isRecaptureDialogVisible = isRecaptureDialogVisible, + isLoading = isLoading, + sideEffect = sideEffect, eventSink = ::handleEvent, ) } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt index 2f24a26e..4a77fefb 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt @@ -8,7 +8,8 @@ import android.view.ViewGroup import android.widget.LinearLayout import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.background @@ -29,11 +30,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -66,9 +68,11 @@ import com.ninecraft.booket.feature.record.R import com.ninecraft.booket.feature.record.ocr.component.CameraFrame import com.ninecraft.booket.feature.record.ocr.component.SentenceBox import com.ninecraft.booket.feature.screens.OcrScreen +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController +import java.io.File import com.ninecraft.booket.core.designsystem.R as designR @CircuitInject(OcrScreen::class, ActivityRetainedComponent::class) @@ -77,6 +81,8 @@ internal fun OcrUi( state: OcrUiState, modifier: Modifier = Modifier, ) { + HandleOcrSideEffects(state = state) + when (state.currentUi) { OcrUi.CAMERA -> CameraPreview(state = state, modifier = modifier) OcrUi.RESULT -> TextScanResult(state = state, modifier = modifier) @@ -114,7 +120,7 @@ private fun CameraPreview( ) { _ -> } // 최초 진입 시 권한 요청 - LaunchedEffect(Unit) { + RememberedEffect(Unit) { if (!isGranted) { state.eventSink(OcrUiEvent.OnHidePermissionDialog) permissionLauncher.launch(permission) @@ -138,27 +144,17 @@ private fun CameraPreview( } /** - * Camera Controller & ImageAnalyzer + * Camera Controller */ val cameraController = remember { LifecycleCameraController(context) } - val imageAnalyzer = remember { - ImageAnalysis.Analyzer { imageProxy -> - state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy)) - } - } DisposableEffect(isGranted, lifecycleOwner, cameraController) { if (isGranted) { cameraController.bindToLifecycle(lifecycleOwner) - cameraController.setImageAnalysisAnalyzer( - ContextCompat.getMainExecutor(context), - imageAnalyzer, - ) } onDispose { cameraController.unbind() - cameraController.clearImageAnalysisAnalyzer() } } @@ -253,8 +249,27 @@ private fun CameraPreview( } Button( + enabled = !state.isLoading, onClick = { - state.eventSink(OcrUiEvent.OnCaptureButtonClick) + state.eventSink(OcrUiEvent.OnCaptureStart) + + val executor = ContextCompat.getMainExecutor(context) + val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir) + val output = ImageCapture.OutputFileOptions.Builder(photoFile).build() + + cameraController.takePicture( + output, + executor, + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) + } + + override fun onError(exception: ImageCaptureException) { + state.eventSink(OcrUiEvent.OnCaptureFailed(exception)) + } + }, + ) }, modifier = Modifier.size(72.dp), shape = CircleShape, @@ -272,6 +287,15 @@ private fun CameraPreview( } Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) } + + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } + } } } @@ -313,9 +337,13 @@ private fun TextScanResult( LazyColumn( modifier = Modifier .weight(1f) - .padding(horizontal = ReedTheme.spacing.spacing3), + .padding(horizontal = ReedTheme.spacing.spacing5), verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), ) { + item { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + } + items(state.sentenceList.size) { index -> SentenceBox( onClick = { diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt index 2a86e6d6..812fc57e 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt @@ -1,27 +1,40 @@ package com.ninecraft.booket.feature.record.ocr -import androidx.camera.core.ImageProxy +import android.net.Uri +import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID data class OcrUiState( val currentUi: OcrUi = OcrUi.CAMERA, val isPermissionDialogVisible: Boolean = false, - val sentenceList: ImmutableList = emptyList().toPersistentList(), + val sentenceList: ImmutableList = persistentListOf(), val selectedIndices: Set = emptySet(), val isTextDetectionFailed: Boolean = false, val isRecaptureDialogVisible: Boolean = false, + val isLoading: Boolean = false, + val sideEffect: OcrSideEffect? = null, val eventSink: (OcrUiEvent) -> Unit, ) : CircuitUiState +@Immutable +sealed interface OcrSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : OcrSideEffect +} + sealed interface OcrUiEvent : CircuitUiEvent { data object OnCloseClick : OcrUiEvent data object OnShowPermissionDialog : OcrUiEvent data object OnHidePermissionDialog : OcrUiEvent - data class OnFrameReceived(val imageProxy: ImageProxy) : OcrUiEvent - data object OnCaptureButtonClick : OcrUiEvent + data object OnCaptureStart : OcrUiEvent + data class OnCaptureFailed(val exception: Exception) : OcrUiEvent + data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent data object OnReCaptureButtonClick : OcrUiEvent data object OnSelectionConfirmed : OcrUiEvent data object OnRecaptureDialogConfirmed : OcrUiEvent diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/SentenceBox.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/SentenceBox.kt index 2ecdb7a0..07b39d8f 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/SentenceBox.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/SentenceBox.kt @@ -26,6 +26,7 @@ fun SentenceBox( val bgColor = if (isSelected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary val borderColor = if (isSelected) ReedTheme.colors.borderBrand else Color.Transparent val textColor = if (isSelected) ReedTheme.colors.contentBrand else ReedTheme.colors.contentPrimary + val textStyle = if (isSelected) ReedTheme.typography.body1Medium else ReedTheme.typography.body1Regular Box( modifier = modifier @@ -51,7 +52,7 @@ fun SentenceBox( Text( text = sentence, color = textColor, - style = ReedTheme.typography.body1Regular, + style = textStyle, ) } } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt index ba892739..63fb8fda 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt @@ -10,9 +10,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.text.TextRange -import com.ninecraft.booket.core.common.constants.ErrorScope +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.utils.handleException -import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.designsystem.EmotionTag import com.ninecraft.booket.core.designsystem.RecordStep @@ -27,6 +26,7 @@ import com.slack.circuit.foundation.rememberAnsweringNavigator import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -38,11 +38,24 @@ class RecordRegisterPresenter @AssistedInject constructor( @Assisted private val screen: RecordScreen, @Assisted private val navigator: Navigator, private val repository: RecordRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { + companion object { + private const val MAX_PAGE = 4032 + private const val RECORD_INPUT_SENTENCE = "record_input_sentence" + private const val RECORD_SELECT_EMOTION = "record_select_emotion" + private const val RECORD_INPUT_OPINION = "record_input_opinion" + private const val RECORD_INPUT_HELP = "record_input_help" + private const val RECORD_COMPLETE = "record_complete" + private const val RECORD_DETAIL = "record_detail" + private const val ERROR_RECORD_SAVE = "error_record_save" + } + @Composable override fun present(): RecordRegisterUiState { val scope = rememberCoroutineScope() + var isLoading by rememberRetained { mutableStateOf(false) } var sideEffect by rememberRetained { mutableStateOf(null) } var currentStep by rememberRetained { mutableStateOf(RecordStep.QUOTE) } val recordPageState = rememberTextFieldState() @@ -92,6 +105,8 @@ class RecordRegisterPresenter @AssistedInject constructor( } } } + var isScanTooltipVisible by rememberRetained { mutableStateOf(true) } + var isImpressionGuideTooltipVisible by rememberRetained { mutableStateOf(true) } val ocrNavigator = rememberAnsweringNavigator(navigator) { result -> recordSentenceState.edit { @@ -108,33 +123,35 @@ class RecordRegisterPresenter @AssistedInject constructor( impression: String, ) { scope.launch { - repository.postRecord( - userBookId = userBookId, - pageNumber = pageNumber, - quote = quote, - emotionTags = emotionTags, - review = impression, - ).onSuccess { result -> - savedRecordId = result.id - isRecordSavedDialogVisible = true - }.onFailure { exception -> - postErrorDialog( - errorScope = ErrorScope.RECORD_REGISTER, - exception = exception, - ) - - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = RecordRegisterSideEffect.ShowToast(message) - } + try { + isLoading = true + repository.postRecord( + userBookId = userBookId, + pageNumber = pageNumber, + quote = quote, + emotionTags = emotionTags, + review = impression, + ).onSuccess { result -> + analyticsHelper.logEvent(RECORD_COMPLETE) + savedRecordId = result.id + isRecordSavedDialogVisible = true + }.onFailure { exception -> + analyticsHelper.logEvent(ERROR_RECORD_SAVE) + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = RecordRegisterSideEffect.ShowToast(message) + } - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } finally { + isLoading = false } } } @@ -173,6 +190,7 @@ class RecordRegisterPresenter @AssistedInject constructor( } is RecordRegisterUiEvent.OnSentenceScanButtonClick -> { + isScanTooltipVisible = false ocrNavigator.goTo(OcrScreen) } @@ -181,6 +199,8 @@ class RecordRegisterPresenter @AssistedInject constructor( } is RecordRegisterUiEvent.OnImpressionGuideButtonClick -> { + analyticsHelper.logScreenView(RECORD_INPUT_HELP) + isImpressionGuideTooltipVisible = false beforeSelectedImpressionGuide = selectedImpressionGuide if (impressionState.text.isEmpty()) { selectedImpressionGuide = "" @@ -244,6 +264,7 @@ class RecordRegisterPresenter @AssistedInject constructor( } is RecordRegisterUiEvent.OnRecordSavedDialogConfirm -> { + analyticsHelper.logScreenView(RECORD_DETAIL) isRecordSavedDialogVisible = false navigator.pop() navigator.goTo(RecordDetailScreen(event.recordId)) @@ -258,7 +279,17 @@ class RecordRegisterPresenter @AssistedInject constructor( } } + ImpressionEffect(currentStep) { + val screenName = when (currentStep) { + RecordStep.QUOTE -> RECORD_INPUT_SENTENCE + RecordStep.EMOTION -> RECORD_SELECT_EMOTION + RecordStep.IMPRESSION -> RECORD_INPUT_OPINION + } + analyticsHelper.logScreenView(screenName) + } + return RecordRegisterUiState( + isLoading = isLoading, currentStep = currentStep, recordPageState = recordPageState, recordSentenceState = recordSentenceState, @@ -274,6 +305,8 @@ class RecordRegisterPresenter @AssistedInject constructor( isImpressionGuideBottomSheetVisible = isImpressionGuideBottomSheetVisible, isExitDialogVisible = isExitDialogVisible, isRecordSavedDialogVisible = isRecordSavedDialogVisible, + isScanTooltipVisible = isScanTooltipVisible, + isImpressionGuideTooltipVisible = isImpressionGuideTooltipVisible, sideEffect = sideEffect, eventSink = ::handleEvent, ) @@ -287,8 +320,4 @@ class RecordRegisterPresenter @AssistedInject constructor( navigator: Navigator, ): RecordRegisterPresenter } - - companion object { - const val MAX_PAGE = 4032 - } } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt index fa7031e8..4f676ad6 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt @@ -4,10 +4,14 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -20,6 +24,7 @@ import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.record.R import com.ninecraft.booket.feature.record.step.EmotionStep import com.ninecraft.booket.feature.record.step.ImpressionStep @@ -43,6 +48,7 @@ internal fun RecordRegisterUi( Scaffold( modifier = modifier.fillMaxSize(), containerColor = White, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.ime), ) { innerPadding -> Column( modifier = Modifier @@ -56,7 +62,7 @@ internal fun RecordRegisterUi( ) RecordProgressBar( currentStep = state.currentStep, - modifier = modifier.padding(horizontal = ReedTheme.spacing.spacing5), + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) when (state.currentStep) { @@ -75,6 +81,10 @@ internal fun RecordRegisterUi( } } + if (state.isLoading) { + ReedLoadingIndicator() + } + if (state.isExitDialogVisible) { ReedDialog( title = stringResource(R.string.record_exit_dialog_title), diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt index a3ae39d3..9641f987 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt @@ -11,6 +11,7 @@ import kotlinx.collections.immutable.persistentListOf import java.util.UUID data class RecordRegisterUiState( + val isLoading: Boolean = false, val currentStep: RecordStep = RecordStep.QUOTE, val recordPageState: TextFieldState = TextFieldState(), val recordSentenceState: TextFieldState = TextFieldState(), @@ -26,6 +27,8 @@ data class RecordRegisterUiState( val isImpressionGuideBottomSheetVisible: Boolean = false, val isExitDialogVisible: Boolean = false, val isRecordSavedDialogVisible: Boolean = false, + val isScanTooltipVisible: Boolean = true, + val isImpressionGuideTooltipVisible: Boolean = true, val sideEffect: RecordRegisterSideEffect? = null, val eventSink: (RecordRegisterUiEvent) -> Unit, ) : CircuitUiState diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionStep.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionStep.kt index 204d2c32..58f22329 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionStep.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionStep.kt @@ -74,7 +74,7 @@ fun EmotionStep( ) } item { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) } items(emotionPairs) { pair -> @@ -134,7 +134,7 @@ private fun EmotionItem( ) .then( if (isSelected) Modifier.border( - width = 2.dp, + width = ReedTheme.border.border15, color = ReedTheme.colors.borderBrand, shape = RoundedCornerShape(ReedTheme.radius.md), ) diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt index 93172488..346e51b8 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt @@ -1,13 +1,17 @@ package com.ninecraft.booket.feature.record.step import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -17,14 +21,17 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.input.ImeAction @@ -40,10 +47,13 @@ import com.ninecraft.booket.core.designsystem.component.textfield.ReedRecordText import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.component.CustomTooltipBox import com.ninecraft.booket.feature.record.component.ImpressionGuideBottomSheet import com.ninecraft.booket.feature.record.register.RecordRegisterUiEvent import com.ninecraft.booket.feature.record.register.RecordRegisterUiState +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import tech.thdev.compose.extensions.keyboard.state.foundation.rememberKeyboardVisible import com.ninecraft.booket.core.designsystem.R as designR @OptIn(ExperimentalMaterial3Api::class) @@ -53,29 +63,33 @@ fun ImpressionStep( modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() - val impressionGuideBottomSheetState = - rememberModalBottomSheetState(skipPartiallyExpanded = true) - + val impressionGuideBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current + val scrollState = rememberScrollState() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val keyboardState by rememberKeyboardVisible() + var isImpressionTextFieldFocused by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - if (state.impressionState.text.isEmpty()) { - focusRequester.requestFocus() - keyboardController?.show() + LaunchedEffect(keyboardState, isImpressionTextFieldFocused) { + if (keyboardState && isImpressionTextFieldFocused) { + delay(150) + bringIntoViewRequester.bringIntoView() } } - Box( + Column( modifier = modifier .fillMaxSize() - .background(White), + .background(White) + .imePadding(), ) { Column( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .weight(1f) .padding(horizontal = ReedTheme.spacing.spacing5) - .verticalScroll(rememberScrollState()), + .padding(bottom = 16.dp) + .verticalScroll(scrollState), ) { Text( text = stringResource(R.string.impression_step_title), @@ -95,28 +109,44 @@ fun ImpressionStep( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) - .height(140.dp), + .height(140.dp) + .onFocusChanged { focusState -> + isImpressionTextFieldFocused = focusState.isFocused + }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Default, ), ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) - ReedButton( - onClick = { - state.eventSink(RecordRegisterUiEvent.OnImpressionGuideButtonClick) - }, - colorStyle = ReedButtonColorStyle.STROKE, - sizeStyle = smallRoundedButtonStyle, - modifier = Modifier.align(Alignment.End), - text = stringResource(R.string.impression_step_guide), - leadingIcon = { - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_book_open), - contentDescription = "Impression Guide Icon", + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (state.isImpressionGuideTooltipVisible) { + CustomTooltipBox( + messageResId = R.string.impression_guide_tooltip_message, ) - }, - ) + } + + ReedButton( + onClick = { + state.eventSink(RecordRegisterUiEvent.OnImpressionGuideButtonClick) + }, + colorStyle = ReedButtonColorStyle.STROKE, + sizeStyle = smallRoundedButtonStyle, + text = stringResource(R.string.impression_step_guide), + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_book_open), + contentDescription = "Impression Guide Icon", + ) + }, + ) + } } ReedButton( @@ -127,9 +157,10 @@ fun ImpressionStep( sizeStyle = largeButtonStyle, modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(horizontal = ReedTheme.spacing.spacing5) - .padding(bottom = ReedTheme.spacing.spacing4), + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), enabled = state.isNextButtonEnabled, text = stringResource(R.string.record_next_button), multipleEventsCutterEnabled = state.currentStep == RecordStep.IMPRESSION, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/QuoteStep.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/QuoteStep.kt index d9d49d98..ecdd2a26 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/QuoteStep.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/QuoteStep.kt @@ -2,7 +2,6 @@ package com.ninecraft.booket.feature.record.step import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits @@ -18,9 +19,15 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource @@ -39,8 +46,11 @@ import com.ninecraft.booket.core.designsystem.component.textfield.digitOnlyInput import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.component.CustomTooltipBox import com.ninecraft.booket.feature.record.register.RecordRegisterUiEvent import com.ninecraft.booket.feature.record.register.RecordRegisterUiState +import kotlinx.coroutines.delay +import tech.thdev.compose.extensions.keyboard.state.foundation.rememberKeyboardVisible import com.ninecraft.booket.core.designsystem.R as designR @Composable @@ -49,8 +59,19 @@ internal fun QuoteStep( modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current + val scrollState = rememberScrollState() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val keyboardState by rememberKeyboardVisible() + var isSentenceTextFieldFocused by remember { mutableStateOf(false) } - Box( + LaunchedEffect(keyboardState, isSentenceTextFieldFocused) { + if (keyboardState && isSentenceTextFieldFocused) { + delay(100) + bringIntoViewRequester.bringIntoView() + } + } + + Column( modifier = modifier .fillMaxSize() .background(White) @@ -58,10 +79,10 @@ internal fun QuoteStep( ) { Column( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .weight(1f) .padding(horizontal = ReedTheme.spacing.spacing5) - .padding(bottom = 80.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState), ) { Text( text = stringResource(R.string.quote_step_title), @@ -105,7 +126,10 @@ internal fun QuoteStep( recordHintRes = R.string.quote_step_sentence_hint, modifier = Modifier .fillMaxWidth() - .height(140.dp), + .height(140.dp) + .onFocusChanged { focusState -> + isSentenceTextFieldFocused = focusState.isFocused + }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Default, @@ -113,9 +137,16 @@ internal fun QuoteStep( ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { + if (state.isScanTooltipVisible) { + CustomTooltipBox(messageResId = R.string.scan_tooltip_message) + } + ReedButton( onClick = { state.eventSink(RecordRegisterUiEvent.OnSentenceScanButtonClick) @@ -141,9 +172,10 @@ internal fun QuoteStep( sizeStyle = largeButtonStyle, modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(horizontal = ReedTheme.spacing.spacing5) - .padding(bottom = ReedTheme.spacing.spacing4), + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), enabled = state.isNextButtonEnabled, text = stringResource(R.string.record_next_button), multipleEventsCutterEnabled = state.currentStep == RecordStep.IMPRESSION, diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml index 2bd05714..093c7da1 100644 --- a/feature/record/src/main/res/values/strings.xml +++ b/feature/record/src/main/res/values/strings.xml @@ -38,4 +38,6 @@ 기록 보러가기 닫기 해당 책의 마지막 페이지 수를 초과했습니다 + 예시 문장을 알려드려요 + 스캔으로 빠르게 입력해요 diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt new file mode 100644 index 00000000..1c31c461 --- /dev/null +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt @@ -0,0 +1,18 @@ +package com.ninecraft.booket.feature.screens + +object ScreenNames { + const val SPLASH = "splash" + const val HOME = "home_main" + const val LIBRARY = "library_main" + const val LOGIN = "login_select_method" + const val SEARCH = "search_book_input" + const val LIBRARY_SEARCH = "library_search_book" + const val TERMS_AGREEMENT = "login_terms_agreement" + const val SETTINGS = "settings_main" + const val RECORD = "record_start" + const val OCR = "record_OCR_camera" + const val RECORD_DETAIL = "record_detail" + const val BOOK_DETAIL = "library_book_detail" + const val ONBOARDING = "onboarding" + const val RECORD_CARD = "record_view_card" +} diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt index 4c4f1ce9..f7f1ae61 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.screens +import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen import kotlinx.parcelize.Parcelize @@ -9,40 +10,49 @@ abstract class ReedScreen(val name: String) : Screen { } @Parcelize -data object HomeScreen : ReedScreen(name = "Home()") +data object HomeScreen : ReedScreen(name = ScreenNames.HOME) @Parcelize -data object LibraryScreen : ReedScreen(name = "Library()") +data object LibraryScreen : ReedScreen(name = ScreenNames.LIBRARY) @Parcelize -data object LoginScreen : ReedScreen(name = "Login()") +data object LoginScreen : ReedScreen(name = ScreenNames.LOGIN) @Parcelize -data object SearchScreen : ReedScreen(name = "Search()") +data object SearchScreen : ReedScreen(name = ScreenNames.SEARCH) @Parcelize -data object LibrarySearchScreen : ReedScreen(name = "LibrarySearch()") +data object LibrarySearchScreen : ReedScreen(name = ScreenNames.LIBRARY_SEARCH) @Parcelize -data object TermsAgreementScreen : ReedScreen(name = "TermsAgreement()") +data object TermsAgreementScreen : ReedScreen(name = ScreenNames.TERMS_AGREEMENT) @Parcelize -data object SettingsScreen : ReedScreen(name = "Settings()") +data object SettingsScreen : ReedScreen(name = ScreenNames.SETTINGS) @Parcelize data object OssLicensesScreen : ReedScreen(name = "OssLicenses()") @Parcelize -data class RecordScreen(val userBookId: String) : ReedScreen(name = "Record") +data class RecordScreen(val userBookId: String) : ReedScreen(name = ScreenNames.RECORD) @Parcelize -data object OcrScreen : ReedScreen(name = "Ocr()") { +data object OcrScreen : ReedScreen(name = ScreenNames.OCR) { @Parcelize data class OcrResult(val sentence: String) : PopResult } @Parcelize -data class RecordDetailScreen(val recordId: String) : ReedScreen(name = "RecordDetail()") +data class RecordDetailScreen(val recordId: String) : ReedScreen(name = ScreenNames.RECORD_DETAIL) + +@Parcelize +data class RecordEditScreen(val recordInfo: RecordEditArgs) : ReedScreen(name = "RecordEdit()") + +@Parcelize +data class EmotionEditScreen(val emotion: String) : ReedScreen(name = "EmotionEdit()") { + @Parcelize + data class Result(val emotion: String) : PopResult +} @Parcelize data class WebViewScreen( @@ -54,10 +64,17 @@ data class WebViewScreen( data class BookDetailScreen( val userBookId: String, val isbn13: String, -) : ReedScreen(name = "BookDetail()") +) : ReedScreen(name = ScreenNames.BOOK_DETAIL) + +@Parcelize +data object OnboardingScreen : ReedScreen(name = ScreenNames.ONBOARDING) @Parcelize -data object OnboardingScreen : ReedScreen(name = "Onboarding()") +data object SplashScreen : ReedScreen(name = ScreenNames.SPLASH) @Parcelize -data object SplashScreen : ReedScreen(name = "Splash()") +data class RecordCardScreen( + val quote: String, + val bookTitle: String, + val emotionTag: String, +) : ReedScreen(name = ScreenNames.RECORD_CARD) diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/arguments/RecordEditArgs.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/arguments/RecordEditArgs.kt new file mode 100644 index 00000000..c4917736 --- /dev/null +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/arguments/RecordEditArgs.kt @@ -0,0 +1,17 @@ +package com.ninecraft.booket.feature.screens.arguments + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RecordEditArgs( + val id: String, + val pageNumber: Int, + val quote: String, + val review: String, + val emotionTags: List, + val bookTitle: String, + val bookPublisher: String, + val bookCoverImageUrl: String, + val author: String, +) : Parcelable diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt index 62efcce8..61cc99e5 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt @@ -8,10 +8,9 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.constants.BookStatus -import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.utils.handleException -import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel @@ -38,9 +37,17 @@ import kotlinx.coroutines.launch class BookSearchPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: BookRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { companion object { private const val START_INDEX = 1 + private const val SEARCH_BOOK_INPUT = "search_book_input" + private const val SEARCH_BOOK_RESULT = "search_book_result" + private const val SEARCH_BOOK_NO_RESULT = "search_book_noresult" + private const val ERROR_SEARCH_LOADING = "error_search_loading" + private const val REGISTER_BOOK_OPTION = "register_book_option" + private const val REGISTER_BOOK_COMPLETE = "register_book_complete" + private const val ERROR_REGISTER_BOOK = "error_register_book" } @Composable @@ -86,9 +93,15 @@ class BookSearchPresenter @AssistedInject constructor( } else { footerState = if (isLastPage) FooterState.End else FooterState.Idle } + + analyticsHelper.logEvent(SEARCH_BOOK_RESULT) + if (startIndex == START_INDEX && result.books.isEmpty()) { + analyticsHelper.logEvent(SEARCH_BOOK_NO_RESULT) + } } .onFailure { exception -> Logger.d(exception) + analyticsHelper.logEvent(ERROR_SEARCH_LOADING) val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." if (startIndex == START_INDEX) { uiState = UiState.Error(exception) @@ -110,17 +123,14 @@ class BookSearchPresenter @AssistedInject constructor( } else book }.toPersistentList() + analyticsHelper.logEvent(REGISTER_BOOK_COMPLETE) selectedBookIsbn = "" selectedBookStatus = null isBookRegisterBottomSheetVisible = false isBookRegisterSuccessBottomSheetVisible = true } .onFailure { exception -> - postErrorDialog( - errorScope = ErrorScope.BOOK_REGISTER, - exception = exception, - ) - + analyticsHelper.logEvent(ERROR_REGISTER_BOOK) val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = BookSearchSideEffect.ShowToast(message) @@ -151,15 +161,16 @@ class BookSearchPresenter @AssistedInject constructor( searchBooks(query = event.query, startIndex = START_INDEX) } - is BookSearchUiEvent.OnRecentSearchRemoveClick -> { + is BookSearchUiEvent.OnRecentSearchDeleteClick -> { scope.launch { - repository.removeBookRecentSearch(query = event.query) + repository.deleteBookRecentSearch(query = event.query) } } is BookSearchUiEvent.OnSearchClick -> { - val query = event.text.trim() + val query = event.query.trim() if (query.isNotEmpty()) { + analyticsHelper.logEvent(SEARCH_BOOK_INPUT) searchBooks(query = query, startIndex = START_INDEX) } } @@ -199,6 +210,7 @@ class BookSearchPresenter @AssistedInject constructor( } is BookSearchUiEvent.OnBookStatusSelect -> { + analyticsHelper.logEvent(REGISTER_BOOK_OPTION) selectedBookStatus = event.bookStatus } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt index 28b8ea15..9e4a9c8f 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text @@ -32,6 +31,7 @@ import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedErrorUi +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.screens.SearchScreen import com.ninecraft.booket.feature.search.R import com.ninecraft.booket.feature.search.book.component.BookItem @@ -93,34 +93,23 @@ internal fun SearchContent( ReedTextField( queryState = state.queryState, queryHintRes = designR.string.search_book_hint, - onSearch = { text -> - state.eventSink(BookSearchUiEvent.OnSearchClick(text)) + onSearch = { query -> + state.eventSink(BookSearchUiEvent.OnSearchClick(query)) }, onClear = { state.eventSink(BookSearchUiEvent.OnClearClick) }, - modifier = modifier.padding(horizontal = ReedTheme.spacing.spacing5), + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), borderStroke = BorderStroke(width = 1.dp, color = ReedTheme.colors.borderBrand), searchIconTint = ReedTheme.colors.contentBrand, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) - - ReedDivider( - modifier = Modifier - .fillMaxWidth() - .height(ReedTheme.spacing.spacing2), - ) - - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + ReedDivider() + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) when (state.uiState) { is UiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } + ReedLoadingIndicator() } is UiState.Error -> { @@ -160,9 +149,9 @@ internal fun SearchContent( onQueryClick = { keyword -> state.eventSink(BookSearchUiEvent.OnRecentSearchClick(keyword)) }, - onRemoveIconClick = { keyword -> + onDeleteIconClick = { keyword -> state.eventSink( - BookSearchUiEvent.OnRecentSearchRemoveClick(keyword), + BookSearchUiEvent.OnRecentSearchDeleteClick(keyword), ) }, ) diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt index 3f534a38..5f023961 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt @@ -12,6 +12,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.util.UUID +@Immutable sealed interface UiState { data object Idle : UiState data object Loading : UiState @@ -47,8 +48,8 @@ sealed interface BookSearchSideEffect { sealed interface BookSearchUiEvent : CircuitUiEvent { data object OnBackClick : BookSearchUiEvent data class OnRecentSearchClick(val query: String) : BookSearchUiEvent - data class OnRecentSearchRemoveClick(val query: String) : BookSearchUiEvent - data class OnSearchClick(val text: String) : BookSearchUiEvent + data class OnRecentSearchDeleteClick(val query: String) : BookSearchUiEvent + data class OnSearchClick(val query: String) : BookSearchUiEvent data object OnClearClick : BookSearchUiEvent data class OnBookClick(val isbn13: String) : BookSearchUiEvent data object OnLoadMore : BookSearchUiEvent diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt index 1c8b1820..59dcdba8 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.component.NetworkImage import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -95,10 +96,13 @@ fun BookItem( text = book.title, color = titleColor, overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = ReedTheme.typography.body1SemiBold, + maxLines = 2, + style = ReedTheme.typography.body1SemiBold.copy( + lineHeight = 16.sp * 1.4f, + letterSpacing = 16.sp * 0.01f, + ), ) - Spacer(Modifier.height(ReedTheme.spacing.spacing1)) + Spacer(Modifier.height(ReedTheme.spacing.spacing2)) BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val authorMaxWidth = maxWidth * 0.7f @@ -141,7 +145,7 @@ private fun BookItemPreview() { ReedTheme { BookItem( book = BookSummaryModel( - title = "여름은 오래 그곳에 남아", + title = "여름은 오래 그곳에 남아 여름은 오래 그곳에 남아 여름은 오래 그곳에 남아 여름은 오래 그곳에 남아", author = "마쓰이에 마사시 마쓰이에 마사시", publisher = "비채", coverImageUrl = "https://example.com/sample-book-cover.jpg", diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt index ada10a33..cd6cc348 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.ui.component.ReedBottomSheet @@ -139,7 +140,7 @@ fun RowScope.BookStatusItem( interactionSource = remember { MutableInteractionSource() }, onClick = onClick, ) - .padding(vertical = ReedTheme.spacing.spacing3), + .padding(vertical = 14.dp), contentAlignment = Alignment.Center, ) { Text( diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt index 7235f724..dd34726a 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt @@ -51,12 +51,13 @@ fun BookRegisterSuccessBottomSheet( ), horizontalAlignment = Alignment.CenterHorizontally, ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) Image( painter = painterResource(R.drawable.img_book_register_complete), contentDescription = "Book Register Complete Image", modifier = Modifier.height(120.dp), ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) Text( text = stringResource(R.string.book_register_success_title), modifier = Modifier.fillMaxWidth(), diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt index 0a435282..a8cb9362 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt @@ -24,7 +24,7 @@ import com.ninecraft.booket.core.designsystem.R as designR fun SearchItem( query: String, onQueryClick: (String) -> Unit, - onRemoveIconClick: (String) -> Unit, + onDeleteIconClick: (String) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -48,11 +48,11 @@ fun SearchItem( Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing3)) Icon( imageVector = ImageVector.vectorResource(id = designR.drawable.ic_close), - contentDescription = "Remove Icon", + contentDescription = "Delete Icon", tint = ReedTheme.colors.contentSecondary, modifier = Modifier .size(18.dp) - .clickable { onRemoveIconClick(query) }, + .clickable { onDeleteIconClick(query) }, ) } } @@ -64,7 +64,7 @@ private fun SearchItemPreview() { SearchItem( query = "최근 검색어 최근 검색어 최근 검색어 최근 검색어 최근 검색어", onQueryClick = {}, - onRemoveIconClick = {}, + onDeleteIconClick = {}, ) } } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt index e15b8e35..240d20e7 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.LibraryBookSummaryModel @@ -21,6 +22,7 @@ import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -33,10 +35,12 @@ import kotlinx.coroutines.launch class LibrarySearchPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: BookRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { companion object { private const val PAGE_SIZE = 20 private const val START_INDEX = 0 + private const val ERROR_SEARCH = "error_search" } @Composable @@ -86,7 +90,7 @@ class LibrarySearchPresenter @AssistedInject constructor( } else { footerState = FooterState.Error(errorMessage) } - + analyticsHelper.logEvent(ERROR_SEARCH) val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = LibrarySearchSideEffect.ShowToast(message) @@ -117,9 +121,9 @@ class LibrarySearchPresenter @AssistedInject constructor( searchLibraryBooks(query = event.query, page = START_INDEX, size = PAGE_SIZE) } - is LibrarySearchUiEvent.OnRecentSearchRemoveClick -> { + is LibrarySearchUiEvent.OnRecentSearchDeleteClick -> { scope.launch { - repository.removeLibraryRecentSearch(event.query) + repository.deleteLibraryRecentSearch(event.query) } } @@ -154,6 +158,10 @@ class LibrarySearchPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(LibrarySearchScreen.name) + } + return LibrarySearchUiState( uiState = uiState, footerState = footerState, diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt index b40cb66e..148c12fc 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -27,6 +26,7 @@ import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedErrorUi +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.screens.LibrarySearchScreen import com.ninecraft.booket.feature.search.R import com.ninecraft.booket.feature.search.common.component.RecentSearchTitle @@ -75,32 +75,23 @@ internal fun LibrarySearchContent( ReedTextField( queryState = state.queryState, queryHintRes = R.string.library_search_hint, - onSearch = { text -> - state.eventSink(LibrarySearchUiEvent.OnSearchClick(text)) + onSearch = { query -> + state.eventSink(LibrarySearchUiEvent.OnSearchClick(query)) }, onClear = { state.eventSink(LibrarySearchUiEvent.OnClearClick) }, - modifier = modifier.padding(horizontal = ReedTheme.spacing.spacing5), + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), backgroundColor = ReedTheme.colors.baseSecondary, borderStroke = null, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) - ReedDivider( - modifier = Modifier - .fillMaxWidth() - .height(ReedTheme.spacing.spacing2), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + ReedDivider() + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) when (state.uiState) { is UiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } + ReedLoadingIndicator() } is UiState.Error -> { @@ -140,8 +131,8 @@ internal fun LibrarySearchContent( onQueryClick = { keyword -> state.eventSink(LibrarySearchUiEvent.OnRecentSearchClick(keyword)) }, - onRemoveIconClick = { keyword -> - state.eventSink(LibrarySearchUiEvent.OnRecentSearchRemoveClick(keyword)) + onDeleteIconClick = { keyword -> + state.eventSink(LibrarySearchUiEvent.OnRecentSearchDeleteClick(keyword)) }, ) HorizontalDivider( diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt index f6d2c9f1..96b576a0 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt @@ -1,6 +1,7 @@ package com.ninecraft.booket.feature.search.library import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState import com.slack.circuit.runtime.CircuitUiEvent @@ -9,6 +10,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.util.UUID +@Immutable sealed interface UiState { data object Idle : UiState data object Loading : UiState @@ -36,7 +38,7 @@ sealed interface LibrarySearchSideEffect { sealed interface LibrarySearchUiEvent : CircuitUiEvent { data object OnBackClick : LibrarySearchUiEvent data class OnRecentSearchClick(val query: String) : LibrarySearchUiEvent - data class OnRecentSearchRemoveClick(val query: String) : LibrarySearchUiEvent + data class OnRecentSearchDeleteClick(val query: String) : LibrarySearchUiEvent data class OnSearchClick(val query: String) : LibrarySearchUiEvent data object OnClearClick : LibrarySearchUiEvent data object OnLoadMore : LibrarySearchUiEvent diff --git a/feature/settings/src/main/assets/oss_licenses.json b/feature/settings/src/main/assets/oss_licenses.json index 71d1421f..ce75a00b 100644 --- a/feature/settings/src/main/assets/oss_licenses.json +++ b/feature/settings/src/main/assets/oss_licenses.json @@ -44,6 +44,56 @@ "license": "Apache License 2.0", "url": "https://github.com/coil-kt/coil" }, + { + "name": "Landscapist", + "license": "Apache License 2.0", + "url": "https://github.com/skydoves/landscapist" + }, + { + "name": "Google Analytics", + "license": "Apache License 2.0", + "url": "https://firebase.google.com/docs/analytics" + }, + { + "name": "Firebase Crashlytics", + "license": "Apache License 2.0", + "url": "https://firebase.google.com/docs/crashlytics" + }, + { + "name": "ML Kit Text Recognition", + "license": "Apache License 2.0", + "url": "https://developers.google.com/ml-kit/vision/text-recognition" + }, + { + "name": "Compose System UI Controller", + "license": "MIT License", + "url": "https://github.com/taehwandev/ComposeExtensions" + }, + { + "name": "Compose Keyboard State", + "license": "MIT License", + "url": "https://github.com/taehwandev/ComposeExtensions" + }, + { + "name": "Lottie Compose", + "license": "Apache License 2.0", + "url": "https://github.com/airbnb/lottie-android" + }, + { + "name": "Kakao SDK", + "license": "Apache License 2.0", + "url": "https://developers.kakao.com/docs/latest/ko/android/getting-started" + }, + { + "name": "Kotlinx Collections Immutable", + "license": "Apache License 2.0", + "url": "https://github.com/Kotlin/kotlinx.collections.immutable" + }, + { + "name": "Compose Shadow", + "license": "MIT License", + "url": "https://github.com/adamglin0/compose-shadow" + }, { "name": "Detekt", "license": "Apache License 2.0", diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt index 3f046e66..7b255355 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt @@ -3,11 +3,13 @@ package com.ninecraft.booket.feature.settings import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.ninecraft.booket.core.common.extensions.openPlayStore import com.skydoves.compose.effects.RememberedEffect @Composable internal fun HandleSettingsSideEffects( state: SettingsUiState, + eventSink: (SettingsUiEvent) -> Unit, ) { val context = LocalContext.current @@ -16,7 +18,16 @@ internal fun HandleSettingsSideEffects( is SettingsSideEffect.ShowToast -> { Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() } - null -> {} + + is SettingsSideEffect.NavigateToPlayStore -> { + context.openPlayStore() + } + + else -> {} + } + + if (state.sideEffect != null) { + eventSink(SettingsUiEvent.InitSideEffect) } } } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt index f9715e19..fefe62d2 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt @@ -5,18 +5,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.constants.WebViewConstants import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.AuthRepository +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OssLicensesScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.ninecraft.booket.feature.screens.WebViewScreen import com.orhanobut.logger.Logger +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -26,19 +30,118 @@ import kotlinx.coroutines.launch class SettingsPresenter @AssistedInject constructor( @Assisted val navigator: Navigator, private val authRepository: AuthRepository, + private val remoteConfigRepository: RemoteConfigRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { + companion object { + private const val SETTINGS_LOGOUT_COMPLETE = "settings_logout_complete" + private const val SETTINGS_WITHDRAWAL_COMPLETE = "settings_withdrawal_complete" + private const val SETTINGS_WITHDRAWAL_WARNING = "settings_withdrawal_warning" + } + @Composable override fun present(): SettingsUiState { val scope = rememberCoroutineScope() var isLoading by rememberRetained { mutableStateOf(false) } - var sideEffect by rememberRetained { mutableStateOf(null) } var isLogoutDialogVisible by rememberRetained { mutableStateOf(false) } var isWithdrawBottomSheetVisible by rememberRetained { mutableStateOf(false) } var isWithdrawConfirmed by rememberRetained { mutableStateOf(false) } + var latestVersion by rememberRetained { mutableStateOf("") } + var isOptionalUpdateDialogVisible by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } + + fun logout() { + scope.launch { + try { + isLoading = true + authRepository.logout() + .onSuccess { + analyticsHelper.logEvent(SETTINGS_LOGOUT_COMPLETE) + navigator.resetRoot(LoginScreen) + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = SettingsSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } finally { + isLoading = false + isLogoutDialogVisible = false + } + } + } + + fun withdraw() { + scope.launch { + try { + isLoading = true + authRepository.withdraw() + .onSuccess { + analyticsHelper.logEvent(SETTINGS_WITHDRAWAL_COMPLETE) + navigator.resetRoot(LoginScreen) + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = SettingsSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } finally { + isLoading = false + isWithdrawBottomSheetVisible = false + } + } + } + + fun getLatestVersion() { + scope.launch { + try { + isLoading = true + remoteConfigRepository.getLatestVersion() + .onSuccess { version -> + latestVersion = version + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = SettingsSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + ) + } + } finally { + isLoading = false + } + } + } fun handleEvent(event: SettingsUiEvent) { when (event) { + is SettingsUiEvent.InitSideEffect -> { + sideEffect = null + } + is SettingsUiEvent.OnBackClick -> { navigator.pop() } @@ -62,6 +165,7 @@ class SettingsPresenter @AssistedInject constructor( } is SettingsUiEvent.OnWithdrawClick -> { + analyticsHelper.logEvent(SETTINGS_WITHDRAWAL_WARNING) isWithdrawBottomSheetVisible = true } @@ -76,69 +180,34 @@ class SettingsPresenter @AssistedInject constructor( } is SettingsUiEvent.Logout -> { - scope.launch { - try { - isLoading = true - authRepository.logout() - .onSuccess { - navigator.resetRoot(LoginScreen) - } - .onFailure { exception -> - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = SettingsSideEffect.ShowToast(message) - } - - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) - } - } finally { - isLoading = false - } - } - isLogoutDialogVisible = false + logout() } is SettingsUiEvent.Withdraw -> { - scope.launch { - try { - isLoading = true - authRepository.withdraw() - .onSuccess { - navigator.resetRoot(LoginScreen) - } - .onFailure { exception -> - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = SettingsSideEffect.ShowToast(message) - } - - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) - } - } finally { - isLoading = false - } - } - isWithdrawBottomSheetVisible = false + withdraw() + } + + is SettingsUiEvent.OnVersionClick -> { + sideEffect = SettingsSideEffect.NavigateToPlayStore } } } + + RememberedEffect(Unit) { + getLatestVersion() + } + + ImpressionEffect { + analyticsHelper.logScreenView(SettingsScreen.name) + } + return SettingsUiState( isLoading = isLoading, isLogoutDialogVisible = isLogoutDialogVisible, isWithdrawBottomSheetVisible = isWithdrawBottomSheetVisible, isWithdrawConfirmed = isWithdrawConfirmed, + latestVersion = latestVersion, + isOptionalUpdateDialogVisible = isOptionalUpdateDialogVisible, sideEffect = sideEffect, eventSink = ::handleEvent, ) diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt index 030c58c9..37a60a3a 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt @@ -1,17 +1,14 @@ package com.ninecraft.booket.feature.settings import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -26,7 +23,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource -import com.ninecraft.booket.core.common.extensions.clickableSingle +import com.ninecraft.booket.core.common.util.compareVersions import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.component.ReedDivider import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -34,11 +31,14 @@ import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.screens.SettingsScreen +import com.ninecraft.booket.feature.settings.component.SettingItem import com.ninecraft.booket.feature.settings.component.WithdrawConfirmationBottomSheet import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.coroutines.launch +import com.ninecraft.booket.core.designsystem.R as designR @OptIn(ExperimentalMaterial3Api::class) @CircuitInject(SettingsScreen::class, ActivityRetainedComponent::class) @@ -47,7 +47,10 @@ internal fun SettingsUi( state: SettingsUiState, modifier: Modifier = Modifier, ) { - HandleSettingsSideEffects(state = state) + HandleSettingsSideEffects( + state = state, + eventSink = state.eventSink, + ) val withDrawSheetState = rememberModalBottomSheetState() val coroutineScope = rememberCoroutineScope() @@ -59,6 +62,10 @@ internal fun SettingsUi( }.getOrNull() ?: "Unknown" } + val isUpdateAvailable = remember(appVersion, state.latestVersion) { + compareVersions(state.latestVersion, appVersion) > 0 + } + ReedScaffold( modifier = modifier .fillMaxSize() @@ -85,7 +92,7 @@ internal fun SettingsUi( }, action = { Icon( - imageVector = ImageVector.vectorResource(id = com.ninecraft.booket.core.designsystem.R.drawable.ic_chevron_right), + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Right Chevron Icon", tint = Color.Unspecified, ) @@ -98,7 +105,7 @@ internal fun SettingsUi( }, action = { Icon( - imageVector = ImageVector.vectorResource(id = com.ninecraft.booket.core.designsystem.R.drawable.ic_chevron_right), + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Right Chevron Icon", tint = Color.Unspecified, ) @@ -111,7 +118,7 @@ internal fun SettingsUi( }, action = { Icon( - imageVector = ImageVector.vectorResource(id = com.ninecraft.booket.core.designsystem.R.drawable.ic_chevron_right), + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Right Chevron Icon", tint = Color.Unspecified, ) @@ -119,12 +126,26 @@ internal fun SettingsUi( ) SettingItem( title = stringResource(R.string.settings_app_version), - isClickable = false, + isClickable = isUpdateAvailable, + onItemClick = { + state.eventSink(SettingsUiEvent.OnVersionClick) + }, action = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = appVersion, + style = ReedTheme.typography.body1Medium, + color = ReedTheme.colors.contentBrand, + ) + } + }, + description = { Text( - text = appVersion, - style = ReedTheme.typography.body1Medium, - color = ReedTheme.colors.contentSecondary, + text = stringResource(R.string.latest_version, state.latestVersion), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, ) }, ) @@ -144,14 +165,7 @@ internal fun SettingsUi( } if (state.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = ReedTheme.colors.contentBrand, - ) - } + ReedLoadingIndicator() } if (state.isLogoutDialogVisible) { @@ -192,40 +206,6 @@ internal fun SettingsUi( } } -@Composable -private fun SettingItem( - title: String, - modifier: Modifier = Modifier, - isClickable: Boolean = true, - onItemClick: () -> Unit = {}, - action: @Composable () -> Unit = {}, -) { - val combinedModifier = if (isClickable) { - modifier - .fillMaxWidth() - .clickableSingle { onItemClick() } - } else { - modifier.fillMaxWidth() - } - - Row( - modifier = combinedModifier - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - text = title, - style = ReedTheme.typography.body1Medium, - color = ReedTheme.colors.contentPrimary, - ) - action() - } -} - @DevicePreview @Composable private fun SettingsScreenPreview() { diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt index 21d5a76b..fb7961e5 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt @@ -10,6 +10,9 @@ data class SettingsUiState( val isLogoutDialogVisible: Boolean = false, val isWithdrawBottomSheetVisible: Boolean = false, val isWithdrawConfirmed: Boolean = false, + val latestVersion: String = "", + val isUpdateAvailable: Boolean = false, + val isOptionalUpdateDialogVisible: Boolean = false, val sideEffect: SettingsSideEffect? = null, val eventSink: (SettingsUiEvent) -> Unit, ) : CircuitUiState @@ -20,9 +23,12 @@ sealed interface SettingsSideEffect { val message: String, private val key: String = UUID.randomUUID().toString(), ) : SettingsSideEffect + + data object NavigateToPlayStore : SettingsSideEffect } sealed interface SettingsUiEvent : CircuitUiEvent { + data object InitSideEffect : SettingsUiEvent data object OnBackClick : SettingsUiEvent data object OnPolicyClick : SettingsUiEvent data object OnTermClick : SettingsUiEvent @@ -33,4 +39,5 @@ sealed interface SettingsUiEvent : CircuitUiEvent { data object OnWithdrawConfirmationToggled : SettingsUiEvent data object Logout : SettingsUiEvent data object Withdraw : SettingsUiEvent + data object OnVersionClick : SettingsUiEvent } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/SettingItem.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/SettingItem.kt new file mode 100644 index 00000000..d794bef3 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/SettingItem.kt @@ -0,0 +1,64 @@ +package com.ninecraft.booket.feature.settings.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.ninecraft.booket.core.common.extensions.clickableSingle +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +internal fun SettingItem( + title: String, + modifier: Modifier = Modifier, + isClickable: Boolean = true, + onItemClick: () -> Unit = {}, + action: @Composable () -> Unit = {}, + description: @Composable () -> Unit = {}, +) { + val combinedModifier = if (isClickable) { + modifier + .fillMaxWidth() + .clickableSingle { onItemClick() } + } else { + modifier.fillMaxWidth() + } + + Column { + Row( + modifier = combinedModifier + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = ReedTheme.typography.body1Medium, + color = ReedTheme.colors.contentPrimary, + ) + description() + } + action() + } + } +} + +@ComponentPreview +@Composable +private fun SettingItemPreview() { + ReedTheme { + SettingItem( + title = "로그아웃", + ) + } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 01bbd5d5..fa897943 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -14,4 +14,8 @@ 취소 탈퇴하기 오픈소스 라이선스 + 최신 버전 %1$s + 최신 버전이 출시되었습니다. + 최적의 사용 환경을 위해 업데이트해주세요. + 업데이트하기 diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/HandleSplashSideEffects.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/HandleSplashSideEffects.kt new file mode 100644 index 00000000..736d6df6 --- /dev/null +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/HandleSplashSideEffects.kt @@ -0,0 +1,27 @@ +package com.ninecraft.booket.splash + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.ninecraft.booket.core.common.extensions.openPlayStore +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleSplashSideEffects( + state: SplashUiState, + eventSink: (SplashUiEvent) -> Unit, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is SplashSideEffect.NavigateToPlayStore -> { + context.openPlayStore() + } + null -> {} + } + + if (state.sideEffect != null) { + eventSink(SplashUiEvent.InitSideEffect) + } + } +} diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt index ef79f06f..a039dc62 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt @@ -6,20 +6,26 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.core.common.constants.ErrorScope +import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.AuthRepository +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.model.AutoLoginState import com.ninecraft.booket.core.model.OnboardingState +import com.ninecraft.booket.core.ui.R import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OnboardingScreen import com.ninecraft.booket.feature.screens.SplashScreen -import com.skydoves.compose.effects.RememberedEffect +import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,6 +37,8 @@ class SplashPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val remoteConfigRepository: RemoteConfigRepository, + private val analyticsHelper: AnalyticsHelper, ) : Presenter { @Composable @@ -38,7 +46,8 @@ class SplashPresenter @AssistedInject constructor( val scope = rememberCoroutineScope() val onboardingState by userRepository.onboardingState.collectAsRetainedState(initial = OnboardingState.IDLE) val autoLoginState by authRepository.autoLoginState.collectAsRetainedState(initial = AutoLoginState.IDLE) - var isSplashTimeCompleted by rememberRetained { mutableStateOf(false) } + var isForceUpdateDialogVisible by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } fun checkTermsAgreement() { scope.launch { @@ -50,20 +59,18 @@ class SplashPresenter @AssistedInject constructor( navigator.resetRoot(LoginScreen) } } - .onFailure { - navigator.resetRoot(LoginScreen) + .onFailure { exception -> + postErrorDialog( + errorScope = ErrorScope.GLOBAL, + exception = exception, + buttonLabelResId = R.string.retry, + action = { checkTermsAgreement() }, + ) } } } - LaunchedEffect(Unit) { - delay(1000L) - isSplashTimeCompleted = true - } - - RememberedEffect(onboardingState, autoLoginState, isSplashTimeCompleted) { - if (!isSplashTimeCompleted) return@RememberedEffect - + fun proceedToNextScreen() { when (onboardingState) { OnboardingState.NOT_COMPLETED -> { navigator.resetRoot(OnboardingScreen) @@ -91,7 +98,54 @@ class SplashPresenter @AssistedInject constructor( } } - return SplashUiState + fun checkForceUpdate() { + scope.launch { + remoteConfigRepository.shouldUpdate() + .onSuccess { shouldUpdate -> + if (shouldUpdate) { + isForceUpdateDialogVisible = true + } else { + proceedToNextScreen() + } + } + .onFailure { exception -> + Logger.e("${exception.message}") + proceedToNextScreen() + } + } + } + + fun handleEvent(event: SplashUiEvent) { + when (event) { + SplashUiEvent.OnUpdateButtonClick -> { + sideEffect = SplashSideEffect.NavigateToPlayStore + } + + SplashUiEvent.InitSideEffect -> { + sideEffect = null + } + } + } + + LaunchedEffect(onboardingState, autoLoginState) { + delay(1000L) + + if (onboardingState == OnboardingState.IDLE || autoLoginState == AutoLoginState.IDLE) { + return@LaunchedEffect + } + + checkForceUpdate() + } + + ImpressionEffect { + analyticsHelper.logScreenView(SplashScreen.name) + } + + return SplashUiState( + isForceUpdateDialogVisible = isForceUpdateDialogVisible, + sideEffect = sideEffect, + eventSink = ::handleEvent, + ) } @CircuitInject(SplashScreen::class, ActivityRetainedComponent::class) diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt index 444165a5..dd7f9d80 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.splash +import android.R.attr.description import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -17,9 +18,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.feature.screens.SplashScreen import com.ninecraft.booket.feature.splash.R import com.slack.circuit.codegen.annotations.CircuitInject @@ -29,6 +32,7 @@ import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiControll @CircuitInject(SplashScreen::class, ActivityRetainedComponent::class) @Composable fun SplashUi( + state: SplashUiState, modifier: Modifier = Modifier, ) { val systemUiController = rememberSystemUiController() @@ -49,6 +53,11 @@ fun SplashUi( } } + HandleSplashSideEffects( + state = state, + eventSink = state.eventSink, + ) + Box( modifier = modifier .fillMaxSize() @@ -68,10 +77,22 @@ fun SplashUi( Text( text = stringResource(R.string.splash_title), color = ReedTheme.colors.contentInverse, + textAlign = TextAlign.Center, style = ReedTheme.typography.heading2SemiBold, ) Spacer(Modifier.height(ReedTheme.spacing.spacing8)) } + + if (state.isForceUpdateDialogVisible) { + ReedDialog( + title = stringResource(R.string.splash_force_update_title), + description = stringResource(R.string.splash_force_update_message), + confirmButtonText = stringResource(R.string.splash_force_update_button_text), + onConfirmRequest = { + state.eventSink(SplashUiEvent.OnUpdateButtonClick) + }, + ) + } } } @@ -79,6 +100,10 @@ fun SplashUi( @Composable private fun SplashPreview() { ReedTheme { - SplashUi() + SplashUi( + state = SplashUiState( + eventSink = {}, + ), + ) } } diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt index d1ed996e..7f8be888 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt @@ -1,5 +1,21 @@ package com.ninecraft.booket.splash +import androidx.compose.runtime.Immutable +import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState -object SplashUiState : CircuitUiState +data class SplashUiState( + val isForceUpdateDialogVisible: Boolean = false, + val sideEffect: SplashSideEffect? = null, + val eventSink: (SplashUiEvent) -> Unit, +) : CircuitUiState + +@Immutable +sealed interface SplashSideEffect { + data object NavigateToPlayStore : SplashSideEffect +} + +sealed interface SplashUiEvent : CircuitUiEvent { + data object InitSideEffect : SplashUiEvent + data object OnUpdateButtonClick : SplashUiEvent +} diff --git a/feature/splash/src/main/res/values/strings.xml b/feature/splash/src/main/res/values/strings.xml index 3c81ec5c..bde1e36c 100644 --- a/feature/splash/src/main/res/values/strings.xml +++ b/feature/splash/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ 책 덮기 전 한 문장을 기록해보세요 + 최신 버전이 출시되었습니다. + 최적의 사용 환경을 위해 업데이트해주세요. + 업데이트 하기 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96ee3042..12530d9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,9 +72,17 @@ androidx-test-runner = "1.6.2" ## Firebase google-service = "4.4.3" -firebase-bom = "33.16.0" +firebase-bom = "34.1.0" firebase-crashlytics = "3.0.4" +## App Configuration +minSdk = "28" +targetSdk = "35" +compileSdk = "35" +versionName = "1.1.0" +versionCode = "4" +packageName = "com.ninecraft.booket" + [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } @@ -123,7 +131,6 @@ landscapist-placeholder = { group = "com.github.skydoves", name = "landscapist-p kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } -kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } circuit-foundation = { group = "com.slack.circuit", name = "circuit-foundation", version.ref = "circuit" } @@ -149,8 +156,9 @@ androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-remote-config = { group = "com.google.firebase", name = "firebase-config" } [plugins] gradle-dependency-handler-extensions = { id = "land.sungbin.dependency.handler.extensions", version.ref = "gradle-dependency-handler-extensions" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 83e03038..9508c61b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,4 +47,5 @@ include( ":feature:settings", ":feature:splash", ":feature:webview", + ":feature:edit", )