diff --git a/README.md b/README.md new file mode 100644 index 00000000..131a8e3c --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Reed - 문장과 감정을 함께 담는 독서 기록 + +[![Kotlin](https://img.shields.io/badge/Kotlin-2.2.0-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) +[![targetSdkVersion](https://img.shields.io/badge/targetSdkVersion-35-orange)](https://developer.android.com/distribute/best-practices/develop/target-sdk) +![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/YAPP-Github/Reed-Android?utm_source=oss&utm_medium=github&utm_campaign=YAPP-Github%2FReed-Android&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) +
+reed_graphic + + + + + +

+ + + +

+

+ + +

+ +## Features +| 홈 | 도서 검색 및 등록 | 내서재 | +|:---:|:---:|:---:| +| 홈 | 도서 검색 및 등록 | 내서재 | + +| OCR | 기록 등록 | 도서 & 기록 상세 | +|:---:|:---:|:---:| +| OCR | 기록 등록 | 도서 & 기록 상세 | + +| 기록 카드 공유 | +|:---:| +| 기록 카드 공유 | + +## TroubleShooting +- [[Compose] M3 ModalBottomSheet 드래그(터치 이벤트) 막는 법](https://velog.io/@mraz3068/Compose-M3-ModalBottomSheet-Drag-Disabled) +- [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) + +## Development + +### Required + +- IDE : Android Studio 최신 버전 +- JDK : Java 17을 실행할 수 있는 JDK + - (권장) Android Studio 설치 시 Embedded 된 JDK (Open JDK) + - Java 17을 사용하는 JDK (Open JDK, AdoptOpenJDK, GraalVM) +- Kotlin Language : 2.2.0 + +### Language + +- Kotlin + +### Libraries + +- AndroidX + - Activity Compose + - Core + - DataStore + - StartUp + - Splash + - CameraX + +- Kotlin Libraries (Coroutine, Serialization, Immutable Collection) +- Compose + - Material3 + +- [Circuit](https://github.com/slackhq/circuit) +- ~~Google ML Kit~~ Google Cloud Vision +- Dagger Hilt +- Retrofit, OkHttp3 +- Lottie-Compose +- Firebase(Analytics, Crashlytics, Remote Config) +- Kakao-Auth +- [Logger](https://github.com/orhanobut/logger) +- [Compose-Stable-Marker](https://github.com/skydoves/compose-stable-marker) +- [Landscapist](https://github.com/skydoves/landscapist), Coil-Compose +- [ComposeExtensions](https://github.com/taehwandev/ComposeExtensions) +- [compose-effects](https://github.com/skydoves/compose-effects) +- [compose-shadow](https://github.com/adamglin0/compose-shadow) + +#### Test & Code analysis + +- Ktlint +- Detekt + +#### Gradle Dependency + +- Gradle Version Catalog + +## Architecture +- Android App Architecture +- MVI + +## Developers + +|Android|Android| +|:---:|:---:| +|[이지훈](https://github.com/easyhooon)|[이서윤](https://github.com/seoyoon513)| +||| + +## Module +image + +## Package Structure +``` +├── app +│   └── application +├── build-logic +├── core +│   ├── common +│   ├── data-api +│   ├── data-impl +│   ├── datastore-api +│   ├── datastore-impl +│   ├── designsystem +│   ├── model +│   ├── network +│   ├── ocr +│   └── ui +├── feature +│   ├── detail +│   ├── edit +│   ├── home +│   ├── library +│   ├── login +│   ├── main +│   ├── onboarding +│   ├── record +│   ├── screens +│   ├── settings +│   ├── splash +│   └── webview +├── gradle +    └── libs.versions.toml + +``` +
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 new file mode 100644 index 00000000..bfe3bbf5 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/UiText.kt @@ -0,0 +1,27 @@ +package com.ninecraft.booket.core.common.utils + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +// https://www.youtube.com/watch?v=mB1Lej0aDus +sealed class UiText { + data class DirectString(val value: String) : UiText() + + class StringResource( + @StringRes val resId: Int, + vararg val args: Any, + ) : UiText() + + @Composable + fun asString() = when (this) { + is DirectString -> value + is StringResource -> stringResource(resId, *args) + } + + fun asString(context: Context) = when (this) { + is DirectString -> value + is StringResource -> context.getString(resId, *args) + } +} 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 35bfbb11..c8943668 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,6 +1,7 @@ package com.ninecraft.booket.core.data.api.repository import com.ninecraft.booket.core.model.AutoLoginState +import com.ninecraft.booket.core.model.UserState import kotlinx.coroutines.flow.Flow interface AuthRepository { @@ -11,4 +12,8 @@ interface AuthRepository { suspend fun withdraw(): Result val autoLoginState: Flow + + val userState: Flow + + suspend fun getCurrentUserState(): UserState } diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt index b29faf03..b2a853de 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt @@ -12,6 +12,11 @@ interface BookRepository { val bookRecentSearches: Flow> val libraryRecentSearches: Flow> + suspend fun searchBookAsGuest( + query: String, + start: Int, + ): Result + suspend fun searchBook( query: String, start: Int, 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 85de7a76..ba0e0e96 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 @@ -26,6 +26,8 @@ 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.GuestBookSearchResponse +import com.ninecraft.booket.core.network.response.GuestBookSummary import com.ninecraft.booket.core.network.response.HomeResponse import com.ninecraft.booket.core.network.response.LibraryBookSummary import com.ninecraft.booket.core.network.response.LibraryBooks @@ -79,6 +81,35 @@ internal fun BookSummary.toModel(): BookSummaryModel { ) } +internal fun GuestBookSearchResponse.toModel(): BookSearchModel { + return BookSearchModel( + version = version, + title = title, + pubDate = pubDate, + totalResults = totalResults, + startIndex = startIndex, + itemsPerPage = itemsPerPage, + query = query, + searchCategoryId = searchCategoryId, + searchCategoryName = searchCategoryName, + lastPage = lastPage, + books = books.map { it.toModel() }, + ) +} + +internal fun GuestBookSummary.toModel(): BookSummaryModel { + return BookSummaryModel( + isbn13 = isbn13, + title = title.decodeHtmlEntities(), + author = author, + publisher = publisher, + coverImageUrl = coverImageUrl, + link = link, + userBookStatus = "BEFORE_REGISTRATION", + key = "$title-$isbn13", + ) +} + internal fun BookDetailResponse.toModel(): BookDetailModel { return BookDetailModel( version = version, 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 c165bba7..9e560650 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 @@ -4,6 +4,7 @@ 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.TokenDataSource import com.ninecraft.booket.core.model.AutoLoginState +import com.ninecraft.booket.core.model.UserState import com.ninecraft.booket.core.network.request.LoginRequest import com.ninecraft.booket.core.network.service.ReedService import kotlinx.coroutines.flow.map @@ -48,9 +49,16 @@ internal class DefaultAuthRepository @Inject constructor( override val autoLoginState = tokenDataSource.accessToken .map { accessToken -> - when { - accessToken.isBlank() -> AutoLoginState.NOT_LOGGED_IN - else -> AutoLoginState.LOGGED_IN - } + if (accessToken.isBlank()) AutoLoginState.NOT_LOGGED_IN else AutoLoginState.LOGGED_IN } + + override val userState = tokenDataSource.accessToken + .map { accessToken -> + if (accessToken.isBlank()) UserState.Guest else UserState.LoggedIn + } + + override suspend fun getCurrentUserState(): UserState { + val accessToken = tokenDataSource.getAccessToken() + return if (accessToken.isBlank()) UserState.Guest else UserState.LoggedIn + } } 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 cf64e2f2..2f4580c5 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 @@ -17,6 +17,19 @@ internal class DefaultBookRepository @Inject constructor( override val bookRecentSearches = bookRecentSearchDataSource.recentSearches override val libraryRecentSearches = libraryRecentSearchDataSource.recentSearches + override suspend fun searchBookAsGuest( + query: String, + start: Int, + ) = runSuspendCatching { + val result = service.searchBookAsGuest( + query = query, + start = start, + ).toModel() + + bookRecentSearchDataSource.addRecentSearch(query) + result + } + override suspend fun searchBook( query: String, start: Int, 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 57db2455..b4ddfb90 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 @@ -2,12 +2,13 @@ package com.ninecraft.booket.core.designsystem.component.button import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.theme.Kakao import com.ninecraft.booket.core.designsystem.theme.ReedTheme enum class ReedButtonColorStyle { - PRIMARY, SECONDARY, TERTIARY, STROKE, KAKAO; + PRIMARY, SECONDARY, TERTIARY, STROKE, TEXT, KAKAO; @Composable fun containerColor(isPressed: Boolean) = when (this) { @@ -15,6 +16,7 @@ enum class ReedButtonColorStyle { 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 + TEXT -> Color.Transparent KAKAO -> Kakao } @@ -24,11 +26,15 @@ enum class ReedButtonColorStyle { SECONDARY -> ReedTheme.colors.contentPrimary TERTIARY -> ReedTheme.colors.contentBrand STROKE -> ReedTheme.colors.contentBrand + TEXT -> ReedTheme.colors.borderBrand KAKAO -> ReedTheme.colors.contentPrimary } @Composable - fun disabledContainerColor() = ReedTheme.colors.bgDisabled + fun disabledContainerColor() = when (this) { + TEXT -> Color.Transparent + else -> ReedTheme.colors.bgDisabled + } @Composable fun disabledContentColor() = ReedTheme.colors.contentDisabled diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedTextButton.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedTextButton.kt new file mode 100644 index 00000000..c1aa1470 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/button/ReedTextButton.kt @@ -0,0 +1,103 @@ +package com.ninecraft.booket.core.designsystem.component.button + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.utils.MultipleEventsCutter +import com.ninecraft.booket.core.common.utils.get +import com.ninecraft.booket.core.designsystem.ComponentPreview + +@Composable +fun ReedTextButton( + onClick: () -> Unit, + text: String, + sizeStyle: ButtonSizeStyle, + colorStyle: ReedButtonColorStyle, + modifier: Modifier = Modifier, + enabled: Boolean = true, + multipleEventsCutterEnabled: Boolean = true, +) { + val multipleEventsCutter = remember { MultipleEventsCutter.get() } + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + TextButton( + onClick = { + if (multipleEventsCutterEnabled) { + multipleEventsCutter.processEvent { onClick() } + } else { + onClick() + } + }, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.textButtonColors( + containerColor = colorStyle.containerColor(isPressed), + contentColor = colorStyle.contentColor(), + disabledContentColor = colorStyle.disabledContentColor(), + disabledContainerColor = colorStyle.disabledContainerColor(), + ), + contentPadding = sizeStyle.paddingValues, + ) { + Column( + modifier = Modifier.width(IntrinsicSize.Max), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Text( + text = text, + style = sizeStyle.textStyle.copy( + color = if (enabled) colorStyle.contentColor() else colorStyle.disabledContentColor(), + ), + ) + HorizontalDivider( + thickness = 1.dp, + color = if (enabled) colorStyle.contentColor() else Color.Transparent, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ReedTextButtonPreview() { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + ReedTextButton( + onClick = {}, + colorStyle = ReedButtonColorStyle.TEXT, + sizeStyle = largeButtonStyle, + text = "text button", + ) + + ReedTextButton( + onClick = {}, + enabled = false, + colorStyle = ReedButtonColorStyle.TEXT, + sizeStyle = largeButtonStyle, + text = "text button", + ) + } + } +} diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 90f1c070..a2793c60 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -3,4 +3,5 @@ 이용에 불편을 드려 죄송합니다.\n잠시후 다시 이용해주세요. 알 수 없는 오류가 발생하였습니다. 도서 검색 후 내 서재에 담아보세요 + 로그인이 필요한 기능입니다 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 cc592841..f1c8b05c 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 @@ -27,4 +27,11 @@ data class BookSummaryModel( val link: String = "", val userBookStatus: String = "", val key: String = "", -) +) { + val isRegistered: Boolean + get() = userBookStatus != BEFORE_REGISTRATION + + companion object { + const val BEFORE_REGISTRATION = "BEFORE_REGISTRATION" + } +} 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/UserState.kt new file mode 100644 index 00000000..43ec0824 --- /dev/null +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserState.kt @@ -0,0 +1,6 @@ +package com.ninecraft.booket.core.model + +sealed interface UserState { + data object Guest : UserState + data object LoggedIn : UserState +} 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 a3544d49..a49e6fe8 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 @@ -13,6 +13,7 @@ internal class TokenInterceptor @Inject constructor( private val noAuthEndpoints = setOf( "api/v1/auth/signin", "api/v1/auth/refresh", + "api/v1/books/guest/search", ) override fun intercept(chain: Interceptor.Chain): Response { diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/GuestBookSearchResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/GuestBookSearchResponse.kt new file mode 100644 index 00000000..eb38bfe5 --- /dev/null +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/GuestBookSearchResponse.kt @@ -0,0 +1,46 @@ +package com.ninecraft.booket.core.network.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GuestBookSearchResponse( + @SerialName("version") + val version: String, + @SerialName("title") + val title: String, + @SerialName("pubDate") + val pubDate: String, + @SerialName("totalResults") + val totalResults: Int, + @SerialName("startIndex") + val startIndex: Int, + @SerialName("itemsPerPage") + val itemsPerPage: Int, + @SerialName("query") + val query: String, + @SerialName("searchCategoryId") + val searchCategoryId: Int, + @SerialName("searchCategoryName") + val searchCategoryName: String, + @SerialName("lastPage") + val lastPage: Boolean, + @SerialName("books") + val books: List, +) + +@Serializable +data class GuestBookSummary( + @SerialName("isbn13") + val isbn13: String, + @SerialName("title") + val title: String, + @SerialName("author") + val author: String, + @SerialName("publisher") + val publisher: String, + @SerialName("coverImageUrl") + val coverImageUrl: String, + @SerialName("link") + val link: 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 f2b0b280..00dc717b 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 @@ -8,6 +8,7 @@ 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.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 @@ -50,6 +51,19 @@ interface ReedService { @GET("api/v1/users/me") suspend fun getUserProfile(): UserProfileResponse + // Book endpoints (no auth required) + @GET("api/v1/books/guest/search") + suspend fun searchBookAsGuest( + @Query("query") query: String, + @Query("queryType") queryType: String = "Title", + @Query("searchTarget") searchTarget: String = "Book", + @Query("maxResults") maxResults: Int = 20, + @Query("start") start: Int = 1, + @Query("sort") sort: String = "Accuracy", + @Query("cover") cover: String? = "Big", + @Query("categoryId") categoryId: Int = 0, + ): GuestBookSearchResponse + // Book endpoints (auth required) @GET("api/v1/books/search") suspend fun searchBook( diff --git a/core/ocr/build.gradle.kts b/core/ocr/build.gradle.kts index 4d70a87e..8fcb5005 100644 --- a/core/ocr/build.gradle.kts +++ b/core/ocr/build.gradle.kts @@ -26,9 +26,6 @@ dependencies { projects.core.common, libs.logger, - libs.androidx.camera.core, - - libs.google.mlkit.text.recognition.korean, ) } diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/LiveTextAnalyzer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/LiveTextAnalyzer.kt deleted file mode 100644 index 0b5ab9b0..00000000 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/LiveTextAnalyzer.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.ninecraft.booket.core.ocr.analyzer - -import androidx.annotation.OptIn -import androidx.camera.core.ExperimentalGetImage -import androidx.camera.core.ImageProxy -import com.google.mlkit.vision.common.InputImage -import com.google.mlkit.vision.text.TextRecognizer -import com.orhanobut.logger.Logger -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * 실시간 카메라 스트림에서 프레임 단위로 텍스트 분석하는 Analyzer 클래스 - * - * ML Kit의 TextRecognizer를 사용하여 `ImageProxy` 객체로부터 텍스트를 추출한다 - * - * @param textRecognizer ML Kit의 TextRecognizer 인스턴스 - * @param onTextDetected 텍스트 인식 성공 시 호출되는 콜백 (인식된 전체 텍스트 전달) - * - * 안정적인 연속 프레임 분석을 위해 CoroutineScope에 [SupervisorJob]을 사용하여 - * 한 프레임 분석에서 예외가 발생해도 다음 프레임 분석에 영향을 주지 않도록 설계 - */ -class LiveTextAnalyzer @AssistedInject constructor( - private val textRecognizer: TextRecognizer, - @Assisted private val onTextDetected: (String) -> Unit, -) : TextAnalyzer { - - companion object { - const val THROTTLE_TIMEOUT_MS = 1_000L // 프레임 처리 간 인터벌 - } - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - @OptIn(ExperimentalGetImage::class) - override fun analyze(imageProxy: ImageProxy) { - scope.launch { - val mediaImage = imageProxy.image ?: run { imageProxy.close(); return@launch } - val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - - suspendCoroutine { continuation -> - textRecognizer.process(inputImage) - .addOnSuccessListener { visionText -> - onTextDetected(visionText.text) - } - .addOnFailureListener { exception -> - Logger.e(exception.message ?: "Unknown error") - } - .addOnCompleteListener { - continuation.resume(Unit) - } - } - delay(THROTTLE_TIMEOUT_MS) - }.invokeOnCompletion { exception -> - if (exception != null) { - Logger.e(exception.message ?: "Unknown error") - } - imageProxy.close() - } - } - - fun cancel() { - scope.cancel() - } - - @AssistedFactory - interface Factory { - fun create( - onTextDetected: (String) -> Unit, - ): LiveTextAnalyzer - } -} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/StillTextAnalyzer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/StillTextAnalyzer.kt deleted file mode 100644 index 39865504..00000000 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/StillTextAnalyzer.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.ninecraft.booket.core.ocr.analyzer - -import androidx.annotation.OptIn -import androidx.camera.core.ExperimentalGetImage -import androidx.camera.core.ImageProxy -import com.google.mlkit.vision.common.InputImage -import com.google.mlkit.vision.text.TextRecognizer -import com.orhanobut.logger.Logger -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * 정적인 카메라 이미지에서 텍스트를 분석하는 클래스 - * - * CameraX의 단일 ImageProxy 프레임을 받아 ML Kit을 통해 텍스트를 추출하고 결과를 콜백으로 전달한다. - * - * @param textRecognizer ML Kit의 TextRecognizer 인스턴스 - * @param onTextDetected 텍스트 인식 성공 시 호출되는 콜백 (인식된 전체 텍스트 전달) - * @param onFailure 인식 실패 시 호출되는 콜백 - * - * 분석이 끝난 후 반드시 imageProxy.close() 호출하여 리소스 해제 - */ -class StillTextAnalyzer @AssistedInject constructor( - private val textRecognizer: TextRecognizer, - @Assisted private val onTextDetected: (String) -> Unit, - @Assisted private val onFailure: () -> Unit, -) : TextAnalyzer { - - private val scope = CoroutineScope(Dispatchers.IO) - - @OptIn(ExperimentalGetImage::class) - override fun analyze(imageProxy: ImageProxy) { - scope.launch { - val mediaImage = imageProxy.image ?: run { imageProxy.close(); return@launch } - val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - - suspendCoroutine { continuation -> - textRecognizer.process(inputImage) - .addOnSuccessListener { visionText -> - onTextDetected(visionText.text) - } - .addOnFailureListener { - onFailure() - } - .addOnCompleteListener { - continuation.resume(Unit) - } - } - }.invokeOnCompletion { exception -> - if (exception != null) { - Logger.e(exception.message ?: "Unknown error") - } - imageProxy.close() - } - } - - fun cancel() { - scope.cancel() - } - - @AssistedFactory - interface Factory { - fun create( - onTextDetected: (String) -> Unit, - onFailure: () -> Unit, - ): StillTextAnalyzer - } -} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/TextAnalyzer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/TextAnalyzer.kt deleted file mode 100644 index c4d30b3c..00000000 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/TextAnalyzer.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.ninecraft.booket.core.ocr.analyzer - -import androidx.camera.core.ImageProxy - -interface TextAnalyzer { - fun analyze(imageProxy: ImageProxy) -} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/OcrModule.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/OcrModule.kt deleted file mode 100644 index 99ce5740..00000000 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/di/OcrModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.ninecraft.booket.core.ocr.di - -import com.google.mlkit.vision.text.TextRecognition -import com.google.mlkit.vision.text.TextRecognizer -import com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object OcrModule { - - @Provides - @Singleton - fun provideTextRecognizer(): TextRecognizer = - TextRecognition.getClient(KoreanTextRecognizerOptions.Builder().build()) -} diff --git a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt similarity index 97% rename from core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt rename to core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt index 538095db..9035c5cf 100644 --- a/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/analyzer/CloudOcrRecognizer.kt +++ b/core/ocr/src/main/kotlin/com/ninecraft/booket/core/ocr/recognizer/CloudOcrRecognizer.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.core.ocr.analyzer +package com.ninecraft.booket.core.ocr.recognizer import android.net.Uri import android.util.Base64 diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt index 3adbc6b0..cd8f9ade 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedLoadingIndicator.kt @@ -4,23 +4,34 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.ninecraft.booket.core.common.extensions.noRippleClickable import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import kotlinx.coroutines.delay @Composable fun ReedLoadingIndicator( modifier: Modifier = Modifier, + delayMillis: Long = 500L, ) { - Box( - modifier = modifier - .fillMaxSize() - .noRippleClickable {}, - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + val showProgressBar by produceState(initialValue = false, key1 = delayMillis) { + delay(delayMillis) + value = true + } + + if (showProgressBar) { + Box( + modifier = modifier + .fillMaxSize() + .noRippleClickable {}, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } } } 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 4d00094f..67d3e962 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 @@ -1,7 +1,6 @@ package com.ninecraft.booket.feature.detail.book 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 @@ -23,8 +22,8 @@ import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.ninecraft.booket.feature.screens.RecordEditScreen import com.ninecraft.booket.feature.screens.RecordScreen import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs -import com.ninecraft.booket.feature.screens.extensions.delayedGoTo import com.orhanobut.logger.Logger +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator @@ -135,7 +134,7 @@ class BookDetailPresenter @AssistedInject constructor( exception = e, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } @@ -160,7 +159,7 @@ class BookDetailPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } @@ -210,7 +209,7 @@ class BookDetailPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } @@ -234,14 +233,14 @@ class BookDetailPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } } } - LaunchedEffect(Unit) { + RememberedEffect(Unit) { initialLoad() } @@ -304,15 +303,13 @@ class BookDetailPresenter @AssistedInject constructor( is BookDetailUiEvent.OnShareRecordClick -> { isRecordMenuBottomSheetVisible = false - scope.launch { - navigator.delayedGoTo( - RecordCardScreen( - quote = selectedRecordInfo.quote, - bookTitle = selectedRecordInfo.bookTitle, - emotionTag = selectedRecordInfo.emotionTags[0], - ), - ) - } + navigator.goTo( + RecordCardScreen( + quote = selectedRecordInfo.quote, + bookTitle = selectedRecordInfo.bookTitle, + emotionTag = selectedRecordInfo.emotionTags[0], + ), + ) } is BookDetailUiEvent.OnEditRecordClick -> { 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 0c1a6d7d..cc27a011 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 @@ -136,7 +136,10 @@ internal fun BookDetailUi( }, sheetState = recordMenuBottomSheetState, onShareRecordClick = { - state.eventSink(BookDetailUiEvent.OnShareRecordClick) + coroutineScope.launch { + recordMenuBottomSheetState.hide() + state.eventSink(BookDetailUiEvent.OnShareRecordClick) + } }, onEditRecordClick = { coroutineScope.launch { 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 f5921683..151eeb03 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 @@ -14,7 +14,6 @@ import com.ninecraft.booket.feature.screens.RecordCardScreen import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.ninecraft.booket.feature.screens.RecordEditScreen import com.ninecraft.booket.feature.screens.arguments.RecordEditArgs -import com.ninecraft.booket.feature.screens.extensions.delayedGoTo import com.orhanobut.logger.Logger import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject @@ -69,7 +68,7 @@ class RecordDetailPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } @@ -93,7 +92,7 @@ class RecordDetailPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } @@ -124,15 +123,13 @@ class RecordDetailPresenter @AssistedInject constructor( is RecordDetailUiEvent.OnShareRecordClick -> { isRecordMenuBottomSheetVisible = false - scope.launch { - navigator.delayedGoTo( - RecordCardScreen( - quote = recordDetailInfo.quote, - bookTitle = recordDetailInfo.bookTitle, - emotionTag = recordDetailInfo.emotionTags[0], - ), - ) - } + navigator.goTo( + RecordCardScreen( + quote = recordDetailInfo.quote, + bookTitle = recordDetailInfo.bookTitle, + emotionTag = recordDetailInfo.emotionTags[0], + ), + ) } is RecordDetailUiEvent.OnEditRecordClick -> { 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 ebd2bea1..48248dd9 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 @@ -83,7 +83,10 @@ internal fun RecordDetailUi( }, sheetState = recordMenuBottomSheetState, onShareRecordClick = { - state.eventSink(RecordDetailUiEvent.OnShareRecordClick) + coroutineScope.launch { + recordMenuBottomSheetState.hide() + state.eventSink(RecordDetailUiEvent.OnShareRecordClick) + } }, onEditRecordClick = { coroutineScope.launch { 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 f420ff45..427ce11e 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 @@ -106,7 +106,7 @@ class RecordEditPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } 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 0a7972c2..9f1257eb 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 @@ -85,114 +85,119 @@ internal fun RecordEditUi( @Composable private fun ColumnScope.RecordEditContent(state: RecordEditUiState) { - BookItem( - imageUrl = state.recordInfo.bookCoverImageUrl, - bookTitle = state.recordInfo.bookTitle, - author = state.recordInfo.author, - publisher = state.recordInfo.bookPublisher, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - thickness = ReedTheme.border.border1, - color = ReedTheme.colors.dividerSm, - ) Column( modifier = Modifier .weight(1f) - .padding(horizontal = ReedTheme.spacing.spacing5) .verticalScroll(rememberScrollState()), ) { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) - Text( - text = stringResource(R.string.edit_record_page_label), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.body1Medium, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReedRecordTextField( - recordState = state.recordPageState, - recordHintRes = R.string.edit_record_page_hint, - inputTransformation = digitOnlyInputTransformation, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - lineLimits = TextFieldLineLimits.SingleLine, - isError = state.isPageError, - errorMessage = stringResource(R.string.edit_record_page_input_error), - onClear = { - state.eventSink(RecordEditUiEvent.OnClearClick) - }, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) - Text( - text = stringResource(R.string.edit_record_quote_label), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.body1Medium, + BookItem( + imageUrl = state.recordInfo.bookCoverImageUrl, + bookTitle = state.recordInfo.bookTitle, + author = state.recordInfo.author, + publisher = state.recordInfo.bookPublisher, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReedRecordTextField( - recordState = state.recordQuoteState, - recordHintRes = R.string.edit_record_quote_hint, - modifier = Modifier - .fillMaxWidth() - .height(140.dp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Default, - ), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) - Text( - text = stringResource(R.string.edit_record_impression_label), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.body1Medium, + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = ReedTheme.border.border1, + color = ReedTheme.colors.dividerSm, ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReedRecordTextField( - recordState = state.recordImpressionState, - recordHintRes = R.string.edit_record_impression_hint, + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Column( modifier = Modifier .fillMaxWidth() - .height(140.dp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Default, - ), - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = ReedTheme.spacing.spacing5), ) { Text( - text = stringResource(R.string.edit_record_emotion_label), + text = stringResource(R.string.edit_record_page_label), color = ReedTheme.colors.contentPrimary, style = ReedTheme.typography.body1Medium, ) - Spacer(modifier = Modifier.weight(1f)) - Row( - modifier = Modifier.clickable { - state.eventSink(RecordEditUiEvent.OnEmotionEditClick) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordPageState, + recordHintRes = R.string.edit_record_page_hint, + inputTransformation = digitOnlyInputTransformation, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine, + isError = state.isPageError, + errorMessage = stringResource(R.string.edit_record_page_input_error), + onClear = { + state.eventSink(RecordEditUiEvent.OnClearClick) }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Text( + text = stringResource(R.string.edit_record_quote_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordQuoteState, + recordHintRes = R.string.edit_record_quote_hint, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + ), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Text( + text = stringResource(R.string.edit_record_impression_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedRecordTextField( + recordState = state.recordImpressionState, + recordHintRes = R.string.edit_record_impression_hint, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + ), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing8)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - val emotion = state.recordInfo.emotionTags.firstOrNull() ?: "" - Text( - text = emotion, - color = ReedTheme.colors.contentSecondary, + text = stringResource(R.string.edit_record_emotion_label), + color = ReedTheme.colors.contentPrimary, style = ReedTheme.typography.body1Medium, ) - Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_right), - contentDescription = "Chevron Right Icon", - tint = ReedTheme.colors.contentSecondary, - ) + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.clickable { + state.eventSink(RecordEditUiEvent.OnEmotionEditClick) + }, + ) { + val emotion = state.recordInfo.emotionTags.firstOrNull() ?: "" + + Text( + text = emotion, + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_right), + contentDescription = "Chevron Right Icon", + tint = ReedTheme.colors.contentSecondary, + ) + } } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing16)) } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing16)) } ReedButton( onClick = { 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 dbfc9623..175b1804 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 @@ -6,15 +6,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.RecentBookModel +import com.ninecraft.booket.core.model.UserState import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.RecordScreen -import com.ninecraft.booket.feature.screens.SearchScreen +import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter @@ -29,14 +32,15 @@ import kotlinx.coroutines.launch class HomePresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, - private val repository: BookRepository, + private val bookRepository: BookRepository, + private val authRepository: AuthRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { @Composable override fun present(): HomeUiState { val scope = rememberCoroutineScope() - + val userState by authRepository.userState.collectAsRetainedState(initial = UserState.Guest) var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var recentBooks by rememberRetained { mutableStateOf(persistentListOf()) } @@ -46,7 +50,7 @@ class HomePresenter @AssistedInject constructor( uiState = UiState.Loading } - repository.getHome() + bookRepository.getHome() .onSuccess { result -> uiState = UiState.Success recentBooks = result.recentBooks.toPersistentList() @@ -63,7 +67,7 @@ class HomePresenter @AssistedInject constructor( } is HomeUiEvent.OnBookRegisterClick -> { - navigator.goTo(SearchScreen) + navigator.goTo(BookSearchScreen) } is HomeUiEvent.OnRecordButtonClick -> { @@ -88,8 +92,10 @@ class HomePresenter @AssistedInject constructor( } } - RememberedEffect(true) { - loadHomeContent() + RememberedEffect(userState) { + if (userState !is UserState.Guest) { + loadHomeContent() + } } ImpressionEffect { @@ -99,6 +105,7 @@ class HomePresenter @AssistedInject constructor( return HomeUiState( uiState = uiState, recentBooks = recentBooks, + isGuestMode = userState is UserState.Guest, eventSink = ::handleEvent, ) } 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 82221807..8307716e 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 @@ -94,85 +94,110 @@ internal fun HomeContent( .fillMaxSize() .background(ReedTheme.colors.baseSecondary), ) { - when (state.uiState) { - is UiState.Idle -> {} - is UiState.Loading -> { - ReedLoadingIndicator() + if (state.isGuestMode) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Text( + text = stringResource(R.string.home_content_label_reading_now), + modifier = Modifier.padding(start = ReedTheme.spacing.spacing5), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.headline2Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) + EmptyBookCard( + onBookRegisterClick = { + state.eventSink(HomeUiEvent.OnBookRegisterClick) + }, + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) } + } else { + when (state.uiState) { + is UiState.Idle -> {} + is UiState.Loading -> { + ReedLoadingIndicator() + } - is UiState.Success -> { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) - Text( - text = stringResource(R.string.home_content_label_reading_now), - modifier = Modifier.padding(start = ReedTheme.spacing.spacing5), - color = ReedTheme.colors.contentSecondary, - style = ReedTheme.typography.headline2Medium, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) - - if (state.recentBooks.isEmpty()) { - EmptyBookCard( - onBookRegisterClick = { - state.eventSink(HomeUiEvent.OnBookRegisterClick) - }, - modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), + is UiState.Success -> { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Text( + text = stringResource(R.string.home_content_label_reading_now), + modifier = Modifier.padding(start = ReedTheme.spacing.spacing5), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.headline2Medium, ) - } else { - val pagerState = rememberPagerState(pageCount = { state.recentBooks.size }) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = ReedTheme.spacing.spacing5), - pageSpacing = ReedTheme.spacing.spacing5, - ) { page -> - BookCard( - recentBookInfo = state.recentBooks[page], - onBookDetailClick = { - state.eventSink( - HomeUiEvent.OnBookDetailClick( - state.recentBooks[page].userBookId, - state.recentBooks[page].isbn13, - ), - ) - }, - onRecordButtonClick = { - state.eventSink(HomeUiEvent.OnRecordButtonClick(state.recentBooks[page].userBookId)) + if (state.recentBooks.isEmpty()) { + EmptyBookCard( + onBookRegisterClick = { + state.eventSink(HomeUiEvent.OnBookRegisterClick) }, + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), ) - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - repeat(pagerState.pageCount) { iteration -> - val color = - if (pagerState.currentPage == iteration) ReedTheme.colors.bgPrimary else ReedTheme.colors.bgSecondaryPressed - Box( - modifier = Modifier - .size(12.dp) - .padding(3.dp) - .clip(CircleShape) - .background(color), + } else { + val pagerState = rememberPagerState(pageCount = { state.recentBooks.size }) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = ReedTheme.spacing.spacing5), + pageSpacing = ReedTheme.spacing.spacing5, + ) { page -> + BookCard( + recentBookInfo = state.recentBooks[page], + onBookDetailClick = { + state.eventSink( + HomeUiEvent.OnBookDetailClick( + state.recentBooks[page].userBookId, + state.recentBooks[page].isbn13, + ), + ) + }, + onRecordButtonClick = { + state.eventSink(HomeUiEvent.OnRecordButtonClick(state.recentBooks[page].userBookId)) + }, ) } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) ReedTheme.colors.bgPrimary else ReedTheme.colors.bgSecondaryPressed + Box( + modifier = Modifier + .size(12.dp) + .padding(3.dp) + .clip(CircleShape) + .background(color), + ) + } + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing7)) } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing7)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) } } - } - is UiState.Error -> { - ReedErrorUi( - exception = state.uiState.exception, - onRetryClick = { state.eventSink(HomeUiEvent.OnRetryClick) }, - ) + is UiState.Error -> { + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(HomeUiEvent.OnRetryClick) }, + ) + } } } } diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt index 9b3115ee..c90d001a 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt @@ -20,6 +20,7 @@ sealed interface UiState { data class HomeUiState( val uiState: UiState = UiState.Idle, val recentBooks: ImmutableList = persistentListOf(), + val isGuestMode: Boolean = false, val sideEffect: HomeSideEffect? = null, val eventSink: (HomeUiEvent) -> Unit, ) : CircuitUiState diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/HandleLibrarySideEffects.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/HandleLibrarySideEffects.kt index 1b20a035..84681666 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/HandleLibrarySideEffects.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/HandleLibrarySideEffects.kt @@ -15,7 +15,7 @@ internal fun HandleLibrarySideEffects( RememberedEffect(state.sideEffect) { when (state.sideEffect) { is LibrarySideEffect.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/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 25b609cc..712d21d0 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 @@ -7,16 +7,21 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.analytics.AnalyticsHelper +import com.ninecraft.booket.core.common.utils.UiText +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.ui.component.FooterState import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.LibraryScreen import com.ninecraft.booket.feature.screens.LibrarySearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen +import com.ninecraft.booket.feature.screens.extensions.redirectToLogin import com.orhanobut.logger.Logger import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter @@ -28,10 +33,12 @@ import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch +import com.ninecraft.booket.core.designsystem.R as designR class LibraryPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, - private val repository: BookRepository, + private val bookRepository: BookRepository, + private val authRepository: AuthRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { companion object { @@ -42,7 +49,7 @@ class LibraryPresenter @AssistedInject constructor( @Composable override fun present(): LibraryUiState { val scope = rememberCoroutineScope() - + val userState by authRepository.userState.collectAsRetainedState(initial = UserState.Guest) var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var footerState by rememberRetained { mutableStateOf(FooterState.Idle) } var filterChips by rememberRetained { @@ -63,7 +70,7 @@ class LibraryPresenter @AssistedInject constructor( footerState = FooterState.Loading } - repository.filterLibraryBooks(status = status, page = page, size = size) + bookRepository.filterLibraryBooks(status = status, page = page, size = size) .onSuccess { result -> filterChips = filterChips.map { chip -> when (chip.option) { @@ -109,7 +116,14 @@ class LibraryPresenter @AssistedInject constructor( } is LibraryUiEvent.OnLibrarySearchClick -> { - navigator.goTo(LibrarySearchScreen) + if (userState is UserState.Guest) { + scope.launch { + sideEffect = LibrarySideEffect.ShowToast(UiText.StringResource(designR.string.login_required)) + navigator.redirectToLogin() + } + } else { + navigator.goTo(LibrarySearchScreen) + } } is LibraryUiEvent.OnSettingsClick -> { @@ -151,15 +165,23 @@ class LibraryPresenter @AssistedInject constructor( restoreState = true, ) } + + is LibraryUiEvent.OnLoginClick -> { + scope.launch { + navigator.redirectToLogin() + } + } } } - RememberedEffect(Unit) { - filterLibraryBooks( - status = currentFilter.getApiValue(), - page = START_INDEX, - size = PAGE_SIZE, - ) + RememberedEffect(userState) { + if (userState !is UserState.Guest) { + filterLibraryBooks( + status = currentFilter.getApiValue(), + page = START_INDEX, + size = PAGE_SIZE, + ) + } } ImpressionEffect { @@ -172,6 +194,7 @@ class LibraryPresenter @AssistedInject constructor( filterChips = filterChips, currentFilter = currentFilter, books = books, + isGuestMode = userState is UserState.Guest, sideEffect = sideEffect, eventSink = ::handleEvent, ) 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 ed1f0512..c69c74bd 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 @@ -1,7 +1,6 @@ package com.ninecraft.booket.feature.library 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 @@ -18,6 +17,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.DevicePreview +import com.ninecraft.booket.core.designsystem.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.theme.ReedTheme import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.ReedScaffold @@ -89,7 +91,6 @@ internal fun LibraryContent( Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { FilterChipGroup( filterList = state.filterChips, @@ -100,54 +101,86 @@ internal fun LibraryContent( ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) - when (state.uiState) { - is UiState.Idle -> { - EmptyResult() + if (state.isGuestMode) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.library_login_required_title), + color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.headline1SemiBold, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + Text( + text = stringResource(R.string.library_login_required_description), + color = ReedTheme.colors.contentSecondary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + ReedButton( + onClick = { + state.eventSink(LibraryUiEvent.OnLoginClick) + }, + text = stringResource(R.string.login), + colorStyle = ReedButtonColorStyle.SECONDARY, + sizeStyle = mediumButtonStyle, + ) + } } + } else { + when (state.uiState) { + is UiState.Idle -> { + EmptyResult() + } - is UiState.Loading -> { - ReedLoadingIndicator() - } + is UiState.Loading -> { + ReedLoadingIndicator() + } - is UiState.Success -> { - if (state.books.isEmpty()) { - EmptyResult() - } else { - InfinityLazyColumn( - modifier = Modifier.fillMaxSize(), - loadMore = { - state.eventSink(LibraryUiEvent.OnLoadMore) - }, - ) { - items(state.books) { - LibraryBookItem( - book = it, - onBookClick = { - state.eventSink(LibraryUiEvent.OnBookClick(it.userBookId, it.isbn13)) - }, - ) - Box( - modifier = modifier - .fillMaxWidth() - .height(1.dp) - .background(ReedTheme.colors.borderPrimary), - ) - } - item { - LoadStateFooter( - footerState = state.footerState, - onRetryClick = { state.eventSink(LibraryUiEvent.OnLoadMore) }, - ) + is UiState.Success -> { + if (state.books.isEmpty()) { + EmptyResult() + } else { + InfinityLazyColumn( + modifier = Modifier.fillMaxSize(), + loadMore = { + state.eventSink(LibraryUiEvent.OnLoadMore) + }, + ) { + items(state.books) { + LibraryBookItem( + book = it, + onBookClick = { + state.eventSink(LibraryUiEvent.OnBookClick(it.userBookId, it.isbn13)) + }, + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(1.dp) + .background(ReedTheme.colors.borderPrimary), + ) + } + item { + LoadStateFooter( + footerState = state.footerState, + onRetryClick = { state.eventSink(LibraryUiEvent.OnLoadMore) }, + ) + } } } } - } - is UiState.Error -> { - ReedErrorUi( - exception = state.uiState.exception, - onRetryClick = { state.eventSink(LibraryUiEvent.OnRetryClick) }, - ) + is UiState.Error -> { + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(LibraryUiEvent.OnRetryClick) }, + ) + } } } } 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 83675e2a..cac40f41 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 @@ -1,6 +1,7 @@ package com.ninecraft.booket.feature.library import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.common.utils.UiText import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState import com.ninecraft.booket.feature.screens.component.MainTab @@ -9,6 +10,7 @@ import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import java.util.UUID @Immutable sealed interface UiState { @@ -25,13 +27,17 @@ data class LibraryUiState( LibraryFilterOption.entries.map { LibraryFilterChip(option = it, count = 0) }.toPersistentList(), val currentFilter: LibraryFilterOption = LibraryFilterOption.TOTAL, val books: ImmutableList = persistentListOf(), + val isGuestMode: Boolean = false, val sideEffect: LibrarySideEffect? = null, val eventSink: (LibraryUiEvent) -> Unit, ) : CircuitUiState @Immutable sealed interface LibrarySideEffect { - data class ShowToast(val message: String) : LibrarySideEffect + data class ShowToast( + val message: UiText, + private val key: String = UUID.randomUUID().toString(), + ) : LibrarySideEffect } sealed interface LibraryUiEvent : CircuitUiEvent { @@ -42,10 +48,12 @@ sealed interface LibraryUiEvent : CircuitUiEvent { val userBookId: String, val isbn13: String, ) : LibraryUiEvent + data object OnLoadMore : LibraryUiEvent data object OnRetryClick : LibraryUiEvent data class OnFilterClick(val filterOption: LibraryFilterOption) : LibraryUiEvent data class OnTabSelected(val tab: MainTab) : LibraryUiEvent + data object OnLoginClick : LibraryUiEvent } data class LibraryFilterChip( diff --git a/feature/library/src/main/res/values/strings.xml b/feature/library/src/main/res/values/strings.xml index 145b3d4a..5bc04e2a 100644 --- a/feature/library/src/main/res/values/strings.xml +++ b/feature/library/src/main/res/values/strings.xml @@ -8,4 +8,7 @@ 완독 아직 등록된 책이 없어요 도서 등록 후 나만의 아카이브를 만들어보세요 + 로그인이 필요해요 + 로그인 후 나만의 서재를 채워보세요 + 로그인 하기 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 1fbc323f..f0f90cc6 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 @@ -15,6 +15,7 @@ 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.popUntil import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted @@ -24,6 +25,7 @@ import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.coroutines.launch class LoginPresenter @AssistedInject constructor( + @Assisted private val screen: LoginScreen, @Assisted private val navigator: Navigator, private val authRepository: AuthRepository, private val userRepository: UserRepository, @@ -45,9 +47,13 @@ class LoginPresenter @AssistedInject constructor( userRepository.getUserProfile() .onSuccess { userProfile -> if (userProfile.termsAgreed) { - navigator.resetRoot(HomeScreen) + if (screen.returnToScreen == null) { + navigator.resetRoot(HomeScreen) + } else { + navigator.popUntil { it == screen.returnToScreen } + } } else { - navigator.resetRoot(TermsAgreementScreen) + navigator.resetRoot(TermsAgreementScreen(screen.returnToScreen)) } }.onFailure { exception -> exception.message?.let { Logger.e(it) } @@ -90,15 +96,24 @@ class LoginPresenter @AssistedInject constructor( } } } + + is LoginUiEvent.OnGuestLoginButtonClick -> { + navigator.resetRoot(HomeScreen) + } + + is LoginUiEvent.OnCloseButtonClick -> { + navigator.pop() + } } } ImpressionEffect { - analyticsHelper.logScreenView(LoginScreen.name) + analyticsHelper.logScreenView(screen.name) } return LoginUiState( isLoading = isLoading, + returnToScreen = screen.returnToScreen, sideEffect = sideEffect, eventSink = ::handleEvent, ) @@ -107,6 +122,9 @@ class LoginPresenter @AssistedInject constructor( @CircuitInject(LoginScreen::class, ActivityRetainedComponent::class) @AssistedFactory fun interface Factory { - fun create(navigator: Navigator): LoginPresenter + 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 c56d466f..359989bf 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 @@ -10,7 +10,6 @@ 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.systemBarsPadding import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,10 +24,13 @@ import androidx.compose.ui.unit.dp 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.ReedTextButton 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.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedCloseTopAppBar import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.screens.LoginScreen import com.slack.circuit.codegen.annotations.CircuitInject @@ -52,12 +54,19 @@ internal fun LoginUi( modifier = modifier .fillMaxSize() .background(White) - .systemBarsPadding(), + .padding(innerPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { Column { + if (state.returnToScreen != null) { + ReedCloseTopAppBar( + onClose = { + state.eventSink(LoginUiEvent.OnCloseButtonClick) + }, + ) + } Column( modifier = Modifier .fillMaxWidth() @@ -77,28 +86,45 @@ internal fun LoginUi( style = ReedTheme.typography.headline2SemiBold, ) } - ReedButton( - onClick = { - state.eventSink(LoginUiEvent.OnKakaoLoginButtonClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.KAKAO, - modifier = Modifier - .fillMaxWidth() - .padding( - start = ReedTheme.spacing.spacing5, - end = ReedTheme.spacing.spacing5, - bottom = ReedTheme.spacing.spacing8, - ), - text = stringResource(id = R.string.kakao_login), - leadingIcon = { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_kakao), - contentDescription = "Kakao Icon", - tint = Color.Unspecified, + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ReedButton( + onClick = { + state.eventSink(LoginUiEvent.OnKakaoLoginButtonClick) + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.KAKAO, + 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) + }, + text = stringResource(R.string.guest_login), + sizeStyle = smallButtonStyle, + colorStyle = ReedButtonColorStyle.TEXT, ) - }, - ) + } + } } if (state.isLoading) { 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 fea02984..731e2bcb 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 @@ -3,10 +3,12 @@ package com.ninecraft.booket.feature.login import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen import java.util.UUID data class LoginUiState( val isLoading: Boolean = false, + val returnToScreen: Screen? = null, val sideEffect: LoginSideEffect? = null, val eventSink: (LoginUiEvent) -> Unit, ) : CircuitUiState @@ -24,4 +26,6 @@ sealed interface LoginUiEvent : CircuitUiEvent { data object OnKakaoLoginButtonClick : LoginUiEvent data class Login(val accessToken: String) : LoginUiEvent data class LoginFailure(val message: String) : LoginUiEvent + data object OnGuestLoginButtonClick : LoginUiEvent + data object OnCloseButtonClick : LoginUiEvent } 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 cd68ea36..a1f4cf04 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 @@ -17,6 +17,7 @@ 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.popUntil import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted @@ -28,6 +29,7 @@ import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch class TermsAgreementPresenter @AssistedInject constructor( + @Assisted private val screen: TermsAgreementScreen, @Assisted private val navigator: Navigator, private val userRepository: UserRepository, private val analyticsHelper: AnalyticsHelper, @@ -73,7 +75,11 @@ class TermsAgreementPresenter @AssistedInject constructor( scope.launch { userRepository.agreeTerms(true) .onSuccess { - navigator.resetRoot(HomeScreen) + if (screen.returnToScreen != null) { + navigator.popUntil { it == screen.returnToScreen } + } else { + navigator.resetRoot(HomeScreen) + } }.onFailure { exception -> exception.message?.let { Logger.e(it) } sideEffect = exception.message?.let { @@ -86,7 +92,7 @@ class TermsAgreementPresenter @AssistedInject constructor( } ImpressionEffect { - analyticsHelper.logScreenView(TermsAgreementScreen.name) + analyticsHelper.logScreenView(screen.name) } return TermsAgreementUiState( @@ -99,6 +105,9 @@ class TermsAgreementPresenter @AssistedInject constructor( @CircuitInject(TermsAgreementScreen::class, ActivityRetainedComponent::class) @AssistedFactory fun interface Factory { - fun create(navigator: Navigator): TermsAgreementPresenter + fun create( + screen: TermsAgreementScreen, + navigator: Navigator, + ): TermsAgreementPresenter } } diff --git a/feature/login/src/main/res/values/strings.xml b/feature/login/src/main/res/values/strings.xml index efbe84fc..51ac38df 100644 --- a/feature/login/src/main/res/values/strings.xml +++ b/feature/login/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ 약관 동의 후\n독서 기록을 남겨보세요 약관 전체 동의 시작하기 + 회원가입 없이 둘러보기 (필수)서비스 이용약관 (필수)개인정보처리방침 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 92efa53b..63304a27 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 @@ -37,7 +37,7 @@ class OnboardingPresenter @AssistedInject constructor( if (event.currentPage == 2) { scope.launch { repository.setOnboardingCompleted(true) - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) } } else { pagerState.let { state -> 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 86291fc4..7ddb581d 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 @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.utils.handleException -import com.ninecraft.booket.core.ocr.analyzer.CloudOcrRecognizer +import com.ninecraft.booket.core.ocr.recognizer.CloudOcrRecognizer import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.feature.screens.OcrScreen import com.orhanobut.logger.Logger 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 4a77fefb..76af3913 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 @@ -35,10 +35,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -68,7 +68,6 @@ import com.ninecraft.booket.feature.record.R import com.ninecraft.booket.feature.record.ocr.component.CameraFrame import com.ninecraft.booket.feature.record.ocr.component.SentenceBox import com.ninecraft.booket.feature.screens.OcrScreen -import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController @@ -101,16 +100,31 @@ private fun CameraPreview( /** * Camera Permission Request */ - var isGranted by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED, - ) + 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 -> - isGranted = granted - if (!granted) { state.eventSink(OcrUiEvent.OnShowPermissionDialog) } @@ -120,29 +134,13 @@ private fun CameraPreview( ) { _ -> } // 최초 진입 시 권한 요청 - RememberedEffect(Unit) { + LaunchedEffect(Unit) { if (!isGranted) { state.eventSink(OcrUiEvent.OnHidePermissionDialog) permissionLauncher.launch(permission) } } - // 앱이 포그라운드로 북귀할 때 OS 권한 동기화 - DisposableEffect(Unit) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - if (isGranted) { - state.eventSink(OcrUiEvent.OnHidePermissionDialog) - } else { - state.eventSink(OcrUiEvent.OnShowPermissionDialog) - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - /** * Camera Controller */ 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 63fb8fda..26a58a92 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 @@ -146,7 +146,7 @@ class RecordRegisterPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } 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 3ae920d7..ac3a7278 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 @@ -85,7 +85,7 @@ internal fun RecordRegisterUi( } if (state.isLoading) { - ReedLoadingIndicator() + ReedLoadingIndicator(delayMillis = 0L) } if (state.isExitDialogVisible) { 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 58f22329..36b7b33e 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 @@ -27,7 +27,6 @@ import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.common.extensions.clickableSingle import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.EmotionTag -import com.ninecraft.booket.core.designsystem.RecordStep 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 @@ -113,7 +112,7 @@ fun EmotionStep( .padding(bottom = ReedTheme.spacing.spacing4), enabled = state.isNextButtonEnabled, text = stringResource(R.string.record_next_button), - multipleEventsCutterEnabled = state.currentStep == RecordStep.IMPRESSION, + multipleEventsCutterEnabled = false, ) } } diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt index 346e51b8..948ac648 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/step/ImpressionStep.kt @@ -38,7 +38,6 @@ 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.RecordStep 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 @@ -163,7 +162,7 @@ fun ImpressionStep( ), enabled = state.isNextButtonEnabled, text = stringResource(R.string.record_next_button), - multipleEventsCutterEnabled = state.currentStep == RecordStep.IMPRESSION, + multipleEventsCutterEnabled = true, ) } @@ -190,6 +189,7 @@ fun ImpressionStep( coroutineScope.launch { impressionGuideBottomSheetState.hide() state.eventSink(RecordRegisterUiEvent.OnImpressionGuideConfirmed) + focusRequester.requestFocus() } }, ) 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 ecdd2a26..b9318765 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 @@ -36,7 +36,6 @@ 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.RecordStep 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 @@ -178,7 +177,7 @@ internal fun QuoteStep( ), enabled = state.isNextButtonEnabled, text = stringResource(R.string.record_next_button), - multipleEventsCutterEnabled = state.currentStep == RecordStep.IMPRESSION, + multipleEventsCutterEnabled = false, ) } } diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt index 1c31c461..338d70a1 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/ScreenNames.kt @@ -5,7 +5,7 @@ object ScreenNames { const val HOME = "home_main" const val LIBRARY = "library_main" const val LOGIN = "login_select_method" - const val SEARCH = "search_book_input" + const val BOOK_SEARCH = "search_book_start" const val LIBRARY_SEARCH = "library_search_book" const val TERMS_AGREEMENT = "login_terms_agreement" const val SETTINGS = "settings_main" 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 f7f1ae61..05f01d7b 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 @@ -16,16 +16,16 @@ data object HomeScreen : ReedScreen(name = ScreenNames.HOME) data object LibraryScreen : ReedScreen(name = ScreenNames.LIBRARY) @Parcelize -data object LoginScreen : ReedScreen(name = ScreenNames.LOGIN) +data class LoginScreen(val returnToScreen: Screen? = null) : ReedScreen(name = ScreenNames.LOGIN) @Parcelize -data object SearchScreen : ReedScreen(name = ScreenNames.SEARCH) +data object BookSearchScreen : ReedScreen(name = ScreenNames.BOOK_SEARCH) @Parcelize data object LibrarySearchScreen : ReedScreen(name = ScreenNames.LIBRARY_SEARCH) @Parcelize -data object TermsAgreementScreen : ReedScreen(name = ScreenNames.TERMS_AGREEMENT) +data class TermsAgreementScreen(val returnToScreen: Screen? = null) : ReedScreen(name = ScreenNames.TERMS_AGREEMENT) @Parcelize data object SettingsScreen : ReedScreen(name = ScreenNames.SETTINGS) 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 a23c9bcb..e4195a0a 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 @@ -30,10 +30,6 @@ 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 -import com.slack.circuit.backstack.SaveableBackStack -import com.slack.circuit.runtime.Navigator -import com.slack.circuit.runtime.popUntil -import com.slack.circuit.runtime.screen.Screen import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -132,23 +128,6 @@ private fun RowScope.MainBottomBarItem( } } -@Suppress("unused") -fun Navigator.popUntilOrGoTo(screen: Screen) { - if (screen in peekBackStack()) { - popUntil { it == screen } - } else { - goTo(screen) - } -} - -@Composable -fun getCurrentTab(backStack: SaveableBackStack): MainTab? { - val currentScreen = backStack.topRecord?.screen - return currentScreen?.let { screen -> - MainTab.entries.find { it.screen::class == currentScreen::class } - } -} - @ComponentPreview @Composable private fun MainBottomBarPreview() { diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/extensions/Navigator.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/extensions/Navigator.kt index 521365ed..711f4a6e 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/extensions/Navigator.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/extensions/Navigator.kt @@ -1,7 +1,9 @@ package com.ninecraft.booket.feature.screens.extensions +import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.ReedScreen import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.screen.Screen import kotlinx.coroutines.delay suspend fun Navigator.delayedGoTo(screen: ReedScreen, delayMillis: Long = 200L) { @@ -13,3 +15,9 @@ suspend fun Navigator.delayedPop(delayMillis: Long = 200L) { delay(delayMillis) pop() } + +suspend fun Navigator.redirectToLogin(): Screen? { + val currentScreen = peek() + delayedGoTo(LoginScreen(currentScreen)) + return currentScreen +} 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 61cc99e5..d6e890ec 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 @@ -10,21 +10,26 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.constants.BookStatus +import com.ninecraft.booket.core.common.utils.UiText 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.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel +import com.ninecraft.booket.core.model.UserState import com.ninecraft.booket.core.ui.component.FooterState +import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.RecordScreen -import com.ninecraft.booket.feature.screens.SearchScreen -import com.ninecraft.booket.feature.screens.extensions.delayedGoTo +import com.ninecraft.booket.feature.screens.extensions.redirectToLogin +import com.ninecraft.booket.feature.search.R import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuitx.effects.ImpressionEffect import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -33,17 +38,17 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch +import com.ninecraft.booket.core.designsystem.R as designR class BookSearchPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: BookRepository, + private val authRepository: AuthRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { companion object { private const val START_INDEX = 1 - private const val SEARCH_BOOK_INPUT = "search_book_input" private const val SEARCH_BOOK_RESULT = "search_book_result" - private const val SEARCH_BOOK_NO_RESULT = "search_book_noresult" private const val ERROR_SEARCH_LOADING = "error_search_loading" private const val REGISTER_BOOK_OPTION = "register_book_option" private const val REGISTER_BOOK_COMPLETE = "register_book_complete" @@ -53,6 +58,7 @@ class BookSearchPresenter @AssistedInject constructor( @Composable override fun present(): BookSearchUiState { val scope = rememberCoroutineScope() + val userState by authRepository.userState.collectAsRetainedState(initial = UserState.Guest) var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var footerState by rememberRetained { mutableStateOf(FooterState.Idle) } val queryState = rememberTextFieldState() @@ -76,7 +82,11 @@ class BookSearchPresenter @AssistedInject constructor( footerState = FooterState.Loading } - repository.searchBook(query = query, start = startIndex) + if (userState is UserState.Guest) { + repository.searchBookAsGuest(query = query, start = startIndex) + } else { + repository.searchBook(query = query, start = startIndex) + } .onSuccess { result -> searchResult = result books = if (startIndex == START_INDEX) { @@ -90,14 +100,10 @@ class BookSearchPresenter @AssistedInject constructor( if (startIndex == START_INDEX) { uiState = UiState.Success + analyticsHelper.logEvent(SEARCH_BOOK_RESULT) } else { footerState = if (isLastPage) FooterState.End else FooterState.Idle } - - analyticsHelper.logEvent(SEARCH_BOOK_RESULT) - if (startIndex == START_INDEX && result.books.isEmpty()) { - analyticsHelper.logEvent(SEARCH_BOOK_NO_RESULT) - } } .onFailure { exception -> Logger.d(exception) @@ -114,6 +120,12 @@ class BookSearchPresenter @AssistedInject constructor( fun upsertBook(isbn13: String, bookStatus: String) { scope.launch { + if (userState is UserState.Guest) { + sideEffect = BookSearchSideEffect.ShowToast(UiText.StringResource(designR.string.login_required)) + navigator.redirectToLogin() + return@launch + } + repository.upsertBook(isbn13, bookStatus) .onSuccess { registeredUserBookId = it.userBookId @@ -133,14 +145,14 @@ class BookSearchPresenter @AssistedInject constructor( analyticsHelper.logEvent(ERROR_REGISTER_BOOK) val handleErrorMessage = { message: String -> Logger.e(message) - sideEffect = BookSearchSideEffect.ShowToast(message) + sideEffect = BookSearchSideEffect.ShowToast(UiText.DirectString(message)) } handleException( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } @@ -149,6 +161,10 @@ class BookSearchPresenter @AssistedInject constructor( fun handleEvent(event: BookSearchUiEvent) { when (event) { + is BookSearchUiEvent.InitSideEffect -> { + sideEffect = null + } + is BookSearchUiEvent.OnBackClick -> { navigator.pop() } @@ -170,7 +186,6 @@ class BookSearchPresenter @AssistedInject constructor( is BookSearchUiEvent.OnSearchClick -> { val query = event.query.trim() if (query.isNotEmpty()) { - analyticsHelper.logEvent(SEARCH_BOOK_INPUT) searchBooks(query = query, startIndex = START_INDEX) } } @@ -197,7 +212,7 @@ class BookSearchPresenter @AssistedInject constructor( selectedBookIsbn = event.isbn13 if (selectedBookIsbn.isEmpty()) { - sideEffect = BookSearchSideEffect.ShowToast("isbn이 없는 도서는 등록할 수 없습니다") + sideEffect = BookSearchSideEffect.ShowToast(UiText.StringResource(R.string.error_book_no_isbn)) } else { isBookRegisterBottomSheetVisible = true } @@ -224,9 +239,7 @@ class BookSearchPresenter @AssistedInject constructor( is BookSearchUiEvent.OnBookRegisterSuccessOkButtonClick -> { isBookRegisterSuccessBottomSheetVisible = false - scope.launch { - navigator.delayedGoTo(RecordScreen(registeredUserBookId)) - } + navigator.goTo(RecordScreen(registeredUserBookId)) } is BookSearchUiEvent.OnBookRegisterSuccessCancelButtonClick -> { @@ -235,6 +248,10 @@ class BookSearchPresenter @AssistedInject constructor( } } + ImpressionEffect { + analyticsHelper.logScreenView(BookSearchScreen.name) + } + return BookSearchUiState( uiState = uiState, footerState = footerState, @@ -246,12 +263,13 @@ class BookSearchPresenter @AssistedInject constructor( isBookRegisterBottomSheetVisible = isBookRegisterBottomSheetVisible, selectedBookStatus = selectedBookStatus, isBookRegisterSuccessBottomSheetVisible = isBookRegisterSuccessBottomSheetVisible, + isGuestMode = userState is UserState.Guest, sideEffect = sideEffect, eventSink = ::handleEvent, ) } - @CircuitInject(SearchScreen::class, ActivityRetainedComponent::class) + @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 9e4a9c8f..ad1fd7ec 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 @@ -32,7 +32,7 @@ import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator -import com.ninecraft.booket.feature.screens.SearchScreen +import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.search.R import com.ninecraft.booket.feature.search.book.component.BookItem import com.ninecraft.booket.feature.search.book.component.BookRegisterBottomSheet @@ -45,13 +45,16 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import com.ninecraft.booket.core.designsystem.R as designR -@CircuitInject(SearchScreen::class, ActivityRetainedComponent::class) +@CircuitInject(BookSearchScreen::class, ActivityRetainedComponent::class) @Composable -internal fun SearchUi( +internal fun BookSearchUi( state: BookSearchUiState, modifier: Modifier = Modifier, ) { - HandleBookSearchSideEffects(state = state) + HandleBookSearchSideEffects( + state = state, + eventSink = state.eventSink, + ) ReedScaffold( modifier = modifier.fillMaxSize(), @@ -68,7 +71,7 @@ internal fun SearchUi( state.eventSink(BookSearchUiEvent.OnBackClick) }, ) - SearchContent( + BookSearchContent( state = state, modifier = Modifier, ) @@ -78,7 +81,7 @@ internal fun SearchUi( @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun SearchContent( +internal fun BookSearchContent( state: BookSearchUiState, modifier: Modifier = Modifier, ) { @@ -219,7 +222,7 @@ internal fun SearchContent( onBookClick = { book -> state.eventSink(BookSearchUiEvent.OnBookClick(book.isbn13)) }, - enabled = BookRegisteredState.from(state.books[index].userBookStatus) == BookRegisteredState.BEFORE_REGISTRATION, + enabled = !state.books[index].isRegistered, ) HorizontalDivider( modifier = Modifier.fillMaxWidth(), @@ -271,7 +274,12 @@ internal fun SearchContent( state.eventSink(BookSearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss) } }, - onOKButtonClick = { state.eventSink(BookSearchUiEvent.OnBookRegisterSuccessOkButtonClick) }, + onOKButtonClick = { + coroutineScope.launch { + bookRegisterSuccessBottomSheetState.hide() + state.eventSink(BookSearchUiEvent.OnBookRegisterSuccessOkButtonClick) + } + }, ) } } @@ -279,9 +287,9 @@ internal fun SearchContent( @DevicePreview @Composable -private fun SearchPreview() { +private fun BookSearchPreview() { ReedTheme { - SearchUi( + BookSearchUi( state = BookSearchUiState( 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 5f023961..09641b05 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 @@ -3,6 +3,7 @@ package com.ninecraft.booket.feature.search.book import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable import com.ninecraft.booket.core.common.constants.BookStatus +import com.ninecraft.booket.core.common.utils.UiText import com.ninecraft.booket.core.model.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState @@ -31,6 +32,7 @@ data class BookSearchUiState( val isBookRegisterBottomSheetVisible: Boolean = false, val selectedBookStatus: BookStatus? = null, val isBookRegisterSuccessBottomSheetVisible: Boolean = false, + val isGuestMode: Boolean = false, val sideEffect: BookSearchSideEffect? = null, val eventSink: (BookSearchUiEvent) -> Unit, ) : CircuitUiState { @@ -40,12 +42,13 @@ data class BookSearchUiState( @Immutable sealed interface BookSearchSideEffect { data class ShowToast( - val message: String, + val message: UiText, private val key: String = UUID.randomUUID().toString(), ) : BookSearchSideEffect } sealed interface BookSearchUiEvent : CircuitUiEvent { + data object InitSideEffect : BookSearchUiEvent data object OnBackClick : BookSearchUiEvent data class OnRecentSearchClick(val query: String) : BookSearchUiEvent data class OnRecentSearchDeleteClick(val query: String) : BookSearchUiEvent @@ -61,14 +64,3 @@ sealed interface BookSearchUiEvent : CircuitUiEvent { data object OnBookRegisterSuccessOkButtonClick : BookSearchUiEvent data object OnBookRegisterSuccessCancelButtonClick : BookSearchUiEvent } - -enum class BookRegisteredState(val value: String) { - BEFORE_REGISTRATION("BEFORE_REGISTRATION"), - ; - - companion object { - fun from(value: String?): BookRegisteredState? { - return entries.find { it.value == value } - } - } -} diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandlingBookSearchSideEffect.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt similarity index 75% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandlingBookSearchSideEffect.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt index 7249bf51..27fd56e1 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandlingBookSearchSideEffect.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandleBookSearchSideEffect.kt @@ -8,16 +8,21 @@ import com.skydoves.compose.effects.RememberedEffect @Composable internal fun HandleBookSearchSideEffects( state: BookSearchUiState, + eventSink: (BookSearchUiEvent) -> Unit, ) { val context = LocalContext.current RememberedEffect(state.sideEffect) { when (state.sideEffect) { is BookSearchSideEffect.ShowToast -> { - Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + Toast.makeText(context, state.sideEffect.message.asString(context), Toast.LENGTH_SHORT).show() } null -> {} } + + if (state.sideEffect != null) { + eventSink(BookSearchUiEvent.InitSideEffect) + } } } 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 240d20e7..fc4799d0 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 @@ -100,7 +100,7 @@ class LibrarySearchPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index fe971294..d8f946d7 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -18,4 +18,5 @@ 등록한 책을 검색해보세요 남긴 기록 내 서재에 해당 도서가 없습니다. + isbn이 없는 도서는 등록할 수 없습니다 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 fefe62d2..007b1aa0 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 @@ -10,13 +10,16 @@ import com.ninecraft.booket.core.common.constants.WebViewConstants import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository +import com.ninecraft.booket.core.model.UserState import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OssLicensesScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.ninecraft.booket.feature.screens.WebViewScreen +import com.ninecraft.booket.feature.screens.extensions.redirectToLogin import com.orhanobut.logger.Logger import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter @@ -43,6 +46,7 @@ class SettingsPresenter @AssistedInject constructor( @Composable override fun present(): SettingsUiState { val scope = rememberCoroutineScope() + val userState by authRepository.userState.collectAsRetainedState(initial = UserState.Guest) var isLoading by rememberRetained { mutableStateOf(false) } var isLogoutDialogVisible by rememberRetained { mutableStateOf(false) } var isWithdrawBottomSheetVisible by rememberRetained { mutableStateOf(false) } @@ -58,7 +62,7 @@ class SettingsPresenter @AssistedInject constructor( authRepository.logout() .onSuccess { analyticsHelper.logEvent(SETTINGS_LOGOUT_COMPLETE) - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) } .onFailure { exception -> val handleErrorMessage = { message: String -> @@ -70,13 +74,12 @@ class SettingsPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } } finally { isLoading = false - isLogoutDialogVisible = false } } } @@ -88,7 +91,7 @@ class SettingsPresenter @AssistedInject constructor( authRepository.withdraw() .onSuccess { analyticsHelper.logEvent(SETTINGS_WITHDRAWAL_COMPLETE) - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) } .onFailure { exception -> val handleErrorMessage = { message: String -> @@ -100,13 +103,12 @@ class SettingsPresenter @AssistedInject constructor( exception = exception, onError = handleErrorMessage, onLoginRequired = { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) }, ) } } finally { isLoading = false - isWithdrawBottomSheetVisible = false } } } @@ -180,16 +182,24 @@ class SettingsPresenter @AssistedInject constructor( } is SettingsUiEvent.Logout -> { + isLogoutDialogVisible = false logout() } is SettingsUiEvent.Withdraw -> { + isWithdrawBottomSheetVisible = false withdraw() } is SettingsUiEvent.OnVersionClick -> { sideEffect = SettingsSideEffect.NavigateToPlayStore } + + is SettingsUiEvent.OnLoginClick -> { + scope.launch { + navigator.redirectToLogin() + } + } } } @@ -208,6 +218,7 @@ class SettingsPresenter @AssistedInject constructor( isWithdrawConfirmed = isWithdrawConfirmed, latestVersion = latestVersion, isOptionalUpdateDialogVisible = isOptionalUpdateDialogVisible, + isGuestMode = userState is UserState.Guest, sideEffect = sideEffect, eventSink = ::handleEvent, ) diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt index 37a60a3a..b5989fdd 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 @@ -150,18 +150,28 @@ internal fun SettingsUi( }, ) ReedDivider(modifier = Modifier.padding(vertical = ReedTheme.spacing.spacing4)) - SettingItem( - title = stringResource(R.string.settings_logout), - onItemClick = { - state.eventSink(SettingsUiEvent.OnLogoutClick) - }, - ) - SettingItem( - title = stringResource(R.string.settings_withdraw), - onItemClick = { - state.eventSink(SettingsUiEvent.OnWithdrawClick) - }, - ) + if (state.isGuestMode) { + SettingItem( + title = stringResource(R.string.settings_login), + onItemClick = { + state.eventSink(SettingsUiEvent.OnLoginClick) + }, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } else { + SettingItem( + title = stringResource(R.string.settings_logout), + onItemClick = { + state.eventSink(SettingsUiEvent.OnLogoutClick) + }, + ) + SettingItem( + title = stringResource(R.string.settings_withdraw), + onItemClick = { + state.eventSink(SettingsUiEvent.OnWithdrawClick) + }, + ) + } } if (state.isLoading) { diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt index fb7961e5..43b50cb2 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ data class SettingsUiState( val latestVersion: String = "", val isUpdateAvailable: Boolean = false, val isOptionalUpdateDialogVisible: Boolean = false, + val isGuestMode: Boolean = false, val sideEffect: SettingsSideEffect? = null, val eventSink: (SettingsUiEvent) -> Unit, ) : CircuitUiState @@ -40,4 +41,5 @@ sealed interface SettingsUiEvent : CircuitUiEvent { data object Logout : SettingsUiEvent data object Withdraw : SettingsUiEvent data object OnVersionClick : SettingsUiEvent + data object OnLoginClick : SettingsUiEvent } 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 106d9f2f..e9f23d27 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 @@ -17,7 +17,7 @@ class OssLicensesPresenter @AssistedInject constructor( override fun present(): OssLicensesUiState { fun handleEvent(event: OssLicensesUiEvent) { when (event) { - is OssLicensesUiEvent.OnBackClicked -> { + is OssLicensesUiEvent.OnBackClick -> { navigator.pop() } } 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 3c31ff3e..dc804a52 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 @@ -71,7 +71,7 @@ internal fun OssLicenses( ReedBackTopAppBar( title = stringResource(R.string.oss_licenses_title), onBackClick = { - state.eventSink(OssLicensesUiEvent.OnBackClicked) + state.eventSink(OssLicensesUiEvent.OnBackClick) }, ) LazyColumn { diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUiState.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUiState.kt index 5f829072..30a82745 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUiState.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/osslicenses/OssLicensesUiState.kt @@ -8,5 +8,5 @@ data class OssLicensesUiState( ) : CircuitUiState sealed interface OssLicensesUiEvent : CircuitUiEvent { - data object OnBackClicked : OssLicensesUiEvent + data object OnBackClick : OssLicensesUiEvent } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index fa897943..7051c68a 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -18,4 +18,5 @@ 최신 버전이 출시되었습니다. 최적의 사용 환경을 위해 업데이트해주세요. 업데이트하기 + 로그인 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 a039dc62..6af98e78 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 @@ -56,7 +56,7 @@ class SplashPresenter @AssistedInject constructor( if (userProfile.termsAgreed) { navigator.resetRoot(HomeScreen) } else { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) } } .onFailure { exception -> @@ -83,7 +83,7 @@ class SplashPresenter @AssistedInject constructor( } AutoLoginState.NOT_LOGGED_IN -> { - navigator.resetRoot(LoginScreen) + navigator.resetRoot(LoginScreen()) } AutoLoginState.IDLE -> { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92be2019..bf731c1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,8 @@ minSdk = "28" targetSdk = "35" compileSdk = "35" -versionName = "1.1.2" -versionCode = "6" +versionName = "1.2.0" +versionCode = "7" packageName = "com.ninecraft.booket" ## Android gradle plugin @@ -12,7 +12,6 @@ android-gradle-plugin = "8.9.3" ## AndroidX androidx-core = "1.16.0" -androidx-lifecycle = "2.9.2" androidx-activity-compose = "1.10.1" androidx-startup = "1.2.0" androidx-splash = "1.0.1" @@ -33,7 +32,6 @@ ksp = "2.2.0-2.0.2" kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" kotlinx-serialization-json = "1.9.0" -kotlinx-datetime = "0.7.1" kotlinx-collections-immutable = "0.4.0" ## Hilt @@ -60,9 +58,6 @@ landscapist = "2.5.1" ## Lottie lottie = "6.6.6" -## OCR -mlkit-text = "16.0.1" - ## Extension # https://github.com/jisungbin/dependency-handler-extensions gradle-dependency-handler-extensions = "1.1.0" @@ -89,7 +84,6 @@ kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-p compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } androidx-splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-splash" } androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx-startup" } @@ -149,9 +143,6 @@ kakao-auth = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-c lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } -google-mlkit-text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkit-text" } -google-mlkit-text-recognition-korean = { group = "com.google.mlkit", name = "text-recognition-korean", version.ref = "mlkit-text" } - androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" }