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",
)