diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..92da1bc83 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,24 @@ +# Reed 프로젝트 작업 지침 + +## 빌드 관련 + +- **빌드는 사용자가 직접 수행합니다** +- 기능 작업 완료 후 빌드를 자동으로 실행하지 마세요 (시간이 오래 걸림) +- 빌드가 필요한 경우 사용자에게 알리고 사용자가 직접 실행하도록 합니다 + +## 커밋 관련 + +- **커밋 메시지에서 Claude 관련 문구를 제거합니다** +- 다음 문구들을 커밋 메시지에 포함하지 마세요: + - `🤖 Generated with [Claude Code](https://claude.com/claude-code)` + - `Co-Authored-By: Claude Sonnet 4.5 ` +- 커밋 작업은 사용자가 직접 수행하는 경우가 많으므로, 요청받지 않은 경우 커밋하지 마세요 + +## MCP 설정 관련 + +- **Claude Code CLI의 MCP 설정 파일 위치** + - Claude Desktop이 아니라 **Claude Code CLI**를 사용 중입니다 + - MCP 설정은 `~/.claude.json`의 `projects` 섹션에서 프로젝트별로 관리됩니다 + - Claude Desktop 설정 파일(`~/Library/Application Support/Claude/claude_desktop_config.json`)을 수정하지 마세요 +- **Figma MCP 설정 경로** + - `~/.claude.json` → `projects` → `/Users/medi/AndroidStudioProjects/YeoBee-Android` → `mcpServers` → `figma` diff --git a/.claude/CODING.md b/.claude/CODING.md new file mode 100644 index 000000000..d461ee756 --- /dev/null +++ b/.claude/CODING.md @@ -0,0 +1,25 @@ +# 코딩 가이드 + +## 코드 작성 원칙 + +- 한글 주석 사용 +- Kotlin 코딩 컨벤션 준수 +- 기존 코드 스타일 유지 +- 파일 끝에 빈 줄(newline) 추가 + +## Compose 관련 + +- **Composable 함수 내 Collection 타입** + - `List`, `Set`, `Map` 등의 Collection 대신 `ImmutableList`, `ImmutableSet`, `ImmutableMap` 사용 + - `kotlinx.collections.immutable` 라이브러리 사용 + - 예시: + ```kotlin + // ❌ 사용하지 않음 + @Composable + fun TripList(trips: List) { ... } + + // ✅ 사용 + @Composable + fun TripList(trips: ImmutableList) { ... } + ``` + - 변환 시 `toImmutableList()`, `persistentListOf()` 등 사용 diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index f3e8862f5..53547da93 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -6,6 +6,8 @@ env: on: pull_request: + push: + branches: [ main, develop ] concurrency: group: build-${{ github.ref }} @@ -15,7 +17,7 @@ jobs: ci-build: runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} + if: ${{ github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} steps: - name: Checkout @@ -30,10 +32,14 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 + # 캐시 저장/정리는 job 완료 후 post action 단계에서 수행 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/actions/setup-gradle@v4 with: - gradle-home-cache-cleanup: true + # 빌드 성공 시 미사용 캐시 엔트리 자동 정리 (default: on-success) + # cache-cleanup: on-success + # PR에서는 캐시 읽기만 허용, push(develop/main)에서만 캐시 갱신하여 용량 절약 + cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Generate local.properties run: echo '${{ secrets.LOCAL_PROPERTIES }}' | base64 -d > ./local.properties @@ -70,10 +76,14 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 + # 캐시 저장/정리는 job 완료 후 post action 단계에서 수행 - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/actions/setup-gradle@v4 with: - gradle-home-cache-cleanup: true + # 빌드 성공 시 미사용 캐시 엔트리 자동 정리 (default: on-success) + # cache-cleanup: on-success + # PR에서는 캐시 읽기만 허용, push(develop/main)에서만 캐시 갱신하여 용량 절약 + cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Generate local.properties run: echo '${{ secrets.LOCAL_PROPERTIES }}' | base64 -d > ./local.properties @@ -84,5 +94,8 @@ jobs: - name: Generate google-services.json run: echo '${{ secrets.GOOGLE_SERVICES }}' | base64 -d > ./app/google-services.json + - name: Compose Stability Dump + run: ./gradlew stabilityDump + - name: Compose Stability Check run: ./gradlew stabilityCheck diff --git a/README.md b/README.md index 131a8e3ca..697302c95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Reed - 문장과 감정을 함께 담는 독서 기록 -[![Kotlin](https://img.shields.io/badge/Kotlin-2.2.0-blue.svg)](https://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.2.21-blue.svg)](https://kotlinlang.org) [![Gradle](https://img.shields.io/badge/gradle-8.11.1-green.svg)](https://gradle.org/) [![Android Studio](https://img.shields.io/badge/Android%20Studio-2025.1.2%20%28Narwhal%29-green)](https://developer.android.com/studio) [![minSdkVersion](https://img.shields.io/badge/minSdkVersion-28-red)](https://developer.android.com/distribute/best-practices/develop/target-sdk) @@ -37,15 +37,19 @@ | 기록 카드 공유 | ## TroubleShooting -- [[Compose] M3 ModalBottomSheet 드래그(터치 이벤트) 막는 법](https://velog.io/@mraz3068/Compose-M3-ModalBottomSheet-Drag-Disabled) +- [Metro 적용해보기](https://velog.io/@mraz3068/Metro-Apply) +- [Compose Stability Analyzer 사용 후기](https://velog.io/@mraz3068/compose-stability-analyzer-review) +- [[Android] Toast 내부 구현 확인 해보기](https://velog.io/@mraz3068/Android-Toast-Deep-Dive) +- [Coroutine CancellationException 따로 처리해야하는 케이스](https://velog.io/@mraz3068/Coroutine-CancellationException-UseCase) +- [Coroutine 에러 처리 패턴: 여러 API 호출을 한 번에 성공/실패 판정하기](https://velog.io/@syoon513/Coroutine-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC) +- [[Circuit] ImpressionEffect](https://velog.io/@mraz3068/Circuit-ImpressionEffect) +- [[Android] 일회성 이벤트를 StateFlow, Compose의 State로 처리할 때 주의해야할 점](https://velog.io/@mraz3068/Handle-One-Time-Event-As-State) +- [Jetpack Compose에서 CameraX + MLKit으로 OCR을 구현해보자](https://velog.io/@syoon513/Jetpack-Compose%EC%97%90%EC%84%9C-CameraX-MLKit%EC%9C%BC%EB%A1%9C-OCR%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90) - [Circuit 찍먹해보기(부제: Circuit 희망편)](https://speakerdeck.com/easyhooon/circuit-jjigmeoghaebogi-buje-circuit-hyimangpyeon) - [Circuit 찍먹해보기(부제: Circuit 절망편)](https://speakerdeck.com/easyhooon/circuit-jjigmeoghaebogi-buje-circuit-jeolmangpyeon) -- [Jetpack Compose에서 CameraX + MLKit으로 OCR을 구현해보자](https://velog.io/@syoon513/Jetpack-Compose%EC%97%90%EC%84%9C-CameraX-MLKit%EC%9C%BC%EB%A1%9C-OCR%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90) -- [[Android] 일회성 이벤트를 StateFlow, Compose의 State로 처리할 때 주의해야할 점](https://velog.io/@mraz3068/Handle-One-Time-Event-As-State) - [Circuit Navigation 사용 시 feature 모듈간의 참조는 어떻게 해결했을까?](https://velog.io/@syoon513/Circuit-Navigation-%EC%82%AC%EC%9A%A9-%EC%8B%9C-feature-%EB%AA%A8%EB%93%88%EA%B0%84-%EC%88%9C%ED%99%98-%EC%B0%B8%EC%A1%B0%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EA%B2%B0%ED%96%88%EC%9D%84%EA%B9%8C) -- [Coroutine 에러 처리 패턴: 여러 API 호출을 한 번에 성공/실패 판정하기](https://velog.io/@syoon513/Coroutine-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC) -- [[Circuit] ImpressionEffect](https://velog.io/@mraz3068/Circuit-ImpressionEffect) -- [Coroutine CancellationException 따로 처리해야하는 케이스](https://velog.io/@mraz3068/Coroutine-CancellationException-UseCase) +- [[Compose] M3 ModalBottomSheet 드래그(터치 이벤트) 막는 법](https://velog.io/@mraz3068/Compose-M3-ModalBottomSheet-Drag-Disabled) + ## Development @@ -55,7 +59,7 @@ - JDK : Java 17을 실행할 수 있는 JDK - (권장) Android Studio 설치 시 Embedded 된 JDK (Open JDK) - Java 17을 사용하는 JDK (Open JDK, AdoptOpenJDK, GraalVM) -- Kotlin Language : 2.2.0 +- Kotlin Language : 2.2.21 ### Language @@ -76,8 +80,8 @@ - Material3 - [Circuit](https://github.com/slackhq/circuit) -- ~~Google ML Kit~~ Google Cloud Vision -- Dagger Hilt +- ~~Google ML Kit~~ -> [Google Cloud Vision](https://cloud.google.com/vision) +- ~~Dagger Hilt~~ -> [Metro](https://github.com/ZacSweers/metro) - Retrofit, OkHttp3 - Lottie-Compose - Firebase(Analytics, Crashlytics, Remote Config) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3790c5394..a3c0a89a1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,13 +1,14 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import com.google.devtools.ksp.gradle.KspExtension +import com.ninecraft.booket.convention.getLocalProperty +import org.gradle.kotlin.dsl.configure import java.util.Properties plugins { alias(libs.plugins.booket.android.application) alias(libs.plugins.booket.android.application.compose) - alias(libs.plugins.booket.android.hilt) alias(libs.plugins.booket.android.firebase) + alias(libs.plugins.metro) + alias(libs.plugins.ksp) } android { @@ -50,8 +51,9 @@ android { } defaultConfig { - buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getApiKey("KAKAO_NATIVE_APP_KEY")) - manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = getApiKey("KAKAO_NATIVE_APP_KEY").trim('"') + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", getLocalProperty("GOOGLE_WEB_CLIENT_ID")) + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getLocalProperty("KAKAO_NATIVE_APP_KEY")) + manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = getLocalProperty("KAKAO_NATIVE_APP_KEY").trim('"') } buildFeatures { @@ -63,8 +65,8 @@ composeStabilityAnalyzer { enabled.set(true) } -ksp { - arg("circuit.codegen.mode", "hilt") +extensions.configure { + arg("circuit.codegen.mode", "metro") } dependencies { @@ -75,6 +77,7 @@ dependencies { projects.core.datastore.api, projects.core.datastore.impl, projects.core.designsystem, + projects.core.di, projects.core.model, projects.core.network, projects.core.ui, @@ -105,7 +108,3 @@ dependencies { api(libs.circuit.codegen.annotation) ksp(libs.circuit.codegen.ksp) } - -fun getApiKey(propertyKey: String): String { - return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4cc128991..7f0f42bde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + tools:targetApi="31" + tools:replace="android:appComponentFactory"> + android:exported="false" + tools:ignore="Instantiatable"> diff --git a/app/src/main/kotlin/com/ninecraft/booket/BooketApplication.kt b/app/src/main/kotlin/com/ninecraft/booket/BooketApplication.kt index 4a77e70b3..ee34e1ee1 100644 --- a/app/src/main/kotlin/com/ninecraft/booket/BooketApplication.kt +++ b/app/src/main/kotlin/com/ninecraft/booket/BooketApplication.kt @@ -5,10 +5,13 @@ import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache import coil.util.DebugLogger -import dagger.hilt.android.HiltAndroidApp +import com.ninecraft.booket.di.AppGraph +import dev.zacsweers.metro.createGraphFactory -@HiltAndroidApp class BooketApplication : Application(), ImageLoaderFactory { + + val appGraph by lazy { createGraphFactory().create(this) } + override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(this) .diskCache { diff --git a/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt b/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt index 5efc49283..2548c6a20 100644 --- a/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt +++ b/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt @@ -3,6 +3,7 @@ package com.ninecraft.booket import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.app.Service import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat @@ -11,20 +12,24 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.designsystem.R +import com.ninecraft.booket.core.di.ServiceKey import com.ninecraft.booket.feature.main.MainActivity -import dagger.hilt.android.AndroidEntryPoint +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import javax.inject.Inject -@AndroidEntryPoint -class ReedFirebaseMessagingService : FirebaseMessagingService() { - - @Inject - lateinit var userRepository: UserRepository +@ContributesIntoMap(AppScope::class, binding = binding()) +@ServiceKey(ReedFirebaseMessagingService::class) +@Inject +class ReedFirebaseMessagingService( + private val userRepository: UserRepository, +) : FirebaseMessagingService() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) diff --git a/app/src/main/kotlin/com/ninecraft/booket/di/AppGraph.kt b/app/src/main/kotlin/com/ninecraft/booket/di/AppGraph.kt new file mode 100644 index 000000000..ead4d1a16 --- /dev/null +++ b/app/src/main/kotlin/com/ninecraft/booket/di/AppGraph.kt @@ -0,0 +1,31 @@ +package com.ninecraft.booket.di + +import android.app.Activity +import android.app.Service +import android.content.Context +import com.ninecraft.booket.core.di.ApplicationContext +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Multibinds +import dev.zacsweers.metro.Provider +import dev.zacsweers.metro.Provides +import kotlin.reflect.KClass + +@DependencyGraph( + scope = AppScope::class, + additionalScopes = [DataScope::class], +) +interface AppGraph { + + @Multibinds(allowEmpty = true) + val activityProviders: Map, Provider> + + @Multibinds(allowEmpty = true) + val serviceProviders: Map, Provider> + + @DependencyGraph.Factory + fun interface Factory { + fun create(@ApplicationContext @Provides context: Context): AppGraph + } +} diff --git a/app/src/main/kotlin/com/ninecraft/booket/di/CircuitGraph.kt b/app/src/main/kotlin/com/ninecraft/booket/di/CircuitGraph.kt new file mode 100644 index 000000000..5b48780d8 --- /dev/null +++ b/app/src/main/kotlin/com/ninecraft/booket/di/CircuitGraph.kt @@ -0,0 +1,52 @@ +package com.ninecraft.booket.di + +import androidx.compose.foundation.background +import androidx.compose.foundation.text.BasicText +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import com.slack.circuit.foundation.Circuit +import com.slack.circuit.foundation.LocalCircuit +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.ui.Ui +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Multibinds +import dev.zacsweers.metro.Provides + +@ContributesTo(AppScope::class) +interface CircuitGraph { + + @Multibinds(allowEmpty = true) + fun presenterFactories(): Set + + @Multibinds(allowEmpty = true) + fun uiFactories(): Set + + @Provides + fun provideCircuit( + presenterFactories: Set, + uiFactories: Set, + ): Circuit { + return Circuit.Builder() + .addPresenterFactories(presenterFactories) + .addUiFactories(uiFactories) + .setAnimatedNavDecoratorFactory(CrossFadeNavDecoratorFactory()) + .setOnUnavailableContent { screen, modifier -> + val circuit = LocalCircuit.current + BasicText( + text = """ + Route not available: ${screen.javaClass.name}. + Presenter: ${circuit?.presenter(screen, Navigator.NoOp)?.javaClass} + UI: ${circuit?.ui(screen)?.javaClass} + All presenterFactories: ${circuit?.newBuilder()?.presenterFactories} + All uiFactories: ${circuit?.newBuilder()?.uiFactories} + """ + .trimIndent(), + modifier = modifier.background(Color.Red), + style = TextStyle(color = Color.Yellow), + ) + } + .build() + } +} diff --git a/app/src/main/kotlin/com/ninecraft/booket/di/CircuitModule.kt b/app/src/main/kotlin/com/ninecraft/booket/di/CircuitModule.kt deleted file mode 100644 index 23df47237..000000000 --- a/app/src/main/kotlin/com/ninecraft/booket/di/CircuitModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.ninecraft.booket.di - -import com.slack.circuit.foundation.Circuit -import com.slack.circuit.runtime.presenter.Presenter -import com.slack.circuit.runtime.ui.Ui -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import dagger.hilt.android.scopes.ActivityRetainedScoped -import dagger.multibindings.Multibinds - -@Module -@InstallIn(ActivityRetainedComponent::class) -abstract class CircuitModule { - - @Multibinds - abstract fun presenterFactories(): Set - - @Multibinds - abstract fun uiFactories(): Set - - companion object { - @[Provides ActivityRetainedScoped] - fun provideCircuit( - presenterFactories: @JvmSuppressWildcards Set, - uiFactories: @JvmSuppressWildcards Set, - ): Circuit = Circuit.Builder() - .addPresenterFactories(presenterFactories) - .addUiFactories(uiFactories) - .setAnimatedNavDecoratorFactory(CrossFadeNavDecoratorFactory()) - .build() - } -} diff --git a/app/src/main/kotlin/com/ninecraft/booket/di/MetroAppComponentFactory.kt b/app/src/main/kotlin/com/ninecraft/booket/di/MetroAppComponentFactory.kt new file mode 100644 index 000000000..e7bc3fa9a --- /dev/null +++ b/app/src/main/kotlin/com/ninecraft/booket/di/MetroAppComponentFactory.kt @@ -0,0 +1,61 @@ +package com.ninecraft.booket.di + +import android.app.Activity +import android.app.Application +import android.app.Service +import android.content.Intent +import androidx.annotation.Keep +import androidx.core.app.AppComponentFactory +import com.ninecraft.booket.BooketApplication +import dev.zacsweers.metro.Provider +import kotlin.reflect.KClass + +/** + * AppComponentFactory that uses Metro for constructor injection of Activities and Services. + * + * Requires minSdk 28+. For lower versions, use manual graph access. + */ +@Keep +class MetroAppComponentFactory : AppComponentFactory() { + + private inline fun getInstance( + cl: ClassLoader, + className: String, + providers: Map, Provider>, + ): T? { + val clazz = Class.forName(className, false, cl).asSubclass(T::class.java) + val modelProvider = providers[clazz.kotlin] ?: return null + return modelProvider() + } + + override fun instantiateActivityCompat( + cl: ClassLoader, + className: String, + intent: Intent?, + ): Activity { + return getInstance(cl, className, activityProviders) + ?: super.instantiateActivityCompat(cl, className, intent) + } + + override fun instantiateServiceCompat( + cl: ClassLoader, + className: String, + intent: Intent?, + ): Service { + return getInstance(cl, className, serviceProviders) + ?: super.instantiateServiceCompat(cl, className, intent) + } + + override fun instantiateApplicationCompat(cl: ClassLoader, className: String): Application { + val app = super.instantiateApplicationCompat(cl, className) + val appGraph = (app as BooketApplication).appGraph + activityProviders = appGraph.activityProviders + serviceProviders = appGraph.serviceProviders + return app + } + + companion object { + private lateinit var activityProviders: Map, Provider> + private lateinit var serviceProviders: Map, Provider> + } +} diff --git a/app/stability/app.stability b/app/stability/app.stability index dc1f99ee2..93818f146 100644 --- a/app/stability/app.stability +++ b/app/stability/app.stability @@ -9,7 +9,7 @@ public fun com.ninecraft.booket.di.CrossFadeNavDecorator.Decoration(targetState: skippable: false restartable: true params: - - targetState: RUNTIME (requires runtime check) + - targetState: UNSTABLE (has mutable properties or unstable members) - innerContent: STABLE (composable function type) @Composable diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8322598d2..baa39c41a 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -1,5 +1,3 @@ -@Suppress("DSL_SCOPE_VIOLATION", "INLINE_FROM_HIGHER_PLATFORM") - plugins { `kotlin-dsl` alias(libs.plugins.gradle.dependency.handler.extensions) @@ -7,8 +5,8 @@ plugins { dependencies { compileOnly(libs.android.gradle.plugin) - compileOnly(libs.kotlin.gradle.plugin) compileOnly(libs.compose.compiler.gradle.plugin) + compileOnly(libs.ksp.gradle.plugin) implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) } @@ -20,7 +18,6 @@ gradlePlugin { "android.library.compose" to "AndroidLibraryComposeConventionPlugin", "android.feature" to "AndroidFeatureConventionPlugin", "android.firebase" to "AndroidFirebaseConventionPlugin", - "android.hilt" to "AndroidHiltConventionPlugin", "android.retrofit" to "AndroidRetrofitConventionPlugin", "jvm.library" to "JvmLibraryConventionPlugin", "kotlin.library.serialization" to "KotlinLibrarySerializationConventionPlugin", diff --git a/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt index d31a051b3..68c6124dc 100644 --- a/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,5 +1,4 @@ import com.android.build.api.dsl.ApplicationExtension -import com.ninecraft.booket.convention.ApplicationConstants import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.configureAndroid @@ -11,10 +10,7 @@ import org.gradle.kotlin.dsl.configure internal class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - applyPlugins( - Plugins.ANDROID_APPLICATION, - Plugins.KOTLIN_ANDROID, - ) + applyPlugins(Plugins.ANDROID_APPLICATION) extensions.configure { configureAndroid(this) diff --git a/build-logic/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidFeatureConventionPlugin.kt index a45d81add..84702c483 100644 --- a/build-logic/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -1,4 +1,4 @@ - +import com.google.devtools.ksp.gradle.KspExtension import com.ninecraft.booket.convention.api import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.implementation @@ -7,6 +7,7 @@ import com.ninecraft.booket.convention.libs import com.ninecraft.booket.convention.project import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies internal class AndroidFeatureConventionPlugin : Plugin { @@ -15,9 +16,14 @@ internal class AndroidFeatureConventionPlugin : Plugin { applyPlugins( "booket.android.library", "booket.android.library.compose", - "booket.android.hilt", + "dev.zacsweers.metro", + "com.google.devtools.ksp", ) + extensions.configure { + arg("circuit.codegen.mode", "metro") + } + dependencies { implementation(project(path = ":core:common")) implementation(project(path = ":core:data:api")) @@ -26,6 +32,7 @@ internal class AndroidFeatureConventionPlugin : Plugin { implementation(project(path = ":core:ui")) implementation(project(path = ":feature:screens")) + implementation(libs.kotlinx.collections.immutable) implementation(libs.compose.effects) implementation(libs.bundles.circuit) diff --git a/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt index dc9423dbb..1ca019d4c 100644 --- a/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt @@ -1,4 +1,3 @@ - import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.implementation diff --git a/build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt deleted file mode 100644 index 0875e71a6..000000000 --- a/build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ /dev/null @@ -1,24 +0,0 @@ -import com.ninecraft.booket.convention.Plugins -import com.ninecraft.booket.convention.applyPlugins -import com.ninecraft.booket.convention.implementation -import com.ninecraft.booket.convention.ksp -import com.ninecraft.booket.convention.libs -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.kotlin.dsl.dependencies - -internal class AndroidHiltConventionPlugin : Plugin { - override fun apply(target: Project) { - with(target) { - applyPlugins( - Plugins.HILT, - Plugins.KSP, - ) - - dependencies { - implementation(libs.hilt.android) - ksp(libs.hilt.android.compiler) - } - } - } -} diff --git a/build-logic/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index 2a912fcfb..9ea25a844 100644 --- a/build-logic/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -1,4 +1,4 @@ -import com.android.build.gradle.LibraryExtension +import com.android.build.api.dsl.LibraryExtension import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.configureCompose diff --git a/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt index b430d6b90..a0bfc3218 100644 --- a/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -1,8 +1,7 @@ -import com.android.build.gradle.LibraryExtension +import com.android.build.api.dsl.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 @@ -10,17 +9,10 @@ import org.gradle.kotlin.dsl.configure internal class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - applyPlugins( - Plugins.ANDROID_LIBRARY, - Plugins.KOTLIN_ANDROID, - ) + applyPlugins(Plugins.ANDROID_LIBRARY) extensions.configure { configureAndroid(this) - - defaultConfig.apply { - targetSdk = libs.versions.targetSdk.get().toInt() - } } } } diff --git a/build-logic/src/main/kotlin/AndroidRetrofitConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidRetrofitConventionPlugin.kt index 3de912884..500f4c69b 100644 --- a/build-logic/src/main/kotlin/AndroidRetrofitConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidRetrofitConventionPlugin.kt @@ -1,13 +1,8 @@ -import com.android.build.gradle.LibraryExtension -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.implementation import com.ninecraft.booket.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies internal class AndroidRetrofitConventionPlugin : Plugin { 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 b02d0b996..b1942498a 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 @@ -2,25 +2,26 @@ package com.ninecraft.booket.convention import com.android.build.api.dsl.CommonExtension import org.gradle.api.Project -import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.withType import org.gradle.kotlin.dsl.dependencies -import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, *>) { +internal fun Project.configureAndroid(extension: CommonExtension) { extension.apply { compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { + defaultConfig.apply { minSdk = libs.versions.minSdk.get().toInt() } - compileOptions { + compileOptions.apply { sourceCompatibility = ApplicationConstants.javaVersion targetCompatibility = ApplicationConstants.javaVersion } - extensions.configure { - jvmToolchain(ApplicationConstants.JAVA_VERSION_INT) + tasks.withType().configureEach { + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } dependencies { diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Compose.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Compose.kt index 2d5831bd0..57dd4f5a2 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Compose.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Compose.kt @@ -5,36 +5,34 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -internal fun Project.configureCompose( - extension: CommonExtension<*, *, *, *, *, *>, -) { +internal fun Project.configureCompose(extension: CommonExtension) { extension.apply { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.androidx.compose) debugImplementation(libs.androidx.compose.ui.tooling) } + } - configure { - includeSourceInformation.set(true) + extensions.configure { + includeSourceInformation.set(true) - metricsDestination.file("build/composeMetrics") - reportsDestination.file("build/composeReports") + metricsDestination.file("build/composeMetrics") + reportsDestination.file("build/composeReports") - stabilityConfigurationFiles.addAll( - project.layout.projectDirectory.file("stability.config.conf"), - ) - } + stabilityConfigurationFiles.addAll( + project.rootProject.layout.projectDirectory.file("stability.config.conf"), + ) + } - tasks.withType().configureEach { - compilerOptions { - freeCompilerArgs.addAll( - buildComposeMetricsParameters(), - ) - } + tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.addAll( + buildComposeMetricsParameters(), + ) } } } diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Extensions.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Extensions.kt index 110c1a157..3eeccfdb4 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Extensions.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Extensions.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.convention +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.gradle.accessors.dm.LibrariesForLibs import org.gradle.api.Project import org.gradle.kotlin.dsl.the @@ -10,3 +11,7 @@ internal val Project.libs internal fun Project.applyPlugins(vararg plugins: String) { plugins.forEach(pluginManager::apply) } + +fun Project.getLocalProperty(propertyKey: String): String { + return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) +} diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Plugins.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Plugins.kt index e8dc98123..3189adc17 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Plugins.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Plugins.kt @@ -4,7 +4,6 @@ object Plugins { const val JAVA_LIBRARY = "java-library" const val KOTLIN_JVM = "org.jetbrains.kotlin.jvm" - const val KOTLIN_ANDROID = "org.jetbrains.kotlin.android" const val KOTLINX_SERIALIZATION = "org.jetbrains.kotlin.plugin.serialization" const val KOTLIN_COMPOSE = "org.jetbrains.kotlin.plugin.compose" @@ -12,8 +11,7 @@ object Plugins { const val ANDROID_LIBRARY = "com.android.library" const val COMPOSE_STABILITY_ANALYZER = "com.github.skydoves.compose.stability.analyzer" - - const val HILT = "dagger.hilt.android.plugin" + const val METRO = "dev.zacsweers.metro" const val KSP = "com.google.devtools.ksp" const val GOOGLE_SERVICES = "com.google.gms.google-services" const val FIREBASE_CRASHLYTICS = "com.google.firebase.crashlytics" diff --git a/build.gradle.kts b/build.gradle.kts index 6d346c9a1..ea8a6f795 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,12 +7,11 @@ plugins { alias(libs.plugins.kotlin.ktlint) alias(libs.plugins.compose.stability.analyzer) apply false alias(libs.plugins.kotlin.parcelize) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.hilt) apply false + alias(libs.plugins.metro) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.google.service) apply false alias(libs.plugins.firebase.crashlytics) apply false diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index ba851ffff..a6af401ec 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,9 +1,7 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.library) alias(libs.plugins.booket.android.library.compose) - alias(libs.plugins.booket.android.hilt) + alias(libs.plugins.metro) alias(libs.plugins.booket.android.retrofit) } 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 index fde333f1a..37a3720dd 100644 --- 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 @@ -3,11 +3,13 @@ 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 +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn -@Singleton -class AnalyticsHelper @Inject constructor( +@SingleIn(AppScope::class) +@Inject +class AnalyticsHelper( private val firebaseAnalytics: FirebaseAnalytics, ) { diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsGraph.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsGraph.kt new file mode 100644 index 000000000..ccb55a4b1 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsGraph.kt @@ -0,0 +1,15 @@ +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +@ContributesTo(AppScope::class) +interface AnalyticsGraph { + + @Provides + fun provideFirebaseAnalytics(): FirebaseAnalytics = Firebase.analytics +} 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 deleted file mode 100644 index c08fc156b..000000000 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/analytics/di/AnalyticsModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -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/BookStatus.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/BookStatus.kt index 6745d507a..2a5fe6c4f 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/BookStatus.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/BookStatus.kt @@ -16,7 +16,7 @@ enum class BookStatus(val value: String) { } } - companion object Companion { + companion object { fun fromValue(value: String): BookStatus? { return entries.find { it.value == value } } 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 index 17b29ffa8..44413134e 100644 --- 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 @@ -3,7 +3,7 @@ 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 android.net.Uri import com.ninecraft.booket.core.common.BuildConfig import android.graphics.Bitmap import android.os.Build @@ -62,6 +62,6 @@ fun Context.saveImageToGallery(bitmap: ImageBitmap) { fun Context.openPlayStore() { val intent = - Intent(Intent.ACTION_VIEW, "market://details?id=${BuildConfig.PACKAGE_NAME}".toUri()) + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${BuildConfig.PACKAGE_NAME}")) startActivity(intent) } 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 4c5695cb7..3df531ae1 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 @@ -3,54 +3,109 @@ package com.ninecraft.booket.core.common.extensions import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction 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.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.SemanticsModifierNode import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role import com.ninecraft.booket.core.common.utils.MultipleEventsCutter import com.ninecraft.booket.core.common.utils.get // https://stackoverflow.com/questions/66703448/how-to-disable-ripple-effect-when-clicking-in-jetpack-compose -inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { - clickable( +fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = + this.clickable( + interactionSource = null, indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - onClick() - } -} + onClick = onClick, + ) fun Modifier.clickableSingle( enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, onClick: () -> Unit, -) = composed( - inspectorInfo = debugInspectorInfo { - name = "clickable" - properties["enabled"] = enabled - properties["onClickLabel"] = onClickLabel - properties["role"] = role - properties["onClick"] = onClick - }, -) { - val multipleEventsCutter = remember { MultipleEventsCutter.get() } - Modifier.clickable( - enabled = enabled, - onClickLabel = onClickLabel, - onClick = { multipleEventsCutter.processEvent { onClick() } }, - role = role, - indication = ripple(), - interactionSource = remember { MutableInteractionSource() }, +): Modifier = this then ClickableSingleElement(enabled, onClickLabel, role, onClick) + +private data class ClickableSingleElement( + private val enabled: Boolean, + private val onClickLabel: String?, + private val role: Role?, + private val onClick: () -> Unit, +) : ModifierNodeElement() { + override fun create() = ClickableSingleNode(enabled, onClickLabel, role, onClick) + override fun update(node: ClickableSingleNode) { + node.update(enabled, onClickLabel, role, onClick) + } +} + +private class ClickableSingleNode( + private var enabled: Boolean, + private var onClickLabel: String?, + private var role: Role?, + private var onClick: () -> Unit, +) : DelegatingNode(), SemanticsModifierNode { + private val multipleEventsCutter = MultipleEventsCutter.get() + private val interactionSource = MutableInteractionSource() + + @Suppress("unused") + private val indicationNode = delegate( + ripple().create(interactionSource), ) + + @Suppress("unused") + private val pointerInputNode = delegate( + SuspendingPointerInputModifierNode { + if (!enabled) return@SuspendingPointerInputModifierNode + detectTapGestures( + onPress = { offset -> + val press = PressInteraction.Press(offset) + interactionSource.emit(press) + val released = tryAwaitRelease() + if (released) { + interactionSource.emit(PressInteraction.Release(press)) + } else { + interactionSource.emit(PressInteraction.Cancel(press)) + } + }, + onTap = { + multipleEventsCutter.processEvent { onClick() } + }, + ) + }, + ) + + override fun SemanticsPropertyReceiver.applySemantics() { + onClick(label = onClickLabel) { + if (!enabled) return@onClick false + multipleEventsCutter.processEvent { this@ClickableSingleNode.onClick() } + true + } + this@ClickableSingleNode.role?.let { this.role = it } + if (!enabled) { + disabled() + } + } + + fun update(enabled: Boolean, onClickLabel: String?, role: Role?, onClick: () -> Unit) { + this.enabled = enabled + this.onClickLabel = onClickLabel + this.role = role + this.onClick = onClick + } } fun Modifier.captureToGraphicsLayer(graphicsLayer: GraphicsLayer) = diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/EmotionAnalyzer.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/EmotionAnalyzer.kt deleted file mode 100644 index 06be275ce..000000000 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/EmotionAnalyzer.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.ninecraft.booket.core.common.utils - -import com.ninecraft.booket.core.model.EmotionModel - -data class EmotionAnalysisResult( - val topEmotions: List, - val displayType: EmotionDisplayType, -) - -enum class EmotionDisplayType { - NONE, // 모든 감정의 count가 0 - SINGLE, // 1개 감정이 1위 - DUAL, // 2개 감정이 공동 1위 - BALANCED, // 3개 이상 감정이 공동 1위 -} - -fun analyzeEmotions(emotions: List): EmotionAnalysisResult { - val maxCount = emotions.maxOf { it.count } - - // 모든 감정의 count가 0인 경우 - if (maxCount == 0) { - return EmotionAnalysisResult(emptyList(), EmotionDisplayType.NONE) - } - - val topEmotions = emotions.filter { it.count == maxCount } - - val displayType = when (topEmotions.size) { - 1 -> EmotionDisplayType.SINGLE - 2 -> EmotionDisplayType.DUAL - else -> EmotionDisplayType.BALANCED - } - - return EmotionAnalysisResult(topEmotions, displayType) -} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/UiText.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/UiText.kt index bfe3bbf5f..21d245011 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/UiText.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/UiText.kt @@ -10,7 +10,7 @@ sealed class UiText { data class DirectString(val value: String) : UiText() class StringResource( - @StringRes val resId: Int, + @param: StringRes val resId: Int, vararg val args: Any, ) : UiText() diff --git a/core/data/api/build.gradle.kts b/core/data/api/build.gradle.kts index 59fe2e1f5..d719aaa10 100644 --- a/core/data/api/build.gradle.kts +++ b/core/data/api/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.library) } diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/AuthRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/AuthRepository.kt index c89436687..008f3b6ca 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/AuthRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/AuthRepository.kt @@ -1,11 +1,15 @@ package com.ninecraft.booket.core.data.api.repository -import com.ninecraft.booket.core.model.AutoLoginState -import com.ninecraft.booket.core.model.UserState +import com.ninecraft.booket.core.model.LoginMethod +import com.ninecraft.booket.core.model.state.AutoLoginState +import com.ninecraft.booket.core.model.state.UserState import kotlinx.coroutines.flow.Flow interface AuthRepository { - suspend fun login(accessToken: String): Result + suspend fun login( + providerType: String, + token: String, + ): Result suspend fun logout(): Result @@ -16,4 +20,10 @@ interface AuthRepository { val userState: Flow suspend fun getCurrentUserState(): UserState + + val recentLoginMethod: Flow + + suspend fun setRecentLoginMethod(loginMethod: LoginMethod) + + suspend fun clearRecentLoginMethod() } diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/EmotionRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/EmotionRepository.kt new file mode 100644 index 000000000..3bedf4ed9 --- /dev/null +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/EmotionRepository.kt @@ -0,0 +1,7 @@ +package com.ninecraft.booket.core.data.api.repository + +import com.ninecraft.booket.core.model.EmotionGroupsModel + +interface EmotionRepository { + suspend fun getEmotions(): 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 753bc76bc..370647665 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,18 +1,17 @@ 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 interface RecordRepository { suspend fun postRecord( userBookId: String, - pageNumber: Int, + pageNumber: Int?, quote: String, - emotionTags: List, review: String, - ): Result + primaryEmotion: String, + detailEmotionTagIds: List, + ): Result suspend fun getReadingRecords( userBookId: String, @@ -23,14 +22,15 @@ interface RecordRepository { suspend fun getRecordDetail( readingRecordId: String, - ): Result + ): Result suspend fun editRecord( readingRecordId: String, - pageNumber: Int, + pageNumber: Int?, quote: String, - emotionTags: List, review: String, + primaryEmotion: String, + detailEmotionTagIds: List, ): Result suspend fun deleteRecord( diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt index 20618cc14..9ea1524c7 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt @@ -1,6 +1,6 @@ package com.ninecraft.booket.core.data.api.repository -import com.ninecraft.booket.core.model.OnboardingState +import com.ninecraft.booket.core.model.state.OnboardingState import com.ninecraft.booket.core.model.TermsAgreementModel import com.ninecraft.booket.core.model.UserProfileModel import kotlinx.coroutines.flow.Flow diff --git a/core/data/impl/build.gradle.kts b/core/data/impl/build.gradle.kts index c24e3f2e5..b34221aee 100644 --- a/core/data/impl/build.gradle.kts +++ b/core/data/impl/build.gradle.kts @@ -1,8 +1,6 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.library) - alias(libs.plugins.booket.android.hilt) + alias(libs.plugins.metro) alias(libs.plugins.booket.kotlin.library.serialization) } @@ -23,6 +21,7 @@ dependencies { projects.core.common, projects.core.data.api, projects.core.datastore.api, + projects.core.di, projects.core.model, projects.core.network, 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/DataGraph.kt similarity index 50% rename from core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt rename to core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/DataGraph.kt index 013d61de0..9190ef5df 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/DataGraph.kt @@ -2,41 +2,38 @@ 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.EmotionRepository 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.DefaultEmotionRepository 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 -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo -@Module -@InstallIn(SingletonComponent::class) -internal abstract class RepositoryModule { +@ContributesTo(DataScope::class) +interface DataGraph { @Binds - @Singleton - abstract fun bindAuthRepository(defaultAuthRepository: DefaultAuthRepository): AuthRepository + val DefaultAuthRepository.bind: AuthRepository @Binds - @Singleton - abstract fun bindUserRepository(defaultUserRepository: DefaultUserRepository): UserRepository + val DefaultBookRepository.bind: BookRepository @Binds - @Singleton - abstract fun bindBookRepository(defaultBookRepository: DefaultBookRepository): BookRepository + val DefaultEmotionRepository.bind: EmotionRepository @Binds - @Singleton - abstract fun bindRecordRepository(defaultRecordRepository: DefaultRecordRepository): RecordRepository + val DefaultRecordRepository.bind: RecordRepository @Binds - @Singleton - abstract fun bindRemoteConfigRepository(defaultRemoteConfigRepository: DefaultRemoteConfigRepository): RemoteConfigRepository + val DefaultRemoteConfigRepository.bind: RemoteConfigRepository + + @Binds + val DefaultUserRepository.bind: UserRepository } 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/FirebaseGraph.kt similarity index 69% rename from core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt rename to core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseGraph.kt index 9c5f5f654..3b8f3f235 100644 --- 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/FirebaseGraph.kt @@ -9,18 +9,15 @@ 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 +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +@ContributesTo(DataScope::class) +interface FirebaseGraph { -@InstallIn(SingletonComponent::class) -@Module -internal object FirebaseModule { - @Singleton @Provides - fun provideRemoteConfig(): FirebaseRemoteConfig { + fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig { return Firebase.remoteConfig.apply { val configSettings by lazy { remoteConfigSettings { @@ -31,11 +28,9 @@ internal object FirebaseModule { } } - @Singleton @Provides fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging - @Singleton @Provides - fun provideFirebaseInstallation(): FirebaseInstallations = Firebase.installations + fun provideFirebaseInstallations(): FirebaseInstallations = Firebase.installations } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt index 66a1c8933..d3ffa85e2 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt @@ -1,23 +1,24 @@ package com.ninecraft.booket.core.data.impl.mapper import com.ninecraft.booket.core.common.extensions.decodeHtmlEntities -import com.ninecraft.booket.core.common.extensions.toFormattedDate import com.ninecraft.booket.core.model.BookDetailModel import com.ninecraft.booket.core.model.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel import com.ninecraft.booket.core.model.BookUpsertModel -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel +import com.ninecraft.booket.core.model.EmotionGroupsModel import com.ninecraft.booket.core.model.EmotionModel import com.ninecraft.booket.core.model.HomeModel import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.model.LibraryBooksModel import com.ninecraft.booket.core.model.LibraryModel import com.ninecraft.booket.core.model.PageInfoModel +import com.ninecraft.booket.core.model.PrimaryEmotionModel import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.model.ReadingRecordsModel import com.ninecraft.booket.core.model.RecentBookModel -import com.ninecraft.booket.core.model.RecordDetailModel -import com.ninecraft.booket.core.model.RecordRegisterModel import com.ninecraft.booket.core.model.SeedModel import com.ninecraft.booket.core.model.TermsAgreementModel import com.ninecraft.booket.core.model.UserProfileModel @@ -26,6 +27,9 @@ import com.ninecraft.booket.core.network.response.BookSearchResponse import com.ninecraft.booket.core.network.response.BookSummary import com.ninecraft.booket.core.network.response.BookUpsertResponse import com.ninecraft.booket.core.network.response.Category +import com.ninecraft.booket.core.network.response.DetailEmotion +import com.ninecraft.booket.core.network.response.EmotionGroup +import com.ninecraft.booket.core.network.response.EmotionGroupsResponse import com.ninecraft.booket.core.network.response.GuestBookSearchResponse import com.ninecraft.booket.core.network.response.GuestBookSummary import com.ninecraft.booket.core.network.response.HomeResponse @@ -33,11 +37,10 @@ import com.ninecraft.booket.core.network.response.LibraryBookSummary import com.ninecraft.booket.core.network.response.LibraryBooks import com.ninecraft.booket.core.network.response.LibraryResponse import com.ninecraft.booket.core.network.response.PageInfo +import com.ninecraft.booket.core.network.response.PrimaryEmotion import com.ninecraft.booket.core.network.response.ReadingRecord import com.ninecraft.booket.core.network.response.ReadingRecordsResponse import com.ninecraft.booket.core.network.response.RecentBook -import com.ninecraft.booket.core.network.response.RecordDetailResponse -import com.ninecraft.booket.core.network.response.RecordRegisterResponse import com.ninecraft.booket.core.network.response.SeedResponse import com.ninecraft.booket.core.network.response.TermsAgreementResponse import com.ninecraft.booket.core.network.response.UserProfileResponse @@ -187,21 +190,31 @@ internal fun PageInfo.toModel(): PageInfoModel { ) } -internal fun RecordRegisterResponse.toModel(): RecordRegisterModel { - return RecordRegisterModel( +internal fun EmotionGroupsResponse.toModel(): EmotionGroupsModel { + return EmotionGroupsModel( + emotions = emotions.map { it.toModel() }, + ) +} + +internal fun EmotionGroup.toModel(): EmotionGroupModel { + val code = EmotionCode.fromCode(code) ?: EmotionCode.OTHER + return EmotionGroupModel( + code = code, + displayName = displayName, + detailEmotions = detailEmotions.map { it.toModel() }, + ) +} + +internal fun DetailEmotion.toModel(): DetailEmotionModel { + return DetailEmotionModel( id = id, - userBookId = userBookId, - pageNumber = pageNumber, - quote = quote, - emotionTags = emotionTags, - review = review ?: "", - createdAt = createdAt, - updatedAt = updatedAt, + name = name, ) } internal fun ReadingRecordsResponse.toModel(): ReadingRecordsModel { return ReadingRecordsModel( + representativeEmotion = representativeEmotion?.toModel(), lastPage = lastPage, totalResults = totalResults, startIndex = startIndex, @@ -217,30 +230,22 @@ internal fun ReadingRecord.toModel(): ReadingRecordModel { pageNumber = pageNumber, quote = quote, review = review ?: "", - emotionTags = emotionTags, + primaryEmotion = primaryEmotion.toModel(), + detailEmotions = detailEmotions.map { it.toModel() }, createdAt = createdAt, updatedAt = updatedAt, - bookTitle = bookTitle, - bookPublisher = bookPublisher, - bookCoverImageUrl = bookCoverImageUrl, - author = author, + bookTitle = bookTitle ?: "", + bookPublisher = bookPublisher ?: "", + bookCoverImageUrl = bookCoverImageUrl ?: "", + author = author ?: "", ) } -internal fun RecordDetailResponse.toModel(): RecordDetailModel { - return RecordDetailModel( - id = id, - userBookId = userBookId, - pageNumber = pageNumber, - quote = quote, - review = review ?: "", - emotionTags = emotionTags, - createdAt = createdAt.toFormattedDate(), - updatedAt = updatedAt.toFormattedDate(), - bookTitle = bookTitle, - bookPublisher = bookPublisher, - bookCoverImageUrl = bookCoverImageUrl, - author = author, +internal fun PrimaryEmotion.toModel(): PrimaryEmotionModel { + val code = EmotionCode.fromCode(code) ?: EmotionCode.OTHER + return PrimaryEmotionModel( + code = code, + displayName = displayName, ) } @@ -270,9 +275,8 @@ internal fun SeedResponse.toModel(): SeedModel { } internal fun Category.toEmotionModel(): EmotionModel? { - val emotion = Emotion.fromDisplayName(name) ?: return null return EmotionModel( - name = emotion, + code = EmotionCode.fromDisplayName(name) ?: return null, count = count, ) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt index 9e5606508..5fdd101fe 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt @@ -2,25 +2,33 @@ package com.ninecraft.booket.core.data.impl.repository import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.AuthRepository +import com.ninecraft.booket.core.datastore.api.datasource.LoginMethodDataSource import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource -import com.ninecraft.booket.core.model.AutoLoginState -import com.ninecraft.booket.core.model.UserState +import com.ninecraft.booket.core.di.DataScope +import com.ninecraft.booket.core.model.LoginMethod +import com.ninecraft.booket.core.model.state.AutoLoginState +import com.ninecraft.booket.core.model.state.UserState import com.ninecraft.booket.core.network.request.LoginRequest import com.ninecraft.booket.core.network.service.ReedService +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.map -import javax.inject.Inject -private const val KAKAO_PROVIDER_TYPE = "KAKAO" - -internal class DefaultAuthRepository @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class DefaultAuthRepository( private val service: ReedService, private val tokenDataSource: TokenDataSource, + private val loginMethodDataSource: LoginMethodDataSource, ) : AuthRepository { - override suspend fun login(accessToken: String) = runSuspendCatching { + override suspend fun login( + providerType: String, + token: String, + ) = runSuspendCatching { val response = service.login( LoginRequest( - providerType = KAKAO_PROVIDER_TYPE, - oauthToken = accessToken, + providerType = providerType, + oauthToken = token, ), ) saveTokens(response.accessToken, response.refreshToken) @@ -34,6 +42,7 @@ internal class DefaultAuthRepository @Inject constructor( override suspend fun withdraw() = runSuspendCatching { service.withdraw() clearTokens() + clearRecentLoginMethod() } private suspend fun saveTokens(accessToken: String, refreshToken: String) { @@ -61,4 +70,14 @@ internal class DefaultAuthRepository @Inject constructor( val accessToken = tokenDataSource.getAccessToken() return if (accessToken.isBlank()) UserState.Guest else UserState.LoggedIn } + + override val recentLoginMethod = loginMethodDataSource.recentLoginMethod + + override suspend fun setRecentLoginMethod(loginMethod: LoginMethod) { + loginMethodDataSource.setRecentLoginMethod(loginMethod) + } + + override suspend fun clearRecentLoginMethod() { + loginMethodDataSource.clearRecentLoginMethod() + } } 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 2f4580c5f..33faf5e5a 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 @@ -7,9 +7,13 @@ import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSo import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource import com.ninecraft.booket.core.network.request.BookUpsertRequest import com.ninecraft.booket.core.network.service.ReedService -import javax.inject.Inject +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn -internal class DefaultBookRepository @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class DefaultBookRepository( private val service: ReedService, private val bookRecentSearchDataSource: BookRecentSearchDataSource, private val libraryRecentSearchDataSource: LibraryRecentSearchDataSource, diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultEmotionRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultEmotionRepository.kt new file mode 100644 index 000000000..95ec3b588 --- /dev/null +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultEmotionRepository.kt @@ -0,0 +1,20 @@ +package com.ninecraft.booket.core.data.impl.repository + +import com.ninecraft.booket.core.common.utils.runSuspendCatching +import com.ninecraft.booket.core.data.api.repository.EmotionRepository +import com.ninecraft.booket.core.data.impl.mapper.toModel +import com.ninecraft.booket.core.di.DataScope +import com.ninecraft.booket.core.model.EmotionGroupsModel +import com.ninecraft.booket.core.network.service.ReedService +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +@SingleIn(DataScope::class) +@Inject +class DefaultEmotionRepository( + private val service: ReedService, +) : EmotionRepository { + override suspend fun getEmotions(): Result = runSuspendCatching { + service.getEmotions().toModel() + } +} 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 410769d0f..776a4efe1 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,22 +3,26 @@ 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.di.DataScope import com.ninecraft.booket.core.network.request.RecordRegisterRequest import com.ninecraft.booket.core.network.service.ReedService -import javax.inject.Inject +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn -class DefaultRecordRepository @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class DefaultRecordRepository( private val service: ReedService, ) : RecordRepository { override suspend fun postRecord( userBookId: String, - pageNumber: Int, + pageNumber: Int?, quote: String, - emotionTags: List, review: String, + primaryEmotion: String, + detailEmotionTagIds: List, ) = runSuspendCatching { - service.postRecord(userBookId, RecordRegisterRequest(pageNumber, quote, emotionTags, review)).toModel() + service.postRecord(userBookId, RecordRegisterRequest(pageNumber, quote, review, primaryEmotion, detailEmotionTagIds)).toModel() } override suspend fun getReadingRecords( @@ -36,12 +40,13 @@ class DefaultRecordRepository @Inject constructor( override suspend fun editRecord( readingRecordId: String, - pageNumber: Int, + pageNumber: Int?, quote: String, - emotionTags: List, review: String, - ): Result = runSuspendCatching { - service.editRecord(readingRecordId, RecordRegisterRequest(pageNumber, quote, emotionTags, review)).toModel() + primaryEmotion: String, + detailEmotionTagIds: List, + ) = runSuspendCatching { + service.editRecord(readingRecordId, RecordRegisterRequest(pageNumber, quote, review, primaryEmotion, detailEmotionTagIds)).toModel() } override suspend fun deleteRecord(readingRecordId: String): Result = runSuspendCatching { 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 index 7f47a519e..ce73e13b3 100644 --- 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 @@ -7,10 +7,14 @@ import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.core.data.impl.BuildConfig import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.tasks.await -import javax.inject.Inject -class DefaultRemoteConfigRepository @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class DefaultRemoteConfigRepository( private val remoteConfig: FirebaseRemoteConfig, ) : RemoteConfigRepository { override suspend fun getLatestVersion(): Result = runSuspendCatching { diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt index 22954832d..bd28b9763 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt @@ -2,6 +2,7 @@ package com.ninecraft.booket.core.data.impl.repository import com.google.firebase.installations.FirebaseInstallations import com.google.firebase.messaging.FirebaseMessaging +import com.ninecraft.booket.core.di.DataScope import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.data.impl.mapper.toModel @@ -12,12 +13,15 @@ import com.ninecraft.booket.core.network.request.NotificationSettingsRequest import com.ninecraft.booket.core.network.request.TermsAgreementRequest import com.ninecraft.booket.core.network.service.ReedService import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await -import javax.inject.Inject -internal class DefaultUserRepository @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class DefaultUserRepository( private val service: ReedService, private val onboardingDataSource: OnboardingDataSource, private val notificationDataSource: NotificationDataSource, diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/LoginMethodDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/LoginMethodDataSource.kt new file mode 100644 index 000000000..b21df56a7 --- /dev/null +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/LoginMethodDataSource.kt @@ -0,0 +1,10 @@ +package com.ninecraft.booket.core.datastore.api.datasource + +import com.ninecraft.booket.core.model.LoginMethod +import kotlinx.coroutines.flow.Flow + +interface LoginMethodDataSource { + val recentLoginMethod: Flow + suspend fun setRecentLoginMethod(loginMethod: LoginMethod) + suspend fun clearRecentLoginMethod() +} diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/OnboardingDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/OnboardingDataSource.kt index e43f92c7e..dfff6f754 100644 --- a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/OnboardingDataSource.kt +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/OnboardingDataSource.kt @@ -1,6 +1,6 @@ package com.ninecraft.booket.core.datastore.api.datasource -import com.ninecraft.booket.core.model.OnboardingState +import com.ninecraft.booket.core.model.state.OnboardingState import kotlinx.coroutines.flow.Flow interface OnboardingDataSource { diff --git a/core/datastore/impl/build.gradle.kts b/core/datastore/impl/build.gradle.kts index ba16afc8a..9523e9ea0 100644 --- a/core/datastore/impl/build.gradle.kts +++ b/core/datastore/impl/build.gradle.kts @@ -1,8 +1,6 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.library) - alias(libs.plugins.booket.android.hilt) + alias(libs.plugins.metro) alias(libs.plugins.booket.kotlin.library.serialization) } @@ -16,14 +14,19 @@ android { dependencies { implementations( + projects.core.common, projects.core.datastore.api, + projects.core.di, projects.core.model, - libs.androidx.datastore.preferences, - libs.logger, ) + // API because DataStore is exposed in public API (DataStoreGraph) + // Metro compiler needs to resolve Preferences type across modules + // See: https://github.com/ZacSweers/metro/discussions/1358#discussioncomment-15020091 + api(libs.androidx.datastore.preferences) + androidTestImplementations( libs.androidx.test.ext.junit, libs.androidx.test.runner, 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 457ea401e..a81a73a6f 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 @@ -8,14 +8,18 @@ import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSo import com.ninecraft.booket.core.datastore.impl.di.BookRecentSearchDataStore import com.ninecraft.booket.core.datastore.impl.util.handleIOException import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json -import javax.inject.Inject -class DefaultBookRecentSearchDataSource @Inject constructor( - @BookRecentSearchDataStore private val dataStore: DataStore, +@SingleIn(DataScope::class) +@Inject +class DefaultBookRecentSearchDataSource( + @param: BookRecentSearchDataStore private val dataStore: DataStore, ) : BookRecentSearchDataSource { override val recentSearches: Flow> = dataStore.data .handleIOException() 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 97cd15d77..0097e9125 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 @@ -8,14 +8,18 @@ import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDat import com.ninecraft.booket.core.datastore.impl.di.LibraryRecentSearchDataStore import com.ninecraft.booket.core.datastore.impl.util.handleIOException import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json -import javax.inject.Inject -class DefaultLibraryRecentSearchDataSource @Inject constructor( - @LibraryRecentSearchDataStore private val dataStore: DataStore, +@SingleIn(DataScope::class) +@Inject +class DefaultLibraryRecentSearchDataSource( + @param: LibraryRecentSearchDataStore private val dataStore: DataStore, ) : LibraryRecentSearchDataSource { override val recentSearches: Flow> = dataStore.data .handleIOException() diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLoginMethodDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLoginMethodDataSource.kt new file mode 100644 index 000000000..f811cbe88 --- /dev/null +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLoginMethodDataSource.kt @@ -0,0 +1,48 @@ +package com.ninecraft.booket.core.datastore.impl.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.ninecraft.booket.core.datastore.api.datasource.LoginMethodDataSource +import com.ninecraft.booket.core.datastore.impl.di.LoginMethodDataStore +import com.ninecraft.booket.core.datastore.impl.util.handleIOException +import com.ninecraft.booket.core.di.DataScope +import com.ninecraft.booket.core.model.LoginMethod +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@SingleIn(DataScope::class) +@Inject +class DefaultLoginMethodDataSource( + @param:LoginMethodDataStore private val dataStore: DataStore, +) : LoginMethodDataSource { + override val recentLoginMethod: Flow = dataStore.data + .handleIOException() + .map { prefs -> + val method = prefs[RECENT_LOGIN_METHOD] + when (method) { + "KAKAO" -> LoginMethod.KAKAO + "GOOGLE" -> LoginMethod.GOOGLE + else -> LoginMethod.NONE + } + } + + override suspend fun setRecentLoginMethod(loginMethod: LoginMethod) { + dataStore.edit { prefs -> + prefs[RECENT_LOGIN_METHOD] = loginMethod.name + } + } + + override suspend fun clearRecentLoginMethod() { + dataStore.edit { prefs -> + prefs.remove(RECENT_LOGIN_METHOD) + } + } + + companion object { + private val RECENT_LOGIN_METHOD = stringPreferencesKey("RECENT_LOGIN_METHOD") + } +} diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt index 22c6d3b4b..4694d9cc9 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt @@ -7,12 +7,16 @@ import androidx.datastore.preferences.core.edit import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource import com.ninecraft.booket.core.datastore.impl.di.NotificationDataStore import com.ninecraft.booket.core.datastore.impl.util.handleIOException +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject -class DefaultNotificationDataSource @Inject constructor( - @NotificationDataStore private val dataStore: DataStore, +@SingleIn(DataScope::class) +@Inject +class DefaultNotificationDataSource( + @param: NotificationDataStore private val dataStore: DataStore, ) : NotificationDataSource { override val isUserNotificationEnabled: Flow = dataStore.data .handleIOException() @@ -44,7 +48,7 @@ class DefaultNotificationDataSource @Inject constructor( } } - companion object Companion { + companion object { private val USER_NOTIFICATION_ENABLED = booleanPreferencesKey("USER_NOTIFICATION_ENABLED") private val LAST_SYNCED_NOTIFICATION_ENABLED = booleanPreferencesKey("LAST_SYNCED_NOTIFICATION_ENABLED") } diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultOnboardingDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultOnboardingDataSource.kt index b314d5f81..73ff6bd53 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultOnboardingDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultOnboardingDataSource.kt @@ -5,15 +5,19 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource -import com.ninecraft.booket.core.model.OnboardingState import com.ninecraft.booket.core.datastore.impl.di.OnboardingDataStore import com.ninecraft.booket.core.datastore.impl.util.handleIOException +import com.ninecraft.booket.core.di.DataScope +import com.ninecraft.booket.core.model.state.OnboardingState +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject -class DefaultOnboardingDataSource @Inject constructor( - @OnboardingDataStore private val dataStore: DataStore, +@SingleIn(DataScope::class) +@Inject +class DefaultOnboardingDataSource( + @param: OnboardingDataStore private val dataStore: DataStore, ) : OnboardingDataSource { override val onboardingState: Flow = dataStore.data .handleIOException() diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultTokenDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultTokenDataSource.kt index b5e89b104..c7d909aca 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultTokenDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultTokenDataSource.kt @@ -8,15 +8,19 @@ import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource import com.ninecraft.booket.core.datastore.impl.di.TokenDataStore import com.ninecraft.booket.core.datastore.impl.security.CryptoManager import com.ninecraft.booket.core.datastore.impl.util.handleIOException +import com.ninecraft.booket.core.di.DataScope import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import java.security.GeneralSecurityException -import javax.inject.Inject -class DefaultTokenDataSource @Inject constructor( - @TokenDataStore private val dataStore: DataStore, +@SingleIn(DataScope::class) +@Inject +class DefaultTokenDataSource( + @param:TokenDataStore private val dataStore: DataStore, private val cryptoManager: CryptoManager, ) : TokenDataSource { override val accessToken: Flow = decryptStringFlow(ACCESS_TOKEN) @@ -60,7 +64,7 @@ class DefaultTokenDataSource @Inject constructor( }.orEmpty() } - companion object Companion { + companion object { private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN") private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN") } diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreGraph.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreGraph.kt new file mode 100644 index 000000000..b390b5ab3 --- /dev/null +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreGraph.kt @@ -0,0 +1,95 @@ +package com.ninecraft.booket.core.datastore.impl.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource +import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource +import com.ninecraft.booket.core.datastore.api.datasource.LoginMethodDataSource +import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource +import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource +import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultBookRecentSearchDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultLibraryRecentSearchDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultLoginMethodDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultNotificationDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultOnboardingDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultTokenDataSource +import com.ninecraft.booket.core.di.ApplicationContext +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +private const val TOKEN_DATASTORE_NAME = "TOKENS_DATASTORE" +private const val BOOK_RECENT_SEARCH_DATASTORE_NAME = "BOOK_RECENT_SEARCH_DATASTORE" +private const val LIBRARY_RECENT_SEARCH_DATASTORE_NAME = "LIBRARY_RECENT_SEARCH_DATASTORE" +private const val ONBOARDING_DATASTORE_NAME = "ONBOARDING_DATASTORE" +private const val NOTIFICATION_DATASTORE_NAME = "NOTIFICATION_DATASTORE" +private const val LOGIN_METHOD_DATASTORE_NAME = "LOGIN_METHOD_DATASTORE" + +private val Context.tokenDataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME) +private val Context.bookRecentSearchDataStore by preferencesDataStore(name = BOOK_RECENT_SEARCH_DATASTORE_NAME) +private val Context.libraryRecentSearchDataStore by preferencesDataStore(name = LIBRARY_RECENT_SEARCH_DATASTORE_NAME) +private val Context.onboardingDataStore by preferencesDataStore(name = ONBOARDING_DATASTORE_NAME) +private val Context.notificationDataStore by preferencesDataStore(name = NOTIFICATION_DATASTORE_NAME) +private val Context.loginMethodDataStore by preferencesDataStore(name = LOGIN_METHOD_DATASTORE_NAME) + +@ContributesTo(DataScope::class) +interface DataStoreGraph { + + @TokenDataStore + @Provides + fun provideTokenDataStore( + @ApplicationContext context: Context, + ): DataStore = context.tokenDataStore + + @BookRecentSearchDataStore + @Provides + fun provideBookRecentSearchDataStore( + @ApplicationContext context: Context, + ): DataStore = context.bookRecentSearchDataStore + + @LibraryRecentSearchDataStore + @Provides + fun provideLibraryRecentSearchDataStore( + @ApplicationContext context: Context, + ): DataStore = context.libraryRecentSearchDataStore + + @OnboardingDataStore + @Provides + fun provideOnboardingDataStore( + @ApplicationContext context: Context, + ): DataStore = context.onboardingDataStore + + @NotificationDataStore + @Provides + fun provideNotificationDataStore( + @ApplicationContext context: Context, + ): DataStore = context.notificationDataStore + + @LoginMethodDataStore + @Provides + fun provideLoginMethodDataStore( + @ApplicationContext context: Context, + ): DataStore = context.loginMethodDataStore + + @Binds + val DefaultTokenDataSource.bind: TokenDataSource + + @Binds + val DefaultBookRecentSearchDataSource.bind: BookRecentSearchDataSource + + @Binds + val DefaultLibraryRecentSearchDataSource.bind: LibraryRecentSearchDataSource + + @Binds + val DefaultOnboardingDataSource.bind: OnboardingDataSource + + @Binds + val DefaultNotificationDataSource.bind: NotificationDataSource + + @Binds + val DefaultLoginMethodDataSource.bind: LoginMethodDataSource +} diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt deleted file mode 100644 index 88a2c3cc8..000000000 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.ninecraft.booket.core.datastore.impl.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore -import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource -import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource -import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource -import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource -import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource -import com.ninecraft.booket.core.datastore.impl.datasource.DefaultLibraryRecentSearchDataSource -import com.ninecraft.booket.core.datastore.impl.datasource.DefaultOnboardingDataSource -import com.ninecraft.booket.core.datastore.impl.datasource.DefaultBookRecentSearchDataSource -import com.ninecraft.booket.core.datastore.impl.datasource.DefaultNotificationDataSource -import com.ninecraft.booket.core.datastore.impl.datasource.DefaultTokenDataSource -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object DataStoreModule { - private const val TOKEN_DATASTORE_NAME = "TOKENS_DATASTORE" - private const val BOOK_RECENT_SEARCH_DATASTORE_NAME = "BOOK_RECENT_SEARCH_DATASTORE" - private const val LIBRARY_RECENT_SEARCH_DATASTORE_NAME = "LIBRARY_RECENT_SEARCH_DATASTORE" - private const val ONBOARDING_DATASTORE_NAME = "ONBOARDING_DATASTORE" - private const val NOTIFICATION_DATASTORE_NAME = "NOTIFICATION_DATASTORE" - - private val Context.tokenDataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME) - private val Context.bookRecentSearchDataStore by preferencesDataStore(name = BOOK_RECENT_SEARCH_DATASTORE_NAME) - private val Context.libraryRecentSearchDataStore by preferencesDataStore(name = LIBRARY_RECENT_SEARCH_DATASTORE_NAME) - private val Context.onboardingDataStore by preferencesDataStore(name = ONBOARDING_DATASTORE_NAME) - private val Context.notificationDataStore by preferencesDataStore(name = NOTIFICATION_DATASTORE_NAME) - - @TokenDataStore - @Provides - @Singleton - fun provideTokenDataStore( - @ApplicationContext context: Context, - ): DataStore = context.tokenDataStore - - @BookRecentSearchDataStore - @Provides - @Singleton - fun provideBookRecentSearchDataStore( - @ApplicationContext context: Context, - ): DataStore = context.bookRecentSearchDataStore - - @LibraryRecentSearchDataStore - @Provides - @Singleton - fun provideLibraryRecentSearchDataStore( - @ApplicationContext context: Context, - ): DataStore = context.libraryRecentSearchDataStore - - @OnboardingDataStore - @Provides - @Singleton - fun provideOnboardingDataStore( - @ApplicationContext context: Context, - ): DataStore = context.onboardingDataStore - - @NotificationDataStore - @Provides - @Singleton - fun provideNotificationDataStore( - @ApplicationContext context: Context, - ): DataStore = context.notificationDataStore -} - -@Module -@InstallIn(SingletonComponent::class) -abstract class DataStoreBindModule { - - @Binds - @Singleton - abstract fun bindTokenDataSource( - defaultTokenDataSource: DefaultTokenDataSource, - ): TokenDataSource - - @Binds - @Singleton - abstract fun bindBookRecentSearchDataSource( - defaultBookRecentSearchDataSource: DefaultBookRecentSearchDataSource, - ): BookRecentSearchDataSource - - @Binds - @Singleton - abstract fun bindLibraryRecentSearchDataSource( - defaultLibraryRecentSearchDataSource: DefaultLibraryRecentSearchDataSource, - ): LibraryRecentSearchDataSource - - @Binds - @Singleton - abstract fun bindOnboardingDataSource( - defaultOnboardingDataSource: DefaultOnboardingDataSource, - ): OnboardingDataSource - - @Binds - @Singleton - abstract fun bindNotificationDataSource( - defaultNotificationDataSource: DefaultNotificationDataSource, - ): NotificationDataSource -} diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt index 8a65ab8c0..4f404203e 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt @@ -1,6 +1,6 @@ package com.ninecraft.booket.core.datastore.impl.di -import javax.inject.Qualifier +import dev.zacsweers.metro.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) @@ -21,3 +21,7 @@ annotation class OnboardingDataStore @Qualifier @Retention(AnnotationRetention.BINARY) annotation class NotificationDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LoginMethodDataStore diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/security/CryptoManager.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/security/CryptoManager.kt index ca5c64b72..cc9acbbea 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/security/CryptoManager.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/security/CryptoManager.kt @@ -3,16 +3,18 @@ package com.ninecraft.booket.core.datastore.impl.security import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.IvParameterSpec -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class CryptoManager @Inject constructor() { +@SingleIn(DataScope::class) +@Inject +class CryptoManager { private val keyStore = KeyStore .getInstance("AndroidKeyStore") .apply { diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index ba6696b46..239ca8eec 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.library) alias(libs.plugins.booket.android.library.compose) diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/ComponentPreview.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/ComponentPreview.kt index 065804537..488855406 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/ComponentPreview.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/ComponentPreview.kt @@ -1,7 +1,6 @@ package com.ninecraft.booket.core.designsystem import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.ui.tooling.preview.Preview @Preview( @@ -9,9 +8,4 @@ import androidx.compose.ui.tooling.preview.Preview showBackground = true, uiMode = UI_MODE_NIGHT_NO, ) -@Preview( - name = "Dark", - showBackground = true, - uiMode = UI_MODE_NIGHT_YES, -) annotation class ComponentPreview diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/DevicePreview.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/DevicePreview.kt index 8dbd713d1..ad9b39ac4 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/DevicePreview.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/DevicePreview.kt @@ -1,7 +1,6 @@ package com.ninecraft.booket.core.designsystem import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.ui.tooling.preview.Preview @Preview( @@ -10,10 +9,4 @@ import androidx.compose.ui.tooling.preview.Preview uiMode = UI_MODE_NIGHT_NO, device = "spec:width=360dp,height=800dp,dpi=411", ) -@Preview( - name = "Dark", - showBackground = true, - uiMode = UI_MODE_NIGHT_YES, - device = "spec:width=360dp,height=800dp,dpi=411", -) annotation class DevicePreview diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/Emotion.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/Emotion.kt deleted file mode 100644 index 5a4f72a32..000000000 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/Emotion.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.ninecraft.booket.core.designsystem - -import androidx.compose.ui.graphics.Color -import com.ninecraft.booket.core.designsystem.theme.InsightBgColor -import com.ninecraft.booket.core.designsystem.theme.InsightTextColor -import com.ninecraft.booket.core.designsystem.theme.JoyBgColor -import com.ninecraft.booket.core.designsystem.theme.JoyTextColor -import com.ninecraft.booket.core.designsystem.theme.SadnessBgColor -import com.ninecraft.booket.core.designsystem.theme.SadnessTextColor -import com.ninecraft.booket.core.designsystem.theme.WarmthBgColor -import com.ninecraft.booket.core.designsystem.theme.WarmthTextColor -import com.ninecraft.booket.core.model.Emotion - -val Emotion.bgColor: Color - get() = when (this) { - Emotion.WARM -> WarmthBgColor - Emotion.JOY -> JoyBgColor - Emotion.SAD -> SadnessBgColor - Emotion.INSIGHT -> InsightBgColor - } - -val Emotion.textColor: Color - get() = when (this) { - Emotion.WARM -> WarmthTextColor - Emotion.JOY -> JoyTextColor - Emotion.SAD -> SadnessTextColor - Emotion.INSIGHT -> InsightTextColor - } - -val Emotion.graphicRes: Int - get() = when (this) { - Emotion.WARM -> R.drawable.img_emotion_warmth - Emotion.JOY -> R.drawable.img_emotion_joy - Emotion.SAD -> R.drawable.img_emotion_sadness - Emotion.INSIGHT -> R.drawable.img_emotion_insight - } diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/EmotionCode.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/EmotionCode.kt new file mode 100644 index 000000000..ae0c5375c --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/EmotionCode.kt @@ -0,0 +1,87 @@ +package com.ninecraft.booket.core.designsystem + +import androidx.compose.ui.graphics.Color +import com.ninecraft.booket.core.designsystem.theme.Blue300 +import com.ninecraft.booket.core.designsystem.theme.Blue500 +import com.ninecraft.booket.core.designsystem.theme.InsightBgColor +import com.ninecraft.booket.core.designsystem.theme.InsightTextColor +import com.ninecraft.booket.core.designsystem.theme.JoyBgColor +import com.ninecraft.booket.core.designsystem.theme.JoyTextColor +import com.ninecraft.booket.core.designsystem.theme.Neutral300 +import com.ninecraft.booket.core.designsystem.theme.Neutral500 +import com.ninecraft.booket.core.designsystem.theme.Orange300 +import com.ninecraft.booket.core.designsystem.theme.Orange400 +import com.ninecraft.booket.core.designsystem.theme.OtherBgColor +import com.ninecraft.booket.core.designsystem.theme.OtherTextColor +import com.ninecraft.booket.core.designsystem.theme.SadnessBgColor +import com.ninecraft.booket.core.designsystem.theme.SadnessTextColor +import com.ninecraft.booket.core.designsystem.theme.Violet300 +import com.ninecraft.booket.core.designsystem.theme.Violet500 +import com.ninecraft.booket.core.designsystem.theme.WarmthBgColor +import com.ninecraft.booket.core.designsystem.theme.WarmthTextColor +import com.ninecraft.booket.core.designsystem.theme.Yellow300 +import com.ninecraft.booket.core.designsystem.theme.Yellow700 +import com.ninecraft.booket.core.model.EmotionCode + +val EmotionCode.bgColor: Color + get() = when (this) { + EmotionCode.WARMTH -> WarmthBgColor + EmotionCode.JOY -> JoyBgColor + EmotionCode.SADNESS -> SadnessBgColor + EmotionCode.INSIGHT -> InsightBgColor + EmotionCode.OTHER -> OtherBgColor + } + +val EmotionCode.textColor: Color + get() = when (this) { + EmotionCode.WARMTH -> WarmthTextColor + EmotionCode.JOY -> JoyTextColor + EmotionCode.SADNESS -> SadnessTextColor + EmotionCode.INSIGHT -> InsightTextColor + EmotionCode.OTHER -> OtherTextColor + } + +val EmotionCode.primaryEmotionColor: Color + get() = when (this) { + EmotionCode.WARMTH -> Yellow700 + EmotionCode.JOY -> Orange400 + EmotionCode.SADNESS -> Blue500 + EmotionCode.INSIGHT -> Violet500 + EmotionCode.OTHER -> Neutral500 + } + +val EmotionCode.ratioBarColor: Color + get() = when (this) { + EmotionCode.WARMTH -> Yellow300 + EmotionCode.JOY -> Orange300 + EmotionCode.SADNESS -> Blue300 + EmotionCode.INSIGHT -> Violet300 + EmotionCode.OTHER -> Neutral300 + } + +val EmotionCode.graphicRes: Int + get() = when (this) { + EmotionCode.WARMTH -> R.drawable.img_warmth + EmotionCode.JOY -> R.drawable.img_joy + EmotionCode.SADNESS -> R.drawable.img_sadness + EmotionCode.INSIGHT -> R.drawable.img_insight + EmotionCode.OTHER -> R.drawable.img_other + } + +val EmotionCode.categoryGraphicRes: Int? + get() = when (this) { + EmotionCode.WARMTH -> R.drawable.img_category_warmth + EmotionCode.JOY -> R.drawable.img_category_joy + EmotionCode.SADNESS -> R.drawable.img_category_sadness + EmotionCode.INSIGHT -> R.drawable.img_category_insight + EmotionCode.OTHER -> null + } + +val EmotionCode.descriptionRes: Int + get() = when (this) { + EmotionCode.WARMTH -> R.string.emotion_warm_description + EmotionCode.JOY -> R.string.emotion_joy_description + EmotionCode.SADNESS -> R.string.emotion_sad_description + EmotionCode.INSIGHT -> R.string.emotion_insight_description + EmotionCode.OTHER -> R.string.emotion_other_description + } diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/RecordStep.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/RecordStep.kt index 1e5eb2eb5..8c79ae8fd 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/RecordStep.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/RecordStep.kt @@ -3,7 +3,6 @@ package com.ninecraft.booket.core.designsystem enum class RecordStep { QUOTE, EMOTION, - IMPRESSION, ; val value: Int get() = ordinal diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/RecordProgressBar.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/RecordProgressBar.kt index 09ca0ae3a..53302b7c5 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/RecordProgressBar.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/RecordProgressBar.kt @@ -23,19 +23,15 @@ fun RecordProgressBar( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing1), ) { - repeat(3) { index -> + repeat(2) { index -> + val bgColor = if (index <= currentStep.ordinal) ReedTheme.colors.bgPrimary else ReedTheme.colors.bgDisabled + Box( modifier = Modifier .weight(1f) .height(6.dp) .clip(RoundedCornerShape(ReedTheme.radius.full)) - .background( - color = if (index <= currentStep.ordinal) { - ReedTheme.colors.bgPrimary - } else { - ReedTheme.colors.bgDisabled - }, - ), + .background(bgColor), ) } } diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonColorStyle.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonColorStyle.kt index b4ddfb90e..d3550498f 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonColorStyle.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ButtonColorStyle.kt @@ -8,16 +8,17 @@ import com.ninecraft.booket.core.designsystem.theme.Kakao import com.ninecraft.booket.core.designsystem.theme.ReedTheme enum class ReedButtonColorStyle { - PRIMARY, SECONDARY, TERTIARY, STROKE, TEXT, KAKAO; + PRIMARY, SECONDARY, TERTIARY, STROKE, TEXT, KAKAO, GOOGLE; @Composable fun containerColor(isPressed: Boolean) = when (this) { PRIMARY -> if (isPressed) ReedTheme.colors.bgPrimaryPressed else ReedTheme.colors.bgPrimary SECONDARY -> if (isPressed) ReedTheme.colors.bgSecondaryPressed else ReedTheme.colors.bgSecondary TERTIARY -> if (isPressed) ReedTheme.colors.bgTertiaryPressed else ReedTheme.colors.bgTertiary - STROKE -> if (isPressed) ReedTheme.colors.basePrimary else ReedTheme.colors.basePrimary + STROKE -> ReedTheme.colors.basePrimary TEXT -> Color.Transparent KAKAO -> Kakao + GOOGLE -> ReedTheme.colors.basePrimary } @Composable @@ -26,8 +27,9 @@ enum class ReedButtonColorStyle { SECONDARY -> ReedTheme.colors.contentPrimary TERTIARY -> ReedTheme.colors.contentBrand STROKE -> ReedTheme.colors.contentBrand - TEXT -> ReedTheme.colors.borderBrand + TEXT -> ReedTheme.colors.contentTertiary KAKAO -> ReedTheme.colors.contentPrimary + GOOGLE -> ReedTheme.colors.contentPrimary } @Composable @@ -42,6 +44,7 @@ enum class ReedButtonColorStyle { @Composable fun borderStroke() = when (this) { STROKE -> BorderStroke(1.dp, ReedTheme.colors.borderBrand) + GOOGLE -> BorderStroke(1.dp, ReedTheme.colors.borderPrimary) else -> null } } diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt index 23384f6d2..e15027ccf 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedButton.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.utils.MultipleEventsCutter import com.ninecraft.booket.core.common.utils.get @@ -93,6 +94,8 @@ fun ReedButton( Text( text = text, + overflow = TextOverflow.Ellipsis, + maxLines = 1, style = sizeStyle.textStyle.copy( color = if (enabled) colorStyle.contentColor() else colorStyle.disabledContentColor(), ), diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/CircleCheckBox.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/CircleCheckBox.kt index 38b6b8dec..0d567923d 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/CircleCheckBox.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/CircleCheckBox.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.vector.ImageVector import androidx.compose.ui.res.vectorResource @@ -36,10 +37,8 @@ fun CircleCheckBox( Box( modifier = modifier .size(24.dp) - .background( - color = bgColor, - shape = CircleShape, - ) + .clip(CircleShape) + .background(bgColor) .border(1.dp, borderColor, CircleShape) .noRippleClickable { onCheckedChange(!checked) } .padding(2.dp), diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/SquareCheckBox.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/SquareCheckBox.kt index e6f21c81a..b55bd24c5 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/SquareCheckBox.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/checkbox/SquareCheckBox.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.vector.ImageVector import androidx.compose.ui.res.vectorResource @@ -36,10 +37,8 @@ fun SquareCheckBox( Box( modifier = modifier .size(24.dp) - .background( - color = bgColor, - shape = RoundedCornerShape(ReedTheme.radius.xs), - ) + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(bgColor) .border(1.dp, borderColor, RoundedCornerShape(ReedTheme.radius.xs)) .noRippleClickable { onCheckedChange(!checked) } .padding(2.dp), diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ChipSizeStyle.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ChipSizeStyle.kt new file mode 100644 index 000000000..360c41b55 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ChipSizeStyle.kt @@ -0,0 +1,29 @@ +package com.ninecraft.booket.core.designsystem.component.chip + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +data class ChipSizeStyle( + val paddingValues: PaddingValues, + val textStyle: TextStyle, +) + +val mediumChipStyle: ChipSizeStyle + @Composable get() = ChipSizeStyle( + paddingValues = PaddingValues( + horizontal = ReedTheme.spacing.spacing3, + vertical = ReedTheme.spacing.spacing2, + ), + textStyle = ReedTheme.typography.body2Medium, + ) + +val smallChipStyle: ChipSizeStyle + @Composable get() = ChipSizeStyle( + paddingValues = PaddingValues( + horizontal = ReedTheme.spacing.spacing3, + vertical = ReedTheme.spacing.spacing15, + ), + textStyle = ReedTheme.typography.label1Medium, + ) diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ReedRemovableChip.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ReedRemovableChip.kt new file mode 100644 index 000000000..df72cccf2 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ReedRemovableChip.kt @@ -0,0 +1,75 @@ +package com.ninecraft.booket.core.designsystem.component.chip + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.vectorResource +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.extensions.noRippleClickable +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.R +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +fun ReedRemovableChip( + label: String, + chipSizeStyle: ChipSizeStyle, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val cornerShape = RoundedCornerShape(ReedTheme.radius.full) + + Row( + modifier = modifier + .clip(cornerShape) + .background(color = ReedTheme.colors.bgTertiary) + .border( + width = 1.dp, + color = ReedTheme.colors.borderBrand, + shape = cornerShape, + ) + .padding(chipSizeStyle.paddingValues), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = ReedTheme.colors.contentBrand, + style = chipSizeStyle.textStyle, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_close), + contentDescription = "Icon Close", + tint = ReedTheme.colors.contentBrand, + modifier = Modifier + .size(14.dp) + .noRippleClickable { + onRemove() + }, + ) + } +} + +@ComponentPreview +@Composable +private fun ReedRemovableChipPreview() { + ReedTheme { + ReedRemovableChip( + label = "text", + chipSizeStyle = mediumChipStyle, + onRemove = {}, + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ReedSelectableChip.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ReedSelectableChip.kt new file mode 100644 index 000000000..c75629a9f --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/chip/ReedSelectableChip.kt @@ -0,0 +1,89 @@ +package com.ninecraft.booket.core.designsystem.component.chip + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.unit.dp +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 ReedSelectableChip( + label: String, + chipSizeStyle: ChipSizeStyle, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cornerShape = RoundedCornerShape(ReedTheme.radius.full) + val backgroundColor = if (selected) ReedTheme.colors.bgTertiary else ReedTheme.colors.basePrimary + val borderColor = if (selected) ReedTheme.colors.borderBrand else ReedTheme.colors.borderPrimary + val textColor = if (selected) ReedTheme.colors.contentBrand else ReedTheme.colors.contentSecondary + + Row( + modifier = modifier + .clip(cornerShape) + .background(color = backgroundColor) + .noRippleClickable { + onClick() + } + .border( + width = 1.dp, + color = borderColor, + shape = cornerShape, + ) + .padding(chipSizeStyle.paddingValues), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = textColor, + style = chipSizeStyle.textStyle, + ) + } +} + +@ComponentPreview +@Composable +private fun ReedSelectableChipPreview() { + ReedTheme { + Column( + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing1), + ) { + ReedSelectableChip( + label = "text", + chipSizeStyle = mediumChipStyle, + selected = false, + onClick = {}, + ) + ReedSelectableChip( + label = "text", + chipSizeStyle = mediumChipStyle, + selected = true, + onClick = {}, + ) + ReedSelectableChip( + label = "text", + chipSizeStyle = smallChipStyle, + selected = false, + onClick = {}, + ) + ReedSelectableChip( + label = "text", + chipSizeStyle = smallChipStyle, + selected = true, + onClick = {}, + ) + } + } +} diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedRecordTextField.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedRecordTextField.kt index ca3535b42..31d8dea94 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedRecordTextField.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedRecordTextField.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.vector.ImageVector import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -80,7 +81,8 @@ fun ReedRecordTextField( decorator = { innerTextField -> Row( modifier = modifier - .background(color = backgroundColor, shape = cornerShape) + .clip(cornerShape) + .background(backgroundColor) .border( border = if (isError) errorBorderStroke else borderStroke, shape = cornerShape, diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt index 880c37481..69f3e3f58 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.vector.ImageVector import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -77,7 +78,8 @@ fun ReedTextField( decorator = { innerTextField -> Row( modifier = modifier - .background(color = backgroundColor, shape = cornerShape) + .clip(cornerShape) + .background(backgroundColor) .then( if (borderStroke != null) { Modifier.border(borderStroke, shape = cornerShape) diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Color.kt index 61997cc91..6e8e0272f 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Color.kt @@ -55,7 +55,7 @@ val Yellow900 = Color(0xFFFF6D00) val Blue50 = Color(0xFFE3F4FF) val Blue100 = Color(0xFFBBE2FF) val Blue200 = Color(0xFF8DD0FF) -val Blue300 = Color(0xFF56BDFF) +val Blue300 = Color(0xFF68A3FF) val Blue400 = Color(0xFF1DADFF) val Blue500 = Color(0xFF009EFF) val Blue600 = Color(0xFF008FFF) @@ -63,7 +63,29 @@ val Blue700 = Color(0xFF007BFF) val Blue800 = Color(0xFF1269EC) val Blue900 = Color(0xFF1F47CD) -val Kakao = Color(0xFFFBD300) +val Orange50 = Color(0xFFFFF1EB) +val Orange100 = Color(0xFFFFD2BE) +val Orange200 = Color(0xFFFFB392) +val Orange300 = Color(0xFFFF9365) +val Orange400 = Color(0xFFEF6D35) +val Orange500 = Color(0xFFCD5622) +val Orange600 = Color(0xFFAB4114) +val Orange700 = Color(0xFF892F08) +val Orange800 = Color(0xFF672001) +val Orange900 = Color(0xFF451500) + +val Violet50 = Color(0xFFF7F0FF) +val Violet100 = Color(0xFFE6CEFF) +val Violet200 = Color(0xFFD4ADFF) +val Violet300 = Color(0xFFC38CFF) +val Violet400 = Color(0xFFB26AFF) +val Violet500 = Color(0xFF9A55E4) +val Violet600 = Color(0xFF7F40C2) +val Violet700 = Color(0xFF652EA0) +val Violet800 = Color(0xFF4C1E7E) +val Violet900 = Color(0xFF36125C) + +val Kakao = Color(0xFFFFEB00) val Blank = Color(0xFFD6D6D6) val HomeBg = Color(0xFFF0F9E8) @@ -76,6 +98,8 @@ val InsightTextColor = Color(0xFF9A55E4) val InsightBgColor = Color(0xFFF3E8FF) val SadnessTextColor = Color(0xFF2872E9) val SadnessBgColor = Color(0xFFE1ECFF) +val OtherTextColor = Color(0xFF737373) +val OtherBgColor = Color(0xFFF5F5F5) @Immutable data class ReedColorScheme( diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Spacing.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Spacing.kt index a4dfd5fde..9595ba84c 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Spacing.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/theme/Spacing.kt @@ -9,6 +9,7 @@ data class ReedSpacing( val spacing0: Dp = 0.dp, val spacing05: Dp = 2.dp, val spacing1: Dp = 4.dp, + val spacing15: Dp = 6.dp, val spacing2: Dp = 8.dp, val spacing3: Dp = 12.dp, val spacing4: Dp = 16.dp, diff --git a/core/designsystem/src/main/res/drawable/ic_gallery.xml b/core/designsystem/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 000000000..8b079b154 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/img_category_insight.webp b/core/designsystem/src/main/res/drawable/img_category_insight.webp new file mode 100644 index 000000000..b0ad9df03 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_category_insight.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_category_joy.webp b/core/designsystem/src/main/res/drawable/img_category_joy.webp new file mode 100644 index 000000000..70036262e Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_category_joy.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_category_sadness.webp b/core/designsystem/src/main/res/drawable/img_category_sadness.webp new file mode 100644 index 000000000..016a7321a Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_category_sadness.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_category_warmth.webp b/core/designsystem/src/main/res/drawable/img_category_warmth.webp new file mode 100644 index 000000000..5ad834e25 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_category_warmth.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_emotion_insight.webp b/core/designsystem/src/main/res/drawable/img_emotion_insight.webp deleted file mode 100644 index c29fecdc7..000000000 Binary files a/core/designsystem/src/main/res/drawable/img_emotion_insight.webp and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable/img_emotion_joy.webp b/core/designsystem/src/main/res/drawable/img_emotion_joy.webp deleted file mode 100644 index b2e9699bb..000000000 Binary files a/core/designsystem/src/main/res/drawable/img_emotion_joy.webp and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable/img_emotion_sadness.webp b/core/designsystem/src/main/res/drawable/img_emotion_sadness.webp deleted file mode 100644 index 0af80f209..000000000 Binary files a/core/designsystem/src/main/res/drawable/img_emotion_sadness.webp and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable/img_emotion_warmth.webp b/core/designsystem/src/main/res/drawable/img_emotion_warmth.webp deleted file mode 100644 index 58fc62f69..000000000 Binary files a/core/designsystem/src/main/res/drawable/img_emotion_warmth.webp and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable/img_insight.webp b/core/designsystem/src/main/res/drawable/img_insight.webp new file mode 100644 index 000000000..6a1d7c903 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_insight.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_joy.webp b/core/designsystem/src/main/res/drawable/img_joy.webp new file mode 100644 index 000000000..4ef8c6b1c Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_joy.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_other.webp b/core/designsystem/src/main/res/drawable/img_other.webp new file mode 100644 index 000000000..239de1791 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_other.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_sadness.webp b/core/designsystem/src/main/res/drawable/img_sadness.webp new file mode 100644 index 000000000..47dbb5545 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_sadness.webp differ diff --git a/core/designsystem/src/main/res/drawable/img_warmth.webp b/core/designsystem/src/main/res/drawable/img_warmth.webp new file mode 100644 index 000000000..af7344207 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_warmth.webp differ diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index a2793c603..57f884365 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -4,4 +4,9 @@ 알 수 없는 오류가 발생하였습니다. 도서 검색 후 내 서재에 담아보세요 로그인이 필요한 기능입니다 + 공감과 위로가 된 순간 + 흥미롭고 유쾌한 순간 + 눈물이 고인 순간 + 생각이 깊어지는 순간 + 네 가지 감정으로 표현하기 어려울 때 diff --git a/core/designsystem/stability/designsystem.stability b/core/designsystem/stability/designsystem.stability index 319a7836b..ed4f6c9ea 100644 --- a/core/designsystem/stability/designsystem.stability +++ b/core/designsystem/stability/designsystem.stability @@ -15,18 +15,12 @@ public fun com.ninecraft.booket.core.designsystem.component.NetworkImage(imageUr - placeholder: RUNTIME (requires runtime check) - contentScale: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.NetworkImagePreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.component.RecordProgressBar(currentStep: com.ninecraft.booket.core.designsystem.RecordStep, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - currentStep: STABLE + - currentStep: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -36,12 +30,6 @@ public fun com.ninecraft.booket.core.designsystem.component.ReedDivider(modifier params: - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.ReedDividerPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.component.ResourceImage(imageRes: kotlin.Int, contentDescription: kotlin.String, modifier: androidx.compose.ui.Modifier, placeholder: androidx.compose.ui.graphics.painter.Painter?, contentScale: androidx.compose.ui.layout.ContentScale): kotlin.Unit skippable: false @@ -53,48 +41,6 @@ public fun com.ninecraft.booket.core.designsystem.component.ResourceImage(imageR - placeholder: RUNTIME (requires runtime check) - contentScale: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.ResourceImagePreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.core.designsystem.component.button.(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.core.designsystem.component.button.(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.core.designsystem.component.button.(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.core.designsystem.component.button.(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.core.designsystem.component.button.(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.core.designsystem.component.button.(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.component.button.ReedButton(onClick: kotlin.Function0, text: kotlin.String, sizeStyle: com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle, colorStyle: com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, multipleEventsCutterEnabled: kotlin.Boolean): kotlin.Unit skippable: true @@ -102,8 +48,8 @@ public fun com.ninecraft.booket.core.designsystem.component.button.ReedButton(on params: - onClick: STABLE (function type) - text: STABLE (String is immutable) - - sizeStyle: STABLE - - colorStyle: STABLE + - sizeStyle: STABLE (class with no mutable properties) + - colorStyle: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) - enabled: STABLE (primitive type) - leadingIcon: STABLE (composable function type) @@ -142,62 +88,62 @@ public fun com.ninecraft.booket.core.designsystem.component.button.ReedButtonCol params: @Composable -private fun com.ninecraft.booket.core.designsystem.component.button.ReedButtonDisabledPreview(): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.ReedTextButton(onClick: kotlin.Function0, text: kotlin.String, sizeStyle: com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle, colorStyle: com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, multipleEventsCutterEnabled: kotlin.Boolean): kotlin.Unit skippable: true restartable: true params: + - onClick: STABLE (function type) + - text: STABLE (String is immutable) + - sizeStyle: STABLE (class with no mutable properties) + - colorStyle: STABLE (class with no mutable properties) + - modifier: STABLE (marked @Stable or @Immutable) + - enabled: STABLE (primitive type) + - multipleEventsCutterEnabled: STABLE (primitive type) @Composable -private fun com.ninecraft.booket.core.designsystem.component.button.ReedLargeButtonPreview(): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle skippable: true restartable: true params: @Composable -private fun com.ninecraft.booket.core.designsystem.component.button.ReedMediumButtonPreview(): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.largeRoundedButtonStyle(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle skippable: true restartable: true params: @Composable -private fun com.ninecraft.booket.core.designsystem.component.button.ReedSmallButtonPreview(): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.mediumButtonStyle(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle skippable: true restartable: true params: @Composable -public fun com.ninecraft.booket.core.designsystem.component.button.ReedTextButton(onClick: kotlin.Function0, text: kotlin.String, sizeStyle: com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle, colorStyle: com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, multipleEventsCutterEnabled: kotlin.Boolean): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.mediumRoundedButtonStyle(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle skippable: true restartable: true params: - - onClick: STABLE (function type) - - text: STABLE (String is immutable) - - sizeStyle: STABLE - - colorStyle: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - - enabled: STABLE (primitive type) - - multipleEventsCutterEnabled: STABLE (primitive type) @Composable -private fun com.ninecraft.booket.core.designsystem.component.button.ReedTextButtonPreview(): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.smallButtonStyle(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle skippable: true restartable: true params: @Composable -public fun com.ninecraft.booket.core.designsystem.component.checkbox.CircleCheckBox(checked: kotlin.Boolean, onCheckedChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.button.smallRoundedButtonStyle(): com.ninecraft.booket.core.designsystem.component.button.ButtonSizeStyle skippable: true restartable: true params: - - checked: STABLE (primitive type) - - onCheckedChange: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.core.designsystem.component.checkbox.CircleCheckboxPreview(): kotlin.Unit +public fun com.ninecraft.booket.core.designsystem.component.checkbox.CircleCheckBox(checked: kotlin.Boolean, onCheckedChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: + - checked: STABLE (primitive type) + - onCheckedChange: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) @Composable public fun com.ninecraft.booket.core.designsystem.component.checkbox.SquareCheckBox(checked: kotlin.Boolean, onCheckedChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -208,12 +154,6 @@ public fun com.ninecraft.booket.core.designsystem.component.checkbox.SquareCheck - onCheckedChange: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.checkbox.SquareCheckboxPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.component.checkbox.TickOnlyCheckBox(checked: kotlin.Boolean, onCheckedChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -223,12 +163,6 @@ public fun com.ninecraft.booket.core.designsystem.component.checkbox.TickOnlyChe - onCheckedChange: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.checkbox.TickOnlyCheckBoxPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.component.textfield.ReedRecordTextField(recordState: androidx.compose.foundation.text.input.TextFieldState, recordHintRes: kotlin.Int, modifier: androidx.compose.ui.Modifier, inputTransformation: androidx.compose.foundation.text.input.InputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, isError: kotlin.Boolean, errorMessage: kotlin.String, onClear: kotlin.Function0?, onNext: kotlin.Function0, backgroundColor: androidx.compose.ui.graphics.Color, textColor: androidx.compose.ui.graphics.Color, cornerShape: androidx.compose.foundation.shape.RoundedCornerShape, borderStroke: androidx.compose.foundation.BorderStroke): kotlin.Unit skippable: true @@ -249,12 +183,6 @@ public fun com.ninecraft.booket.core.designsystem.component.textfield.ReedRecord - cornerShape: STABLE (known stable type) - borderStroke: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.textfield.ReedRecordTextFieldPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.component.textfield.ReedTextField(queryState: androidx.compose.foundation.text.input.TextFieldState, queryHintRes: kotlin.Int, onSearch: kotlin.Function1, onClear: kotlin.Function0, modifier: androidx.compose.ui.Modifier, backgroundColor: androidx.compose.ui.graphics.Color, textColor: androidx.compose.ui.graphics.Color, cornerShape: androidx.compose.foundation.shape.RoundedCornerShape, borderStroke: androidx.compose.foundation.BorderStroke?, searchIconTint: androidx.compose.ui.graphics.Color): kotlin.Unit skippable: true @@ -271,12 +199,6 @@ public fun com.ninecraft.booket.core.designsystem.component.textfield.ReedTextFi - borderStroke: STABLE (marked @Stable or @Immutable) - searchIconTint: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.core.designsystem.component.textfield.ReedTextFieldPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme(content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true @@ -285,31 +207,31 @@ public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme(content: @[Com - content: STABLE (composable function type) @Composable -public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.(): com.ninecraft.booket.core.designsystem.theme.ReedBorder +public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.border(): com.ninecraft.booket.core.designsystem.theme.ReedBorder skippable: true restartable: true params: @Composable -public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.(): com.ninecraft.booket.core.designsystem.theme.ReedColorScheme +public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.colors(): com.ninecraft.booket.core.designsystem.theme.ReedColorScheme skippable: true restartable: true params: @Composable -public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.(): com.ninecraft.booket.core.designsystem.theme.ReedRadius +public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.radius(): com.ninecraft.booket.core.designsystem.theme.ReedRadius skippable: true restartable: true params: @Composable -public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.(): com.ninecraft.booket.core.designsystem.theme.ReedSpacing +public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.spacing(): com.ninecraft.booket.core.designsystem.theme.ReedSpacing skippable: true restartable: true params: @Composable -public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.(): com.ninecraft.booket.core.designsystem.theme.ReedTypography +public fun com.ninecraft.booket.core.designsystem.theme.ReedTheme.typography(): com.ninecraft.booket.core.designsystem.theme.ReedTypography skippable: true restartable: true params: diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts new file mode 100644 index 000000000..70617c980 --- /dev/null +++ b/core/di/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.booket.android.library) + alias(libs.plugins.metro) +} + +android { + namespace = "com.ninecraft.booket.core.di" +} diff --git a/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ActivityKey.kt b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ActivityKey.kt new file mode 100644 index 000000000..96744840d --- /dev/null +++ b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ActivityKey.kt @@ -0,0 +1,10 @@ +package com.ninecraft.booket.core.di + +import android.app.Activity +import dev.zacsweers.metro.MapKey +import kotlin.reflect.KClass + +/** A [MapKey] annotation for binding Activities in a multibinding map. */ +@MapKey +@Target(AnnotationTarget.CLASS) +annotation class ActivityKey(val value: KClass) diff --git a/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ApplicationContext.kt b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ApplicationContext.kt new file mode 100644 index 000000000..fd8504b4b --- /dev/null +++ b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ApplicationContext.kt @@ -0,0 +1,6 @@ +package com.ninecraft.booket.core.di + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class ApplicationContext diff --git a/core/di/src/main/kotlin/com/ninecraft/booket/core/di/DataScope.kt b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/DataScope.kt new file mode 100644 index 000000000..4075a9c7e --- /dev/null +++ b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/DataScope.kt @@ -0,0 +1,9 @@ +package com.ninecraft.booket.core.di + +/** + * Scope for data layer dependencies including: + * - Network (Retrofit, OkHttp, Interceptors) + * - DataStore and DataSources + * - Repository implementations + */ +abstract class DataScope private constructor() diff --git a/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ServiceKey.kt b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ServiceKey.kt new file mode 100644 index 000000000..fd5185308 --- /dev/null +++ b/core/di/src/main/kotlin/com/ninecraft/booket/core/di/ServiceKey.kt @@ -0,0 +1,9 @@ +package com.ninecraft.booket.core.di + +import android.app.Service +import dev.zacsweers.metro.MapKey +import kotlin.reflect.KClass + +@MapKey +@Target(AnnotationTarget.CLASS) +annotation class ServiceKey(val value: KClass) diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/AutoLoginState.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/AutoLoginState.kt deleted file mode 100644 index fead4e570..000000000 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/AutoLoginState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.ninecraft.booket.core.model - -enum class AutoLoginState { - IDLE, - LOGGED_IN, - NOT_LOGGED_IN, -} diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookDetailModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookDetailModel.kt index c398445a5..0fd424a23 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookDetailModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookDetailModel.kt @@ -1,8 +1,8 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable -@Stable +@Immutable data class BookDetailModel( val version: String = "", val title: String = "", diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt index f1c8b05c1..9e25a2190 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt @@ -1,8 +1,8 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable -@Stable +@Immutable data class BookSearchModel( val version: String = "", val title: String = "", @@ -17,7 +17,7 @@ data class BookSearchModel( val books: List = emptyList(), ) -@Stable +@Immutable data class BookSummaryModel( val isbn13: String = "", val title: String = "", diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/EmotionModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/EmotionModel.kt new file mode 100644 index 000000000..a0c084c8d --- /dev/null +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/EmotionModel.kt @@ -0,0 +1,42 @@ +package com.ninecraft.booket.core.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class EmotionGroupsModel( + val emotions: List, +) + +@Immutable +data class EmotionGroupModel( + val code: EmotionCode, + val displayName: String, + val detailEmotions: List, +) + +@Immutable +data class DetailEmotionModel( + val id: String, + val name: String, +) + +enum class EmotionCode( + val displayName: String, +) { + WARMTH("따뜻함"), + JOY("즐거움"), + SADNESS("슬픔"), + INSIGHT("깨달음"), + OTHER("기타"), + ; + + companion object { + fun fromCode(code: String): EmotionCode? { + return EmotionCode.entries.find { it.name == code } + } + + fun fromDisplayName(displayName: String): EmotionCode? { + return entries.find { it.displayName == displayName } + } + } +} diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/HomeModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/HomeModel.kt index 7d55b27be..2970d4a3a 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/HomeModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/HomeModel.kt @@ -1,13 +1,13 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable -@Stable +@Immutable data class HomeModel( val recentBooks: List = emptyList(), ) -@Stable +@Immutable data class RecentBookModel( val userBookId: String = "", val isbn13: String = "", diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LibraryModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LibraryModel.kt index 39dad91a7..9bb4b94b0 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LibraryModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LibraryModel.kt @@ -1,8 +1,8 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable -@Stable +@Immutable data class LibraryModel( val books: LibraryBooksModel = LibraryBooksModel(), val totalCount: Int = 0, @@ -11,13 +11,13 @@ data class LibraryModel( val completedCount: Int = 0, ) -@Stable +@Immutable data class LibraryBooksModel( val content: List = emptyList(), val page: PageInfoModel = PageInfoModel(), ) -@Stable +@Immutable data class LibraryBookSummaryModel( val userBookId: String = "", val userId: String = "", @@ -32,7 +32,7 @@ data class LibraryBookSummaryModel( val updatedAt: String = "", ) -@Stable +@Immutable data class PageInfoModel( val size: Int = 0, val number: Int = 0, diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LoginMethod.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LoginMethod.kt new file mode 100644 index 000000000..dd7f94454 --- /dev/null +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/LoginMethod.kt @@ -0,0 +1,7 @@ +package com.ninecraft.booket.core.model + +enum class LoginMethod { + NONE, + KAKAO, + GOOGLE, +} diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/OnboardingState.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/OnboardingState.kt deleted file mode 100644 index 1d4789299..000000000 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/OnboardingState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.ninecraft.booket.core.model - -enum class OnboardingState { - IDLE, - NOT_COMPLETED, - COMPLETED, -} 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 1c6356e16..912091c0b 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,8 +1,10 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable +@Immutable data class ReadingRecordsModel( + val representativeEmotion: PrimaryEmotionModel? = null, val lastPage: Boolean = true, val totalResults: Int = 0, val startIndex: Int = 0, @@ -10,14 +12,15 @@ data class ReadingRecordsModel( val readingRecords: List = emptyList(), ) -@Stable +@Immutable data class ReadingRecordModel( val id: String = "", val userBookId: String = "", - val pageNumber: Int = 0, + val pageNumber: Int? = null, val quote: String = "", val review: String = "", - val emotionTags: List = emptyList(), + val primaryEmotion: PrimaryEmotionModel = PrimaryEmotionModel(), + val detailEmotions: List = emptyList(), val createdAt: String = "", val updatedAt: String = "", val bookTitle: String = "", @@ -25,3 +28,9 @@ data class ReadingRecordModel( val bookCoverImageUrl: String = "", val author: String = "", ) + +@Immutable +data class PrimaryEmotionModel( + val code: EmotionCode = EmotionCode.OTHER, + val displayName: String = "기타", +) diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/RecordDetailModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/RecordDetailModel.kt deleted file mode 100644 index 19e49633d..000000000 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/RecordDetailModel.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.ninecraft.booket.core.model - -import androidx.compose.runtime.Stable - -@Stable -data class RecordDetailModel( - val id: String = "", - val userBookId: String = "", - val pageNumber: Int = 0, - val quote: String = "", - val review: String = "", - val emotionTags: List = emptyList(), - val createdAt: String = "", - val updatedAt: String = "", - val bookTitle: String = "", - val bookPublisher: String = "", - val bookCoverImageUrl: String = "", - val author: String = "", -) diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/RecordRegisterModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/RecordRegisterModel.kt deleted file mode 100644 index c60ac2032..000000000 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/RecordRegisterModel.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.ninecraft.booket.core.model - -data class RecordRegisterModel( - val id: String = "", - val userBookId: String = "", - val pageNumber: Int = 0, - val quote: String = "", - val emotionTags: List = emptyList(), - val review: String = "", - val createdAt: String = "", - val updatedAt: String = "", -) diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/SeedModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/SeedModel.kt index 670b050df..db102d40f 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/SeedModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/SeedModel.kt @@ -1,30 +1,14 @@ package com.ninecraft.booket.core.model -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable -@Stable +@Immutable data class SeedModel( val categories: List = emptyList(), ) -@Stable +@Immutable data class EmotionModel( - val name: Emotion, + val code: EmotionCode, val count: Int, ) - -enum class Emotion( - val displayName: String, -) { - WARM("따뜻함"), - JOY("즐거움"), - SAD("슬픔"), - INSIGHT("깨달음"), - ; - - companion object { - fun fromDisplayName(displayName: String): Emotion? { - return entries.find { it.displayName == displayName } - } - } -} diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/AutoLoginState.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/AutoLoginState.kt new file mode 100644 index 000000000..e1816d2b4 --- /dev/null +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/AutoLoginState.kt @@ -0,0 +1,10 @@ +package com.ninecraft.booket.core.model.state + +import androidx.compose.runtime.Stable + +@Stable +enum class AutoLoginState { + IDLE, + LOGGED_IN, + NOT_LOGGED_IN, +} diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/OnboardingState.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/OnboardingState.kt new file mode 100644 index 000000000..8b4c332aa --- /dev/null +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/OnboardingState.kt @@ -0,0 +1,10 @@ +package com.ninecraft.booket.core.model.state + +import androidx.compose.runtime.Stable + +@Stable +enum class OnboardingState { + IDLE, + NOT_COMPLETED, + COMPLETED, +} diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserState.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/UserState.kt similarity index 52% rename from core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserState.kt rename to core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/UserState.kt index 43ec08241..777c5efb1 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserState.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/state/UserState.kt @@ -1,5 +1,8 @@ -package com.ninecraft.booket.core.model +package com.ninecraft.booket.core.model.state +import androidx.compose.runtime.Stable + +@Stable sealed interface UserState { data object Guest : UserState data object LoggedIn : UserState diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 5d03bc71f..e3e157f26 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -1,12 +1,10 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import com.ninecraft.booket.convention.getLocalProperty plugins { alias(libs.plugins.booket.android.library) alias(libs.plugins.booket.android.retrofit) - alias(libs.plugins.booket.android.hilt) + alias(libs.plugins.metro) } android { @@ -18,11 +16,11 @@ android { buildTypes { debug { - buildConfigField("String", "SERVER_BASE_URL", getServerBaseUrl("DEBUG_SERVER_BASE_URL")) + buildConfigField("String", "SERVER_BASE_URL", getLocalProperty("DEBUG_SERVER_BASE_URL")) } release { - buildConfigField("String", "SERVER_BASE_URL", getServerBaseUrl("RELEASE_SERVER_BASE_URL")) + buildConfigField("String", "SERVER_BASE_URL", getLocalProperty("RELEASE_SERVER_BASE_URL")) } } } @@ -30,11 +28,12 @@ android { dependencies { implementations( projects.core.datastore.api, + projects.core.di, + libs.kotlinx.coroutines.core, libs.logger, ) -} -fun getServerBaseUrl(propertyKey: String): String { - return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) + debugImplementation(libs.chucker) + releaseImplementation(libs.chucker.no.op) } diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenAuthenticator.kt index 011ab824f..87d749234 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/TokenAuthenticator.kt @@ -1,51 +1,69 @@ package com.ninecraft.booket.core.network import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource +import com.ninecraft.booket.core.di.DataScope import com.ninecraft.booket.core.network.request.RefreshTokenRequest import com.ninecraft.booket.core.network.service.ReedService import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route -import javax.inject.Inject -import javax.inject.Provider -class TokenAuthenticator @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class TokenAuthenticator( private val tokenDataSource: TokenDataSource, - private val serviceProvider: Provider, + private val reedService: Lazy, ) : Authenticator { + private val lock = Any() + override fun authenticate(route: Route?, response: Response): Request? { - return runBlocking { - try { - val refreshToken = tokenDataSource.getRefreshToken() + // 동시 401 응답 시 중복 refresh 방지 (refresh token rotation 대응) + synchronized(lock) { + val failedToken = response.request.header("Authorization") + ?.removePrefix("Bearer ") + .orEmpty() - if (refreshToken.isBlank()) { - Logger.d("TokenAuthenticator", "No refresh token available") - tokenDataSource.clearTokens() - return@runBlocking null - } + val currentToken = runBlocking { tokenDataSource.getAccessToken() } - val refreshTokenRequest = RefreshTokenRequest(refreshToken) - val refreshResponse = serviceProvider.get().refreshToken(refreshTokenRequest) + // 다른 요청이 이미 토큰을 갱신한 경우, 새 토큰으로 재시도만 수행 + if (failedToken != currentToken) { + return response.request.newBuilder() + .header("Authorization", "Bearer $currentToken") + .build() + } - tokenDataSource.apply { - setAccessToken(refreshResponse.accessToken) - setRefreshToken(refreshResponse.refreshToken) - } + return runBlocking { + try { + val refreshToken = tokenDataSource.getRefreshToken() - Logger.d("TokenAuthenticator", "Token refreshed successfully") + if (refreshToken.isBlank()) { + Logger.d("No refresh token available") + tokenDataSource.clearTokens() + return@runBlocking null + } - response.request.newBuilder() - .header("Authorization", "Bearer ${refreshResponse.accessToken}") - .build() - } catch (e: Exception) { - Logger.e("TokenAuthenticator", e.message) - tokenDataSource.clearTokens() + val refreshResponse = reedService.value.refreshToken(RefreshTokenRequest(refreshToken)) + + tokenDataSource.apply { + setAccessToken(refreshResponse.accessToken) + setRefreshToken(refreshResponse.refreshToken) + } + + Logger.d("Token refreshed successfully") - // refresh token이 만료되었거나 잘못된 경우, 재시도하지 않음 - return@runBlocking null + response.request.newBuilder() + .header("Authorization", "Bearer ${refreshResponse.accessToken}") + .build() + } catch (e: Exception) { + Logger.e(e, "Token refresh failed") + tokenDataSource.clearTokens() + return@runBlocking null + } } } } 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 a49e6fe87..daa2d701c 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 @@ -1,12 +1,16 @@ package com.ninecraft.booket.core.network import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource +import dev.zacsweers.metro.Inject +import com.ninecraft.booket.core.di.DataScope +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response -import javax.inject.Inject -internal class TokenInterceptor @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class TokenInterceptor( private val tokenDataSource: TokenDataSource, ) : Interceptor { diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkGraph.kt similarity index 69% rename from core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt rename to core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkGraph.kt index 572a06847..d6c82ff70 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkGraph.kt @@ -1,36 +1,33 @@ package com.ninecraft.booket.core.network.di +import android.content.Context import android.util.Log -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.ninecraft.booket.core.di.ApplicationContext +import com.ninecraft.booket.core.di.DataScope +import com.ninecraft.booket.core.network.BuildConfig +import com.ninecraft.booket.core.network.TokenAuthenticator +import com.ninecraft.booket.core.network.TokenInterceptor +import com.ninecraft.booket.core.network.service.ReedService +import com.orhanobut.logger.AndroidLogAdapter +import com.orhanobut.logger.PrettyFormatStrategy +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides 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 com.ninecraft.booket.core.network.BuildConfig -import com.ninecraft.booket.core.network.TokenInterceptor -import com.ninecraft.booket.core.network.TokenAuthenticator -import com.ninecraft.booket.core.network.service.ReedService -import com.orhanobut.logger.AndroidLogAdapter -import com.orhanobut.logger.PrettyFormatStrategy import retrofit2.create import java.util.concurrent.TimeUnit -import javax.inject.Singleton private const val MaxTimeoutMillis = 15_000L private val jsonRule = Json { - // 기본값도 JSON에 포함하여 직렬화 encodeDefaults = true - // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) ignoreUnknownKeys = true - // JSON을 보기 좋게 들여쓰기하여 포맷팅 prettyPrint = true - // 엄격하지 않은 파싱 (따옴표 없는 키, 후행 쉼표 등 허용) isLenient = true } @@ -50,29 +47,26 @@ private val FILTERED_HEADERS = setOf( "content-length", ) -@Module -@InstallIn(SingletonComponent::class) -internal object NetworkModule { +@ContributesTo(DataScope::class) +interface NetworkGraph { - @Singleton @Provides - internal fun provideNetworkLogAdapter(): AndroidLogAdapter { + fun provideNetworkLogAdapter(): AndroidLogAdapter { val networkFormatStrategy = PrettyFormatStrategy.newBuilder() - .showThreadInfo(false) // 스레드 정보 제거 - .methodCount(0) // 메서드 스택 제거 - .methodOffset(0) // 오프셋 제거 - .tag("NETWORK") // API 호출 전용 태그 + .showThreadInfo(false) + .methodCount(0) + .methodOffset(0) + .tag("NETWORK") .build() return AndroidLogAdapter(networkFormatStrategy) } - @Singleton @Provides - internal fun provideHttpLoggingInterceptor( + fun provideHttpLoggingInterceptor( networkLogAdapter: AndroidLogAdapter, ): HttpLoggingInterceptor { - return HttpLoggingInterceptor { message -> + val interceptor = HttpLoggingInterceptor { message -> val shouldFilter = FILTERED_HEADERS.any { header -> message.lowercase().contains("$header:") } @@ -83,21 +77,28 @@ internal object NetworkModule { if (!shouldFilter && !isDuplicateContentType && message.isNotBlank()) { networkLogAdapter.log(Log.DEBUG, null, message) } - }.apply { - level = if (BuildConfig.DEBUG) { - HttpLoggingInterceptor.Level.BODY - } else { - HttpLoggingInterceptor.Level.NONE - } } + interceptor.level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + return interceptor + } + + @Provides + fun provideChuckerInterceptor( + @ApplicationContext context: Context, + ): ChuckerInterceptor { + return ChuckerInterceptor(context) } - @Singleton @Provides - internal fun provideOkHttpClient( + fun provideOkHttpClient( httpLoggingInterceptor: HttpLoggingInterceptor, tokenInterceptor: TokenInterceptor, tokenAuthenticator: TokenAuthenticator, + chuckerInterceptor: ChuckerInterceptor, ): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) @@ -105,13 +106,13 @@ internal object NetworkModule { .writeTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) .addInterceptor(tokenInterceptor) .authenticator(tokenAuthenticator) + .addInterceptor(chuckerInterceptor) .addInterceptor(httpLoggingInterceptor) .build() } - @Singleton @Provides - internal fun provideRetrofit( + fun provideRetrofit( okHttpClient: OkHttpClient, ): Retrofit { return Retrofit.Builder() @@ -121,9 +122,8 @@ internal object NetworkModule { .build() } - @Singleton @Provides - internal fun provideReedService( + fun provideReedService( retrofit: Retrofit, ): ReedService { return retrofit.create() diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/RecordRegisterRequest.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/RecordRegisterRequest.kt index e26f533d9..a09aeccf1 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/RecordRegisterRequest.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/RecordRegisterRequest.kt @@ -6,11 +6,13 @@ import kotlinx.serialization.Serializable @Serializable data class RecordRegisterRequest( @SerialName("pageNumber") - val pageNumber: Int, + val pageNumber: Int?, @SerialName("quote") val quote: String, - @SerialName("emotionTags") - val emotionTags: List, @SerialName("review") val review: String, + @SerialName("primaryEmotion") + val primaryEmotion: String, + @SerialName("detailEmotionTagIds") + val detailEmotionTagIds: List, ) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/EmotionGroupsResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/EmotionGroupsResponse.kt new file mode 100644 index 000000000..091ab1e33 --- /dev/null +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/EmotionGroupsResponse.kt @@ -0,0 +1,28 @@ +package com.ninecraft.booket.core.network.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EmotionGroupsResponse( + @SerialName("emotions") + val emotions: List, +) + +@Serializable +data class EmotionGroup( + @SerialName("code") + val code: String, + @SerialName("displayName") + val displayName: String, + @SerialName("detailEmotions") + val detailEmotions: List, +) + +@Serializable +data class DetailEmotion( + @SerialName("id") + val id: String, + @SerialName("name") + val name: String, +) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/ReadingRecordsResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/ReadingRecordsResponse.kt index f11488193..67f85469b 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/ReadingRecordsResponse.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/ReadingRecordsResponse.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.Serializable @Serializable data class ReadingRecordsResponse( + @SerialName("representativeEmotion") + val representativeEmotion: PrimaryEmotion?, @SerialName("lastPage") val lastPage: Boolean, @SerialName("totalResults") @@ -24,23 +26,33 @@ data class ReadingRecord( @SerialName("userBookId") val userBookId: String, @SerialName("pageNumber") - val pageNumber: Int, + val pageNumber: Int?, @SerialName("quote") val quote: String, @SerialName("review") val review: String?, - @SerialName("emotionTags") - val emotionTags: List = emptyList(), + @SerialName("primaryEmotion") + val primaryEmotion: PrimaryEmotion, + @SerialName("detailEmotions") + val detailEmotions: List, @SerialName("createdAt") val createdAt: String, @SerialName("updatedAt") val updatedAt: String, @SerialName("bookTitle") - val bookTitle: String, + val bookTitle: String?, @SerialName("bookPublisher") - val bookPublisher: String, + val bookPublisher: String?, @SerialName("bookCoverImageUrl") - val bookCoverImageUrl: String, + val bookCoverImageUrl: String?, @SerialName("author") - val author: String, + val author: String?, +) + +@Serializable +data class PrimaryEmotion( + @SerialName("code") + val code: String, + @SerialName("displayName") + val displayName: String, ) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/RecordDetailResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/RecordDetailResponse.kt deleted file mode 100644 index c78a219b7..000000000 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/RecordDetailResponse.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.ninecraft.booket.core.network.response - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class RecordDetailResponse( - @SerialName("id") - val id: String, - @SerialName("userBookId") - val userBookId: String, - @SerialName("pageNumber") - val pageNumber: Int, - @SerialName("quote") - val quote: String, - @SerialName("review") - val review: String?, - @SerialName("emotionTags") - val emotionTags: List, - @SerialName("createdAt") - val createdAt: String, - @SerialName("updatedAt") - val updatedAt: String, - @SerialName("bookTitle") - val bookTitle: String, - @SerialName("bookPublisher") - val bookPublisher: String, - @SerialName("bookCoverImageUrl") - val bookCoverImageUrl: String, - @SerialName("author") - val author: String, -) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/RecordRegisterResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/RecordRegisterResponse.kt deleted file mode 100644 index a6a4c2b5e..000000000 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/RecordRegisterResponse.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.ninecraft.booket.core.network.response - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class RecordRegisterResponse( - @SerialName("id") - val id: String, - @SerialName("userBookId") - val userBookId: String, - @SerialName("pageNumber") - val pageNumber: Int, - @SerialName("quote") - val quote: String, - @SerialName("emotionTags") - val emotionTags: List, - @SerialName("review") - val review: String?, - @SerialName("createdAt") - val createdAt: String, - @SerialName("updatedAt") - val updatedAt: String, -) 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 f3926e64e..ee3122249 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 @@ -10,14 +10,13 @@ import com.ninecraft.booket.core.network.request.TermsAgreementRequest import com.ninecraft.booket.core.network.response.BookDetailResponse import com.ninecraft.booket.core.network.response.BookSearchResponse import com.ninecraft.booket.core.network.response.BookUpsertResponse +import com.ninecraft.booket.core.network.response.EmotionGroupsResponse import com.ninecraft.booket.core.network.response.GuestBookSearchResponse 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 import com.ninecraft.booket.core.network.response.RefreshTokenResponse import com.ninecraft.booket.core.network.response.SeedResponse import com.ninecraft.booket.core.network.response.TermsAgreementResponse @@ -25,7 +24,6 @@ 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 @@ -107,14 +105,18 @@ interface ReedService { @Path("userBookId") userBookId: String, ) + // Emotions (auth required) + @GET("api/v2/emotions") + suspend fun getEmotions(): EmotionGroupsResponse + // Reading-records endpoints (auth required) - @POST("api/v1/reading-records/{userBookId}") + @POST("api/v2/reading-records/{userBookId}") suspend fun postRecord( @Path("userBookId") userBookId: String, @Body recordRegisterRequest: RecordRegisterRequest, - ): RecordRegisterResponse + ): ReadingRecord - @GET("api/v1/reading-records/{userBookId}") + @GET("api/v2/reading-records/{userBookId}") suspend fun getReadingRecords( @Path("userBookId") userBookId: String, @Query("sort") sort: String = "CREATED_DATE_DESC", @@ -122,23 +124,23 @@ interface ReedService { @Query("size") size: Int = 20, ): ReadingRecordsResponse - @GET("api/v1/reading-records/{userBookId}/seed/stats") + @GET("api/v2/reading-records/{userBookId}/seed/stats") suspend fun getSeedsStats( @Path("userBookId") userBookId: String, ): SeedResponse - @GET("api/v1/reading-records/detail/{readingRecordId}") + @GET("api/v2/reading-records/detail/{readingRecordId}") suspend fun getRecordDetail( @Path("readingRecordId") readingRecordId: String, - ): RecordDetailResponse + ): ReadingRecord - @PATCH("api/v1/reading-records/{readingRecordId}") + @PUT("api/v2/reading-records/{readingRecordId}") suspend fun editRecord( @Path("readingRecordId") readingRecordId: String, @Body recordRegisterRequest: RecordRegisterRequest, ): ReadingRecord - @DELETE("api/v1/reading-records/{readingRecordId}") + @DELETE("api/v2/reading-records/{readingRecordId}") suspend fun deleteRecord( @Path("readingRecordId") readingRecordId: String, ) diff --git a/core/ocr/build.gradle.kts b/core/ocr/build.gradle.kts index 8fcb50054..8fc0d1175 100644 --- a/core/ocr/build.gradle.kts +++ b/core/ocr/build.gradle.kts @@ -1,19 +1,16 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties - +import com.ninecraft.booket.convention.getLocalProperty plugins { alias(libs.plugins.booket.android.library) alias(libs.plugins.booket.android.retrofit) - alias(libs.plugins.booket.android.hilt) + alias(libs.plugins.metro) } android { namespace = "com.ninecraft.booket.core.ocr" defaultConfig { - buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY")) + buildConfigField("String", "CLOUD_VISION_API_KEY", getLocalProperty("CLOUD_VISION_API_KEY")) } buildFeatures { @@ -24,11 +21,9 @@ android { dependencies { implementations( projects.core.common, + projects.core.di, + libs.kotlinx.coroutines.core, libs.logger, ) } - -fun getApiKey(propertyKey: String): String { - return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) -} 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 index b79d4ea15..ec0b0b17c 100644 --- 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 @@ -1,6 +1,6 @@ package com.ninecraft.booket.core.ocr.di -import javax.inject.Qualifier +import dev.zacsweers.metro.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) 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/OcrGraph.kt similarity index 65% rename from core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/CloudVisionNetworkModule.kt rename to core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/OcrGraph.kt index c38717b50..d21eb0acd 100644 --- 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/OcrGraph.kt @@ -1,44 +1,37 @@ package com.ninecraft.booket.core.ocr.di +import com.ninecraft.booket.core.di.DataScope 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 dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides 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 retrofit2.create 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 const val CLOUD_VISION_BASE_URL = "https://vision.googleapis.com/" 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 { +@ContributesTo(DataScope::class) +interface OcrGraph { - @Provides - @Singleton @CloudVisionOkHttp - fun provideOkHttp(): OkHttpClient { + @Provides + fun provideCloudVisionOkHttpClient(): OkHttpClient { val log = HttpLoggingInterceptor().apply { redactHeader("X-Goog-Api-Key") level = if (BuildConfig.DEBUG) { @@ -55,21 +48,22 @@ object CloudVisionNetworkModule { .build() } - @Provides - @Singleton @CloudVisionRetrofit - fun provideRetrofit( + @Provides + fun provideCloudVisionRetrofit( @CloudVisionOkHttp okHttpClient: OkHttpClient, ): Retrofit { return Retrofit.Builder() - .baseUrl(BASE_URL) + .baseUrl(CLOUD_VISION_BASE_URL) .client(okHttpClient) .addConverterFactory(jsonConverterFactory) .build() } @Provides - @Singleton - fun provideVisionApi(@CloudVisionRetrofit retrofit: Retrofit): CloudVisionService = - retrofit.create(CloudVisionService::class.java) + fun provideCloudVisionService( + @CloudVisionRetrofit retrofit: Retrofit, + ): CloudVisionService { + return retrofit.create() + } } diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt index 9035c5cf4..94dd4522f 100644 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt @@ -1,8 +1,10 @@ package com.ninecraft.booket.core.ocr.recognizer +import android.content.Context import android.net.Uri import android.util.Base64 import com.ninecraft.booket.core.common.utils.runSuspendCatching +import com.ninecraft.booket.core.di.ApplicationContext import com.ninecraft.booket.core.ocr.BuildConfig import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest import com.ninecraft.booket.core.ocr.model.CloudVisionRequest @@ -14,16 +16,29 @@ import com.ninecraft.booket.core.ocr.service.CloudVisionService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -import javax.inject.Inject +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import com.ninecraft.booket.core.di.DataScope -class CloudOcrRecognizer @Inject constructor( +@SingleIn(DataScope::class) +@Inject +class CloudOcrRecognizer( + @param: ApplicationContext private val context: Context, 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 byte = when (imageUri.scheme) { + null, "file" -> { + val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.") + val file = File(filePath) + file.readBytes() + } + else -> { + context.contentResolver.openInputStream(imageUri)?.use { it.readBytes() } + ?: throw IllegalArgumentException("Unable to open image input stream.") + } + } val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP) val request = CloudVisionRequest( diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 9e0af88a6..372c7946e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.library) alias(libs.plugins.booket.android.library.compose) 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 b0798ffdb..5939249e7 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 @@ -31,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -139,10 +140,8 @@ private fun InfinityLazyColumnPreview() { modifier = Modifier .width(68.dp) .height(100.dp) - .background( - color = ReedTheme.colors.contentTertiary, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ), + .clip(RoundedCornerShape(ReedTheme.radius.sm)) + .background(color = ReedTheme.colors.contentTertiary), ) Spacer(Modifier.width(ReedTheme.spacing.spacing4)) Column { 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 d82b136c2..9a4cd21a3 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 @@ -13,6 +13,7 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog @@ -31,6 +32,8 @@ fun ReedDialog( description: String? = null, dismissButtonText: String? = null, onDismissRequest: () -> Unit = {}, + dismissOnClickOutside: Boolean = true, + dismissOnBackPress: Boolean = true, headerContent: @Composable (() -> Unit)? = null, ) { Dialog( @@ -38,6 +41,8 @@ fun ReedDialog( onDismissRequest() }, properties = DialogProperties( + dismissOnClickOutside = dismissOnClickOutside, + dismissOnBackPress = dismissOnBackPress, usePlatformDefaultWidth = false, ), ) { @@ -45,12 +50,8 @@ fun ReedDialog( modifier = modifier .fillMaxWidth() .padding(horizontal = ReedTheme.spacing.spacing5) - .background( - color = ReedTheme.colors.basePrimary, - shape = RoundedCornerShape( - ReedTheme.radius.lg, - ), - ) + .clip(RoundedCornerShape(ReedTheme.radius.lg)) + .background(color = ReedTheme.colors.basePrimary) .padding( start = ReedTheme.spacing.spacing5, top = ReedTheme.spacing.spacing8, diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedTopAppBar.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedTopAppBar.kt index ece245b45..4c291e6d7 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedTopAppBar.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedTopAppBar.kt @@ -37,11 +37,13 @@ fun ReedTopAppBar( endIconDescription: String = "", endIconOnClick: () -> Unit = {}, ) { + val bgColor = if (isDark) Neutral950 else White + Row( modifier = modifier .fillMaxWidth() .height(60.dp) - .background(color = if (isDark) Neutral950 else White) + .background(bgColor) .padding(horizontal = ReedTheme.spacing.spacing2), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, diff --git a/core/ui/stability/ui.stability b/core/ui/stability/ui.stability index 44d5e6580..0305109da 100644 --- a/core/ui/stability/ui.stability +++ b/core/ui/stability/ui.stability @@ -35,12 +35,6 @@ public fun com.ninecraft.booket.core.ui.component.InfinityLazyColumn(modifier: a - loadMore: STABLE (function type) - content: STABLE (function type) -@Composable -private fun com.ninecraft.booket.core.ui.component.InfinityLazyColumnPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.ui.component.LoadStateFooter(footerState: com.ninecraft.booket.core.ui.component.FooterState, onRetryClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -60,12 +54,6 @@ public fun com.ninecraft.booket.core.ui.component.ReedBackTopAppBar(modifier: an - title: STABLE (String is immutable) - onBackClick: STABLE (function type) -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedBackTopAppBarPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.ui.component.ReedBottomSheet(onDismissRequest: kotlin.Function0, modifier: androidx.compose.ui.Modifier, sheetState: androidx.compose.material3.SheetState, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true @@ -76,18 +64,6 @@ public fun com.ninecraft.booket.core.ui.component.ReedBottomSheet(onDismissReque - sheetState: STABLE (marked @Stable or @Immutable) - content: STABLE (composable function type) -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedChoiceDialogPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar(modifier: androidx.compose.ui.Modifier, isDark: kotlin.Boolean, title: kotlin.String, onClose: kotlin.Function0): kotlin.Unit skippable: true @@ -98,18 +74,6 @@ public fun com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar(modifier: a - title: STABLE (String is immutable) - onClose: STABLE (function type) -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedCloseTopAppBarPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedConfirmDialogPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.ui.component.ReedDialog(confirmButtonText: kotlin.String, onConfirmRequest: kotlin.Function0, modifier: androidx.compose.ui.Modifier, title: kotlin.String?, description: kotlin.String?, dismissButtonText: kotlin.String?, onDismissRequest: kotlin.Function0, headerContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?): kotlin.Unit skippable: true @@ -118,9 +82,9 @@ public fun com.ninecraft.booket.core.ui.component.ReedDialog(confirmButtonText: - confirmButtonText: STABLE (String is immutable) - onConfirmRequest: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - - title: STABLE - - description: STABLE - - dismissButtonText: STABLE + - title: STABLE (class with no mutable properties) + - description: STABLE (class with no mutable properties) + - dismissButtonText: STABLE (class with no mutable properties) - onDismissRequest: STABLE (function type) - headerContent: STABLE (composable function type) @@ -149,24 +113,6 @@ public fun com.ninecraft.booket.core.ui.component.ReedLoadingIndicator(modifier: - modifier: STABLE (marked @Stable or @Immutable) - delayMillis: STABLE (primitive type) -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedLoadingIndicatorPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedNetworkErrorUiPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedServerErrorUiPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.core.ui.component.ReedTopAppBar(modifier: androidx.compose.ui.Modifier, isDark: kotlin.Boolean, title: kotlin.String, startIconRes: kotlin.Int?, startIconDescription: kotlin.String, startIconOnClick: kotlin.Function0, endIconRes: kotlin.Int?, endIconDescription: kotlin.String, endIconOnClick: kotlin.Function0): kotlin.Unit skippable: true @@ -175,19 +121,13 @@ public fun com.ninecraft.booket.core.ui.component.ReedTopAppBar(modifier: androi - modifier: STABLE (marked @Stable or @Immutable) - isDark: STABLE (primitive type) - title: STABLE (String is immutable) - - startIconRes: STABLE + - startIconRes: STABLE (class with no mutable properties) - startIconDescription: STABLE (String is immutable) - startIconOnClick: STABLE (function type) - - endIconRes: STABLE + - endIconRes: STABLE (class with no mutable properties) - endIconDescription: STABLE (String is immutable) - endIconOnClick: STABLE (function type) -@Composable -private fun com.ninecraft.booket.core.ui.component.ReedTopAppBarPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable private fun com.ninecraft.booket.core.ui.component.onLoadMore(limitCount: kotlin.Int, loadOnBottom: kotlin.Boolean, action: kotlin.Function0): kotlin.Unit skippable: true diff --git a/feature/detail/build.gradle.kts b/feature/detail/build.gradle.kts index c3af32c62..873428e15 100644 --- a/feature/detail/build.gradle.kts +++ b/feature/detail/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.booket.kotlin.library.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.detail" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.kotlinx.collections.immutable, 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 07833fe8b..2a457532a 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 @@ -13,6 +13,7 @@ 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 import com.ninecraft.booket.core.model.EmotionModel +import com.ninecraft.booket.core.model.PrimaryEmotionModel import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.ui.component.FooterState import com.ninecraft.booket.feature.screens.BookDetailScreen @@ -21,6 +22,8 @@ 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.DetailEmotionArg +import com.ninecraft.booket.feature.screens.arguments.PrimaryEmotionArg import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs import com.orhanobut.logger.Logger import com.skydoves.compose.effects.RememberedEffect @@ -29,10 +32,10 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -43,13 +46,21 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import java.time.LocalDateTime -class BookDetailPresenter @AssistedInject constructor( +@AssistedInject +class BookDetailPresenter( @Assisted private val screen: BookDetailScreen, @Assisted private val navigator: Navigator, private val bookRepository: BookRepository, private val recordRepository: RecordRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + + @CircuitInject(BookDetailScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: BookDetailScreen, navigator: Navigator): BookDetailPresenter + } + companion object { private const val PAGE_SIZE = 20 private const val START_INDEX = 0 @@ -59,7 +70,7 @@ class BookDetailPresenter @AssistedInject constructor( private fun getRecordComparator(sortType: RecordSort): Comparator { return when (sortType) { - RecordSort.PAGE_NUMBER_ASC -> compareBy { it.pageNumber } + RecordSort.PAGE_NUMBER_ASC -> compareBy(nullsLast()) { it.pageNumber } RecordSort.CREATED_DATE_DESC -> compareByDescending { LocalDateTime.parse(it.createdAt) } } } @@ -70,7 +81,9 @@ class BookDetailPresenter @AssistedInject constructor( var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var footerState by rememberRetained { mutableStateOf(FooterState.Idle) } var bookDetail by rememberRetained { mutableStateOf(BookDetailModel()) } + var representativeEmotion by rememberRetained { mutableStateOf(null) } var seedsStates by rememberRetained { mutableStateOf>(persistentListOf()) } + var isStatsExpanded by rememberRetained { mutableStateOf(false) } var readingRecords by rememberRetained { mutableStateOf(persistentListOf()) } var readingRecordsTotalCount by rememberRetained { mutableIntStateOf(0) } var currentStartIndex by rememberRetained { mutableIntStateOf(START_INDEX) } @@ -110,6 +123,7 @@ class BookDetailPresenter @AssistedInject constructor( bookDetail = detail currentBookStatus = BookStatus.fromValue(detail.userBookStatus) ?: BookStatus.BEFORE_READING selectedBookStatus = currentBookStatus + representativeEmotion = records.representativeEmotion seedsStates = seeds.categories.toImmutableList() readingRecords = records.readingRecords.toPersistentList() readingRecordsTotalCount = records.totalResults @@ -306,7 +320,7 @@ class BookDetailPresenter @AssistedInject constructor( RecordCardScreen( quote = selectedRecordInfo.quote, bookTitle = selectedRecordInfo.bookTitle, - emotion = selectedRecordInfo.emotionTags[0], + emotionCode = selectedRecordInfo.primaryEmotion.code, ), ) } @@ -320,11 +334,17 @@ class BookDetailPresenter @AssistedInject constructor( pageNumber = selectedRecordInfo.pageNumber, quote = selectedRecordInfo.quote, review = selectedRecordInfo.review, - emotionTags = selectedRecordInfo.emotionTags, - bookTitle = selectedRecordInfo.bookTitle, - bookPublisher = selectedRecordInfo.bookPublisher, - bookCoverImageUrl = selectedRecordInfo.bookCoverImageUrl, - author = selectedRecordInfo.author, + primaryEmotion = PrimaryEmotionArg( + code = selectedRecordInfo.primaryEmotion.code, + displayName = selectedRecordInfo.primaryEmotion.displayName, + ), + detailEmotions = selectedRecordInfo.detailEmotions.map { + DetailEmotionArg(id = it.id, name = it.name) + }, + bookTitle = selectedRecordInfo.bookTitle.ifEmpty { bookDetail.title }, + bookPublisher = selectedRecordInfo.bookPublisher.ifEmpty { bookDetail.publisher }, + bookCoverImageUrl = selectedRecordInfo.bookCoverImageUrl.ifEmpty { bookDetail.coverImageUrl }, + author = selectedRecordInfo.author.ifEmpty { bookDetail.author }, ), ), ) @@ -390,6 +410,10 @@ class BookDetailPresenter @AssistedInject constructor( initialLoad() } } + + is BookDetailUiEvent.OnStatsToggleClick -> { + isStatsExpanded = event.flag + } } } @@ -401,7 +425,9 @@ class BookDetailPresenter @AssistedInject constructor( uiState = uiState, footerState = footerState, bookDetail = bookDetail, + representativeEmotion = representativeEmotion, seedsStats = seedsStates, + isStatsExpanded = isStatsExpanded, readingRecords = readingRecords, readingRecordsTotalCount = readingRecordsTotalCount, isBookUpdateBottomSheetVisible = isBookUpdateBottomSheetVisible, @@ -418,13 +444,4 @@ class BookDetailPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(BookDetailScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create( - screen: BookDetailScreen, - navigator: Navigator, - ): BookDetailPresenter - } } 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 6ce7d6f7a..1e0ebee1e 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 @@ -1,6 +1,5 @@ package com.ninecraft.booket.feature.detail.book -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -37,6 +36,10 @@ import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorSt 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.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionModel +import com.ninecraft.booket.core.model.PrimaryEmotionModel +import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter @@ -56,14 +59,15 @@ import com.ninecraft.booket.feature.detail.record.component.RecordMenuBottomShee import com.ninecraft.booket.feature.screens.BookDetailScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition @OptIn(ExperimentalMaterial3Api::class) -@CircuitInject(BookDetailScreen::class, ActivityRetainedComponent::class) +@CircuitInject(BookDetailScreen::class, AppScope::class) @Composable internal fun BookDetailUi( state: BookDetailUiState, @@ -238,7 +242,7 @@ internal fun BookDetailContent( item { Column { BookItem(bookDetail = state.bookDetail) - Spacer(Modifier.height(ReedTheme.spacing.spacing7)) + Spacer(Modifier.height(ReedTheme.spacing.spacing5)) Row( modifier = Modifier .fillMaxWidth() @@ -280,7 +284,14 @@ internal fun BookDetailContent( item { if (state.hasEmotionData()) { - CollectedSeeds(seedsStats = state.seedsStats) + CollectedSeeds( + seedsStats = state.seedsStats, + representativeEmotion = state.representativeEmotion!!, + isStatsExpanded = state.isStatsExpanded, + onToggleClick = { + state.eventSink(BookDetailUiEvent.OnStatsToggleClick(!state.isStatsExpanded)) + }, + ) } else { Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) } @@ -292,7 +303,7 @@ internal fun BookDetailContent( Column( modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), ) { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) ReadingRecordsHeader( totalCount = state.readingRecordsTotalCount, currentRecordSort = state.currentRecordSort, @@ -329,6 +340,9 @@ internal fun BookDetailContent( val record = state.readingRecords[index] RecordItem( recordInfo = record, + onRecordClick = { + state.eventSink(BookDetailUiEvent.OnRecordItemClick(record.id)) + }, onRecordMenuClick = { recordInfo -> state.eventSink(BookDetailUiEvent.OnRecordMenuClick(recordInfo)) }, @@ -337,10 +351,7 @@ internal fun BookDetailContent( start = ReedTheme.spacing.spacing5, end = ReedTheme.spacing.spacing5, bottom = ReedTheme.spacing.spacing3, - ) - .clickable { - state.eventSink(BookDetailUiEvent.OnRecordItemClick(record.id)) - }, + ), ) } @@ -365,7 +376,27 @@ internal fun BookDetailContent( @ComponentPreview @Composable -private fun BookDetailPreview() { +private fun BookDetailEmptyPreview() { + ReedTheme { + BookDetailUi( + state = BookDetailUiState( + uiState = UiState.Success, + bookDetail = BookDetailModel( + title = "데미안", + author = "헤르만 헤세", + publisher = "민음사", + pubDate = "2023-01-01", + coverImageUrl = "", + ), + eventSink = {}, + ), + ) + } +} + +@ComponentPreview +@Composable +private fun BookDetailSeedStatsPreview() { ReedTheme { BookDetailUi( state = BookDetailUiState( @@ -377,6 +408,93 @@ private fun BookDetailPreview() { pubDate = "2023-01-01", coverImageUrl = "", ), + seedsStats = persistentListOf( + EmotionModel(code = EmotionCode.WARMTH, count = 5), + EmotionModel(code = EmotionCode.JOY, count = 3), + EmotionModel(code = EmotionCode.SADNESS, count = 2), + EmotionModel(code = EmotionCode.INSIGHT, count = 7), + ), + readingRecords = persistentListOf( + ReadingRecordModel( + id = "1", + pageNumber = 42, + quote = "새는 알에서 나오려고 투쟁한다. 알은 세계이다.", + review = "정말 인상 깊은 구절이었다.", + primaryEmotion = PrimaryEmotionModel(displayName = "깨달음"), + createdAt = "2024-01-15T10:30:00.000000", + ), + ReadingRecordModel( + id = "2", + pageNumber = 78, + quote = "나는 더 이상 꿈을 꾸지 않으려 했다.", + review = "성장통을 느끼는 부분", + primaryEmotion = PrimaryEmotionModel(displayName = "슬픔"), + createdAt = "2024-01-20T14:20:00.000000", + ), + ReadingRecordModel( + id = "3", + pageNumber = 156, + quote = "운명과 성향은 같은 개념의 두 이름이다.", + review = "내 삶을 돌아보게 되었다.", + primaryEmotion = PrimaryEmotionModel(displayName = "깨달음"), + createdAt = "2024-01-25T09:15:00.000000", + ), + ), + readingRecordsTotalCount = 3, + eventSink = {}, + ), + ) + } +} + +@ComponentPreview +@Composable +private fun BookDetailSeedsStatsExpandedPreview() { + ReedTheme { + BookDetailUi( + state = BookDetailUiState( + uiState = UiState.Success, + bookDetail = BookDetailModel( + title = "데미안", + author = "헤르만 헤세", + publisher = "민음사", + pubDate = "2023-01-01", + coverImageUrl = "", + ), + seedsStats = persistentListOf( + EmotionModel(code = EmotionCode.WARMTH, count = 5), + EmotionModel(code = EmotionCode.JOY, count = 3), + EmotionModel(code = EmotionCode.SADNESS, count = 2), + EmotionModel(code = EmotionCode.INSIGHT, count = 7), + ), + isStatsExpanded = true, + readingRecords = persistentListOf( + ReadingRecordModel( + id = "1", + pageNumber = 42, + quote = "새는 알에서 나오려고 투쟁한다. 알은 세계이다.", + review = "정말 인상 깊은 구절이었다.", + primaryEmotion = PrimaryEmotionModel(displayName = "깨달음"), + createdAt = "2024-01-15T10:30:00.000000", + ), + ReadingRecordModel( + id = "2", + pageNumber = 78, + quote = "나는 더 이상 꿈을 꾸지 않으려 했다.", + review = "성장통을 느끼는 부분", + primaryEmotion = PrimaryEmotionModel(displayName = "슬픔"), + createdAt = "2024-01-20T14:20:00.000000", + ), + ReadingRecordModel( + id = "3", + pageNumber = 156, + quote = "운명과 성향은 같은 개념의 두 이름이다.", + review = "내 삶을 돌아보게 되었다.", + primaryEmotion = PrimaryEmotionModel(displayName = "깨달음"), + createdAt = "2024-01-25T09:15:00.000000", + ), + ), + readingRecordsTotalCount = 3, eventSink = {}, ), ) 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 550d0ee10..1b351a463 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 @@ -5,6 +5,7 @@ import com.ninecraft.booket.core.common.R import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.model.BookDetailModel import com.ninecraft.booket.core.model.EmotionModel +import com.ninecraft.booket.core.model.PrimaryEmotionModel import com.ninecraft.booket.core.model.ReadingRecordModel import com.ninecraft.booket.core.ui.component.FooterState import com.slack.circuit.runtime.CircuitUiEvent @@ -26,7 +27,9 @@ data class BookDetailUiState( val footerState: FooterState = FooterState.Idle, val isLoading: Boolean = false, val bookDetail: BookDetailModel = BookDetailModel(), + val representativeEmotion: PrimaryEmotionModel? = null, val seedsStats: ImmutableList = persistentListOf(), + val isStatsExpanded: Boolean = false, val readingRecords: ImmutableList = persistentListOf(), val readingRecordsTotalCount: Int = 0, val currentStartIndex: Int = 1, @@ -84,6 +87,7 @@ sealed interface BookDetailUiEvent : CircuitUiEvent { data class OnRecordItemClick(val recordId: String) : BookDetailUiEvent data object OnLoadMore : BookDetailUiEvent data object OnRetryClick : BookDetailUiEvent + data class OnStatsToggleClick(val flag: Boolean) : BookDetailUiEvent } enum class RecordSort(val value: String) { @@ -98,7 +102,7 @@ enum class RecordSort(val value: String) { } } - companion object Companion { + companion object { fun fromValue(value: String): RecordSort? { return entries.find { it.value == value } } 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 7d7789eec..26c33ece9 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 @@ -131,11 +131,13 @@ fun RowScope.BookStatusItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val bgColor = if (selected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary + Box( modifier = modifier .weight(1f) .clip(RoundedCornerShape(ReedTheme.radius.sm)) - .background(if (selected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary) + .background(bgColor) .selectable( selected = selected, indication = null, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeeds.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeeds.kt index db940fd56..de84d9206 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeeds.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeeds.kt @@ -1,7 +1,11 @@ package com.ninecraft.booket.feature.detail.book.component +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,25 +14,50 @@ 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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.graphicRes +import com.ninecraft.booket.core.designsystem.primaryEmotionColor +import com.ninecraft.booket.core.designsystem.ratioBarColor import com.ninecraft.booket.core.designsystem.theme.ReedTheme -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionCode import com.ninecraft.booket.core.model.EmotionModel +import com.ninecraft.booket.core.model.PrimaryEmotionModel 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 + +private val EMOTION_DISPLAY_ORDER = listOf( + EmotionCode.WARMTH, + EmotionCode.JOY, + EmotionCode.SADNESS, + EmotionCode.INSIGHT, + EmotionCode.OTHER, +) @Composable internal fun CollectedSeeds( seedsStats: ImmutableList, + representativeEmotion: PrimaryEmotionModel, + isStatsExpanded: Boolean, + onToggleClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -41,67 +70,246 @@ internal fun CollectedSeeds( bottom = ReedTheme.spacing.spacing6, ) .clip(RoundedCornerShape(ReedTheme.radius.md)) - .background(ReedTheme.colors.baseSecondary), + .background(ReedTheme.colors.baseSecondary) + .padding(ReedTheme.spacing.spacing4), ) { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) - Text( - text = stringResource(R.string.collected_seed_title), - modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing4), - color = ReedTheme.colors.contentSecondary, - style = ReedTheme.typography.body2Medium, + CollectedSeedsHeader( + primaryEmotion = representativeEmotion, + isStatsExpanded = isStatsExpanded, + onToggleClick = onToggleClick, ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, + + AnimatedVisibility( + visible = isStatsExpanded, + enter = expandVertically(), + exit = shrinkVertically(), ) { - seedsStats.forEach { emotion -> - SeedItem(emotion = emotion) + Column { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + HorizontalDivider( + color = ReedTheme.colors.dividerSm, + thickness = 1.dp, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + + EmotionRatioBar(seedsStats = seedsStats) + + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + + val displayEmotions = remember(seedsStats) { + EMOTION_DISPLAY_ORDER.mapNotNull { emotionCode -> + seedsStats.find { it.code == emotionCode && it.count > 0 } + } + } + + if (displayEmotions.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing1), + ) { + displayEmotions.forEach { emotionModel -> + EmotionStatCard( + emotion = emotionModel, + modifier = Modifier.weight(1f), + ) + } + } + } } } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = ReedTheme.spacing.spacing4) - .clip(RoundedCornerShape(ReedTheme.radius.sm)) - .background(ReedTheme.colors.basePrimary) - .border( - width = 1.dp, - color = ReedTheme.colors.borderPrimary, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) - .padding(ReedTheme.spacing.spacing3), + } +} + +@Composable +private fun CollectedSeedsHeader( + primaryEmotion: PrimaryEmotionModel, + isStatsExpanded: Boolean, + onToggleClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onToggleClick() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, ) { - EmotionAnalysisResultText( - emotions = seedsStats, - brandColor = ReedTheme.colors.contentBrand, - secondaryColor = ReedTheme.colors.contentSecondary, - emotionTextStyle = ReedTheme.typography.label2SemiBold, - regularTextStyle = ReedTheme.typography.label2Regular, - )?.let { annotatedString -> + primaryEmotion.let { emotion -> + Image( + painter = painterResource(id = emotion.code.graphicRes), + contentDescription = "Seed Image", + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(ReedTheme.colors.basePrimary), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + val emotionDisplayName = if (primaryEmotion.code == EmotionCode.OTHER) { + stringResource(R.string.collected_seed_other_emotion_name) + } else { + primaryEmotion.code.displayName + } Text( - text = annotatedString, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, + text = "'$emotionDisplayName'", + color = primaryEmotion.code.primaryEmotionColor, + style = ReedTheme.typography.label1SemiBold, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = stringResource( + if (primaryEmotion.code == EmotionCode.OTHER) { + R.string.collected_seed_other_emotion_description + } else { + R.string.collected_seed_emotion_description + }, + ), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.label1Medium, ) } } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + + Icon( + imageVector = ImageVector.vectorResource( + if (isStatsExpanded) designR.drawable.ic_chevron_up else designR.drawable.ic_chevron_down, + ), + contentDescription = if (isStatsExpanded) "Collapse" else "Expand", + modifier = Modifier.size(24.dp), + tint = ReedTheme.colors.contentTertiary, + ) + } +} + +@Composable +private fun EmotionRatioBar( + seedsStats: ImmutableList, + modifier: Modifier = Modifier, +) { + val totalCount = remember(seedsStats) { + seedsStats.sumOf { it.count }.coerceAtLeast(1) + } + + Row( + modifier = modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(ReedTheme.radius.full)), + ) { + EmotionCode.entries.forEach { emotionCode -> + val emotionModel = seedsStats.find { it.code == emotionCode } + val count = emotionModel?.count ?: 0 + if (count > 0) { + val weight = count.toFloat() / totalCount + Box( + modifier = Modifier + .weight(weight) + .height(12.dp) + .background(emotionCode.ratioBarColor), + ) + } + } + } +} + +@Composable +private fun EmotionStatCard( + emotion: EmotionModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(ReedTheme.colors.basePrimary) + .padding( + top = ReedTheme.spacing.spacing3, + bottom = ReedTheme.spacing.spacing2, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(emotion.code.ratioBarColor), + ) + + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + + Text( + text = emotion.code.displayName, + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.label2Regular, + ) + + Text( + text = "${emotion.count}개", + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.caption1Regular, + ) + } +} + +@ComponentPreview +@Composable +private fun CollectedSeedsCollapsedPreview() { + ReedTheme { + CollectedSeeds( + seedsStats = persistentListOf( + EmotionModel(EmotionCode.WARMTH, 2), + EmotionModel(EmotionCode.JOY, 2), + EmotionModel(EmotionCode.SADNESS, 4), + EmotionModel(EmotionCode.INSIGHT, 2), + EmotionModel(EmotionCode.OTHER, 2), + ), + representativeEmotion = PrimaryEmotionModel(EmotionCode.SADNESS, "슬픔"), + isStatsExpanded = false, + onToggleClick = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun CollectedSeedsExpandedPreview() { + ReedTheme { + CollectedSeeds( + seedsStats = persistentListOf( + EmotionModel(EmotionCode.WARMTH, 2), + EmotionModel(EmotionCode.JOY, 2), + EmotionModel(EmotionCode.SADNESS, 2), + EmotionModel(EmotionCode.INSIGHT, 2), + EmotionModel(EmotionCode.OTHER, 4), + ), + representativeEmotion = PrimaryEmotionModel(EmotionCode.OTHER, "기타"), + isStatsExpanded = true, + onToggleClick = {}, + ) } } @ComponentPreview @Composable -private fun CollectedSeedPreview() { +private fun CollectedSeedsExpandedDuplicatedPreview() { ReedTheme { CollectedSeeds( seedsStats = persistentListOf( - EmotionModel(Emotion.WARM, 3), - EmotionModel(Emotion.JOY, 2), - EmotionModel(Emotion.SAD, 1), - EmotionModel(Emotion.INSIGHT, 3), + EmotionModel(EmotionCode.WARMTH, 4), + EmotionModel(EmotionCode.JOY, 4), + EmotionModel(EmotionCode.SADNESS, 2), + EmotionModel(EmotionCode.INSIGHT, 2), + EmotionModel(EmotionCode.OTHER, 2), ), + representativeEmotion = PrimaryEmotionModel(EmotionCode.WARMTH, "따뜻함"), + isStatsExpanded = true, + onToggleClick = {}, ) } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/EmotionAnalysisResultText.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/EmotionAnalysisResultText.kt deleted file mode 100644 index a0cd832b4..000000000 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/EmotionAnalysisResultText.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.ninecraft.booket.feature.detail.book.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.ninecraft.booket.core.common.utils.EmotionDisplayType -import com.ninecraft.booket.core.common.utils.analyzeEmotions -import com.ninecraft.booket.core.designsystem.ComponentPreview -import com.ninecraft.booket.core.designsystem.theme.ReedTheme -import com.ninecraft.booket.core.model.Emotion -import com.ninecraft.booket.core.model.EmotionModel -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf - -@Composable -internal fun EmotionAnalysisResultText( - emotions: ImmutableList, - brandColor: Color, - secondaryColor: Color, - emotionTextStyle: TextStyle, - regularTextStyle: TextStyle, -): AnnotatedString? { - val analysisResult = remember(emotions) { analyzeEmotions(emotions) } - - return when (analysisResult.displayType) { - EmotionDisplayType.NONE -> null - - EmotionDisplayType.SINGLE -> { - val emotion = analysisResult.topEmotions.first() - buildAnnotatedString { - withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { - append("이 책에서 ") - } - withStyle(SpanStyle(color = brandColor, fontSize = emotionTextStyle.fontSize, fontWeight = emotionTextStyle.fontWeight)) { - append(emotion.name.displayName) - } - withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { - append(" 감정을 많이 느꼈어요") - } - } - } - - EmotionDisplayType.DUAL -> { - val emotions = analysisResult.topEmotions - buildAnnotatedString { - withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { - append("이 책에서 ") - } - emotions.forEachIndexed { index, emotion -> - if (index > 0) { - withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { - append(", ") - } - } - withStyle(SpanStyle(color = brandColor, fontSize = emotionTextStyle.fontSize, fontWeight = emotionTextStyle.fontWeight)) { - append(emotion.name.displayName) - } - } - withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { - append(" 감정을 많이 느꼈어요") - } - } - } - - EmotionDisplayType.BALANCED -> { - buildAnnotatedString { - withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { - append("이 책에서 ") - } - withStyle(SpanStyle(color = brandColor, fontSize = emotionTextStyle.fontSize, fontWeight = emotionTextStyle.fontWeight)) { - append("여러 감정이 고르게 담겼어요") - } - } - } - } -} - -@ComponentPreview -@Composable -private fun EmotionTextAllCasesPreview() { - ReedTheme { - Column(modifier = Modifier.padding(16.dp)) { - Text(text = "1개의 감정이 1위인 경우:") - EmotionAnalysisResultText( - emotions = persistentListOf( - EmotionModel(name = Emotion.WARM, count = 5), - EmotionModel(name = Emotion.JOY, count = 2), - ), - brandColor = ReedTheme.colors.contentBrand, - secondaryColor = ReedTheme.colors.contentSecondary, - emotionTextStyle = ReedTheme.typography.label2SemiBold, - regularTextStyle = ReedTheme.typography.label2Regular, - )?.let { annotatedString -> - Text( - text = annotatedString, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text(text = "2개의 감정이 공동 1위인 경우:") - EmotionAnalysisResultText( - emotions = persistentListOf( - EmotionModel(name = Emotion.WARM, count = 5), - EmotionModel(name = Emotion.JOY, count = 5), - EmotionModel(name = Emotion.SAD, count = 2), - ), - brandColor = ReedTheme.colors.contentBrand, - secondaryColor = ReedTheme.colors.contentSecondary, - emotionTextStyle = ReedTheme.typography.label2SemiBold, - regularTextStyle = ReedTheme.typography.label2Regular, - )?.let { annotatedString -> - Text( - text = annotatedString, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text(text = "3~4개의 감정이 공동 1위인 경우:") - EmotionAnalysisResultText( - emotions = persistentListOf( - EmotionModel(name = Emotion.WARM, count = 3), - EmotionModel(name = Emotion.JOY, count = 3), - EmotionModel(name = Emotion.SAD, count = 3), - EmotionModel(name = Emotion.INSIGHT, count = 3), - ), - brandColor = ReedTheme.colors.contentBrand, - secondaryColor = ReedTheme.colors.contentSecondary, - emotionTextStyle = ReedTheme.typography.label2SemiBold, - regularTextStyle = ReedTheme.typography.label2Regular, - )?.let { annotatedString -> - Text( - text = annotatedString, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text(text = "모든 감정의 count가 0인 경우:") - EmotionAnalysisResultText( - emotions = persistentListOf( - EmotionModel(name = Emotion.WARM, count = 0), - EmotionModel(name = Emotion.JOY, count = 0), - EmotionModel(name = Emotion.SAD, count = 0), - ), - brandColor = ReedTheme.colors.contentBrand, - secondaryColor = ReedTheme.colors.contentSecondary, - emotionTextStyle = ReedTheme.typography.label2SemiBold, - regularTextStyle = ReedTheme.typography.label2Regular, - )?.let { annotatedString -> - Text( - text = annotatedString, - modifier = Modifier.padding(vertical = 8.dp), - ) - } ?: Text( - text = "null 반환 - 표시되지 않음", - modifier = Modifier.padding(vertical = 8.dp), - color = ReedTheme.colors.contentSecondary, - ) - } - } -} 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 7dfdefb26..d0cf80d0e 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 @@ -43,7 +43,7 @@ internal fun ReadingRecordsHeader( Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) Text( text = "$totalCount", - color = ReedTheme.colors.contentBrand, + color = ReedTheme.colors.contentTertiary, style = ReedTheme.typography.headline2SemiBold, ) } 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 4b0b8d3b7..04a90ee4d 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 @@ -1,6 +1,5 @@ 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 @@ -11,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -21,21 +18,20 @@ 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 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.PrimaryEmotionModel import com.ninecraft.booket.core.model.ReadingRecordModel -import com.ninecraft.booket.feature.detail.R -import kotlinx.collections.immutable.persistentListOf import com.ninecraft.booket.core.designsystem.R as designR @Composable internal fun RecordItem( recordInfo: ReadingRecordModel, + onRecordClick: () -> Unit, onRecordMenuClick: (ReadingRecordModel) -> Unit, modifier: Modifier = Modifier, ) { @@ -43,6 +39,7 @@ internal fun RecordItem( modifier = modifier .fillMaxSize() .clip(RoundedCornerShape(ReedTheme.radius.md)) + .clickable { onRecordClick() } .background(ReedTheme.colors.baseSecondary) .padding( start = ReedTheme.spacing.spacing5, @@ -55,19 +52,12 @@ internal fun RecordItem( 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]}", + text = if (recordInfo.pageNumber != null) "${recordInfo.pageNumber}p" + else "-p", color = ReedTheme.colors.contentBrand, - style = ReedTheme.typography.body1SemiBold, + style = ReedTheme.typography.label1Medium, + fontStyle = FontStyle.Italic, ) Spacer(modifier = Modifier.weight(1f)) Icon( @@ -81,7 +71,7 @@ internal fun RecordItem( tint = ReedTheme.colors.contentTertiary, ) } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) Text( text = "\"${recordInfo.quote}\"", color = ReedTheme.colors.contentSecondary, @@ -89,37 +79,26 @@ internal fun RecordItem( maxLines = 4, style = ReedTheme.typography.body2Medium, ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = recordInfo.createdAt.toFormattedDate(), + text = "#${recordInfo.primaryEmotion.displayName}", color = ReedTheme.colors.contentTertiary, style = ReedTheme.typography.label1Medium, ) Spacer(modifier = Modifier.weight(1f)) Text( - text = "${recordInfo.pageNumber}p", + text = recordInfo.createdAt.toFormattedDate(), color = ReedTheme.colors.contentTertiary, - style = ReedTheme.typography.body2Medium, - fontStyle = FontStyle.Italic, + style = ReedTheme.typography.label1Medium, ) } } } -fun getEmotionImageResourceByDisplayName(displayName: String): Int { - return when (displayName) { - "따뜻함" -> R.drawable.img_warm - "즐거움" -> R.drawable.img_joy - "슬픔" -> R.drawable.img_sad - "깨달음" -> R.drawable.img_insight - else -> R.drawable.img_warm - } -} - @ComponentPreview @Composable private fun RecordItemPreview() { @@ -127,10 +106,11 @@ private fun RecordItemPreview() { RecordItem( recordInfo = ReadingRecordModel( quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", - emotionTags = persistentListOf("따뜻함"), + primaryEmotion = PrimaryEmotionModel(displayName = "따뜻함"), pageNumber = 12, - createdAt = "2025.06.25", + createdAt = "2025-06-25T10:30:00.000000", ), + onRecordClick = {}, onRecordMenuClick = {}, ) } 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 ebeaf2a46..3f422be11 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 @@ -18,11 +18,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.bgColor +import com.ninecraft.booket.core.designsystem.graphicRes import com.ninecraft.booket.core.designsystem.textColor import com.ninecraft.booket.core.designsystem.theme.ReedTheme -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionCode import com.ninecraft.booket.core.model.EmotionModel -import com.ninecraft.booket.feature.detail.R @Composable internal fun SeedItem( @@ -34,7 +34,7 @@ internal fun SeedItem( horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = painterResource(id = getEmotionImageResource(emotion.name)), + painter = painterResource(id = emotion.code.graphicRes), contentDescription = "Seed Graphic Image", modifier = Modifier.size(50.dp), ) @@ -42,7 +42,7 @@ internal fun SeedItem( Box( modifier = Modifier .clip(RoundedCornerShape(ReedTheme.radius.full)) - .background(emotion.name.bgColor) + .background(emotion.code.bgColor) .padding( horizontal = ReedTheme.spacing.spacing2, vertical = ReedTheme.spacing.spacing1, @@ -50,8 +50,8 @@ internal fun SeedItem( contentAlignment = Alignment.Center, ) { Text( - text = emotion.name.displayName, - color = emotion.name.textColor, + text = emotion.code.displayName, + color = emotion.code.textColor, style = ReedTheme.typography.label2SemiBold, ) } @@ -64,22 +64,13 @@ internal fun SeedItem( } } -private fun getEmotionImageResource(emotion: Emotion): Int { - return when (emotion) { - Emotion.WARM -> R.drawable.img_warm - Emotion.JOY -> R.drawable.img_joy - Emotion.SAD -> R.drawable.img_sad - Emotion.INSIGHT -> R.drawable.img_insight - } -} - @ComponentPreview @Composable private fun SeedItemPreview() { ReedTheme { SeedItem( emotion = EmotionModel( - name = Emotion.WARM, + code = EmotionCode.WARMTH, count = 3, ), ) 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 index 1ab1de374..f7c0cb30f 100644 --- 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 @@ -11,17 +11,24 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject -class RecordCardPresenter @AssistedInject constructor( +@AssistedInject +class RecordCardPresenter( @Assisted private val screen: RecordCardScreen, @Assisted private val navigator: Navigator, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(RecordCardScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: RecordCardScreen, navigator: Navigator): RecordCardPresenter + } + companion object { private const val RECORD_CARD_SAVE = "record_card_save" private const val RECORD_CARD_SHARE = "record_card_share" @@ -74,7 +81,7 @@ class RecordCardPresenter @AssistedInject constructor( isLoading = isLoading, quote = screen.quote, bookTitle = screen.bookTitle, - emotion = screen.emotion, + emotionCode = screen.emotionCode, isCapturing = isCapturing, isSharing = isSharing, sideEffect = sideEffect, @@ -82,12 +89,3 @@ class RecordCardPresenter @AssistedInject constructor( ) } } - -@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/RecordCardUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/card/RecordCardUi.kt index 3e3813bec..006166d6d 100644 --- 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 @@ -38,11 +38,11 @@ import com.ninecraft.booket.feature.detail.card.component.RecordCard import com.ninecraft.booket.feature.screens.RecordCardScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition -@CircuitInject(RecordCardScreen::class, ActivityRetainedComponent::class) +@CircuitInject(RecordCardScreen::class, AppScope::class) @Composable internal fun RecordCardUi( state: RecordCardUiState, @@ -83,7 +83,7 @@ internal fun RecordCardUi( RecordCard( quote = state.quote, bookTitle = state.bookTitle, - emotion = state.emotion, + emotionCode = state.emotionCode, modifier = Modifier .padding(top = ReedTheme.spacing.spacing5) .clip(RoundedCornerShape(ReedTheme.radius.md)) 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 index 43c2c71a3..9b49e8e30 100644 --- 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 @@ -2,6 +2,7 @@ package com.ninecraft.booket.feature.detail.card import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.ImageBitmap +import com.ninecraft.booket.core.model.EmotionCode import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.util.UUID @@ -11,7 +12,7 @@ data class RecordCardUiState( val quote: String = "", val bookTitle: String = "", val author: String = "", - val emotion: String = "", + val emotionCode: EmotionCode = EmotionCode.OTHER, val isCapturing: Boolean = false, val isSharing: Boolean = false, val sideEffect: RecordCardSideEffect? = null, 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 index 4c055930b..823036021 100644 --- 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 @@ -20,19 +20,19 @@ 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.theme.ReedTheme -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionCode import com.ninecraft.booket.feature.detail.R @Composable internal fun RecordCard( quote: String, bookTitle: String, - emotion: String, + emotionCode: EmotionCode, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxWidth()) { Image( - painter = painterResource(getEmotionCardImage(emotion)), + painter = painterResource(getEmotionCardImage(emotionCode)), contentDescription = "Record Card Image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, @@ -74,13 +74,13 @@ internal fun RecordCard( } } -private fun getEmotionCardImage(emotion: String): Int { - return when (emotion) { - Emotion.WARM.displayName -> R.drawable.img_record_card_warm - Emotion.JOY.displayName -> R.drawable.img_record_card_joy - Emotion.SAD.displayName -> R.drawable.img_record_card_sad - Emotion.INSIGHT.displayName -> R.drawable.img_record_card_insight - else -> R.drawable.img_record_card_warm +private fun getEmotionCardImage(emotionCode: EmotionCode): Int { + return when (emotionCode) { + EmotionCode.WARMTH -> R.drawable.img_record_card_warmth + EmotionCode.JOY -> R.drawable.img_record_card_joy + EmotionCode.SADNESS -> R.drawable.img_record_card_sadness + EmotionCode.INSIGHT -> R.drawable.img_record_card_insight + EmotionCode.OTHER -> R.drawable.img_record_card_other } } @@ -91,7 +91,7 @@ private fun RecordCardPreview() { RecordCard( quote = "이 세상에 집이라 이름 붙일 수 없는 것이 있다면 그건 바로 여기, 내가 앉아 있는 이곳일 것이다.", bookTitle = "샤이닝", - emotion = Emotion.WARM.displayName, + emotionCode = EmotionCode.WARMTH, ) } } 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 fbc0723db..20e11826a 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 @@ -8,11 +8,13 @@ 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.core.model.ReadingRecordModel 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.DetailEmotionArg +import com.ninecraft.booket.feature.screens.arguments.PrimaryEmotionArg import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs import com.orhanobut.logger.Logger import com.skydoves.compose.effects.RememberedEffect @@ -21,19 +23,26 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch -class RecordDetailPresenter @AssistedInject constructor( +@AssistedInject +class RecordDetailPresenter( @Assisted private val screen: RecordDetailScreen, @Assisted private val navigator: Navigator, private val repository: RecordRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(RecordDetailScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: RecordDetailScreen, navigator: Navigator): RecordDetailPresenter + } + companion object { private const val RECORD_DELETE = "record_delete" private const val RECORD_DELETE_COMPLETE = "record_delete_complete" @@ -43,7 +52,7 @@ class RecordDetailPresenter @AssistedInject constructor( override fun present(): RecordDetailUiState { val scope = rememberCoroutineScope() var uiState by rememberRetained { mutableStateOf(UiState.Idle) } - var recordDetailInfo by rememberRetained { mutableStateOf(RecordDetailModel()) } + var recordDetailInfo by rememberRetained { mutableStateOf(ReadingRecordModel()) } var isRecordMenuBottomSheetVisible by rememberRetained { mutableStateOf(false) } var isRecordDeleteDialogVisible by rememberRetained { mutableStateOf(false) } var sideEffect by rememberRetained { mutableStateOf(null) } @@ -127,7 +136,7 @@ class RecordDetailPresenter @AssistedInject constructor( RecordCardScreen( quote = recordDetailInfo.quote, bookTitle = recordDetailInfo.bookTitle, - emotion = recordDetailInfo.emotionTags[0], + emotionCode = recordDetailInfo.primaryEmotion.code, ), ) } @@ -141,7 +150,16 @@ class RecordDetailPresenter @AssistedInject constructor( pageNumber = recordDetailInfo.pageNumber, quote = recordDetailInfo.quote, review = recordDetailInfo.review, - emotionTags = recordDetailInfo.emotionTags, + primaryEmotion = PrimaryEmotionArg( + code = recordDetailInfo.primaryEmotion.code, + displayName = recordDetailInfo.primaryEmotion.displayName, + ), + detailEmotions = recordDetailInfo.detailEmotions.map { + DetailEmotionArg( + id = it.id, + name = it.name, + ) + }, bookTitle = recordDetailInfo.bookTitle, bookPublisher = recordDetailInfo.bookPublisher, bookCoverImageUrl = recordDetailInfo.bookCoverImageUrl, @@ -186,13 +204,4 @@ class RecordDetailPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @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 8bedd731a..d33303107 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 @@ -19,7 +19,7 @@ import com.ninecraft.booket.core.designsystem.ComponentPreview 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.model.ReadingRecordModel import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.core.ui.component.ReedErrorUi @@ -33,13 +33,14 @@ import com.ninecraft.booket.feature.detail.record.component.ReviewItem import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition @OptIn(ExperimentalMaterial3Api::class) -@CircuitInject(RecordDetailScreen::class, ActivityRetainedComponent::class) +@CircuitInject(RecordDetailScreen::class, AppScope::class) @Composable internal fun RecordDetailUi( state: RecordDetailUiState, @@ -163,7 +164,8 @@ private fun RecordDetailContent( ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) ReviewItem( - emotion = state.recordDetailInfo.emotionTags.getOrNull(0) ?: "", + primaryEmotion = state.recordDetailInfo.primaryEmotion, + detailEmotions = state.recordDetailInfo.detailEmotions.toPersistentList(), createdAt = state.recordDetailInfo.createdAt, review = state.recordDetailInfo.review, ) @@ -187,13 +189,12 @@ private fun ReviewDetailPreview() { RecordDetailUi( state = RecordDetailUiState( uiState = UiState.Success, - recordDetailInfo = RecordDetailModel( + recordDetailInfo = ReadingRecordModel( id = "", userBookId = "", pageNumber = 90, quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", review = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다", - emotionTags = listOf("따뜻함"), createdAt = "2023.10.10", updatedAt = "", bookTitle = "여름은 오래 그곳에 남아", @@ -214,13 +215,12 @@ private fun ReviewDetailEmptyPreview() { RecordDetailUi( state = RecordDetailUiState( uiState = UiState.Success, - recordDetailInfo = RecordDetailModel( + recordDetailInfo = ReadingRecordModel( id = "", userBookId = "", pageNumber = 90, quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", review = "", - emotionTags = listOf("따뜻함"), createdAt = "2023.10.10", updatedAt = "", bookTitle = "여름은 오래 그곳에 남아", 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 07ff909a8..4d845ea22 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 @@ -1,7 +1,7 @@ package com.ninecraft.booket.feature.detail.record import androidx.compose.runtime.Immutable -import com.ninecraft.booket.core.model.RecordDetailModel +import com.ninecraft.booket.core.model.ReadingRecordModel import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.util.UUID @@ -16,7 +16,7 @@ sealed interface UiState { data class RecordDetailUiState( val uiState: UiState = UiState.Idle, - val recordDetailInfo: RecordDetailModel = RecordDetailModel(), + val recordDetailInfo: ReadingRecordModel = ReadingRecordModel(), val isRecordMenuBottomSheetVisible: Boolean = false, val isRecordDeleteDialogVisible: Boolean = false, val sideEffect: RecordDetailSideEffect? = null, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt index 2e1158f1a..02787b607 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteItem.kt @@ -9,6 +9,7 @@ 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.draw.clip import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import com.ninecraft.booket.core.designsystem.ComponentPreview @@ -17,16 +18,16 @@ import com.ninecraft.booket.core.designsystem.theme.ReedTheme @Composable internal fun QuoteItem( quote: String, - page: Int, + page: Int?, modifier: Modifier = Modifier, ) { + val pageNumber = page?.toString() ?: "-" + Box( modifier = modifier .fillMaxWidth() - .background( - color = ReedTheme.colors.baseSecondary, - shape = RoundedCornerShape(ReedTheme.radius.md), - ) + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(color = ReedTheme.colors.baseSecondary) .padding( horizontal = ReedTheme.spacing.spacing5, vertical = ReedTheme.spacing.spacing4, @@ -39,7 +40,7 @@ internal fun QuoteItem( style = ReedTheme.typography.label1Medium, ) Text( - text = "${page}p", + text = "${pageNumber}p", modifier = Modifier.fillMaxWidth(), color = ReedTheme.colors.contentBrand, textAlign = TextAlign.End, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt index 6a1c14db7..d34a136e8 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewItem.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -19,13 +20,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import com.ninecraft.booket.core.common.extensions.toFormattedDate import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.graphicRes import com.ninecraft.booket.core.designsystem.theme.ReedTheme -import com.ninecraft.booket.feature.detail.book.component.getEmotionImageResourceByDisplayName +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.PrimaryEmotionModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable internal fun ReviewItem( - emotion: String, + primaryEmotion: PrimaryEmotionModel, + detailEmotions: ImmutableList, createdAt: String, review: String, modifier: Modifier = Modifier, @@ -33,73 +41,178 @@ internal fun ReviewItem( Box( modifier = modifier .fillMaxWidth() - .background( - color = ReedTheme.colors.baseSecondary, - shape = RoundedCornerShape(ReedTheme.radius.md), - ) + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(color = ReedTheme.colors.baseSecondary) .padding( horizontal = ReedTheme.spacing.spacing4, vertical = ReedTheme.spacing.spacing4, ), ) { Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - painter = painterResource(getEmotionImageResourceByDisplayName(emotion)), - contentDescription = "Emotion Graphic", - modifier = Modifier - .size(ReedTheme.spacing.spacing10) - .clip(CircleShape) - .background(ReedTheme.colors.basePrimary), - ) - Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) - Text( - text = "#$emotion", - color = ReedTheme.colors.contentBrand, - style = ReedTheme.typography.body2Medium, - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = createdAt, - color = ReedTheme.colors.contentTertiary, - style = ReedTheme.typography.label2Regular, - ) - } if (review.isNotBlank()) { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) Text( text = review, - color = ReedTheme.colors.contentSecondary, + color = ReedTheme.colors.contentPrimary, style = ReedTheme.typography.label1Medium, ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) } + EmotionContent(primaryEmotion, detailEmotions, createdAt) } } } +@Composable +private fun EmotionContent( + primaryEmotion: PrimaryEmotionModel, + detailEmotions: ImmutableList, + createdAt: String, +) { + val hasDetailEmotion = detailEmotions.isNotEmpty() + val primaryEmotionBackgroundColor = if (primaryEmotion.code == EmotionCode.OTHER) ReedTheme.colors.bgDisabled else ReedTheme.colors.bgTertiary + val primaryEmotionTextColor = if (primaryEmotion.code == EmotionCode.OTHER) ReedTheme.colors.contentTertiary else ReedTheme.colors.contentBrand + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(primaryEmotion.code.graphicRes), + contentDescription = "Emotion Graphic", + modifier = Modifier + .size(ReedTheme.spacing.spacing10) + .clip(CircleShape) + .background(ReedTheme.colors.basePrimary), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = primaryEmotion.displayName, + modifier = Modifier + .background( + color = primaryEmotionBackgroundColor, + shape = RoundedCornerShape(ReedTheme.radius.full), + ) + .padding( + horizontal = ReedTheme.spacing.spacing2, + vertical = ReedTheme.spacing.spacing1, + ), + color = primaryEmotionTextColor, + style = ReedTheme.typography.label2SemiBold, + ) + + if (hasDetailEmotion) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + FlowRow { + detailEmotions.forEach { detail -> + Text( + text = "#${detail.name}", + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.caption1Regular, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + } + } + } + } + Text( + text = createdAt.toFormattedDate(), + modifier = Modifier.align( + if (hasDetailEmotion) Alignment.Bottom else Alignment.CenterVertically, + ), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label2Regular, + ) + } +} + @ComponentPreview @Composable -private fun ReviewBoxPreview() { +private fun ReviewItemPreview() { + val primaryEmotion = PrimaryEmotionModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + ) + + val detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + DetailEmotionModel( + id = "84f95f13-e54c-11f0-8545-525ae7dd628c", + name = "다정한", + ), + DetailEmotionModel( + id = "84f95fc0-e54c-11f0-8545-525ae7dd628c", + name = "고마운", + ), + DetailEmotionModel( + id = "84f96094-e54c-11f0-8545-525ae7dd628c", + name = "마음이 놓이는", + ), + DetailEmotionModel( + id = "84f9612c-e54c-11f0-8545-525ae7dd628c", + name = "편안한", + ), + ) + ReedTheme { ReviewItem( - emotion = "따뜻함", + primaryEmotion = primaryEmotion, + detailEmotions = detailEmotions, review = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다", - createdAt = "2025.06.25", + createdAt = "2026-01-08T15:31:36.113488", + ) + } +} + +@ComponentPreview +@Composable +private fun EmptyReviewItemPreview() { + val primaryEmotion = PrimaryEmotionModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + ) + + val detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + ) + + ReedTheme { + ReviewItem( + primaryEmotion = primaryEmotion, + detailEmotions = detailEmotions, + review = "", + createdAt = "2026-01-08T15:31:36.113488", ) } } @ComponentPreview @Composable -private fun ReviewBoxEmptyPreview() { +private fun EmptyDetailEmotionsReviewItemPreview() { + val primaryEmotion = PrimaryEmotionModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + ) ReedTheme { ReviewItem( - emotion = "따뜻함", + primaryEmotion = primaryEmotion, + detailEmotions = persistentListOf(), review = "", - createdAt = "2025.06.25", + createdAt = "2026-01-08T15:31:36.113488", ) } } diff --git a/feature/detail/src/main/res/drawable/img_insight.webp b/feature/detail/src/main/res/drawable/img_insight.webp deleted file mode 100644 index 77d816fdb..000000000 Binary files a/feature/detail/src/main/res/drawable/img_insight.webp and /dev/null differ diff --git a/feature/detail/src/main/res/drawable/img_joy.webp b/feature/detail/src/main/res/drawable/img_joy.webp deleted file mode 100644 index 00414f16c..000000000 Binary files a/feature/detail/src/main/res/drawable/img_joy.webp and /dev/null differ diff --git a/feature/detail/src/main/res/drawable/img_record_card_other.webp b/feature/detail/src/main/res/drawable/img_record_card_other.webp new file mode 100644 index 000000000..b5d141255 Binary files /dev/null and b/feature/detail/src/main/res/drawable/img_record_card_other.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_sadness.webp similarity index 100% rename from feature/detail/src/main/res/drawable/img_record_card_sad.webp rename to feature/detail/src/main/res/drawable/img_record_card_sadness.webp diff --git a/feature/detail/src/main/res/drawable/img_record_card_warm.webp b/feature/detail/src/main/res/drawable/img_record_card_warmth.webp similarity index 100% rename from feature/detail/src/main/res/drawable/img_record_card_warm.webp rename to feature/detail/src/main/res/drawable/img_record_card_warmth.webp diff --git a/feature/detail/src/main/res/drawable/img_sad.webp b/feature/detail/src/main/res/drawable/img_sad.webp deleted file mode 100644 index 439e3224e..000000000 Binary files a/feature/detail/src/main/res/drawable/img_sad.webp and /dev/null differ diff --git a/feature/detail/src/main/res/drawable/img_warm.webp b/feature/detail/src/main/res/drawable/img_warm.webp deleted file mode 100644 index a432f9e6f..000000000 Binary files a/feature/detail/src/main/res/drawable/img_warm.webp and /dev/null differ diff --git a/feature/detail/src/main/res/values/strings.xml b/feature/detail/src/main/res/values/strings.xml index 498897112..072c0b758 100644 --- a/feature/detail/src/main/res/values/strings.xml +++ b/feature/detail/src/main/res/values/strings.xml @@ -8,6 +8,9 @@ 정렬 내 기록 모음 내가 모은 씨앗 + 감정을 많이 느꼈어요 + 여러 + 감정을 느꼈어요 첫 기록을 남겨 보세요!\n나만의 아카이브를 만들 수 있어요. 독서 기록 추가 공유하기 diff --git a/feature/detail/stability/detail.stability b/feature/detail/stability/detail.stability index 7b658259a..3d59a3b15 100644 --- a/feature/detail/stability/detail.stability +++ b/feature/detail/stability/detail.stability @@ -9,7 +9,7 @@ internal fun com.ninecraft.booket.feature.detail.book.BookDetailContent(state: c skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - innerPadding: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) - lazyListState: STABLE (marked @Stable or @Immutable) @@ -20,18 +20,12 @@ public fun com.ninecraft.booket.feature.detail.book.BookDetailPresenter.present( restartable: true params: -@Composable -private fun com.ninecraft.booket.feature.detail.book.BookDetailPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.detail.book.BookDetailUi(state: com.ninecraft.booket.feature.detail.book.BookDetailUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -39,7 +33,7 @@ internal fun com.ninecraft.booket.feature.detail.book.HandleBookDetailSideEffect skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - eventSink: STABLE (function type) @Composable @@ -50,18 +44,12 @@ internal fun com.ninecraft.booket.feature.detail.book.component.BookItem(bookDet - bookDetail: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.detail.book.component.BookItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.feature.detail.book.component.BookStatusItem(item: com.ninecraft.booket.core.common.constants.BookStatus, selected: kotlin.Boolean, onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - item: STABLE + - item: STABLE (class with no mutable properties) - selected: STABLE (primitive type) - onClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @@ -75,36 +63,31 @@ internal fun com.ninecraft.booket.feature.detail.book.component.BookUpdateBottom - sheetState: STABLE (marked @Stable or @Immutable) - onCloseButtonClick: STABLE (function type) - bookStatuses: STABLE (known stable type) - - currentBookStatus: STABLE - - selectedBookStatus: STABLE + - currentBookStatus: STABLE (class with no mutable properties) + - selectedBookStatus: STABLE (class with no mutable properties) - onItemSelected: STABLE (function type) - onBookUpdateButtonClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.detail.book.component.BookUpdateBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.detail.book.component.ChoiceBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.detail.book.component.CollectedSeedPreview(): kotlin.Unit +internal fun com.ninecraft.booket.feature.detail.book.component.CollectedSeeds(seedsStats: kotlinx.collections.immutable.ImmutableList, representativeEmotion: com.ninecraft.booket.core.model.PrimaryEmotionModel, isStatsExpanded: kotlin.Boolean, onToggleClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: + - seedsStats: STABLE (known stable type) + - representativeEmotion: STABLE (marked @Stable or @Immutable) + - isStatsExpanded: STABLE (primitive type) + - onToggleClick: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.ninecraft.booket.feature.detail.book.component.CollectedSeeds(seedsStats: kotlinx.collections.immutable.ImmutableList, modifier: androidx.compose.ui.Modifier): kotlin.Unit +private fun com.ninecraft.booket.feature.detail.book.component.CollectedSeedsHeader(primaryEmotion: com.ninecraft.booket.core.model.PrimaryEmotionModel, isStatsExpanded: kotlin.Boolean, onToggleClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - seedsStats: STABLE (known stable type) + - primaryEmotion: STABLE (marked @Stable or @Immutable) + - isStatsExpanded: STABLE (primitive type) + - onToggleClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -130,21 +113,20 @@ private fun com.ninecraft.booket.feature.detail.book.component.DetailMenuItem(ic - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.ninecraft.booket.feature.detail.book.component.EmotionAnalysisResultText(emotions: kotlinx.collections.immutable.ImmutableList, brandColor: androidx.compose.ui.graphics.Color, secondaryColor: androidx.compose.ui.graphics.Color, emotionTextStyle: androidx.compose.ui.text.TextStyle, regularTextStyle: androidx.compose.ui.text.TextStyle): androidx.compose.ui.text.AnnotatedString? +private fun com.ninecraft.booket.feature.detail.book.component.EmotionRatioBar(seedsStats: kotlinx.collections.immutable.ImmutableList, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - emotions: STABLE (known stable type) - - brandColor: STABLE (marked @Stable or @Immutable) - - secondaryColor: STABLE (marked @Stable or @Immutable) - - emotionTextStyle: STABLE (marked @Stable or @Immutable) - - regularTextStyle: STABLE (marked @Stable or @Immutable) + - seedsStats: STABLE (known stable type) + - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.detail.book.component.EmotionTextAllCasesPreview(): kotlin.Unit +private fun com.ninecraft.booket.feature.detail.book.component.EmotionStatCard(emotion: com.ninecraft.booket.core.model.EmotionModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: + - emotion: STABLE (marked @Stable or @Immutable) + - modifier: STABLE (marked @Stable or @Immutable) @Composable internal fun com.ninecraft.booket.feature.detail.book.component.ReadingRecordsHeader(totalCount: kotlin.Int, currentRecordSort: com.ninecraft.booket.feature.detail.book.RecordSort, onReadingRecordClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -152,31 +134,20 @@ internal fun com.ninecraft.booket.feature.detail.book.component.ReadingRecordsHe restartable: true params: - totalCount: STABLE (primitive type) - - currentRecordSort: STABLE + - currentRecordSort: STABLE (class with no mutable properties) - onReadingRecordClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.detail.book.component.ReadingRecordsHeaderPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.detail.book.component.RecordItem(recordInfo: com.ninecraft.booket.core.model.ReadingRecordModel, onRecordMenuClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.ninecraft.booket.feature.detail.book.component.RecordItem(recordInfo: com.ninecraft.booket.core.model.ReadingRecordModel, onRecordClick: kotlin.Function0, onRecordMenuClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - recordInfo: STABLE (marked @Stable or @Immutable) + - onRecordClick: STABLE (function type) - onRecordMenuClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.detail.book.component.RecordItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.detail.book.component.RecordSortBottomSheet(onDismissRequest: kotlin.Function0, sheetState: androidx.compose.material3.SheetState, onCloseButtonClick: kotlin.Function0, recordSortItems: kotlinx.collections.immutable.ImmutableList, currentRecordSort: com.ninecraft.booket.feature.detail.book.RecordSort, onItemSelected: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -186,22 +157,16 @@ internal fun com.ninecraft.booket.feature.detail.book.component.RecordSortBottom - sheetState: STABLE (marked @Stable or @Immutable) - onCloseButtonClick: STABLE (function type) - recordSortItems: STABLE (known stable type) - - currentRecordSort: STABLE + - currentRecordSort: STABLE (class with no mutable properties) - onItemSelected: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.detail.book.component.RecordSortBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.feature.detail.book.component.RecordSortItem(item: com.ninecraft.booket.feature.detail.book.RecordSort, selected: kotlin.Boolean, onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - item: STABLE + - item: STABLE (class with no mutable properties) - selected: STABLE (primitive type) - onClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @@ -214,18 +179,12 @@ internal fun com.ninecraft.booket.feature.detail.book.component.SeedItem(emotion - emotion: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.detail.book.component.SeedItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.detail.card.HandleRecordCardSideEffects(state: com.ninecraft.booket.feature.detail.card.RecordCardUiState, recordCardGraphicsLayer: androidx.compose.ui.graphics.layer.GraphicsLayer, eventSink: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - recordCardGraphicsLayer: UNSTABLE (has mutable properties or unstable members) - eventSink: STABLE (function type) @@ -240,44 +199,32 @@ internal fun com.ninecraft.booket.feature.detail.card.RecordCardUi(state: com.ni skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.detail.card.RecordCardUiPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.detail.card.component.RecordCard(quote: kotlin.String, bookTitle: kotlin.String, emotionTag: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.ninecraft.booket.feature.detail.card.component.RecordCard(quote: kotlin.String, bookTitle: kotlin.String, emotionCode: com.ninecraft.booket.core.model.EmotionCode, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - quote: STABLE (String is immutable) - bookTitle: STABLE (String is immutable) - - emotionTag: STABLE (String is immutable) + - emotionCode: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.detail.card.component.RecordCardPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.detail.record.HandleRecordDetailSideEffects(state: com.ninecraft.booket.feature.detail.record.RecordDetailUiState): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) @Composable private fun com.ninecraft.booket.feature.detail.record.RecordDetailContent(state: com.ninecraft.booket.feature.detail.record.RecordDetailUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -291,21 +238,9 @@ internal fun com.ninecraft.booket.feature.detail.record.RecordDetailUi(state: co skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.detail.record.ReviewDetailEmptyPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.detail.record.ReviewDetailPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.detail.record.component.BookItem(imageUrl: kotlin.String, bookTitle: kotlin.String, author: kotlin.String, publisher: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -318,30 +253,21 @@ internal fun com.ninecraft.booket.feature.detail.record.component.BookItem(image - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.detail.record.component.BookItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.detail.record.component.ChoiceBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.detail.record.component.QuoteBoxPreview(): kotlin.Unit +private fun com.ninecraft.booket.feature.detail.record.component.EmotionContent(primaryEmotion: com.ninecraft.booket.core.model.PrimaryEmotionModel, detailEmotions: kotlinx.collections.immutable.ImmutableList, createdAt: kotlin.String): kotlin.Unit skippable: true restartable: true params: + - primaryEmotion: STABLE (marked @Stable or @Immutable) + - detailEmotions: STABLE (known stable type) + - createdAt: STABLE (String is immutable) @Composable -internal fun com.ninecraft.booket.feature.detail.record.component.QuoteItem(quote: kotlin.String, page: kotlin.Int, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.ninecraft.booket.feature.detail.record.component.QuoteItem(quote: kotlin.String, page: kotlin.Int?, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - quote: STABLE (String is immutable) - - page: STABLE (primitive type) + - page: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -369,23 +295,12 @@ private fun com.ninecraft.booket.feature.detail.record.component.RecordMenuItem( - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.detail.record.component.ReviewBoxEmptyPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.detail.record.component.ReviewBoxPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.detail.record.component.ReviewItem(emotion: kotlin.String, createdAt: kotlin.String, review: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.ninecraft.booket.feature.detail.record.component.ReviewItem(primaryEmotion: com.ninecraft.booket.core.model.PrimaryEmotionModel, detailEmotions: kotlinx.collections.immutable.ImmutableList, createdAt: kotlin.String, review: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - emotion: STABLE (String is immutable) + - primaryEmotion: STABLE (marked @Stable or @Immutable) + - detailEmotions: STABLE (known stable type) - createdAt: STABLE (String is immutable) - review: STABLE (String is immutable) - modifier: STABLE (marked @Stable or @Immutable) diff --git a/feature/edit/build.gradle.kts b/feature/edit/build.gradle.kts index 092b48492..36b5e63c3 100644 --- a/feature/edit/build.gradle.kts +++ b/feature/edit/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.booket.kotlin.library.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.edit" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.kotlinx.collections.immutable, 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 index cf1326718..f24d41ee4 100644 --- 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 @@ -1,34 +1,102 @@ package com.ninecraft.booket.feature.edit.emotion import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.model.Emotion +import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.data.api.repository.EmotionRepository +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel import com.ninecraft.booket.feature.screens.EmotionEditScreen +import com.ninecraft.booket.feature.screens.EmotionEditScreen.Result +import com.ninecraft.booket.feature.screens.LoginScreen +import com.ninecraft.booket.feature.screens.arguments.DetailEmotionArg +import com.ninecraft.booket.feature.screens.arguments.PrimaryEmotionArg +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 dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch -class EmotionEditPresenter @AssistedInject constructor( +@AssistedInject +class EmotionEditPresenter( @Assisted private val screen: EmotionEditScreen, @Assisted private val navigator: Navigator, + private val emotionRepository: EmotionRepository, ) : Presenter { + + @CircuitInject(EmotionEditScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: EmotionEditScreen, navigator: Navigator): EmotionEditPresenter + } + @Composable override fun present(): EmotionEditUiState { - var selectedEmotion by rememberRetained { mutableStateOf(screen.emotion) } - val emotions by rememberRetained { mutableStateOf(Emotion.entries.toPersistentList()) } + val scope = rememberCoroutineScope() + var emotionUiState by rememberRetained { mutableStateOf(EmotionUiState.Idle) } + var emotionGroups by rememberRetained { mutableStateOf(persistentListOf()) } + var selectedEmotionCode by rememberRetained { mutableStateOf(null) } + var selectedEmotionMap by rememberRetained { mutableStateOf>>(persistentMapOf()) } + var committedEmotionCode by rememberRetained { mutableStateOf(null) } + var committedEmotionMap by rememberRetained { mutableStateOf>>(persistentMapOf()) } + var isEmotionDetailBottomSheetVisible by rememberRetained { mutableStateOf(false) } val isEditButtonEnabled by remember { derivedStateOf { - selectedEmotion != screen.emotion + val originalEmotionCode = screen.primaryEmotionCode + val originalDetailIds = screen.detailEmotionIds.toSet() + + val currentEmotionCode = committedEmotionCode + val currentDetailIds = committedEmotionMap[currentEmotionCode].orEmpty().toSet() + + val isPrimaryEmotionChanged = originalEmotionCode != currentEmotionCode + val isDetailEmotionChanged = originalDetailIds != currentDetailIds + + isPrimaryEmotionChanged || isDetailEmotionChanged + } + } + + fun getEmotionGroups() { + scope.launch { + emotionUiState = EmotionUiState.Loading + emotionRepository.getEmotions() + .onSuccess { result -> + emotionUiState = EmotionUiState.Success + emotionGroups = result.emotions.toPersistentList() + selectedEmotionCode = screen.primaryEmotionCode + selectedEmotionMap = persistentMapOf(screen.primaryEmotionCode to screen.detailEmotionIds.toPersistentList()) + committedEmotionCode = screen.primaryEmotionCode + committedEmotionMap = persistentMapOf(screen.primaryEmotionCode to screen.detailEmotionIds.toPersistentList()) + }.onFailure { exception -> + emotionUiState = EmotionUiState.Error(exception) + + val handleErrorMessage = { message: String -> + Logger.e(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen()) + }, + ) + } } } @@ -38,30 +106,118 @@ class EmotionEditPresenter @AssistedInject constructor( navigator.pop() } - is EmotionEditUiEvent.OnSelectEmotion -> { - selectedEmotion = event.emotion + is EmotionEditUiEvent.OnEditButtonClick -> { + val committedCode = committedEmotionCode ?: EmotionCode.OTHER + val committedDetailIds = committedEmotionMap[committedCode].orEmpty() + + val primaryEmotionArg = emotionGroups.firstOrNull { it.code == committedCode } + ?.let { + PrimaryEmotionArg( + code = it.code, + displayName = it.displayName, + ) + } + ?: PrimaryEmotionArg( + code = EmotionCode.OTHER, + displayName = "기타", + ) + + val detailEmotionArgs = + emotionGroups + .firstOrNull { it.code == committedCode } + ?.detailEmotions + ?.filter { it.id in committedDetailIds } + ?.map { + DetailEmotionArg( + id = it.id, + name = it.name, + ) + } + .orEmpty() + + navigator.pop( + result = Result( + primaryEmotion = primaryEmotionArg, + detailEmotions = detailEmotionArgs, + ), + ) } - is EmotionEditUiEvent.OnEditButtonClick -> { - navigator.pop(result = EmotionEditScreen.Result(selectedEmotion)) + is EmotionEditUiEvent.OnSelectEmotionCode -> { + selectedEmotionCode = event.emotionCode + + if (selectedEmotionCode == EmotionCode.OTHER) { + committedEmotionCode = selectedEmotionCode + committedEmotionMap = persistentMapOf() + selectedEmotionMap = persistentMapOf() + } else { + isEmotionDetailBottomSheetVisible = true + } + } + + is EmotionEditUiEvent.OnEmotionDetailToggled -> { + val emotionKey = selectedEmotionCode ?: return + val currentDetails = selectedEmotionMap[selectedEmotionCode].orEmpty() + val updatedDetails = if (event.detailId in currentDetails) { + currentDetails - event.detailId + } else { + currentDetails + event.detailId + } + + selectedEmotionMap = selectedEmotionMap.put(emotionKey, updatedDetails.toPersistentList()) + } + + is EmotionEditUiEvent.OnEmotionDetailRemoved -> { + val emotionKey = selectedEmotionCode ?: return + val currentDetails = committedEmotionMap[selectedEmotionCode].orEmpty() + val updatedDetails = currentDetails - event.detailId + + committedEmotionMap = committedEmotionMap.put(emotionKey, updatedDetails.toPersistentList()) + selectedEmotionMap = selectedEmotionMap.put(emotionKey, updatedDetails.toPersistentList()) + } + + is EmotionEditUiEvent.OnEmotionDetailCommitted -> { + val emotionKey = selectedEmotionCode ?: return + val details = selectedEmotionMap[emotionKey] ?: persistentListOf() + + committedEmotionCode = emotionKey + committedEmotionMap = persistentMapOf(emotionKey to details) + selectedEmotionMap = persistentMapOf(emotionKey to details) + isEmotionDetailBottomSheetVisible = false + } + + is EmotionEditUiEvent.OnEmotionDetailSkipped -> { + committedEmotionCode = selectedEmotionCode + // 건너뛰기 시 세부감정 선택 초기화 + committedEmotionMap = persistentMapOf() + selectedEmotionMap = persistentMapOf() + isEmotionDetailBottomSheetVisible = false + } + + is EmotionEditUiEvent.OnEmotionDetailBottomSheetDismiss -> { + isEmotionDetailBottomSheetVisible = false + } + + EmotionEditUiEvent.OnRetryGetEmotions -> { + getEmotionGroups() } } } + LaunchedEffect(Unit) { + getEmotionGroups() + } + return EmotionEditUiState( - selectedEmotion = selectedEmotion, - emotions = emotions, + emotionUiState = emotionUiState, + emotionGroups = emotionGroups, + selectedEmotionCode = selectedEmotionCode, + selectedEmotionMap = selectedEmotionMap, + committedEmotionCode = committedEmotionCode, + committedEmotionMap = committedEmotionMap, + isEmotionDetailBottomSheetVisible = isEmotionDetailBottomSheetVisible, 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 index d425dba8a..7915db93a 100644 --- 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 @@ -1,9 +1,6 @@ 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 @@ -11,39 +8,40 @@ 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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope 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.common.extensions.toErrorType 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.graphicRes import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White -import com.ninecraft.booket.core.model.Emotion import com.ninecraft.booket.core.ui.ReedScaffold 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.edit.R +import com.ninecraft.booket.feature.edit.emotion.component.EmotionDetailBottomSheet +import com.ninecraft.booket.feature.edit.emotion.component.EmotionItem import com.ninecraft.booket.feature.screens.EmotionEditScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent -import kotlinx.collections.immutable.toPersistentList +import dev.zacsweers.metro.AppScope +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch @TraceRecomposition -@CircuitInject(EmotionEditScreen::class, ActivityRetainedComponent::class) +@CircuitInject(EmotionEditScreen::class, AppScope::class) @Composable internal fun EmotionEditUi( state: EmotionEditUiState, @@ -63,56 +61,88 @@ internal fun EmotionEditUi( state.eventSink(EmotionEditUiEvent.OnBackClick) }, ) - EmotionEditContent(state = state) + when (state.emotionUiState) { + is EmotionUiState.Idle -> {} + is EmotionUiState.Loading -> { + ReedLoadingIndicator() + } + + is EmotionUiState.Success -> { + EmotionEditContent(state = state) + } + + is EmotionUiState.Error -> { + ReedErrorUi( + errorType = state.emotionUiState.exception.toErrorType(), + onRetryClick = { state.eventSink(EmotionEditUiEvent.OnRetryGetEmotions) }, + ) + } + } } } } +@OptIn(ExperimentalMaterial3Api::class) @TraceRecomposition @Composable private fun EmotionEditContent( state: EmotionEditUiState, modifier: Modifier = Modifier, ) { - Column( + val emotionDetailBottomSheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + Box( modifier = modifier .fillMaxSize() - .padding( - start = ReedTheme.spacing.spacing5, - top = ReedTheme.spacing.spacing4, - end = ReedTheme.spacing.spacing5, - ), + .background(color = White), ) { - 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.emotions) { tag -> - EmotionItem( - emotion = tag, - onClick = { - state.eventSink(EmotionEditUiEvent.OnSelectEmotion(tag.displayName)) - }, - isSelected = state.selectedEmotion == tag.displayName, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = ReedTheme.spacing.spacing5) + .padding(bottom = 80.dp), + ) { + item { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } + item { + Text( + text = stringResource(R.string.edit_emotion_title), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.heading1Bold, + ) + } + item { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + } + item { + Text( + text = stringResource(R.string.edit_emotion_description), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, + ) + } + item { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + } + + items(state.emotionGroups) { emotion -> + EmotionItem( + emotionGroup = emotion, + selectedEmotionDetailIds = state.committedEmotionMap[emotion.code] ?: persistentListOf(), + onClick = { + state.eventSink(EmotionEditUiEvent.OnSelectEmotionCode(emotion.code)) + }, + isSelected = state.committedEmotionCode == emotion.code, + onEmotionDetailRemove = { detail -> + state.eventSink(EmotionEditUiEvent.OnEmotionDetailRemoved(detail)) + }, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + } + } + ReedButton( onClick = { state.eventSink(EmotionEditUiEvent.OnEditButtonClick) @@ -121,46 +151,44 @@ private fun EmotionEditContent( sizeStyle = largeButtonStyle, modifier = Modifier .fillMaxWidth() - .padding(vertical = ReedTheme.spacing.spacing4), + .align(Alignment.BottomCenter) + .padding(horizontal = ReedTheme.spacing.spacing5) + .padding(bottom = ReedTheme.spacing.spacing4), enabled = state.isEditButtonEnabled, text = stringResource(R.string.edit_emotion_edit), ) } -} -@Composable -private fun EmotionItem( - emotion: Emotion, - 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() + if (state.isEmotionDetailBottomSheetVisible) { + val selectedEmotionGroup = state.emotionGroups.firstOrNull { it.code == state.selectedEmotionCode } ?: return + EmotionDetailBottomSheet( + emotionGroup = selectedEmotionGroup, + selectedEmotionDetailIds = state.selectedEmotionMap[state.selectedEmotionCode] ?: persistentListOf(), + onDismissRequest = { + state.eventSink(EmotionEditUiEvent.OnEmotionDetailBottomSheetDismiss) + }, + sheetState = emotionDetailBottomSheetState, + onCloseButtonClick = { + coroutineScope.launch { + emotionDetailBottomSheetState.hide() + state.eventSink(EmotionEditUiEvent.OnEmotionDetailBottomSheetDismiss) + } + }, + onEmotionDetailToggled = { detail -> + state.eventSink(EmotionEditUiEvent.OnEmotionDetailToggled(detail)) + }, + onSkipButtonClick = { + coroutineScope.launch { + emotionDetailBottomSheetState.hide() + state.eventSink(EmotionEditUiEvent.OnEmotionDetailSkipped) + } + }, + onConfirmButtonClick = { + coroutineScope.launch { + emotionDetailBottomSheetState.hide() + state.eventSink(EmotionEditUiEvent.OnEmotionDetailCommitted) + } }, - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(emotion.graphicRes), - contentDescription = "Emotion Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, ) } } @@ -169,11 +197,8 @@ private fun EmotionItem( @Composable private fun EmotionEditUiPreview() { ReedTheme { - val emotions = Emotion.entries.toPersistentList() - EmotionEditUi( state = EmotionEditUiState( - emotions = emotions, 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 index 4a4bb3998..18390fe7c 100644 --- 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 @@ -1,20 +1,43 @@ package com.ninecraft.booket.feature.edit.emotion -import com.ninecraft.booket.core.model.Emotion +import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +@Immutable +sealed interface EmotionUiState { + data object Idle : EmotionUiState + data object Loading : EmotionUiState + data object Success : EmotionUiState + data class Error(val exception: Throwable) : EmotionUiState +} data class EmotionEditUiState( - val selectedEmotion: String = "", + val emotionUiState: EmotionUiState = EmotionUiState.Idle, val isEditButtonEnabled: Boolean = false, - val emotions: ImmutableList = persistentListOf(), + val emotionGroups: ImmutableList = persistentListOf(), + val selectedEmotionCode: EmotionCode? = null, + val selectedEmotionMap: PersistentMap> = persistentMapOf(), + val committedEmotionCode: EmotionCode? = null, + val committedEmotionMap: PersistentMap> = persistentMapOf(), + val isEmotionDetailBottomSheetVisible: Boolean = false, val eventSink: (EmotionEditUiEvent) -> Unit, ) : CircuitUiState sealed interface EmotionEditUiEvent : CircuitUiEvent { data object OnBackClick : EmotionEditUiEvent - data class OnSelectEmotion(val emotion: String) : EmotionEditUiEvent + data class OnSelectEmotionCode(val emotionCode: EmotionCode) : EmotionEditUiEvent + data class OnEmotionDetailToggled(val detailId: String) : EmotionEditUiEvent + data class OnEmotionDetailRemoved(val detailId: String) : EmotionEditUiEvent + data object OnEmotionDetailCommitted : EmotionEditUiEvent + data object OnEmotionDetailSkipped : EmotionEditUiEvent + data object OnEmotionDetailBottomSheetDismiss : EmotionEditUiEvent data object OnEditButtonClick : EmotionEditUiEvent + data object OnRetryGetEmotions : EmotionEditUiEvent } diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/component/EmotionDetailBottomSheet.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/component/EmotionDetailBottomSheet.kt new file mode 100644 index 000000000..5a208ddf5 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/component/EmotionDetailBottomSheet.kt @@ -0,0 +1,202 @@ +package com.ninecraft.booket.feature.edit.emotion.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.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.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.ninecraft.booket.core.common.extensions.clickableSingle +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.chip.ReedSelectableChip +import com.ninecraft.booket.core.designsystem.component.chip.mediumChipStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel +import com.ninecraft.booket.core.ui.component.ReedBottomSheet +import com.ninecraft.booket.feature.edit.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import com.ninecraft.booket.core.designsystem.R as designR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EmotionDetailBottomSheet( + emotionGroup: EmotionGroupModel, + selectedEmotionDetailIds: ImmutableList, + onDismissRequest: () -> Unit, + sheetState: SheetState, + onCloseButtonClick: () -> Unit, + onEmotionDetailToggled: (String) -> Unit, + onSkipButtonClick: () -> Unit, + onConfirmButtonClick: () -> Unit, +) { + ReedBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.edit_emotion_detail_title, emotionGroup.displayName), + color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.heading2SemiBold, + ) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_close), + contentDescription = "Close Icon", + modifier = Modifier.clickableSingle { + onCloseButtonClick() + }, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + Text( + text = stringResource(R.string.edit_emotion_detail_description), + modifier = Modifier.fillMaxWidth(), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.label1Medium, + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + start = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing6, + bottom = ReedTheme.spacing.spacing3, + ), + horizontalArrangement = Arrangement.spacedBy( + ReedTheme.spacing.spacing2, + Alignment.CenterHorizontally, + ), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + ) { + emotionGroup.detailEmotions.forEach { detail -> + ReedSelectableChip( + label = detail.name, + chipSizeStyle = mediumChipStyle, + selected = detail.id in selectedEmotionDetailIds, + onClick = { + onEmotionDetailToggled(detail.id) + }, + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = ReedTheme.spacing.spacing4), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ReedButton( + onClick = { + onSkipButtonClick() + }, + text = stringResource(R.string.edit_emotion_detail_skip), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + ReedButton( + onClick = { + onConfirmButtonClick() + }, + text = stringResource(R.string.edit_emotion_detail_confirm), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier.weight(1f), + enabled = selectedEmotionDetailIds.isNotEmpty(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ComponentPreview +@Composable +private fun EmotionDetailBottomSheetPreview() { + val warmthEmotionGroup = EmotionGroupModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + DetailEmotionModel( + id = "84f95f13-e54c-11f0-8545-525ae7dd628c", + name = "다정한", + ), + DetailEmotionModel( + id = "84f95fc0-e54c-11f0-8545-525ae7dd628c", + name = "고마운", + ), + DetailEmotionModel( + id = "84f96094-e54c-11f0-8545-525ae7dd628c", + name = "마음이 놓이는", + ), + DetailEmotionModel( + id = "84f9612c-e54c-11f0-8545-525ae7dd628c", + name = "편안한", + ), + ), + ) + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ReedTheme { + EmotionDetailBottomSheet( + emotionGroup = warmthEmotionGroup, + selectedEmotionDetailIds = persistentListOf(), + onDismissRequest = {}, + sheetState = sheetState, + onCloseButtonClick = {}, + onSkipButtonClick = {}, + onConfirmButtonClick = {}, + onEmotionDetailToggled = {}, + ) + } +} diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/component/EmotionItem.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/component/EmotionItem.kt new file mode 100644 index 000000000..4385e5f59 --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/emotion/component/EmotionItem.kt @@ -0,0 +1,181 @@ +package com.ninecraft.booket.feature.edit.emotion.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.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.stringResource +import androidx.compose.ui.res.vectorResource +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.chip.ReedRemovableChip +import com.ninecraft.booket.core.designsystem.component.chip.smallChipStyle +import com.ninecraft.booket.core.designsystem.descriptionRes +import com.ninecraft.booket.core.designsystem.categoryGraphicRes +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun EmotionItem( + emotionGroup: EmotionGroupModel, + selectedEmotionDetailIds: ImmutableList, + onClick: () -> Unit, + isSelected: Boolean, + onEmotionDetailRemove: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val cornerShape = RoundedCornerShape(ReedTheme.radius.md) + val iconRes = if (isSelected) R.drawable.ic_check else R.drawable.ic_chevron_right + val iconTint = if (isSelected) ReedTheme.colors.borderBrand else ReedTheme.colors.contentTertiary + + Column( + modifier = modifier + .fillMaxWidth() + .clip(cornerShape) + .clickable { + onClick() + } + .background(color = ReedTheme.colors.baseSecondary) + .then( + if (isSelected) Modifier.border( + width = ReedTheme.border.border15, + color = ReedTheme.colors.borderBrand, + shape = cornerShape, + ) + else Modifier, + ) + .padding( + horizontal = ReedTheme.spacing.spacing4, + vertical = ReedTheme.spacing.spacing3, + ), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val emotionGraphicRes = emotionGroup.code.categoryGraphicRes + if (emotionGraphicRes != null) { + Image( + painter = painterResource(emotionGraphicRes), + contentDescription = "Emotion Image", + modifier = Modifier + .size(60.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing4)) + } + + Column { + Text( + text = emotionGroup.displayName, + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.headline1SemiBold, + ) + Text( + text = stringResource(emotionGroup.code.descriptionRes), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = "Chevron Right", + tint = iconTint, + ) + } + + if (selectedEmotionDetailIds.isNotEmpty()) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + ) { + selectedEmotionDetailIds.forEach { detailId -> + val detailName = emotionGroup.detailEmotions.firstOrNull { it.id == detailId }?.name ?: return@forEach + ReedRemovableChip( + label = detailName, + chipSizeStyle = smallChipStyle, + onRemove = { + onEmotionDetailRemove(detailId) + }, + ) + } + } + } + } +} + +@ComponentPreview +@Composable +private fun EmotionItemPreview() { + val warmthEmotionGroup = EmotionGroupModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + DetailEmotionModel( + id = "84f95f13-e54c-11f0-8545-525ae7dd628c", + name = "다정한", + ), + DetailEmotionModel( + id = "84f95fc0-e54c-11f0-8545-525ae7dd628c", + name = "고마운", + ), + DetailEmotionModel( + id = "84f96094-e54c-11f0-8545-525ae7dd628c", + name = "마음이 놓이는", + ), + DetailEmotionModel( + id = "84f9612c-e54c-11f0-8545-525ae7dd628c", + name = "편안한", + ), + ), + ) + + val selectedEmotionDetailIds = persistentListOf( + "84f95fc0-e54c-11f0-8545-525ae7dd628c", + "84f96094-e54c-11f0-8545-525ae7dd628c", + ) + + ReedTheme { + EmotionItem( + emotionGroup = warmthEmotionGroup, + selectedEmotionDetailIds = selectedEmotionDetailIds, + onClick = {}, + isSelected = false, + onEmotionDetailRemove = {}, + modifier = Modifier.fillMaxWidth(), + ) + } +} 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 index 0c1b72201..c1752ffb2 100644 --- 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 @@ -22,19 +22,26 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch -class RecordEditPresenter @AssistedInject constructor( +@AssistedInject +class RecordEditPresenter( @Assisted private val screen: RecordEditScreen, @Assisted private val navigator: Navigator, private val repository: RecordRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(RecordEditScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: RecordEditScreen, navigator: Navigator): RecordEditPresenter + } + companion object { private const val MAX_PAGE = 4032 private const val RECORD_EDIT = "record_edit_save" @@ -45,7 +52,7 @@ class RecordEditPresenter @AssistedInject constructor( override fun present(): RecordEditUiState { val scope = rememberCoroutineScope() var recordInfo by rememberRetained { mutableStateOf(screen.recordInfo) } - val recordPageState = rememberTextFieldState(recordInfo.pageNumber.toString()) + val recordPageState = rememberTextFieldState(recordInfo.pageNumber?.toString() ?: "") val recordQuoteState = rememberTextFieldState(recordInfo.quote) val recordImpressionState = rememberTextFieldState(recordInfo.review) val isPageError by remember { @@ -56,32 +63,38 @@ class RecordEditPresenter @AssistedInject constructor( } val hasChanges by remember { derivedStateOf { - val pageChanged = recordPageState.text.toString() != recordInfo.pageNumber.toString() + val pageChanged = recordPageState.text.toString().toIntOrNull() != recordInfo.pageNumber val quoteChanged = recordQuoteState.text.toString() != recordInfo.quote val impressionChanged = recordImpressionState.text.toString() != recordInfo.review - val emotionChanged = recordInfo.emotionTags != screen.recordInfo.emotionTags + + val originalPrimaryEmotionCode = screen.recordInfo.primaryEmotion.code + val originalDetailEmotionIds = screen.recordInfo.detailEmotions.map { it.id }.toSet() + val currentPrimaryEmotionCode = recordInfo.primaryEmotion.code + val currentDetailEmotionIds = recordInfo.detailEmotions.map { it.id }.toSet() + val emotionChanged = originalPrimaryEmotionCode != currentPrimaryEmotionCode || originalDetailEmotionIds != currentDetailEmotionIds pageChanged || quoteChanged || impressionChanged || emotionChanged } } val isSaveButtonEnabled by remember { derivedStateOf { - recordPageState.text.isNotEmpty() && - recordQuoteState.text.isNotEmpty() && - !isPageError && - hasChanges + recordQuoteState.text.isNotEmpty() && !isPageError && hasChanges } } var sideEffect by rememberRetained { mutableStateOf(null) } val emotionEditNavigator = rememberAnsweringNavigator(navigator) { result -> - recordInfo = recordInfo.copy(emotionTags = listOf(result.emotion)) + recordInfo = recordInfo.copy( + primaryEmotion = result.primaryEmotion, + detailEmotions = result.detailEmotions, + ) } fun editRecord( readingRecordId: String, - pageNumber: Int, + pageNumber: Int?, quote: String, - emotionTags: List, + primaryEmotion: String, + detailEmotionIds: List, impression: String, onSuccess: () -> Unit = {}, ) { @@ -90,8 +103,9 @@ class RecordEditPresenter @AssistedInject constructor( readingRecordId = readingRecordId, pageNumber = pageNumber, quote = quote, - emotionTags = emotionTags, review = impression, + primaryEmotion = primaryEmotion, + detailEmotionTagIds = detailEmotionIds, ).onSuccess { analyticsHelper.logEvent(RECORD_EDIT_SAVE) onSuccess() @@ -123,16 +137,21 @@ class RecordEditPresenter @AssistedInject constructor( } RecordEditUiEvent.OnEmotionEditClick -> { - val emotion = recordInfo.emotionTags.firstOrNull() ?: "" - emotionEditNavigator.goTo(EmotionEditScreen(emotion)) + emotionEditNavigator.goTo( + EmotionEditScreen( + primaryEmotionCode = recordInfo.primaryEmotion.code, + detailEmotionIds = recordInfo.detailEmotions.map { it.id }, + ), + ) } RecordEditUiEvent.OnSaveButtonClick -> { editRecord( readingRecordId = recordInfo.id, - pageNumber = recordPageState.text.toString().toIntOrNull() ?: 0, + pageNumber = recordPageState.text.toString().toIntOrNull(), quote = recordQuoteState.text.toString(), - emotionTags = recordInfo.emotionTags, + primaryEmotion = recordInfo.primaryEmotion.code.name, + detailEmotionIds = recordInfo.detailEmotions.map { it.id }, impression = recordImpressionState.text.toString(), onSuccess = { navigator.pop() @@ -157,13 +176,4 @@ class RecordEditPresenter @AssistedInject constructor( 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 index 84ee03c6c..5373d776e 100644 --- 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 @@ -1,9 +1,7 @@ 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.WindowInsets import androidx.compose.foundation.layout.exclude @@ -13,21 +11,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding 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.ScaffoldDefaults 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 @@ -39,19 +32,24 @@ import com.ninecraft.booket.core.designsystem.component.textfield.ReedRecordText 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.model.EmotionCode 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.edit.record.component.EmotionItem import com.ninecraft.booket.feature.screens.RecordEditScreen +import com.ninecraft.booket.feature.screens.arguments.DetailEmotionArg +import com.ninecraft.booket.feature.screens.arguments.PrimaryEmotionArg import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope +import kotlinx.collections.immutable.toPersistentList import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition -@CircuitInject(RecordEditScreen::class, ActivityRetainedComponent::class) +@CircuitInject(RecordEditScreen::class, AppScope::class) @Composable internal fun RecordEditUi( state: RecordEditUiState, @@ -152,7 +150,7 @@ private fun ColumnScope.RecordEditContent(state: RecordEditUiState) { ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) Text( - text = stringResource(R.string.edit_record_impression_label), + text = stringResource(R.string.edit_record_memo_label), color = ReedTheme.colors.contentPrimary, style = ReedTheme.typography.body1Medium, ) @@ -169,36 +167,20 @@ private fun ColumnScope.RecordEditContent(state: RecordEditUiState) { ), ) 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, - ) - } - } + Text( + text = stringResource(R.string.edit_record_emotion_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + EmotionItem( + primaryEmotionCode = state.recordInfo.primaryEmotion.code, + primaryEmotionName = state.recordInfo.primaryEmotion.displayName, + detailEmotions = state.recordInfo.detailEmotions.toPersistentList(), + onClick = { + state.eventSink(RecordEditUiEvent.OnEmotionEditClick) + }, + ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing16)) } } @@ -230,7 +212,20 @@ private fun RecordEditUiPreview() { pageNumber = 33, quote = "소설가들은 늘 소재를 찾아 떠도는 존재 같지만, 실은 그 반대인 경우가 더 잦다.", review = "감동적이었다.", - emotionTags = listOf("따뜻함"), + primaryEmotion = PrimaryEmotionArg( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + ), + detailEmotions = listOf( + DetailEmotionArg( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionArg( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + ), bookTitle = "여름은 오래 그곳에 남아", bookPublisher = "비채", bookCoverImageUrl = "", diff --git a/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/component/EmotionItem.kt b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/component/EmotionItem.kt new file mode 100644 index 000000000..1084cca6f --- /dev/null +++ b/feature/edit/src/main/kotlin/com/ninecraft/booket/feature/edit/record/component/EmotionItem.kt @@ -0,0 +1,147 @@ +package com.ninecraft.booket.feature.edit.record.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.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 com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.R +import com.ninecraft.booket.core.designsystem.graphicRes +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.feature.screens.arguments.DetailEmotionArg +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun EmotionItem( + primaryEmotionCode: EmotionCode, + primaryEmotionName: String, + detailEmotions: ImmutableList, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(color = ReedTheme.colors.baseSecondary) + .clickable { + onClick() + } + .padding( + horizontal = ReedTheme.spacing.spacing4, + vertical = ReedTheme.spacing.spacing4, + ), + ) { + EmotionContent(primaryEmotionCode, primaryEmotionName, detailEmotions) + } +} + +@Composable +private fun EmotionContent( + primaryEmotionCode: EmotionCode, + primaryEmotionName: String, + detailEmotions: ImmutableList, +) { + val hasDetailEmotion = detailEmotions.isNotEmpty() + val primaryEmotionBackgroundColor = if (primaryEmotionCode == EmotionCode.OTHER) ReedTheme.colors.bgDisabled else ReedTheme.colors.bgTertiary + val primaryEmotionTextColor = if (primaryEmotionCode == EmotionCode.OTHER) ReedTheme.colors.contentTertiary else ReedTheme.colors.contentBrand + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(primaryEmotionCode.graphicRes), + contentDescription = "Emotion Graphic", + modifier = Modifier + .size(ReedTheme.spacing.spacing10) + .clip(CircleShape) + .background(ReedTheme.colors.basePrimary), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Column { + Text( + text = primaryEmotionName, + modifier = Modifier + .background( + color = primaryEmotionBackgroundColor, + shape = RoundedCornerShape(ReedTheme.radius.full), + ) + .padding( + horizontal = ReedTheme.spacing.spacing2, + vertical = ReedTheme.spacing.spacing1, + ), + color = primaryEmotionTextColor, + style = ReedTheme.typography.label2SemiBold, + ) + + if (hasDetailEmotion) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + FlowRow { + detailEmotions.forEach { detail -> + Text( + text = "#${detail.name}", + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.caption1Regular, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + } + } + } + } + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right), + contentDescription = "Chevron Right Icon", + tint = ReedTheme.colors.contentSecondary, + ) + } +} + +@ComponentPreview +@Composable +private fun EmotionItemPreview() { + val primaryEmotionName = "따뜻함" + + val detailEmotions = persistentListOf( + DetailEmotionArg( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionArg( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + ) + + ReedTheme { + EmotionItem( + primaryEmotionName = primaryEmotionName, + primaryEmotionCode = EmotionCode.WARMTH, + detailEmotions = detailEmotions, + onClick = {}, + ) + } +} diff --git a/feature/edit/src/main/res/values/strings.xml b/feature/edit/src/main/res/values/strings.xml index bd99c9a71..d3ca53560 100644 --- a/feature/edit/src/main/res/values/strings.xml +++ b/feature/edit/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ 독서 기록 수정 책 페이지 문장 기록 - 감상평 + 메모 감정 기록하고 싶은 페이지를 작성해보세요 해당 책의 마지막 페이지 수를 초과했습니다 @@ -13,4 +13,8 @@ 문장에 대해 어떤 감정이 드셨나요? 대표 감정을 한 가지 선택해주세요 수정하기 + 어떤 %1$s을 느꼈나요? + 더 자세한 감정을 선택 기록할 수 있어요. + 건너뛰기 + 선택 완료 diff --git a/feature/edit/stability/edit.stability b/feature/edit/stability/edit.stability index c3591cda7..72126dd27 100644 --- a/feature/edit/stability/edit.stability +++ b/feature/edit/stability/edit.stability @@ -9,7 +9,7 @@ private fun com.ninecraft.booket.feature.edit.emotion.EmotionEditContent(state: skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -23,23 +23,33 @@ internal fun com.ninecraft.booket.feature.edit.emotion.EmotionEditUi(state: com. skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.edit.emotion.EmotionEditUiPreview(): kotlin.Unit +internal fun com.ninecraft.booket.feature.edit.emotion.component.EmotionDetailBottomSheet(emotionGroup: com.ninecraft.booket.core.model.EmotionGroupModel, selectedEmotionDetailIds: kotlinx.collections.immutable.ImmutableList, onDismissRequest: kotlin.Function0, sheetState: androidx.compose.material3.SheetState, onCloseButtonClick: kotlin.Function0, onEmotionDetailToggled: kotlin.Function1, onSkipButtonClick: kotlin.Function0, onConfirmButtonClick: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: + - emotionGroup: STABLE (marked @Stable or @Immutable) + - selectedEmotionDetailIds: STABLE (known stable type) + - onDismissRequest: STABLE (function type) + - sheetState: STABLE (marked @Stable or @Immutable) + - onCloseButtonClick: STABLE (function type) + - onEmotionDetailToggled: STABLE (function type) + - onSkipButtonClick: STABLE (function type) + - onConfirmButtonClick: STABLE (function type) @Composable -private fun com.ninecraft.booket.feature.edit.emotion.EmotionItem(emotionTag: com.ninecraft.booket.core.designsystem.EmotionTag, onClick: kotlin.Function0, isSelected: kotlin.Boolean, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.ninecraft.booket.feature.edit.emotion.component.EmotionItem(emotionGroup: com.ninecraft.booket.core.model.EmotionGroupModel, selectedEmotionDetailIds: kotlinx.collections.immutable.ImmutableList, onClick: kotlin.Function0, isSelected: kotlin.Boolean, onEmotionDetailRemove: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - emotionTag: STABLE + - emotionGroup: STABLE (marked @Stable or @Immutable) + - selectedEmotionDetailIds: STABLE (known stable type) - onClick: STABLE (function type) - isSelected: STABLE (primitive type) + - onEmotionDetailRemove: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -47,14 +57,14 @@ internal fun com.ninecraft.booket.feature.edit.record.HandleRecordEditSideEffect skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) @Composable private fun com.ninecraft.booket.feature.edit.record.RecordEditContent(state: com.ninecraft.booket.feature.edit.record.RecordEditUiState): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) @Composable public fun com.ninecraft.booket.feature.edit.record.RecordEditPresenter.present(): com.ninecraft.booket.feature.edit.record.RecordEditUiState @@ -67,15 +77,9 @@ internal fun com.ninecraft.booket.feature.edit.record.RecordEditUi(state: com.ni skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.edit.record.RecordEditUiPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.edit.record.component.BookItem(imageUrl: kotlin.String, bookTitle: kotlin.String, author: kotlin.String, publisher: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -88,8 +92,22 @@ internal fun com.ninecraft.booket.feature.edit.record.component.BookItem(imageUr - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.edit.record.component.BookItemPreview(): kotlin.Unit +private fun com.ninecraft.booket.feature.edit.record.component.EmotionContent(primaryEmotionCode: com.ninecraft.booket.core.model.EmotionCode, primaryEmotionName: kotlin.String, detailEmotions: kotlinx.collections.immutable.ImmutableList): kotlin.Unit + skippable: true + restartable: true + params: + - primaryEmotionCode: STABLE (class with no mutable properties) + - primaryEmotionName: STABLE (String is immutable) + - detailEmotions: STABLE (known stable type) + +@Composable +internal fun com.ninecraft.booket.feature.edit.record.component.EmotionItem(primaryEmotionCode: com.ninecraft.booket.core.model.EmotionCode, primaryEmotionName: kotlin.String, detailEmotions: kotlinx.collections.immutable.ImmutableList, onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: + - primaryEmotionCode: STABLE (class with no mutable properties) + - primaryEmotionName: STABLE (String is immutable) + - detailEmotions: STABLE (known stable type) + - onClick: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 840963c20..332195b52 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.kotlin.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.home" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.logger, 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 3ad310237..e2b80d0b5 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 @@ -12,7 +12,7 @@ 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.UserRepository import com.ninecraft.booket.core.model.RecentBookModel -import com.ninecraft.booket.core.model.UserState +import com.ninecraft.booket.core.model.state.UserState import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.screens.HomeScreen @@ -27,15 +27,16 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class HomePresenter @AssistedInject constructor( +@AssistedInject +class HomePresenter( @Assisted private val navigator: Navigator, private val bookRepository: BookRepository, private val authRepository: AuthRepository, @@ -43,6 +44,12 @@ class HomePresenter @AssistedInject constructor( private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(HomeScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): HomePresenter + } + @Composable override fun present(): HomeUiState { val scope = rememberCoroutineScope() @@ -108,8 +115,7 @@ class HomePresenter @AssistedInject constructor( is HomeUiEvent.OnTabSelected -> { navigator.resetRoot( newRoot = event.tab.screen, - saveState = true, - restoreState = true, + options = Navigator.StateOptions.SaveAndRestore, ) } @@ -148,10 +154,4 @@ class HomePresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(HomeScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): HomePresenter - } } 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 f18583025..826064a7c 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 @@ -49,11 +49,11 @@ import com.ninecraft.booket.feature.screens.component.MainBottomBar import com.ninecraft.booket.feature.screens.component.MainTab import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import kotlinx.collections.immutable.toImmutableList @TraceRecomposition -@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class) +@CircuitInject(HomeScreen::class, AppScope::class) @Composable internal fun HomeUi( state: HomeUiState, @@ -100,7 +100,7 @@ internal fun HomeUi( Column( modifier = Modifier .fillMaxSize() - .background(HomeBg) + .background(color = HomeBg) .padding(innerPadding), ) { HomeHeader( 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 758513275..5ee110fc0 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 @@ -62,11 +62,8 @@ fun BookCard( ambientColor = Color(0xFFBCC4BE).copy(alpha = 0.2f), spotColor = Color(0xFFBCC4BE).copy(alpha = 0.2f), ) - .background( - color = ReedTheme.colors.basePrimary, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) .clip(shape = RoundedCornerShape(ReedTheme.radius.sm)) + .background(color = ReedTheme.colors.basePrimary) .border( width = 1.dp, color = ReedTheme.colors.borderSecondary, @@ -148,11 +145,8 @@ fun BookCard( ) { Row( modifier = Modifier - .background( - color = ReedTheme.colors.baseSecondary, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) - .clip(shape = RoundedCornerShape(ReedTheme.radius.sm)) + .clip(RoundedCornerShape(ReedTheme.radius.sm)) + .background(color = ReedTheme.colors.baseSecondary) .clickableSingle { onBookDetailClick() } @@ -219,10 +213,8 @@ fun EmptyBookCard( ambientColor = Color(0xFFBCC4BE).copy(alpha = 0.2f), spotColor = Color(0xFFBCC4BE).copy(alpha = 0.2f), ) - .background( - color = ReedTheme.colors.basePrimary, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) + .clip(RoundedCornerShape(ReedTheme.radius.sm)) + .background(color = ReedTheme.colors.basePrimary) .border( width = ReedTheme.border.border1, color = ReedTheme.colors.borderPrimary, diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeHeader.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeHeader.kt index a193a689f..81e35d64e 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeHeader.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/component/HomeHeader.kt @@ -30,7 +30,7 @@ fun HomeHeader( Row( modifier = modifier .fillMaxWidth() - .background(HomeBg) + .background(color = HomeBg) .height(60.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/feature/home/stability/home.stability b/feature/home/stability/home.stability index c66b0bb98..3df123760 100644 --- a/feature/home/stability/home.stability +++ b/feature/home/stability/home.stability @@ -4,96 +4,9 @@ // Do not edit this file directly. To update it, run: // ./gradlew :home:stabilityDump -@Composable -internal fun com.ninecraft.booket.feature.home.HandleHomeSideEffects(state: com.ninecraft.booket.feature.home.HomeUiState): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - -@Composable -internal fun com.ninecraft.booket.feature.home.HomeContent(state: com.ninecraft.booket.feature.home.HomeUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - @Composable public fun com.ninecraft.booket.feature.home.HomePresenter.present(): com.ninecraft.booket.feature.home.HomeUiState skippable: true restartable: true params: -@Composable -private fun com.ninecraft.booket.feature.home.HomePreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.home.HomeUi(state: com.ninecraft.booket.feature.home.HomeUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -public fun com.ninecraft.booket.feature.home.component.BookCard(recentBookInfo: com.ninecraft.booket.core.model.RecentBookModel, onBookDetailClick: kotlin.Function0, onRecordButtonClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - recentBookInfo: STABLE (marked @Stable or @Immutable) - - onBookDetailClick: STABLE (function type) - - onRecordButtonClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.home.component.BookCardPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.home.component.EmptyBookCard(onBookRegisterClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onBookRegisterClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.home.component.EmptyBookCardPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.home.component.HomeBanner(onBookRegisterClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onBookRegisterClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.home.component.HomeBannerPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.home.component.HomeHeader(onSettingsClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onSettingsClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.home.component.HomeHeaderPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/library/build.gradle.kts b/feature/library/build.gradle.kts index 5f01e5cae..c89604598 100644 --- a/feature/library/build.gradle.kts +++ b/feature/library/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.kotlin.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.library" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.logger, 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 e557f31c1..9df94d1bf 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 @@ -12,7 +12,7 @@ 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.BookRepository import com.ninecraft.booket.core.model.LibraryBookSummaryModel -import com.ninecraft.booket.core.model.UserState +import com.ninecraft.booket.core.model.state.UserState import com.ninecraft.booket.core.ui.component.FooterState import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.LibraryScreen @@ -28,20 +28,28 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class LibraryPresenter @AssistedInject constructor( +@AssistedInject +class LibraryPresenter( @Assisted private val navigator: Navigator, private val bookRepository: BookRepository, private val authRepository: AuthRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + + @CircuitInject(LibraryScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): LibraryPresenter + } + companion object { private const val PAGE_SIZE = 20 private const val START_INDEX = 0 @@ -176,8 +184,7 @@ class LibraryPresenter @AssistedInject constructor( is LibraryUiEvent.OnTabSelected -> { navigator.resetRoot( newRoot = event.tab.screen, - saveState = true, - restoreState = true, + options = Navigator.StateOptions.SaveAndRestore, ) } @@ -214,10 +221,4 @@ class LibraryPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(LibraryScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): LibraryPresenter - } } 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 aac44e112..8c6a9a62c 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 @@ -20,7 +20,7 @@ import com.ninecraft.booket.core.common.extensions.toErrorType 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.mediumButtonStyle +import com.ninecraft.booket.core.designsystem.component.button.smallButtonStyle import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.ReedScaffold @@ -36,12 +36,12 @@ import com.ninecraft.booket.feature.screens.component.MainBottomBar import com.ninecraft.booket.feature.screens.component.MainTab import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @TraceRecomposition -@CircuitInject(LibraryScreen::class, ActivityRetainedComponent::class) +@CircuitInject(LibraryScreen::class, AppScope::class) @Composable internal fun LibraryUi( state: LibraryUiState, @@ -131,7 +131,7 @@ internal fun LibraryContent( }, text = stringResource(R.string.login), colorStyle = ReedButtonColorStyle.SECONDARY, - sizeStyle = mediumButtonStyle, + sizeStyle = smallButtonStyle, ) } } @@ -237,3 +237,16 @@ private fun LibraryPreview() { ) } } + +@DevicePreview +@Composable +private fun LibraryGuestModePreview() { + ReedTheme { + LibraryUi( + state = LibraryUiState( + isGuestMode = true, + eventSink = {}, + ), + ) + } +} 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 cac40f41a..4ab0366b3 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 @@ -85,7 +85,7 @@ enum class LibraryFilterOption(val value: String) { } } - companion object Companion { + companion object { fun fromValue(value: String): LibraryFilterOption? { return entries.find { it.value == value } } 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 08236f8de..3145ed3c1 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 @@ -38,11 +38,8 @@ fun FilterChip( Box( modifier = modifier - .background( - color = chipColor, - shape = RoundedCornerShape(ReedTheme.radius.full), - ) .clip(shape = RoundedCornerShape(ReedTheme.radius.full)) + .background(chipColor) .noRippleClickable { onChipClick(option) } diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryHeader.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryHeader.kt index 745b888c4..54ac38073 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryHeader.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryHeader.kt @@ -31,7 +31,7 @@ fun LibraryHeader( modifier = modifier .fillMaxWidth() .height(60.dp) - .background(White), + .background(color = White), verticalAlignment = Alignment.CenterVertically, ) { Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing5)) diff --git a/feature/library/stability/library.stability b/feature/library/stability/library.stability index edab8a55c..e3d809a30 100644 --- a/feature/library/stability/library.stability +++ b/feature/library/stability/library.stability @@ -4,108 +4,9 @@ // Do not edit this file directly. To update it, run: // ./gradlew :library:stabilityDump -@Composable -private fun com.ninecraft.booket.feature.library.EmptyResult(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.library.HandleLibrarySideEffects(state: com.ninecraft.booket.feature.library.LibraryUiState, eventSink: kotlin.Function1): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - eventSink: STABLE (function type) - -@Composable -internal fun com.ninecraft.booket.feature.library.LibraryContent(state: com.ninecraft.booket.feature.library.LibraryUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - @Composable public fun com.ninecraft.booket.feature.library.LibraryPresenter.present(): com.ninecraft.booket.feature.library.LibraryUiState skippable: true restartable: true params: -@Composable -private fun com.ninecraft.booket.feature.library.LibraryPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.library.LibraryUi(state: com.ninecraft.booket.feature.library.LibraryUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.library.component.ChipPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.library.component.FilterChip(option: com.ninecraft.booket.feature.library.LibraryFilterOption, count: kotlin.Int, isSelected: kotlin.Boolean, onChipClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - option: STABLE - - count: STABLE (primitive type) - - isSelected: STABLE (primitive type) - - onChipClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -public fun com.ninecraft.booket.feature.library.component.FilterChipGroup(filterList: kotlinx.collections.immutable.ImmutableList, selectedChipOption: com.ninecraft.booket.feature.library.LibraryFilterOption, onChipClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - filterList: STABLE (known stable type) - - selectedChipOption: STABLE - - onChipClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.library.component.FilterChipGroupPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.library.component.LibraryBookItem(book: com.ninecraft.booket.core.model.LibraryBookSummaryModel, onBookClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - book: STABLE (marked @Stable or @Immutable) - - onBookClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.library.component.LibraryBookItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.library.component.LibraryHeader(onSearchClick: kotlin.Function0, onSettingClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onSearchClick: STABLE (function type) - - onSettingClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.library.component.LibraryHeaderPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts index cd173eebe..b47904113 100644 --- a/feature/login/build.gradle.kts +++ b/feature/login/build.gradle.kts @@ -1,4 +1,4 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") +import com.ninecraft.booket.convention.getLocalProperty plugins { alias(libs.plugins.booket.android.feature) @@ -8,15 +8,22 @@ plugins { android { namespace = "com.ninecraft.booket.feature.login" -} -ksp { - arg("circuit.codegen.mode", "hilt") + defaultConfig { + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", getLocalProperty("GOOGLE_WEB_CLIENT_ID")) + } + + buildFeatures { + buildConfig = true + } } dependencies { implementations( libs.logger, libs.kakao.auth, + libs.androidx.credentials, + libs.androidx.credentials.play.services.auth, + libs.googleid, ) } diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/HandleLoginSideEffects.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/HandleLoginSideEffects.kt index 2e7b48f12..01251b43e 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/HandleLoginSideEffects.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/HandleLoginSideEffects.kt @@ -3,8 +3,12 @@ package com.ninecraft.booket.feature.login import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext +import com.ninecraft.booket.feature.login.client.GoogleLoginClient +import com.ninecraft.booket.feature.login.client.KakaoLoginClient import com.skydoves.compose.effects.RememberedEffect +import kotlinx.coroutines.launch @Composable internal fun HandleLoginSideEffects( @@ -12,7 +16,9 @@ internal fun HandleLoginSideEffects( eventSink: (LoginUiEvent) -> Unit, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val kakaoLoginClient = remember { KakaoLoginClient() } + val googleLoginClient = remember { GoogleLoginClient() } RememberedEffect(state.sideEffect) { when (state.sideEffect) { @@ -20,7 +26,12 @@ internal fun HandleLoginSideEffects( kakaoLoginClient.loginWithKakao( context = context, onSuccess = { token -> - eventSink(LoginUiEvent.Login(token)) + eventSink( + LoginUiEvent.Login( + providerType = LoginUiEvent.PROVIDER_TYPE_KAKAO, + token = token, + ), + ) }, onFailure = { errorMessage -> eventSink(LoginUiEvent.LoginFailure(errorMessage)) @@ -28,6 +39,26 @@ internal fun HandleLoginSideEffects( ) } + is LoginSideEffect.GoogleLogin -> { + scope.launch { + googleLoginClient.loginWithGoogle( + context = context, + webClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, + onSuccess = { idToken -> + eventSink( + LoginUiEvent.Login( + providerType = LoginUiEvent.PROVIDER_TYPE_GOOGLE, + token = idToken, + ), + ) + }, + onFailure = { errorMessage -> + eventSink(LoginUiEvent.LoginFailure(errorMessage)) + }, + ) + } + } + is LoginSideEffect.ShowToast -> { Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() } 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 0bb0de32b..d18b8da22 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 @@ -1,6 +1,7 @@ package com.ninecraft.booket.feature.login 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 @@ -10,6 +11,7 @@ import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.event.postErrorDialog import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.UserRepository +import com.ninecraft.booket.core.model.LoginMethod import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.TermsAgreementScreen @@ -20,13 +22,14 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.popUntil 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch -class LoginPresenter @AssistedInject constructor( +@AssistedInject +class LoginPresenter( @Assisted private val screen: LoginScreen, @Assisted private val navigator: Navigator, private val authRepository: AuthRepository, @@ -34,6 +37,12 @@ class LoginPresenter @AssistedInject constructor( private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(LoginScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: LoginScreen, navigator: Navigator): LoginPresenter + } + companion object { private const val EVENT_ERROR_LOGIN = "error_login" } @@ -43,6 +52,15 @@ class LoginPresenter @AssistedInject constructor( val scope = rememberCoroutineScope() var isLoading by rememberRetained { mutableStateOf(false) } var sideEffect by rememberRetained { mutableStateOf(null) } + var showLoginTooltip by rememberRetained { mutableStateOf(false) } + var recentLoginMethod by rememberRetained { mutableStateOf(LoginMethod.NONE) } + + LaunchedEffect(Unit) { + authRepository.recentLoginMethod.collect { method -> + recentLoginMethod = method + showLoginTooltip = method != LoginMethod.NONE + } + } fun navigateAfterLogin() { scope.launch { @@ -78,6 +96,11 @@ class LoginPresenter @AssistedInject constructor( sideEffect = LoginSideEffect.KakaoLogin() } + is LoginUiEvent.OnGoogleLoginButtonClick -> { + isLoading = true + sideEffect = LoginSideEffect.GoogleLogin() + } + is LoginUiEvent.LoginFailure -> { isLoading = false analyticsHelper.logEvent(EVENT_ERROR_LOGIN) @@ -88,8 +111,15 @@ class LoginPresenter @AssistedInject constructor( scope.launch { try { isLoading = true - authRepository.login(event.accessToken) + authRepository.login(event.providerType, event.token) .onSuccess { + authRepository.setRecentLoginMethod( + if (event.providerType == LoginUiEvent.PROVIDER_TYPE_KAKAO) { + LoginMethod.KAKAO + } else { + LoginMethod.GOOGLE + }, + ) userRepository.syncFcmToken() navigateAfterLogin() }.onFailure { exception -> @@ -113,6 +143,10 @@ class LoginPresenter @AssistedInject constructor( is LoginUiEvent.OnCloseButtonClick -> { navigator.pop() } + + is LoginUiEvent.OnDismissLoginTooltip -> { + showLoginTooltip = false + } } } @@ -124,16 +158,9 @@ class LoginPresenter @AssistedInject constructor( isLoading = isLoading, returnToScreen = screen.returnToScreen, sideEffect = sideEffect, + showLoginTooltip = showLoginTooltip, + recentLoginMethod = recentLoginMethod, eventSink = ::handleEvent, ) } - - @CircuitInject(LoginScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create( - screen: LoginScreen, - navigator: Navigator, - ): LoginPresenter - } } 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 4e6bcd93f..7240d60a7 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 @@ -2,13 +2,13 @@ package com.ninecraft.booket.feature.login import androidx.compose.foundation.Image 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -20,7 +20,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.extensions.noRippleClickable 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 @@ -29,16 +31,18 @@ import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle import com.ninecraft.booket.core.designsystem.component.button.smallButtonStyle import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.model.LoginMethod import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator +import com.ninecraft.booket.feature.login.component.LoginTooltipBox import com.ninecraft.booket.feature.screens.LoginScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope @TraceRecomposition -@CircuitInject(LoginScreen::class, ActivityRetainedComponent::class) +@CircuitInject(LoginScreen::class, AppScope::class) @Composable internal fun LoginUi( state: LoginUiState, @@ -52,87 +56,145 @@ internal fun LoginUi( ReedScaffold( modifier = modifier.fillMaxSize(), ) { innerPadding -> - Column( - modifier = modifier + Box( + modifier = Modifier .fillMaxSize() .background(White) - .padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .padding(innerPadding) + .then( + if (state.showLoginTooltip) { + Modifier.noRippleClickable { + state.eventSink(LoginUiEvent.OnDismissLoginTooltip) + } + } else { + Modifier + }, + ), ) { - Box(modifier = Modifier.fillMaxSize()) { - Column { - if (state.returnToScreen != null) { - ReedCloseTopAppBar( - onClose = { - state.eventSink(LoginUiEvent.OnCloseButtonClick) - }, - ) - } - Column( + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state.returnToScreen != null) { + ReedCloseTopAppBar( + onClose = { + state.eventSink(LoginUiEvent.OnCloseButtonClick) + }, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(R.drawable.img_reed_logo_big), + contentDescription = "Reed Logo", + modifier = Modifier.height(67.14.dp), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Text( + text = stringResource(R.string.login_reed_slogan), + color = ReedTheme.colors.contentBrand, + style = ReedTheme.typography.headline2SemiBold, + ) + Spacer(modifier = Modifier.weight(1f)) + + // 카카오 로그인 버튼 + 툴팁 + Box( + modifier = Modifier.fillMaxWidth(), + ) { + ReedButton( + onClick = { + state.eventSink(LoginUiEvent.OnKakaoLoginButtonClick) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.KAKAO, modifier = Modifier .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(R.drawable.img_reed_logo_big), - contentDescription = "Reed Logo", - modifier = Modifier.height(67.14.dp), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) - Text( - text = stringResource(R.string.login_reed_slogan), - color = ReedTheme.colors.contentBrand, - style = ReedTheme.typography.headline2SemiBold, + .padding(horizontal = ReedTheme.spacing.spacing5), + text = stringResource(id = R.string.kakao_login), + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_kakao), + contentDescription = "Kakao Icon", + tint = Color.Unspecified, + ) + }, + ) + + if (state.showLoginTooltip && state.recentLoginMethod == LoginMethod.KAKAO) { + LoginTooltipBox( + messageResId = R.string.recent_login, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset { + IntOffset( + x = (-28).dp.roundToPx(), + y = (-32).dp.roundToPx(), + ) + }, ) } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ReedButton( - onClick = { - state.eventSink(LoginUiEvent.OnKakaoLoginButtonClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.KAKAO, + } + + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + + // 구글 로그인 버튼 + 툴팁 + Box( + modifier = Modifier.fillMaxWidth(), + ) { + ReedButton( + onClick = { + state.eventSink(LoginUiEvent.OnGoogleLoginButtonClick) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.GOOGLE, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing5), + text = stringResource(id = R.string.google_login), + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_google), + contentDescription = "Google Icon", + tint = Color.Unspecified, + ) + }, + ) + + if (state.showLoginTooltip && state.recentLoginMethod == LoginMethod.GOOGLE) { + LoginTooltipBox( + messageResId = R.string.recent_login, modifier = Modifier - .fillMaxWidth() - .padding( - start = ReedTheme.spacing.spacing5, - end = ReedTheme.spacing.spacing5, - ), - text = stringResource(id = R.string.kakao_login), - leadingIcon = { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_kakao), - contentDescription = "Kakao Icon", - tint = Color.Unspecified, - ) - }, - ) - Spacer( - modifier = Modifier.height(if (state.returnToScreen == null) ReedTheme.spacing.spacing2 else ReedTheme.spacing.spacing8), - ) - if (state.returnToScreen == null) { - ReedTextButton( - onClick = { - state.eventSink(LoginUiEvent.OnGuestLoginButtonClick) + .align(Alignment.BottomEnd) + .offset { + IntOffset( + x = (-28).dp.roundToPx(), + y = (-32).dp.roundToPx(), + ) }, - text = stringResource(R.string.guest_login), - sizeStyle = smallButtonStyle, - colorStyle = ReedButtonColorStyle.TEXT, - ) - } + ) } } - if (state.isLoading) { - ReedLoadingIndicator() + Spacer( + modifier = Modifier.height( + if (state.returnToScreen == null) ReedTheme.spacing.spacing3 else ReedTheme.spacing.spacing8, + ), + ) + + if (state.returnToScreen == null) { + ReedTextButton( + onClick = { + state.eventSink(LoginUiEvent.OnGuestLoginButtonClick) + }, + text = stringResource(R.string.guest_login), + sizeStyle = smallButtonStyle, + colorStyle = ReedButtonColorStyle.TEXT, + ) } } + + if (state.isLoading) { + ReedLoadingIndicator() + } } } } @@ -148,3 +210,31 @@ private fun LoginPreview() { ) } } + +@DevicePreview +@Composable +private fun LoginKakaoTooltipPreview() { + ReedTheme { + LoginUi( + state = LoginUiState( + showLoginTooltip = true, + recentLoginMethod = LoginMethod.KAKAO, + eventSink = {}, + ), + ) + } +} + +@DevicePreview +@Composable +private fun LoginGoogleTooltipPreview() { + ReedTheme { + LoginUi( + state = LoginUiState( + showLoginTooltip = true, + recentLoginMethod = LoginMethod.GOOGLE, + eventSink = {}, + ), + ) + } +} diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt index 1f58e2482..b4f6a6766 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt @@ -1,6 +1,7 @@ package com.ninecraft.booket.feature.login import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.model.LoginMethod import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen @@ -10,12 +11,15 @@ data class LoginUiState( val isLoading: Boolean = false, val returnToScreen: Screen? = null, val sideEffect: LoginSideEffect? = null, + val showLoginTooltip: Boolean = false, + val recentLoginMethod: LoginMethod = LoginMethod.NONE, val eventSink: (LoginUiEvent) -> Unit, ) : CircuitUiState @Immutable sealed interface LoginSideEffect { data class KakaoLogin(private val key: String = UUID.randomUUID().toString()) : LoginSideEffect + data class GoogleLogin(private val key: String = UUID.randomUUID().toString()) : LoginSideEffect data class ShowToast( val message: String, private val key: String = UUID.randomUUID().toString(), @@ -24,8 +28,18 @@ sealed interface LoginSideEffect { sealed interface LoginUiEvent : CircuitUiEvent { data object OnKakaoLoginButtonClick : LoginUiEvent - data class Login(val accessToken: String) : LoginUiEvent + data object OnGoogleLoginButtonClick : LoginUiEvent + data class Login( + val providerType: String, + val token: String, + ) : LoginUiEvent data class LoginFailure(val message: String) : LoginUiEvent data object OnGuestLoginButtonClick : LoginUiEvent data object OnCloseButtonClick : LoginUiEvent + data object OnDismissLoginTooltip : LoginUiEvent + + companion object { + const val PROVIDER_TYPE_KAKAO = "KAKAO" + const val PROVIDER_TYPE_GOOGLE = "GOOGLE" + } } diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/client/GoogleLoginClient.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/client/GoogleLoginClient.kt new file mode 100644 index 000000000..b3712be3e --- /dev/null +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/client/GoogleLoginClient.kt @@ -0,0 +1,82 @@ +package com.ninecraft.booket.feature.login.client + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.ninecraft.booket.core.designsystem.R +import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject + +@Inject +internal class GoogleLoginClient { + suspend fun loginWithGoogle( + context: Context, + webClientId: String, + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + ) { + val credentialManager = CredentialManager.create(context) + + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(webClientId) + .setAutoSelectEnabled(true) + .build() + + val credentialRequest = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + try { + val result = credentialManager.getCredential( + request = credentialRequest, + context = context, + ) + handleSignIn(result, onSuccess, onFailure, context) + } catch (e: GetCredentialCancellationException) { + Logger.e("Google 로그인 취소됨, ${e.message}") + onFailure(context.getString(R.string.unknown_error_message)) + } catch (e: NoCredentialException) { + Logger.e("Google 계정을 찾을 수 없음, ${e.message}") + onFailure(context.getString(R.string.unknown_error_message)) + } catch (e: GetCredentialException) { + Logger.e("Google 로그인 실패, ${e.message}") + onFailure(context.getString(R.string.unknown_error_message)) + } catch (e: Exception) { + Logger.e("알 수 없는 오류: ${e.message}") + onFailure(context.getString(R.string.unknown_error_message)) + } + } + + private fun handleSignIn( + result: GetCredentialResponse, + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + context: Context, + ) { + val credential = result.credential + + if (credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + try { + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val idToken = googleIdTokenCredential.idToken + Logger.d("Google 로그인 성공: ${googleIdTokenCredential.id}") + onSuccess(idToken) + } catch (e: Exception) { + Logger.e("Google ID Token 파싱 실패: ${e.message}") + onFailure(context.getString(R.string.unknown_error_message)) + } + } else { + Logger.e("예상치 못한 credential type: ${credential.type}") + onFailure(context.getString(R.string.unknown_error_message)) + } + } +} diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/KakaoLoginClient.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/client/KakaoLoginClient.kt similarity index 76% rename from feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/KakaoLoginClient.kt rename to feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/client/KakaoLoginClient.kt index 022b788c3..9334d1a68 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/KakaoLoginClient.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/client/KakaoLoginClient.kt @@ -1,14 +1,15 @@ -package com.ninecraft.booket.feature.login +package com.ninecraft.booket.feature.login.client import android.content.Context import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.AuthError import com.kakao.sdk.user.UserApiClient -import com.ninecraft.booket.core.designsystem.R as designR -import javax.inject.Inject +import com.ninecraft.booket.core.designsystem.R import com.orhanobut.logger.Logger +import dev.zacsweers.metro.Inject -internal class KakaoLoginClient @Inject constructor() { +@Inject +internal class KakaoLoginClient { fun loginWithKakao( context: Context, onSuccess: (String) -> Unit, @@ -18,7 +19,7 @@ internal class KakaoLoginClient @Inject constructor() { when { error != null -> handleLoginError(context, error, onFailure) token != null -> handleLoginSuccess(token, onSuccess, onFailure, context) - else -> onFailure(context.getString(designR.string.unknown_error_message)) + else -> onFailure(context.getString(R.string.unknown_error_message)) } } @@ -37,12 +38,12 @@ internal class KakaoLoginClient @Inject constructor() { when { (error is AuthError && error.response.error == "ProtocolError") -> { Logger.e("로그인 실패: ${error.response.error}, ${error.response.errorDescription}") - onFailure(context.getString(designR.string.network_error_message)) + onFailure(context.getString(R.string.network_error_message)) } else -> { Logger.e("로그인 실패: ${error.message}") - onFailure(context.getString(designR.string.unknown_error_message)) + onFailure(context.getString(R.string.unknown_error_message)) } } } @@ -56,7 +57,7 @@ internal class KakaoLoginClient @Inject constructor() { UserApiClient.instance.me { user, _ -> user?.let { onSuccess(token.accessToken) - } ?: onFailure(context.getString(designR.string.unknown_error_message)) + } ?: onFailure(context.getString(R.string.unknown_error_message)) } } } diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/component/LoginTooltipBox.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/component/LoginTooltipBox.kt new file mode 100644 index 000000000..6dba04c10 --- /dev/null +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/component/LoginTooltipBox.kt @@ -0,0 +1,99 @@ +package com.ninecraft.booket.feature.login.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +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.login.R + +private val TriangleShape = object : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val path = Path().apply { + // 왼쪽 위 + moveTo(0f, 0f) + // 오른쪽 위 + lineTo(size.width, 0f) + // 중앙 아래 (뾰족한 부분) + lineTo(size.width / 2, size.height) + // 닫기 + close() + } + return Outline.Generic(path) + } +} + +@Composable +internal fun LoginTooltipBox( + @StringRes messageResId: Int, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Box( + modifier = Modifier + .shadow(ReedTheme.radius.xs, RoundedCornerShape(ReedTheme.radius.xs), clip = false) + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(ReedTheme.colors.contentBrand) + .padding( + horizontal = ReedTheme.spacing.spacing2, + vertical = ReedTheme.spacing.spacing15, + ), + ) { + Text( + text = stringResource(messageResId), + color = ReedTheme.colors.contentInverse, + style = ReedTheme.typography.caption1Medium, + ) + } + Box( + Modifier + .width(ReedTheme.spacing.spacing3) + .height(ReedTheme.spacing.spacing3 / 2) + .offset { + IntOffset( + x = 8.dp.roundToPx(), + y = 0, + ) + } + .graphicsLayer { + shadowElevation = 8.dp.toPx() + shape = TriangleShape + clip = true + } + .background(ReedTheme.colors.contentBrand), + ) + } +} + +@ComponentPreview +@Composable +private fun LoginTooltipBoxPreview() { + ReedTheme { + LoginTooltipBox(messageResId = R.string.recent_login) + } +} 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 a1f4cf042..142d0d3f0 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 @@ -20,21 +20,28 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.popUntil 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class TermsAgreementPresenter @AssistedInject constructor( +@AssistedInject +class TermsAgreementPresenter( @Assisted private val screen: TermsAgreementScreen, @Assisted private val navigator: Navigator, private val userRepository: UserRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(TermsAgreementScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: TermsAgreementScreen, navigator: Navigator): TermsAgreementPresenter + } + @Composable override fun present(): TermsAgreementUiState { val scope = rememberCoroutineScope() @@ -101,13 +108,4 @@ class TermsAgreementPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(TermsAgreementScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create( - screen: TermsAgreementScreen, - navigator: Navigator, - ): TermsAgreementPresenter - } } 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 2443ce0bf..39fe1adab 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 @@ -31,11 +31,11 @@ import com.ninecraft.booket.feature.screens.TermsAgreementScreen import com.ninecraft.booket.feature.termsagreement.component.TermItem import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import kotlinx.collections.immutable.persistentListOf @TraceRecomposition -@CircuitInject(TermsAgreementScreen::class, ActivityRetainedComponent::class) +@CircuitInject(TermsAgreementScreen::class, AppScope::class) @Composable internal fun TermsAgreementUi( state: TermsAgreementUiState, diff --git a/feature/login/src/main/res/drawable/ic_google.xml b/feature/login/src/main/res/drawable/ic_google.xml new file mode 100644 index 000000000..4b1067920 --- /dev/null +++ b/feature/login/src/main/res/drawable/ic_google.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/feature/login/src/main/res/values/strings.xml b/feature/login/src/main/res/values/strings.xml index 51ac38dfb..dc136219b 100644 --- a/feature/login/src/main/res/values/strings.xml +++ b/feature/login/src/main/res/values/strings.xml @@ -2,10 +2,12 @@ 책 덮기 전 한 문장을 기록해보세요 카카오로 시작하기 + Google로 시작하기 약관 동의 후\n독서 기록을 남겨보세요 약관 전체 동의 시작하기 회원가입 없이 둘러보기 + 최근 로그인 (필수)서비스 이용약관 (필수)개인정보처리방침 diff --git a/feature/login/stability/login.stability b/feature/login/stability/login.stability index 0d5e70760..4afc84104 100644 --- a/feature/login/stability/login.stability +++ b/feature/login/stability/login.stability @@ -6,57 +6,47 @@ @Composable internal fun com.ninecraft.booket.feature.login.HandleLoginSideEffects(state: com.ninecraft.booket.feature.login.LoginUiState, eventSink: kotlin.Function1): kotlin.Unit - skippable: true + skippable: false restartable: true params: - - state: STABLE + - state: UNSTABLE (has mutable properties or unstable members) - eventSink: STABLE (function type) -@Composable -public fun com.ninecraft.booket.feature.login.LoginPresenter.present(): com.ninecraft.booket.feature.login.LoginUiState - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.login.LoginPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.login.LoginUi(state: com.ninecraft.booket.feature.login.LoginUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true + skippable: false restartable: true params: - - state: STABLE + - state: UNSTABLE (has mutable properties or unstable members) - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.ninecraft.booket.feature.termsagreement.HandleTermsAgreementSideEffects(state: com.ninecraft.booket.feature.termsagreement.TermsAgreementUiState): kotlin.Unit +internal fun com.ninecraft.booket.feature.login.component.LoginTooltipBox(messageResId: kotlin.Int, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - messageResId: STABLE (primitive type) + - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.ninecraft.booket.feature.termsagreement.TermsAgreementPresenter.present(): com.ninecraft.booket.feature.termsagreement.TermsAgreementUiState +private fun com.ninecraft.booket.feature.login.component.LoginTooltipBoxPreview(): kotlin.Unit skippable: true restartable: true params: @Composable -private fun com.ninecraft.booket.feature.termsagreement.TermsAgreementPreview(): kotlin.Unit - skippable: true +internal fun com.ninecraft.booket.feature.termsagreement.HandleTermsAgreementSideEffects(state: com.ninecraft.booket.feature.termsagreement.TermsAgreementUiState): kotlin.Unit + skippable: false restartable: true params: + - state: UNSTABLE (has mutable properties or unstable members) @Composable internal fun com.ninecraft.booket.feature.termsagreement.TermsAgreementUi(state: com.ninecraft.booket.feature.termsagreement.TermsAgreementUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true + skippable: false restartable: true params: - - state: STABLE + - state: UNSTABLE (has mutable properties or unstable members) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -71,9 +61,3 @@ internal fun com.ninecraft.booket.feature.termsagreement.component.TermItem(titl - hasDetailAction: STABLE (primitive type) - onDetailClick: STABLE (function type) -@Composable -private fun com.ninecraft.booket.feature.termsagreement.component.TermItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index b5e1791e1..343697107 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) } @@ -8,12 +6,10 @@ android { namespace = "com.ninecraft.booket.feature.main" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( + projects.core.di, + libs.androidx.activity.compose, libs.androidx.splash, 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 cdc440cbd..6f793b50a 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 @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.main +import android.app.Activity import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -17,20 +18,25 @@ import com.ninecraft.booket.core.common.event.EventHelper import com.ninecraft.booket.core.common.event.ReedEvent import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.core.di.ActivityKey import com.ninecraft.booket.feature.screens.SplashScreen import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator -import dagger.hilt.android.AndroidEntryPoint +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController -import javax.inject.Inject -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - @Inject - lateinit var circuit: Circuit +@ContributesIntoMap(AppScope::class, binding = binding()) +@ActivityKey(MainActivity::class) +@Inject +class MainActivity( + private val circuit: Circuit, +) : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 95b589947..7038431e4 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.kotlin.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.onboarding" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.logger, 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 63304a27e..76ed72952 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 @@ -12,20 +12,27 @@ 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 -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch const val ONBOARDING_STEPS_COUNT = 3 -class OnboardingPresenter @AssistedInject constructor( +@AssistedInject +class OnboardingPresenter( @Assisted private val navigator: Navigator, private val repository: UserRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(OnboardingScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): OnboardingPresenter + } + @Composable override fun present(): OnboardingUiState { val scope = rememberCoroutineScope() @@ -59,10 +66,4 @@ class OnboardingPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(OnboardingScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): OnboardingPresenter - } } diff --git a/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingUi.kt b/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingUi.kt index 444d9c4bd..8419da5c1 100644 --- a/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingUi.kt +++ b/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/OnboardingUi.kt @@ -25,10 +25,10 @@ import com.ninecraft.booket.feature.onboarding.component.PagerIndicator import com.ninecraft.booket.feature.screens.OnboardingScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope @TraceRecomposition -@CircuitInject(OnboardingScreen::class, ActivityRetainedComponent::class) +@CircuitInject(OnboardingScreen::class, AppScope::class) @Composable internal fun OnboardingUi( state: OnboardingUiState, @@ -86,7 +86,7 @@ internal fun OnboardingUi( onClick = { state.eventSink(OnboardingUiEvent.OnNextButtonClick(state.pagerState.currentPage)) }, - text = stringResource(R.string.next), + text = stringResource(R.string.start), sizeStyle = largeButtonStyle, colorStyle = ReedButtonColorStyle.PRIMARY, modifier = Modifier diff --git a/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/component/OnboardingPage.kt b/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/component/OnboardingPage.kt index 0d451bd05..39f99eeca 100644 --- a/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/component/OnboardingPage.kt +++ b/feature/onboarding/src/main/kotlin/com/ninecraft/booket/feature/onboarding/component/OnboardingPage.kt @@ -13,8 +13,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.utils.HighlightedText import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.Black @@ -25,10 +25,21 @@ import com.ninecraft.booket.feature.onboarding.R internal fun OnboardingPage( imageRes: Int, titleRes: Int, - highlightTextRes: Int, + highlightTextRes: Int? = null, descriptionRes: Int, modifier: Modifier = Modifier, ) { + val titleText = + if (highlightTextRes != null) { + HighlightedText( + fullText = stringResource(titleRes), + highlightText = stringResource(highlightTextRes), + highlightColor = ReedTheme.colors.bgPrimary, + ) + } else { + AnnotatedString(stringResource(titleRes)) + } + Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -38,17 +49,11 @@ internal fun OnboardingPage( painter = painterResource(imageRes), contentDescription = "Onboarding Graphic", contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .height(274.dp), + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) Text( - text = HighlightedText( - fullText = stringResource(titleRes), - highlightText = stringResource(highlightTextRes), - highlightColor = ReedTheme.colors.bgPrimary, - ), + text = titleText, color = Black, textAlign = TextAlign.Center, style = ReedTheme.typography.heading1Bold, diff --git a/feature/onboarding/src/main/res/drawable/img_onboarding_second.webp b/feature/onboarding/src/main/res/drawable/img_onboarding_second.webp index 2e88d60d8..0bebce7a5 100644 Binary files a/feature/onboarding/src/main/res/drawable/img_onboarding_second.webp and b/feature/onboarding/src/main/res/drawable/img_onboarding_second.webp differ diff --git a/feature/onboarding/src/main/res/drawable/img_onboarding_third.webp b/feature/onboarding/src/main/res/drawable/img_onboarding_third.webp index d401228d4..171ef35a9 100644 Binary files a/feature/onboarding/src/main/res/drawable/img_onboarding_third.webp and b/feature/onboarding/src/main/res/drawable/img_onboarding_third.webp differ diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml index c065465fa..e33445fe2 100644 --- a/feature/onboarding/src/main/res/values/strings.xml +++ b/feature/onboarding/src/main/res/values/strings.xml @@ -1,13 +1,13 @@ - 다음 + 시작하기 읽고 있는 책을 등록하고\n바로 기록해보세요 책을 덮기 전, 마음에 남은 문장과\n감정을 간편하게 남길 수 있어요 - 어떻게 쓸지 막막할땐,\n감상평 가이드가 도와드려요 - 감정과 생각을 이끌어주는\n문장들이 기록을 자연스럽게 도와줘요 - 독서 중 느낀 감정은\n씨앗으로 남겨보세요 - 책마다 쌓인 감정들은\n나만의 독서에 흔적이 됩니다 + 독서 중 느낀 감정을\n자세히 남겨 보세요 + 책마다 쌓인 감정들은\n나만의 독서 흔적이 됩니다 + 기록한 문장을\n카드로 공유해 보세요 + 감정 캐릭터와 함께\n이미지로 저장하고 공유할 수 있어요 기록 - 감상평 가이드 - 씨앗 + 감정 + 공유 diff --git a/feature/onboarding/stability/onboarding.stability b/feature/onboarding/stability/onboarding.stability index f01b11fc3..cf927d4cb 100644 --- a/feature/onboarding/stability/onboarding.stability +++ b/feature/onboarding/stability/onboarding.stability @@ -10,37 +10,25 @@ public fun com.ninecraft.booket.feature.onboarding.OnboardingPresenter.present() restartable: true params: -@Composable -private fun com.ninecraft.booket.feature.onboarding.OnboardingScreenPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.onboarding.OnboardingUi(state: com.ninecraft.booket.feature.onboarding.OnboardingUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.ninecraft.booket.feature.onboarding.component.OnboardingPage(imageRes: kotlin.Int, titleRes: kotlin.Int, highlightTextRes: kotlin.Int, descriptionRes: kotlin.Int, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.ninecraft.booket.feature.onboarding.component.OnboardingPage(imageRes: kotlin.Int, titleRes: kotlin.Int, highlightTextRes: kotlin.Int?, descriptionRes: kotlin.Int, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - imageRes: STABLE (primitive type) - titleRes: STABLE (primitive type) - - highlightTextRes: STABLE (primitive type) + - highlightTextRes: STABLE (class with no mutable properties) - descriptionRes: STABLE (primitive type) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.onboarding.component.OnboardingPagePreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.onboarding.component.PagerIndicator(pageCount: kotlin.Int, pagerState: androidx.compose.foundation.pager.PagerState, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -50,9 +38,3 @@ internal fun com.ninecraft.booket.feature.onboarding.component.PagerIndicator(pa - pagerState: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.onboarding.component.PagerIndicatorPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/record/build.gradle.kts b/feature/record/build.gradle.kts index 8b49d90b2..83a99665c 100644 --- a/feature/record/build.gradle.kts +++ b/feature/record/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.kotlin.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.record" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( projects.core.ocr, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/ImpressionGuideBottomSheet.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/ImpressionGuideBottomSheet.kt deleted file mode 100644 index 599b6dbd1..000000000 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/ImpressionGuideBottomSheet.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.ninecraft.booket.feature.record.component - -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.TextFieldState -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.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import com.ninecraft.booket.core.common.extensions.clickableSingle -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.theme.ReedTheme -import com.ninecraft.booket.core.ui.component.ReedBottomSheet -import com.ninecraft.booket.feature.record.R -import com.skydoves.compose.stability.runtime.TraceRecomposition -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList -import com.ninecraft.booket.core.designsystem.R as designR - -@TraceRecomposition -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ImpressionGuideBottomSheet( - onDismissRequest: () -> Unit, - sheetState: SheetState, - impressionState: TextFieldState, - impressionGuideList: ImmutableList, - beforeSelectedImpressionGuide: String, - selectedImpressionGuide: String, - onGuideClick: (Int) -> Unit, - onCloseButtonClick: () -> Unit, - onSelectionConfirmButtonClick: () -> Unit, -) { - ReedBottomSheet( - onDismissRequest = { - onDismissRequest() - }, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .padding( - start = ReedTheme.spacing.spacing5, - top = ReedTheme.spacing.spacing5, - end = ReedTheme.spacing.spacing5, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.impression_step_guide), - color = ReedTheme.colors.contentPrimary, - textAlign = TextAlign.Center, - style = ReedTheme.typography.heading2SemiBold, - ) - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_close), - contentDescription = "Close Icon", - modifier = Modifier.clickableSingle { - onCloseButtonClick() - }, - ) - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) - Text( - text = stringResource(R.string.impression_guide_description), - modifier = Modifier.fillMaxWidth(), - color = ReedTheme.colors.contentSecondary, - style = ReedTheme.typography.label1Medium, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = ReedTheme.spacing.spacing5), - verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), - ) { - impressionGuideList.forEachIndexed { index, guide -> - ImpressionGuideBox( - onClick = { - onGuideClick(index) - }, - impressionText = guide, - isSelected = selectedImpressionGuide == guide, - ) - } - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) - val isButtonEnabled = if (impressionState.text.isEmpty()) { - selectedImpressionGuide.isNotEmpty() - } else { - beforeSelectedImpressionGuide != selectedImpressionGuide - } - ReedButton( - onClick = { - onSelectionConfirmButtonClick() - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - modifier = Modifier.fillMaxWidth(), - enabled = isButtonEnabled, - text = stringResource(R.string.impression_guide_selection_done), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@ComponentPreview -@Composable -private fun ImpressionGuideBottomSheetPreview() { - val sheetState = SheetState( - skipPartiallyExpanded = true, - initialValue = SheetValue.Expanded, - positionalThreshold = { 0f }, - velocityThreshold = { 0f }, - ) - val impressionGuideList = listOf( - "에서 위로 받았다", - "이 마음에 남았다", - "에서 작가의 의도가 궁금하다", - "에 대한 다른 사람들의 생각이 궁금하다", - "에서 크게 공감이 된다", - "을 보고 예전 기억이 났다", - "에서 문장에 머물렀다", - ).toPersistentList() - - ReedTheme { - ImpressionGuideBottomSheet( - onDismissRequest = {}, - sheetState = sheetState, - impressionState = TextFieldState(), - impressionGuideList = impressionGuideList, - beforeSelectedImpressionGuide = "", - selectedImpressionGuide = "", - onGuideClick = {}, - onCloseButtonClick = {}, - onSelectionConfirmButtonClick = {}, - ) - } -} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/ImpressionGuideBox.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/ImpressionGuideBox.kt deleted file mode 100644 index 641d2cde9..000000000 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/ImpressionGuideBox.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.ninecraft.booket.feature.record.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.res.stringResource -import androidx.compose.ui.unit.dp -import com.ninecraft.booket.core.common.extensions.noRippleClickable -import com.ninecraft.booket.core.designsystem.ComponentPreview -import com.ninecraft.booket.core.designsystem.theme.Blank -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.skydoves.compose.stability.runtime.TraceRecomposition - -@TraceRecomposition -@Composable -fun ImpressionGuideBox( - onClick: () -> Unit, - impressionText: String, - modifier: Modifier = Modifier, - isSelected: Boolean = false, -) { - val bgColor = if (isSelected) ReedTheme.colors.bgTertiary else White - val borderColor = if (isSelected) ReedTheme.colors.borderBrand else ReedTheme.colors.borderPrimary - - Box( - modifier = modifier - .fillMaxWidth() - .background( - color = bgColor, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) - .clip(RoundedCornerShape(ReedTheme.radius.sm)) - .noRippleClickable { - onClick() - } - .padding( - horizontal = ReedTheme.spacing.spacing4, - vertical = ReedTheme.spacing.spacing4, - ), - ) { - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = stringResource(R.string.impression_guide_blank), - color = Blank, - style = ReedTheme.typography.label1SemiBold, - ) - Text( - text = impressionText, - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.label1SemiBold, - ) - } - } -} - -@ComponentPreview -@Composable -private fun ImpressionGuideBoxPreview() { - ReedTheme { - ImpressionGuideBox( - onClick = {}, - impressionText = "에서 위로 받았다", - ) - } -} 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/RecordTooltipBox.kt similarity index 79% rename from feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/CustomTooltipBox.kt rename to feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/component/RecordTooltipBox.kt index 80cc30b2d..fb70bdff2 100644 --- 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/RecordTooltipBox.kt @@ -12,26 +12,26 @@ 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.draw.shadow import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset 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( +internal fun RecordTooltipBox( @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), - ) + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(ReedTheme.colors.contentPrimary) .padding( horizontal = ReedTheme.spacing.spacing3, vertical = ReedTheme.spacing.spacing2, @@ -47,9 +47,12 @@ internal fun CustomTooltipBox( Modifier .padding(start = 2.dp) .size(ReedTheme.spacing.spacing3) - .offset( - x = (-10).dp, - ) + .offset { + IntOffset( + x = (-10).dp.roundToPx(), + y = 0, + ) + } .graphicsLayer { rotationZ = 45f shadowElevation = 8.dp.toPx() @@ -62,8 +65,8 @@ internal fun CustomTooltipBox( @ComponentPreview @Composable -private fun CustomTooltipBoxPreview() { +private fun RecordTooltipBoxPreview() { ReedTheme { - CustomTooltipBox(messageResId = R.string.scan_tooltip_message) + RecordTooltipBox(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 index 8b9e8569a..5b4a73177 100644 --- 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 @@ -14,7 +14,7 @@ internal fun HandleOcrSideEffects( RememberedEffect(state.sideEffect) { when (state.sideEffect) { is OcrSideEffect.ShowToast -> { - Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + Toast.makeText(context, state.sideEffect.message.asString(context), 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 84389369f..bd45e0994 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 @@ -2,54 +2,112 @@ package com.ninecraft.booket.feature.record.ocr import android.net.Uri 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 androidx.core.net.toUri import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.core.common.utils.UiText import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrSideEffect.ShowToast import com.ninecraft.booket.feature.screens.OcrScreen +import com.ninecraft.booket.feature.screens.OcrScreen.OcrResult 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -class OcrPresenter @AssistedInject constructor( +@AssistedInject +class OcrPresenter( @Assisted private val navigator: Navigator, private val recognizer: CloudOcrRecognizer, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(OcrScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): OcrPresenter + } + companion object { private const val RECORD_OCR_SENTENCE = "record_OCR_sentence" + private const val CAMERA_MAX_FAILURES = 2 } @Composable override fun present(): OcrUiState { val scope = rememberCoroutineScope() + var isLoading by rememberRetained { mutableStateOf(false) } var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) } var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) } + var selectedImage by rememberRetained { mutableStateOf("") } var sentenceList by rememberRetained { mutableStateOf(persistentListOf()) } var selectedIndices by rememberRetained { mutableStateOf(persistentSetOf()) } var mergedSentence by rememberRetained { mutableStateOf("") } var isTextDetectionFailed by rememberRetained { mutableStateOf(false) } + var isCameraRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) } + var isGalleryRecognitionFailedDialogVisible by rememberRetained { mutableStateOf(false) } var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) } - var isLoading by rememberRetained { mutableStateOf(false) } + var cameraFailureCount by rememberRetained { mutableIntStateOf(0) } var sideEffect by rememberRetained { mutableStateOf(null) } - fun recognizeText(imageUri: Uri) { + LaunchedEffect(isTextDetectionFailed) { + if (isTextDetectionFailed) { + delay(2000) + isTextDetectionFailed = false + } + } + + fun handleRecognitionSuccess(text: String) { + isTextDetectionFailed = false + cameraFailureCount = 0 + + val sentences = text + .split("\n") + .map { it.trim() } + .filter { it.isNotEmpty() } + + sentenceList = sentences.toPersistentList() + currentUi = OcrUi.RESULT + analyticsHelper.logScreenView(RECORD_OCR_SENTENCE) + } + + fun handleRecognitionFailure(source: RecognizeSource) { + when (source) { + RecognizeSource.CAMERA -> { + isTextDetectionFailed = true + cameraFailureCount += 1 + + if (cameraFailureCount > CAMERA_MAX_FAILURES) { + isCameraRecognitionFailedDialogVisible = true + } + } + + RecognizeSource.GALLERY -> { + isGalleryRecognitionFailedDialogVisible = true + } + } + } + + fun recognizeText(imageUri: Uri, source: RecognizeSource) { scope.launch { try { isLoading = true @@ -58,25 +116,17 @@ class OcrPresenter @AssistedInject constructor( val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty() 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) + handleRecognitionSuccess(text) } else { - isTextDetectionFailed = true + handleRecognitionFailure(source) } } .onFailure { exception -> - isTextDetectionFailed = true + handleRecognitionFailure(source) val handleErrorMessage = { message: String -> Logger.e("Cloud Vision API Error: ${exception.message}") - sideEffect = OcrSideEffect.ShowToast(message) + sideEffect = ShowToast(UiText.DirectString(message)) } handleException( @@ -111,14 +161,24 @@ class OcrPresenter @AssistedInject constructor( is OcrUiEvent.OnCaptureFailed -> { isLoading = false - sideEffect = OcrSideEffect.ShowToast("이미지 캡처에 실패했어요") + sideEffect = ShowToast(UiText.StringResource(R.string.ocr_capture_failed)) Logger.e("ImageCaptureException: ${event.exception.message}") } is OcrUiEvent.OnImageCaptured -> { isTextDetectionFailed = false - recognizeText(event.imageUri) + recognizeText(event.imageUri, RecognizeSource.CAMERA) + } + + is OcrUiEvent.OnImageSelected -> { + currentUi = OcrUi.IMAGE + selectedImage = event.imageUri + isTextDetectionFailed = false + cameraFailureCount = 0 + + val pareUri = selectedImage.toUri() + recognizeText(pareUri, RecognizeSource.GALLERY) } is OcrUiEvent.OnReCaptureButtonClick -> { @@ -128,7 +188,7 @@ class OcrPresenter @AssistedInject constructor( is OcrUiEvent.OnSelectionConfirmed -> { mergedSentence = selectedIndices .sorted().joinToString("") { sentenceList[it] } - navigator.pop(result = OcrScreen.OcrResult(mergedSentence)) + navigator.pop(result = OcrResult(mergedSentence)) } is OcrUiEvent.OnSentenceSelected -> { @@ -148,6 +208,19 @@ class OcrPresenter @AssistedInject constructor( is OcrUiEvent.OnRecaptureDialogDismissed -> { isRecaptureDialogVisible = false } + + OcrUiEvent.OnImageContentClosed -> { + currentUi = OcrUi.CAMERA + } + + OcrUiEvent.OnCameraRecognitionFailedDialogDismissed -> { + isCameraRecognitionFailedDialogVisible = false + cameraFailureCount = 0 + } + + OcrUiEvent.OnImageRecognitionFailedDialogDismissed -> { + isGalleryRecognitionFailedDialogVisible = false + } } } @@ -158,19 +231,16 @@ class OcrPresenter @AssistedInject constructor( return OcrUiState( currentUi = currentUi, isPermissionDialogVisible = isPermissionDialogVisible, + selectedImage = selectedImage, sentenceList = sentenceList, selectedIndices = selectedIndices, isTextDetectionFailed = isTextDetectionFailed, + isCameraRecognitionFailedDialogVisible = isCameraRecognitionFailedDialogVisible, + isGalleryRecognitionFailedDialogVisible = isGalleryRecognitionFailedDialogVisible, isRecaptureDialogVisible = isRecaptureDialogVisible, isLoading = isLoading, sideEffect = sideEffect, eventSink = ::handleEvent, ) } - - @CircuitInject(OcrScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): OcrPresenter - } } 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 82b9c56c6..45c520616 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 @@ -1,82 +1,17 @@ package com.ninecraft.booket.feature.record.ocr -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.provider.Settings -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -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 -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -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.produceState -import androidx.compose.runtime.remember -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.platform.LocalContext -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 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 -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.theme.Neutral950 -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.ReedCloseTopAppBar -import com.ninecraft.booket.core.ui.component.ReedDialog -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.record.ocr.content.OcrCameraContent +import com.ninecraft.booket.feature.record.ocr.content.OcrImageContent +import com.ninecraft.booket.feature.record.ocr.content.OcrResultContent import com.ninecraft.booket.feature.screens.OcrScreen import com.skydoves.compose.stability.runtime.TraceRecomposition 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 +import dev.zacsweers.metro.AppScope @TraceRecomposition -@CircuitInject(OcrScreen::class, ActivityRetainedComponent::class) +@CircuitInject(OcrScreen::class, AppScope::class) @Composable internal fun OcrUi( state: OcrUiState, @@ -85,345 +20,8 @@ internal fun OcrUi( HandleOcrSideEffects(state = state) when (state.currentUi) { - OcrUi.CAMERA -> CameraPreview(state = state, modifier = modifier) - OcrUi.RESULT -> TextScanResult(state = state, modifier = modifier) - } -} - -@TraceRecomposition -@Composable -private fun CameraPreview( - state: OcrUiState, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val permission = android.Manifest.permission.CAMERA - - /** - * Camera Permission Request - */ - val isGranted by produceState( - initialValue = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, - key1 = lifecycleOwner, // lifecycle 변경 시 재설정 - ) { - // 최초 동기화 - value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - - // 포그라운드 복귀 시 OS 권한 동기화 - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - if (value) { - state.eventSink(OcrUiEvent.OnHidePermissionDialog) - } else { - state.eventSink(OcrUiEvent.OnShowPermissionDialog) - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - awaitDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> - if (!granted) { - state.eventSink(OcrUiEvent.OnShowPermissionDialog) - } - } - val settingsLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { _ -> } - - // 최초 진입 시 권한 요청 - LaunchedEffect(Unit) { - if (!isGranted) { - state.eventSink(OcrUiEvent.OnHidePermissionDialog) - permissionLauncher.launch(permission) - } - } - - /** - * Camera Controller - */ - val cameraController = remember { LifecycleCameraController(context) } - - DisposableEffect(isGranted, lifecycleOwner, cameraController) { - if (isGranted) { - cameraController.bindToLifecycle(lifecycleOwner) - } - - onDispose { - cameraController.unbind() - } - } - - /** - * SystemStatusBar Color - */ - val systemUiController = rememberSystemUiController() - - DisposableEffect(systemUiController) { - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = false, - isNavigationBarContrastEnforced = false, - ) - - onDispose { - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = true, - isNavigationBarContrastEnforced = false, - ) - } - } - - ReedScaffold( - modifier = modifier.fillMaxSize(), - containerColor = Neutral950, - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - ReedCloseTopAppBar( - modifier = Modifier - .background(color = Color.Black) - .align(Alignment.TopCenter), - isDark = true, - onClose = { - state.eventSink(OcrUiEvent.OnCloseClick) - }, - ) - Text( - text = stringResource(R.string.ocr_guide), - modifier = Modifier - .align(Alignment.Center) - .offset(y = (-164).dp), - color = ReedTheme.colors.contentInverse, - textAlign = TextAlign.Center, - style = ReedTheme.typography.headline2Medium, - ) - - if (isGranted) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(White) - .height(200.dp) - .align(Alignment.Center), - ) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - PreviewView(context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - clipToOutline = true - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - scaleType = PreviewView.ScaleType.FILL_CENTER - controller = cameraController - } - }, - ) - } - CameraFrame(modifier = Modifier.align(Alignment.Center)) - } - - Column( - modifier = Modifier.align(Alignment.BottomCenter), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (state.isTextDetectionFailed) { - Text( - text = stringResource(R.string.ocr_error_text_detection_failed), - color = ReedTheme.colors.contentError, - textAlign = TextAlign.Center, - style = ReedTheme.typography.label2Regular, - ) - Spacer(modifier = Modifier.height(22.dp)) - } - - Button( - enabled = !state.isLoading, - onClick = { - 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, - colors = ButtonDefaults.buttonColors( - containerColor = ReedTheme.colors.bgPrimary, - contentColor = White, - ), - contentPadding = PaddingValues(ReedTheme.spacing.spacing0), - ) { - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize), - contentDescription = "Scan Icon", - modifier = Modifier.size(ReedTheme.spacing.spacing8), - ) - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) - } - - if (state.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) - } - } - } - } - - if (state.isPermissionDialogVisible) { - ReedDialog( - title = stringResource(R.string.permission_dialog_title), - description = stringResource(R.string.permission_dialog_description), - confirmButtonText = stringResource(R.string.permission_dialog_move_to_settings), - onConfirmRequest = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - settingsLauncher.launch(intent) - }, - ) - } -} - -@TraceRecomposition -@Composable -private fun TextScanResult( - state: OcrUiState, - modifier: Modifier = Modifier, -) { - ReedScaffold( - modifier = modifier.fillMaxSize(), - containerColor = White, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - ReedCloseTopAppBar( - title = stringResource(R.string.ocr_sentence_selection), - onClose = { - state.eventSink(OcrUiEvent.OnCloseClick) - }, - ) - LazyColumn( - modifier = Modifier - .weight(1f) - .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 = { - state.eventSink(OcrUiEvent.OnSentenceSelected(index)) - }, - sentence = state.sentenceList[index], - isSelected = state.selectedIndices.contains(index), - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - ) { - ReedButton( - onClick = { - state.eventSink(OcrUiEvent.OnReCaptureButtonClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.SECONDARY, - modifier = Modifier.weight(1f), - text = stringResource(R.string.ocr_recapture), - ) - Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) - ReedButton( - onClick = { - state.eventSink(OcrUiEvent.OnSelectionConfirmed) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - enabled = state.selectedIndices.isNotEmpty(), - modifier = Modifier.weight(1f), - text = stringResource(R.string.ocr_selection_confirm), - ) - } - } - } - - if (state.isRecaptureDialogVisible) { - ReedDialog( - title = stringResource(R.string.recapture_dialog_title), - description = stringResource(R.string.recapture_dialog_description), - confirmButtonText = stringResource(R.string.recapture_dialog_confirm), - onConfirmRequest = { - state.eventSink(OcrUiEvent.OnRecaptureDialogConfirmed) - }, - dismissButtonText = stringResource(R.string.recapture_dialog_cancel), - onDismissRequest = { - state.eventSink(OcrUiEvent.OnRecaptureDialogDismissed) - }, - ) - } -} - -@ComponentPreview -@Composable -private fun CameraPreviewPreview() { - ReedTheme { - CameraPreview( - state = OcrUiState( - eventSink = {}, - ), - ) - } -} - -@ComponentPreview -@Composable -private fun TextRecognitionResultPreview() { - ReedTheme { - TextScanResult( - state = OcrUiState( - eventSink = {}, - ), - ) + OcrUi.CAMERA -> OcrCameraContent(state = state, modifier = modifier) + OcrUi.IMAGE -> OcrImageContent(state = state, modifier = modifier) + OcrUi.RESULT -> OcrResultContent(state = state, modifier = modifier) } } 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 7b932e0be..4fa6fa665 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 @@ -2,6 +2,7 @@ package com.ninecraft.booket.feature.record.ocr import android.net.Uri import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.common.utils.UiText import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList @@ -13,9 +14,12 @@ import java.util.UUID data class OcrUiState( val currentUi: OcrUi = OcrUi.CAMERA, val isPermissionDialogVisible: Boolean = false, + val selectedImage: String = "", val sentenceList: ImmutableList = persistentListOf(), val selectedIndices: ImmutableSet = persistentSetOf(), val isTextDetectionFailed: Boolean = false, + val isCameraRecognitionFailedDialogVisible: Boolean = false, + val isGalleryRecognitionFailedDialogVisible: Boolean = false, val isRecaptureDialogVisible: Boolean = false, val isLoading: Boolean = false, val sideEffect: OcrSideEffect? = null, @@ -25,26 +29,36 @@ data class OcrUiState( @Immutable sealed interface OcrSideEffect { data class ShowToast( - val message: String, + val message: UiText, private val key: String = UUID.randomUUID().toString(), ) : OcrSideEffect } sealed interface OcrUiEvent : CircuitUiEvent { data object OnCloseClick : OcrUiEvent + data object OnImageContentClosed : OcrUiEvent data object OnShowPermissionDialog : OcrUiEvent data object OnHidePermissionDialog : OcrUiEvent data object OnCaptureStart : OcrUiEvent data class OnCaptureFailed(val exception: Exception) : OcrUiEvent data class OnImageCaptured(val imageUri: Uri) : OcrUiEvent + data class OnImageSelected(val imageUri: String) : OcrUiEvent data object OnReCaptureButtonClick : OcrUiEvent data object OnSelectionConfirmed : OcrUiEvent data object OnRecaptureDialogConfirmed : OcrUiEvent data object OnRecaptureDialogDismissed : OcrUiEvent + data object OnCameraRecognitionFailedDialogDismissed : OcrUiEvent + data object OnImageRecognitionFailedDialogDismissed : OcrUiEvent data class OnSentenceSelected(val index: Int) : OcrUiEvent } enum class OcrUi { CAMERA, + IMAGE, RESULT, } + +enum class RecognizeSource { + CAMERA, + GALLERY, +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt new file mode 100644 index 000000000..d6536047c --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraBottomBar.kt @@ -0,0 +1,89 @@ +package com.ninecraft.booket.feature.record.ocr.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +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.vectorResource +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.Neutral900 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.designsystem.R as designR + +@Composable +internal fun CameraBottomBar( + onGalleryClick: () -> Unit, + onCaptureClick: () -> Unit, + buttonEnabled: Boolean = true, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing6), + ) { + IconButton( + onClick = onGalleryClick, + modifier = Modifier + .size(ReedTheme.spacing.spacing12) + .align(Alignment.CenterStart), + enabled = buttonEnabled, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Neutral900, + contentColor = White, + ), + shape = RoundedCornerShape(ReedTheme.radius.sm), + ) { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_gallery), + contentDescription = "Gallery Icon", + modifier = Modifier + .size(ReedTheme.spacing.spacing6), + ) + } + Button( + onClick = onCaptureClick, + modifier = Modifier + .size(72.dp) + .align(Alignment.Center), + enabled = buttonEnabled, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = ReedTheme.colors.bgPrimary, + contentColor = White, + ), + contentPadding = PaddingValues(ReedTheme.spacing.spacing0), + ) { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize), + contentDescription = "Scan Icon", + modifier = Modifier.size(ReedTheme.spacing.spacing8), + ) + } + } +} + +@ComponentPreview +@Composable +private fun CameraBottomBarPreview() { + ReedTheme { + CameraBottomBar( + onGalleryClick = {}, + onCaptureClick = {}, + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraFrame.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraFrame.kt index 21f0e4c0b..230b1fee5 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraFrame.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/CameraFrame.kt @@ -1,6 +1,5 @@ package com.ninecraft.booket.feature.record.ocr.component -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -10,8 +9,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.ComponentPreview @@ -25,7 +23,6 @@ fun CameraFrame(modifier: Modifier = Modifier) { modifier = modifier .fillMaxWidth() .height(200.dp) - .background(Color.Transparent) .border( width = ReedTheme.spacing.spacing5, color = Neutral800.copy(alpha = 0.6f), @@ -47,8 +44,11 @@ fun CameraFrame(modifier: Modifier = Modifier) { painter = painterResource(R.drawable.img_frame_marker), contentDescription = "Frame Marker", modifier = Modifier - .scale(scaleX = -1f, scaleY = 1f) .align(Alignment.TopEnd) + .graphicsLayer { + scaleX = -1f + scaleY = 1f + } .padding( start = ReedTheme.spacing.spacing5, top = ReedTheme.spacing.spacing5, @@ -59,8 +59,11 @@ fun CameraFrame(modifier: Modifier = Modifier) { painter = painterResource(R.drawable.img_frame_marker), contentDescription = "Frame Marker", modifier = Modifier - .scale(scaleX = 1f, scaleY = -1f) .align(Alignment.BottomStart) + .graphicsLayer { + scaleX = 1f + scaleY = -1f + } .padding( start = ReedTheme.spacing.spacing5, top = ReedTheme.spacing.spacing5, @@ -71,8 +74,11 @@ fun CameraFrame(modifier: Modifier = Modifier) { painter = painterResource(R.drawable.img_frame_marker), contentDescription = "Frame Marker", modifier = Modifier - .scale(scaleX = -1f, scaleY = -1f) .align(Alignment.BottomEnd) + .graphicsLayer { + scaleX = -1f + scaleY = -1f + } .padding( start = ReedTheme.spacing.spacing5, top = ReedTheme.spacing.spacing5, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt new file mode 100644 index 000000000..3d8473a44 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/component/ImageProcessingLoader.kt @@ -0,0 +1,85 @@ +package com.ninecraft.booket.feature.record.ocr.component + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.Green100 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +internal fun ImageProcessingLoader( + modifier: Modifier = Modifier, +) { + val infiniteTransition = rememberInfiniteTransition(label = "loading") + + // 점들의 애니메이션 상태 리스트 (0ms, 300ms, 600ms 지연) + val dotAnimations = listOf(0, 1, 2).map { index -> + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0.0f at 0 using LinearOutSlowInEasing // 시작 + 1.0f at 300 using FastOutLinearInEasing // 최고점 + 0.0f at 600 using LinearOutSlowInEasing // 바닥 도착 + 0.0f at 1200 // 대기 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(index * 200), // 순차적 시작 지연 + ), + label = "dot_y_offset", + ) + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + dotAnimations.forEachIndexed { index, animValue -> + Box( + modifier = Modifier + .size(ReedTheme.spacing.spacing3) + // 애니메이션 값에 따라 y축 좌표 이동 + .graphicsLayer { + translationY = -animValue.value * 12.dp.toPx() + } + .background( + color = if (animValue.value > 0f) ReedTheme.colors.contentBrand else Green100, + shape = CircleShape, + ), + ) + + if (index < 2) { + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing3)) + } + } + } +} + +@ComponentPreview +@Composable +private fun ImageProcessingLoaderPreview() { + ReedTheme { + ImageProcessingLoader() + } +} 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 ddc188bfc..2f1b4e1cb 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 @@ -28,20 +28,18 @@ fun SentenceBox( val bgColor = if (isSelected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary val borderColor = if (isSelected) ReedTheme.colors.borderBrand else Color.Transparent val textStyle = if (isSelected) ReedTheme.typography.body1Medium else ReedTheme.typography.body1Regular + val cornerShape = RoundedCornerShape(ReedTheme.radius.sm) Box( modifier = modifier .fillMaxWidth() - .background( - color = bgColor, - shape = RoundedCornerShape(ReedTheme.radius.sm), - ) + .clip(cornerShape) + .background(bgColor) .border( width = 1.dp, color = borderColor, - shape = RoundedCornerShape(ReedTheme.radius.sm), + shape = cornerShape, ) - .clip(RoundedCornerShape(ReedTheme.radius.sm)) .noRippleClickable { onClick() } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt new file mode 100644 index 000000000..e1daf915c --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt @@ -0,0 +1,322 @@ +package com.ninecraft.booket.feature.record.ocr.content + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +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 +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +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.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +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 +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.Neutral950 +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.ReedCloseTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.CameraBottomBar +import com.ninecraft.booket.feature.record.ocr.component.CameraFrame +import com.skydoves.compose.stability.runtime.TraceRecomposition +import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController +import java.io.File + +@TraceRecomposition +@Composable +internal fun OcrCameraContent( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val permission = android.Manifest.permission.CAMERA + + /** + * Camera Permission Request + */ + val isGranted by produceState( + initialValue = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, + key1 = lifecycleOwner, // lifecycle 변경 시 재설정 + ) { + // 최초 동기화 + value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + // 포그라운드 복귀 시 OS 권한 동기화 + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + value = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + if (value) { + state.eventSink(OcrUiEvent.OnHidePermissionDialog) + } else { + state.eventSink(OcrUiEvent.OnShowPermissionDialog) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + awaitDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (!granted) { + state.eventSink(OcrUiEvent.OnShowPermissionDialog) + } + } + val settingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { _ -> } + + // 최초 진입 시 권한 요청 + LaunchedEffect(Unit) { + if (!isGranted) { + state.eventSink(OcrUiEvent.OnHidePermissionDialog) + permissionLauncher.launch(permission) + } + } + + /** + * Camera Controller + */ + val cameraController = remember { LifecycleCameraController(context) } + + DisposableEffect(isGranted, lifecycleOwner, cameraController) { + if (isGranted) { + cameraController.bindToLifecycle(lifecycleOwner) + } + + onDispose { + cameraController.unbind() + } + } + + /** + * SystemStatusBar Color + */ + val systemUiController = rememberSystemUiController() + + DisposableEffect(systemUiController) { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = false, + isNavigationBarContrastEnforced = false, + ) + + onDispose { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = true, + isNavigationBarContrastEnforced = false, + ) + } + } + + /** + * Image Picker + */ + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + if (uri != null) { + state.eventSink(OcrUiEvent.OnImageSelected(uri.toString())) + } + }, + ) + + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Neutral950, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ReedCloseTopAppBar( + modifier = Modifier + .background(color = Color.Black) + .align(Alignment.TopCenter), + isDark = true, + onClose = { + state.eventSink(OcrUiEvent.OnCloseClick) + }, + ) + Text( + text = stringResource(R.string.ocr_guide), + modifier = Modifier + .align(Alignment.Center) + .offset { + IntOffset( + x = 0, + y = (-164).dp.roundToPx(), + ) + }, + color = ReedTheme.colors.contentInverse, + textAlign = TextAlign.Center, + style = ReedTheme.typography.headline2Medium, + ) + + if (isGranted) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = White) + .height(200.dp) + .align(Alignment.Center), + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + PreviewView(context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + clipToOutline = true + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + controller = cameraController + } + }, + ) + } + CameraFrame(modifier = Modifier.align(Alignment.Center)) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state.isTextDetectionFailed) { + Text( + text = stringResource(R.string.ocr_error_text_detection_failed), + color = ReedTheme.colors.contentError, + textAlign = TextAlign.Center, + style = ReedTheme.typography.label2Regular, + ) + Spacer(modifier = Modifier.height(22.dp)) + } + CameraBottomBar( + onGalleryClick = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + onCaptureClick = { + 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)) + } + }, + ) + }, + buttonEnabled = !state.isLoading, + ) + + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } + + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } + } + } + } + + if (state.isPermissionDialogVisible) { + ReedDialog( + title = stringResource(R.string.permission_dialog_title), + description = stringResource(R.string.permission_dialog_description), + confirmButtonText = stringResource(R.string.permission_dialog_move_to_settings), + onConfirmRequest = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + settingsLauncher.launch(intent) + }, + ) + } + + if (state.isCameraRecognitionFailedDialogVisible) { + ReedDialog( + title = stringResource(R.string.ocr_recognition_failed_dialog_title), + description = stringResource(R.string.ocr_recognition_failed_dialog_description), + confirmButtonText = stringResource(R.string.ocr_recognition_failed_dialog_direct_input), + onConfirmRequest = { + state.eventSink(OcrUiEvent.OnCloseClick) + }, + dismissButtonText = stringResource(R.string.ocr_recognition_failed_dialog_camera), + onDismissRequest = { + state.eventSink(OcrUiEvent.OnCameraRecognitionFailedDialogDismissed) + }, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrCameraContentPreview() { + ReedTheme { + OcrCameraContent( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt new file mode 100644 index 000000000..0cb9e7738 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrImageContent.kt @@ -0,0 +1,125 @@ +package com.ninecraft.booket.feature.record.ocr.content + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.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.res.stringResource +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.theme.Black +import com.ninecraft.booket.core.designsystem.theme.Neutral950 +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.ImageProcessingLoader + +@Composable +internal fun OcrImageContent( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + if (uri != null) { + state.eventSink(OcrUiEvent.OnImageSelected(uri.toString())) + } + }, + ) + + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Neutral950, + ) { innerPadding -> + Column( + modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + ReedCloseTopAppBar( + modifier = Modifier + .background(color = Color.Black), + isDark = true, + onClose = { + state.eventSink(OcrUiEvent.OnImageContentClosed) + }, + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + NetworkImage( + imageUrl = state.selectedImage, + contentDescription = "Selected Image", + modifier = Modifier.fillMaxWidth(), + ) + Box( + modifier = Modifier + .matchParentSize() + .background(Black.copy(alpha = 0.5f)), + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.ocr_image_scanning), + color = ReedTheme.colors.contentInverse, + style = ReedTheme.typography.headline2Medium, + ) + Spacer(modifier = Modifier.height(42.dp)) + ImageProcessingLoader() + } + } + } + } + + if (state.isGalleryRecognitionFailedDialogVisible) { + ReedDialog( + title = stringResource(R.string.ocr_recognition_failed_dialog_title), + description = stringResource(R.string.ocr_recognition_failed_dialog_description), + confirmButtonText = stringResource(R.string.ocr_recognition_failed_dialog_direct_input), + onConfirmRequest = { + state.eventSink(OcrUiEvent.OnCloseClick) + }, + dismissButtonText = stringResource(R.string.ocr_recognition_failed_dialog_image), + onDismissRequest = { + state.eventSink(OcrUiEvent.OnImageRecognitionFailedDialogDismissed) + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + dismissOnClickOutside = false, + dismissOnBackPress = false, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrImageContentPreview() { + ReedTheme { + OcrImageContent( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt new file mode 100644 index 000000000..d78c7d63b --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrResultContent.kt @@ -0,0 +1,130 @@ +package com.ninecraft.booket.feature.record.ocr.content + +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar +import com.ninecraft.booket.core.ui.component.ReedDialog +import com.ninecraft.booket.feature.record.R +import com.ninecraft.booket.feature.record.ocr.OcrUiEvent +import com.ninecraft.booket.feature.record.ocr.OcrUiState +import com.ninecraft.booket.feature.record.ocr.component.SentenceBox +import com.skydoves.compose.stability.runtime.TraceRecomposition + +@TraceRecomposition +@Composable +internal fun OcrResultContent( + state: OcrUiState, + modifier: Modifier = Modifier, +) { + ReedScaffold( + modifier = modifier.fillMaxSize(), + containerColor = White, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ReedCloseTopAppBar( + title = stringResource(R.string.ocr_sentence_selection), + onClose = { + state.eventSink(OcrUiEvent.OnReCaptureButtonClick) + }, + ) + LazyColumn( + modifier = Modifier + .weight(1f) + .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 = { + state.eventSink(OcrUiEvent.OnSentenceSelected(index)) + }, + sentence = state.sentenceList[index], + isSelected = state.selectedIndices.contains(index), + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + ) { + ReedButton( + onClick = { + state.eventSink(OcrUiEvent.OnReCaptureButtonClick) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.weight(1f), + text = stringResource(R.string.ocr_recapture), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + ReedButton( + onClick = { + state.eventSink(OcrUiEvent.OnSelectionConfirmed) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + enabled = state.selectedIndices.isNotEmpty(), + modifier = Modifier.weight(1f), + text = stringResource(R.string.ocr_selection_confirm), + ) + } + } + } + + if (state.isRecaptureDialogVisible) { + ReedDialog( + title = stringResource(R.string.recapture_dialog_title), + description = stringResource(R.string.recapture_dialog_description), + confirmButtonText = stringResource(R.string.recapture_dialog_confirm), + onConfirmRequest = { + state.eventSink(OcrUiEvent.OnRecaptureDialogConfirmed) + }, + dismissButtonText = stringResource(R.string.recapture_dialog_cancel), + onDismissRequest = { + state.eventSink(OcrUiEvent.OnRecaptureDialogDismissed) + }, + ) + } +} + +@ComponentPreview +@Composable +private fun OcrResultContentPreview() { + ReedTheme { + OcrResultContent( + state = OcrUiState( + eventSink = {}, + ), + ) + } +} 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 16abd5ca7..148d27e20 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 @@ -3,18 +3,20 @@ package com.ninecraft.booket.feature.record.register import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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 androidx.compose.ui.text.TextRange import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.data.api.repository.EmotionRepository import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.designsystem.RecordStep -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OcrScreen import com.ninecraft.booket.feature.screens.RecordDetailScreen @@ -27,26 +29,36 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class RecordRegisterPresenter @AssistedInject constructor( +@AssistedInject +class RecordRegisterPresenter( @Assisted private val screen: RecordScreen, @Assisted private val navigator: Navigator, private val repository: RecordRepository, + private val emotionRepository: EmotionRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(RecordScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: RecordScreen, navigator: Navigator): RecordRegisterPresenter + } + 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" @@ -56,31 +68,22 @@ class RecordRegisterPresenter @AssistedInject constructor( override fun present(): RecordRegisterUiState { val scope = rememberCoroutineScope() var isLoading by rememberRetained { mutableStateOf(false) } + var emotionUiState by rememberRetained { mutableStateOf(EmotionUiState.Idle) } var sideEffect by rememberRetained { mutableStateOf(null) } var currentStep by rememberRetained { mutableStateOf(RecordStep.QUOTE) } val recordPageState = rememberTextFieldState() val recordSentenceState = rememberTextFieldState() - val impressionGuideList by rememberRetained { - mutableStateOf( - listOf( - "에서 위로 받았다", - "이 마음에 남았다", - "에서 작가의 의도가 궁금하다", - "에 대한 다른 사람들의 생각이 궁금하다", - "에서 크게 공감이 된다", - "을 보고 예전 기억이 났다", - "에서 문장에 머물렀다", - ).toPersistentList(), - ) - } - val emotions by rememberRetained { mutableStateOf(Emotion.entries.toPersistentList()) } - var selectedEmotion by rememberRetained { mutableStateOf(null) } - var selectedImpressionGuide by rememberRetained { mutableStateOf("") } - var beforeSelectedImpressionGuide by rememberRetained { mutableStateOf(selectedImpressionGuide) } - val impressionState = rememberTextFieldState() + val memoState = rememberTextFieldState() + var emotionGroups by rememberRetained { mutableStateOf(persistentListOf()) } + var pendingEmotionCode by rememberRetained { mutableStateOf(null) } + var selectedEmotionCode by rememberRetained { mutableStateOf(null) } + var selectedEmotionMap by rememberRetained { mutableStateOf>>(persistentMapOf()) } + var committedEmotionCode by rememberRetained { mutableStateOf(null) } + var committedEmotionMap by rememberRetained { mutableStateOf>>(persistentMapOf()) } + var isEmotionDetailBottomSheetVisible by rememberRetained { mutableStateOf(false) } var savedRecordId by rememberRetained { mutableStateOf("") } - var isImpressionGuideBottomSheetVisible by rememberRetained { mutableStateOf(false) } var isExitDialogVisible by rememberRetained { mutableStateOf(false) } + var isEmotionEditDialogVisible by rememberRetained { mutableStateOf(false) } var isRecordSavedDialogVisible by rememberRetained { mutableStateOf(false) } val isPageError by remember { derivedStateOf { @@ -92,19 +95,15 @@ class RecordRegisterPresenter @AssistedInject constructor( derivedStateOf { when (currentStep) { RecordStep.QUOTE -> { - recordPageState.text.isNotEmpty() && recordSentenceState.text.isNotEmpty() && !isPageError + recordSentenceState.text.isNotEmpty() && !isPageError } RecordStep.EMOTION -> { - selectedEmotion != null + committedEmotionCode != null } - - RecordStep.IMPRESSION -> true } } } - var isScanTooltipVisible by rememberRetained { mutableStateOf(true) } - var isImpressionGuideTooltipVisible by rememberRetained { mutableStateOf(true) } val ocrNavigator = rememberAnsweringNavigator(navigator) { result -> recordSentenceState.edit { @@ -115,10 +114,11 @@ class RecordRegisterPresenter @AssistedInject constructor( fun postRecord( userBookId: String, - pageNumber: Int, + pageNumber: Int?, quote: String, - emotionTags: List, - impression: String, + primaryEmotion: String, + detailEmotionTagIds: List, + review: String, ) { scope.launch { try { @@ -127,8 +127,9 @@ class RecordRegisterPresenter @AssistedInject constructor( userBookId = userBookId, pageNumber = pageNumber, quote = quote, - emotionTags = emotionTags, - review = impression, + review = review, + primaryEmotion = primaryEmotion, + detailEmotionTagIds = detailEmotionTagIds, ).onSuccess { result -> analyticsHelper.logEvent(RECORD_COMPLETE) savedRecordId = result.id @@ -154,6 +155,31 @@ class RecordRegisterPresenter @AssistedInject constructor( } } + fun getEmotionGroups() { + scope.launch { + emotionUiState = EmotionUiState.Loading + emotionRepository.getEmotions() + .onSuccess { result -> + emotionUiState = EmotionUiState.Success + emotionGroups = result.emotions.toPersistentList() + }.onFailure { exception -> + emotionUiState = EmotionUiState.Error(exception) + + val handleErrorMessage = { message: String -> + Logger.e(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen()) + }, + ) + } + } + } + fun handleEvent(event: RecordRegisterUiEvent) { when (event) { is RecordRegisterUiEvent.OnBackButtonClick -> { @@ -165,10 +191,6 @@ class RecordRegisterPresenter @AssistedInject constructor( RecordStep.EMOTION -> { currentStep = RecordStep.QUOTE } - - RecordStep.IMPRESSION -> { - currentStep = RecordStep.EMOTION - } } } @@ -188,55 +210,75 @@ class RecordRegisterPresenter @AssistedInject constructor( } is RecordRegisterUiEvent.OnSentenceScanButtonClick -> { - isScanTooltipVisible = false ocrNavigator.goTo(OcrScreen) } - is RecordRegisterUiEvent.OnSelectEmotion -> { - selectedEmotion = event.emotion + is RecordRegisterUiEvent.OnSelectEmotionCode -> { + if (selectedEmotionCode != null && selectedEmotionCode != event.emotionCode) { + pendingEmotionCode = event.emotionCode + isEmotionEditDialogVisible = true + } else { + selectedEmotionCode = event.emotionCode + + if (selectedEmotionCode == EmotionCode.OTHER) { + committedEmotionCode = selectedEmotionCode + committedEmotionMap = persistentMapOf() + selectedEmotionMap = persistentMapOf() + } else { + isEmotionDetailBottomSheetVisible = true + } + } } - is RecordRegisterUiEvent.OnImpressionGuideButtonClick -> { - analyticsHelper.logScreenView(RECORD_INPUT_HELP) - isImpressionGuideTooltipVisible = false - beforeSelectedImpressionGuide = selectedImpressionGuide - if (impressionState.text.isEmpty()) { - selectedImpressionGuide = "" + is RecordRegisterUiEvent.OnEmotionDetailToggled -> { + val emotionKey = selectedEmotionCode ?: return + val currentDetails = selectedEmotionMap[selectedEmotionCode].orEmpty() + val updatedDetails = if (event.detailId in currentDetails) { + currentDetails - event.detailId + } else { + currentDetails + event.detailId } - isImpressionGuideBottomSheetVisible = true + + selectedEmotionMap = selectedEmotionMap.put(emotionKey, updatedDetails.toPersistentList()) } - is RecordRegisterUiEvent.OnSelectImpressionGuide -> { - val index = event.index - if (index in impressionGuideList.indices) { - selectedImpressionGuide = impressionGuideList[index] - } + is RecordRegisterUiEvent.OnEmotionDetailRemoved -> { + val emotionKey = selectedEmotionCode ?: return + val currentDetails = committedEmotionMap[selectedEmotionCode].orEmpty() + val updatedDetails = currentDetails - event.detailId + + committedEmotionMap = committedEmotionMap.put(emotionKey, updatedDetails.toPersistentList()) + selectedEmotionMap = selectedEmotionMap.put(emotionKey, updatedDetails.toPersistentList()) } - is RecordRegisterUiEvent.OnImpressionGuideConfirmed -> { - val currentImpressionText = impressionState.text.toString() + is RecordRegisterUiEvent.OnEmotionDetailSkipped -> { + committedEmotionCode = selectedEmotionCode + // 건너뛰기 시 세부감정 선택 초기화 + committedEmotionMap = persistentMapOf() + selectedEmotionMap = persistentMapOf() + isEmotionDetailBottomSheetVisible = false + } - if (currentImpressionText.isNotEmpty()) { - // 이미 작성된 감상문이 있는 경우 줄바꿈해서 추가 - val startIndex = currentImpressionText.length + is RecordRegisterUiEvent.OnEmotionDetailCommitted -> { + val emotionKey = selectedEmotionCode ?: return + val details = selectedEmotionMap[emotionKey] ?: persistentListOf() - impressionState.edit { - replace(0, length, currentImpressionText + "\n" + selectedImpressionGuide) - this.selection = TextRange(startIndex + 1) // 줄바꿈한 문장 맨 앞에 커서 위치 - } + committedEmotionCode = emotionKey + committedEmotionMap = persistentMapOf(emotionKey to details) + selectedEmotionMap = persistentMapOf(emotionKey to details) + isEmotionDetailBottomSheetVisible = false + } + + is RecordRegisterUiEvent.OnEmotionDetailBottomSheetDismiss -> { + if (committedEmotionCode == null) { + selectedEmotionCode = null + selectedEmotionMap = persistentMapOf() } else { - impressionState.edit { - replace(0, length, "") - append(selectedImpressionGuide) - this.selection = TextRange(0) // 커서를 문장 맨 앞에 위치 - } + selectedEmotionCode = committedEmotionCode + selectedEmotionMap = committedEmotionMap } - isImpressionGuideBottomSheetVisible = false - } - - is RecordRegisterUiEvent.OnImpressionGuideBottomSheetDismiss -> { - isImpressionGuideBottomSheetVisible = false + isEmotionDetailBottomSheetVisible = false } is RecordRegisterUiEvent.OnNextButtonClick -> { @@ -246,16 +288,13 @@ class RecordRegisterPresenter @AssistedInject constructor( } RecordStep.EMOTION -> { - currentStep = RecordStep.IMPRESSION - } - - RecordStep.IMPRESSION -> { postRecord( userBookId = screen.userBookId, - pageNumber = recordPageState.text.toString().toIntOrNull() ?: 0, + pageNumber = recordPageState.text.toString().toIntOrNull(), quote = recordSentenceState.text.toString(), - emotionTags = selectedEmotion?.let { listOf(it.displayName) } ?: emptyList(), - impression = impressionState.text.toString(), + review = memoState.text.toString(), + primaryEmotion = committedEmotionCode?.name ?: "", + detailEmotionTagIds = committedEmotionMap[committedEmotionCode] ?: persistentListOf(), ) } } @@ -274,48 +313,63 @@ class RecordRegisterPresenter @AssistedInject constructor( navigator.delayedPop() } } + + RecordRegisterUiEvent.OnRetryGetEmotions -> { + getEmotionGroups() + } + + RecordRegisterUiEvent.OnEmotionEditDialogConfirm -> { + selectedEmotionCode = pendingEmotionCode + + if (selectedEmotionCode == EmotionCode.OTHER) { + committedEmotionCode = selectedEmotionCode + committedEmotionMap = persistentMapOf() + selectedEmotionMap = persistentMapOf() + } else { + isEmotionDetailBottomSheetVisible = true + } + isEmotionEditDialogVisible = false + } + + RecordRegisterUiEvent.OnEmotionEditDialogDismiss -> { + isEmotionEditDialogVisible = false + } } } + LaunchedEffect(Unit) { + getEmotionGroups() + } + 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, + emotionUiState = emotionUiState, currentStep = currentStep, recordPageState = recordPageState, recordSentenceState = recordSentenceState, + memoState = memoState, isPageError = isPageError, - emotions = emotions, - selectedEmotion = selectedEmotion, - impressionState = impressionState, - impressionGuideList = impressionGuideList, - selectedImpressionGuide = selectedImpressionGuide, - beforeSelectedImpressionGuide = beforeSelectedImpressionGuide, + emotionGroups = emotionGroups, + selectedEmotionCode = selectedEmotionCode, + selectedEmotionMap = selectedEmotionMap, + committedEmotionCode = committedEmotionCode, + committedEmotionMap = committedEmotionMap, + isEmotionDetailBottomSheetVisible = isEmotionDetailBottomSheetVisible, savedRecordId = savedRecordId, isNextButtonEnabled = isNextButtonEnabled, - isImpressionGuideBottomSheetVisible = isImpressionGuideBottomSheetVisible, isExitDialogVisible = isExitDialogVisible, + isEmotionEditDialogVisible = isEmotionEditDialogVisible, isRecordSavedDialogVisible = isRecordSavedDialogVisible, - isScanTooltipVisible = isScanTooltipVisible, - isImpressionGuideTooltipVisible = isImpressionGuideTooltipVisible, sideEffect = sideEffect, eventSink = ::handleEvent, ) } - - @CircuitInject(RecordScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create( - screen: RecordScreen, - navigator: Navigator, - ): RecordRegisterPresenter - } } 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 255338ce3..430a4998f 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 @@ -18,6 +18,7 @@ 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.preventMultiTouch +import com.ninecraft.booket.core.common.extensions.toErrorType import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.RecordStep import com.ninecraft.booket.core.designsystem.component.RecordProgressBar @@ -25,18 +26,18 @@ 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.ReedErrorUi 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 import com.ninecraft.booket.feature.record.step.QuoteStep import com.ninecraft.booket.feature.screens.RecordScreen import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope @TraceRecomposition -@CircuitInject(RecordScreen::class, ActivityRetainedComponent::class) +@CircuitInject(RecordScreen::class, AppScope::class) @Composable internal fun RecordRegisterUi( state: RecordRegisterUiState, @@ -76,11 +77,23 @@ internal fun RecordRegisterUi( } RecordStep.EMOTION -> { - EmotionStep(state = state) - } + when (state.emotionUiState) { + is EmotionUiState.Idle -> {} + is EmotionUiState.Loading -> { + ReedLoadingIndicator() + } + + is EmotionUiState.Success -> { + EmotionStep(state = state) + } - RecordStep.IMPRESSION -> { - ImpressionStep(state = state) + is EmotionUiState.Error -> { + ReedErrorUi( + errorType = state.emotionUiState.exception.toErrorType(), + onRetryClick = { state.eventSink(RecordRegisterUiEvent.OnRetryGetEmotions) }, + ) + } + } } } } @@ -94,8 +107,8 @@ internal fun RecordRegisterUi( ReedDialog( title = stringResource(R.string.record_exit_dialog_title), description = stringResource(R.string.record_exit_dialog_description), - confirmButtonText = stringResource(R.string.record_exit_dialog_confirm), - dismissButtonText = stringResource(R.string.record_exit_dialog_dismiss), + confirmButtonText = stringResource(R.string.record_dialog_confirm), + dismissButtonText = stringResource(R.string.record_dialog_dismiss), onConfirmRequest = { state.eventSink(RecordRegisterUiEvent.OnExitDialogConfirm) }, @@ -105,6 +118,21 @@ internal fun RecordRegisterUi( ) } + if (state.isEmotionEditDialogVisible) { + ReedDialog( + title = stringResource(R.string.emotion_edit_dialog_title), + description = stringResource(R.string.emotion_edit_dialog_description), + confirmButtonText = stringResource(R.string.record_dialog_confirm), + dismissButtonText = stringResource(R.string.record_dialog_dismiss), + onConfirmRequest = { + state.eventSink(RecordRegisterUiEvent.OnEmotionEditDialogConfirm) + }, + onDismissRequest = { + state.eventSink(RecordRegisterUiEvent.OnEmotionEditDialogDismiss) + }, + ) + } + if (state.isRecordSavedDialogVisible) { ReedDialog( title = stringResource(R.string.record_saved_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 167325662..36f723d4b 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 @@ -3,32 +3,43 @@ package com.ninecraft.booket.feature.record.register import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable import com.ninecraft.booket.core.designsystem.RecordStep -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf import java.util.UUID +@Immutable +sealed interface EmotionUiState { + data object Idle : EmotionUiState + data object Loading : EmotionUiState + data object Success : EmotionUiState + data class Error(val exception: Throwable) : EmotionUiState +} + data class RecordRegisterUiState( val isLoading: Boolean = false, + val emotionUiState: EmotionUiState = EmotionUiState.Idle, val currentStep: RecordStep = RecordStep.QUOTE, val recordPageState: TextFieldState = TextFieldState(), val recordSentenceState: TextFieldState = TextFieldState(), val isPageError: Boolean = false, - val emotions: ImmutableList = persistentListOf(), - val selectedEmotion: Emotion? = null, - val impressionState: TextFieldState = TextFieldState(), - val impressionGuideList: ImmutableList = persistentListOf(), - val selectedImpressionGuide: String = "", - val beforeSelectedImpressionGuide: String = "", + val memoState: TextFieldState = TextFieldState(), + val emotionGroups: ImmutableList = persistentListOf(), + val selectedEmotionCode: EmotionCode? = null, + val selectedEmotionMap: PersistentMap> = persistentMapOf(), + val committedEmotionCode: EmotionCode? = null, + val committedEmotionMap: PersistentMap> = persistentMapOf(), + val isEmotionDetailBottomSheetVisible: Boolean = false, val savedRecordId: String = "", val isNextButtonEnabled: Boolean = false, - val isImpressionGuideBottomSheetVisible: Boolean = false, val isExitDialogVisible: Boolean = false, + val isEmotionEditDialogVisible: Boolean = false, val isRecordSavedDialogVisible: Boolean = false, - val isScanTooltipVisible: Boolean = true, - val isImpressionGuideTooltipVisible: Boolean = true, val sideEffect: RecordRegisterSideEffect? = null, val eventSink: (RecordRegisterUiEvent) -> Unit, ) : CircuitUiState @@ -46,13 +57,17 @@ sealed interface RecordRegisterUiEvent : CircuitUiEvent { data object OnClearClick : RecordRegisterUiEvent data object OnNextButtonClick : RecordRegisterUiEvent data object OnSentenceScanButtonClick : RecordRegisterUiEvent - data class OnSelectEmotion(val emotion: Emotion) : RecordRegisterUiEvent - data object OnImpressionGuideButtonClick : RecordRegisterUiEvent - data object OnImpressionGuideBottomSheetDismiss : RecordRegisterUiEvent - data class OnSelectImpressionGuide(val index: Int) : RecordRegisterUiEvent - data object OnImpressionGuideConfirmed : RecordRegisterUiEvent + data class OnSelectEmotionCode(val emotionCode: EmotionCode) : RecordRegisterUiEvent + data class OnEmotionDetailToggled(val detailId: String) : RecordRegisterUiEvent + data class OnEmotionDetailRemoved(val detailId: String) : RecordRegisterUiEvent + data object OnEmotionDetailSkipped : RecordRegisterUiEvent + data object OnEmotionDetailCommitted : RecordRegisterUiEvent + data object OnEmotionDetailBottomSheetDismiss : RecordRegisterUiEvent data object OnExitDialogConfirm : RecordRegisterUiEvent data object OnExitDialogDismiss : RecordRegisterUiEvent + data object OnEmotionEditDialogConfirm : RecordRegisterUiEvent + data object OnEmotionEditDialogDismiss : RecordRegisterUiEvent data class OnRecordSavedDialogConfirm(val recordId: String) : RecordRegisterUiEvent data object OnRecordSavedDialogDismiss : RecordRegisterUiEvent + data object OnRetryGetEmotions : RecordRegisterUiEvent } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionDetailBottomSheet.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionDetailBottomSheet.kt new file mode 100644 index 000000000..c69e56f1d --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionDetailBottomSheet.kt @@ -0,0 +1,202 @@ +package com.ninecraft.booket.feature.record.step + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.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.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.ninecraft.booket.core.common.extensions.clickableSingle +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.chip.ReedSelectableChip +import com.ninecraft.booket.core.designsystem.component.chip.mediumChipStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel +import com.ninecraft.booket.core.ui.component.ReedBottomSheet +import com.ninecraft.booket.feature.record.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import com.ninecraft.booket.core.designsystem.R as designR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EmotionDetailBottomSheet( + emotionGroup: EmotionGroupModel, + selectedEmotionDetailIds: ImmutableList, + onDismissRequest: () -> Unit, + sheetState: SheetState, + onCloseButtonClick: () -> Unit, + onEmotionDetailToggled: (String) -> Unit, + onSkipButtonClick: () -> Unit, + onConfirmButtonClick: () -> Unit, +) { + ReedBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.emotion_detail_title, emotionGroup.displayName), + color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.heading2SemiBold, + ) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_close), + contentDescription = "Close Icon", + modifier = Modifier.clickableSingle { + onCloseButtonClick() + }, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + Text( + text = stringResource(R.string.emotion_detail_description), + modifier = Modifier.fillMaxWidth(), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.label1Medium, + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + start = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing6, + bottom = ReedTheme.spacing.spacing3, + ), + horizontalArrangement = Arrangement.spacedBy( + ReedTheme.spacing.spacing2, + Alignment.CenterHorizontally, + ), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + ) { + emotionGroup.detailEmotions.forEach { detail -> + ReedSelectableChip( + label = detail.name, + chipSizeStyle = mediumChipStyle, + selected = detail.id in selectedEmotionDetailIds, + onClick = { + onEmotionDetailToggled(detail.id) + }, + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = ReedTheme.spacing.spacing4), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ReedButton( + onClick = { + onSkipButtonClick() + }, + text = stringResource(R.string.emotion_detail_skip), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + ReedButton( + onClick = { + onConfirmButtonClick() + }, + text = stringResource(R.string.emotion_detail_confirm), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier.weight(1f), + enabled = selectedEmotionDetailIds.isNotEmpty(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ComponentPreview +@Composable +private fun EmotionDetailBottomSheetPreview() { + val warmthEmotionGroup = EmotionGroupModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + DetailEmotionModel( + id = "84f95f13-e54c-11f0-8545-525ae7dd628c", + name = "다정한", + ), + DetailEmotionModel( + id = "84f95fc0-e54c-11f0-8545-525ae7dd628c", + name = "고마운", + ), + DetailEmotionModel( + id = "84f96094-e54c-11f0-8545-525ae7dd628c", + name = "마음이 놓이는", + ), + DetailEmotionModel( + id = "84f9612c-e54c-11f0-8545-525ae7dd628c", + name = "편안한", + ), + ), + ) + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ReedTheme { + EmotionDetailBottomSheet( + emotionGroup = warmthEmotionGroup, + selectedEmotionDetailIds = persistentListOf(), + onDismissRequest = {}, + sheetState = sheetState, + onCloseButtonClick = {}, + onSkipButtonClick = {}, + onConfirmButtonClick = {}, + onEmotionDetailToggled = {}, + ) + } +} diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionItem.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionItem.kt new file mode 100644 index 000000000..bebb67dd3 --- /dev/null +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/EmotionItem.kt @@ -0,0 +1,181 @@ +package com.ninecraft.booket.feature.record.step + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.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.stringResource +import androidx.compose.ui.res.vectorResource +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.chip.ReedRemovableChip +import com.ninecraft.booket.core.designsystem.component.chip.smallChipStyle +import com.ninecraft.booket.core.designsystem.descriptionRes +import com.ninecraft.booket.core.designsystem.categoryGraphicRes +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun EmotionItem( + emotionGroup: EmotionGroupModel, + selectedEmotionDetailIds: ImmutableList, + onClick: () -> Unit, + isSelected: Boolean, + onEmotionDetailRemove: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val cornerShape = RoundedCornerShape(ReedTheme.radius.md) + val iconRes = if (isSelected) R.drawable.ic_check else R.drawable.ic_chevron_right + val iconTint = if (isSelected) ReedTheme.colors.borderBrand else ReedTheme.colors.contentTertiary + + Column( + modifier = modifier + .fillMaxWidth() + .clip(cornerShape) + .clickable { + onClick() + } + .background(color = ReedTheme.colors.baseSecondary) + .then( + if (isSelected) Modifier.border( + width = ReedTheme.border.border15, + color = ReedTheme.colors.borderBrand, + shape = cornerShape, + ) + else Modifier, + ) + .padding( + horizontal = ReedTheme.spacing.spacing4, + vertical = ReedTheme.spacing.spacing3, + ), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val emotionGraphicRes = emotionGroup.code.categoryGraphicRes + if (emotionGraphicRes != null) { + Image( + painter = painterResource(emotionGraphicRes), + contentDescription = "Emotion Image", + modifier = Modifier + .size(60.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing4)) + } + + Column { + Text( + text = emotionGroup.displayName, + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.headline1SemiBold, + ) + Text( + text = stringResource(emotionGroup.code.descriptionRes), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = "Chevron Right", + tint = iconTint, + ) + } + + if (selectedEmotionDetailIds.isNotEmpty()) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + ) { + selectedEmotionDetailIds.forEach { detailId -> + val detailName = emotionGroup.detailEmotions.firstOrNull { it.id == detailId }?.name ?: return@forEach + ReedRemovableChip( + label = detailName, + chipSizeStyle = smallChipStyle, + onRemove = { + onEmotionDetailRemove(detailId) + }, + ) + } + } + } + } +} + +@ComponentPreview +@Composable +private fun EmotionItemPreview() { + val warmthEmotionGroup = EmotionGroupModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + DetailEmotionModel( + id = "84f95f13-e54c-11f0-8545-525ae7dd628c", + name = "다정한", + ), + DetailEmotionModel( + id = "84f95fc0-e54c-11f0-8545-525ae7dd628c", + name = "고마운", + ), + DetailEmotionModel( + id = "84f96094-e54c-11f0-8545-525ae7dd628c", + name = "마음이 놓이는", + ), + DetailEmotionModel( + id = "84f9612c-e54c-11f0-8545-525ae7dd628c", + name = "편안한", + ), + ), + ) + + val selectedEmotionDetailIds = persistentListOf( + "84f95fc0-e54c-11f0-8545-525ae7dd628c", + "84f96094-e54c-11f0-8545-525ae7dd628c", + ) + + ReedTheme { + EmotionItem( + emotionGroup = warmthEmotionGroup, + selectedEmotionDetailIds = selectedEmotionDetailIds, + onClick = {}, + isSelected = false, + onEmotionDetailRemove = {}, + modifier = Modifier.fillMaxWidth(), + ) + } +} 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 c6b97c24f..4e96499de 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 @@ -1,11 +1,7 @@ package com.ninecraft.booket.feature.record.step -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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -13,44 +9,45 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.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.graphicRes import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White -import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.DetailEmotionModel +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.core.model.EmotionGroupModel import com.ninecraft.booket.feature.record.R import com.ninecraft.booket.feature.record.register.RecordRegisterUiEvent import com.ninecraft.booket.feature.record.register.RecordRegisterUiState import com.skydoves.compose.stability.runtime.TraceRecomposition -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @TraceRecomposition @Composable -fun EmotionStep( +internal fun EmotionStep( state: RecordRegisterUiState, modifier: Modifier = Modifier, ) { - val emotionPairs = remember(state.emotions) { state.emotions.chunked(2) } + val emotionDetailBottomSheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() Box( modifier = modifier .fillMaxSize() - .background(White), + .background(color = White), ) { LazyColumn( modifier = Modifier @@ -76,29 +73,22 @@ fun EmotionStep( ) } item { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) } - items(emotionPairs) { pair -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing3), - ) { - pair.forEach { tag -> - EmotionItem( - emotion = tag, - onClick = { - state.eventSink(RecordRegisterUiEvent.OnSelectEmotion(tag)) - }, - isSelected = state.selectedEmotion == tag, - modifier = Modifier.weight(1f), - ) - } - if (pair.size == 1) { - Spacer(modifier = Modifier.weight(1f)) - } - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) + items(state.emotionGroups) { emotion -> + EmotionItem( + emotionGroup = emotion, + selectedEmotionDetailIds = state.committedEmotionMap[emotion.code] ?: persistentListOf(), + onClick = { + state.eventSink(RecordRegisterUiEvent.OnSelectEmotionCode(emotion.code)) + }, + isSelected = state.committedEmotionCode == emotion.code, + onEmotionDetailRemove = { detail -> + state.eventSink(RecordRegisterUiEvent.OnEmotionDetailRemoved(detail)) + }, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) } } @@ -114,58 +104,82 @@ fun EmotionStep( .padding(horizontal = ReedTheme.spacing.spacing5) .padding(bottom = ReedTheme.spacing.spacing4), enabled = state.isNextButtonEnabled, - text = stringResource(R.string.record_next_button_text), + text = stringResource(R.string.record_finish_button_text), multipleEventsCutterEnabled = false, ) } -} -@Composable -private fun EmotionItem( - emotion: Emotion, - 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 = ReedTheme.border.border15, - color = ReedTheme.colors.borderBrand, - shape = RoundedCornerShape(ReedTheme.radius.md), - ) - else Modifier, - ) - .clip(RoundedCornerShape(ReedTheme.radius.md)) - .clickableSingle { - onClick() + if (state.isEmotionDetailBottomSheetVisible) { + val selectedEmotionGroup = state.emotionGroups.firstOrNull { it.code == state.selectedEmotionCode } ?: return + EmotionDetailBottomSheet( + emotionGroup = selectedEmotionGroup, + selectedEmotionDetailIds = state.selectedEmotionMap[state.selectedEmotionCode] ?: persistentListOf(), + onDismissRequest = { + state.eventSink(RecordRegisterUiEvent.OnEmotionDetailBottomSheetDismiss) + }, + sheetState = emotionDetailBottomSheetState, + onCloseButtonClick = { + coroutineScope.launch { + emotionDetailBottomSheetState.hide() + state.eventSink(RecordRegisterUiEvent.OnEmotionDetailBottomSheetDismiss) + } + }, + onEmotionDetailToggled = { detail -> + state.eventSink(RecordRegisterUiEvent.OnEmotionDetailToggled(detail)) + }, + onSkipButtonClick = { + coroutineScope.launch { + emotionDetailBottomSheetState.hide() + state.eventSink(RecordRegisterUiEvent.OnEmotionDetailSkipped) + } + }, + onConfirmButtonClick = { + coroutineScope.launch { + emotionDetailBottomSheetState.hide() + state.eventSink(RecordRegisterUiEvent.OnEmotionDetailCommitted) + } }, - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(emotion.graphicRes), - contentDescription = "Emotion Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, ) } } @ComponentPreview @Composable -private fun RecordRegisterPreview() { - val emotions = Emotion.entries.toPersistentList() - +private fun EmotionStepPreview() { + val warmthEmotionGroup = EmotionGroupModel( + code = EmotionCode.WARMTH, + displayName = "따뜻함", + detailEmotions = persistentListOf( + DetailEmotionModel( + id = "84f95d93-e54c-11f0-8545-525ae7dd628c", + name = "위로받은", + ), + DetailEmotionModel( + id = "84f95e7e-e54c-11f0-8545-525ae7dd628c", + name = "포근한", + ), + DetailEmotionModel( + id = "84f95f13-e54c-11f0-8545-525ae7dd628c", + name = "다정한", + ), + DetailEmotionModel( + id = "84f95fc0-e54c-11f0-8545-525ae7dd628c", + name = "고마운", + ), + DetailEmotionModel( + id = "84f96094-e54c-11f0-8545-525ae7dd628c", + name = "마음이 놓이는", + ), + DetailEmotionModel( + id = "84f9612c-e54c-11f0-8545-525ae7dd628c", + name = "편안한", + ), + ), + ) ReedTheme { EmotionStep( state = RecordRegisterUiState( - emotions = emotions, + emotionGroups = persistentListOf(warmthEmotionGroup), eventSink = {}, ), ) 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 deleted file mode 100644 index 7d219f976..000000000 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt +++ /dev/null @@ -1,239 +0,0 @@ -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.FlowRow -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.layout.width -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -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.draw.clip -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.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.button.smallRoundedButtonStyle -import com.ninecraft.booket.core.designsystem.component.textfield.ReedRecordTextField -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 com.skydoves.compose.stability.runtime.TraceRecomposition -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 - -@TraceRecomposition -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ImpressionStep( - state: RecordRegisterUiState, - modifier: Modifier = Modifier, -) { - val coroutineScope = rememberCoroutineScope() - val impressionGuideBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val focusRequester = remember { FocusRequester() } - val scrollState = rememberScrollState() - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val keyboardState by rememberKeyboardVisible() - var isImpressionTextFieldFocused by remember { mutableStateOf(false) } - - LaunchedEffect(keyboardState, isImpressionTextFieldFocused) { - if (keyboardState && isImpressionTextFieldFocused) { - delay(150) - bringIntoViewRequester.bringIntoView() - } - } - - Column( - modifier = modifier - .fillMaxSize() - .background(White) - .imePadding(), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = ReedTheme.spacing.spacing5) - .padding(bottom = 16.dp) - .verticalScroll(scrollState), - ) { - FlowRow( - itemVerticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.impression_step_title), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.heading1Bold, - ) - Spacer(modifier = Modifier.width(10.dp)) - Box( - modifier = Modifier - .clip(RoundedCornerShape(ReedTheme.radius.xs)) - .background(ReedTheme.colors.bgTertiary), - ) { - Text( - text = stringResource(R.string.select), - modifier = Modifier.padding( - start = ReedTheme.spacing.spacing2, - top = ReedTheme.spacing.spacing05, - end = ReedTheme.spacing.spacing2, - bottom = ReedTheme.spacing.spacing05, - ), - color = ReedTheme.colors.contentBrand, - style = ReedTheme.typography.caption1Medium, - ) - } - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) - Text( - text = stringResource(R.string.impression_step_description), - color = ReedTheme.colors.contentTertiary, - style = ReedTheme.typography.label1Medium, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) - ReedRecordTextField( - recordState = state.impressionState, - recordHintRes = R.string.impression_step_hint, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .height(140.dp) - .onFocusChanged { focusState -> - isImpressionTextFieldFocused = focusState.isFocused - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Default, - ), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) - 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( - onClick = { - state.eventSink(RecordRegisterUiEvent.OnNextButtonClick) - }, - colorStyle = ReedButtonColorStyle.PRIMARY, - sizeStyle = largeButtonStyle, - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - enabled = state.isNextButtonEnabled, - text = stringResource(R.string.record_finish_button_text), - multipleEventsCutterEnabled = true, - ) - } - - if (state.isImpressionGuideBottomSheetVisible) { - ImpressionGuideBottomSheet( - onDismissRequest = { - state.eventSink(RecordRegisterUiEvent.OnImpressionGuideBottomSheetDismiss) - }, - sheetState = impressionGuideBottomSheetState, - impressionState = state.impressionState, - impressionGuideList = state.impressionGuideList, - beforeSelectedImpressionGuide = state.beforeSelectedImpressionGuide, - selectedImpressionGuide = state.selectedImpressionGuide, - onGuideClick = { - state.eventSink(RecordRegisterUiEvent.OnSelectImpressionGuide(it)) - }, - onCloseButtonClick = { - coroutineScope.launch { - impressionGuideBottomSheetState.hide() - state.eventSink(RecordRegisterUiEvent.OnImpressionGuideBottomSheetDismiss) - } - }, - onSelectionConfirmButtonClick = { - coroutineScope.launch { - impressionGuideBottomSheetState.hide() - state.eventSink(RecordRegisterUiEvent.OnImpressionGuideConfirmed) - focusRequester.requestFocus() - } - }, - ) - } -} - -@ComponentPreview -@Composable -private fun ImpressionStepPreview() { - ReedTheme { - ImpressionStep( - state = RecordRegisterUiState( - eventSink = {}, - ), - ) - } -} 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 13f85de06..5e790edd6 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 @@ -1,7 +1,6 @@ package com.ninecraft.booket.feature.record.step import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -10,9 +9,11 @@ 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.layout.width import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.verticalScroll @@ -26,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.vector.ImageVector @@ -39,13 +41,12 @@ 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.button.smallRoundedButtonStyle +import com.ninecraft.booket.core.designsystem.component.button.mediumButtonStyle 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.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 com.skydoves.compose.stability.runtime.TraceRecomposition @@ -75,7 +76,7 @@ internal fun QuoteStep( Column( modifier = modifier .fillMaxSize() - .background(White) + .background(color = White) .imePadding(), ) { Column( @@ -92,11 +93,69 @@ internal fun QuoteStep( ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) Text( - text = stringResource(R.string.quote_step_page_label), + text = stringResource(R.string.quote_step_sentence_label), color = ReedTheme.colors.contentPrimary, style = ReedTheme.typography.body1Medium, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordSentenceState, + recordHintRes = R.string.quote_step_sentence_hint, + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .onFocusChanged { focusState -> + isSentenceTextFieldFocused = focusState.isFocused + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + ), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) + ReedButton( + onClick = { + state.eventSink(RecordRegisterUiEvent.OnSentenceScanButtonClick) + }, + colorStyle = ReedButtonColorStyle.TERTIARY, + sizeStyle = mediumButtonStyle, + text = stringResource(R.string.quote_step_scan_sentence), + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize), + contentDescription = "Scan Icon", + ) + }, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing12)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.quote_step_page_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Text( + text = stringResource(R.string.select), + modifier = Modifier + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(color = ReedTheme.colors.bgSecondary) + .padding( + start = ReedTheme.spacing.spacing2, + top = ReedTheme.spacing.spacing05, + end = ReedTheme.spacing.spacing2, + bottom = ReedTheme.spacing.spacing05, + ), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.caption1Medium, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) ReedRecordTextField( recordState = state.recordPageState, recordHintRes = R.string.quote_step_page_hint, @@ -115,54 +174,43 @@ internal fun QuoteStep( .fillMaxWidth() .height(50.dp), ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) - Text( - text = stringResource(R.string.quote_step_sentence_label), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.body1Medium, - ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing12)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.quote_step_memo_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Text( + text = stringResource(R.string.select), + modifier = Modifier + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(color = ReedTheme.colors.bgSecondary) + .padding( + start = ReedTheme.spacing.spacing2, + top = ReedTheme.spacing.spacing05, + end = ReedTheme.spacing.spacing2, + bottom = ReedTheme.spacing.spacing05, + ), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.caption1Medium, + ) + } Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) ReedRecordTextField( - recordState = state.recordSentenceState, - recordHintRes = R.string.quote_step_sentence_hint, + recordState = state.memoState, + recordHintRes = R.string.quote_step_memo_hint, modifier = Modifier .fillMaxWidth() - .height(140.dp) - .onFocusChanged { focusState -> - isSentenceTextFieldFocused = focusState.isFocused - }, + .height(140.dp), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Default, ), ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) - Row( - 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) - }, - colorStyle = ReedButtonColorStyle.STROKE, - sizeStyle = smallRoundedButtonStyle, - text = stringResource(R.string.quote_step_scan_sentence), - leadingIcon = { - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_maximize), - contentDescription = "Scan Icon", - ) - }, - ) - } } ReedButton( diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml index c24a72f7f..2bc8c7fa2 100644 --- a/feature/record/src/main/res/values/strings.xml +++ b/feature/record/src/main/res/values/strings.xml @@ -2,13 +2,14 @@ 기록을 그만하고 나가시겠어요? 지금까지 기록한 내용은 저장되지 않습니다. - 확인 - 취소 + 확인 + 취소 수집할 문장이 화면에 모두 담기도록\n조정 후 하단 캡쳐 버튼을 눌러주세요 기록할 문장 선택 선택 완료 다시 촬영하기 문장을 인식하지 못했어요\n다시 한 번 촬영해주세요 + 이미지 스캔 중입니다\n잠시만 기다려 주세요 다시 촬영하시겠어요? 선택한 문장은 삭제될 예정입니다. 확인 @@ -24,6 +25,8 @@ 문장 스캔하기 기록하고 싶은 페이지를 작성해보세요 기록하고 싶은 문장을 작성해보세요 + 메모 + 기록하고 싶은 메모가 있다면 작성해보세요 문장에 대해 어떤 감정이 드셨나요? 대표 감정을 한 가지 선택해주세요 문장에 대한 감상을 남겨주세요 @@ -42,4 +45,16 @@ 예시 문장을 알려드려요 스캔으로 빠르게 입력해요 선택 + 어떤 %1$s을 느꼈나요? + 더 자세한 감정을 선택 기록할 수 있어요. + 건너뛰기 + 선택 완료 + 감정을 수정하시겠어요? + 기록된 감정이 삭제됩니다. + 문장을 인식하지 못했어요 + 직접 문장을 입력하시겠어요? + 직접 입력하기 + 다시 촬영하기 + 이미지 선택하기 + 이미지 캡처에 실패했어요 diff --git a/feature/record/stability/record.stability b/feature/record/stability/record.stability index 21fb836f4..41cde6e55 100644 --- a/feature/record/stability/record.stability +++ b/feature/record/stability/record.stability @@ -4,19 +4,6 @@ // Do not edit this file directly. To update it, run: // ./gradlew :record:stabilityDump -@Composable -internal fun com.ninecraft.booket.feature.record.component.CustomTooltipBox(messageResId: kotlin.Int): kotlin.Unit - skippable: true - restartable: true - params: - - messageResId: STABLE (primitive type) - -@Composable -private fun com.ninecraft.booket.feature.record.component.CustomTooltipBoxPreview(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.ninecraft.booket.feature.record.component.ImpressionGuideBottomSheet(onDismissRequest: kotlin.Function0, sheetState: androidx.compose.material3.SheetState, impressionState: androidx.compose.foundation.text.input.TextFieldState, impressionGuideList: kotlinx.collections.immutable.ImmutableList, beforeSelectedImpressionGuide: kotlin.String, selectedImpressionGuide: kotlin.String, onGuideClick: kotlin.Function1, onCloseButtonClick: kotlin.Function0, onSelectionConfirmButtonClick: kotlin.Function0): kotlin.Unit skippable: true @@ -54,12 +41,25 @@ private fun com.ninecraft.booket.feature.record.component.ImpressionGuideBoxPrev restartable: true params: +@Composable +internal fun com.ninecraft.booket.feature.record.component.RecordTooltipBox(messageResId: kotlin.Int): kotlin.Unit + skippable: true + restartable: true + params: + - messageResId: STABLE (primitive type) + +@Composable +private fun com.ninecraft.booket.feature.record.component.RecordTooltipBoxPreview(): kotlin.Unit + skippable: true + restartable: true + params: + @Composable private fun com.ninecraft.booket.feature.record.ocr.CameraPreview(state: com.ninecraft.booket.feature.record.ocr.OcrUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -73,7 +73,7 @@ internal fun com.ninecraft.booket.feature.record.ocr.HandleOcrSideEffects(state: skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) @Composable public fun com.ninecraft.booket.feature.record.ocr.OcrPresenter.present(): com.ninecraft.booket.feature.record.ocr.OcrUiState @@ -86,7 +86,7 @@ internal fun com.ninecraft.booket.feature.record.ocr.OcrUi(state: com.ninecraft. skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -100,7 +100,7 @@ private fun com.ninecraft.booket.feature.record.ocr.TextScanResult(state: com.ni skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -137,7 +137,7 @@ internal fun com.ninecraft.booket.feature.record.register.HandleRecordRegisterSi skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) @Composable public fun com.ninecraft.booket.feature.record.register.RecordRegisterPresenter.present(): com.ninecraft.booket.feature.record.register.RecordRegisterUiState @@ -156,15 +156,15 @@ internal fun com.ninecraft.booket.feature.record.register.RecordRegisterUi(state skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.ninecraft.booket.feature.record.step.EmotionItem(emotionTag: com.ninecraft.booket.core.designsystem.EmotionTag, onClick: kotlin.Function0, isSelected: kotlin.Boolean, modifier: androidx.compose.ui.Modifier): kotlin.Unit +private fun com.ninecraft.booket.feature.record.step.EmotionItem(emotion: com.ninecraft.booket.core.model.Emotion, onClick: kotlin.Function0, isSelected: kotlin.Boolean, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true restartable: true params: - - emotionTag: STABLE + - emotion: STABLE (class with no mutable properties) - onClick: STABLE (function type) - isSelected: STABLE (primitive type) - modifier: STABLE (marked @Stable or @Immutable) @@ -174,7 +174,7 @@ public fun com.ninecraft.booket.feature.record.step.EmotionStep(state: com.ninec skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -182,7 +182,7 @@ public fun com.ninecraft.booket.feature.record.step.ImpressionStep(state: com.ni skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -196,7 +196,7 @@ internal fun com.ninecraft.booket.feature.record.step.QuoteStep(state: com.ninec skippable: true restartable: true params: - - state: STABLE + - state: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) @Composable diff --git a/feature/screens/build.gradle.kts b/feature/screens/build.gradle.kts index cf55a687b..9181eea80 100644 --- a/feature/screens/build.gradle.kts +++ b/feature/screens/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { libs.kotlinx.collections.immutable, libs.circuit.foundation, - libs.compose.shadow, ) api(libs.circuit.runtime) } 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 2d057b3b9..a70b02921 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,8 @@ package com.ninecraft.booket.feature.screens +import com.ninecraft.booket.core.model.EmotionCode +import com.ninecraft.booket.feature.screens.arguments.DetailEmotionArg +import com.ninecraft.booket.feature.screens.arguments.PrimaryEmotionArg import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen @@ -52,9 +55,15 @@ data class RecordDetailScreen(val recordId: String) : ReedScreen(name = ScreenNa data class RecordEditScreen(val recordInfo: RecordEditArgs) : ReedScreen(name = "RecordEdit()") @Parcelize -data class EmotionEditScreen(val emotion: String) : ReedScreen(name = "EmotionEdit()") { +data class EmotionEditScreen( + val primaryEmotionCode: EmotionCode, + val detailEmotionIds: List, +) : ReedScreen(name = "EmotionEdit()") { @Parcelize - data class Result(val emotion: String) : PopResult + data class Result( + val primaryEmotion: PrimaryEmotionArg, + val detailEmotions: List, + ) : PopResult } @Parcelize @@ -79,5 +88,5 @@ data object SplashScreen : ReedScreen(name = ScreenNames.SPLASH) data class RecordCardScreen( val quote: String, val bookTitle: String, - val emotion: String, + val emotionCode: EmotionCode, ) : 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 index d068dd9b6..cf18cad50 100644 --- 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 @@ -2,18 +2,34 @@ package com.ninecraft.booket.feature.screens.arguments import android.os.Parcelable import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.model.EmotionCode import kotlinx.parcelize.Parcelize @Immutable @Parcelize data class RecordEditArgs( val id: String, - val pageNumber: Int, + val pageNumber: Int?, val quote: String, val review: String, - val emotionTags: List, + val primaryEmotion: PrimaryEmotionArg, + val detailEmotions: List, val bookTitle: String, val bookPublisher: String, val bookCoverImageUrl: String, val author: String, ) : Parcelable + +@Immutable +@Parcelize +data class PrimaryEmotionArg( + val code: EmotionCode, + val displayName: String, +) : Parcelable + +@Immutable +@Parcelize +data class DetailEmotionArg( + val id: String, + val name: String, +) : Parcelable diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainBottomBar.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainBottomBar.kt index e4195a0ae..6639e716c 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainBottomBar.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainBottomBar.kt @@ -21,12 +21,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import com.adamglin.composeshadow.dropShadow import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White @@ -47,9 +49,12 @@ fun MainBottomBar( topStart = ReedTheme.spacing.spacing3, topEnd = ReedTheme.spacing.spacing3, ), - color = ReedTheme.colors.borderPrimary.copy(alpha = 0.05f), - offsetY = (-4).dp, - blur = 20.dp, + shadow = Shadow( + radius = 20.dp, + spread = 0.dp, + offset = DpOffset(x = 0.dp, (-4).dp), + color = ReedTheme.colors.borderPrimary.copy(alpha = 0.05f), + ), ) .clip( RoundedCornerShape( @@ -65,7 +70,7 @@ fun MainBottomBar( topEnd = ReedTheme.spacing.spacing3, ), ) - .background(White), + .background(color = White), ) { Row( modifier = Modifier diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainTab.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainTab.kt index 2395c416e..d4c220bf5 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainTab.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/component/MainTab.kt @@ -8,9 +8,9 @@ import com.ninecraft.booket.feature.screens.R import com.slack.circuit.runtime.screen.Screen enum class MainTab( - @DrawableRes val iconResId: Int, - @DrawableRes val selectedIconResId: Int, - @StringRes val labelResId: Int, + @param: DrawableRes val iconResId: Int, + @param: DrawableRes val selectedIconResId: Int, + @param: StringRes val labelResId: Int, internal val contentDescription: String, val screen: Screen, ) { diff --git a/feature/screens/stability/screens.stability b/feature/screens/stability/screens.stability index aaa1f6b6e..70f3502dc 100644 --- a/feature/screens/stability/screens.stability +++ b/feature/screens/stability/screens.stability @@ -10,7 +10,7 @@ public fun com.ninecraft.booket.feature.screens.component.MainBottomBar(tabs: ko restartable: true params: - tabs: STABLE (known stable type) - - currentTab: STABLE + - currentTab: STABLE (class with no mutable properties) - onTabSelected: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @@ -19,13 +19,7 @@ private fun com.ninecraft.booket.feature.screens.component.MainBottomBarItem(tab skippable: true restartable: true params: - - tab: STABLE + - tab: STABLE (class with no mutable properties) - selected: STABLE (primitive type) - onClick: STABLE (function type) -@Composable -private fun com.ninecraft.booket.feature.screens.component.MainBottomBarPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index ff1f0354d..533a07eb5 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -1,4 +1,5 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") +import com.ninecraft.booket.convention.getLocalProperty + plugins { alias(libs.plugins.booket.android.feature) @@ -8,10 +9,14 @@ plugins { android { namespace = "com.ninecraft.booket.feature.search" -} -ksp { - arg("circuit.codegen.mode", "hilt") + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "REED_KAKAOTALK_CHANNEL_URL", getLocalProperty("REED_KAKAOTALK_CHANNEL_URL")) + } } dependencies { 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 e78e03f7c..01aa176d9 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 @@ -17,7 +17,7 @@ import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel -import com.ninecraft.booket.core.model.UserState +import com.ninecraft.booket.core.model.state.UserState import com.ninecraft.booket.core.ui.component.FooterState import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.screens.LoginScreen @@ -31,21 +31,29 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class BookSearchPresenter @AssistedInject constructor( +@AssistedInject +class BookSearchPresenter( @Assisted private val navigator: Navigator, private val repository: BookRepository, private val authRepository: AuthRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + + @CircuitInject(BookSearchScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): BookSearchPresenter + } + companion object { private const val START_INDEX = 1 private const val SEARCH_BOOK_RESULT = "search_book_result" @@ -59,7 +67,7 @@ class BookSearchPresenter @AssistedInject constructor( override fun present(): BookSearchUiState { val scope = rememberCoroutineScope() val userState by authRepository.userState.collectAsRetainedState(initial = UserState.Guest) - var uiState by rememberRetained { mutableStateOf(UiState.Idle) } + var searchUiState by rememberRetained { mutableStateOf(SearchUiState.Idle) } var footerState by rememberRetained { mutableStateOf(FooterState.Idle) } val queryState = rememberTextFieldState() val recentSearches by repository.bookRecentSearches.collectAsRetainedState(initial = emptyList()) @@ -78,7 +86,7 @@ class BookSearchPresenter @AssistedInject constructor( fun searchBooks(query: String, startIndex: Int = START_INDEX) { scope.launch { if (startIndex == START_INDEX) { - uiState = UiState.Loading + searchUiState = SearchUiState.Loading } else { footerState = FooterState.Loading } @@ -100,7 +108,7 @@ class BookSearchPresenter @AssistedInject constructor( isLastPage = result.lastPage if (startIndex == START_INDEX) { - uiState = UiState.Success + searchUiState = SearchUiState.Success analyticsHelper.logEvent(SEARCH_BOOK_RESULT) } else { footerState = if (isLastPage) FooterState.End else FooterState.Idle @@ -111,7 +119,7 @@ class BookSearchPresenter @AssistedInject constructor( analyticsHelper.logEvent(ERROR_SEARCH_LOADING) val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." if (startIndex == START_INDEX) { - uiState = UiState.Error(exception) + searchUiState = SearchUiState.Error(exception) } else { footerState = FooterState.Error(errorMessage) } @@ -252,6 +260,10 @@ class BookSearchPresenter @AssistedInject constructor( is BookSearchUiEvent.OnBookRegisterSuccessCancelButtonClick -> { isBookRegisterSuccessBottomSheetVisible = false } + + is BookSearchUiEvent.OnInquireClick -> { + sideEffect = BookSearchSideEffect.NavigateToKakaoTalkChannel + } } } @@ -260,7 +272,7 @@ class BookSearchPresenter @AssistedInject constructor( } return BookSearchUiState( - uiState = uiState, + searchUiState = searchUiState, footerState = footerState, queryState = queryState, recentSearches = recentSearches.toImmutableList(), @@ -276,10 +288,4 @@ class BookSearchPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(BookSearchScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): BookSearchPresenter - } } 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 5904f2587..f6c600537 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 @@ -24,6 +24,9 @@ import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.common.extensions.toErrorType import com.ninecraft.booket.core.designsystem.DevicePreview 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.smallButtonStyle import com.ninecraft.booket.core.designsystem.component.textfield.ReedTextField import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White @@ -42,13 +45,13 @@ import com.ninecraft.booket.feature.search.common.component.RecentSearchTitle import com.ninecraft.booket.feature.search.common.component.SearchItem import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition -@CircuitInject(BookSearchScreen::class, ActivityRetainedComponent::class) +@CircuitInject(BookSearchScreen::class, AppScope::class) @Composable internal fun BookSearchUi( state: BookSearchUiState, @@ -114,19 +117,19 @@ internal fun BookSearchContent( ReedDivider() Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - when (state.uiState) { - is UiState.Loading -> { + when (state.searchUiState) { + is SearchUiState.Loading -> { ReedLoadingIndicator() } - is UiState.Error -> { + is SearchUiState.Error -> { ReedErrorUi( - errorType = state.uiState.exception.toErrorType(), + errorType = state.searchUiState.exception.toErrorType(), onRetryClick = { state.eventSink(BookSearchUiEvent.OnRetryClick) }, ) } - is UiState.Idle -> { + is SearchUiState.Idle -> { if (state.recentSearches.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), @@ -173,17 +176,33 @@ internal fun BookSearchContent( } } - is UiState.Success -> { + is SearchUiState.Success -> { if (state.isEmptySearchResult) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Text( - text = stringResource(R.string.empty_results), - color = ReedTheme.colors.contentSecondary, - style = ReedTheme.typography.body1Medium, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.empty_results_title), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.headline1SemiBold, + ) + Text( + text = stringResource(R.string.empty_results_description), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + ReedButton( + onClick = { state.eventSink(BookSearchUiEvent.OnInquireClick) }, + text = stringResource(R.string.inquire), + sizeStyle = smallButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + ) + } } } else { Row( @@ -294,7 +313,7 @@ internal fun BookSearchContent( @DevicePreview @Composable -private fun BookSearchPreview() { +private fun BookRecentSearchPreview() { ReedTheme { BookSearchUi( state = BookSearchUiState( @@ -303,3 +322,16 @@ private fun BookSearchPreview() { ) } } + +@DevicePreview +@Composable +private fun BookSearchEmptyResultPreview() { + ReedTheme { + BookSearchContent( + state = BookSearchUiState( + searchUiState = SearchUiState.Success, + eventSink = {}, + ), + ) + } +} 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 17e15a452..aba63c9b7 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 @@ -14,15 +14,15 @@ import kotlinx.collections.immutable.persistentListOf import java.util.UUID @Immutable -sealed interface UiState { - data object Idle : UiState - data object Loading : UiState - data object Success : UiState - data class Error(val exception: Throwable) : UiState +sealed interface SearchUiState { + data object Idle : SearchUiState + data object Loading : SearchUiState + data object Success : SearchUiState + data class Error(val exception: Throwable) : SearchUiState } data class BookSearchUiState( - val uiState: UiState = UiState.Idle, + val searchUiState: SearchUiState = SearchUiState.Idle, val footerState: FooterState = FooterState.Idle, val queryState: TextFieldState = TextFieldState(), val recentSearches: ImmutableList = persistentListOf(), @@ -37,7 +37,7 @@ data class BookSearchUiState( val sideEffect: BookSearchSideEffect? = null, val eventSink: (BookSearchUiEvent) -> Unit, ) : CircuitUiState { - val isEmptySearchResult: Boolean get() = uiState is UiState.Success && searchResult.totalResults == 0 + val isEmptySearchResult: Boolean get() = searchUiState is SearchUiState.Success && searchResult.totalResults == 0 } @Immutable @@ -46,6 +46,8 @@ sealed interface BookSearchSideEffect { val message: UiText, private val key: String = UUID.randomUUID().toString(), ) : BookSearchSideEffect + + data object NavigateToKakaoTalkChannel : BookSearchSideEffect } sealed interface BookSearchUiEvent : CircuitUiEvent { @@ -64,4 +66,5 @@ sealed interface BookSearchUiEvent : CircuitUiEvent { data object OnBookRegisterButtonClick : BookSearchUiEvent data object OnBookRegisterSuccessOkButtonClick : BookSearchUiEvent data object OnBookRegisterSuccessCancelButtonClick : BookSearchUiEvent + data object OnInquireClick : BookSearchUiEvent } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt index 27fd56e1e..5eeaae2d2 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt @@ -3,6 +3,8 @@ package com.ninecraft.booket.feature.search.book import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import com.ninecraft.booket.feature.search.BuildConfig import com.skydoves.compose.effects.RememberedEffect @Composable @@ -11,6 +13,7 @@ internal fun HandleBookSearchSideEffects( eventSink: (BookSearchUiEvent) -> Unit, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current RememberedEffect(state.sideEffect) { when (state.sideEffect) { @@ -18,6 +21,10 @@ internal fun HandleBookSearchSideEffects( Toast.makeText(context, state.sideEffect.message.asString(context), Toast.LENGTH_SHORT).show() } + is BookSearchSideEffect.NavigateToKakaoTalkChannel -> { + uriHandler.openUri(BuildConfig.REED_KAKAOTALK_CHANNEL_URL) + } + null -> {} } 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 cd6cc348b..e583449ff 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 @@ -31,11 +31,11 @@ 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 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.ui.component.ReedBottomSheet import com.ninecraft.booket.feature.search.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -128,11 +128,14 @@ fun RowScope.BookStatusItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val bgColor = if (selected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary + val textColor = if (selected) ReedTheme.colors.contentBrand else ReedTheme.colors.contentPrimary + Box( modifier = modifier .weight(1f) .clip(RoundedCornerShape(ReedTheme.radius.sm)) - .background(if (selected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary) + .background(bgColor) .selectable( selected = selected, indication = null, @@ -145,7 +148,7 @@ fun RowScope.BookStatusItem( ) { Text( text = stringResource(item.getDisplayNameRes()), - color = if (selected) ReedTheme.colors.contentBrand else ReedTheme.colors.contentPrimary, + color = textColor, style = ReedTheme.typography.body1Medium, ) } 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 fc4799d01..b0ffd869f 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 @@ -23,20 +23,28 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class LibrarySearchPresenter @AssistedInject constructor( +@AssistedInject +class LibrarySearchPresenter( @Assisted private val navigator: Navigator, private val repository: BookRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { + + @CircuitInject(LibrarySearchScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): LibrarySearchPresenter + } + companion object { private const val PAGE_SIZE = 20 private const val START_INDEX = 0 @@ -172,10 +180,4 @@ class LibrarySearchPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(LibrarySearchScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): LibrarySearchPresenter - } } 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 227d3ad00..c535add3b 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 @@ -35,10 +35,10 @@ import com.ninecraft.booket.feature.search.common.component.SearchItem import com.ninecraft.booket.feature.search.library.component.LibraryBookItem import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope @TraceRecomposition -@CircuitInject(LibrarySearchScreen::class, ActivityRetainedComponent::class) +@CircuitInject(LibrarySearchScreen::class, AppScope::class) @Composable internal fun LibrarySearchUi( state: LibrarySearchUiState, diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index c27ec69f2..9fc993e7a 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -5,7 +5,9 @@ 최근 검색어 오류가 발생했습니다 - 검색어와 일치하는 도서가 없습니다 + 아직 등록된 책이 없어요 + 카카오톡 채널로 문의를 남겨주세요 + 문의하기 등록 옵션 도서가 등록되었어요! 독서 기록을 바로 시작할까요? diff --git a/feature/search/stability/search.stability b/feature/search/stability/search.stability index b2786505e..1eff2c36e 100644 --- a/feature/search/stability/search.stability +++ b/feature/search/stability/search.stability @@ -4,195 +4,9 @@ // Do not edit this file directly. To update it, run: // ./gradlew :search:stabilityDump -@Composable -internal fun com.ninecraft.booket.feature.search.book.BookSearchContent(state: com.ninecraft.booket.feature.search.book.BookSearchUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - @Composable public fun com.ninecraft.booket.feature.search.book.BookSearchPresenter.present(): com.ninecraft.booket.feature.search.book.BookSearchUiState skippable: true restartable: true params: -@Composable -private fun com.ninecraft.booket.feature.search.book.BookSearchPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.search.book.BookSearchUi(state: com.ninecraft.booket.feature.search.book.BookSearchUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -internal fun com.ninecraft.booket.feature.search.book.HandleBookSearchSideEffects(state: com.ninecraft.booket.feature.search.book.BookSearchUiState, eventSink: kotlin.Function1): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - eventSink: STABLE (function type) - -@Composable -public fun com.ninecraft.booket.feature.search.book.component.BookItem(book: com.ninecraft.booket.core.model.BookSummaryModel, onBookClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean): kotlin.Unit - skippable: true - restartable: true - params: - - book: STABLE (marked @Stable or @Immutable) - - onBookClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - - enabled: STABLE (primitive type) - -@Composable -private fun com.ninecraft.booket.feature.search.book.component.BookItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.search.book.component.BookRegisterBottomSheet(onDismissRequest: kotlin.Function0, sheetState: androidx.compose.material3.SheetState, onCloseButtonClick: kotlin.Function0, bookStatuses: kotlinx.collections.immutable.ImmutableList, currentBookStatus: com.ninecraft.booket.core.common.constants.BookStatus?, onItemSelected: kotlin.Function1, onBookRegisterButtonClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onDismissRequest: STABLE (function type) - - sheetState: STABLE (marked @Stable or @Immutable) - - onCloseButtonClick: STABLE (function type) - - bookStatuses: STABLE (known stable type) - - currentBookStatus: STABLE - - onItemSelected: STABLE (function type) - - onBookRegisterButtonClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.search.book.component.BookRegisterBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.search.book.component.BookRegisterSuccessBeforeReadingBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.search.book.component.BookRegisterSuccessBottomSheet(onDismissRequest: kotlin.Function0, sheetState: androidx.compose.material3.SheetState, upsertedBookStatus: com.ninecraft.booket.core.common.constants.BookStatus, onCancelButtonClick: kotlin.Function0, onOKButtonClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onDismissRequest: STABLE (function type) - - sheetState: STABLE (marked @Stable or @Immutable) - - upsertedBookStatus: STABLE - - onCancelButtonClick: STABLE (function type) - - onOKButtonClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.search.book.component.BookRegisterSuccessCompletedBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.search.book.component.BookRegisterSuccessReadingBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.search.book.component.BookStatusItem(item: com.ninecraft.booket.core.common.constants.BookStatus, selected: kotlin.Boolean, onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - item: STABLE - - selected: STABLE (primitive type) - - onClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -internal fun com.ninecraft.booket.feature.search.common.component.RecentSearchTitle(modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.search.common.component.RecentSearchTitlePreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.search.common.component.SearchItem(query: kotlin.String, onQueryClick: kotlin.Function1, onDeleteIconClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - query: STABLE (String is immutable) - - onQueryClick: STABLE (function type) - - onDeleteIconClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.search.common.component.SearchItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.search.library.HandlingLibrarySearchSideEffect(state: com.ninecraft.booket.feature.search.library.LibrarySearchUiState): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - -@Composable -internal fun com.ninecraft.booket.feature.search.library.LibrarySearchContent(state: com.ninecraft.booket.feature.search.library.LibrarySearchUiState, innerPadding: androidx.compose.foundation.layout.PaddingValues, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - innerPadding: STABLE (marked @Stable or @Immutable) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -public fun com.ninecraft.booket.feature.search.library.LibrarySearchPresenter.present(): com.ninecraft.booket.feature.search.library.LibrarySearchUiState - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.search.library.LibrarySearchPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.search.library.LibrarySearchUi(state: com.ninecraft.booket.feature.search.library.LibrarySearchUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -public fun com.ninecraft.booket.feature.search.library.component.LibraryBookItem(book: com.ninecraft.booket.core.model.LibraryBookSummaryModel, onBookClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - book: STABLE (marked @Stable or @Immutable) - - onBookClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.search.library.component.LibraryBookItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 9ecba1475..c9a088cab 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.booket.kotlin.library.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.settings" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.logger, diff --git a/feature/settings/src/main/assets/oss_licenses.json b/feature/settings/src/main/assets/oss_licenses.json index ce75a00bf..1a1b6e7ec 100644 --- a/feature/settings/src/main/assets/oss_licenses.json +++ b/feature/settings/src/main/assets/oss_licenses.json @@ -15,9 +15,9 @@ "url": "https://github.com/skydoves/compose-stable-marker" }, { - "name": "Hilt", + "name": "Metro", "license": "Apache License 2.0", - "url": "https://dagger.dev/hilt/" + "url": "https://zacsweers.github.io/metro" }, { "name": "Logger", 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 003a09bce..39cdc8a23 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 @@ -11,7 +11,8 @@ 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.core.data.api.repository.UserRepository -import com.ninecraft.booket.core.model.UserState +import com.ninecraft.booket.core.model.LoginMethod +import com.ninecraft.booket.core.model.state.UserState import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.NotificationScreen import com.ninecraft.booket.feature.screens.OssLicensesScreen @@ -26,13 +27,14 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch -class SettingsPresenter @AssistedInject constructor( +@AssistedInject +class SettingsPresenter( @Assisted val navigator: Navigator, private val authRepository: AuthRepository, private val userRepository: UserRepository, @@ -40,6 +42,12 @@ class SettingsPresenter @AssistedInject constructor( private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(SettingsScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): SettingsPresenter + } + companion object { private const val SETTINGS_LOGOUT_COMPLETE = "settings_logout_complete" private const val SETTINGS_WITHDRAWAL_COMPLETE = "settings_withdrawal_complete" @@ -95,6 +103,7 @@ class SettingsPresenter @AssistedInject constructor( authRepository.withdraw() .onSuccess { userRepository.resetNotificationData() + authRepository.setRecentLoginMethod(LoginMethod.NONE) analyticsHelper.logEvent(SETTINGS_WITHDRAWAL_COMPLETE) navigator.resetRoot(LoginScreen()) } @@ -232,10 +241,4 @@ class SettingsPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(SettingsScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): SettingsPresenter - } } 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 8977e64be..221357455 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 @@ -37,13 +37,13 @@ import com.ninecraft.booket.feature.settings.component.SettingItem import com.ninecraft.booket.feature.settings.component.WithdrawConfirmationBottomSheet import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import kotlinx.coroutines.launch import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition @OptIn(ExperimentalMaterial3Api::class) -@CircuitInject(SettingsScreen::class, ActivityRetainedComponent::class) +@CircuitInject(SettingsScreen::class, AppScope::class) @Composable internal fun SettingsUi( state: SettingsUiState, @@ -71,7 +71,7 @@ internal fun SettingsUi( ReedScaffold( modifier = modifier .fillMaxSize() - .background(White), + .background(color = White), containerColor = White, ) { innerPadding -> Column( diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ReedSwitch.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ReedSwitch.kt index a7a8db082..8a318274f 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ReedSwitch.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ReedSwitch.kt @@ -20,8 +20,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.extensions.noRippleClickable import com.ninecraft.booket.core.designsystem.DevicePreview @@ -51,13 +53,18 @@ internal fun ReedSwitch( .width(51.dp) .height(31.dp) .clip(RoundedCornerShape(ReedTheme.radius.full)) - .background(trackColor) + .drawBehind { drawRect(trackColor) } .noRippleClickable { onCheckedChange(!checked) }, contentAlignment = Alignment.CenterStart, ) { Box( modifier = Modifier - .offset(x = thumbOffset) + .offset { + IntOffset( + x = thumbOffset.roundToPx(), + y = 0, + ) + } .size(27.dp) .shadow(elevation = 1.dp, shape = CircleShape) .clip(CircleShape) diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt index 7152080bb..4d6e789a5 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt @@ -16,16 +16,24 @@ 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 dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch -class NotificationPresenter @AssistedInject constructor( +@AssistedInject +class NotificationPresenter( @Assisted val navigator: Navigator, private val userRepository: UserRepository, ) : Presenter { + + @CircuitInject(NotificationScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): NotificationPresenter + } + @Composable override fun present(): NotificationUiState { val scope = rememberCoroutineScope() @@ -117,10 +125,4 @@ class NotificationPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(NotificationScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): NotificationPresenter - } } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt index 66a863857..b46524c39 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState 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.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -45,11 +46,11 @@ import com.ninecraft.booket.feature.settings.R import com.ninecraft.booket.feature.settings.component.ToggleItem import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import com.ninecraft.booket.core.designsystem.R as designR @TraceRecomposition -@CircuitInject(NotificationScreen::class, ActivityRetainedComponent::class) +@CircuitInject(NotificationScreen::class, AppScope::class) @Composable internal fun NotificationUi( state: NotificationUiState, @@ -138,10 +139,8 @@ internal fun NotificationGuideItem( horizontal = ReedTheme.spacing.spacing5, ) .fillMaxWidth() - .background( - color = ReedTheme.colors.baseSecondary, - shape = RoundedCornerShape(ReedTheme.radius.md), - ) + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(color = ReedTheme.colors.baseSecondary) .noRippleClickable { onClick() } .padding( vertical = ReedTheme.spacing.spacing6, diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesPresenter.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesPresenter.kt index e9f23d27f..200db811d 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesPresenter.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesPresenter.kt @@ -5,14 +5,22 @@ import com.ninecraft.booket.feature.screens.OssLicensesScreen import com.slack.circuit.codegen.annotations.CircuitInject 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject -class OssLicensesPresenter @AssistedInject constructor( +@AssistedInject +class OssLicensesPresenter( @Assisted val navigator: Navigator, ) : Presenter { + + @CircuitInject(OssLicensesScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): OssLicensesPresenter + } + @Composable override fun present(): OssLicensesUiState { fun handleEvent(event: OssLicensesUiEvent) { @@ -26,10 +34,4 @@ class OssLicensesPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(OssLicensesScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): OssLicensesPresenter - } } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUi.kt index e6380a55e..5fc02edaf 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUi.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -39,14 +40,14 @@ import com.ninecraft.booket.feature.screens.OssLicensesScreen import com.orhanobut.logger.Logger import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.IOException @TraceRecomposition -@CircuitInject(OssLicensesScreen::class, ActivityRetainedComponent::class) +@CircuitInject(OssLicensesScreen::class, AppScope::class) @Composable internal fun OssLicenses( state: OssLicensesUiState, @@ -110,10 +111,8 @@ private fun OssLicenseItem( Box( modifier = Modifier .size(ReedTheme.spacing.spacing1) - .background( - color = ReedTheme.colors.contentBrand, - shape = CircleShape, - ), + .clip(CircleShape) + .background(color = ReedTheme.colors.contentBrand), ) Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) Text( @@ -129,10 +128,8 @@ private fun OssLicenseItem( text = url, modifier = Modifier .fillMaxWidth() - .background( - color = ReedTheme.colors.bgSecondary, - shape = RoundedCornerShape(ReedTheme.radius.xs), - ) + .clip(RoundedCornerShape(ReedTheme.radius.xs)) + .background(color = ReedTheme.colors.bgSecondary) .padding( horizontal = ReedTheme.spacing.spacing2, vertical = ReedTheme.spacing.spacing4, diff --git a/feature/settings/stability/settings.stability b/feature/settings/stability/settings.stability index bb954ee0b..849b8b18f 100644 --- a/feature/settings/stability/settings.stability +++ b/feature/settings/stability/settings.stability @@ -4,165 +4,9 @@ // Do not edit this file directly. To update it, run: // ./gradlew :settings:stabilityDump -@Composable -internal fun com.ninecraft.booket.feature.settings.HandleSettingsSideEffects(state: com.ninecraft.booket.feature.settings.SettingsUiState, eventSink: kotlin.Function1): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - eventSink: STABLE (function type) - @Composable public fun com.ninecraft.booket.feature.settings.SettingsPresenter.present(): com.ninecraft.booket.feature.settings.SettingsUiState skippable: true restartable: true params: -@Composable -private fun com.ninecraft.booket.feature.settings.SettingsScreenPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.settings.SettingsUi(state: com.ninecraft.booket.feature.settings.SettingsUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -internal fun com.ninecraft.booket.feature.settings.component.ReedSwitch(checked: kotlin.Boolean, onCheckedChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - checked: STABLE (primitive type) - - onCheckedChange: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.settings.component.ReedSwitchPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.settings.component.SettingItem(title: kotlin.String, modifier: androidx.compose.ui.Modifier, isClickable: kotlin.Boolean, onItemClick: kotlin.Function0, action: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, description: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit - skippable: true - restartable: true - params: - - title: STABLE (String is immutable) - - modifier: STABLE (marked @Stable or @Immutable) - - isClickable: STABLE (primitive type) - - onItemClick: STABLE (function type) - - action: STABLE (composable function type) - - description: STABLE (composable function type) - -@Composable -private fun com.ninecraft.booket.feature.settings.component.SettingItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.settings.component.ToggleItem(title: kotlin.String, description: kotlin.String, isChecked: kotlin.Boolean, onCheckedChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - title: STABLE (String is immutable) - - description: STABLE (String is immutable) - - isChecked: STABLE (primitive type) - - onCheckedChange: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.settings.component.ToggleItemPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.feature.settings.component.WithdrawConfirmationBottomSheet(onDismissRequest: kotlin.Function0, sheetState: androidx.compose.material3.SheetState, isCheckBoxChecked: kotlin.Boolean, onCheckBoxCheckedChange: kotlin.Function0, onCancelButtonClick: kotlin.Function0, onWithdrawButtonClick: kotlin.Function0): kotlin.Unit - skippable: true - restartable: true - params: - - onDismissRequest: STABLE (function type) - - sheetState: STABLE (marked @Stable or @Immutable) - - isCheckBoxChecked: STABLE (primitive type) - - onCheckBoxCheckedChange: STABLE (function type) - - onCancelButtonClick: STABLE (function type) - - onWithdrawButtonClick: STABLE (function type) - -@Composable -private fun com.ninecraft.booket.feature.settings.component.WithdrawConfirmationBottomSheetPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.settings.notification.HandleNotificationSideEffects(state: com.ninecraft.booket.feature.settings.notification.NotificationUiState, eventSink: kotlin.Function1): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - eventSink: STABLE (function type) - -@Composable -internal fun com.ninecraft.booket.feature.settings.notification.NotificationGuideItem(onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onClick: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -public fun com.ninecraft.booket.feature.settings.notification.NotificationPresenter.present(): com.ninecraft.booket.feature.settings.notification.NotificationUiState - skippable: true - restartable: true - params: - -@Composable -internal fun com.ninecraft.booket.feature.settings.notification.NotificationUi(state: com.ninecraft.booket.feature.settings.notification.NotificationUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.ninecraft.booket.feature.settings.notification.NotificationUiPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.settings.osslicenses.OssLicenseItem(name: kotlin.String, license: kotlin.String, url: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - name: STABLE (String is immutable) - - license: STABLE (String is immutable) - - url: STABLE (String is immutable) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -internal fun com.ninecraft.booket.feature.settings.osslicenses.OssLicenses(state: com.ninecraft.booket.feature.settings.osslicenses.OssLicensesUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -public fun com.ninecraft.booket.feature.settings.osslicenses.OssLicensesPresenter.present(): com.ninecraft.booket.feature.settings.osslicenses.OssLicensesUiState - skippable: true - restartable: true - params: - -@Composable -private fun com.ninecraft.booket.feature.settings.osslicenses.OssLicensesScreenPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/feature/splash/build.gradle.kts b/feature/splash/build.gradle.kts index a758e7875..096b36907 100644 --- a/feature/splash/build.gradle.kts +++ b/feature/splash/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) } @@ -8,10 +6,6 @@ android { namespace = "com.ninecraft.booket.feature.splash" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.compose.system.ui.controller, 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 02a0761e9..b1cb8ae17 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 @@ -12,8 +12,8 @@ import com.ninecraft.booket.core.common.event.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.model.state.AutoLoginState +import com.ninecraft.booket.core.model.state.OnboardingState import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OnboardingScreen @@ -25,14 +25,15 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.delay import kotlinx.coroutines.launch -class SplashPresenter @AssistedInject constructor( +@AssistedInject +class SplashPresenter( @Assisted private val navigator: Navigator, private val userRepository: UserRepository, private val authRepository: AuthRepository, @@ -40,6 +41,12 @@ class SplashPresenter @AssistedInject constructor( private val analyticsHelper: AnalyticsHelper, ) : Presenter { + @CircuitInject(SplashScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): SplashPresenter + } + @Composable override fun present(): SplashUiState { val scope = rememberCoroutineScope() @@ -147,10 +154,4 @@ class SplashPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(SplashScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create(navigator: Navigator): SplashPresenter - } } 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 d60631d0f..b68483bb6 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 @@ -26,11 +26,11 @@ import com.ninecraft.booket.feature.screens.SplashScreen import com.ninecraft.booket.feature.splash.R import com.skydoves.compose.stability.runtime.TraceRecomposition import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController @TraceRecomposition -@CircuitInject(SplashScreen::class, ActivityRetainedComponent::class) +@CircuitInject(SplashScreen::class, AppScope::class) @Composable fun SplashUi( state: SplashUiState, diff --git a/feature/splash/stability/splash.stability b/feature/splash/stability/splash.stability index f00ec2420..c82c87217 100644 --- a/feature/splash/stability/splash.stability +++ b/feature/splash/stability/splash.stability @@ -4,31 +4,9 @@ // Do not edit this file directly. To update it, run: // ./gradlew :splash:stabilityDump -@Composable -internal fun com.ninecraft.booket.splash.HandleSplashSideEffects(state: com.ninecraft.booket.splash.SplashUiState, eventSink: kotlin.Function1): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - eventSink: STABLE (function type) - @Composable public fun com.ninecraft.booket.splash.SplashPresenter.present(): com.ninecraft.booket.splash.SplashUiState skippable: true restartable: true params: -@Composable -private fun com.ninecraft.booket.splash.SplashPreview(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ninecraft.booket.splash.SplashUi(state: com.ninecraft.booket.splash.SplashUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE - - modifier: STABLE (marked @Stable or @Immutable) - diff --git a/feature/webview/build.gradle.kts b/feature/webview/build.gradle.kts index 46adfe38c..3c7ff93c8 100644 --- a/feature/webview/build.gradle.kts +++ b/feature/webview/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") - plugins { alias(libs.plugins.booket.android.feature) alias(libs.plugins.booket.kotlin.library.serialization) @@ -10,10 +8,6 @@ android { namespace = "com.ninecraft.booket.feature.webview" } -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { implementations( libs.logger, diff --git a/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewPresenter.kt b/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewPresenter.kt index 1483a0c68..662d68574 100644 --- a/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewPresenter.kt +++ b/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewPresenter.kt @@ -5,16 +5,23 @@ import com.ninecraft.booket.feature.screens.WebViewScreen import com.slack.circuit.codegen.annotations.CircuitInject 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject -class WebViewPresenter @AssistedInject constructor( +@AssistedInject +class WebViewPresenter( @Assisted private val screen: WebViewScreen, @Assisted private val navigator: Navigator, ) : Presenter { + @CircuitInject(WebViewScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(screen: WebViewScreen, navigator: Navigator): WebViewPresenter + } + @Composable override fun present(): WebViewUiState { fun handleEvent(event: WebViewUiEvent) { @@ -31,13 +38,4 @@ class WebViewPresenter @AssistedInject constructor( eventSink = ::handleEvent, ) } - - @CircuitInject(WebViewScreen::class, ActivityRetainedComponent::class) - @AssistedFactory - fun interface Factory { - fun create( - screen: WebViewScreen, - navigator: Navigator, - ): WebViewPresenter - } } diff --git a/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewUi.kt b/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewUi.kt index 4835072d0..279ab2ca4 100644 --- a/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewUi.kt +++ b/feature/webview/src/main/kotlin/com/ninecraft/booket/feature/webview/WebViewUi.kt @@ -17,9 +17,9 @@ import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.feature.screens.WebViewScreen import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.android.components.ActivityRetainedComponent +import dev.zacsweers.metro.AppScope -@CircuitInject(WebViewScreen::class, ActivityRetainedComponent::class) +@CircuitInject(WebViewScreen::class, AppScope::class) @Composable internal fun WebViewUi( state: WebViewUiState, diff --git a/feature/webview/stability/webview.stability b/feature/webview/stability/webview.stability index 936e51efa..dbfdc6b05 100644 --- a/feature/webview/stability/webview.stability +++ b/feature/webview/stability/webview.stability @@ -6,30 +6,18 @@ @Composable internal fun com.ninecraft.booket.feature.webview.WebViewContent(state: com.ninecraft.booket.feature.webview.WebViewUiState, innerPadding: androidx.compose.foundation.layout.PaddingValues, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true + skippable: false restartable: true params: - - state: STABLE + - state: UNSTABLE (has mutable properties or unstable members) - innerPadding: STABLE (marked @Stable or @Immutable) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -public fun com.ninecraft.booket.feature.webview.WebViewPresenter.present(): com.ninecraft.booket.feature.webview.WebViewUiState - skippable: true - restartable: true - params: - @Composable internal fun com.ninecraft.booket.feature.webview.WebViewUi(state: com.ninecraft.booket.feature.webview.WebViewUiState, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true + skippable: false restartable: true params: - - state: STABLE + - state: UNSTABLE (has mutable properties or unstable members) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -private fun com.ninecraft.booket.feature.webview.WebViewUiPreview(): kotlin.Unit - skippable: true - restartable: true - params: - diff --git a/gradle.properties b/gradle.properties index 4f2bc3716..f552f2fee 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,14 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects -# org.gradle.parallel=true +org.gradle.parallel=true + +# Enable Gradle configuration caching +org.gradle.configuration-cache=true + +# Enable Gradle build cache +org.gradle.caching=true + # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 53eb5898e..0f85ffaf8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,63 +1,66 @@ [versions] ## App Configuration minSdk = "28" -targetSdk = "35" -compileSdk = "35" -versionName = "1.3.0" -versionCode = "9" +targetSdk = "36" +compileSdk = "36" +versionName = "1.4.0" +versionCode = "11" packageName = "com.ninecraft.booket" ## Android gradle plugin -android-gradle-plugin = "8.9.3" +android-gradle-plugin = "9.0.0" ## AndroidX -androidx-core = "1.16.0" -androidx-activity-compose = "1.10.1" +androidx-core = "1.17.0" +androidx-activity-compose = "1.12.3" androidx-startup = "1.2.0" -androidx-splash = "1.0.1" -androidx-datastore = "1.1.7" -androidx-camera = "1.4.2" +androidx-splash = "1.2.0" +androidx-datastore = "1.2.0" +androidx-camera = "1.5.3" ## Compose -androidx-compose-bom = "2025.07.00" -androidx-compose-material3 = "1.4.0-alpha18" -compose-stable-marker = "1.0.6" -compose-effects = "0.1.1" -compose-shadow = "2.0.4" -compose-stability-analyzer = "0.4.2" +androidx-compose-bom = "2026.01.01" +androidx-compose-material3 = "1.4.0" +compose-stable-marker = "1.0.7" +compose-effects = "0.1.4" +compose-stability-analyzer = "0.6.6" ## Kotlin Symbol Processing -ksp = "2.3.0" +ksp = "2.3.4" ## Kotlin -kotlin = "2.2.21" +kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" -kotlinx-serialization-json = "1.9.0" +kotlinx-serialization-json = "1.10.0" kotlinx-collections-immutable = "0.4.0" -## Hilt -hilt = "2.57" -hilt-navigation-compose = "1.2.0" +## Metro +metro = "0.10.2" ## Network -okhttp = "5.1.0" +okhttp = "5.3.2" retrofit = "3.0.0" +chucker = "4.3.1" ## Circuit -circuit = "0.30.0" +circuit = "0.32.0" ## Logging logger = "2.2.0" ## Kakao Login -kakao-core = "2.21.4" +kakao-core = "2.23.2" + +## Google Credential Manager +androidx-credentials = "1.6.0-rc01" +googleid = "1.2.0" ## Image Load coil-compose = "2.7.0" -landscapist = "2.5.1" +landscapist = "2.9.0" ## Lottie -lottie = "6.6.6" +lottie = "6.7.1" ## Extension # https://github.com/jisungbin/dependency-handler-extensions @@ -71,18 +74,18 @@ kotlin-ktlint-gradle = "11.6.1" kotlin-ktlint-source = "0.50.0" ## Test -androidx-test-ext-junit = "1.2.1" -androidx-test-runner = "1.6.2" +androidx-test-ext-junit = "1.3.0" +androidx-test-runner = "1.7.0" ## Firebase -google-service = "4.4.3" -firebase-bom = "34.1.0" -firebase-crashlytics = "3.0.4" +google-service = "4.4.4" +firebase-bom = "34.8.0" +firebase-crashlytics = "3.0.6" [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" } compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +ksp-gradle-plugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } @@ -108,15 +111,12 @@ compose-stable-marker = { group = "com.github.skydoves", name = "compose-stable- compose-effects = { group = "com.github.skydoves", name = "compose-effects", version.ref = "compose-effects" } compose-system-ui-controller = { group = "tech.thdev", name = "extensions-compose-system-ui-controller", version.ref = "compose-extensions" } compose-keyboard-state = { group = "tech.thdev", name = "extensions-compose-keyboard-state", version.ref = "compose-extensions" } -compose-shadow = { group = "com.adamglin", name = "compose-shadow", version.ref = "compose-shadow" } - -hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } -hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlinx-serialization-converter = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } +chucker-no-op = { group = "com.github.chuckerteam.chucker", name = "library-no-op", version.ref = "chucker" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil-compose" } landscapist-bom = { group = "com.github.skydoves", name = "landscapist-bom", version.ref = "landscapist" } @@ -142,6 +142,10 @@ detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-form kakao-auth = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-core" } +androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidx-credentials" } +androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidx-credentials" } +googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } + lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } @@ -160,7 +164,6 @@ android-application = { id = "com.android.application", version.ref = "android-g android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } android-test = { id = "com.android.test", version.ref = "android-gradle-plugin" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } @@ -168,13 +171,13 @@ kotlin-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "kotlin-dete kotlin-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "kotlin-ktlint-gradle" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +metro = { id = "dev.zacsweers.metro", version.ref = "metro" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } google-service = { id = "com.google.gms.google-services", version.ref = "google-service" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" } -compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "compose-stability-analyzer"} +compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "compose-stability-analyzer" } # Plugins defined by this project booket-android-application = { id = "booket.android.application", version = "unspecified" } @@ -184,7 +187,6 @@ booket-android-library-compose = { id = "booket.android.library.compose", versio booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified" } booket-android-feature = { id = "booket.android.feature", version = "unspecified" } booket-android-firebase = { id = "booket.android.firebase", version = "unspecified" } -booket-android-hilt = { id = "booket.android.hilt", version = "unspecified" } booket-jvm-library = { id = "booket.jvm.library", version = "unspecified" } booket-kotlin-library-serialization = { id = "booket.kotlin.library.serialization", version = "unspecified" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8f89fa561..04d281290 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Jun 13 20:04:35 KST 2025 +#Tue Feb 03 16:01:18 KST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 9508c61bb..8a7371207 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ include( ":core:datastore:api", ":core:datastore:impl", ":core:designsystem", + ":core:di", ":core:model", ":core:network", ":core:ui", diff --git a/stability.config.conf b/stability.config.conf new file mode 100644 index 000000000..2d770ff8e --- /dev/null +++ b/stability.config.conf @@ -0,0 +1,32 @@ +// Stability Configuration for Reed-Android +// This file declares classes that should be considered stable by the Compose Compiler +// https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file + +// Kotlin standard library +kotlin.Pair +kotlin.Triple +kotlin.Result + +// Kotlinx collections +kotlinx.collections.immutable.* + +// Kotlinx coroutines +kotlinx.coroutines.flow.StateFlow +kotlinx.coroutines.flow.Flow +kotlinx.coroutines.flow.MutableStateFlow +kotlinx.coroutines.flow.SharedFlow +kotlinx.coroutines.flow.MutableSharedFlow + +// Project model classes +com.ninecraft.booket.core.model.* +com.ninecraft.booket.core.domain.model.* + +// Circuit +com.slack.circuit.runtime.CircuitUiState +com.slack.circuit.runtime.CircuitUiEvent + +// Android architecture components +androidx.lifecycle.LiveData +androidx.lifecycle.MutableLiveData +androidx.compose.runtime.State +androidx.compose.runtime.MutableState