diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..1d25749ae --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +max_line_length = 150 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true \ No newline at end of file diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 24b0c6cb0..f2b0195ef 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -21,10 +21,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' cache: gradle @@ -49,9 +49,9 @@ jobs: # run: | # echo '${{ secrets.GOOGLE_SERVICES }}' >> ./app/google-services.json -# - name: Code style checks -# run: | -# ./gradlew detekt --continue + - name: Code style checks + run: | + ./gradlew detekt --continue - name: Run build - run: ./gradlew buildDebug --stacktrace \ No newline at end of file + run: ./gradlew buildDebug --stacktrace diff --git a/.github/workflows/discord_push_event_notify.yml b/.github/workflows/discord_push_event_notify.yml new file mode 100644 index 000000000..89193ffa9 --- /dev/null +++ b/.github/workflows/discord_push_event_notify.yml @@ -0,0 +1,81 @@ +name: Discord Push Notify + +on: + push: + branches: + - main + - develop + +jobs: + notify: + runs-on: ubuntu-latest + + steps: + - name: Send push to Discord (Embed) + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + set -e + + # ---- push info ---- + REF=$(jq -r '.ref' "$GITHUB_EVENT_PATH") + BRANCH="${REF#refs/heads/}" + + REPO_URL=$(jq -r '.repository.html_url' "$GITHUB_EVENT_PATH") + URL=$(jq -r '.compare // .repository.html_url' "$GITHUB_EVENT_PATH") + TIMESTAMP=$(jq -r '.head_commit.timestamp // empty' "$GITHUB_EVENT_PATH") + + COUNT=$(jq -r '.commits | length' "$GITHUB_EVENT_PATH") + + # "• [hash] — message" (hash는 링크) + COMMITS=$(jq -r --arg repo_url "$REPO_URL" ' + (.commits // [])[:20] + | map( + "• [" + (.id[0:7]) + "](" + ($repo_url + "/commit/" + .id) + ") — " + + ((.message | split("\n")[0]) | gsub("\r";"")) + ) + | join("\n") + ' "$GITHUB_EVENT_PATH") + + TITLE="🚀 New Push · [${BRANCH}] · ${COUNT} commits" + + if [ -z "$COMMITS" ]; then + DESC=$(printf "총 %s개 커밋\n(커밋 정보 없음)" "$COUNT") + else + DESC=$(printf "총 %s개 커밋\n%s" "$COUNT" "$COMMITS") + fi + + payload=$(jq -n \ + --arg username "네키 디스코드 알림 봇" \ + --arg avatar "https://i.ifh.cc/PbdkGM.jpg" \ + --arg title "$TITLE" \ + --arg url "$URL" \ + --arg desc "$DESC" \ + --arg ts "$TIMESTAMP" \ + --argjson color 3066993 \ + '{ + username: $username, + avatar_url: $avatar, + embeds: [ + { + title: $title, + url: $url, + description: $desc, + color: $color + } + ] + } + | (if ($ts|length) > 0 then .embeds[0].timestamp = $ts else . end) + ') + + resp=$(curl -sS -X POST -H "Content-Type: application/json" -d "$payload" \ + -w "\nHTTP_STATUS:%{http_code}\n" \ + "$DISCORD_WEBHOOK_URL" || true) + + echo "$resp" + + status=$(echo "$resp" | sed -n 's/HTTP_STATUS://p') + if [ -z "$status" ] || [ "$status" -ge 400 ]; then + echo "Discord webhook failed" + exit 1 + fi diff --git a/.github/workflows/discord_review_event_notify.yml b/.github/workflows/discord_review_event_notify.yml new file mode 100644 index 000000000..c424591c3 --- /dev/null +++ b/.github/workflows/discord_review_event_notify.yml @@ -0,0 +1,182 @@ +name: Discord PR Review + +on: + pull_request_review: + types: [submitted] + issue_comment: + types: [created] + +jobs: + notify: + if: | + github.actor != 'coderabbitai[bot]' && + github.actor != 'coderabbitai' && + (github.event_name != 'issue_comment' || github.event.issue.pull_request) + runs-on: ubuntu-latest + + steps: + - name: Send PR activity to Discord (Embed) + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + + EVENT="$GITHUB_EVENT_NAME" + REPO=$(jq -r '.repository.full_name' "$GITHUB_EVENT_PATH") + + truncate_lines () { + local text="$1" + local max="$2" + echo "$text" | awk -v max="$max" 'NR<=max {print} NR==max {exit}' + } + + truncate_chars () { + local s="$1" + local n="$2" + if [ "${#s}" -gt "$n" ]; then + echo "${s:0:$n}…" + else + echo "$s" + fi + } + + quote_block () { + # 각 줄을 "> "로 감싸서 Discord 인용문 만들기 + echo "$1" | sed 's/^/> /' + } + + TITLE="" + DESC="" + URL="" + COLOR=7506394 + TIMESTAMP="" + + if [ "$EVENT" = "pull_request_review" ]; then + PR_NUMBER=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH") + PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH") + PR_URL=$(jq -r '.pull_request.html_url' "$GITHUB_EVENT_PATH") + + REVIEWER=$(jq -r '.review.user.login' "$GITHUB_EVENT_PATH") + STATE=$(jq -r '.review.state' "$GITHUB_EVENT_PATH") + REVIEW_BODY=$(jq -r '.review.body // ""' "$GITHUB_EVENT_PATH") + + REVIEW_ID=$(jq -r '.review.id' "$GITHUB_EVENT_PATH") + TIMESTAMP=$(jq -r '.review.submitted_at // empty' "$GITHUB_EVENT_PATH") + + # state별 컬러 + case "$STATE" in + approved|APPROVED) COLOR=5763719 ;; + changes_requested|CHANGES_REQUESTED) COLOR=15548997 ;; + commented|COMMENTED) COLOR=3447003 ;; + dismissed|DISMISSED) COLOR=9807270 ;; + *) COLOR=7506394 ;; + esac + + # ✅ 타이틀: 2줄 + TITLE=$(printf "📝 PR Review · %s\n[#%s] — “%s”" "$STATE" "$PR_NUMBER" "$PR_TITLE") + URL="$PR_URL" + + # ✅ 리뷰 본문: 있으면 2~4줄만, 없으면 아예 출력 안 함 + REVIEW_SNIP="" + if [ -n "$REVIEW_BODY" ] && [ "$REVIEW_BODY" != "null" ]; then + REVIEW_SNIP=$(truncate_lines "$REVIEW_BODY" 4) + fi + + # 인라인 코멘트 가져오기 (파일 경로 제거, 코멘트만) + COMMENTS_JSON=$(curl -sS -L \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$REPO/pulls/$PR_NUMBER/reviews/$REVIEW_ID/comments?per_page=100") + + INLINE_COMMENTS=$(echo "$COMMENTS_JSON" | jq -r ' + if type=="array" and length>0 then + .[:10] | map(.body | gsub("\r";"")) | join("\n\n---\n\n") + else + "" + end + ') + + QUOTED_INLINE="" + if [ -n "$INLINE_COMMENTS" ]; then + QUOTED_INLINE=$(quote_block "$INLINE_COMMENTS") + fi + + # ✅ Description 조립 + DESC=$(printf "Reviewer: %s" "$REVIEWER") + + if [ -n "$REVIEW_SNIP" ]; then + DESC=$(printf "%s\n\n%s" "$DESC" "$REVIEW_SNIP") + fi + + if [ -n "$QUOTED_INLINE" ]; then + DESC=$(printf "%s\n\n**💬 Comments**\n%s" "$DESC" "$QUOTED_INLINE") + fi + + DESC=$(truncate_chars "$DESC" 3800) + + elif [ "$EVENT" = "issue_comment" ]; then + PR_NUMBER=$(jq -r '.issue.number' "$GITHUB_EVENT_PATH") + PR_TITLE=$(jq -r '.issue.title' "$GITHUB_EVENT_PATH") + ISSUE_URL=$(jq -r '.issue.html_url' "$GITHUB_EVENT_PATH") + + COMMENTER=$(jq -r '.comment.user.login' "$GITHUB_EVENT_PATH") + COMMENT_BODY=$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH") + TIMESTAMP=$(jq -r '.comment.created_at // empty' "$GITHUB_EVENT_PATH") + + COLOR=15105570 + + TITLE=$(printf "💬 PR Comment\n[#%s] — “%s”" "$PR_NUMBER" "$PR_TITLE") + URL="$ISSUE_URL" + + if [ -z "$COMMENT_BODY" ] || [ "$COMMENT_BODY" = "null" ]; then + COMMENT_BODY="" + fi + + DESC=$(printf "Commenter: %s" "$COMMENTER") + + if [ -n "$COMMENT_BODY" ]; then + DESC=$(printf "%s\n\n%s" "$DESC" "$(quote_block "$COMMENT_BODY")") + fi + + DESC=$(truncate_chars "$DESC" 3800) + + else + exit 0 + fi + + payload=$(jq -n \ + --arg username "네키 디스코드 알림 봇" \ + --arg avatar "https://i.ifh.cc/PbdkGM.jpg" \ + --arg title "$TITLE" \ + --arg url "$URL" \ + --arg desc "$DESC" \ + --arg ts "$TIMESTAMP" \ + --argjson color "$COLOR" \ + '{ + username: $username, + avatar_url: $avatar, + embeds: [ + { + title: $title, + url: $url, + description: $desc, + color: $color + } + ] + } + | (if ($ts|length) > 0 then .embeds[0].timestamp = $ts else . end) + ') + + resp=$(curl -sS -X POST -H "Content-Type: application/json" -d "$payload" \ + -w "\nHTTP_STATUS:%{http_code}\n" \ + "$DISCORD_WEBHOOK_URL" || true) + + echo "$resp" + + status=$(echo "$resp" | sed -n 's/HTTP_STATUS://p') + if [ -z "$status" ] || [ "$status" -ge 400 ]; then + echo "Discord webhook failed" + exit 1 + fi diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe2278788..09d4680a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,17 @@ +import java.util.Properties +import kotlin.apply + plugins { alias(libs.plugins.neki.android.application) + alias(libs.plugins.neki.android.application.compose) + alias(libs.plugins.oss.licenses) +} + +val localPropertiesFile = project.rootProject.file("local.properties") +val properties = Properties().apply { + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } } android { @@ -8,25 +20,61 @@ android { buildFeatures { buildConfig = true } + + defaultConfig { + buildConfigField("String", "NAVER_MAP_CLIENT_ID", properties["NAVER_MAP_CLIENT_ID"].toString()) + manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = buildConfigField( + "String", + "KAKAO_NATIVE_APP_KEY", + properties["KAKAO_NATIVE_APP_KEY"].toString() + ) + } + + signingConfigs { + create("release") { + storeFile = rootProject.file("neki_key_store.jks") + storePassword = properties["STORE_PASSWORD"].toString() + keyAlias = properties["KEY_ALIAS"].toString() + keyPassword = properties["KEY_PASSWORD"].toString() + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + } dependencies { implementation(projects.core.common) implementation(projects.core.dataApi) implementation(projects.core.data) - implementation(projects.core.domain) - implementation(projects.core.model) - implementation(projects.core.designsystem) - implementation(projects.feature.sample.impl) - implementation(projects.feature.sample.api) - - implementation(projects.core.common) - implementation(projects.core.data) - implementation(projects.core.dataApi) implementation(projects.core.designsystem) implementation(projects.core.domain) implementation(projects.core.model) + implementation(projects.core.navigation) + implementation(projects.core.ui) + implementation(projects.feature.auth.api) + implementation(projects.feature.auth.impl) + implementation(projects.feature.pose.api) + implementation(projects.feature.pose.impl) + implementation(projects.feature.archive.api) + implementation(projects.feature.archive.impl) + implementation(projects.feature.map.api) + implementation(projects.feature.map.impl) + implementation(projects.feature.mypage.api) + implementation(projects.feature.mypage.impl) + implementation(projects.feature.photoUpload.api) + implementation(projects.feature.photoUpload.impl) implementation(libs.timber) -} \ No newline at end of file + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation3.ui) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb4348..9aa01c402 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,17 +5,191 @@ # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Keep line number information for debugging stack traces. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# ======================== +# Project Classes +# ======================== +-keep class com.neki.android.** { *; } +-keepclassmembers class com.neki.android.** { *; } + +# ======================== +# Kotlin +# ======================== +-keep class kotlin.Metadata { *; } +-keepattributes RuntimeVisibleAnnotations +-keepattributes *Annotation* + +# Kotlin Coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} +-keepclassmembernames class kotlinx.** { + volatile ; +} + +# ======================== +# Ktor +# ======================== +-keep class io.ktor.** { *; } +-keepclassmembers class io.ktor.** { *; } +-dontwarn io.ktor.** + +# ======================== +# kotlinx.serialization +# ======================== +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +-keepclassmembers @kotlinx.serialization.Serializable class ** { + *** Companion; +} +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1>$Companion { + kotlinx.serialization.KSerializer serializer(...); +} +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers class * { + @kotlinx.serialization.SerialName ; +} +-keep,includedescriptorclasses class com.neki.android.**$$serializer { *; } +-keepclassmembers class com.neki.android.** { + *** Companion; +} + +# ======================== +# Kakao SDK +# ======================== +-keep class com.kakao.sdk.** { *; } +-keepclassmembers class com.kakao.sdk.** { *; } +-dontwarn com.kakao.sdk.** + +# Kakao SDK enums (TokenNotFound 등) +-keepclassmembers enum com.kakao.sdk.** { + public static **[] values(); + public static ** valueOf(java.lang.String); + ; +} + +# ======================== +# Hilt / Dagger +# ======================== +-keep class dagger.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; } +-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; } +-keepclassmembers class * { + @dagger.hilt.* ; + @dagger.hilt.* ; + @javax.inject.* ; + @javax.inject.* ; +} +-dontwarn dagger.internal.codegen.** +-dontwarn dagger.hilt.internal.** + +# ======================== +# Android / Jetpack +# ======================== +# Lifecycle +-keep class androidx.lifecycle.** { *; } +-keepclassmembers class * implements androidx.lifecycle.LifecycleObserver { + (...); +} + +# Navigation +-keep class androidx.navigation.** { *; } + +# Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# DataStore +-keep class androidx.datastore.** { *; } +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { + ; +} + +# Paging +-keep class androidx.paging.** { *; } + +# ======================== +# Enums (General) +# ======================== +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); + ; +} + +# ======================== +# Timber +# ======================== +-dontwarn org.jetbrains.annotations.** + +# ======================== +# OSS Licenses +# ======================== +-keep class com.google.android.gms.oss.licenses.** { *; } + +# ======================== +# Coil +# ======================== +-keep class coil3.** { *; } +-dontwarn coil3.** + +# ======================== +# Naver Maps +# ======================== +-keep class com.naver.maps.** { *; } +-dontwarn com.naver.maps.** + +# ======================== +# OkHttp (used by Coil) +# ======================== +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + +# ======================== +# ML Kit Barcode +# ======================== +-keep class com.google.mlkit.** { *; } +-dontwarn com.google.mlkit.** + +# ======================== +# Play Services +# ======================== +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# ======================== +# CameraX +# ======================== +-keep class androidx.camera.** { *; } +-dontwarn androidx.camera.** + +# ======================== +# Missing class warnings suppression +# ======================== +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.slf4j.** +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f892cf440..f442abbee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,13 @@ - + + + + + + + @@ -23,7 +29,29 @@ + + + + + + + + + + + - \ No newline at end of file + + + + + + diff --git a/app/src/main/java/com/neki/android/app/MainActivity.kt b/app/src/main/java/com/neki/android/app/MainActivity.kt new file mode 100644 index 000000000..9116b7619 --- /dev/null +++ b/app/src/main/java/com/neki/android/app/MainActivity.kt @@ -0,0 +1,101 @@ +package com.neki.android.app + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import androidx.navigation3.runtime.entryProvider +import com.neki.android.core.dataapi.auth.AuthEvent +import com.neki.android.core.dataapi.auth.AuthEventManager +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.NavigatorImpl +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.core.navigation.result.ResultEventBus +import com.neki.android.core.navigation.root.RootNavKey +import com.neki.android.core.navigation.root.RootNavigationState +import com.neki.android.core.navigation.toEntries +import com.neki.android.feature.auth.impl.LoginRoute +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var rootNavigationState: RootNavigationState + + @Inject + lateinit var navigator: NavigatorImpl + + @Inject + lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> + + @Inject + lateinit var authEventManager: AuthEventManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + NekiTheme { + val resultBus = remember { ResultEventBus() } + NekiTheme { + CompositionLocalProvider(LocalResultEventBus provides resultBus) { + when (rootNavigationState.currentRootKey) { + RootNavKey.Login -> { + LoginRoute( + navigateToMain = { navigator.navigateRoot(RootNavKey.Main) }, + ) + } + + RootNavKey.Main -> { + MainScreen( + currentKey = navigator.state.currentKey, + currentTopLevelKey = navigator.state.currentTopLevelKey, + topLevelKeys = navigator.state.topLevelKeys, + entries = navigator.state.toEntries( + entryProvider = entryProvider { + entryProviderScopes.forEach { builder -> this.builder() } + }, + ), + onTabSelected = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + navigateToLogin = { navigator.navigateRoot(RootNavKey.Login) }, + ) + } + } + } + } + } + } + observeAuthEvents() + } + + private fun observeAuthEvents() { + lifecycleScope.launch { + authEventManager.authEvent.collect { event -> + when (event) { + AuthEvent.RefreshTokenExpired -> { + Toast.makeText( + this@MainActivity, + "RefreshToken이 만료되었습니다.", + Toast.LENGTH_SHORT, + ).show() + + navigator.navigateRoot(RootNavKey.Login) + } + } + } + } + } +} diff --git a/app/src/main/java/com/neki/android/app/MainScreen.kt b/app/src/main/java/com/neki/android/app/MainScreen.kt new file mode 100644 index 000000000..028ce52cf --- /dev/null +++ b/app/src/main/java/com/neki/android/app/MainScreen.kt @@ -0,0 +1,55 @@ +package com.neki.android.app + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay +import com.neki.android.app.ui.BottomNavigationBar +import com.neki.android.feature.map.api.MapNavKey + +@Composable +fun MainScreen( + currentKey: NavKey, + currentTopLevelKey: NavKey, + topLevelKeys: Set, + entries: SnapshotStateList>, + onTabSelected: (NavKey) -> Unit, + onBack: () -> Unit, + navigateToLogin: () -> Unit, +) { + val shouldShowBottomBar by remember(currentKey) { + mutableStateOf(currentKey in topLevelKeys) + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + bottomBar = { + BottomNavigationBar( + visible = shouldShowBottomBar, + currentTab = currentTopLevelKey, + currentKey = currentKey, + onTabSelected = { onTabSelected(it.navKey) }, + ) + }, + ) { innerPadding -> + NavDisplay( + modifier = Modifier.padding( + if (currentKey == MapNavKey.Map) PaddingValues(bottom = innerPadding.calculateBottomPadding()) else innerPadding, + ), + entries = entries, + onBack = onBack, + ) + } +} diff --git a/app/src/main/java/com/neki/android/app/NekiApplication.kt b/app/src/main/java/com/neki/android/app/NekiApplication.kt index 3f2fad624..f42f5e762 100644 --- a/app/src/main/java/com/neki/android/app/NekiApplication.kt +++ b/app/src/main/java/com/neki/android/app/NekiApplication.kt @@ -1,24 +1,29 @@ package com.neki.android.app import android.app.Application +import com.kakao.sdk.common.KakaoSdk +import com.naver.maps.map.NaverMapSdk import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @HiltAndroidApp -class NekiApplication: Application() { +class NekiApplication : Application() { override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { - Timber.plant(object : Timber.Tree() { - override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - // 여기는 Release일 때 로그를 남기지 않고 어딘가로 전송 ? - - } - }) + Timber.plant( + object : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + // 여기는 Release일 때 로그를 남기지 않고 어딘가로 전송 ? + } + }, + ) } + NaverMapSdk.getInstance(this).client = NaverMapSdk.NcpKeyClient(BuildConfig.NAVER_MAP_CLIENT_ID) + KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/neki/android/app/navigation/TopLevelNavItem.kt b/app/src/main/java/com/neki/android/app/navigation/TopLevelNavItem.kt new file mode 100644 index 000000000..9fcc9a4ee --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/TopLevelNavItem.kt @@ -0,0 +1,47 @@ +package com.neki.android.app.navigation + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.navigation3.runtime.NavKey +import com.neki.android.app.R +import com.neki.android.feature.archive.api.ArchiveNavKey +import com.neki.android.feature.map.api.MapNavKey +import com.neki.android.feature.mypage.api.MyPageNavKey +import com.neki.android.feature.pose.api.PoseNavKey + +enum class TopLevelNavItem( + @DrawableRes val selectedIconRes: Int, + @DrawableRes val unselectedIconRes: Int, + @StringRes val iconStringRes: Int, + val navKey: NavKey, +) { + ARCHIVE( + selectedIconRes = R.drawable.ic_nav_archive_selected, + unselectedIconRes = R.drawable.ic_nav_archive_unselected, + iconStringRes = R.string.top_level_nav_archive, + navKey = ArchiveNavKey.Archive, + ), + POSE_RECOMMEND( + selectedIconRes = R.drawable.ic_nav_pose_selected, + unselectedIconRes = R.drawable.ic_nav_pose_unselected, + iconStringRes = R.string.top_level_nav_pose, + navKey = PoseNavKey.PoseMain, + ), + MAP( + selectedIconRes = R.drawable.ic_nav_map_selected, + unselectedIconRes = R.drawable.ic_nav_map_unselected, + iconStringRes = R.string.top_level_nav_map, + navKey = MapNavKey.Map, + ), + MYPAGE( + selectedIconRes = R.drawable.ic_nav_mypage_selected, + unselectedIconRes = R.drawable.ic_nav_mypage_unselected, + iconStringRes = R.string.top_level_nav_mypage, + navKey = MyPageNavKey.MyPage, + ), + ; + + companion object { + val startTopLevelItem = ARCHIVE + } +} diff --git a/app/src/main/java/com/neki/android/app/navigation/di/AppModule.kt b/app/src/main/java/com/neki/android/app/navigation/di/AppModule.kt new file mode 100644 index 000000000..9c9f65551 --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/di/AppModule.kt @@ -0,0 +1,18 @@ +package com.neki.android.app.navigation.di + +import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.NavigatorImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent + +@Module +@InstallIn(ActivityRetainedComponent::class) +internal interface AppModule { + + @Binds + fun bindsNavigator( + impl: NavigatorImpl, + ): Navigator +} diff --git a/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt b/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt new file mode 100644 index 000000000..f28a13f8a --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt @@ -0,0 +1,34 @@ +package com.neki.android.app.navigation.di + +import com.neki.android.app.navigation.keys.START_NAV_KEY +import com.neki.android.app.navigation.keys.START_ROOT_NAV_KEY +import com.neki.android.app.navigation.keys.TOP_LEVEL_NAV_KEYS +import com.neki.android.core.navigation.NavigationState +import com.neki.android.core.navigation.root.RootNavigationState +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +internal object NavigationModule { + + @Provides + @ActivityRetainedScoped + fun providesNavigationState(): NavigationState { + return NavigationState( + startKey = START_NAV_KEY, + topLevelKeys = TOP_LEVEL_NAV_KEYS.toSet(), + ) + } + + @Provides + @ActivityRetainedScoped + fun providesRootNavigationState(): RootNavigationState { + return RootNavigationState( + startKey = START_ROOT_NAV_KEY, + ) + } +} diff --git a/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt b/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt new file mode 100644 index 000000000..50a2d285c --- /dev/null +++ b/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt @@ -0,0 +1,9 @@ +package com.neki.android.app.navigation.keys + +import com.neki.android.app.navigation.TopLevelNavItem +import com.neki.android.core.navigation.root.RootNavKey +import com.neki.android.feature.archive.api.ArchiveNavKey + +internal val START_ROOT_NAV_KEY = RootNavKey.Login +internal val START_NAV_KEY = ArchiveNavKey.Archive +internal val TOP_LEVEL_NAV_KEYS = TopLevelNavItem.entries.map { it.navKey } diff --git a/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt b/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt new file mode 100644 index 000000000..ff8d77400 --- /dev/null +++ b/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt @@ -0,0 +1,120 @@ +package com.neki.android.app.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey +import com.neki.android.app.navigation.TopLevelNavItem +import com.neki.android.core.designsystem.modifier.tabbarShadow +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun BottomNavigationBar( + visible: Boolean, + currentKey: NavKey, + currentTab: NavKey, + tabs: List = TopLevelNavItem.entries, + onTabSelected: (TopLevelNavItem) -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + Surface( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .tabbarShadow(), + color = NekiTheme.colorScheme.white, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 19.5.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + tabs.forEach { tab -> + BottomNavigationBarItem( + modifier = Modifier.weight(1f), + selected = tab.navKey == currentTab, + tab = tab, + onClick = { if (tab.navKey != currentKey) onTabSelected(tab) }, + ) + } + } + } + } +} + +@Composable +fun BottomNavigationBarItem( + selected: Boolean, + tab: TopLevelNavItem, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + val icon = if (selected) tab.selectedIconRes else tab.unselectedIconRes + val iconColor = if (selected) NekiTheme.colorScheme.gray800 else NekiTheme.colorScheme.gray200 + val textColor = if (selected) NekiTheme.colorScheme.gray800 else NekiTheme.colorScheme.gray500 + + Surface( + modifier = modifier, + onClick = onClick, + color = NekiTheme.colorScheme.white, + ) { + Column( + modifier = Modifier.padding(vertical = 1.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + modifier = Modifier.size(26.dp), + imageVector = ImageVector.vectorResource(icon), + contentDescription = stringResource(tab.iconStringRes), + tint = iconColor, + ) + Text( + text = stringResource(tab.iconStringRes), + color = textColor, + style = NekiTheme.typography.caption12SemiBold, + ) + } + } +} + +@Preview +@Composable +private fun BottomNavigationBarPreview() { + var currentTab by remember { mutableStateOf(TopLevelNavItem.ARCHIVE) } + NekiTheme { + BottomNavigationBar( + visible = true, + tabs = TopLevelNavItem.entries, + currentTab = currentTab.navKey, + currentKey = currentTab.navKey, + ) { currentTab = it } + } +} diff --git a/app/src/main/res/drawable/ic_nav_archive_selected.xml b/app/src/main/res/drawable/ic_nav_archive_selected.xml new file mode 100644 index 000000000..cbc544202 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_archive_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_archive_unselected.xml b/app/src/main/res/drawable/ic_nav_archive_unselected.xml new file mode 100644 index 000000000..3326a37a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_archive_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_map_selected.xml b/app/src/main/res/drawable/ic_nav_map_selected.xml new file mode 100644 index 000000000..bf9d67933 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_map_selected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nav_map_unselected.xml b/app/src/main/res/drawable/ic_nav_map_unselected.xml new file mode 100644 index 000000000..f8fa1da48 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_map_unselected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nav_mypage_selected.xml b/app/src/main/res/drawable/ic_nav_mypage_selected.xml new file mode 100644 index 000000000..4d4e83f92 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_mypage_selected.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nav_mypage_unselected.xml b/app/src/main/res/drawable/ic_nav_mypage_unselected.xml new file mode 100644 index 000000000..b633e5435 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_mypage_unselected.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nav_pose_selected.xml b/app/src/main/res/drawable/ic_nav_pose_selected.xml new file mode 100644 index 000000000..ef5d248c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_pose_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_pose_unselected.xml b/app/src/main/res/drawable/ic_nav_pose_unselected.xml new file mode 100644 index 000000000..61143f466 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_pose_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_nav_archive_selected.xml b/app/src/main/res/drawable/icon_nav_archive_selected.xml new file mode 100644 index 000000000..fb9e6f13f --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_archive_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_nav_archive_unselected.xml b/app/src/main/res/drawable/icon_nav_archive_unselected.xml new file mode 100644 index 000000000..e70c42e65 --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_archive_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_nav_map_selected.xml b/app/src/main/res/drawable/icon_nav_map_selected.xml new file mode 100644 index 000000000..2196191b8 --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_map_selected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/icon_nav_map_unselected.xml b/app/src/main/res/drawable/icon_nav_map_unselected.xml new file mode 100644 index 000000000..f92a513d4 --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_map_unselected.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/icon_nav_mypage_selected.xml b/app/src/main/res/drawable/icon_nav_mypage_selected.xml new file mode 100644 index 000000000..92f7c657c --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_mypage_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_nav_mypage_unselected.xml b/app/src/main/res/drawable/icon_nav_mypage_unselected.xml new file mode 100644 index 000000000..b5be1485d --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_mypage_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_nav_pose_selected.xml b/app/src/main/res/drawable/icon_nav_pose_selected.xml new file mode 100644 index 000000000..1a0d4868b --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_pose_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_nav_pose_unselected.xml b/app/src/main/res/drawable/icon_nav_pose_unselected.xml new file mode 100644 index 000000000..93dc71333 --- /dev/null +++ b/app/src/main/res/drawable/icon_nav_pose_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 820454ae5..a82505ad5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,9 @@ Neki - \ No newline at end of file + + 포즈 + 아카이빙 + 네컷지도 + 마이 + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3a126b0e1..208d2f239 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,13 @@ - + - + + + diff --git a/app/src/test/java/com/neki/android/ExampleUnitTest.kt b/app/src/test/java/com/neki/android/ExampleUnitTest.kt deleted file mode 100644 index 6bf1db485..000000000 --- a/app/src/test/java/com/neki/android/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.neki.android - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 4c85a0abf..f6ad754b4 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -31,9 +31,13 @@ gradlePlugin { id = "neki.kotlin.library" implementationClass = "KotlinLibraryConventionPlugin" } - register("androidFeatureCompose") { - id = "neki.android.feature" - implementationClass = "AndroidFeatureConventionPlugin" + register("androidFeatureApi") { + id = "neki.android.feature.api" + implementationClass = "AndroidFeatureApiConventionPlugin" + } + register("androidFeatureImplCompose") { + id = "neki.android.feature.impl" + implementationClass = "AndroidFeatureImplConventionPlugin" } register("hilt") { id = "neki.hilt" diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt b/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt index ddc7aa593..64d82394d 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/const/BuildConst.kt @@ -9,8 +9,8 @@ object BuildConst { const val VERSION_NAME = "1.0.0" const val MIN_SDK = 29 - const val TARGET_SDK = 35 - const val COMPILE_SDK = 35 + const val TARGET_SDK = 36 + const val COMPILE_SDK = 36 const val JDK_VERSION = 21 val JAVA_VERSION = JavaVersion.VERSION_21 diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt b/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt index 5f814f081..9d8291e26 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt @@ -1,17 +1,22 @@ package com.neki.android.buildlogic.extensions -import com.neki.android.buildlogic.const.BuildConst import com.android.build.api.dsl.CommonExtension +import com.neki.android.buildlogic.const.BuildConst import org.gradle.api.Project import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.dependencies import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions internal fun Project.configureAndroid( - commonExtension: CommonExtension<*, *, *, *, *, *> + commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { compileSdk = BuildConst.COMPILE_SDK + defaultConfig { + minSdk = BuildConst.MIN_SDK + } + compileOptions { sourceCompatibility = BuildConst.JAVA_VERSION targetCompatibility = BuildConst.JAVA_VERSION @@ -21,14 +26,8 @@ internal fun Project.configureAndroid( jvmTarget = BuildConst.JDK_VERSION.toString() } - buildTypes { - getByName("release") { - isMinifyEnabled = true - proguardFiles( - getDefaultProguardFile("proguard-android.txt"), - "proguard-rules.pro", - ) - } + dependencies { + add("detektPlugins", libs.findLibrary("detekt.formatting").get()) } } } @@ -37,4 +36,4 @@ internal fun CommonExtension<*, *, *, *, *, *>.configureAndroidOptions( block: KotlinJvmOptions.() -> Unit, ) { (this as ExtensionAware).extensions.configure("kotlinOptions", block) -} \ No newline at end of file +} diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidApplicationConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidApplicationConventionPlugin.kt index 524cb4605..92d92b9a5 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidApplicationConventionPlugin.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidApplicationConventionPlugin.kt @@ -20,7 +20,6 @@ class AndroidApplicationConventionPlugin: Plugin { defaultConfig.apply { applicationId = BuildConst.APPLICATION_ID - minSdk = BuildConst.MIN_SDK targetSdk = BuildConst.TARGET_SDK versionCode = BuildConst.VERSION_CODE @@ -29,4 +28,4 @@ class AndroidApplicationConventionPlugin: Plugin { } } } -} \ No newline at end of file +} diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureApiConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureApiConventionPlugin.kt new file mode 100644 index 000000000..dd83fb415 --- /dev/null +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureApiConventionPlugin.kt @@ -0,0 +1,20 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class AndroidFeatureApiConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("neki.android.library") + apply("org.jetbrains.kotlin.plugin.serialization") + } + + dependencies { + "api"(project(":core:navigation")) + "api"(project(":core:model")) + } + } + } +} diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt similarity index 64% rename from build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureConventionPlugin.kt rename to build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt index 5d6c60438..95ffd3b20 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureConventionPlugin.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt @@ -1,8 +1,9 @@ +import com.neki.android.buildlogic.extensions.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies -class AndroidFeatureConventionPlugin: Plugin { +class AndroidFeatureImplConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { @@ -13,9 +14,12 @@ class AndroidFeatureConventionPlugin: Plugin { dependencies { "implementation"(project(":core:designsystem")) - "implementation"(project(":core:model")) "implementation"(project(":core:data-api")) "implementation"(project(":core:common")) + "implementation"(project(":core:domain")) + "implementation"(project(":core:ui")) + + "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModel.compose").get()) } } } diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/KotlinLibraryConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/KotlinLibraryConventionPlugin.kt index 1544eb89e..b17ca3a97 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/KotlinLibraryConventionPlugin.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/KotlinLibraryConventionPlugin.kt @@ -1,8 +1,10 @@ import com.neki.android.buildlogic.const.BuildConst +import com.neki.android.buildlogic.extensions.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.JavaPluginExtension import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension class KotlinLibraryConventionPlugin: Plugin { @@ -21,6 +23,10 @@ class KotlinLibraryConventionPlugin: Plugin { extensions.configure { jvmToolchain(BuildConst.JDK_VERSION) } + + dependencies { + add("detektPlugins", libs.findLibrary("detekt.formatting").get()) + } } } } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4d0913b8b..6ba648c59 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,28 @@ +import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import org.gradle.kotlin.dsl.configure + plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.jetbrains.kotlin.jvm) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false -} \ No newline at end of file + alias(libs.plugins.detekt) apply false + alias(libs.plugins.oss.licenses) apply false +} + +subprojects { + apply { + plugin(rootProject.libs.plugins.detekt.get().pluginId) + } + + configure { + parallel = true + buildUponDefaultConfig = true + toolVersion = rootProject.libs.versions.detekt.get() + config.setFrom(files("$rootDir/detekt-config.yml")) + } +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 8250c3e29..e9144caad 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.neki.android.library) alias(libs.plugins.neki.android.library.compose) + alias(libs.plugins.neki.hilt) } android { @@ -9,5 +10,7 @@ android { dependencies { api(libs.timber) - -} \ No newline at end of file + api(libs.kakao.user) + implementation(libs.androidx.security.crypto) + implementation(libs.androidx.core.ktx) +} diff --git a/core/common/src/main/java/com/neki/android/core/common/const/Const.kt b/core/common/src/main/java/com/neki/android/core/common/const/Const.kt index 9fa393635..2d9b4a9d2 100644 --- a/core/common/src/main/java/com/neki/android/core/common/const/Const.kt +++ b/core/common/src/main/java/com/neki/android/core/common/const/Const.kt @@ -2,5 +2,4 @@ package com.neki.android.core.common.const object Const { const val TAG_REST_API = "TAG_REST_API" - -} \ No newline at end of file +} diff --git a/core/common/src/main/java/com/neki/android/core/common/coroutine/di/CoroutineScopeModule.kt b/core/common/src/main/java/com/neki/android/core/common/coroutine/di/CoroutineScopeModule.kt new file mode 100644 index 000000000..27a93f69e --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/coroutine/di/CoroutineScopeModule.kt @@ -0,0 +1,26 @@ +package com.neki.android.core.common.coroutine.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopeModule { + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/core/common/src/main/java/com/neki/android/core/common/crypto/CryptoManager.kt b/core/common/src/main/java/com/neki/android/core/common/crypto/CryptoManager.kt new file mode 100644 index 000000000..c18f5203f --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/crypto/CryptoManager.kt @@ -0,0 +1,64 @@ +package com.neki.android.core.common.crypto + +import android.annotation.SuppressLint +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +object CryptoManager { + private const val ALGORITHM = "AES/GCM/NoPadding" + private const val KEY_ALIAS = "neki_token_encryption_key" + + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + private fun getSecretKey(): SecretKey { + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + @SuppressLint("NewApi") + private fun createKey(): SecretKey { + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + }.generateKey() + } + + @SuppressLint("NewApi") + fun encrypt(text: String): String { + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + + val encryptedBytes = cipher.doFinal(text.toByteArray()) + val combined = cipher.iv + encryptedBytes + + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + @SuppressLint("NewApi") + fun decrypt(encryptedText: String): String { + val combined = Base64.decode(encryptedText, Base64.NO_WRAP) + val encryptedData = combined.sliceArray(12 until combined.size) + + val cipher = Cipher.getInstance(ALGORITHM) + val iv = combined.sliceArray(0 until 12) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + return String(cipher.doFinal(encryptedData)) + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt b/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt new file mode 100644 index 000000000..a08f3ac50 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt @@ -0,0 +1,5 @@ +package com.neki.android.core.common.exception + +class RandomPoseRetryExhaustedException( + message: String, +) : RuntimeException(message) diff --git a/core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt b/core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt new file mode 100644 index 000000000..c75f5b2d0 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt @@ -0,0 +1,57 @@ +package com.neki.android.core.common.kakao + +import android.content.Context +import com.kakao.sdk.user.UserApiClient + +class KakaoAuthHelper( + private val context: Context, +) { + fun login( + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + ) { + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + if (error != null) { + onFailure(error.message ?: "카카오 로그인에 실패했습니다.") + } else if (token != null) { + onSuccess(token.idToken!!) + } + } + } else { + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + if (error != null) { + onFailure(error.message ?: "카카오 로그인에 실패했습니다.") + } else if (token != null) { + onSuccess(token.idToken!!) + } + } + } + } + + fun logout( + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + UserApiClient.instance.logout { error -> + if (error != null) { + onFailure(error.message ?: "카카오 로그아웃에 실패했습니다.") + } else { + onSuccess() + } + } + } + + fun unlink( + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + UserApiClient.instance.unlink { error -> + if (error != null) { + onFailure(error.message ?: "카카오 연결 해제에 실패했습니다.") + } else { + onSuccess() + } + } + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/CameraPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/CameraPermissionManager.kt new file mode 100644 index 000000000..f68fc9d24 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/CameraPermissionManager.kt @@ -0,0 +1,19 @@ +package com.neki.android.core.common.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker + +object CameraPermissionManager { + const val CAMERA_PERMISSION = Manifest.permission.CAMERA + + fun isGrantedCameraPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission(context, CAMERA_PERMISSION) == PermissionChecker.PERMISSION_GRANTED + } + + fun shouldShowCameraRationale(activity: Activity): Boolean { + return activity.shouldShowRequestPermissionRationale(CAMERA_PERMISSION) + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt new file mode 100644 index 000000000..9649afbf8 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt @@ -0,0 +1,26 @@ +package com.neki.android.core.common.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker + +object LocationPermissionManager { + val LOCATION_PERMISSIONS = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + + fun isGrantedLocationPermission(context: Context): Boolean { + return LOCATION_PERMISSIONS.any { permission -> + ContextCompat.checkSelfPermission(context, permission) == PermissionChecker.PERMISSION_GRANTED + } + } + + fun shouldShowLocationRationale(activity: Activity): Boolean { + return LOCATION_PERMISSIONS.any { permission -> + activity.shouldShowRequestPermissionRationale(permission) + } + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt new file mode 100644 index 000000000..76c53d32d --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt @@ -0,0 +1,25 @@ +package com.neki.android.core.common.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker + +object NotificationPermissionManager { + const val NOTIFICATION_PERMISSION = Manifest.permission.POST_NOTIFICATIONS + + fun isGrantedNotificationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, NOTIFICATION_PERMISSION) == PermissionChecker.PERMISSION_GRANTED + } else NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + fun shouldShowNotificationRationale(activity: Activity): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.shouldShowRequestPermissionRationale(NOTIFICATION_PERMISSION) + } else false + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/PermissionUtils.kt b/core/common/src/main/java/com/neki/android/core/common/permission/PermissionUtils.kt new file mode 100644 index 000000000..1286aa658 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/PermissionUtils.kt @@ -0,0 +1,13 @@ +package com.neki.android.core.common.permission + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +fun navigateToAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) +} diff --git a/core/common/src/main/java/com/neki/android/core/common/util/ByteArray.kt b/core/common/src/main/java/com/neki/android/core/common/util/ByteArray.kt new file mode 100644 index 000000000..3b3610dee --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/util/ByteArray.kt @@ -0,0 +1,41 @@ +package com.neki.android.core.common.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.net.URL + +private const val DEFAULT_QUALITY = 80 + +fun Uri.toByteArray( + context: Context, + quality: Int = DEFAULT_QUALITY, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, +): ByteArray? { + val bytes = context.contentResolver.openInputStream(this)?.use { it.readBytes() } ?: return null + return bytes.compress(quality, format) +} + +suspend fun String.urlToByteArray( + quality: Int = DEFAULT_QUALITY, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, +): ByteArray = withContext(Dispatchers.IO) { + val bytes = URL(this@urlToByteArray).openStream().use { it.readBytes() } + bytes.compress(quality, format) +} + +private fun ByteArray.compress( + quality: Int, + format: Bitmap.CompressFormat, +): ByteArray { + val bitmap = BitmapFactory.decodeByteArray(this, 0, this.size) + return ByteArrayOutputStream().use { outputStream -> + bitmap.compress(format, quality, outputStream) + bitmap.recycle() + outputStream.toByteArray() + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/util/Date.kt b/core/common/src/main/java/com/neki/android/core/common/util/Date.kt new file mode 100644 index 000000000..b0ce5de6a --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/util/Date.kt @@ -0,0 +1,24 @@ +package com.neki.android.core.common.util + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * ISO-8601 형식의 날짜 문자열을 지정된 패턴으로 변환 + * + * @param pattern 출력 패턴 (기본값: "yyyy.MM.dd") + * @return 변환된 날짜 문자열 + * + * 예시: + * - Input: "2026-01-22T14:40:33.313120" + * - Output: "2026.01.22" + */ +fun String.toFormattedDate(pattern: String = "yyyy.MM.dd"): String { + return try { + val dateTime = LocalDateTime.parse(this) + dateTime.format(DateTimeFormatter.ofPattern(pattern)) + } catch (e: DateTimeParseException) { + this + } +} diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index ed411b848..dc29c6c33 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -1,7 +1,14 @@ plugins { - alias(libs.plugins.neki.kotlin.library) + alias(libs.plugins.neki.android.library) +} + +android { + namespace = "com.neki.android.core.dataapi" } dependencies { implementation(projects.core.model) + implementation(libs.kotlinx.coroutines.core) + api(libs.androidx.datastore.preferences) + api(libs.androidx.paging.common) } \ No newline at end of file diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthCacheManager.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthCacheManager.kt new file mode 100644 index 000000000..02653e70c --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthCacheManager.kt @@ -0,0 +1,5 @@ +package com.neki.android.core.dataapi.auth + +interface AuthCacheManager { + fun invalidateTokenCache() +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthEventManager.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthEventManager.kt new file mode 100644 index 000000000..191275151 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthEventManager.kt @@ -0,0 +1,12 @@ +package com.neki.android.core.dataapi.auth + +import kotlinx.coroutines.flow.SharedFlow + +sealed class AuthEvent { + data object RefreshTokenExpired : AuthEvent() +} + +interface AuthEventManager { + val authEvent: SharedFlow + fun emitTokenExpired() +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt new file mode 100644 index 000000000..e145d556f --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.dataapi.datastore + +import androidx.datastore.preferences.core.stringPreferencesKey + +object DataStoreKey { + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt new file mode 100644 index 000000000..dfff3f25c --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.Auth + +interface AuthRepository { + suspend fun loginWithKakao(idToken: String): Result + suspend fun updateAccessToken(refreshToken: String): Result + suspend fun withdrawAccount(): Result +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt new file mode 100644 index 000000000..2ce25268d --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.dataapi.repository + +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.flow.Flow + +interface DataStoreRepository { + suspend fun setBoolean(key: Preferences.Key, value: Boolean) + fun getBoolean(key: Preferences.Key): Flow +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt new file mode 100644 index 000000000..b8f1e1096 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt @@ -0,0 +1,10 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.AlbumPreview + +interface FolderRepository { + suspend fun getFolders(): Result> + suspend fun createFolder(name: String): Result + suspend fun deleteFolder(id: List, deletePhotos: Boolean): Result + suspend fun removePhotosFromFolder(folderId: Long, photoIds: List): Result +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MapRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MapRepository.kt new file mode 100644 index 000000000..b05401220 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MapRepository.kt @@ -0,0 +1,20 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.Brand +import com.neki.android.core.model.PhotoBooth + +interface MapRepository { + suspend fun getBrands(): Result> + + suspend fun getPhotoBoothsByPoint( + longitude: Double?, + latitude: Double?, + radiusInMeters: Int, + brandIds: List, + ): Result> + + suspend fun getPhotoBoothsByPolygon( + coordinates: List>, + brandIds: List, + ): Result> +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt new file mode 100644 index 000000000..602cd7e21 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt @@ -0,0 +1,32 @@ +package com.neki.android.core.dataapi.repository + +import android.net.Uri +import com.neki.android.core.model.ContentType +import com.neki.android.core.model.MediaUploadTicket + +interface MediaUploadRepository { + suspend fun getSingleUploadTicket( + fileName: String, + contentType: String, + mediaType: String, + ): Result + + suspend fun getMultipleUploadTicket( + uploadCount: Int, + fileName: String, + contentType: String, + mediaType: String, + ): Result> + + suspend fun uploadImageFromUri( + uploadUrl: String, + uri: Uri, + contentType: ContentType, + ): Result + + suspend fun uploadImageFromUrl( + uploadUrl: String, + imageUrl: String, + contentType: ContentType, + ): Result +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt new file mode 100644 index 000000000..af03e789e --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PhotoRepository.kt @@ -0,0 +1,42 @@ +package com.neki.android.core.dataapi.repository + +import androidx.paging.PagingData +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow + +interface PhotoRepository { + suspend fun getPhotos( + folderId: Long? = null, + page: Int = 0, + size: Int = 20, + ): Result> + + suspend fun registerPhoto( + mediaIds: List, + folderId: Long? = null, + ): Result + + suspend fun deletePhoto(photoId: Long): Result + suspend fun deletePhoto(photoIds: List): Result + + suspend fun updateFavorite(photoId: Long, favorite: Boolean): Result + + suspend fun getFavoritePhotos( + page: Int = 0, + size: Int = 20, + sortOrder: SortOrder = SortOrder.DESC, + ): Result> + + suspend fun getFavoriteSummary(): Result + + fun getPhotosFlow( + folderId: Long? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + + fun getFavoritePhotosFlow( + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt new file mode 100644 index 000000000..e81cbfb08 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -0,0 +1,36 @@ +package com.neki.android.core.dataapi.repository + +import androidx.paging.PagingData +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow + +interface PoseRepository { + + fun getPosesFlow( + headCount: PeopleCount? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + + fun getScrappedPosesFlow( + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + + suspend fun getPose(poseId: Long): Result + + suspend fun getSingleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + maxRetry: Int, + ): Result + + suspend fun getMultipleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + poseSize: Int, + maxRetry: Int, + ): Result> + + suspend fun updateScrap(poseId: Long, scrap: Boolean): Result +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/SampleRespository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/SampleRespository.kt deleted file mode 100644 index be2feca69..000000000 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/SampleRespository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.neki.android.core.dataapi.repository - -import com.neki.android.core.model.Post - -interface SampleRepository { - suspend fun getPosts(): List - suspend fun getPost( - id: Int - ): Post - - -} \ No newline at end of file diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt new file mode 100644 index 000000000..a0227f5df --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.dataapi.repository + +import kotlinx.coroutines.flow.Flow + +interface TokenRepository { + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) + fun isSavedTokens(): Flow + fun getAccessToken(): Flow + fun getRefreshToken(): Flow + suspend fun clearTokens() +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt new file mode 100644 index 000000000..892385422 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.UserInfo + +interface UserRepository { + suspend fun getUserInfo(): Result + suspend fun updateUserInfo(nickname: String): Result + suspend fun updateProfileImage(mediaId: Long?): Result +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 5e8c015e0..7df78da5d 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -1,11 +1,29 @@ +import java.util.Properties +import kotlin.apply + plugins { alias(libs.plugins.neki.android.library) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.neki.hilt) } +val localPropertiesFile = project.rootProject.file("local.properties") +val properties = Properties().apply { + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } +} + android { namespace = "com.neki.android.core.data" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "BASE_URL", properties["BASE_URL"].toString()) + } } dependencies { @@ -18,9 +36,11 @@ dependencies { implementation(libs.ktor.client.android) implementation(libs.ktor.client.logging.jvm) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.auth) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.annotation.experimental) implementation(libs.androidx.datastore.core) implementation(libs.androidx.datastore.preferences) -} \ No newline at end of file + implementation(libs.androidx.paging.runtime) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/auth/AuthEventManagerImpl.kt b/core/data/src/main/java/com/neki/android/core/data/auth/AuthEventManagerImpl.kt new file mode 100644 index 000000000..842a6a85f --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/auth/AuthEventManagerImpl.kt @@ -0,0 +1,19 @@ +package com.neki.android.core.data.auth + +import com.neki.android.core.dataapi.auth.AuthEvent +import com.neki.android.core.dataapi.auth.AuthEventManager +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthEventManagerImpl @Inject constructor() : AuthEventManager { + private val _authEvent = MutableSharedFlow(extraBufferCapacity = 1) + override val authEvent: SharedFlow = _authEvent.asSharedFlow() + + override fun emitTokenExpired() { + _authEvent.tryEmit(AuthEvent.RefreshTokenExpired) + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt new file mode 100644 index 000000000..568cc7405 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt @@ -0,0 +1,33 @@ +package com.neki.android.core.data.local.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +private const val AUTH_DATASTORE = "auth_datastore" +private val Context.authDataStore: DataStore by preferencesDataStore(name = AUTH_DATASTORE) + +private const val TOKEN_DATASTORE = "token_datastore" +private val Context.tokenDataStore: DataStore by preferencesDataStore(name = TOKEN_DATASTORE) + +@InstallIn(SingletonComponent::class) +@Module +internal object DataStoreModule { + + @AuthDataStore + @Singleton + @Provides + fun provideAuthDataStore(@ApplicationContext context: Context): DataStore = context.authDataStore + + @TokenDataStore + @Singleton + @Provides + fun provideTokenDataStore(@ApplicationContext context: Context): DataStore = context.tokenDataStore +} diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt new file mode 100644 index 000000000..87863741f --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt @@ -0,0 +1,11 @@ +package com.neki.android.core.data.local.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class TokenDataStore diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreRepository.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreRepository.kt deleted file mode 100644 index 4b30f951f..000000000 --- a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreRepository.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.neki.android.core.data.local.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.preferencesDataStoreFile -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@InstallIn(SingletonComponent::class) -@Module -internal object DataStoreModule { - private const val DATASTORE_NAME = "neki-datastore" - - @Singleton - @Provides - fun provideDataStore( - @ApplicationContext context: Context - ): DataStore { - return PreferenceDataStoreFactory.create( - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { emptyPreferences() }, - ), - produceFile = { context.preferencesDataStoreFile(DATASTORE_NAME) }, - ) - } - - -} \ No newline at end of file diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt new file mode 100644 index 000000000..ba1648c24 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/FavoritePhotoPagingSource.kt @@ -0,0 +1,41 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PhotoService +import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder + +class FavoritePhotoPagingSource( + private val photoService: PhotoService, + private val sortOrder: SortOrder, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = photoService.getFavoritePhotos( + page = page, + size = params.loadSize, + sortOrder = sortOrder.name, + ) + val photos = response.data.toModels() + val hasNext = response.data.hasNext + + LoadResult.Page( + data = photos, + prevKey = if (page == 0) null else page - 1, + nextKey = if (hasNext) page + 1 else null, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt new file mode 100644 index 000000000..a6b42fa1a --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt @@ -0,0 +1,42 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PhotoService +import com.neki.android.core.model.Photo + +class PhotoPagingSource( + private val photoService: PhotoService, + private val folderId: Long?, + private val sortOrder: String, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = photoService.getPhotos( + folderId = folderId, + page = page, + size = params.loadSize, + sortOrder = sortOrder, + ) + val photos = response.data.toModels() + val hasNext = response.data.hasNext + + LoadResult.Page( + data = photos, + prevKey = if (page == 0) null else page - 1, + nextKey = if (hasNext) page + 1 else null, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt new file mode 100644 index 000000000..8c3ff2aa4 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt @@ -0,0 +1,43 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PoseService +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder + +class PosePagingSource( + private val poseService: PoseService, + private val headCount: PeopleCount?, + private val sortOrder: SortOrder, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = poseService.getPoses( + page = page, + size = params.loadSize, + headCount = headCount?.name, + sortOrder = sortOrder.name, + ) + val poses = response.data.toModels() + + LoadResult.Page( + data = poses, + prevKey = if (page == 0) null else page - 1, + nextKey = if (poses.isEmpty()) null else page + 1, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/ScrapPosePagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/ScrapPosePagingSource.kt new file mode 100644 index 000000000..ef936d7f8 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/ScrapPosePagingSource.kt @@ -0,0 +1,40 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PoseService +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder + +class ScrapPosePagingSource( + private val poseService: PoseService, + private val sortOrder: SortOrder, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = poseService.getScrappedPoses( + page = page, + size = params.loadSize, + sortOrder = sortOrder.name, + ) + val poses = response.data.toModels() + + LoadResult.Page( + data = poses, + prevKey = if (page == 0) null else page - 1, + nextKey = if (poses.isEmpty()) null else page + 1, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/ApiService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/ApiService.kt deleted file mode 100644 index 6dac50389..000000000 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/ApiService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.neki.android.core.data.remote.api - -import com.neki.android.core.data.remote.model.response.PostResponse -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get - -class ApiService( - private val client: HttpClient -) { - // 게시글 목록 조회 - suspend fun getPosts(): List { - return client.get("/posts").body() - } - - // 게시글 조회 - suspend fun getPost( - id: Int - ): PostResponse { - return client.get("/posts/$id").body() - } - - -} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt new file mode 100644 index 000000000..c2ee66b3f --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt @@ -0,0 +1,32 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.KakaoLoginRequest +import com.neki.android.core.data.remote.model.request.RefreshTokenRequest +import com.neki.android.core.data.remote.model.response.AuthResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.BasicNullableResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import javax.inject.Inject + +class AuthService @Inject constructor( + private val client: HttpClient, +) { + // 카카오 로그인 + suspend fun loginWithKakao(requestBody: KakaoLoginRequest): BasicResponse { + return client.post("/api/auth/kakao/login") { setBody(requestBody) }.body() + } + + // AccessToken 갱신 + suspend fun updateAccessToken(requestBody: RefreshTokenRequest): BasicResponse { + return client.post("/api/auth/refresh") { setBody(requestBody) }.body() + } + + // 회원 탈퇴 + suspend fun withdrawAccount(): BasicNullableResponse { + return client.delete("/api/users/me").body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt new file mode 100644 index 000000000..424030027 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/FolderService.kt @@ -0,0 +1,46 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.CreateFolderRequest +import com.neki.android.core.data.remote.model.request.DeleteFolderRequest +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest +import com.neki.android.core.data.remote.model.response.BasicNullableResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.CreateFolderResponse +import com.neki.android.core.data.remote.model.response.FolderResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import javax.inject.Inject + +class FolderService @Inject constructor( + private val client: HttpClient, +) { + // 폴더 목록 조회 + suspend fun getFolders(): BasicResponse { + return client.get("/api/folders").body() + } + + // 폴더 생성 + suspend fun createFolder(requestBody: CreateFolderRequest): BasicNullableResponse { + return client.post("/api/folders") { setBody(requestBody) }.body() + } + + // 폴더 삭제 + suspend fun deleteFolder(requestBody: DeleteFolderRequest, deletePhotos: Boolean): BasicNullableResponse { + return client.delete("/api/folders") { + setBody(requestBody) + parameter("deletePhotos", deletePhotos) + }.body() + } + + // 폴더에서 사진 제거 (사진 자체는 삭제되지 않음) + suspend fun removePhotosFromFolder(folderId: Long, requestBody: DeletePhotoRequest): BasicNullableResponse { + return client.delete("/api/folders/$folderId/photos") { + setBody(requestBody) + }.body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/MapService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/MapService.kt new file mode 100644 index 000000000..bfd979ab2 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/MapService.kt @@ -0,0 +1,38 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.PhotoBoothPointRequest +import com.neki.android.core.data.remote.model.request.PhotoBoothPolygonRequest +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.BrandResponse +import com.neki.android.core.data.remote.model.response.PhotoBoothPointResponse +import com.neki.android.core.data.remote.model.response.PhotoBoothPolygonResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import javax.inject.Inject + +class MapService @Inject constructor( + private val client: HttpClient, +) { + suspend fun getBrands(): BasicResponse> { + return client.get("/api/photo-booths/brand").body() + } + + suspend fun getPhotoBoothsByPoint( + request: PhotoBoothPointRequest, + ): BasicResponse { + return client.post("/api/photo-booths/point") { + setBody(request) + }.body() + } + + suspend fun getPhotoBoothsByPolygon( + request: PhotoBoothPolygonRequest, + ): BasicResponse { + return client.post("/api/photo-booths/polygon") { + setBody(request) + }.body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt new file mode 100644 index 000000000..051d14236 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt @@ -0,0 +1,74 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest +import com.neki.android.core.data.remote.model.request.RegisterPhotoRequest +import com.neki.android.core.data.remote.model.request.UpdateFavoriteRequest +import com.neki.android.core.data.remote.model.response.BasicNullableResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.FavoritePhotoResponse +import com.neki.android.core.data.remote.model.response.FavoriteSummaryResponse +import com.neki.android.core.data.remote.model.response.PhotoResponse +import com.neki.android.core.data.remote.model.response.RegisterPhotoResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import javax.inject.Inject + +class PhotoService @Inject constructor( + private val client: HttpClient, +) { + // 사진 조회 + suspend fun getPhotos( + folderId: Long? = null, + page: Int = 0, + size: Int = 20, + sortOrder: String = "DESC", + ): BasicResponse { + return client.get("/api/photos") { + parameter("folderId", folderId) + parameter("page", page) + parameter("size", size) + parameter("sortOrder", sortOrder) + }.body() + } + + // 사진 등록 + suspend fun registerPhoto(requestBody: RegisterPhotoRequest): BasicNullableResponse { + return client.post("/api/photos") { setBody(requestBody) }.body() + } + + // 사진 삭제 + suspend fun deletePhoto(requestBody: DeletePhotoRequest): BasicNullableResponse { + return client.delete("/api/photos") { setBody(requestBody) }.body() + } + + // 즐겨찾기 업데이트 + suspend fun updateFavorite(photoId: Long, favorite: Boolean): BasicNullableResponse { + return client.patch("/api/photos/$photoId/favorite") { + setBody(UpdateFavoriteRequest(favorite)) + }.body() + } + + // 즐겨찾기 사진 조회 + suspend fun getFavoritePhotos( + page: Int = 0, + size: Int = 20, + sortOrder: String, + ): BasicResponse { + return client.get("/api/photos/favorite") { + parameter("page", page) + parameter("size", size) + parameter("sortOrder", sortOrder) + }.body() + } + + // 즐겨찾기 요약 조회 + suspend fun getFavoriteSummary(): BasicResponse { + return client.get("/api/photos/favorite/summary").body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt new file mode 100644 index 000000000..2d30cb7c2 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -0,0 +1,66 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.UpdateScrapRequest +import com.neki.android.core.data.remote.model.response.BasicNullableResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.PoseDetailResponse +import com.neki.android.core.data.remote.model.response.PoseResponse +import com.neki.android.core.data.remote.model.response.ScrappedPoseResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.setBody +import javax.inject.Inject + +class PoseService @Inject constructor( + private val client: HttpClient, +) { + // 포즈 목록 조회 + suspend fun getPoses( + page: Int = 0, + size: Int = 20, + headCount: String? = null, + sortOrder: String = "DESC", + ): BasicResponse { + return client.get("/api/poses") { + parameter("page", page) + parameter("size", size) + parameter("headCount", headCount) + parameter("sortOrder", sortOrder) + }.body() + } + + // 포즈 상세 조회 + suspend fun getPose(poseId: Long): BasicResponse { + return client.get("/api/poses/$poseId").body() + } + + // 랜덤 포즈 조회 + suspend fun getRandomPose(headCount: String): BasicResponse { + return client.get("/api/poses/random") { + parameter("headCount", headCount) + }.body() + } + + // 스크랩된 포즈 목록 조회 + suspend fun getScrappedPoses( + page: Int = 0, + size: Int = 20, + sortOrder: String = "DESC", + ): BasicResponse { + return client.get("/api/poses/scrap") { + parameter("page", page) + parameter("size", size) + parameter("sortOrder", sortOrder) + }.body() + } + + // 스크랩 업데이트 + suspend fun updateScrap(poseId: Long, scrap: Boolean): BasicNullableResponse { + return client.patch("/api/poses/$poseId/scrap") { + setBody(UpdateScrapRequest(scrap)) + }.body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt new file mode 100644 index 000000000..7eeb272ce --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/UploadService.kt @@ -0,0 +1,36 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.MediaUploadTicketRequest +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.MediaUploadTicketDataResponse +import com.neki.android.core.data.remote.qualifier.UploadHttpClient +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import javax.inject.Inject + +class UploadService @Inject constructor( + private val client: HttpClient, + @UploadHttpClient private val uploadClient: HttpClient, +) { + // Media Upload Ticket 받기 + suspend fun getUploadTicket(requestBody: MediaUploadTicketRequest): BasicResponse { + return client.post("/api/media/upload") { setBody(requestBody) }.body() + } + + // PresignedUrl 에 파일 업로드 + suspend fun uploadImage( + presignedUrl: String, + imageBytes: ByteArray, + contentType: String, + ) { + uploadClient.put(presignedUrl) { + contentType(ContentType.parse(contentType)) + setBody(imageBytes) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/UserService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/UserService.kt new file mode 100644 index 000000000..b000a3194 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/UserService.kt @@ -0,0 +1,32 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.UpdateProfileImageRequest +import com.neki.android.core.data.remote.model.request.UpdateUserInfoRequest +import com.neki.android.core.data.remote.model.response.BasicNullableResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.UserInfoResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.setBody +import javax.inject.Inject + +class UserService @Inject constructor( + private val client: HttpClient, +) { + // 사용자 정보 조회 + suspend fun getUserInfo(): BasicResponse { + return client.get("/api/users/info").body() + } + + // 사용자 프로필 정보 변경(닉네임) + suspend fun updateUserInfo(request: UpdateUserInfoRequest): BasicNullableResponse { + return client.patch("/api/users/me") { setBody(request) }.body() + } + + // 사용자 프로필 이미지 변경 + suspend fun updateProfileImage(request: UpdateProfileImageRequest): BasicNullableResponse { + return client.patch("/api/users/me/profile-image") { setBody(request) }.body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt b/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt index e3fd32750..7622d34ee 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt @@ -1,54 +1,141 @@ package com.neki.android.core.data.remote.di import com.neki.android.core.common.const.Const.TAG_REST_API -import com.neki.android.core.data.remote.api.ApiService +import com.neki.android.core.data.BuildConfig +import com.neki.android.core.data.remote.model.request.RefreshTokenRequest +import com.neki.android.core.data.remote.model.response.AuthResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.qualifier.UploadHttpClient +import com.neki.android.core.dataapi.auth.AuthCacheManager +import com.neki.android.core.dataapi.auth.AuthEventManager +import com.neki.android.core.dataapi.repository.TokenRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.android.Android import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.plugin import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.first +import dagger.Lazy import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Singleton + @Module @InstallIn(SingletonComponent::class) internal object NetworkModule { - const val BASE_URL = "https://jsonplaceholder.typicode.com" + val BASE_URL = BuildConfig.BASE_URL const val TIME_OUT = 5000L + const val UPLOAD_TIME_OUT = 10_000L + + val sendWithoutAuthUrls = listOf( + "/api/auth/kakao/login", + "/api/auth/refresh", + ) + + private val json = Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + explicitNulls = false + } @Provides @Singleton - fun provideApiService( - client: HttpClient - ): ApiService = ApiService(client) + fun provideAuthCacheManager( + httpClient: Lazy, + ): AuthCacheManager = object : AuthCacheManager { + override fun invalidateTokenCache() { + httpClient.get().plugin(Auth).providers + .filterIsInstance() + .firstOrNull() + ?.clearToken() + } + } @Provides @Singleton - fun provideHttpClient(): HttpClient { + fun provideHttpClient( + tokenRepository: TokenRepository, + authEventManager: AuthEventManager, + ): HttpClient { return HttpClient(Android) { install(DefaultRequest) { url(BASE_URL) header(HttpHeaders.ContentType, ContentType.Application.Json) } + install(Auth) { + bearer { + loadTokens { + if (tokenRepository.isSavedTokens().first()) { + BearerTokens( + accessToken = tokenRepository.getAccessToken().first(), + refreshToken = tokenRepository.getRefreshToken().first(), + ) + } else null + } + + refreshTokens { + if (oldTokens != null) { + return@refreshTokens try { + val response = client.post("/api/auth/refresh") { + setBody( + RefreshTokenRequest( + refreshToken = tokenRepository.getRefreshToken().first(), + ), + ) + }.body>() + + tokenRepository.saveTokens( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + ) + + BearerTokens( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + ) + } catch (e: Exception) { + Timber.e(e) + tokenRepository.clearTokens() + authEventManager.emitTokenExpired() + null + } + } else null + } + + sendWithoutRequest { request -> + val shouldNotAuth = sendWithoutAuthUrls.any { + request.url.encodedPath == it + } + !shouldNotAuth + } + } + } + install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) + json(json) } install(Logging) { @@ -70,6 +157,31 @@ internal object NetworkModule { } } + @UploadHttpClient + @Provides + @Singleton + fun provideUploadHttpClient(): HttpClient { + return HttpClient(Android) { + install(ContentNegotiation) { + json(json) + } -} + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Timber.tag(TAG_REST_API).d(message) + } + } + level = LogLevel.HEADERS + } + + install(HttpTimeout) { + connectTimeoutMillis = UPLOAD_TIME_OUT + requestTimeoutMillis = UPLOAD_TIME_OUT + } + + expectSuccess = true + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.kt new file mode 100644 index 000000000..337387fe3 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/CreateFolderRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateFolderRequest( + @SerialName("name") val name: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.kt new file mode 100644 index 000000000..7ccc0c03b --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeleteFolderRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DeleteFolderRequest( + @SerialName("folderIds") val folderIds: List, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeletePhotoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeletePhotoRequest.kt new file mode 100644 index 000000000..b2cefa21c --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/DeletePhotoRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DeletePhotoRequest( + @SerialName("photoIds") val photoIds: List, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/KakaoLoginRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/KakaoLoginRequest.kt new file mode 100644 index 000000000..baa8e9b7d --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/KakaoLoginRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class KakaoLoginRequest( + @SerialName("idToken") val idToken: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt new file mode 100644 index 000000000..0bdda69e3 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/MediaUploadTicketRequest.kt @@ -0,0 +1,16 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MediaUploadTicketRequest( + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("contentType") val contentType: String, + @SerialName("filename") val filename: String = "", + @SerialName("mediaType") val mediaType: String, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/PhotoBoothPointRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/PhotoBoothPointRequest.kt new file mode 100644 index 000000000..157439347 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/PhotoBoothPointRequest.kt @@ -0,0 +1,12 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoBoothPointRequest( + @SerialName("longitude") val longitude: Double?, + @SerialName("latitude") val latitude: Double?, + @SerialName("radiusInMeters") val radiusInMeters: Int, + @SerialName("brandIds") val brandIds: List, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/PhotoBoothPolygonRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/PhotoBoothPolygonRequest.kt new file mode 100644 index 000000000..7d17c9f2a --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/PhotoBoothPolygonRequest.kt @@ -0,0 +1,16 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoBoothPolygonRequest( + @SerialName("coordinates") val coordinates: List, + @SerialName("brandIds") val brandIds: List, +) + +@Serializable +data class Coordinate( + @SerialName("longitude") val longitude: Double, + @SerialName("latitude") val latitude: Double, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RefreshTokenRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RefreshTokenRequest.kt new file mode 100644 index 000000000..e7b9f37ee --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RefreshTokenRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshTokenRequest( + @SerialName("refreshToken") val refreshToken: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt new file mode 100644 index 000000000..608ab9920 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt @@ -0,0 +1,16 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterPhotoRequest( + @SerialName("folderId") val folderId: Long? = null, + @SerialName("uploads") val uploads: List, +) { + @Serializable + data class Upload( + @SerialName("mediaId") val mediaId: Long, + @SerialName("memo") val memo: String? = null, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateFavoriteRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateFavoriteRequest.kt new file mode 100644 index 000000000..56b456cf6 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateFavoriteRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateFavoriteRequest( + @SerialName("favorite") val favorite: Boolean, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateProfileImageRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateProfileImageRequest.kt new file mode 100644 index 000000000..fc9cbc8d4 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateProfileImageRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateProfileImageRequest( + @SerialName("mediaId") val mediaId: Long?, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt new file mode 100644 index 000000000..6093883cf --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateScrapRequest( + @SerialName("scrap") val scrap: Boolean, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateUserInfoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateUserInfoRequest.kt new file mode 100644 index 000000000..dffb682fc --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateUserInfoRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateUserInfoRequest( + @SerialName("name") val nickname: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/AuthResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/AuthResponse.kt new file mode 100644 index 000000000..cb412b8e9 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/AuthResponse.kt @@ -0,0 +1,16 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.Auth +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthResponse( + @SerialName("accessToken") val accessToken: String, + @SerialName("refreshToken") val refreshToken: String, +) { + fun toModel() = Auth( + accessToken = this.accessToken, + refreshToken = this.refreshToken, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/BasicResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/BasicResponse.kt new file mode 100644 index 000000000..9c0f79acb --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/BasicResponse.kt @@ -0,0 +1,17 @@ +package com.neki.android.core.data.remote.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class BasicResponse( + val resultCode: String, + val message: String, + val data: T, +) + +@Serializable +data class BasicNullableResponse( + val resultCode: String, + val message: String, + val data: T?, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/BrandResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/BrandResponse.kt new file mode 100644 index 000000000..651b2241a --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/BrandResponse.kt @@ -0,0 +1,22 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.Brand +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BrandResponse( + @SerialName("id") val id: Long, + @SerialName("name") val name: String, + @SerialName("code") val code: String, + @SerialName("imageUrl") val imageUrl: String, +) { + internal fun toModel(): Brand = Brand( + id = id, + name = name, + code = code, + imageUrl = imageUrl, + ) +} + +internal fun List.toModels(): List = map { it.toModel() } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt new file mode 100644 index 000000000..e8a5d88e8 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt @@ -0,0 +1,10 @@ +package com.neki.android.core.data.remote.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// TODO: 추후 API 스펙 업데이트 시 제거 +@Serializable +data class CreateFolderResponse( + @SerialName("folderId") val folderId: Long, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt new file mode 100644 index 000000000..ba4ebf2f0 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt @@ -0,0 +1,31 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.common.util.toFormattedDate +import com.neki.android.core.model.Photo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FavoritePhotoResponse( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + @SerialName("favorite") val favorite: Boolean, + @SerialName("folderId") val folderId: Long?, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("photoId") val photoId: Long, + ) { + internal fun toModel() = Photo( + id = photoId, + imageUrl = imageUrl, + isFavorite = favorite, + date = createdAt.toFormattedDate(), + ) + } + + fun toModels() = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt new file mode 100644 index 000000000..66aa27c60 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt @@ -0,0 +1,18 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.AlbumPreview +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FavoriteSummaryResponse( + @SerialName("latestImageUrl") val latestImageUrl: String?, + @SerialName("totalCount") val totalCount: Int, +) { + fun toModel() = AlbumPreview( + id = -1L, + title = "즐겨찾는사진", + thumbnailUrl = latestImageUrl, + photoCount = totalCount, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt new file mode 100644 index 000000000..9cdc89e28 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/FolderResponse.kt @@ -0,0 +1,27 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.AlbumPreview +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FolderResponse( + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("folderId") val folderId: Long, + @SerialName("name") val name: String, + @SerialName("latestImageUrl") val latestImageUrl: String?, + @SerialName("totalCount") val totalCount: Int, + ) { + internal fun toModel() = AlbumPreview( + id = folderId, + title = name, + thumbnailUrl = latestImageUrl, + photoCount = totalCount, + ) + } + + fun toModels() = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt new file mode 100644 index 000000000..f9315c50d --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/MediaUploadTicketResponse.kt @@ -0,0 +1,26 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.MediaUploadTicket +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MediaUploadTicketDataResponse( + @SerialName("method") val method: String, + @SerialName("expiresIn") val expiresIn: String, + @SerialName("items") val items: List, +) { + fun toModels() = items.map { it.toModel() } + + @Serializable + data class MediaUploadTicketItemResponse( + @SerialName("mediaId") val mediaId: Long, + @SerialName("uploadTicket") val uploadTicket: String, + @SerialName("contentType") val contentType: String, + ) { + fun toModel() = MediaUploadTicket( + mediaId = mediaId, + uploadUrl = uploadTicket, + ) + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoBoothPointResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoBoothPointResponse.kt new file mode 100644 index 000000000..0a970df0e --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoBoothPointResponse.kt @@ -0,0 +1,33 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.PhotoBooth +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoBoothPointResponse( + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("id") val id: Long, + @SerialName("brandName") val brandName: String, + @SerialName("branchName") val branchName: String, + @SerialName("address") val address: String, + @SerialName("longitude") val longitude: Double, + @SerialName("latitude") val latitude: Double, + @SerialName("distance") val distance: Int, + ) { + internal fun toModel(): PhotoBooth = PhotoBooth( + id = id, + brandName = brandName, + branchName = branchName, + address = address, + longitude = longitude, + latitude = latitude, + distance = distance, + ) + } + + internal fun toModels(): List = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoBoothPolygonResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoBoothPolygonResponse.kt new file mode 100644 index 000000000..8faad3822 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoBoothPolygonResponse.kt @@ -0,0 +1,31 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.PhotoBooth +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoBoothPolygonResponse( + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("id") val id: Long, + @SerialName("brandName") val brandName: String, + @SerialName("branchName") val branchName: String, + @SerialName("address") val address: String, + @SerialName("longitude") val longitude: Double, + @SerialName("latitude") val latitude: Double, + ) { + internal fun toModel(): PhotoBooth = PhotoBooth( + id = id, + brandName = brandName, + branchName = branchName, + address = address, + longitude = longitude, + latitude = latitude, + ) + } + + internal fun toModels(): List = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt new file mode 100644 index 000000000..a5de87fc7 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt @@ -0,0 +1,31 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.common.util.toFormattedDate +import com.neki.android.core.model.Photo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoResponse( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + @SerialName("favorite") val isFavorite: Boolean, + @SerialName("folderId") val folderId: Long?, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("photoId") val photoId: Long, + ) { + internal fun toModel() = Photo( + id = photoId, + imageUrl = imageUrl, + isFavorite = isFavorite, + date = createdAt.toFormattedDate(), + ) + } + + fun toModels() = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt new file mode 100644 index 000000000..2cde355d0 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt @@ -0,0 +1,23 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PoseDetailResponse( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("scrap") val scrap: Boolean, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, +) { + internal fun toModel() = Pose( + id = poseId, + isScrapped = scrap, + poseImageUrl = imageUrl, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt new file mode 100644 index 000000000..9e6cac3ef --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt @@ -0,0 +1,54 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PoseResponse( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + ) { + internal fun toModel() = Pose( + id = poseId, + poseImageUrl = imageUrl, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + isScrapped = false, + ) + } + + fun toModels() = items.map { it.toModel() } +} + +@Serializable +data class ScrappedPoseResponse( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + ) { + internal fun toModel() = Pose( + id = poseId, + poseImageUrl = imageUrl, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + isScrapped = true, + ) + } + + fun toModels() = items.map { it.toModel() } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PostResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PostResponse.kt deleted file mode 100644 index 53b8977e4..000000000 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PostResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.neki.android.core.data.remote.model.response - -import com.neki.android.core.model.Post -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class PostResponse( - @SerialName("userId") val userId: Int, - @SerialName("id") val id: Int, - @SerialName("title") val title: String, - @SerialName("body") val body: String -) { - fun toModel(): Post { - return Post( - userId = this.userId, - id = this.id, - title = this.title, - body = this.body - ) - } -} \ No newline at end of file diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/RegisterPhotoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/RegisterPhotoResponse.kt new file mode 100644 index 000000000..9f945c95d --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/RegisterPhotoResponse.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterPhotoResponse( + @SerialName("photoId") val photoId: Long, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/UserInfoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/UserInfoResponse.kt new file mode 100644 index 000000000..9b5f1d207 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/UserInfoResponse.kt @@ -0,0 +1,21 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.UserInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfoResponse( + @SerialName("userId") val userId: Long, + @SerialName("name") val name: String, + @SerialName("email") val email: String, + @SerialName("profileImageUrl") val profileImageUrl: String, + @SerialName("providerType") val providerType: String, +) { + fun toModel() = UserInfo( + id = userId, + nickname = name, + profileImageUrl = profileImageUrl, + loginType = providerType, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/qualifier/UploadHttpClient.kt b/core/data/src/main/java/com/neki/android/core/data/remote/qualifier/UploadHttpClient.kt new file mode 100644 index 000000000..58d0d90d1 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/qualifier/UploadHttpClient.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.data.remote.qualifier + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION) +annotation class UploadHttpClient diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index a6c88f9da..76b47b01a 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -1,20 +1,92 @@ package com.neki.android.core.data.repository.di -import com.neki.android.core.data.repository.impl.SampleRepositoryImpl -import com.neki.android.core.dataapi.repository.SampleRepository +import com.neki.android.core.data.auth.AuthEventManagerImpl +import com.neki.android.core.data.repository.impl.AuthRepositoryImpl +import com.neki.android.core.data.repository.impl.DataStoreRepositoryImpl +import com.neki.android.core.data.repository.impl.MediaUploadRepositoryImpl +import com.neki.android.core.data.repository.impl.FolderRepositoryImpl +import com.neki.android.core.data.repository.impl.MapRepositoryImpl +import com.neki.android.core.data.repository.impl.PhotoRepositoryImpl +import com.neki.android.core.data.repository.impl.PoseRepositoryImpl +import com.neki.android.core.data.repository.impl.TokenRepositoryImpl +import com.neki.android.core.data.repository.impl.UserRepositoryImpl +import com.neki.android.core.dataapi.auth.AuthEventManager +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.AuthRepository +import com.neki.android.core.dataapi.repository.DataStoreRepository +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.dataapi.repository.MapRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.dataapi.repository.TokenRepository +import com.neki.android.core.dataapi.repository.UserRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal abstract class RepositoryModule { +internal interface RepositoryModule { @Binds - abstract fun bindSampleRepositoryImpl( - sampleRepositoryImpl: SampleRepositoryImpl - ): SampleRepository + @Singleton + fun bindDataStoreRepositoryImpl( + dataStoreRepositoryImpl: DataStoreRepositoryImpl, + ): DataStoreRepository + @Binds + @Singleton + fun bindAuthRepositoryImpl( + authRepositoryImpl: AuthRepositoryImpl, + ): AuthRepository + + @Binds + @Singleton + fun bindUserRepositoryImpl( + userRepositoryImpl: UserRepositoryImpl, + ): UserRepository + + @Binds + @Singleton + fun bindTokenRepositoryImpl( + tokenRepositoryImpl: TokenRepositoryImpl, + ): TokenRepository + + @Binds + @Singleton + fun bindAuthEventManagerImpl( + authEventManagerImpl: AuthEventManagerImpl, + ): AuthEventManager + + @Binds + @Singleton + fun bindMediaUploadRepositoryImpl( + mediaUploadRepositoryImpl: MediaUploadRepositoryImpl, + ): MediaUploadRepository + + @Binds + @Singleton + fun bindPhotoRepositoryImpl( + photoRepositoryImpl: PhotoRepositoryImpl, + ): PhotoRepository + + @Binds + @Singleton + fun bindFolderRepositoryImpl( + folderRepositoryImpl: FolderRepositoryImpl, + ): FolderRepository + + @Binds + @Singleton + fun bindMapRepositoryImpl( + mapRepositoryImpl: MapRepositoryImpl, + ): MapRepository -} \ No newline at end of file + @Binds + @Singleton + fun bindPoseRepositoryImpl( + poseRepositoryImpl: PoseRepositoryImpl, + ): PoseRepository +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt new file mode 100644 index 000000000..efe52e23a --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.AuthService +import com.neki.android.core.data.remote.model.request.KakaoLoginRequest +import com.neki.android.core.data.remote.model.request.RefreshTokenRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.AuthRepository +import com.neki.android.core.model.Auth +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val authService: AuthService, +) : AuthRepository { + override suspend fun loginWithKakao(idToken: String): Result = runSuspendCatching { + authService.loginWithKakao( + requestBody = KakaoLoginRequest( + idToken = idToken, + ), + ).data.toModel() + } + + override suspend fun updateAccessToken(refreshToken: String): Result = runSuspendCatching { + authService.updateAccessToken( + requestBody = RefreshTokenRequest( + refreshToken = refreshToken, + ), + ).data.toModel() + } + + override suspend fun withdrawAccount(): Result = runSuspendCatching { + authService.withdrawAccount() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt new file mode 100644 index 000000000..64bd4ca1c --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt @@ -0,0 +1,27 @@ +package com.neki.android.core.data.repository.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.neki.android.core.dataapi.repository.DataStoreRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import com.neki.android.core.data.local.di.AuthDataStore +import javax.inject.Inject + +class DataStoreRepositoryImpl @Inject constructor( + @AuthDataStore private val dataStore: DataStore, +) : DataStoreRepository { + + override suspend fun setBoolean(key: Preferences.Key, value: Boolean) { + dataStore.edit { preferences -> + preferences[key] = value + } + } + + override fun getBoolean(key: Preferences.Key): Flow { + return dataStore.data.map { preferences -> + preferences[key] ?: false + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt new file mode 100644 index 000000000..7404f274e --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt @@ -0,0 +1,38 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.FolderService +import com.neki.android.core.data.remote.model.request.CreateFolderRequest +import com.neki.android.core.data.remote.model.request.DeleteFolderRequest +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.model.AlbumPreview +import javax.inject.Inject + +class FolderRepositoryImpl @Inject constructor( + private val folderService: FolderService, +) : FolderRepository { + override suspend fun getFolders(): Result> = runSuspendCatching { + folderService.getFolders().data.toModels() + } + + override suspend fun createFolder(name: String): Result = runSuspendCatching { + folderService.createFolder( + requestBody = CreateFolderRequest(name = name), + ).data + } + + override suspend fun deleteFolder(id: List, deletePhotos: Boolean): Result = runSuspendCatching { + folderService.deleteFolder( + requestBody = DeleteFolderRequest(folderIds = id), + deletePhotos = deletePhotos, + ).data + } + + override suspend fun removePhotosFromFolder(folderId: Long, photoIds: List): Result = runSuspendCatching { + folderService.removePhotosFromFolder( + folderId = folderId, + requestBody = DeletePhotoRequest(photoIds = photoIds), + ).data + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MapRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MapRepositoryImpl.kt new file mode 100644 index 000000000..ab52c9df7 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MapRepositoryImpl.kt @@ -0,0 +1,50 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.MapService +import com.neki.android.core.data.remote.model.request.Coordinate +import com.neki.android.core.data.remote.model.request.PhotoBoothPointRequest +import com.neki.android.core.data.remote.model.request.PhotoBoothPolygonRequest +import com.neki.android.core.data.remote.model.response.toModels +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MapRepository +import com.neki.android.core.model.Brand +import com.neki.android.core.model.PhotoBooth +import javax.inject.Inject + +class MapRepositoryImpl @Inject constructor( + private val mapService: MapService, +) : MapRepository { + override suspend fun getBrands(): Result> = runSuspendCatching { + mapService.getBrands().data.toModels() + } + + override suspend fun getPhotoBoothsByPoint( + longitude: Double?, + latitude: Double?, + radiusInMeters: Int, + brandIds: List, + ): Result> = runSuspendCatching { + mapService.getPhotoBoothsByPoint( + request = PhotoBoothPointRequest( + longitude = longitude, + latitude = latitude, + radiusInMeters = radiusInMeters, + brandIds = brandIds, + ), + ).data.toModels() + } + + override suspend fun getPhotoBoothsByPolygon( + coordinates: List>, + brandIds: List, + ): Result> = runSuspendCatching { + mapService.getPhotoBoothsByPolygon( + request = PhotoBoothPolygonRequest( + coordinates = coordinates.map { (longitude, latitude) -> + Coordinate(longitude = longitude, latitude = latitude) + }, + brandIds = brandIds, + ), + ).data.toModels() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt new file mode 100644 index 000000000..1a79c9889 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt @@ -0,0 +1,98 @@ +package com.neki.android.core.data.repository.impl + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import com.neki.android.core.common.util.toByteArray +import com.neki.android.core.common.util.urlToByteArray +import com.neki.android.core.data.remote.api.UploadService +import com.neki.android.core.data.remote.model.request.MediaUploadTicketRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.model.ContentType +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class MediaUploadRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val uploadService: UploadService, +) : MediaUploadRepository { + override suspend fun getSingleUploadTicket( + fileName: String, + contentType: String, + mediaType: String, + ) = runSuspendCatching { + uploadService.getUploadTicket( + requestBody = MediaUploadTicketRequest( + items = listOf( + MediaUploadTicketRequest.Item( + filename = fileName, + contentType = contentType, + mediaType = mediaType, + ), + ), + ), + ).data.toModels().first() + } + + override suspend fun getMultipleUploadTicket( + uploadCount: Int, + fileName: String, + contentType: String, + mediaType: String, + ) = runSuspendCatching { + uploadService.getUploadTicket( + requestBody = MediaUploadTicketRequest( + items = List(uploadCount) { + MediaUploadTicketRequest.Item( + filename = fileName, + contentType = contentType, + mediaType = mediaType, + ) + }, + ), + ).data.toModels() + } + + override suspend fun uploadImageFromUri( + uploadUrl: String, + uri: Uri, + contentType: ContentType, + ) = runSuspendCatching { + val imageBytes = withContext(Dispatchers.Default) { + uri.toByteArray( + context = context, + format = contentType.toCompressFormat(), + ) ?: error("Failed to convert uri to byte array") + } + + uploadService.uploadImage( + presignedUrl = uploadUrl, + imageBytes = imageBytes, + contentType = contentType.label, + ) + } + + override suspend fun uploadImageFromUrl( + uploadUrl: String, + imageUrl: String, + contentType: ContentType, + ) = runSuspendCatching { + val imageBytes = imageUrl.urlToByteArray( + format = contentType.toCompressFormat(), + ) + + uploadService.uploadImage( + presignedUrl = uploadUrl, + imageBytes = imageBytes, + contentType = contentType.label, + ) + } + + private fun ContentType.toCompressFormat(): Bitmap.CompressFormat = when (this) { + ContentType.JPEG -> Bitmap.CompressFormat.JPEG + ContentType.PNG -> Bitmap.CompressFormat.PNG + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt new file mode 100644 index 000000000..4f45f07b1 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt @@ -0,0 +1,100 @@ +package com.neki.android.core.data.repository.impl + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.neki.android.core.data.paging.FavoritePhotoPagingSource +import com.neki.android.core.data.paging.PhotoPagingSource +import com.neki.android.core.data.remote.api.PhotoService +import com.neki.android.core.data.remote.model.request.DeletePhotoRequest +import com.neki.android.core.data.remote.model.request.RegisterPhotoRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +private const val PAGE_SIZE = 20 +private const val PREFETCH_DISTANCE = 10 + +class PhotoRepositoryImpl @Inject constructor( + private val photoService: PhotoService, +) : PhotoRepository { + override suspend fun getPhotos( + folderId: Long?, + page: Int, + size: Int, + ): Result> = runSuspendCatching { + photoService.getPhotos( + folderId = folderId, + page = page, + size = size, + ).data.toModels() + } + + override suspend fun registerPhoto( + mediaIds: List, + folderId: Long?, + ): Result = runSuspendCatching { + photoService.registerPhoto( + requestBody = RegisterPhotoRequest( + folderId = folderId, + uploads = mediaIds.map { RegisterPhotoRequest.Upload(mediaId = it) }, + ), + ).data + } + + override suspend fun deletePhoto(photoId: Long): Result = runSuspendCatching { + photoService.deletePhoto( + requestBody = DeletePhotoRequest(photoIds = listOf(photoId)), + ).data + } + + override suspend fun deletePhoto(photoIds: List): Result = runSuspendCatching { + photoService.deletePhoto( + requestBody = DeletePhotoRequest(photoIds = photoIds), + ).data + } + + override suspend fun updateFavorite(photoId: Long, favorite: Boolean): Result = runSuspendCatching { + photoService.updateFavorite(photoId, favorite).data + } + + override suspend fun getFavoritePhotos( + page: Int, + size: Int, + sortOrder: SortOrder, + ): Result> = runSuspendCatching { + photoService.getFavoritePhotos(page, size, sortOrder.name).data.toModels() + } + + override suspend fun getFavoriteSummary(): Result = runSuspendCatching { + photoService.getFavoriteSummary().data.toModel() + } + + override fun getPhotosFlow(folderId: Long?, sortOrder: SortOrder): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { PhotoPagingSource(photoService, folderId, sortOrder.name) }, + ).flow + } + + override fun getFavoritePhotosFlow(sortOrder: SortOrder): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { FavoritePhotoPagingSource(photoService, sortOrder) }, + ).flow + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt new file mode 100644 index 000000000..53084699f --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -0,0 +1,109 @@ +package com.neki.android.core.data.repository.impl + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.neki.android.core.common.exception.RandomPoseRetryExhaustedException +import com.neki.android.core.data.paging.PosePagingSource +import com.neki.android.core.data.paging.ScrapPosePagingSource +import com.neki.android.core.data.remote.api.PoseService +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +private const val PAGE_SIZE = 20 +private const val PREFETCH_DISTANCE = 10 + +class PoseRepositoryImpl @Inject constructor( + private val poseService: PoseService, +) : PoseRepository { + + override fun getPosesFlow( + headCount: PeopleCount?, + sortOrder: SortOrder, + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + PosePagingSource( + poseService = poseService, + headCount = headCount, + sortOrder = sortOrder, + ) + }, + ).flow + } + + override fun getScrappedPosesFlow( + sortOrder: SortOrder, + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + ScrapPosePagingSource( + poseService = poseService, + sortOrder = sortOrder, + ) + }, + ).flow + } + + override suspend fun getPose(poseId: Long): Result = runSuspendCatching { + poseService.getPose(poseId).data.toModel() + } + + override suspend fun getSingleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + maxRetry: Int, + ): Result = runSuspendCatching { + repeat(maxRetry) { + val pose = poseService.getRandomPose(headCount = headCount.name).data.toModel() + if (pose.id !in excludeIds) { + return@runSuspendCatching pose + } + } + throw RandomPoseRetryExhaustedException("새로운 포즈를 찾지 못했어요") + } + + override suspend fun getMultipleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + poseSize: Int, + maxRetry: Int, + ): Result> = runSuspendCatching { + val result = mutableListOf() + val collectedIds = excludeIds.toMutableSet() + var retryCount = 0 + + while (result.size < poseSize && retryCount < maxRetry) { + val pose = poseService.getRandomPose(headCount = headCount.name).data.toModel() + if (pose.id !in collectedIds) { + result.add(pose) + collectedIds.add(pose.id) + } else { + retryCount++ + } + } + + result.ifEmpty { throw RandomPoseRetryExhaustedException("새로운 포즈를 찾지 못했어요") } + } + + override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result = runSuspendCatching { + poseService.updateScrap(poseId, scrap) + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/SampleRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/SampleRepositoryImpl.kt deleted file mode 100644 index 9e257fb62..000000000 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/SampleRepositoryImpl.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.neki.android.core.data.repository.impl - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import com.neki.android.core.data.remote.api.ApiService -import com.neki.android.core.dataapi.repository.SampleRepository -import com.neki.android.core.model.Post -import javax.inject.Inject - -class SampleRepositoryImpl @Inject constructor( - private val apiService: ApiService, - private val dataStore: DataStore -): SampleRepository { - override suspend fun getPosts(): List { - return apiService.getPosts() - .map { it.toModel() } - } - - override suspend fun getPost( - id: Int - ): Post { - return apiService.getPost(id = id) - .toModel() - } - - -} \ No newline at end of file diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt new file mode 100644 index 000000000..0c0d390ec --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt @@ -0,0 +1,58 @@ +package com.neki.android.core.data.repository.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.neki.android.core.common.crypto.CryptoManager +import com.neki.android.core.dataapi.datastore.DataStoreKey +import com.neki.android.core.dataapi.auth.AuthCacheManager +import com.neki.android.core.dataapi.repository.TokenRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import com.neki.android.core.data.local.di.TokenDataStore +import javax.inject.Inject + +class TokenRepositoryImpl @Inject constructor( + @TokenDataStore private val dataStore: DataStore, + private val authCacheManager: AuthCacheManager, +) : TokenRepository { + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + dataStore.edit { preferences -> + preferences[DataStoreKey.ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) + preferences[DataStoreKey.REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) + } + authCacheManager.invalidateTokenCache() + } + + override fun isSavedTokens(): Flow { + return dataStore.data.map { preferences -> + val accessToken = preferences[DataStoreKey.ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } + val refreshToken = preferences[DataStoreKey.REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } + + !accessToken.isNullOrBlank() && !refreshToken.isNullOrBlank() + } + } + + override fun getAccessToken(): Flow { + return dataStore.data.map { preferences -> + preferences[DataStoreKey.ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" + } + } + + override fun getRefreshToken(): Flow { + return dataStore.data.map { preferences -> + preferences[DataStoreKey.REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" + } + } + + override suspend fun clearTokens() { + dataStore.edit { preferences -> + preferences.remove(DataStoreKey.ACCESS_TOKEN) + preferences.remove(DataStoreKey.REFRESH_TOKEN) + } + authCacheManager.invalidateTokenCache() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt new file mode 100644 index 000000000..c708f61e3 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.UserService +import com.neki.android.core.data.remote.model.request.UpdateProfileImageRequest +import com.neki.android.core.data.remote.model.request.UpdateUserInfoRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.UserRepository +import com.neki.android.core.model.UserInfo +import javax.inject.Inject + +class UserRepositoryImpl @Inject constructor( + private val userService: UserService, +) : UserRepository { + override suspend fun getUserInfo(): Result = runSuspendCatching { + userService.getUserInfo().data.toModel() + } + + override suspend fun updateUserInfo(nickname: String): Result = runSuspendCatching { + userService.updateUserInfo(UpdateUserInfoRequest(nickname)) + } + + override suspend fun updateProfileImage(mediaId: Long?): Result = runSuspendCatching { + userService.updateProfileImage(UpdateProfileImageRequest(mediaId)) + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/util/RunSuspendCatching.kt b/core/data/src/main/java/com/neki/android/core/data/util/RunSuspendCatching.kt new file mode 100644 index 000000000..c795a6741 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/util/RunSuspendCatching.kt @@ -0,0 +1,20 @@ +package com.neki.android.core.data.util + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalContracts::class) +inline fun runSuspendCatching(block: () -> T): Result { + // Kotlin 의 contract(계약) 시스템을 이용해 block 이 정확히 한번만 호출 되어야 함을 나타냄 + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + + return runCatching(block).also { result -> + // 만약 람다에서 예외가 발생하면, Result 객체는 실패를 나타내고 해당 예외를 포함, 추가적인 작업을 실행 + val maybeException = result.exceptionOrNull() + // 만약 예외가 CancellationException 이면 예외를 던져 코루틴 계층 구조에 따라 상위 코루틴까지 취소 신호를 전파 + // 이를 통해, 상위 코루틴에서 적절한 예외 처리 루틴을 수행할 수 있음 + if (maybeException is CancellationException) throw maybeException + } +} diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 2d0e6dc20..cea0182b9 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -5,4 +5,10 @@ plugins { android { namespace = "com.neki.android.core.designsystem" -} \ No newline at end of file +} + +dependencies { + implementation(libs.androidx.core.ktx) + api(libs.haze) + api(libs.haze.materials) +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ComponentPreview.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ComponentPreview.kt new file mode 100644 index 000000000..ea86f9278 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ComponentPreview.kt @@ -0,0 +1,12 @@ +package com.neki.android.core.designsystem + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "Default", + showBackground = true, + backgroundColor = 0xFFFFFFFF, + uiMode = UI_MODE_NIGHT_NO, +) +annotation class ComponentPreview diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/DevicePreview.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/DevicePreview.kt new file mode 100644 index 000000000..285c64df7 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/DevicePreview.kt @@ -0,0 +1,13 @@ +package com.neki.android.core.designsystem + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "Default", + showBackground = true, + backgroundColor = 0xFFFFFFFF, + uiMode = UI_MODE_NIGHT_NO, + device = "spec:width=375dp,height=812dp,dpi=480", +) +annotation class DevicePreview diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/bottomsheet/BottomSheetDragHandle.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/bottomsheet/BottomSheetDragHandle.kt new file mode 100644 index 000000000..59b153c04 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/bottomsheet/BottomSheetDragHandle.kt @@ -0,0 +1,47 @@ +package com.neki.android.core.designsystem.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun BottomSheetDragHandle( + modifier: Modifier = Modifier.Companion, + width: Dp = 45.dp, + height: Dp = 4.dp, + color: Color = NekiTheme.colorScheme.gray300, +) { + Box( + modifier = modifier.fillMaxWidth(), + ) { + Box( + modifier = Modifier.Companion + .padding(vertical = 10.dp) + .size(width = width, height = height) + .background( + color = color, + shape = RoundedCornerShape(13.dp), + ) + .align(Alignment.Companion.Center), + ) + } +} + +@ComponentPreview +@Composable +private fun BottomSheetDragHandlePreview() { + NekiTheme { + BottomSheetDragHandle() + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/bottomsheet/NekiTextFieldBottomSheet.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/bottomsheet/NekiTextFieldBottomSheet.kt new file mode 100644 index 000000000..a57a89977 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/bottomsheet/NekiTextFieldBottomSheet.kt @@ -0,0 +1,279 @@ +package com.neki.android.core.designsystem.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.button.CTAButtonGray +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NekiTextFieldBottomSheet( + title: String, + subtitle: String, + textFieldState: TextFieldState, + onDismissRequest: () -> Unit, + onClickCancel: () -> Unit, + onClickConfirm: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), + placeholder: String = "", + maxLength: Int? = null, + confirmButtonText: String = "추가하기", + isError: Boolean = false, + errorMessage: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + containerColor = NekiTheme.colorScheme.white, + dragHandle = { BottomSheetDragHandle(color = NekiTheme.colorScheme.gray100) }, + ) { + NekiTextFieldBottomSheetContent( + title = title, + subtitle = subtitle, + textFieldState = textFieldState, + onClickCancel = onClickCancel, + onClickConfirm = onClickConfirm, + placeholder = placeholder, + maxLength = maxLength, + confirmButtonText = confirmButtonText, + isError = isError, + errorMessage = errorMessage, + keyboardOptions = keyboardOptions, + ) + } +} + +@Composable +private fun NekiTextFieldBottomSheetContent( + title: String, + subtitle: String, + textFieldState: TextFieldState, + onClickCancel: () -> Unit, + onClickConfirm: () -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + maxLength: Int? = null, + confirmButtonText: String = "추가하기", + isError: Boolean = false, + errorMessage: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 34.dp), + ) { + // Title & Subtitle + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + Text( + text = subtitle, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray700, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + // TextField + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + NekiBottomSheetTextField( + textFieldState = textFieldState, + placeholder = placeholder, + maxLength = maxLength, + isError = isError, + keyboardOptions = keyboardOptions, + ) + + // Error message + Text( + modifier = Modifier.heightIn(min = 16.dp), + text = if (isError) errorMessage.orEmpty() else "", + style = NekiTheme.typography.caption12Regular, + color = NekiTheme.colorScheme.primary600, + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CTAButtonGray( + modifier = Modifier.weight(93f), + text = "취소", + onClick = onClickCancel, + ) + CTAButtonPrimary( + modifier = Modifier.weight(230f), + text = confirmButtonText, + onClick = onClickConfirm, + enabled = textFieldState.text.isNotEmpty() && !isError, + ) + } + } +} + +@Composable +private fun NekiBottomSheetTextField( + textFieldState: TextFieldState, + modifier: Modifier = Modifier, + placeholder: String = "", + maxLength: Int? = null, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val borderColor = when { + isError -> NekiTheme.colorScheme.primary600 + isFocused -> NekiTheme.colorScheme.gray700 + else -> NekiTheme.colorScheme.gray75 + } + + BasicTextField( + state = textFieldState, + modifier = modifier + .fillMaxWidth() + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(8.dp), + ) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + textStyle = NekiTheme.typography.body16Medium.copy( + color = NekiTheme.colorScheme.gray900, + ), + inputTransformation = maxLength?.let { InputTransformation.maxLength(it) }, + interactionSource = interactionSource, + cursorBrush = SolidColor(NekiTheme.colorScheme.gray800), + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = keyboardOptions, + decorator = { innerTextField -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (textFieldState.text.isEmpty()) { + Text( + text = placeholder, + style = NekiTheme.typography.body16Regular, + color = NekiTheme.colorScheme.gray300, + ) + } + innerTextField() + } + maxLength?.let { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${textFieldState.text.length}/$maxLength", + style = NekiTheme.typography.caption12Regular, + color = NekiTheme.colorScheme.gray300, + ) + } + } + }, + ) +} + +@ComponentPreview +@Composable +private fun NekiTextFieldBottomSheetContentDefaultPreview() { + NekiTheme { + NekiTextFieldBottomSheetContent( + title = "텍스트", + subtitle = "보조 텍스트가 들어가는 자리입니다", + textFieldState = rememberTextFieldState(), + onClickCancel = {}, + onClickConfirm = {}, + placeholder = "플레이스 홀더에 들어갈 문구", + ) + } +} + +@ComponentPreview +@Composable +private fun NekiTextFieldBottomSheetContentCompletedPreview() { + NekiTheme { + NekiTextFieldBottomSheetContent( + title = "텍스트", + subtitle = "보조 텍스트가 들어가는 자리입니다", + textFieldState = rememberTextFieldState(initialText = "입력 완료 입력 완료 입력 완료 입력 완료 입력 완료 입력 완료 "), + onClickCancel = {}, + onClickConfirm = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun NekiTextFieldBottomSheetContentErrorPreview() { + NekiTheme { + NekiTextFieldBottomSheetContent( + title = "텍스트", + subtitle = "보조 텍스트가 들어가는 자리입니다", + textFieldState = rememberTextFieldState(initialText = "오류인 상태 텍스트입니다"), + onClickCancel = {}, + onClickConfirm = {}, + maxLength = 16, + isError = true, + errorMessage = "에러 메세지 텍스트", + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/CTAButton.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/CTAButton.kt new file mode 100644 index 000000000..8d3dbbdc3 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/CTAButton.kt @@ -0,0 +1,123 @@ +package com.neki.android.core.designsystem.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun CTAButton( + text: String, + onClick: () -> Unit, + containerColor: Color, + contentColor: Color, + disabledContainerColor: Color, + disabledContentColor: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + NekiButton( + modifier = modifier, + onClick = onClick, + shape = RoundedCornerShape(12.dp), + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + enabled = enabled, + contentPadding = PaddingValues(vertical = 14.dp), + ) { + Text( + text = text, + style = NekiTheme.typography.body16SemiBold, + ) + } +} + +@Composable +fun CTAButtonPrimary( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + CTAButton( + modifier = modifier, + text = text, + onClick = onClick, + containerColor = NekiTheme.colorScheme.primary400, + contentColor = NekiTheme.colorScheme.white, + disabledContainerColor = NekiTheme.colorScheme.primary400.copy(alpha = 0.4f), + disabledContentColor = NekiTheme.colorScheme.white, + enabled = enabled, + ) +} + +@Composable +fun CTAButtonGray( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + CTAButton( + modifier = modifier, + text = text, + onClick = onClick, + containerColor = NekiTheme.colorScheme.gray50, + contentColor = NekiTheme.colorScheme.gray300, + disabledContainerColor = NekiTheme.colorScheme.gray50, + disabledContentColor = NekiTheme.colorScheme.gray300, + enabled = enabled, + ) +} + +@ComponentPreview +@Composable +private fun CTAButtonPrimaryPreview() { + NekiTheme { + CTAButtonPrimary( + modifier = Modifier + .width(200.dp) + .padding(8.dp), + text = "텍스트", + onClick = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun CTAButtonPrimaryDisabledPreview() { + NekiTheme { + CTAButtonPrimary( + modifier = Modifier + .width(200.dp) + .padding(8.dp), + text = "텍스트", + onClick = {}, + enabled = false, + ) + } +} + +@ComponentPreview +@Composable +private fun CTAButtonGrayPreview() { + NekiTheme { + CTAButtonGray( + modifier = Modifier + .width(200.dp) + .padding(8.dp), + text = "텍스트", + onClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiButton.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiButton.kt new file mode 100644 index 000000000..4c1bb9309 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiButton.kt @@ -0,0 +1,43 @@ +package com.neki.android.core.designsystem.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import com.neki.android.core.designsystem.modifier.MultipleEventsCutter +import com.neki.android.core.designsystem.modifier.get + +@Composable +fun NekiButton( + shape: Shape, + onClick: () -> Unit, + containerColor: Color, + contentColor: Color, + disabledContainerColor: Color, + disabledContentColor: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) { + val multipleEventsCutter = remember { MultipleEventsCutter.get() } + Button( + onClick = { multipleEventsCutter.processEvent { onClick() } }, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ), + contentPadding = contentPadding, + content = content, + ) +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiIconButton.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiIconButton.kt new file mode 100644 index 000000000..c1ac2ef3b --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiIconButton.kt @@ -0,0 +1,70 @@ +package com.neki.android.core.designsystem.button + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.MultipleEventsCutter +import com.neki.android.core.designsystem.modifier.get +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +/** + * 중복 클릭 방지 기능이 포함된 아이콘 버튼 컴포넌트 + * + * @param onClick 클릭 이벤트 핸들러 + * @param enabled 버튼 활성화 여부 + * @param multipleEventsCutterEnabled 중복 클릭 방지 활성화 여부 + * + * Note: IconButton의 default size는 48.dp + */ +@Composable +fun NekiIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + multipleEventsCutterEnabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource? = null, + content: @Composable () -> Unit, +) { + val multipleEventsCutter = remember { MultipleEventsCutter.get() } + IconButton( + modifier = modifier, + onClick = { + if (multipleEventsCutterEnabled) { + multipleEventsCutter.processEvent { onClick() } + } else { + onClick() + } + }, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) { + content() + } +} + +@ComponentPreview +@Composable +private fun NekiIconButtonPreview() { + NekiTheme { + NekiIconButton(onClick = {}) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + contentDescription = null, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiTextButton.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiTextButton.kt new file mode 100644 index 000000000..cc9062c67 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiTextButton.kt @@ -0,0 +1,60 @@ +package com.neki.android.core.designsystem.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.MultipleEventsCutter +import com.neki.android.core.designsystem.modifier.get +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun NekiTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + multipleEventsCutterEnabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, + content: @Composable () -> Unit = {}, +) { + val multipleEventsCutter = remember { MultipleEventsCutter.get() } + TextButton( + onClick = { + if (multipleEventsCutterEnabled) { + multipleEventsCutter.processEvent { onClick() } + } else { + onClick() + } + }, + modifier = modifier, + contentPadding = contentPadding, + enabled = enabled, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + content() + } +} + +@ComponentPreview +@Composable +private fun NekiTextButtonPreview() { + NekiTheme { + NekiTextButton( + onClick = {}, + ) { + Text( + text = "Text Button", + textDecoration = TextDecoration.Underline, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiToastActionButton.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiToastActionButton.kt new file mode 100644 index 000000000..4d67d57f1 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/NekiToastActionButton.kt @@ -0,0 +1,47 @@ +package com.neki.android.core.designsystem.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun NekiToastActionButton( + buttonText: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .clickableSingle(onClick = onClick) + .background( + color = NekiTheme.colorScheme.gray700, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = buttonText, + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray25, + ) + } +} + +@ComponentPreview +@Composable +private fun NekiToastActionButtonPreview() { + NekiTheme { + NekiToastActionButton( + buttonText = "텍스트", + onClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/TopBarTextButton.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/TopBarTextButton.kt new file mode 100644 index 000000000..252b204ca --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/button/TopBarTextButton.kt @@ -0,0 +1,58 @@ +package com.neki.android.core.designsystem.button + +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.LayoutDirection +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun TopBarTextButton( + buttonText: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + enabledTextColor: Color = NekiTheme.colorScheme.primary500, + disabledTextColor: Color = NekiTheme.colorScheme.gray200, + onClick: () -> Unit = {}, +) { + NekiTextButton( + modifier = modifier.offset( + x = ButtonDefaults.TextButtonContentPadding.calculateLeftPadding(LayoutDirection.Ltr), + ), + onClick = onClick, + enabled = enabled, + ) { + Text( + text = buttonText, + style = NekiTheme.typography.body16SemiBold, + color = if (enabled) enabledTextColor else disabledTextColor, + ) + } +} + +@ComponentPreview +@Composable +private fun EnabledTopBarTextButtonPreview() { + NekiTheme { + TopBarTextButton( + buttonText = "텍스트버튼", + onClick = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun DisabledTopBarTextButtonPreview() { + NekiTheme { + TopBarTextButton( + buttonText = "텍스트버튼", + onClick = {}, + enabled = false, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt new file mode 100644 index 000000000..ce924ae12 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/DoubleButtonAlertDialog.kt @@ -0,0 +1,117 @@ +package com.neki.android.core.designsystem.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.CTAButtonGray +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun DoubleButtonAlertDialog( + title: String, + content: String, + grayButtonText: String, + primaryButtonText: String, + onDismissRequest: () -> Unit, + onClickPrimaryButton: () -> Unit, + onClickGrayButton: () -> Unit, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + ), +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .widthIn(max = 400.dp) + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white) + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_dialog_alert), + tint = Color.Unspecified, + contentDescription = null, + ) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = NekiTheme.typography.title18Bold, + color = NekiTheme.colorScheme.gray900, + textAlign = TextAlign.Center, + ) + Text( + text = content, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + textAlign = TextAlign.Center, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CTAButtonGray( + text = grayButtonText, + onClick = onClickGrayButton, + modifier = Modifier.weight(1f), + ) + CTAButtonPrimary( + text = primaryButtonText, + onClick = onClickPrimaryButton, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun DoubleButtonAlertDialogPreview() { + NekiTheme { + DoubleButtonAlertDialog( + title = "메인 텍스트가 들어가는 곳", + content = "보조 설명 텍스트가 들어가는 공간입니다", + grayButtonText = "텍스트", + primaryButtonText = "텍스트", + onDismissRequest = {}, + onClickPrimaryButton = {}, + onClickGrayButton = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt new file mode 100644 index 000000000..ba4cc3ec2 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonAlertDialog.kt @@ -0,0 +1,104 @@ +package com.neki.android.core.designsystem.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun SingleButtonAlertDialog( + title: String, + content: String, + buttonText: String, + onDismissRequest: () -> Unit, + onClick: () -> Unit, + enabled: Boolean = true, + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .widthIn(max = 400.dp) + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white) + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_dialog_alert), + tint = Color.Unspecified, + contentDescription = null, + ) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = NekiTheme.typography.title18Bold, + color = NekiTheme.colorScheme.gray900, + textAlign = TextAlign.Center, + ) + Text( + text = content, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + textAlign = TextAlign.Center, + ) + } + CTAButtonPrimary( + text = buttonText, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + enabled = enabled, + ) + } + } +} + +@ComponentPreview +@Composable +private fun SingleButtonAlertDialogPreview() { + NekiTheme { + SingleButtonAlertDialog( + title = "메인 텍스트가 들어가는 곳", + content = "보조 설명 텍스트가 들어가는 공간입니다", + buttonText = "텍스트", + onDismissRequest = {}, + onClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonWithTextButtonAlertDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonWithTextButtonAlertDialog.kt new file mode 100644 index 000000000..27aeb1470 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/SingleButtonWithTextButtonAlertDialog.kt @@ -0,0 +1,128 @@ +package com.neki.android.core.designsystem.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun SingleButtonWithTextButtonAlertDialog( + title: String, + content: String, + buttonText: String, + textButtonText: String, + onDismissRequest: () -> Unit, + onButtonClick: () -> Unit, + onTextButtonClick: () -> Unit, + enabled: Boolean = true, + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .widthIn(max = 400.dp) + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white) + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_dialog_alert), + tint = Color.Unspecified, + contentDescription = null, + ) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = NekiTheme.typography.title18Bold, + color = NekiTheme.colorScheme.gray900, + textAlign = TextAlign.Center, + ) + Text( + text = content, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + textAlign = TextAlign.Center, + ) + } + Column( + modifier = Modifier.padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CTAButtonPrimary( + text = buttonText, + onClick = onButtonClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + enabled = enabled, + ) + Text( + modifier = Modifier + .padding( + vertical = 4.dp, + horizontal = 56.dp, + ) + .clickableSingle(onClick = onTextButtonClick), + text = textButtonText, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.primary600, + textDecoration = TextDecoration.Underline, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun SingleButtonWithTextButtonAlertDialogPreview() { + NekiTheme { + SingleButtonWithTextButtonAlertDialog( + title = "메인 텍스트가 들어가는 곳", + content = "보조 설명 텍스트가 들어가는 공간입니다", + buttonText = "텍스트", + textButtonText = "텍스트 공간", + onDismissRequest = {}, + onButtonClick = {}, + onTextButtonClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt new file mode 100644 index 000000000..bc2607c6a --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt @@ -0,0 +1,91 @@ +package com.neki.android.core.designsystem.dialog + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun WarningDialog( + content: String, + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + ), +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white), + ) { + Icon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp) + .size(24.dp) + .clickableSingle(onClick = onDismissRequest), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + tint = NekiTheme.colorScheme.gray900, + contentDescription = null, + ) + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(vertical = 20.dp, horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_dialog_alert), + tint = Color.Unspecified, + contentDescription = null, + ) + + Text( + text = content, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun WarningDialogPreview() { + NekiTheme { + WarningDialog( + content = "텍스트가 들어가는 자리입니다.\n2줄 이상이면 이렇게 돼요.", + onDismissRequest = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt new file mode 100644 index 000000000..b93915064 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt @@ -0,0 +1,83 @@ +package com.neki.android.core.designsystem.modifier + +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect + +/** + * 사진 컴포넌트에 적용되는 그라데이션 배경 + * 좌하단에서 우상단으로 갈수록 어두워지는 효과 + */ +fun Modifier.photoBackground( + shape: Shape = RoundedCornerShape(12.dp), +): Modifier = this.background( + brush = Brush.linearGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 0f), + 0.7f to Color.Black.copy(alpha = 0.09f), + 1f to Color.Black.copy(alpha = 0.3f), + ), + start = Offset(0f, Float.POSITIVE_INFINITY), + end = Offset(Float.POSITIVE_INFINITY, 0f), + ), + shape = shape, +) + +/** + * 포즈 컴포넌트에 적용되는 그라데이션 배경 + * 상단에서 134/242 지점까지 어두워지는 효과 + */ +fun Modifier.poseBackground( + shape: Shape = RoundedCornerShape(12.dp), +): Modifier = this.background( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 0.2f), + 134f / 242f to Color.Black.copy(alpha = 0f), + ), + ), + shape = shape, +) + +/** + * 블러 효과가 적용된 배경을 설정하는 Modifier 확장 함수 + * + * @param hazeState Haze 블러 효과를 관리하는 상태 객체 + * @param enabled 블러 효과 활성화 여부 (false일 경우 단색 배경 적용) + * @param color 블러 효과에 적용될 배경 색상 + * @param defaultBackgroundColor 블러 비활성화 시 적용될 기본 배경 색상 + * @param blurRadius 블러 효과의 반경 + */ +@Composable +fun Modifier.backgroundHazeBlur( + hazeState: HazeState, + enabled: Boolean = true, + color: Color = Color(0xFF202227).copy(alpha = 0.9f), + defaultBackgroundColor: Color = color, + blurRadius: Dp = 12.dp, +): Modifier = + if (enabled) { + this.hazeEffect( + state = hazeState, + style = HazeStyle( + backgroundColor = color, + tint = HazeTint( + color.copy(alpha = if (color.luminance() >= 0.5) 0.6f else 0.65f), + ), + blurRadius = blurRadius, + ), + ) + } else this.background(color = defaultBackgroundColor) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt new file mode 100644 index 000000000..84a38b7ea --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -0,0 +1,260 @@ +package com.neki.android.core.designsystem.modifier + +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.PressGestureScope +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.material3.ripple +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * 클릭의 리플 효과를 없애주는 [Modifier] + */ +fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = this.clickable( + indication = null, + interactionSource = null, + onClick = onClick, +) + +/** + * 클릭의 리플 효과를 없애고 500ms 내에 중복 클릭을 막아주는 [Modifier] + */ +fun Modifier.noRippleClickableSingle( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit, +): Modifier = this.then( + ClickableSingleElement( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + ), +) + +/** + * 500ms 내의 중복 클릭을 막아주는 [Modifier] + */ +fun Modifier.clickableSingle( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + interactionSource: MutableInteractionSource? = null, + onClick: () -> Unit, +): Modifier = this.then( + ClickableSingleElement( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + indicationNodeFactory = ripple(), + interactionSource = interactionSource, + ), +) + +private data class ClickableSingleElement( + private val enabled: Boolean, + private val onClickLabel: String?, + private val role: Role?, + private val indicationNodeFactory: IndicationNodeFactory? = null, + private val interactionSource: MutableInteractionSource? = null, + private val onClick: () -> Unit, +) : ModifierNodeElement() { + + override fun create(): ClickableSingleNode = ClickableSingleNode( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, + interactionSource = interactionSource, + ) + + override fun update(node: ClickableSingleNode) = node.update( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, + interactionSource = interactionSource, + ) +} + +private class ClickableSingleNode( + private var enabled: Boolean, + private var onClickLabel: String?, + private var role: Role?, + private var indicationNodeFactory: IndicationNodeFactory?, + private var interactionSource: MutableInteractionSource?, + private var onClick: () -> Unit, +) : DelegatingNode(), PointerInputModifierNode, SemanticsModifierNode { + + private val multipleEventsCutter = MultipleEventsCutter.get() + private var internalInteractionSource: MutableInteractionSource? = null + private var indicationNode: DelegatableNode? = null + private var currentPressInteraction: PressInteraction.Press? = null + + private val activeInteractionSource: MutableInteractionSource? + get() = interactionSource ?: internalInteractionSource + + override val shouldAutoInvalidate: Boolean = false + + private val pointerInputNode = delegate( + SuspendingPointerInputModifierNode { + detectTapGestures( + onPress = { offset -> if (enabled) handlePressInteraction(offset) }, + onTap = { if (enabled) processClick() }, + ) + }, + ) + + override fun onAttach() { + initializeIndicationIfNeeded() + } + + private fun initializeIndicationIfNeeded() { + if (indicationNode != null) return + indicationNodeFactory?.let { factory -> + if (interactionSource == null && internalInteractionSource == null) { + internalInteractionSource = MutableInteractionSource() + } + val source = activeInteractionSource ?: return@let + val node = factory.create(source) + delegate(node) + indicationNode = node + } + } + + private suspend fun PressGestureScope.handlePressInteraction(offset: Offset) { + initializeIndicationIfNeeded() + activeInteractionSource?.let { source -> + coroutineScope { + val press = PressInteraction.Press(offset) + currentPressInteraction = press + launch { source.emit(press) } + + val success = tryAwaitRelease() + currentPressInteraction = null + val endInteraction = if (success) { + PressInteraction.Release(press) + } else { + PressInteraction.Cancel(press) + } + launch { source.emit(endInteraction) } + } + } + } + + private fun processClick() { + multipleEventsCutter.processEvent { onClick() } + } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize, + ) { + pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) + } + + override fun onCancelPointerInput() { + pointerInputNode.onCancelPointerInput() + } + + override fun SemanticsPropertyReceiver.applySemantics() { + this@ClickableSingleNode.role?.let { this.role = it } + onClick( + label = onClickLabel, + action = { processClick(); true }, + ) + if (!enabled) { disabled() } + } + + fun update( + enabled: Boolean, + onClickLabel: String?, + role: Role?, + indicationNodeFactory: IndicationNodeFactory?, + interactionSource: MutableInteractionSource?, + onClick: () -> Unit, + ) { + val interactionSourceChanged = this.interactionSource != interactionSource + val indicationChanged = this.indicationNodeFactory != indicationNodeFactory + + this.enabled = enabled + this.onClickLabel = onClickLabel + this.role = role + this.onClick = onClick + + if (interactionSourceChanged) { + this.interactionSource = interactionSource + } + + if (indicationChanged || interactionSourceChanged) { + indicationNode?.let { undelegate(it) } + indicationNode = null + if (interactionSource == null) { + internalInteractionSource = null + } + this.indicationNodeFactory = indicationNodeFactory + initializeIndicationIfNeeded() + } + } + + override fun onDetach() { + currentPressInteraction?.let { press -> + activeInteractionSource?.tryEmit(PressInteraction.Cancel(press)) + } + currentPressInteraction = null + } +} + +/** + * 중복 클릭 방지를 위한 인터페이스 + * Button, IconButton 등 Composable 컴포넌트에서 사용 + */ +interface MultipleEventsCutter { + fun processEvent(event: () -> Unit) + + companion object +} + +fun MultipleEventsCutter.Companion.get(): MultipleEventsCutter = MultipleEventsCutterImpl() + +private class MultipleEventsCutterImpl : MultipleEventsCutter { + private val now: Long + get() = System.currentTimeMillis() + + private var lastEventTimeMs: Long = 0 + + override fun processEvent(event: () -> Unit) { + if (now - lastEventTimeMs >= DEBOUNCE_TIME_MS) { + lastEventTimeMs = now + event.invoke() + } + } + + companion object { + private const val DEBOUNCE_TIME_MS = 500L + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt new file mode 100644 index 000000000..1d7131d27 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt @@ -0,0 +1,154 @@ +package com.neki.android.core.designsystem.modifier + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Figma btn_shadow 스타일 + * DROP_SHADOW: color #00000040, offset (0, 2), radius 8, spread 0 + */ +fun Modifier.buttonShadow( + shape: Shape = CircleShape, + color: Color = Color.Black.copy(alpha = 0.25f), + offsetX: Dp = 0.dp, + offsetY: Dp = 2.dp, + blurRadius: Dp = 8.dp, +): Modifier = this.drawBehind { + drawIntoCanvas { canvas -> + val paint = Paint().apply { + asFrameworkPaint().apply { + this.color = Color.Transparent.toArgb() + setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + color.toArgb(), + ) + } + } + val outline = shape.createOutline(size, layoutDirection, this) + canvas.drawOutline(outline, paint) + } +} + +/** + * Figma card_shadow 스타일 + * DROP_SHADOW: color #00000040, offset (0, 2), radius 4, spread 0 + */ +fun Modifier.cardShadow( + shape: Shape = RectangleShape, + color: Color = Color.Black.copy(alpha = 0.25f), + offsetX: Dp = 0.dp, + offsetY: Dp = 2.dp, + blurRadius: Dp = 4.dp, +): Modifier = this.drawBehind { + drawIntoCanvas { canvas -> + val paint = Paint().apply { + asFrameworkPaint().apply { + this.color = Color.Transparent.toArgb() + setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + color.toArgb(), + ) + } + } + val outline = shape.createOutline(size, layoutDirection, this) + canvas.drawOutline(outline, paint) + } +} + +/** + * Figma tabbar_shadow 스타일 + * DROP_SHADOW: color #0000000A, offset (0, -2), radius 10, spread 0 + */ +fun Modifier.tabbarShadow( + shape: Shape = RectangleShape, + color: Color = Color.Black.copy(alpha = 0.04f), + offsetX: Dp = 0.dp, + offsetY: Dp = (-2).dp, + blurRadius: Dp = 10.dp, +): Modifier = this.drawBehind { + drawIntoCanvas { canvas -> + val paint = Paint().apply { + asFrameworkPaint().apply { + this.color = Color.Transparent.toArgb() + setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + color.toArgb(), + ) + } + } + val outline = shape.createOutline(size, layoutDirection, this) + canvas.drawOutline(outline, paint) + } +} + +/** + * Figma dropdown_shadow 스타일 + * DROP_SHADOW: color #00000033, offset (0, 0), radius 5, spread 0 + */ +fun Modifier.dropdownShadow( + shape: Shape = RectangleShape, + color: Color = Color.Black.copy(alpha = 0.20f), + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, + blurRadius: Dp = 5.dp, +): Modifier = this.drawBehind { + drawIntoCanvas { canvas -> + val paint = Paint().apply { + asFrameworkPaint().apply { + this.color = Color.Transparent.toArgb() + setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + color.toArgb(), + ) + } + } + val outline = shape.createOutline(size, layoutDirection, this) + canvas.drawOutline(outline, paint) + } +} + +/** + * Figma pin_shadow 스타일 + * DROP_SHADOW: color #00000066, offset (0, 1), radius 2.5, spread 0 + */ +fun Modifier.pinShadow( + shape: Shape = RectangleShape, + color: Color = Color.Black.copy(alpha = 0.40f), + offsetX: Dp = 0.dp, + offsetY: Dp = 1.dp, + blurRadius: Dp = 2.5.dp, +): Modifier = this.drawBehind { + drawIntoCanvas { canvas -> + val paint = Paint().apply { + asFrameworkPaint().apply { + this.color = Color.Transparent.toArgb() + setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + color.toArgb(), + ) + } + } + val outline = shape.createOutline(size, layoutDirection, this) + canvas.drawOutline(outline, paint) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/toast/NekiToast.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/toast/NekiToast.kt new file mode 100644 index 000000000..d4853a6f6 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/toast/NekiToast.kt @@ -0,0 +1,104 @@ +package com.neki.android.core.designsystem.toast + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiToastActionButton +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun NekiToast( + @DrawableRes iconRes: Int, + text: String, + modifier: Modifier = Modifier, + actionButton: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = NekiTheme.colorScheme.gray800, + shape = RoundedCornerShape(12.dp), + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(iconRes), + tint = Color.Unspecified, + contentDescription = null, + ) + Text( + text = text, + style = NekiTheme.typography.body16Medium, + color = NekiTheme.colorScheme.gray25, + ) + } + actionButton?.invoke() + } +} + +@Composable +fun NekiActionToast( + @DrawableRes iconRes: Int, + text: String, + modifier: Modifier = Modifier, + buttonText: String, + onClickActionButton: () -> Unit, +) { + NekiToast( + iconRes = iconRes, + text = text, + modifier = modifier, + actionButton = { + NekiToastActionButton( + buttonText = buttonText, + onClick = onClickActionButton, + ) + }, + ) +} + +@ComponentPreview +@Composable +private fun NekiToastPreview() { + NekiTheme { + NekiToast( + iconRes = R.drawable.icon_checkbox_on, + text = "텍스트", + ) + } +} + +@ComponentPreview +@Composable +private fun NekiActionToastPreview() { + NekiTheme { + NekiActionToast( + iconRes = R.drawable.icon_checkbox_on, + text = "텍스트", + buttonText = "텍스트", + onClickActionButton = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/topbar/NekiTopBar.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/topbar/NekiTopBar.kt new file mode 100644 index 000000000..d27b24b7a --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/topbar/NekiTopBar.kt @@ -0,0 +1,71 @@ +package com.neki.android.core.designsystem.topbar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun NekiTitleTopBar( + title: String, + modifier: Modifier = Modifier, + leadingIcon: @Composable ((Modifier) -> Unit)? = null, + actions: @Composable ((Modifier) -> Unit)? = null, +) { + NekiTopBar( + modifier = modifier, + leadingIcon = leadingIcon, + actions = actions, + title = { modifier -> + Text( + modifier = modifier, + text = title, + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + }, + ) +} + +@Composable +fun NekiLeftTitleTopBar( + modifier: Modifier = Modifier, + title: @Composable (() -> Unit)? = null, + actions: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(54.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + title?.invoke() + actions?.invoke() + } +} + +@Composable +private fun NekiTopBar( + modifier: Modifier = Modifier, + title: @Composable ((Modifier) -> Unit)? = null, + leadingIcon: @Composable ((Modifier) -> Unit)? = null, + actions: @Composable ((Modifier) -> Unit)? = null, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(54.dp), + ) { + leadingIcon?.invoke(Modifier.align(Alignment.CenterStart)) + title?.invoke(Modifier.align(Alignment.Center)) + actions?.invoke(Modifier.align(Alignment.CenterEnd)) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/topbar/TitleTopBar.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/topbar/TitleTopBar.kt new file mode 100644 index 000000000..2165c1fec --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/topbar/TitleTopBar.kt @@ -0,0 +1,351 @@ +package com.neki.android.core.designsystem.topbar + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.button.NekiTextButton +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun CloseTitleTopBar( + title: String, + modifier: Modifier = Modifier, + onClose: () -> Unit = {}, +) { + NekiTitleTopBar( + modifier = modifier, + leadingIcon = { modifier -> + NekiIconButton( + modifier = modifier + .padding(start = 8.dp) + .size(52.dp), + onClick = onClose, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + title = title, + ) +} + +@Composable +fun CloseTitleTextButtonTopBar( + title: String, + buttonText: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + enabledTextColor: Color = NekiTheme.colorScheme.primary500, + disabledTextColor: Color = NekiTheme.colorScheme.gray200, + onClose: () -> Unit = {}, + onClickTextButton: () -> Unit = {}, +) { + NekiTitleTopBar( + modifier = modifier, + leadingIcon = { modifier -> + NekiIconButton( + modifier = modifier + .padding(start = 8.dp) + .size(52.dp), + onClick = onClose, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + title = title, + actions = { modifier -> + NekiTextButton( + modifier = modifier.fillMaxHeight(), + contentPadding = PaddingValues(horizontal = 20.dp), + onClick = onClickTextButton, + enabled = enabled, + ) { + Text( + text = buttonText, + style = NekiTheme.typography.body16SemiBold, + color = if (enabled) enabledTextColor else disabledTextColor, + ) + } + }, + ) +} + +@Composable +fun BackTitleTextButtonTopBar( + title: String, + buttonLabel: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + enabledTextColor: Color = NekiTheme.colorScheme.primary500, + disabledTextColor: Color = NekiTheme.colorScheme.gray200, + onBack: () -> Unit = {}, + onClickTextButton: () -> Unit = {}, +) { + NekiTitleTopBar( + modifier = modifier, + leadingIcon = { modifier -> + NekiIconButton( + modifier = modifier + .padding(start = 8.dp) + .size(52.dp), + onClick = onBack, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_left), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + title = title, + actions = { modifier -> + NekiTextButton( + modifier = modifier.fillMaxHeight(), + contentPadding = PaddingValues(horizontal = 20.dp), + onClick = onClickTextButton, + enabled = enabled, + ) { + Text( + text = buttonLabel, + style = NekiTheme.typography.body16SemiBold, + color = if (enabled) enabledTextColor else disabledTextColor, + ) + } + }, + ) +} + +@Composable +fun BackTitleTopBar( + title: String, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, +) { + NekiTitleTopBar( + modifier = modifier, + leadingIcon = { modifier -> + NekiIconButton( + modifier = modifier + .padding(start = 8.dp) + .size(52.dp), + onClick = onBack, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_left), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + title = title, + ) +} + +@Composable +fun BackTitleOptionTopBar( + title: String, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onClickIcon: () -> Unit = {}, +) { + NekiTitleTopBar( + modifier = modifier, + leadingIcon = { modifier -> + NekiIconButton( + modifier = modifier + .padding(start = 8.dp) + .size(52.dp), + onClick = onBack, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_left), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + title = title, + actions = { modifier -> + NekiIconButton( + modifier = modifier + .padding(end = 8.dp) + .size(52.dp), + onClick = onClickIcon, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_kebab), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + ) +} + +@Composable +fun BackTitleTextButtonOptionTopBar( + title: String, + buttonLabel: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + enabledTextColor: Color = NekiTheme.colorScheme.primary500, + disabledTextColor: Color = NekiTheme.colorScheme.gray200, + optionIconRes: Int = R.drawable.icon_option, + onBack: () -> Unit = {}, + onClickTextButton: () -> Unit = {}, + onClickIcon: () -> Unit = {}, +) { + NekiTitleTopBar( + modifier = modifier, + leadingIcon = { modifier -> + NekiIconButton( + modifier = modifier + .padding(start = 8.dp) + .size(52.dp), + onClick = onBack, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_left), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + }, + title = title, + actions = { modifier -> + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + NekiTextButton( + modifier = modifier + .fillMaxHeight() + .padding(vertical = 3.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + onClick = onClickTextButton, + enabled = enabled, + ) { + Text( + text = buttonLabel, + style = NekiTheme.typography.body16SemiBold, + color = if (enabled) enabledTextColor else disabledTextColor, + ) + } + NekiIconButton( + modifier = modifier + .padding(end = 8.dp) + .size(52.dp), + onClick = onClickIcon, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(optionIconRes), + tint = NekiTheme.colorScheme.gray800, + contentDescription = null, + ) + } + } + }, + ) +} + +@ComponentPreview +@Composable +private fun NekiTitleTopBarPreview() { + NekiTheme { + NekiTitleTopBar( + title = "텍스트", + ) + } +} + +@ComponentPreview +@Composable +private fun CloseTitleTopBarPreview() { + NekiTheme { + CloseTitleTopBar( + title = "텍스트", + ) + } +} + +@ComponentPreview +@Composable +private fun CloseTitleTextButtonTopBarPreview() { + NekiTheme { + CloseTitleTextButtonTopBar( + title = "텍스트", + buttonText = "텍스트버튼", + ) + } +} + +@ComponentPreview +@Composable +private fun BackTitleTextButtonTopBarPreview() { + NekiTheme { + BackTitleTextButtonTopBar( + title = "텍스트", + buttonLabel = "텍스트버튼", + ) + } +} + +@ComponentPreview +@Composable +private fun BackTitleTopBarPreview() { + NekiTheme { + BackTitleTopBar( + title = "텍스트", + ) + } +} + +@ComponentPreview +@Composable +private fun BackTitleOptionTopBarPreview() { + NekiTheme { + BackTitleOptionTopBar( + title = "텍스트", + ) + } +} + +@ComponentPreview +@Composable +private fun BackTitleTwoOptionTopBarPreview() { + NekiTheme { + BackTitleTextButtonOptionTopBar( + title = "텍스트", + buttonLabel = "텍스트버튼", + ) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Color.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Color.kt index 4b467f099..70447d0d7 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Color.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Color.kt @@ -1,11 +1,80 @@ package com.neki.android.core.designsystem.ui.theme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +@Immutable +data class NekiColorScheme( + // System + val white: Color, -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file + // Grayscale + val gray900: Color, + val gray800: Color, + val gray700: Color, + val gray600: Color, + val gray500: Color, + val gray400: Color, + val gray300: Color, + val gray200: Color, + val gray100: Color, + val gray75: Color, + val gray50: Color, + val gray25: Color, + + // Primary + val primary900: Color, + val primary800: Color, + val primary700: Color, + val primary600: Color, + val primary500: Color, + val primary400: Color, + val primary300: Color, + val primary200: Color, + val primary100: Color, + val primary50: Color, + val primary25: Color, + + // Album Cover + val favoriteAlbumCover: Color, + val defaultAlbumCover: Color, +) + +internal val defaultNekiColors = NekiColorScheme( + // System + white = Color(0xFFFFFFFF), + + // Grayscale + gray900 = Color(0xFF202227), + gray800 = Color(0xFF3C3E48), + gray700 = Color(0xFF4F525F), + gray600 = Color(0xFF616575), + gray500 = Color(0xFF74788B), + gray400 = Color(0xFF8A8E9E), + gray300 = Color(0xFFA0A3B0), + gray200 = Color(0xFFB7B9C3), + gray100 = Color(0xFFCDCED5), + gray75 = Color(0xFFE3E4E8), + gray50 = Color(0xFFEEF1F1), + gray25 = Color(0xFFF9FAFA), + + // Primary + primary900 = Color(0xFF7A0A00), + primary800 = Color(0xFFA30E00), + primary700 = Color(0xFFCC1100), + primary600 = Color(0xFFF51500), + primary500 = Color(0xFFFF311F), + primary400 = Color(0xFFFF5647), + primary300 = Color(0xFFFF786B), + primary200 = Color(0xFFFFA299), + primary100 = Color(0xFFFFC7C2), + primary50 = Color(0xFFFFDAD6), + primary25 = Color(0xFFFFECEB), + + // Album Cover + favoriteAlbumCover = Color(0xFFFF5647), + defaultAlbumCover = Color(0xFF202227), +) + +val LocalColorScheme = staticCompositionLocalOf { defaultNekiColors } diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Theme.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Theme.kt index 4b2633b6a..98b301d4c 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Theme.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Theme.kt @@ -1,57 +1,70 @@ package com.neki.android.core.designsystem.ui.theme -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme +import android.app.Activity import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.core.view.WindowInsetsControllerCompat -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) +@Composable +fun ProvideTypographyAndColor( + typography: NekiTypography, + colors: NekiColorScheme, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalTypography provides typography, + LocalColorScheme provides colors, + content = content, + ) +} @Composable fun NekiTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowInsetsControllerCompat(window, view).isAppearanceLightStatusBars = true } + } - darkTheme -> DarkColorScheme - else -> LightColorScheme + CompositionLocalProvider( + LocalDensity provides Density( + density = LocalDensity.current.density, + fontScale = 1f, + ), + ) { + ProvideTypographyAndColor( + typography = defaultNekiTypography, + colors = defaultNekiColors, + ) { + MaterialTheme( + colorScheme = lightColorScheme( + background = NekiTheme.colorScheme.white, + ), + content = content, + ) + } } +} - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file +object NekiTheme { + val typography: NekiTypography + @Composable + @ReadOnlyComposable + get() = LocalTypography.current + + val colorScheme: NekiColorScheme + @Composable + @ReadOnlyComposable + get() = LocalColorScheme.current +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt index d0c0be7bf..8c025fd1b 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt @@ -1,34 +1,194 @@ package com.neki.android.core.designsystem.ui.theme -import androidx.compose.material3.Typography +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import com.neki.android.core.designsystem.R -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, +internal val pretendardFamily = FontFamily( + Font(R.font.pretendard_bold, FontWeight.Bold), + Font(R.font.pretendard_semibold, FontWeight.SemiBold), + Font(R.font.pretendard_medium, FontWeight.Medium), + Font(R.font.pretendard_regular, FontWeight.Normal), +) + +private val pretendardStyle = TextStyle( + fontFamily = pretendardFamily, + platformStyle = PlatformTextStyle( + includeFontPadding = false, + ), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), +) + +internal val defaultNekiTypography = NekiTypography( + // Title 24 + title24Bold = pretendardStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 36.sp, + letterSpacing = (-0.02).em, + ), + title24SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + // Title 20 + title20Bold = pretendardStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + title20SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + title20Medium = pretendardStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + // Title 18 + title18Bold = pretendardStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + title18SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + title18Medium = pretendardStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 18.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + title18Regular = pretendardStyle.copy( fontWeight = FontWeight.Normal, + fontSize = 18.sp, + lineHeight = 28.sp, + letterSpacing = (-0.02).em, + ), + // Body 16 + body16SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, + letterSpacing = (-0.02).em, + ), + body16Medium = pretendardStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = (-0.02).em, + ), + body16Regular = pretendardStyle.copy( fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = (-0.02).em, + ), + // Body 14 + body14SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = (-0.02).em, + ), + body14Medium = pretendardStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = (-0.02).em, + ), + body14Regular = pretendardStyle.copy( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = (-0.02).em, + ), + // Caption 12 + caption12SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = (-0.02).em, ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, + caption12Medium = pretendardStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = (-0.02).em, + ), + caption12Regular = pretendardStyle.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = (-0.02).em, + ), + // Caption 11 + caption11SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = (-0.02).em, + ), + caption11Medium = pretendardStyle.copy( fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file + letterSpacing = (-0.02).em, + ), +) + +@Immutable +data class NekiTypography( + // Title 24 + val title24Bold: TextStyle, + val title24SemiBold: TextStyle, + // Title 20 + val title20Bold: TextStyle, + val title20SemiBold: TextStyle, + val title20Medium: TextStyle, + // Title 18 + val title18Bold: TextStyle, + val title18SemiBold: TextStyle, + val title18Medium: TextStyle, + val title18Regular: TextStyle, + // Body 16 + val body16SemiBold: TextStyle, + val body16Medium: TextStyle, + val body16Regular: TextStyle, + // Body 14 + val body14SemiBold: TextStyle, + val body14Medium: TextStyle, + val body14Regular: TextStyle, + // Caption 12 + val caption12SemiBold: TextStyle, + val caption12Medium: TextStyle, + val caption12Regular: TextStyle, + // Caption 11 + val caption11SemiBold: TextStyle, + val caption11Medium: TextStyle, +) + +val LocalTypography = staticCompositionLocalOf { defaultNekiTypography } diff --git a/core/designsystem/src/main/res/drawable/icon_align.xml b/core/designsystem/src/main/res/drawable/icon_align.xml new file mode 100644 index 000000000..8056b3274 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_align.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_arrow_down.xml b/core/designsystem/src/main/res/drawable/icon_arrow_down.xml new file mode 100644 index 000000000..565681688 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_arrow_down.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_arrow_left.xml b/core/designsystem/src/main/res/drawable/icon_arrow_left.xml new file mode 100644 index 000000000..8b7cd18e5 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_arrow_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_arrow_right.xml b/core/designsystem/src/main/res/drawable/icon_arrow_right.xml new file mode 100644 index 000000000..a9554864c --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_arrow_right.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_arrow_top_right.xml b/core/designsystem/src/main/res/drawable/icon_arrow_top_right.xml new file mode 100644 index 000000000..7d64cef60 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_arrow_top_right.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_bell.xml b/core/designsystem/src/main/res/drawable/icon_bell.xml new file mode 100644 index 000000000..3ceabd966 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_bell.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_camera.xml b/core/designsystem/src/main/res/drawable/icon_camera.xml new file mode 100644 index 000000000..ee23c05cc --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_check.xml b/core/designsystem/src/main/res/drawable/icon_check.xml new file mode 100644 index 000000000..f16588162 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_check_primary.xml b/core/designsystem/src/main/res/drawable/icon_check_primary.xml new file mode 100644 index 000000000..51f0bd40f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_check_primary.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_checkbox_on.xml b/core/designsystem/src/main/res/drawable/icon_checkbox_on.xml new file mode 100644 index 000000000..af7d41b4e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_checkbox_on.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_close.xml b/core/designsystem/src/main/res/drawable/icon_close.xml new file mode 100644 index 000000000..3df3309a2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_close.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_current_location_off.xml b/core/designsystem/src/main/res/drawable/icon_current_location_off.xml new file mode 100644 index 000000000..0fc38bf56 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_current_location_off.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_current_location_on.xml b/core/designsystem/src/main/res/drawable/icon_current_location_on.xml new file mode 100644 index 000000000..de7abcd66 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_current_location_on.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_dialog_alert.xml b/core/designsystem/src/main/res/drawable/icon_dialog_alert.xml new file mode 100644 index 000000000..011a49cab --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_dialog_alert.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_download.xml b/core/designsystem/src/main/res/drawable/icon_download.xml new file mode 100644 index 000000000..41b405c47 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_edit.xml b/core/designsystem/src/main/res/drawable/icon_edit.xml new file mode 100644 index 000000000..db5b49de9 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_find_direction.xml b/core/designsystem/src/main/res/drawable/icon_find_direction.xml new file mode 100644 index 000000000..dc8027f61 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_find_direction.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_haru_film.xml b/core/designsystem/src/main/res/drawable/icon_haru_film.xml new file mode 100644 index 000000000..dc4fcde9e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_haru_film.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_heart.xml b/core/designsystem/src/main/res/drawable/icon_heart.xml new file mode 100644 index 000000000..e84073cb4 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_heart_stroke.xml b/core/designsystem/src/main/res/drawable/icon_heart_stroke.xml new file mode 100644 index 000000000..1401d4975 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_heart_stroke.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_info_gray_stroke.xml b/core/designsystem/src/main/res/drawable/icon_info_gray_stroke.xml new file mode 100644 index 000000000..7553ccda0 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_info_gray_stroke.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_info_primary_fill.xml b/core/designsystem/src/main/res/drawable/icon_info_primary_fill.xml new file mode 100644 index 000000000..0d114426d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_info_primary_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_kebab.xml b/core/designsystem/src/main/res/drawable/icon_kebab.xml new file mode 100644 index 000000000..bdcfc26e6 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_kebab.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_life_four_cut.xml b/core/designsystem/src/main/res/drawable/icon_life_four_cut.xml new file mode 100644 index 000000000..8e78244f8 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_life_four_cut.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_map_pin.xml b/core/designsystem/src/main/res/drawable/icon_map_pin.xml new file mode 100644 index 000000000..9b95789ba --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_map_pin.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_option.xml b/core/designsystem/src/main/res/drawable/icon_option.xml new file mode 100644 index 000000000..ae90bd947 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_option.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_person.xml b/core/designsystem/src/main/res/drawable/icon_person.xml new file mode 100644 index 000000000..8c80ac044 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_photo_signature.xml b/core/designsystem/src/main/res/drawable/icon_photo_signature.xml new file mode 100644 index 000000000..7377b50bc --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_photo_signature.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_photogray.xml b/core/designsystem/src/main/res/drawable/icon_photogray.xml new file mode 100644 index 000000000..e1cb5bf52 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_photogray.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_photoism.xml b/core/designsystem/src/main/res/drawable/icon_photoism.xml new file mode 100644 index 000000000..d678a21c2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_photoism.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_planb_studio.xml b/core/designsystem/src/main/res/drawable/icon_planb_studio.xml new file mode 100644 index 000000000..a3703391e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_planb_studio.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_plus_primary.xml b/core/designsystem/src/main/res/drawable/icon_plus_primary.xml new file mode 100644 index 000000000..2ecef4836 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_plus_primary.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_qr_light.xml b/core/designsystem/src/main/res/drawable/icon_qr_light.xml new file mode 100644 index 000000000..c7e3beffc --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_qr_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_repeat_recommendation.xml b/core/designsystem/src/main/res/drawable/icon_repeat_recommendation.xml new file mode 100644 index 000000000..3ac832fea --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_repeat_recommendation.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_rotation.xml b/core/designsystem/src/main/res/drawable/icon_rotation.xml new file mode 100644 index 000000000..b9ad21315 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_rotation.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_scrap.xml b/core/designsystem/src/main/res/drawable/icon_scrap.xml new file mode 100644 index 000000000..e0900c7e2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_scrap.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_scrap_unselected.xml b/core/designsystem/src/main/res/drawable/icon_scrap_unselected.xml new file mode 100644 index 000000000..02527f67d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_scrap_unselected.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_trash.xml b/core/designsystem/src/main/res/drawable/icon_trash.xml new file mode 100644 index 000000000..6fd96ee8e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/image_empty_profile_image.png b/core/designsystem/src/main/res/drawable/image_empty_profile_image.png new file mode 100644 index 000000000..3c36f1f7d Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_empty_profile_image.png differ diff --git a/core/designsystem/src/main/res/drawable/image_google_map.png b/core/designsystem/src/main/res/drawable/image_google_map.png new file mode 100644 index 000000000..eda6f0363 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_google_map.png differ diff --git a/core/designsystem/src/main/res/drawable/image_kakao_map.png b/core/designsystem/src/main/res/drawable/image_kakao_map.png new file mode 100644 index 000000000..942f864dd Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_kakao_map.png differ diff --git a/core/designsystem/src/main/res/drawable/image_naver_map.png b/core/designsystem/src/main/res/drawable/image_naver_map.png new file mode 100644 index 000000000..4ac0dbc8d Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_naver_map.png differ diff --git a/core/designsystem/src/main/res/font/pretendard_bold.ttf b/core/designsystem/src/main/res/font/pretendard_bold.ttf new file mode 100644 index 000000000..fb07fc65e Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_bold.ttf differ diff --git a/core/designsystem/src/main/res/font/pretendard_medium.ttf b/core/designsystem/src/main/res/font/pretendard_medium.ttf new file mode 100644 index 000000000..1db67c68f Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_medium.ttf differ diff --git a/core/designsystem/src/main/res/font/pretendard_regular.ttf b/core/designsystem/src/main/res/font/pretendard_regular.ttf new file mode 100644 index 000000000..01147e999 Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_regular.ttf differ diff --git a/core/designsystem/src/main/res/font/pretendard_semibold.ttf b/core/designsystem/src/main/res/font/pretendard_semibold.ttf new file mode 100644 index 000000000..9f2690f09 Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_semibold.ttf differ diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 303da660a..0532017b6 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -1,7 +1,14 @@ plugins { - alias(libs.plugins.neki.kotlin.library) + alias(libs.plugins.neki.android.library) + alias(libs.plugins.neki.hilt) } -dependencies { +android { + namespace = "com.neki.android.core.domain" +} +dependencies { + implementation(projects.core.dataApi) + implementation(projects.core.model) + implementation(projects.core.data) } diff --git a/core/domain/src/main/java/com/neki/android/core/domain/extension/ContentTypeUtil.kt b/core/domain/src/main/java/com/neki/android/core/domain/extension/ContentTypeUtil.kt new file mode 100644 index 000000000..cb30b0b3e --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/extension/ContentTypeUtil.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.domain.extension + +import com.neki.android.core.model.ContentType +import java.util.UUID + +object ContentTypeUtil { + fun generateFileName(contentType: ContentType): String { + val extension = when (contentType) { + ContentType.JPEG -> "jpeg" + ContentType.PNG -> "png" + } + return "${UUID.randomUUID()}.$extension" + } +} diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/OptionalUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/OptionalUseCase.kt deleted file mode 100644 index 79520bdc7..000000000 --- a/core/domain/src/main/java/com/neki/android/core/domain/usecase/OptionalUseCase.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.neki.android.core.domain.usecase - -class OptionalUseCase { -} \ No newline at end of file diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt new file mode 100644 index 000000000..424a47355 --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt @@ -0,0 +1,69 @@ +package com.neki.android.core.domain.usecase + +import android.net.Uri +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.extension.ContentTypeUtil.generateFileName +import com.neki.android.core.model.ContentType +import com.neki.android.core.model.Media +import com.neki.android.core.model.MediaType +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadMultiplePhotoUseCase @Inject constructor( + private val mediaUploadRepository: MediaUploadRepository, + private val photoRepository: PhotoRepository, +) { + suspend operator fun invoke( + imageUris: List, + contentType: ContentType = ContentType.JPEG, + folderId: Long? = null, + ): Result> = runSuspendCatching { + require(imageUris.isNotEmpty()) { "imageUris must not be empty" } + + val fileName = generateFileName(contentType) + + // 1. 업로드 티켓 발급 (이미지 개수만큼) + val tickets = mediaUploadRepository.getMultipleUploadTicket( + uploadCount = imageUris.size, + fileName = fileName, + contentType = contentType.label, + mediaType = MediaType.PHOTO_BOOTH.name, + ).getOrThrow() + + // 2. 각 이미지를 Presigned URL로 업로드 + coroutineScope { + imageUris.mapIndexed { index, uri -> + async { + val ticket = tickets[index] + mediaUploadRepository.uploadImageFromUri( + uploadUrl = ticket.uploadUrl, + uri = uri, + contentType = contentType, + ).getOrThrow() + } + }.awaitAll() + } + + // 3. 사진 등록 (모든 mediaId를 한번에) + val mediaIds = tickets.map { it.mediaId } + photoRepository.registerPhoto( + mediaIds = mediaIds, + folderId = folderId, + ).getOrThrow() + + return@runSuspendCatching tickets.map { ticket -> + Media( + mediaId = ticket.mediaId, + folderId = folderId, + fileName = fileName, + contentType = contentType, + ) + } + } +} diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt new file mode 100644 index 000000000..7b190e512 --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt @@ -0,0 +1,46 @@ +package com.neki.android.core.domain.usecase + +import android.net.Uri +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.dataapi.repository.UserRepository +import com.neki.android.core.domain.extension.ContentTypeUtil +import com.neki.android.core.model.ContentType +import com.neki.android.core.model.MediaType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadProfileImageUseCase @Inject constructor( + private val mediaUploadRepository: MediaUploadRepository, + private val userRepository: UserRepository, +) { + suspend operator fun invoke( + uri: Uri?, + contentType: ContentType = ContentType.JPEG, + ): Result = runSuspendCatching { + if (uri == null) { + // null 이면 1, 2 과정 없이 바로 기본 프로필 이미지로 변경 요청 + userRepository.updateProfileImage(null).getOrThrow() + } else { + val fileName = ContentTypeUtil.generateFileName(contentType) + + // 1. 업로드 티켓 발급 (mediaId, presignedUrl) + val (mediaId, presignedUrl) = mediaUploadRepository.getSingleUploadTicket( + fileName = fileName, + contentType = contentType.label, + mediaType = MediaType.USER_PROFILE.name, + ).getOrThrow() + + // 2. Presigned URL로 이미지 업로드 + mediaUploadRepository.uploadImageFromUri( + uploadUrl = presignedUrl, + uri = uri, + contentType = contentType, + ).getOrThrow() + + // 3. 프로필 이미지 갱신 + userRepository.updateProfileImage(mediaId).getOrThrow() + } + } +} diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt new file mode 100644 index 000000000..90f04f1c3 --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt @@ -0,0 +1,52 @@ +package com.neki.android.core.domain.usecase + +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.extension.ContentTypeUtil +import com.neki.android.core.model.ContentType +import com.neki.android.core.model.Media +import com.neki.android.core.model.MediaType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadSinglePhotoUseCase @Inject constructor( + private val mediaUploadRepository: MediaUploadRepository, + private val photoRepository: PhotoRepository, +) { + suspend operator fun invoke( + imageUrl: String, + contentType: ContentType = ContentType.JPEG, + folderId: Long? = null, + ): Result = runSuspendCatching { + val fileName = ContentTypeUtil.generateFileName(contentType) + + // 1. 업로드 티켓 발급 (mediaId, presignedUrl) + val (mediaId, presignedUrl) = mediaUploadRepository.getSingleUploadTicket( + fileName = fileName, + contentType = contentType.label, + mediaType = MediaType.PHOTO_BOOTH.name, + ).getOrThrow() + + // 2. Presigned URL로 이미지 업로드 + mediaUploadRepository.uploadImageFromUrl( + uploadUrl = presignedUrl, + imageUrl = imageUrl, + contentType = contentType, + ).getOrThrow() + + // 3. 사진 등록 + photoRepository.registerPhoto( + mediaIds = listOf(mediaId), + folderId = folderId, + ).getOrThrow() + + return@runSuspendCatching Media( + mediaId = mediaId, + folderId = folderId, + fileName = fileName, + contentType = contentType, + ) + } +} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index a0afa3df4..685dd9640 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -1,3 +1,10 @@ plugins { alias(libs.plugins.neki.kotlin.library) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + compileOnly(libs.compose.stable.marker) + api(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) } diff --git a/core/model/src/main/java/com/neki/android/core/model/Album.kt b/core/model/src/main/java/com/neki/android/core/model/Album.kt new file mode 100644 index 000000000..f73418f6f --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Album.kt @@ -0,0 +1,24 @@ +package com.neki.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class Album( + val id: Long = 0L, + val title: String = "", + val thumbnailUrl: String? = null, + val photoList: ImmutableList = persistentListOf(), +) + +@Serializable +@Immutable +data class AlbumPreview( + val id: Long = 0L, + val title: String = "", + val thumbnailUrl: String? = null, + val photoCount: Int = 0, +) diff --git a/core/model/src/main/java/com/neki/android/core/model/Auth.kt b/core/model/src/main/java/com/neki/android/core/model/Auth.kt new file mode 100644 index 000000000..bce24043f --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Auth.kt @@ -0,0 +1,6 @@ +package com.neki.android.core.model + +data class Auth( + val accessToken: String = "", + val refreshToken: String = "", +) diff --git a/core/model/src/main/java/com/neki/android/core/model/Brand.kt b/core/model/src/main/java/com/neki/android/core/model/Brand.kt new file mode 100644 index 000000000..60d609dcc --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Brand.kt @@ -0,0 +1,12 @@ +package com.neki.android.core.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class Brand( + val isChecked: Boolean = false, + val id: Long = 0L, + val name: String = "", + val code: String = "", + val imageUrl: String = "", +) diff --git a/core/model/src/main/java/com/neki/android/core/model/Media.kt b/core/model/src/main/java/com/neki/android/core/model/Media.kt new file mode 100644 index 000000000..d3c0e856e --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Media.kt @@ -0,0 +1,25 @@ +package com.neki.android.core.model + +data class Media( + val mediaId: Long = 0L, + val folderId: Long? = null, + val fileName: String = "", + val contentType: ContentType = ContentType.JPEG, + val mediaType: MediaType = MediaType.PHOTO_BOOTH, +) + +data class MediaUploadTicket( + val mediaId: Long, + val uploadUrl: String, +) + +enum class MediaType(val label: String) { + USER_PROFILE("user-profiles"), + PHOTO_BOOTH("photo-booth"), + ATTACHMENT("attachments"), +} + +enum class ContentType(val label: String) { + JPEG("image/jpeg"), + PNG("image/png"), +} diff --git a/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt b/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt new file mode 100644 index 000000000..1ea4d763d --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class PeopleCount(val displayText: String, val value: Int) { + ONE("1인", 1), + TWO("2인", 2), + THREE("3인", 3), + FOUR("4인", 4), + ; + + override fun toString(): String = displayText +} diff --git a/core/model/src/main/java/com/neki/android/core/model/Photo.kt b/core/model/src/main/java/com/neki/android/core/model/Photo.kt new file mode 100644 index 000000000..4bf01f306 --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Photo.kt @@ -0,0 +1,13 @@ +package com.neki.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class Photo( + val id: Long = 0L, + val imageUrl: String = "", + val isFavorite: Boolean = false, + val date: String = "", +) diff --git a/core/model/src/main/java/com/neki/android/core/model/PhotoBooth.kt b/core/model/src/main/java/com/neki/android/core/model/PhotoBooth.kt new file mode 100644 index 000000000..4d5f37d1a --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/PhotoBooth.kt @@ -0,0 +1,17 @@ +package com.neki.android.core.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class PhotoBooth( + val isFocused: Boolean = false, + val isCheckedBrand: Boolean = true, + val id: Long = 0L, + val brandName: String = "", + val branchName: String = "", + val address: String = "", + val longitude: Double = 0.0, + val latitude: Double = 0.0, + val distance: Int = 0, + val imageUrl: String = "", +) diff --git a/core/model/src/main/java/com/neki/android/core/model/Pose.kt b/core/model/src/main/java/com/neki/android/core/model/Pose.kt new file mode 100644 index 000000000..db0c1f3d4 --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Pose.kt @@ -0,0 +1,13 @@ +package com.neki.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class Pose( + val id: Long = 0L, + val poseImageUrl: String = "", + val isScrapped: Boolean = false, + val peopleCount: Int = 0, +) diff --git a/core/model/src/main/java/com/neki/android/core/model/Post.kt b/core/model/src/main/java/com/neki/android/core/model/Post.kt deleted file mode 100644 index d1c1748f5..000000000 --- a/core/model/src/main/java/com/neki/android/core/model/Post.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.neki.android.core.model - -data class Post( - val userId: Int = 0, - val id: Int = 0, - val title: String = "", - val body: String = "" -) \ No newline at end of file diff --git a/core/model/src/main/java/com/neki/android/core/model/SortOrder.kt b/core/model/src/main/java/com/neki/android/core/model/SortOrder.kt new file mode 100644 index 000000000..4852a0a6f --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/SortOrder.kt @@ -0,0 +1,6 @@ +package com.neki.android.core.model + +enum class SortOrder { + ASC, + DESC, +} diff --git a/core/model/src/main/java/com/neki/android/core/model/UploadType.kt b/core/model/src/main/java/com/neki/android/core/model/UploadType.kt new file mode 100644 index 000000000..c6a5b8264 --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/UploadType.kt @@ -0,0 +1,6 @@ +package com.neki.android.core.model + +enum class UploadType { + QR_CODE, + GALLERY, +} diff --git a/core/model/src/main/java/com/neki/android/core/model/UserInfo.kt b/core/model/src/main/java/com/neki/android/core/model/UserInfo.kt new file mode 100644 index 000000000..9f370f406 --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/UserInfo.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.model + +data class UserInfo( + val id: Long = 0L, + val nickname: String = "", + val profileImageUrl: String = "", + val loginType: String = "", +) diff --git a/feature/sample/api/.gitignore b/core/navigation/.gitignore similarity index 100% rename from feature/sample/api/.gitignore rename to core/navigation/.gitignore diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts new file mode 100644 index 000000000..2d21a85a2 --- /dev/null +++ b/core/navigation/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.neki.android.library) + alias(libs.plugins.neki.android.library.compose) + alias(libs.plugins.neki.hilt) +} + +android { + namespace = "com.neki.android.core.navigation" +} + +dependencies { + api(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewModel.navigation3) +} \ No newline at end of file diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt new file mode 100644 index 000000000..00cf422ca --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt @@ -0,0 +1,130 @@ +package com.neki.android.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner + +/** + * Hilt ViewModel 공유를 지원하는 NavEntryDecorator. + * + * 사용법: + * 1. NavDisplay에 decorator 등록 + * 2. 부모 entry에 clazzContentKey 설정 + * 3. 자식 entry에 parent() 메타데이터 설정 + * + * @see HiltSharedViewModelStoreNavEntryDecorator.parent + */ +@Composable +fun rememberHiltSharedViewModelStoreNavEntryDecorator( + viewModelStoreOwner: ViewModelStoreOwner = + checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + removeViewModelStoreOnPop: () -> Boolean = { true }, +): HiltSharedViewModelStoreNavEntryDecorator { + val currentRemoveViewModelStoreOnPop = rememberUpdatedState(removeViewModelStoreOnPop) + return remember(viewModelStoreOwner, currentRemoveViewModelStoreOnPop) { + HiltSharedViewModelStoreNavEntryDecorator( + viewModelStore = viewModelStoreOwner.viewModelStore, + removeViewModelStoreOnPop = currentRemoveViewModelStoreOnPop.value, + ) + } +} + +/** + * 부모-자식 NavEntry 간 ViewModel 공유를 위한 Decorator. + * hiltViewModel()과 함께 사용 가능. + */ +class HiltSharedViewModelStoreNavEntryDecorator( + viewModelStore: ViewModelStore, + removeViewModelStoreOnPop: () -> Boolean, +) : NavEntryDecorator( + onPop = { key -> + if (removeViewModelStoreOnPop()) { + viewModelStore.getHiltEntryViewModel().clearViewModelStoreOwnerForKey(key) + } + }, + decorate = { entry -> + // parent() 메타데이터가 있으면 부모의 contentKey 사용 + val contentKey = entry.metadata[PARENT_CONTENT_KEY] ?: entry.contentKey + val entryViewModelStore = + viewModelStore.getHiltEntryViewModel().viewModelStoreForKey(contentKey) + + val baseOwner = checkNotNull(LocalViewModelStoreOwner.current) + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + + val childViewModelStoreOwner = remember(entryViewModelStore, savedStateRegistryOwner, baseOwner) { + object : ViewModelStoreOwner, + SavedStateRegistryOwner by savedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + + override val viewModelStore: ViewModelStore + get() = entryViewModelStore + + // ✅ 이게 핵심: 부모 owner의 factory/extras를 그대로 전달 + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = (baseOwner as HasDefaultViewModelProviderFactory).defaultViewModelProviderFactory + + override val defaultViewModelCreationExtras: CreationExtras + get() = (baseOwner as HasDefaultViewModelProviderFactory).defaultViewModelCreationExtras + + init { + require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) + enableSavedStateHandles() + } + } + } + + CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelStoreOwner) { + entry.Content() + } + }, +) { + companion object { + private const val PARENT_CONTENT_KEY = "hilt_shared_decorator_parent_content_key" + + /** + * 자식 entry가 부모의 ViewModelStore를 공유하도록 설정. + * @param contentKey 부모 entry의 clazzContentKey 값과 동일해야 함 + */ + fun parent(contentKey: Any) = mapOf(PARENT_CONTENT_KEY to contentKey) + } +} + +/** NavEntry별 ViewModelStore 관리용 ViewModel */ +private class HiltEntryViewModel : ViewModel() { + private val owners = mutableMapOf() + + fun viewModelStoreForKey(key: Any): ViewModelStore = owners.getOrPut(key) { ViewModelStore() } + + fun clearViewModelStoreOwnerForKey(key: Any) { + owners.remove(key)?.clear() + } + + override fun onCleared() { + owners.forEach { (_, store) -> store.clear() } + } +} + +private fun ViewModelStore.getHiltEntryViewModel(): HiltEntryViewModel { + val provider = ViewModelProvider.create( + store = this, + factory = viewModelFactory { initializer { HiltEntryViewModel() } }, + ) + return provider[HiltEntryViewModel::class] +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt new file mode 100644 index 000000000..a0fd494ae --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt @@ -0,0 +1,51 @@ +package com.neki.android.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import javax.inject.Inject + +class NavigationState @Inject constructor( + val startKey: NavKey, + val topLevelKeys: Set, +) { + val topLevelStack: SnapshotStateList = mutableStateListOf(startKey) + val subStacks = topLevelKeys.associateWith { key -> mutableStateListOf(key) } + val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() } + + val currentSubStack: SnapshotStateList + get() = subStacks[currentTopLevelKey] + ?: error("Sub stack for $currentTopLevelKey does not exist") + + val currentKey: NavKey by derivedStateOf { currentSubStack.last() } +} + +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry, +): SnapshotStateList> { + val decoratedEntries = subStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + rememberHiltSharedViewModelStoreNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider, + ) + } + + return topLevelStack + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/Navigator.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/Navigator.kt new file mode 100644 index 000000000..3ccbaa953 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/Navigator.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.root.RootNavKey + +typealias EntryProviderInstaller = EntryProviderScope.() -> Unit + +interface Navigator { + fun navigateRoot(rootNavKey: RootNavKey) + fun navigate(key: NavKey) + fun goBack() + fun remove(key: NavKey) +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt new file mode 100644 index 000000000..0ba4f709c --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt @@ -0,0 +1,74 @@ +package com.neki.android.core.navigation + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.root.RootNavKey +import com.neki.android.core.navigation.root.RootNavigationState +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +@ActivityRetainedScoped +class NavigatorImpl @Inject constructor( + private val rootState: RootNavigationState, + val state: NavigationState, +) : Navigator { + override fun navigateRoot(rootNavKey: RootNavKey) { + clearRootSubStack() + rootState.stack.clear() + rootState.stack.add(rootNavKey) + } + + private fun clearRootSubStack() { + state.topLevelStack.clear() + state.topLevelStack.add(state.startKey) + state.subStacks.forEach { (key, stack) -> + stack.clear() + stack.add(key) + } + } + + override fun navigate(key: NavKey) { + when (key) { + state.currentTopLevelKey -> clearSubStack() + in state.topLevelKeys -> goToTopLevel(key) + else -> goToKey(key) + } + } + + override fun goBack() { + when (state.currentKey) { + state.startKey -> error("You cannot go back from the start route") + state.currentTopLevelKey -> { + state.topLevelStack.removeLastOrNull() + } + + else -> state.currentSubStack.removeLastOrNull() + } + } + + private fun goToKey(key: NavKey) { + state.currentSubStack.apply { + remove(key) + add(key) + } + } + + private fun goToTopLevel(key: NavKey) { + state.topLevelStack.apply { + if (key == state.startKey) clear() + else remove(key) + add(key) + } + } + + private fun clearSubStack() { + state.currentSubStack.run { + if (size > 1) subList(1, size).clear() + } + } + + override fun remove(key: NavKey) { + state.currentSubStack.apply { + remove(key) + } + } +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEffect.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEffect.kt new file mode 100644 index 000000000..ff92f5683 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEffect.kt @@ -0,0 +1,31 @@ +package com.neki.android.core.navigation.result + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +/** + * An Effect to provide a result even between different screens + * + * The trailing lambda provides the result from a flow of results. + * + * @param resultEventBus the ResultEventBus to retrieve the result from. The default value + * is read from the `LocalResultEventBus` composition local. + * @param resultKey the key that should be associated with this effect + * @param onResult the callback to invoke when a result is received + */ + +@Composable +inline fun ResultEffect( + resultEventBus: ResultEventBus = LocalResultEventBus.current, + resultKey: String = T::class.toString(), + withRemove: Boolean = true, + crossinline onResult: suspend (T) -> Unit, +) { + LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { + resultEventBus.getResultFlow(resultKey)?.collect { result -> + onResult.invoke(result as T) + + if (withRemove) resultEventBus.removeResult(resultKey) + } + } +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt new file mode 100644 index 000000000..fd49cb2e9 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt @@ -0,0 +1,60 @@ +package com.neki.android.core.navigation.result + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val mLocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { null } + + val current: ResultEventBus + @Composable + get() = mLocalResultEventBus.current ?: error("No ResultEventBus has been provided") + + infix fun provides( + bus: ResultEventBus, + ): ProvidedValue { + return mLocalResultEventBus.provides(bus) + } +} + +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +// https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/README.md +class ResultEventBus { + val channelMap: MutableMap> = mutableMapOf() + + inline fun getResultFlow(resultKey: String = T::class.toString()) = + channelMap[resultKey]?.receiveAsFlow() + + inline fun sendResult( + resultKey: String = T::class.toString(), + result: T, + allowDuplicate: Boolean = true, + ) { + if (!channelMap.contains(resultKey)) { + channelMap[resultKey] = if (allowDuplicate) { + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } else { + Channel(capacity = Channel.CONFLATED) + } + } + channelMap[resultKey]?.trySend(result) + } + + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap.remove(resultKey) + } +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt new file mode 100644 index 000000000..3405cfefa --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.navigation.root + +import androidx.navigation3.runtime.NavKey + +sealed interface RootNavKey : NavKey { + data object Login : RootNavKey + data object Main : RootNavKey +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt new file mode 100644 index 000000000..48710eba6 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.navigation.root + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import javax.inject.Inject + +class RootNavigationState @Inject constructor( + val startKey: RootNavKey, +) { + internal val stack: SnapshotStateList = mutableStateListOf(startKey) + val currentRootKey: RootNavKey by derivedStateOf { stack.last() } +} diff --git a/feature/sample/impl/.gitignore b/core/ui/.gitignore similarity index 100% rename from feature/sample/impl/.gitignore rename to core/ui/.gitignore diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 000000000..d4a92c5a3 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.neki.android.library) + alias(libs.plugins.neki.android.library.compose) +} + + +android { + namespace = "com.neki.android.core.ui" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.designsystem) + implementation(projects.core.model) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.kotlinx.collections.immutable) + + api(libs.coil.compose) + api(libs.coil.network.okhttp) +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/MviIntentStore.kt b/core/ui/src/main/java/com/neki/android/core/ui/MviIntentStore.kt new file mode 100644 index 000000000..6e1aa677f --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/MviIntentStore.kt @@ -0,0 +1,71 @@ +package com.neki.android.core.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +interface MviIntentStore { + val uiState: StateFlow + val sideEffects: Flow + fun onIntent(intent: INTENT) +} + +class MviIntentStoreImpl( + initialState: STATE, + initialFetchData: () -> Unit, + private val coroutineScope: CoroutineScope, + private val onIntent: ( + intent: INTENT, + state: STATE, + reduce: (STATE.() -> STATE) -> Unit, + postSideEffect: (EFFECT) -> Unit, + ) -> Unit, +) : MviIntentStore { + private val _uiState = MutableStateFlow(initialState) + override val uiState: StateFlow = _uiState + .onStart { initialFetchData() } + .stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = initialState, + ) + private val _sideEffects = Channel(Channel.BUFFERED) + override val sideEffects: Flow = _sideEffects.receiveAsFlow() + private fun setState(reduce: STATE.() -> STATE) { + _uiState.update(reduce) + } + + private fun postSideEffect(effect: EFFECT) { + coroutineScope.launch { _sideEffects.send(effect) } + } + + override fun onIntent(intent: INTENT) { + onIntent( + intent, + _uiState.value, + { reduce -> setState { reduce() } }, + { effect -> postSideEffect(effect) }, + ) + } +} + +fun ViewModel.mviIntentStore( + initialState: STATE, + onIntent: (INTENT, STATE, (STATE.() -> STATE) -> Unit, (EFFECT) -> Unit) -> Unit, + initialFetchData: () -> Unit = {}, +): MviIntentStore = MviIntentStoreImpl( + initialState = initialState, + coroutineScope = viewModelScope, + onIntent = onIntent, + initialFetchData = initialFetchData, +) diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt new file mode 100644 index 000000000..606c7b9f7 --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/AlbumRowComponent.kt @@ -0,0 +1,214 @@ +package com.neki.android.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.AlbumPreview + +@Composable +fun FavoriteAlbumRowComponent( + album: AlbumPreview, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .noRippleClickable(onClick = onClick) + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + FavoriteAlbumThumbnail( + thumbnailUrl = album.thumbnailUrl, + ) + + AlbumInfo( + title = "즐겨찾는 사진", + photoCount = album.photoCount, + ) + } +} + +@Composable +fun AlbumRowComponent( + album: AlbumPreview, + modifier: Modifier = Modifier, + isSelectable: Boolean = false, + isSelected: Boolean = false, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .noRippleClickable(onClick = onClick) + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + AlbumThumbnail( + thumbnailUrl = album.thumbnailUrl, + ) + + AlbumInfo( + modifier = Modifier.weight(1f), + title = album.title, + photoCount = album.photoCount, + ) + + if (isSelectable) { + SelectionCheckbox( + isSelected = isSelected, + ) + } + } +} + +@Composable +private fun FavoriteAlbumThumbnail( + thumbnailUrl: String?, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.matchParentSize(), + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + + Box( + modifier = Modifier + .matchParentSize() + .background(NekiTheme.colorScheme.favoriteAlbumCover.copy(alpha = 0.5f)), + ) + + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_heart), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } +} + +@Composable +private fun AlbumThumbnail( + thumbnailUrl: String?, + modifier: Modifier = Modifier, +) { + AsyncImage( + modifier = modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + color = NekiTheme.colorScheme.gray50, + shape = RoundedCornerShape(8.dp), + ), + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + ) +} + +@Composable +private fun AlbumInfo( + title: String, + photoCount: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + + Text( + text = "${photoCount}장", + style = NekiTheme.typography.caption12Medium, + color = NekiTheme.colorScheme.gray500, + ) + } +} + +@ComponentPreview +@Composable +private fun FavoriteAlbumRowComponentPreview() { + NekiTheme { + FavoriteAlbumRowComponent( + album = AlbumPreview( + id = 0, + title = "즐겨찾는 사진", + ), + ) + } +} + +@ComponentPreview +@Composable +private fun AlbumRowComponentPreview() { + NekiTheme { + AlbumRowComponent( + album = AlbumPreview( + id = 1, + title = "일반앨범제목", + ), + ) + } +} + +@ComponentPreview +@Composable +private fun AlbumRowComponentSelectablePreview() { + NekiTheme { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + AlbumRowComponent( + album = AlbumPreview( + id = 1, + title = "선택되지 않은 앨범", + ), + isSelectable = true, + isSelected = false, + ) + + AlbumRowComponent( + album = AlbumPreview( + id = 2, + title = "선택된 앨범", + ), + isSelectable = true, + isSelected = true, + ) + } + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt new file mode 100644 index 000000000..5be1d7652 --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt @@ -0,0 +1,218 @@ +package com.neki.android.core.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.bottomsheet.BottomSheetDragHandle +import com.neki.android.core.designsystem.button.CTAButtonGray +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DoubleButtonOptionBottomSheet( + title: String, + options: ImmutableList, + primaryButtonText: String, + secondaryButtonText: String, + selectedOption: T?, + onDismissRequest: () -> Unit, + onClickSecondaryButton: () -> Unit, + onClickPrimaryButton: () -> Unit, + onOptionSelect: (T) -> Unit, + modifier: Modifier = Modifier, + buttonEnabled: Boolean = true, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + containerColor = NekiTheme.colorScheme.white, + dragHandle = { BottomSheetDragHandle(color = NekiTheme.colorScheme.gray100) }, + ) { + DoubleButtonOptionBottomSheetContent( + title = title, + options = options, + selectedOption = selectedOption, + onClickCancel = onClickSecondaryButton, + onClickDoubleButton = onClickPrimaryButton, + onOptionSelect = onOptionSelect, + buttonEnabled = buttonEnabled, + primaryButtonText = primaryButtonText, + secondaryButtonText = secondaryButtonText, + ) + } +} + +@Composable +internal fun DoubleButtonOptionBottomSheetContent( + title: String, + options: ImmutableList, + primaryButtonText: String, + secondaryButtonText: String, + selectedOption: T?, + onClickCancel: () -> Unit, + onClickDoubleButton: () -> Unit, + onOptionSelect: (T) -> Unit, + modifier: Modifier = Modifier, + buttonEnabled: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 34.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEach { option -> + OptionRow( + label = option.toString(), + isSelected = selectedOption == option, + onClick = { onOptionSelect(option) }, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CTAButtonGray( + modifier = Modifier.weight(93f), + text = secondaryButtonText, + onClick = onClickCancel, + enabled = buttonEnabled, + ) + CTAButtonPrimary( + modifier = Modifier.weight(230f), + text = primaryButtonText, + onClick = onClickDoubleButton, + enabled = buttonEnabled, + ) + } + } +} + +@Composable +private fun OptionRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isSelected) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_check), + contentDescription = null, + tint = NekiTheme.colorScheme.primary500, + ) + } + + Text( + text = label, + style = if (isSelected) NekiTheme.typography.body16SemiBold else NekiTheme.typography.body16Medium, + color = if (isSelected) NekiTheme.colorScheme.gray900 else NekiTheme.colorScheme.gray400, + ) + } +} + +private enum class PreviewOption(val label: String) { + OPTION_1("옵션 1"), + OPTION_2("옵션 2"), + ; + + override fun toString(): String = label +} + +@ComponentPreview +@Composable +private fun DoubleButtonOptionBottomSheetContentPreview() { + NekiTheme { + DoubleButtonOptionBottomSheetContent( + title = "삭제하시겠어요?", + options = PreviewOption.entries.toImmutableList(), + primaryButtonText = "확인", + secondaryButtonText = "취소", + selectedOption = PreviewOption.OPTION_1, + onClickCancel = {}, + onClickDoubleButton = {}, + onOptionSelect = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun DoubleButtonOptionBottomSheetContentOption2Preview() { + NekiTheme { + DoubleButtonOptionBottomSheetContent( + title = "삭제하시겠어요?", + options = PreviewOption.entries.toImmutableList(), + primaryButtonText = "확인", + secondaryButtonText = "취소", + selectedOption = PreviewOption.OPTION_2, + onClickCancel = {}, + onClickDoubleButton = {}, + onOptionSelect = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun DoubleButtonOptionBottomSheetContentDisabledPreview() { + NekiTheme { + DoubleButtonOptionBottomSheetContent( + title = "삭제하시겠어요?", + options = PreviewOption.entries.toImmutableList(), + primaryButtonText = "확인", + secondaryButtonText = "취소", + selectedOption = null, + onClickCancel = {}, + onClickDoubleButton = {}, + onOptionSelect = {}, + buttonEnabled = false, + ) + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/FilterBar.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/FilterBar.kt new file mode 100644 index 000000000..eab33be43 --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/FilterBar.kt @@ -0,0 +1,143 @@ +package com.neki.android.core.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun FilterBar( + isDownIconChipSelected: Boolean, + isDefaultChipSelected: Boolean, + downIconChipDisplayText: String, + defaultChipDisplayText: String, + modifier: Modifier = Modifier, + visible: Boolean = true, + onClickDownIconChip: () -> Unit = {}, + onClickDefaultChip: () -> Unit = {}, +) { + AnimatedVisibility( + modifier = modifier + .background(NekiTheme.colorScheme.white) + .fillMaxWidth(), + visible = visible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DownIconFilterChip( + isSelected = isDownIconChipSelected, + displayText = downIconChipDisplayText, + onClick = onClickDownIconChip, + ) + DefaultFilterChip( + isSelected = isDefaultChipSelected, + displayText = defaultChipDisplayText, + onClick = onClickDefaultChip, + ) + } + } +} + +@Composable +private fun DownIconFilterChip( + isSelected: Boolean, + displayText: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .background( + shape = CircleShape, + color = if (isSelected) NekiTheme.colorScheme.gray800 + else NekiTheme.colorScheme.gray50, + ) + .padding(vertical = 7.dp, horizontal = 12.dp) + .noRippleClickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayText, + style = NekiTheme.typography.body14Medium, + color = if (isSelected) NekiTheme.colorScheme.white + else NekiTheme.colorScheme.gray700, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_down), + contentDescription = null, + tint = NekiTheme.colorScheme.gray400, + ) + } +} + +@Composable +private fun DefaultFilterChip( + isSelected: Boolean, + displayText: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Text( + modifier = modifier + .background( + shape = CircleShape, + color = if (isSelected) NekiTheme.colorScheme.gray800 + else NekiTheme.colorScheme.gray50, + ) + .padding(vertical = 7.dp, horizontal = 12.dp) + .noRippleClickable(onClick = onClick), + text = displayText, + style = NekiTheme.typography.body14Medium, + color = if (isSelected) NekiTheme.colorScheme.white + else NekiTheme.colorScheme.gray700, + ) +} + +@ComponentPreview +@Composable +private fun FilterBarDefaultPreview() { + NekiTheme { + FilterBar( + isDownIconChipSelected = false, + isDefaultChipSelected = false, + downIconChipDisplayText = "인원수", + defaultChipDisplayText = "스크랩", + ) + } +} + +@ComponentPreview +@Composable +private fun FilterBarSelectedPreview() { + NekiTheme { + FilterBar( + isDownIconChipSelected = true, + isDefaultChipSelected = true, + downIconChipDisplayText = "2인", + defaultChipDisplayText = "스크랩", + ) + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/LoadingIndicator.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/LoadingIndicator.kt new file mode 100644 index 000000000..c6f16c7db --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/LoadingIndicator.kt @@ -0,0 +1,56 @@ +package com.neki.android.core.ui.component + +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun LoadingDialog( + modifier: Modifier = Modifier, + circleColor: Color = NekiTheme.colorScheme.primary300, + backgroundColor: Color = NekiTheme.colorScheme.primary100, + properties: DialogProperties = DialogProperties(), + onDismissRequest: () -> Unit = {}, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + CircularProgressIndicator( + modifier = modifier, + strokeWidth = 6.dp, + color = circleColor, + trackColor = backgroundColor, + ) + } +} + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, + circleColor: Color = NekiTheme.colorScheme.primary300, + backgroundColor: Color = NekiTheme.colorScheme.primary100, +) { + CircularProgressIndicator( + modifier = modifier, + strokeWidth = 6.dp, + color = circleColor, + trackColor = backgroundColor, + ) +} + +@ComponentPreview +@Composable +private fun LoadingDialogPreview() { + NekiTheme { + LoadingDialog( + onDismissRequest = {}, + ) + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/PhotoComponent.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/PhotoComponent.kt new file mode 100644 index 000000000..2cb84b113 --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/PhotoComponent.kt @@ -0,0 +1,54 @@ +package com.neki.android.core.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Photo + +@Composable +fun PhotoComponent( + photo: Photo, + modifier: Modifier = Modifier, + onClickItem: (Photo) -> Unit = {}, + additionalContent: @Composable BoxScope.() -> Unit = {}, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .noRippleClickable { onClickItem(photo) }, + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + model = photo.imageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + ) + + additionalContent() + } +} + +@ComponentPreview +@Composable +private fun PhotoComponentPreview() { + NekiTheme { + PhotoComponent( + modifier = Modifier.size(120.dp), + photo = Photo(), + ) + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/SelectionCheckbox.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/SelectionCheckbox.kt new file mode 100644 index 000000000..5ea1412dc --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/SelectionCheckbox.kt @@ -0,0 +1,81 @@ +package com.neki.android.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +fun SelectionCheckbox( + isSelected: Boolean, + modifier: Modifier = Modifier, + size: Dp = 24.dp, + selectedColor: Color = NekiTheme.colorScheme.primary400, + unselectedColor: Color = NekiTheme.colorScheme.white, + borderColor: Color = NekiTheme.colorScheme.gray100, +) { + val iconSize = size * 2 / 3 + + Box( + modifier = modifier + .size(size) + .then( + if (isSelected) { + Modifier.background( + color = selectedColor, + shape = CircleShape, + ) + } else { + Modifier + .background( + color = unselectedColor, + shape = CircleShape, + ) + .border( + width = 1.dp, + color = borderColor, + shape = CircleShape, + ) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + modifier = Modifier.size(iconSize), + imageVector = ImageVector.vectorResource(R.drawable.icon_check), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } +} + +@ComponentPreview +@Composable +private fun SelectionCheckboxUnselectedPreview() { + NekiTheme { + SelectionCheckbox(isSelected = false) + } +} + +@ComponentPreview +@Composable +private fun SelectionCheckboxSelectedPreview() { + NekiTheme { + SelectionCheckbox(isSelected = true) + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/compose/Flow.kt b/core/ui/src/main/java/com/neki/android/core/ui/compose/Flow.kt new file mode 100644 index 000000000..3e3603cb0 --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/compose/Flow.kt @@ -0,0 +1,21 @@ +package com.neki.android.core.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow + +@Composable +inline fun Flow.collectWithLifecycle( + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + noinline action: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(this, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) { + this@collectWithLifecycle.collect { action(it) } + } + } +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/compose/Spacer.kt b/core/ui/src/main/java/com/neki/android/core/ui/compose/Spacer.kt new file mode 100644 index 000000000..a32dc26d9 --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/compose/Spacer.kt @@ -0,0 +1,39 @@ +package com.neki.android.core.ui.compose + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun HorizontalSpacer( + width: Dp = 0.dp, +) { + Spacer(modifier = Modifier.width(width)) +} + +@Composable +fun RowScope.HorizontalSpacer( + weight: Float, +) { + Spacer(modifier = Modifier.weight(weight)) +} + +@Composable +fun VerticalSpacer( + height: Dp = 0.dp, +) { + Spacer(modifier = Modifier.height(height)) +} + +@Composable +fun ColumnScope.VerticalSpacer( + weight: Float, +) { + Spacer(modifier = Modifier.weight(weight)) +} diff --git a/core/ui/src/main/java/com/neki/android/core/ui/toast/NekiToast.kt b/core/ui/src/main/java/com/neki/android/core/ui/toast/NekiToast.kt new file mode 100644 index 000000000..43a28e2cc --- /dev/null +++ b/core/ui/src/main/java/com/neki/android/core/ui/toast/NekiToast.kt @@ -0,0 +1,95 @@ +package com.neki.android.core.ui.toast + +import android.content.Context +import android.content.res.Resources +import android.view.Gravity +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.toast.NekiActionToast +import com.neki.android.core.designsystem.toast.NekiToast +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +class NekiToast( + private val context: Context, +) : Toast(context) { + private fun makeText( + duration: Int = LENGTH_SHORT, + toast: @Composable () -> Unit, + ) { + val activity = context as ComponentActivity + + val composeView = ComposeView(context).apply { + setContent { + NekiTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + toast() + } + } + } + + setViewTreeLifecycleOwner(activity) + setViewTreeSavedStateRegistryOwner(activity) + setViewTreeViewModelStoreOwner(activity) + } + + this.duration = duration + view = composeView + setGravity( + Gravity.FILL_HORIZONTAL or Gravity.BOTTOM, + 0, + (28 * Resources.getSystem().displayMetrics.density).toInt(), + ) + + show() + } + + fun showToast( + text: String, + @DrawableRes iconRes: Int = R.drawable.icon_checkbox_on, + duration: Int = LENGTH_SHORT, + ) { + makeText( + duration = duration, + ) { + NekiToast( + iconRes = iconRes, + text = text, + ) + } + } + + fun showActionToast( + @DrawableRes iconRes: Int, + text: String, + buttonText: String, + onClickActionButton: () -> Unit, + duration: Int = LENGTH_SHORT, + ) { + makeText( + duration = duration, + ) { + NekiActionToast( + iconRes = iconRes, + text = text, + buttonText = buttonText, + onClickActionButton = onClickActionButton, + ) + } + } +} diff --git a/detekt-config.yml b/detekt-config.yml new file mode 100644 index 000000000..f034288ca --- /dev/null +++ b/detekt-config.yml @@ -0,0 +1,89 @@ +complexity: + LongMethod: + threshold: 100 + ignoreAnnotated: [ 'Composable' ] + CyclomaticComplexMethod: + threshold: 40 + ignoreAnnotated: [ 'Composable' ] + LongParameterList: + active: false + TooManyFunctions: + active: false + ComplexCondition: + threshold: 7 + +performance: + SpreadOperator: + active: false + +formatting: + TrailingCommaOnCallSite: + active: true + TrailingCommaOnDeclarationSite: + active: true + ImportOrdering: + active: false + MaximumLineLength: + active: false + MultiLineIfElse: + active: false + Indentation: + indentSize: 4 + ParameterListWrapping: + active: false + ArgumentListWrapping: + indentSize: 4 + maxLineLength: 150 + KdocWrapping: + active: true + indentSize: 4 + Wrapping: + indentSize: 4 + active: false + CommentWrapping: + active: false + Filename: + active: false + PackageName: + active: false + AnnotationOnSeparateLine: + active: false + +exceptions: + TooGenericExceptionCaught: + active: false + +style: + FunctionOnlyReturningConstant: + active: false + UnusedParameter: + active: false + MagicNumber: + active: false + ignoreAnnotated: [ 'AllowMagicNumber' ] + ForbiddenComment: + active: false + UnusedPrivateMember: + ignoreAnnotated: [ 'Preview', 'ComponentPreview', 'DevicePreview' ] + ThrowsCount: + active: false + ReturnCount: + active: false + LoopWithTooManyJumpStatements: + active: false + DestructuringDeclarationWithTooManyEntries: + active: false + MaxLineLength: + active: false + +naming: + TopLevelPropertyNaming: + constantPattern: '[A-Z][A-Za-z0-9_]*' + FunctionNaming: + ignoreAnnotated: [ 'Composable' ] + MatchingDeclarationName: + active: false + VariableNaming: + active: false + PackageNaming: + packagePattern: '[a-z]+(\._?[_?A-Za-z0-9]*)*' diff --git a/feature/archive/api/.gitignore b/feature/archive/api/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/feature/archive/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/archive/api/build.gradle.kts b/feature/archive/api/build.gradle.kts new file mode 100644 index 000000000..356bd1f24 --- /dev/null +++ b/feature/archive/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.archive.api" +} diff --git a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt new file mode 100644 index 000000000..a8e57c9fc --- /dev/null +++ b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt @@ -0,0 +1,48 @@ +package com.neki.android.feature.archive.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.model.Photo +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface ArchiveNavKey : NavKey { + + @Serializable + data object Archive : ArchiveNavKey + + @Serializable + data object AllPhoto : ArchiveNavKey + + @Serializable + data object AllAlbum : ArchiveNavKey + + @Serializable + data class AlbumDetail( + val isFavorite: Boolean, + val title: String, + val albumId: Long, + ) : ArchiveNavKey + + @Serializable + data class PhotoDetail(val photo: Photo) : ArchiveNavKey +} + +fun Navigator.navigateToArchive() { + navigate(ArchiveNavKey.Archive) +} + +fun Navigator.navigateToAllPhoto() { + navigate(ArchiveNavKey.AllPhoto) +} + +fun Navigator.navigateToAllAlbum() { + navigate(ArchiveNavKey.AllAlbum) +} + +fun Navigator.navigateToAlbumDetail(id: Long, title: String = "", isFavorite: Boolean = false) { + navigate(ArchiveNavKey.AlbumDetail(isFavorite, title, id)) +} + +fun Navigator.navigateToPhotoDetail(photo: Photo) { + navigate(ArchiveNavKey.PhotoDetail(photo)) +} diff --git a/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt new file mode 100644 index 000000000..dd4bea93d --- /dev/null +++ b/feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveResult.kt @@ -0,0 +1,9 @@ +package com.neki.android.feature.archive.api + +sealed interface ArchiveResult { + data class PhotoDeleted(val photoId: List) : ArchiveResult { + constructor(photoId: Long) : this(listOf(photoId)) + } + + data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : ArchiveResult +} diff --git a/feature/archive/impl/.gitignore b/feature/archive/impl/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/feature/archive/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/archive/impl/build.gradle.kts b/feature/archive/impl/build.gradle.kts new file mode 100644 index 000000000..7540e385c --- /dev/null +++ b/feature/archive/impl/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.archive.impl" +} + +dependencies { + implementation(projects.feature.archive.api) + implementation(projects.feature.photoUpload.api) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.paging.compose) +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt new file mode 100644 index 000000000..62771d983 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumContract.kt @@ -0,0 +1,61 @@ +package com.neki.android.feature.archive.impl.album + +import com.neki.android.core.model.AlbumPreview +import com.neki.android.feature.archive.impl.model.SelectMode +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class AllAlbumState( + val isLoading: Boolean = false, + val favoriteAlbum: AlbumPreview = AlbumPreview(), + val albums: ImmutableList = persistentListOf(), + val selectMode: SelectMode = SelectMode.DEFAULT, + val selectedAlbums: ImmutableList = persistentListOf(), + val isShowOptionPopup: Boolean = false, + val selectedDeleteOption: AlbumDeleteOption = AlbumDeleteOption.DELETE_WITH_PHOTOS, + val isShowAddAlbumBottomSheet: Boolean = false, + val isShowDeleteAlbumBottomSheet: Boolean = false, +) + +enum class AlbumDeleteOption(val label: String) { + DELETE_WITH_PHOTOS("사진까지 함께 삭제"), + DELETE_ALBUM_ONLY("사진은 유지하고 앨범만 삭제"), + ; + + override fun toString(): String = label +} + +sealed interface AllAlbumIntent { + + data object EnterAllAlbumScreen : AllAlbumIntent + + // TopBar Intent + data object ClickBackIcon : AllAlbumIntent + data object OnBackPressed : AllAlbumIntent + data object ClickCreateButton : AllAlbumIntent + data object ClickOptionIcon : AllAlbumIntent + data object DismissOptionPopup : AllAlbumIntent + data object ClickDeleteOptionRow : AllAlbumIntent + data object ClickDeleteButton : AllAlbumIntent + data object ClickCancelDeleteMode : AllAlbumIntent + + // Album Intent + data object ClickFavoriteAlbum : AllAlbumIntent + data class ClickAlbumItem(val album: AlbumPreview) : AllAlbumIntent + + // Add Album BottomSheet Intent + data object DismissAddAlbumBottomSheet : AllAlbumIntent + data class ClickAddAlbumButton(val albumName: String) : AllAlbumIntent + + // Delete Album BottomSheet Intent + data object DismissDeleteAlbumBottomSheet : AllAlbumIntent + data class SelectDeleteOption(val option: AlbumDeleteOption) : AllAlbumIntent + data object ClickDeleteConfirmButton : AllAlbumIntent +} + +sealed interface AllAlbumSideEffect { + data object NavigateBack : AllAlbumSideEffect + data class NavigateToFavoriteAlbum(val albumId: Long) : AllAlbumSideEffect + data class NavigateToAlbumDetail(val albumId: Long, val title: String) : AllAlbumSideEffect + data class ShowToastMessage(val message: String) : AllAlbumSideEffect +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt new file mode 100644 index 000000000..2648fc37a --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt @@ -0,0 +1,200 @@ +package com.neki.android.feature.archive.impl.album + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.ui.component.AlbumRowComponent +import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet +import com.neki.android.core.ui.component.FavoriteAlbumRowComponent +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.archive.impl.album.component.AllAlbumTopBar +import com.neki.android.feature.archive.impl.component.AddAlbumBottomSheet +import com.neki.android.feature.archive.impl.model.SelectMode +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun AllAlbumRoute( + viewModel: AllAlbumViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToFavoriteAlbum: (Long) -> Unit, + navigateToAlbumDetail: (Long, String) -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + AllAlbumSideEffect.NavigateBack -> navigateBack() + is AllAlbumSideEffect.NavigateToFavoriteAlbum -> navigateToFavoriteAlbum(sideEffect.albumId) + is AllAlbumSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title) + is AllAlbumSideEffect.ShowToastMessage -> { + nekiToast.showToast(text = sideEffect.message) + } + } + } + + AllAlbumScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AllAlbumScreen( + uiState: AllAlbumState = AllAlbumState(), + onIntent: (AllAlbumIntent) -> Unit = {}, +) { + BackHandler(enabled = true) { + onIntent(AllAlbumIntent.OnBackPressed) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.white), + ) { + AllAlbumTopBar( + selectMode = uiState.selectMode, + showOptionPopup = uiState.isShowOptionPopup, + onClickBack = { onIntent(AllAlbumIntent.ClickBackIcon) }, + onClickCreate = { onIntent(AllAlbumIntent.ClickCreateButton) }, + onClickOption = { onIntent(AllAlbumIntent.ClickOptionIcon) }, + onDismissPopup = { onIntent(AllAlbumIntent.DismissOptionPopup) }, + onClickDeleteOption = { onIntent(AllAlbumIntent.ClickDeleteOptionRow) }, + onClickDelete = { onIntent(AllAlbumIntent.ClickDeleteButton) }, + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + item { + FavoriteAlbumRowComponent( + album = uiState.favoriteAlbum, + onClick = { onIntent(AllAlbumIntent.ClickFavoriteAlbum) }, + ) + } + + items( + items = uiState.albums, + key = { album -> album.id }, + ) { album -> + val isSelected = uiState.selectedAlbums.any { it.id == album.id } + AlbumRowComponent( + album = album, + isSelectable = uiState.selectMode == SelectMode.SELECTING, + isSelected = isSelected, + onClick = { onIntent(AllAlbumIntent.ClickAlbumItem(album)) }, + ) + } + } + } + + if (uiState.isShowAddAlbumBottomSheet) { + val textFieldState = rememberTextFieldState() + val existingAlbumNames = remember(uiState.albums) { uiState.albums.map { it.title } } + + val errorMessage by remember(textFieldState.text) { + derivedStateOf { + val name = textFieldState.text.toString() + if (existingAlbumNames.contains(name)) { + "이미 사용 중인 앨범명이에요." + } else { + null + } + } + } + + AddAlbumBottomSheet( + textFieldState = textFieldState, + onDismissRequest = { onIntent(AllAlbumIntent.DismissAddAlbumBottomSheet) }, + onClickCancel = { onIntent(AllAlbumIntent.DismissAddAlbumBottomSheet) }, + onClickConfirm = { + val albumName = textFieldState.text.toString() + if (errorMessage == null && albumName.isNotBlank()) { + onIntent(AllAlbumIntent.ClickAddAlbumButton(albumName)) + } + }, + isError = errorMessage != null, + errorMessage = errorMessage, + ) + } + + if (uiState.isShowDeleteAlbumBottomSheet) { + DoubleButtonOptionBottomSheet( + title = "앨범을 삭제하시겠어요?", + options = AlbumDeleteOption.entries.toImmutableList(), + primaryButtonText = "삭제하기", + secondaryButtonText = "취소", + selectedOption = uiState.selectedDeleteOption, + onDismissRequest = { onIntent(AllAlbumIntent.DismissDeleteAlbumBottomSheet) }, + onClickSecondaryButton = { onIntent(AllAlbumIntent.DismissDeleteAlbumBottomSheet) }, + onClickPrimaryButton = { onIntent(AllAlbumIntent.ClickDeleteConfirmButton) }, + onOptionSelect = { onIntent(AllAlbumIntent.SelectDeleteOption(it)) }, + ) + } +} + +@DevicePreview +@Composable +private fun AllAlbumScreenPreview() { + NekiTheme { + AllAlbumScreen( + uiState = AllAlbumState( + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + AlbumPreview(id = 4, title = "회사 송년회", photoCount = 5), + ), + ), + ) + } +} + +@DevicePreview +@Composable +private fun AllAlbumScreenSelectingPreview() { + NekiTheme { + AllAlbumScreen( + uiState = AllAlbumState( + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + ), + selectMode = SelectMode.SELECTING, + selectedAlbums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + ), + ), + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt new file mode 100644 index 000000000..8b803102b --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt @@ -0,0 +1,216 @@ +package com.neki.android.feature.archive.impl.album + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.archive.impl.model.SelectMode +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AllAlbumViewModel @Inject constructor( + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, +) : ViewModel() { + + val store: MviIntentStore = + mviIntentStore( + initialState = AllAlbumState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(AllAlbumIntent.EnterAllAlbumScreen) }, + ) + + private fun onIntent( + intent: AllAlbumIntent, + state: AllAlbumState, + reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, + postSideEffect: (AllAlbumSideEffect) -> Unit, + ) { + when (intent) { + AllAlbumIntent.EnterAllAlbumScreen -> fetchInitialData(reduce) + + // TopBar Intent + AllAlbumIntent.ClickBackIcon -> handleBackClick(state, reduce, postSideEffect) + AllAlbumIntent.OnBackPressed -> handleBackClick(state, reduce, postSideEffect) + AllAlbumIntent.ClickCreateButton -> reduce { copy(isShowAddAlbumBottomSheet = true) } + AllAlbumIntent.ClickOptionIcon -> reduce { copy(isShowOptionPopup = true) } + AllAlbumIntent.DismissOptionPopup -> reduce { copy(isShowOptionPopup = false) } + AllAlbumIntent.ClickDeleteOptionRow -> reduce { + copy( + isShowOptionPopup = false, + selectMode = SelectMode.SELECTING, + ) + } + + AllAlbumIntent.ClickDeleteButton -> handleDeleteButtonClick(state, reduce, postSideEffect) + AllAlbumIntent.ClickCancelDeleteMode -> reduce { + copy( + selectMode = SelectMode.DEFAULT, + selectedAlbums = persistentListOf(), + ) + } + + // Album Intent + AllAlbumIntent.ClickFavoriteAlbum -> postSideEffect( + AllAlbumSideEffect.NavigateToFavoriteAlbum(state.favoriteAlbum.id), + ) + + is AllAlbumIntent.ClickAlbumItem -> handleAlbumClick(intent.album, state, reduce, postSideEffect) + + // Add Album BottomSheet Intent + AllAlbumIntent.DismissAddAlbumBottomSheet -> reduce { copy(isShowAddAlbumBottomSheet = false) } + is AllAlbumIntent.ClickAddAlbumButton -> handleAddAlbum(intent.albumName, reduce, postSideEffect) + + // Delete Album BottomSheet Intent + AllAlbumIntent.DismissDeleteAlbumBottomSheet -> reduce { copy(isShowDeleteAlbumBottomSheet = false) } + is AllAlbumIntent.SelectDeleteOption -> reduce { copy(selectedDeleteOption = intent.option) } + AllAlbumIntent.ClickDeleteConfirmButton -> handleDeleteConfirm(state, reduce, postSideEffect) + } + } + + private fun fetchInitialData(reduce: (AllAlbumState.() -> AllAlbumState) -> Unit) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + try { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchFolders(reduce) }, + ) + } finally { + reduce { copy(isLoading = false) } + } + } + } + + private suspend fun fetchFavoriteSummary(reduce: (AllAlbumState.() -> AllAlbumState) -> Unit) { + photoRepository.getFavoriteSummary() + .onSuccess { data -> + reduce { copy(favoriteAlbum = data) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private suspend fun fetchFolders(reduce: (AllAlbumState.() -> AllAlbumState) -> Unit) { + folderRepository.getFolders() + .onSuccess { data -> + reduce { copy(albums = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private fun handleBackClick( + state: AllAlbumState, + reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, + postSideEffect: (AllAlbumSideEffect) -> Unit, + ) { + when (state.selectMode) { + SelectMode.DEFAULT -> postSideEffect(AllAlbumSideEffect.NavigateBack) + SelectMode.SELECTING -> reduce { + copy( + selectMode = SelectMode.DEFAULT, + selectedAlbums = persistentListOf(), + ) + } + } + } + + private fun handleDeleteButtonClick( + state: AllAlbumState, + reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, + postSideEffect: (AllAlbumSideEffect) -> Unit, + ) { + if (state.selectedAlbums.isEmpty()) { + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범을 선택해주세요.")) + return + } + reduce { copy(isShowDeleteAlbumBottomSheet = true) } + } + + private fun handleAlbumClick( + album: AlbumPreview, + state: AllAlbumState, + reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, + postSideEffect: (AllAlbumSideEffect) -> Unit, + ) { + when (state.selectMode) { + SelectMode.DEFAULT -> { + postSideEffect(AllAlbumSideEffect.NavigateToAlbumDetail(album.id, album.title)) + } + + SelectMode.SELECTING -> { + val album = state.albums.find { it.id == album.id } ?: return + val isSelected = state.selectedAlbums.any { it.id == album.id } + if (isSelected) { + reduce { + copy(selectedAlbums = selectedAlbums.filter { it.id != album.id }.toImmutableList()) + } + } else { + reduce { + copy(selectedAlbums = (selectedAlbums + album).toImmutableList()) + } + } + } + } + } + + private fun handleAddAlbum( + albumName: String, + reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, + postSideEffect: (AllAlbumSideEffect) -> Unit, + ) { + viewModelScope.launch { + folderRepository.createFolder(name = albumName) + .onSuccess { + fetchFolders(reduce) + postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + } + .onFailure { error -> + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) + Timber.e(error) + } + reduce { copy(isShowAddAlbumBottomSheet = false) } + } + } + + private fun handleDeleteConfirm( + state: AllAlbumState, + reduce: (AllAlbumState.() -> AllAlbumState) -> Unit, + postSideEffect: (AllAlbumSideEffect) -> Unit, + ) { + viewModelScope.launch { + val selectedAlbumIds = state.selectedAlbums.map { it.id } + val deletePhotos = state.selectedDeleteOption == AlbumDeleteOption.DELETE_WITH_PHOTOS + + folderRepository.deleteFolder(selectedAlbumIds, deletePhotos) + .onSuccess { + fetchFolders(reduce) + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error, "사진 삭제 실패") + postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 삭제에 실패했어요")) + } + reduce { + copy( + selectedAlbums = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteAlbumBottomSheet = false, + ) + } + } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/component/AllAlbumTopBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/component/AllAlbumTopBar.kt new file mode 100644 index 000000000..558ad9f49 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/component/AllAlbumTopBar.kt @@ -0,0 +1,172 @@ +package com.neki.android.feature.archive.impl.album.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.modifier.dropdownShadow +import com.neki.android.core.designsystem.topbar.BackTitleTextButtonOptionTopBar +import com.neki.android.core.designsystem.topbar.BackTitleTextButtonTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.model.SelectMode + +@Composable +internal fun AllAlbumTopBar( + selectMode: SelectMode, + showOptionPopup: Boolean, + modifier: Modifier = Modifier, + onClickBack: () -> Unit = {}, + onClickCreate: () -> Unit = {}, + onClickOption: () -> Unit = {}, + onDismissPopup: () -> Unit = {}, + onClickDeleteOption: () -> Unit = {}, + onClickDelete: () -> Unit = {}, +) { + Box { + when (selectMode) { + SelectMode.DEFAULT -> { + DefaultTopBar( + modifier = modifier, + onClickBack = onClickBack, + onClickCreate = onClickCreate, + onClickOption = onClickOption, + ) + } + + SelectMode.SELECTING -> { + SelectingTopBar( + modifier = modifier, + onClickBack = onClickBack, + onClickDelete = onClickDelete, + ) + } + } + + if (showOptionPopup) { + OptionPopup( + onDismissRequest = onDismissPopup, + onClickDeleteOption = onClickDeleteOption, + ) + } + } +} + +@Composable +private fun DefaultTopBar( + modifier: Modifier = Modifier, + onClickBack: () -> Unit = {}, + onClickCreate: () -> Unit = {}, + onClickOption: () -> Unit = {}, +) { + BackTitleTextButtonOptionTopBar( + modifier = modifier, + title = "모든 앨범", + buttonLabel = "생성", + optionIconRes = R.drawable.icon_option, + onBack = onClickBack, + onClickTextButton = onClickCreate, + onClickIcon = onClickOption, + ) +} + +@Composable +private fun SelectingTopBar( + modifier: Modifier = Modifier, + onClickBack: () -> Unit = {}, + onClickDelete: () -> Unit = {}, +) { + BackTitleTextButtonTopBar( + modifier = modifier, + title = "모든 앨범", + buttonLabel = "삭제", + onBack = onClickBack, + onClickTextButton = onClickDelete, + ) +} + +@Composable +private fun OptionPopup( + onDismissRequest: () -> Unit, + onClickDeleteOption: () -> Unit, +) { + val density = LocalDensity.current + val popupOffsetX = with(density) { (-20).dp.toPx().toInt() } + val popupOffsetY = with(density) { 47.dp.toPx().toInt() } + + Popup( + offset = IntOffset(x = popupOffsetX, y = popupOffsetY), + alignment = Alignment.TopEnd, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier + .dropdownShadow(shape = RoundedCornerShape(12.dp)) + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(12.dp), + ) + .padding(vertical = 8.dp), + ) { + Text( + modifier = Modifier + .width(158.dp) + .clickableSingle { + onClickDeleteOption() + onDismissRequest() + } + .padding(horizontal = 12.dp, vertical = 5.dp), + text = "삭제하기", + style = NekiTheme.typography.body16Medium, + color = NekiTheme.colorScheme.gray900, + ) + } + } +} + +@ComponentPreview +@Composable +private fun AllAlbumTopBarDefaultPreview() { + NekiTheme { + AllAlbumTopBar( + selectMode = SelectMode.DEFAULT, + showOptionPopup = false, + ) + } +} + +@ComponentPreview +@Composable +private fun AllAlbumTopBarWithPopupPreview() { + NekiTheme { + AllAlbumTopBar( + selectMode = SelectMode.DEFAULT, + showOptionPopup = true, + ) + } +} + +@ComponentPreview +@Composable +private fun AllAlbumTopBarSelectingPreview() { + NekiTheme { + AllAlbumTopBar( + selectMode = SelectMode.SELECTING, + showOptionPopup = false, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt new file mode 100644 index 000000000..807dc997c --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt @@ -0,0 +1,65 @@ +package com.neki.android.feature.archive.impl.album_detail + +import com.neki.android.core.model.Photo +import com.neki.android.feature.archive.impl.model.SelectMode +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class AlbumDetailState( + val isLoading: Boolean = false, + val title: String = "", + val isFavoriteAlbum: Boolean = false, + val selectMode: SelectMode = SelectMode.DEFAULT, + val selectedPhotos: ImmutableList = persistentListOf(), + val deletedPhotoIds: Set = emptySet(), + val isShowDeleteDialog: Boolean = false, + val isShowDeleteBottomSheet: Boolean = false, + val selectedDeleteOption: PhotoDeleteOption = PhotoDeleteOption.REMOVE_FROM_ALBUM, +) + +enum class PhotoDeleteOption(val label: String) { + REMOVE_FROM_ALBUM("앨범에서만 제거"), + REMOVE_FROM_ALL("모든 위치에서 사진 제거"), + ; + + override fun toString(): String = label +} + +sealed interface AlbumDetailIntent { + data object EnterAlbumDetailScreen : AlbumDetailIntent + + // TopBar Intent + data object ClickBackIcon : AlbumDetailIntent + data object OnBackPressed : AlbumDetailIntent + data object ClickSelectButton : AlbumDetailIntent + data object ClickCancelButton : AlbumDetailIntent + + // Photo Intent + data class ClickPhotoItem(val photo: Photo) : AlbumDetailIntent + + // ActionBar Intent + data object ClickDownloadIcon : AlbumDetailIntent + data object ClickDeleteIcon : AlbumDetailIntent + + // Delete Dialog Intent (for Favorite Album) + data object DismissDeleteDialog : AlbumDetailIntent + data object ClickDeleteDialogCancelButton : AlbumDetailIntent + data object ClickDeleteDialogConfirmButton : AlbumDetailIntent + + // Delete BottomSheet Intent (for Regular Album) + data object DismissDeleteBottomSheet : AlbumDetailIntent + data class SelectDeleteOption(val option: PhotoDeleteOption) : AlbumDetailIntent + data object ClickDeleteBottomSheetCancelButton : AlbumDetailIntent + data object ClickDeleteBottomSheetConfirmButton : AlbumDetailIntent + + // Result Intent + data class PhotoDeleted(val photoIds: List) : AlbumDetailIntent + data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : AlbumDetailIntent +} + +sealed interface AlbumDetailSideEffect { + data object NavigateBack : AlbumDetailSideEffect + data class NavigateToPhotoDetail(val photo: Photo) : AlbumDetailSideEffect + data class ShowToastMessage(val message: String) : AlbumDetailSideEffect + data class DownloadImages(val imageUrls: List) : AlbumDetailSideEffect +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt new file mode 100644 index 000000000..aad0acc6e --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt @@ -0,0 +1,245 @@ +package com.neki.android.feature.archive.impl.album_detail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.neki.android.core.designsystem.topbar.BackTitleTextButtonTopBar +import com.neki.android.core.designsystem.topbar.BackTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.archive.impl.album_detail.component.EmptyContent +import com.neki.android.feature.archive.impl.component.DeletePhotoDialog +import com.neki.android.feature.archive.impl.component.SelectablePhotoItem +import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_TOP_PADDING +import com.neki.android.feature.archive.impl.model.SelectMode +import com.neki.android.feature.archive.impl.photo.component.PhotoActionBar +import com.neki.android.feature.archive.impl.util.ImageDownloader +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun AlbumDetailRoute( + viewModel: AlbumDetailViewModel, + navigateBack: () -> Unit, + navigateToPhotoDetail: (Photo) -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + AlbumDetailSideEffect.NavigateBack -> navigateBack() + is AlbumDetailSideEffect.NavigateToPhotoDetail -> navigateToPhotoDetail(sideEffect.photo) + is AlbumDetailSideEffect.ShowToastMessage -> { + nekiToast.showToast(text = sideEffect.message) + } + + is AlbumDetailSideEffect.DownloadImages -> { + ImageDownloader.downloadImages(context, sideEffect.imageUrls) + .onSuccess { + nekiToast.showToast(text = "사진을 갤러리에 다운로드했어요") + } + .onFailure { + nekiToast.showToast(text = "다운로드에 실패했어요") + } + } + } + } + + AlbumDetailScreen( + uiState = uiState, + pagingItems = pagingItems, + onIntent = viewModel.store::onIntent, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AlbumDetailScreen( + uiState: AlbumDetailState, + pagingItems: LazyPagingItems, + onIntent: (AlbumDetailIntent) -> Unit = {}, +) { + val lazyState = rememberLazyStaggeredGridState() + + val isRefreshing = pagingItems.loadState.refresh is LoadState.Loading + val isEmpty = pagingItems.itemCount == 0 && pagingItems.loadState.refresh is LoadState.NotLoading + + BackHandler(enabled = true) { + onIntent(AlbumDetailIntent.OnBackPressed) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.white), + ) { + AlbumDetailTopBar( + hasNoPhoto = isEmpty, + title = if (uiState.isFavoriteAlbum) "즐겨찾는 사진" else uiState.title, + selectMode = uiState.selectMode, + onClickBack = { onIntent(AlbumDetailIntent.ClickBackIcon) }, + onClickSelect = { onIntent(AlbumDetailIntent.ClickSelectButton) }, + onClickCancel = { onIntent(AlbumDetailIntent.ClickCancelButton) }, + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + LazyVerticalStaggeredGrid( + modifier = Modifier.fillMaxSize(), + columns = StaggeredGridCells.Fixed(2), + state = lazyState, + contentPadding = PaddingValues( + top = PHOTO_GRID_LAYOUT_TOP_PADDING.dp, + start = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + end = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + bottom = PHOTO_GRAY_LAYOUT_BOTTOM_PADDING.dp, + ), + verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, + horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), + ) { + items( + count = pagingItems.itemCount, + key = pagingItems.itemKey { it.id }, + ) { index -> + val photo = pagingItems[index] + if (photo != null) { + val isSelected = uiState.selectedPhotos.any { it.id == photo.id } + SelectablePhotoItem( + photo = photo, + isSelected = isSelected, + isSelectMode = uiState.selectMode == SelectMode.SELECTING, + onClickItem = { onIntent(AlbumDetailIntent.ClickPhotoItem(photo)) }, + onClickSelect = { onIntent(AlbumDetailIntent.ClickPhotoItem(photo)) }, + ) + } + } + + if (pagingItems.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = NekiTheme.colorScheme.primary500, + ) + } + } + } + } + } + + PhotoActionBar( + visible = uiState.selectMode == SelectMode.SELECTING, + isEnabled = uiState.selectedPhotos.isNotEmpty(), + onClickDownload = { onIntent(AlbumDetailIntent.ClickDownloadIcon) }, + onClickDelete = { onIntent(AlbumDetailIntent.ClickDeleteIcon) }, + ) + } + + if (isRefreshing || uiState.isLoading) { + LoadingDialog() + } + + if (isEmpty) { + EmptyContent( + isFavorite = uiState.isFavoriteAlbum, + ) + } + + if (uiState.isShowDeleteDialog) { + DeletePhotoDialog( + onDismissRequest = { onIntent(AlbumDetailIntent.DismissDeleteDialog) }, + onClickDelete = { onIntent(AlbumDetailIntent.ClickDeleteDialogConfirmButton) }, + onClickCancel = { onIntent(AlbumDetailIntent.ClickDeleteDialogCancelButton) }, + ) + } + + if (uiState.isShowDeleteBottomSheet) { + DoubleButtonOptionBottomSheet( + title = "사진을 삭제하시겠어요?", + options = PhotoDeleteOption.entries.toImmutableList(), + selectedOption = uiState.selectedDeleteOption, + primaryButtonText = "삭제하기", + secondaryButtonText = "취소", + onDismissRequest = { onIntent(AlbumDetailIntent.DismissDeleteBottomSheet) }, + onClickSecondaryButton = { onIntent(AlbumDetailIntent.ClickDeleteBottomSheetCancelButton) }, + onClickPrimaryButton = { onIntent(AlbumDetailIntent.ClickDeleteBottomSheetConfirmButton) }, + onOptionSelect = { onIntent(AlbumDetailIntent.SelectDeleteOption(it)) }, + ) + } +} + +@Composable +private fun AlbumDetailTopBar( + title: String, + selectMode: SelectMode, + onClickBack: () -> Unit, + onClickSelect: () -> Unit, + onClickCancel: () -> Unit, + modifier: Modifier = Modifier, + hasNoPhoto: Boolean = false, +) { + if (hasNoPhoto) { + BackTitleTopBar( + modifier = modifier, + title = title, + onBack = onClickBack, + ) + } else { + BackTitleTextButtonTopBar( + modifier = modifier, + title = title, + buttonLabel = when (selectMode) { + SelectMode.DEFAULT -> "선택" + SelectMode.SELECTING -> "취소" + }, + enabledTextColor = when (selectMode) { + SelectMode.DEFAULT -> NekiTheme.colorScheme.primary500 + SelectMode.SELECTING -> NekiTheme.colorScheme.gray800 + }, + onBack = onClickBack, + onClickTextButton = when (selectMode) { + SelectMode.DEFAULT -> onClickSelect + SelectMode.SELECTING -> onClickCancel + }, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt new file mode 100644 index 000000000..a9150c55b --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt @@ -0,0 +1,268 @@ +package com.neki.android.feature.archive.impl.album_detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.archive.impl.model.SelectMode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel(assistedFactory = AlbumDetailViewModel.Factory::class) +class AlbumDetailViewModel @AssistedInject constructor( + @Assisted private val id: Long, + @Assisted private val title: String, + @Assisted private val isFavoriteAlbum: Boolean, + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(id: Long, title: String, isFavoriteAlbum: Boolean): AlbumDetailViewModel + } + + private val deletedPhotoIds = MutableStateFlow>(emptySet()) + private val updatedFavorites = MutableStateFlow>(emptyMap()) + + private val originalPagingData: Flow> = + if (isFavoriteAlbum) { + photoRepository.getFavoritePhotosFlow() + } else { + photoRepository.getPhotosFlow(id) + }.cachedIn(viewModelScope) + + val photoPagingData: Flow> = combine( + originalPagingData, + deletedPhotoIds, + updatedFavorites, + ) { pagingData, deletedIds, favorites -> + pagingData + .filter { photo -> photo.id !in deletedIds } + .map { photo -> + favorites[photo.id]?.let { isFavorite -> + photo.copy(isFavorite = isFavorite) + } ?: photo + } + } + + val store: MviIntentStore = + mviIntentStore( + initialState = AlbumDetailState( + title = title, + isFavoriteAlbum = isFavoriteAlbum, + ), + onIntent = ::onIntent, + ) + + private fun onIntent( + intent: AlbumDetailIntent, + state: AlbumDetailState, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + when (intent) { + AlbumDetailIntent.EnterAlbumDetailScreen -> { + // Paging이 자동으로 처리 + } + + AlbumDetailIntent.ClickBackIcon -> handleBackClick(state, reduce, postSideEffect) + AlbumDetailIntent.OnBackPressed -> handleBackClick(state, reduce, postSideEffect) + AlbumDetailIntent.ClickSelectButton -> reduce { copy(selectMode = SelectMode.SELECTING) } + AlbumDetailIntent.ClickCancelButton -> reduce { + copy( + selectMode = SelectMode.DEFAULT, + selectedPhotos = persistentListOf(), + ) + } + + is AlbumDetailIntent.ClickPhotoItem -> handlePhotoClick(intent.photo, state, reduce, postSideEffect) + + AlbumDetailIntent.ClickDownloadIcon -> handleDownload(state, postSideEffect) + AlbumDetailIntent.ClickDeleteIcon -> handleDeleteIconClick(state, reduce, postSideEffect) + + AlbumDetailIntent.DismissDeleteDialog -> reduce { copy(isShowDeleteDialog = false) } + AlbumDetailIntent.ClickDeleteDialogCancelButton -> reduce { copy(isShowDeleteDialog = false) } + AlbumDetailIntent.ClickDeleteDialogConfirmButton -> handleFavoriteDelete(state, reduce, postSideEffect) + + AlbumDetailIntent.DismissDeleteBottomSheet -> reduce { copy(isShowDeleteBottomSheet = false) } + is AlbumDetailIntent.SelectDeleteOption -> reduce { copy(selectedDeleteOption = intent.option) } + AlbumDetailIntent.ClickDeleteBottomSheetCancelButton -> reduce { copy(isShowDeleteBottomSheet = false) } + AlbumDetailIntent.ClickDeleteBottomSheetConfirmButton -> handleAlbumPhotoDelete(state, reduce, postSideEffect) + + // Result Intent + is AlbumDetailIntent.PhotoDeleted -> { + deletedPhotoIds.update { it + intent.photoIds.toSet() } + } + is AlbumDetailIntent.FavoriteChanged -> { + updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + } + } + } + + private fun handleBackClick( + state: AlbumDetailState, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + when (state.selectMode) { + SelectMode.DEFAULT -> postSideEffect(AlbumDetailSideEffect.NavigateBack) + SelectMode.SELECTING -> reduce { + copy( + selectMode = SelectMode.DEFAULT, + selectedPhotos = persistentListOf(), + ) + } + } + } + + private fun handlePhotoClick( + photo: Photo, + state: AlbumDetailState, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + when (state.selectMode) { + SelectMode.DEFAULT -> { + postSideEffect(AlbumDetailSideEffect.NavigateToPhotoDetail(photo)) + } + + SelectMode.SELECTING -> { + val isSelected = state.selectedPhotos.any { it.id == photo.id } + if (isSelected) { + reduce { + copy(selectedPhotos = selectedPhotos.filter { it.id != photo.id }.toImmutableList()) + } + } else { + reduce { + copy(selectedPhotos = (selectedPhotos + photo).toImmutableList()) + } + } + } + } + } + + private fun handleDownload( + state: AlbumDetailState, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + if (state.selectedPhotos.isEmpty()) { + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 선택해주세요.")) + return + } + postSideEffect(AlbumDetailSideEffect.DownloadImages(state.selectedPhotos.map { it.imageUrl })) + } + + private fun handleDeleteIconClick( + state: AlbumDetailState, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + if (state.selectedPhotos.isEmpty()) { + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 선택해주세요.")) + return + } + + if (state.isFavoriteAlbum) { + reduce { copy(isShowDeleteDialog = true) } + } else { + reduce { copy(isShowDeleteBottomSheet = true) } + } + } + + private fun handleFavoriteDelete( + state: AlbumDetailState, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + + viewModelScope.launch { + reduce { copy(isLoading = true) } + + photoRepository.deletePhoto(photoIds = selectedPhotoIds) + .onSuccess { + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteDialog = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { + copy( + isShowDeleteDialog = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + } + } + + private fun handleAlbumPhotoDelete( + state: AlbumDetailState, + reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, + postSideEffect: (AlbumDetailSideEffect) -> Unit, + ) { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + + viewModelScope.launch { + reduce { copy(isLoading = true) } + + val result = when (state.selectedDeleteOption) { + PhotoDeleteOption.REMOVE_FROM_ALBUM -> folderRepository.removePhotosFromFolder(id, selectedPhotoIds) + PhotoDeleteOption.REMOVE_FROM_ALL -> photoRepository.deletePhoto(photoIds = selectedPhotoIds) + } + + result + .onSuccess { + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteBottomSheet = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { + copy( + isShowDeleteBottomSheet = false, + isLoading = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/component/EmptyContent.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/component/EmptyContent.kt new file mode 100644 index 000000000..b7709dec4 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/component/EmptyContent.kt @@ -0,0 +1,71 @@ +package com.neki.android.feature.archive.impl.album_detail.component + +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.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun EmptyContent( + isFavorite: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Box( + modifier = Modifier + .size(104.dp) + .clip(CircleShape) + .background( + color = NekiTheme.colorScheme.gray50, + shape = CircleShape, + ), + ) + Text( + text = if (isFavorite) "아직 등록된 사진이 없어요\n아카이빙 페이지에서 추가해보세요!" + else "아직 등록된 사진이 없어요\n아카이빙 페이지에서 추가해보세요!", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray300, + textAlign = TextAlign.Center, + ) + } + } +} + +@ComponentPreview +@Composable +private fun FavoriteEmptyContentPreview() { + NekiTheme { + EmptyContent( + isFavorite = true, + ) + } +} + +@ComponentPreview +@Composable +private fun EmptyContentPreview() { + NekiTheme { + EmptyContent( + isFavorite = false, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/AddAlbumBottomSheet.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/AddAlbumBottomSheet.kt new file mode 100644 index 000000000..9bb8a5248 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/AddAlbumBottomSheet.kt @@ -0,0 +1,65 @@ +package com.neki.android.feature.archive.impl.component + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.bottomsheet.NekiTextFieldBottomSheet +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_ALBUM_NAME_MAX_LENGTH + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AddAlbumBottomSheet( + textFieldState: TextFieldState, + onDismissRequest: () -> Unit, + onClickCancel: () -> Unit, + onClickConfirm: () -> Unit, + modifier: Modifier = Modifier, + isError: Boolean = false, + errorMessage: String? = null, +) { + NekiTextFieldBottomSheet( + title = "새 앨범 추가", + subtitle = "네컷사진을 모을 앨범명을 입력하세요", + textFieldState = textFieldState, + onDismissRequest = onDismissRequest, + onClickCancel = onClickCancel, + onClickConfirm = onClickConfirm, + modifier = modifier, + placeholder = "앨범명을 입력하세요", + maxLength = ARCHIVE_ALBUM_NAME_MAX_LENGTH, + confirmButtonText = "추가하기", + isError = isError, + errorMessage = errorMessage, + ) +} + +@ComponentPreview +@Composable +private fun AddAlbumBottomSheetPreview() { + NekiTheme { + AddAlbumBottomSheet( + textFieldState = TextFieldState(), + onDismissRequest = {}, + onClickCancel = {}, + onClickConfirm = {}, + ) + } +} + +@ComponentPreview +@Composable +private fun AddAlbumBottomSheetErrorPreview() { + NekiTheme { + AddAlbumBottomSheet( + textFieldState = TextFieldState(), + onDismissRequest = {}, + onClickCancel = {}, + onClickConfirm = {}, + isError = true, + errorMessage = "앨범명은 최대 16자까지 입력할 수 있어요.", + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/DeletePhotoDialog.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/DeletePhotoDialog.kt new file mode 100644 index 000000000..a531d481c --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/DeletePhotoDialog.kt @@ -0,0 +1,38 @@ +package com.neki.android.feature.archive.impl.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun DeletePhotoDialog( + onDismissRequest: () -> Unit, + onClickDelete: () -> Unit, + onClickCancel: () -> Unit, + properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), +) { + DoubleButtonAlertDialog( + title = "사진을 삭제하시겠어요?", + content = "이 작업은 실행취소할 수 없어요", + grayButtonText = "취소", + primaryButtonText = "삭제하기", + onDismissRequest = onDismissRequest, + onClickPrimaryButton = onClickDelete, + onClickGrayButton = onClickCancel, + properties = properties, + ) +} + +@ComponentPreview +@Composable +private fun DeletePhotoDialogPreview() { + NekiTheme { + DeletePhotoDialog( + onDismissRequest = {}, + onClickDelete = {}, + onClickCancel = {}, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/SelcetablePhotoItem.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/SelcetablePhotoItem.kt new file mode 100644 index 000000000..efc06c283 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/component/SelcetablePhotoItem.kt @@ -0,0 +1,141 @@ +package com.neki.android.feature.archive.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.modifier.photoBackground +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.PhotoComponent +import com.neki.android.core.ui.component.SelectionCheckbox + +@Composable +internal fun SelectablePhotoItem( + photo: Photo, + isSelected: Boolean, + isSelectMode: Boolean = false, + modifier: Modifier = Modifier, + onClickItem: (Photo) -> Unit = {}, + onClickSelect: (Photo) -> Unit = {}, + onClickFavorite: (Photo) -> Unit = {}, +) { + PhotoComponent( + photo = photo, + modifier = modifier.then( + if (isSelected) Modifier + .border( + width = 2.dp, + color = NekiTheme.colorScheme.primary400, + shape = RoundedCornerShape(12.dp), + ) + .background( + color = Color.Black.copy(alpha = 0.2f), + shape = RoundedCornerShape(12.dp), + ) else Modifier + .clip(RoundedCornerShape(12.dp)) + .photoBackground(), + ), + onClickItem = onClickItem, + ) { + if (isSelectMode) { + SelectionCheckbox( + isSelected = isSelected, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 12.dp, start = 12.dp) + .noRippleClickable { onClickSelect(photo) }, + unselectedColor = NekiTheme.colorScheme.white.copy(alpha = 0.2f), + ) + } + + if (photo.isFavorite) { + Icon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 10.dp, end = 10.dp) + .size(20.dp) + .noRippleClickable { onClickFavorite(photo) }, + imageVector = ImageVector.vectorResource(R.drawable.icon_heart), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } +} + +@ComponentPreview +@Composable +private fun SelectablePhotoItemUnselectedPreview() { + NekiTheme { + SelectablePhotoItem( + photo = Photo( + id = 1, + imageUrl = "https://picsum.photos/200/300", + isFavorite = false, + ), + isSelected = false, + ) + } +} + +@ComponentPreview +@Composable +private fun SelectablePhotoItemSelectedPreview() { + NekiTheme { + SelectablePhotoItem( + isSelectMode = true, + photo = Photo( + id = 1, + imageUrl = "https://picsum.photos/200/300", + isFavorite = false, + ), + isSelected = true, + ) + } +} + +@ComponentPreview +@Composable +private fun SelectablePhotoItemFavoriteUnselectedPreview() { + NekiTheme { + SelectablePhotoItem( + isSelectMode = true, + photo = Photo( + id = 1, + imageUrl = "https://picsum.photos/200/300", + isFavorite = true, + ), + isSelected = false, + ) + } +} + +@ComponentPreview +@Composable +private fun SelectablePhotoItemFavoriteSelectedPreview() { + NekiTheme { + SelectablePhotoItem( + isSelectMode = true, + photo = Photo( + id = 1, + imageUrl = "https://picsum.photos/200/300", + isFavorite = true, + ), + isSelected = true, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt new file mode 100644 index 000000000..702f1d1b4 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/const/ArchiveConst.kt @@ -0,0 +1,23 @@ +package com.neki.android.feature.archive.impl.const + +internal object ArchiveConst { + // Layout + internal const val ARCHIVE_ROW_TEXT_BUTTON_PADDING = 12 + internal const val PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING = 20 + internal const val PHOTO_GRID_LAYOUT_TOP_PADDING = 8 + internal const val PHOTO_GRAY_LAYOUT_BOTTOM_PADDING = 76 + internal const val ARCHIVE_GRID_ITEM_SPACING = 12 + + // Corner Radius + internal const val ARCHIVE_PHOTO_CORNER_RADIUS = 12 + internal const val ARCHIVE_POPUP_CORNER_RADIUS = 12 + + // Icon Size + internal const val ARCHIVE_ICON_SIZE_SMALL = 20 + internal const val ARCHIVE_ICON_SIZE_MEDIUM = 28 + + // Album + internal const val ARCHIVE_ALBUM_ITEM_WIDTH = 124 + internal const val ARCHIVE_ALBUM_ITEM_HEIGHT = 166 + internal const val ARCHIVE_ALBUM_NAME_MAX_LENGTH = 16 +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt new file mode 100644 index 000000000..beac2bf90 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt @@ -0,0 +1,74 @@ +package com.neki.android.feature.archive.impl.main + +import android.net.Uri +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.model.Photo +import com.neki.android.core.model.UploadType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class ArchiveMainState( + val isLoading: Boolean = false, + val isFirstEntered: Boolean = true, + val favoriteAlbum: AlbumPreview = AlbumPreview(title = "즐겨찾는사진"), + val albums: ImmutableList = persistentListOf(), + val recentPhotos: ImmutableList = persistentListOf(), + val scannedImageUrl: String? = null, + val selectedUris: ImmutableList = persistentListOf(), + val isShowAddDialog: Boolean = false, + val isShowChooseWithAlbumDialog: Boolean = false, + val isShowAddAlbumBottomSheet: Boolean = false, +) { + val uploadType: UploadType + get() = if (scannedImageUrl != null) UploadType.QR_CODE else UploadType.GALLERY +} + +sealed interface ArchiveMainIntent { + data object EnterArchiveMainScreen : ArchiveMainIntent + data object RefreshArchiveMainScreen : ArchiveMainIntent + data class QRCodeScanned(val imageUrl: String) : ArchiveMainIntent + data object ClickScreen : ArchiveMainIntent + data object ClickGoToTopButton : ArchiveMainIntent + + // TopBar Intent + data object ClickAddIcon : ArchiveMainIntent + data object DismissAddDialog : ArchiveMainIntent + data object ClickQRScanRow : ArchiveMainIntent + + data object ClickGalleryUploadRow : ArchiveMainIntent + data class SelectGalleryImage(val uris: List) : ArchiveMainIntent + data object DismissChooseWithAlbumDialog : ArchiveMainIntent + data object ClickUploadWithAlbumRow : ArchiveMainIntent + data object ClickUploadWithoutAlbumRow : ArchiveMainIntent + + data object ClickAddNewAlbumRow : ArchiveMainIntent + data object ClickNotificationIcon : ArchiveMainIntent + + // Album Intent + data object ClickAllAlbumText : ArchiveMainIntent + data object ClickFavoriteAlbum : ArchiveMainIntent + data class ClickAlbumItem(val albumId: Long, val albumTitle: String) : ArchiveMainIntent + + // Photo Intent + data object ClickAllPhotoText : ArchiveMainIntent + data class ClickPhotoItem(val photo: Photo) : ArchiveMainIntent + + // Add Album BottomSheet Intent + data object DismissAddAlbumBottomSheet : ArchiveMainIntent + data class ClickAddAlbumButton(val albumName: String) : ArchiveMainIntent +} + +sealed interface ArchiveMainSideEffect { + data object NavigateToQRScan : ArchiveMainSideEffect + data class NavigateToUploadAlbumWithGallery(val uriStrings: List) : ArchiveMainSideEffect + data class NavigateToUploadAlbumWithQRScan(val imageUrl: String) : ArchiveMainSideEffect + data object NavigateToAllAlbum : ArchiveMainSideEffect + data class NavigateToFavoriteAlbum(val albumId: Long) : ArchiveMainSideEffect + data class NavigateToAlbumDetail(val albumId: Long, val title: String) : ArchiveMainSideEffect + data object NavigateToAllPhoto : ArchiveMainSideEffect + data class NavigateToPhotoDetail(val photo: Photo) : ArchiveMainSideEffect + + data object ScrollToTop : ArchiveMainSideEffect + data object OpenGallery : ArchiveMainSideEffect + data class ShowToastMessage(val message: String) : ArchiveMainSideEffect +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt new file mode 100644 index 000000000..8f79a6d99 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt @@ -0,0 +1,309 @@ +package com.neki.android.feature.archive.impl.main + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.archive.impl.component.AddAlbumBottomSheet +import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.archive.impl.main.component.ArchiveMainAlbumList +import com.neki.android.feature.archive.impl.main.component.ArchiveMainPhotoItem +import com.neki.android.feature.archive.impl.main.component.ArchiveMainTitleRow +import com.neki.android.feature.archive.impl.main.component.ArchiveMainTopBar +import com.neki.android.feature.archive.impl.main.component.ChooseWithAlbumDialog +import com.neki.android.feature.archive.impl.main.component.GotoTopButton +import com.neki.android.feature.archive.impl.main.component.NoPhotoContent +import kotlinx.collections.immutable.persistentListOf +import timber.log.Timber + +@Composable +internal fun ArchiveMainRoute( + viewModel: ArchiveMainViewModel = hiltViewModel(), + navigateToQRScan: () -> Unit, + navigateToUploadAlbumWithGallery: (List) -> Unit, + navigateToUploadAlbumWithQRScan: (String) -> Unit, + navigateToAllAlbum: () -> Unit, + navigateToFavoriteAlbum: (Long) -> Unit, + navigateToAlbumDetail: (Long, String) -> Unit, + navigateToAllPhoto: () -> Unit, + navigateToPhotoDetail: (Photo) -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lazyState = rememberLazyStaggeredGridState() + val nekiToast = remember { NekiToast(context) } + val photoPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(10)) { uris -> + if (uris.isNotEmpty()) { + viewModel.store.onIntent(ArchiveMainIntent.SelectGalleryImage(uris)) + } else { + Timber.d("No media selected") + } + } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + ArchiveMainSideEffect.NavigateToQRScan -> navigateToQRScan() + is ArchiveMainSideEffect.NavigateToUploadAlbumWithGallery -> navigateToUploadAlbumWithGallery(sideEffect.uriStrings) + is ArchiveMainSideEffect.NavigateToUploadAlbumWithQRScan -> navigateToUploadAlbumWithQRScan(sideEffect.imageUrl) + ArchiveMainSideEffect.NavigateToAllAlbum -> navigateToAllAlbum() + is ArchiveMainSideEffect.NavigateToFavoriteAlbum -> navigateToFavoriteAlbum(sideEffect.albumId) + is ArchiveMainSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title) + ArchiveMainSideEffect.NavigateToAllPhoto -> navigateToAllPhoto() + is ArchiveMainSideEffect.NavigateToPhotoDetail -> navigateToPhotoDetail(sideEffect.photo) + ArchiveMainSideEffect.ScrollToTop -> lazyState.animateScrollToItem(0) + ArchiveMainSideEffect.OpenGallery -> photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + is ArchiveMainSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message) + } + } + + ArchiveMainScreen( + lazyState = lazyState, + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +internal fun ArchiveMainScreen( + lazyState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + uiState: ArchiveMainState = ArchiveMainState(), + onIntent: (ArchiveMainIntent) -> Unit = {}, +) { + Box( + modifier = Modifier + .fillMaxSize() + .noRippleClickableSingle { onIntent(ArchiveMainIntent.ClickScreen) }, + ) { + Column { + ArchiveMainTopBar( + modifier = Modifier.padding(start = 20.dp, end = 8.dp), + showTooltip = uiState.isFirstEntered, + showAddPopup = uiState.isShowAddDialog, + onClickPlusIcon = { onIntent(ArchiveMainIntent.ClickAddIcon) }, + onClickNotificationIcon = { onIntent(ArchiveMainIntent.ClickNotificationIcon) }, + onClickQRScan = { onIntent(ArchiveMainIntent.ClickQRScanRow) }, + onClickGallery = { onIntent(ArchiveMainIntent.ClickGalleryUploadRow) }, + onClickNewAlbum = { onIntent(ArchiveMainIntent.ClickAddNewAlbumRow) }, + onDismissPopup = { onIntent(ArchiveMainIntent.DismissAddDialog) }, + ) + ArchiveMainContent( + uiState = uiState, + lazyState = lazyState, + onClickShowAllAlbum = { onIntent(ArchiveMainIntent.ClickAllAlbumText) }, + onClickFavoriteAlbum = { onIntent(ArchiveMainIntent.ClickFavoriteAlbum) }, + onClickAlbumItem = { onIntent(ArchiveMainIntent.ClickAlbumItem(it.id, it.title)) }, + onClickShowAllPhoto = { onIntent(ArchiveMainIntent.ClickAllPhotoText) }, + onClickPhotoItem = { photo -> onIntent(ArchiveMainIntent.ClickPhotoItem(photo)) }, + ) + } + + GotoTopButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 20.dp), + visible = !lazyState.isScrollInProgress && lazyState.firstVisibleItemIndex != 0, + onClick = { onIntent(ArchiveMainIntent.ClickGoToTopButton) }, + ) + } + + if (uiState.isLoading) { + LoadingDialog() + } + + if (uiState.isShowAddAlbumBottomSheet) { + val textFieldState = rememberTextFieldState() + val existingAlbumNames = remember(uiState.albums) { uiState.albums.map { it.title } } + + val errorMessage by remember(textFieldState.text) { + derivedStateOf { + val name = textFieldState.text.toString() + when { + existingAlbumNames.contains(name) -> "이미 사용 중인 앨범명이에요." + else -> null + } + } + } + + AddAlbumBottomSheet( + textFieldState = textFieldState, + onDismissRequest = { onIntent(ArchiveMainIntent.DismissAddAlbumBottomSheet) }, + onClickCancel = { onIntent(ArchiveMainIntent.DismissAddAlbumBottomSheet) }, + onClickConfirm = { + val albumName = textFieldState.text.toString() + if (errorMessage == null && albumName.isNotBlank()) { + onIntent(ArchiveMainIntent.ClickAddAlbumButton(albumName)) + } + }, + isError = errorMessage != null, + errorMessage = errorMessage, + ) + } + + if (uiState.isShowChooseWithAlbumDialog) { + ChooseWithAlbumDialog( + onDismissRequest = { onIntent(ArchiveMainIntent.DismissChooseWithAlbumDialog) }, + onClickUploadWithOutAlbum = { onIntent(ArchiveMainIntent.ClickUploadWithoutAlbumRow) }, + onClickUploadWithAlbum = { onIntent(ArchiveMainIntent.ClickUploadWithAlbumRow) }, + ) + } +} + +@Composable +private fun ArchiveMainContent( + uiState: ArchiveMainState, + lazyState: LazyStaggeredGridState, + onClickShowAllAlbum: () -> Unit, + onClickFavoriteAlbum: () -> Unit, + onClickAlbumItem: (AlbumPreview) -> Unit, + onClickShowAllPhoto: () -> Unit, + onClickPhotoItem: (Photo) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val screenWidth = with(density) { LocalWindowInfo.current.containerSize.width.toDp() } + + LazyVerticalStaggeredGrid( + modifier = modifier.fillMaxWidth(), + columns = StaggeredGridCells.Fixed(2), + state = lazyState, + contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, + bottom = PHOTO_GRAY_LAYOUT_BOTTOM_PADDING.dp, + ), + verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, + horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), + ) { + item(span = StaggeredGridItemSpan.FullLine) { + ArchiveMainTitleRow( + title = "앨범", + textButtonTitle = "전체 보기", + onClickShowAllAlbum = onClickShowAllAlbum, + ) + } + + item(span = StaggeredGridItemSpan.FullLine) { + ArchiveMainAlbumList( + modifier = Modifier.requiredWidth(screenWidth), + favoriteAlbum = uiState.favoriteAlbum, + albumList = uiState.albums, + onClickFavoriteAlbum = onClickFavoriteAlbum, + onClickAlbumItem = onClickAlbumItem, + ) + } + + item(span = StaggeredGridItemSpan.FullLine) { + ArchiveMainTitleRow( + title = "최근 사진", + textButtonTitle = "모든 사진", + onClickShowAllAlbum = onClickShowAllPhoto, + ) + } + + if (uiState.recentPhotos.isEmpty()) { + item(span = StaggeredGridItemSpan.FullLine) { + NoPhotoContent() + } + } + + items( + uiState.recentPhotos, + key = { photo -> photo.id }, + ) { photo -> + ArchiveMainPhotoItem( + photo = photo, + onClickItem = onClickPhotoItem, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun ArchiveMainScreenPreview() { + val dummyPhotos = persistentListOf( + Photo(id = 1, imageUrl = "https://picsum.photos/seed/photo1/200/300", isFavorite = true), + Photo(id = 2, imageUrl = "https://picsum.photos/seed/photo2/200/250"), + Photo(id = 3, imageUrl = "https://picsum.photos/seed/photo3/200/350", isFavorite = true), + Photo(id = 4, imageUrl = "https://picsum.photos/seed/photo4/200/280"), + Photo(id = 5, imageUrl = "https://picsum.photos/seed/photo5/200/320"), + Photo(id = 6, imageUrl = "https://picsum.photos/seed/photo6/200/260", isFavorite = true), + Photo(id = 7, imageUrl = "https://picsum.photos/seed/photo7/200/290"), + Photo(id = 8, imageUrl = "https://picsum.photos/seed/photo8/200/310"), + ) + + val dummyAlbums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", thumbnailUrl = "https://picsum.photos/seed/travel1/200/300", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", thumbnailUrl = "https://picsum.photos/seed/family1/200/300", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", thumbnailUrl = "https://picsum.photos/seed/friend1/200/300", photoCount = 3), + ) + + val favoriteAlbum = AlbumPreview( + id = 0, + title = "즐겨찾는 사진", + thumbnailUrl = "https://picsum.photos/seed/fav1/200/300", + photoCount = 5, + ) + + NekiTheme { + ArchiveMainScreen( + uiState = ArchiveMainState( + favoriteAlbum = favoriteAlbum, + albums = dummyAlbums, + recentPhotos = dummyPhotos, + ), + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun ArchiveMainScreenEmptyPreview() { + val favoriteAlbum = AlbumPreview( + id = 0, + title = "즐겨찾는 사진", + ) + + NekiTheme { + ArchiveMainScreen( + uiState = ArchiveMainState( + favoriteAlbum = favoriteAlbum, + ), + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt new file mode 100644 index 000000000..e148ac045 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt @@ -0,0 +1,257 @@ +package com.neki.android.feature.archive.impl.main + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase +import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase +import com.neki.android.core.model.UploadType +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val DEFAULT_PHOTOS_SIZE = 20 + +@HiltViewModel +class ArchiveMainViewModel @Inject constructor( + private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, + private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, +) : ViewModel() { + + val store: MviIntentStore = + mviIntentStore( + initialState = ArchiveMainState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(ArchiveMainIntent.EnterArchiveMainScreen) }, + ) + + private var fetchJob: Job? = null + private fun onIntent( + intent: ArchiveMainIntent, + state: ArchiveMainState, + reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, + postSideEffect: (ArchiveMainSideEffect) -> Unit, + ) { + if (intent != ArchiveMainIntent.EnterArchiveMainScreen) reduce { copy(isFirstEntered = false) } + when (intent) { + is ArchiveMainIntent.QRCodeScanned -> reduce { + copy( + scannedImageUrl = intent.imageUrl, + isShowChooseWithAlbumDialog = true, + ) + } + + ArchiveMainIntent.EnterArchiveMainScreen -> fetchInitialData(reduce) + ArchiveMainIntent.RefreshArchiveMainScreen -> fetchInitialData(reduce) + ArchiveMainIntent.ClickScreen -> reduce { copy(isFirstEntered = false) } + ArchiveMainIntent.ClickGoToTopButton -> postSideEffect(ArchiveMainSideEffect.ScrollToTop) + + // TopBar Intent + ArchiveMainIntent.ClickAddIcon -> reduce { copy(isShowAddDialog = true) } + ArchiveMainIntent.DismissAddDialog -> reduce { copy(isShowAddDialog = false) } + ArchiveMainIntent.ClickQRScanRow -> { + reduce { copy(isShowAddDialog = false) } + postSideEffect(ArchiveMainSideEffect.NavigateToQRScan) + } + + ArchiveMainIntent.ClickGalleryUploadRow -> { + reduce { copy(isShowAddDialog = false) } + postSideEffect(ArchiveMainSideEffect.OpenGallery) + } + + is ArchiveMainIntent.SelectGalleryImage -> reduce { + copy( + isShowChooseWithAlbumDialog = true, + selectedUris = intent.uris.toImmutableList(), + ) + } + + ArchiveMainIntent.DismissChooseWithAlbumDialog -> reduce { copy(isShowChooseWithAlbumDialog = false) } + ArchiveMainIntent.ClickUploadWithAlbumRow -> { + reduce { + copy( + isShowChooseWithAlbumDialog = false, + scannedImageUrl = null, + selectedUris = persistentListOf(), + ) + } + if (state.scannedImageUrl == null) + postSideEffect(ArchiveMainSideEffect.NavigateToUploadAlbumWithGallery(state.selectedUris.map { it.toString() })) + else postSideEffect(ArchiveMainSideEffect.NavigateToUploadAlbumWithQRScan(state.scannedImageUrl)) + } + + ArchiveMainIntent.ClickUploadWithoutAlbumRow -> uploadWithoutAlbum(state, reduce, postSideEffect) + + ArchiveMainIntent.ClickAddNewAlbumRow -> reduce { + copy( + isShowAddDialog = false, + isShowAddAlbumBottomSheet = true, + ) + } + + ArchiveMainIntent.ClickNotificationIcon -> {} + + // Album Intent + ArchiveMainIntent.ClickAllAlbumText -> postSideEffect(ArchiveMainSideEffect.NavigateToAllAlbum) + ArchiveMainIntent.ClickFavoriteAlbum -> postSideEffect(ArchiveMainSideEffect.NavigateToFavoriteAlbum(-1L)) + is ArchiveMainIntent.ClickAlbumItem -> postSideEffect(ArchiveMainSideEffect.NavigateToAlbumDetail(intent.albumId, intent.albumTitle)) + + // Photo Intent + ArchiveMainIntent.ClickAllPhotoText -> postSideEffect(ArchiveMainSideEffect.NavigateToAllPhoto) + is ArchiveMainIntent.ClickPhotoItem -> postSideEffect(ArchiveMainSideEffect.NavigateToPhotoDetail(intent.photo)) + + // Add Album BottomSheet Intent + ArchiveMainIntent.DismissAddAlbumBottomSheet -> reduce { copy(isShowAddAlbumBottomSheet = false) } + is ArchiveMainIntent.ClickAddAlbumButton -> handleAddAlbum(intent.albumName, reduce, postSideEffect) + } + } + + private fun fetchInitialData(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit) { + if (fetchJob?.isActive == true) return + + fetchJob = viewModelScope.launch { + reduce { copy(isLoading = true) } + try { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchPhotos(reduce) }, + async { fetchFolders(reduce) }, + ) + } finally { + reduce { copy(isLoading = false) } + } + } + } + + private suspend fun fetchFavoriteSummary(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit) { + photoRepository.getFavoriteSummary() + .onSuccess { data -> + reduce { copy(favoriteAlbum = data) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private suspend fun fetchPhotos(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, size: Int = DEFAULT_PHOTOS_SIZE) { + photoRepository.getPhotos() + .onSuccess { data -> + reduce { copy(recentPhotos = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private suspend fun fetchFolders(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit) { + folderRepository.getFolders() + .onSuccess { data -> + reduce { copy(albums = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private fun uploadWithoutAlbum( + state: ArchiveMainState, + reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, + postSideEffect: (ArchiveMainSideEffect) -> Unit, + ) { + reduce { copy(isShowChooseWithAlbumDialog = false) } + val onSuccessSideEffect = { + reduce { copy(isLoading = false) } + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지를 추가했어요")) + } + if (state.uploadType == UploadType.QR_CODE) { + uploadSingleImage( + imageUrl = state.scannedImageUrl ?: return, + reduce = reduce, + postSideEffect = postSideEffect, + onSuccess = onSuccessSideEffect, + ) + } else { + uploadMultipleImages( + imageUris = state.selectedUris, + reduce = reduce, + postSideEffect = postSideEffect, + onSuccess = onSuccessSideEffect, + ) + } + } + + private fun uploadSingleImage( + imageUrl: String, + reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, + postSideEffect: (ArchiveMainSideEffect) -> Unit, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + uploadSinglePhotoUseCase( + imageUrl = imageUrl, + ).onSuccess { + fetchPhotos(reduce, 1) // 가장 최신 데이터 가져오기 + onSuccess() + }.onFailure { error -> + Timber.e(error) + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) + reduce { copy(isLoading = false) } + } + } + } + + private fun uploadMultipleImages( + imageUris: List, + reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, + postSideEffect: (ArchiveMainSideEffect) -> Unit, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + uploadMultiplePhotoUseCase( + imageUris = imageUris, + ).onSuccess { + fetchPhotos(reduce) + onSuccess() + }.onFailure { error -> + Timber.e(error) + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) + reduce { copy(isLoading = false) } + } + } + } + + private fun handleAddAlbum( + albumName: String, + reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, + postSideEffect: (ArchiveMainSideEffect) -> Unit, + ) { + viewModelScope.launch { + folderRepository.createFolder(name = albumName) + .onSuccess { + fetchFolders(reduce) + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) + } + .onFailure { error -> + postSideEffect(ArchiveMainSideEffect.ShowToastMessage("앨범 추가에 실패했어요")) + Timber.e(error) + } + reduce { copy(isShowAddAlbumBottomSheet = false) } + } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/AddPhotoRow.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/AddPhotoRow.kt new file mode 100644 index 000000000..c0cb162e4 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/AddPhotoRow.kt @@ -0,0 +1,43 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun AddPhotoRow( + @DrawableRes iconRes: Int, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clickableSingle(onClick = onClick) + .padding(end = 11.dp) + .padding(vertical = 6.dp, horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = null, + tint = NekiTheme.colorScheme.gray600, + ) + Text( + text = label, + style = NekiTheme.typography.body16Medium, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/AlbumTitleRow.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/AlbumTitleRow.kt new file mode 100644 index 000000000..3f3568390 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/AlbumTitleRow.kt @@ -0,0 +1,63 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiTextButton +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun AlbumTitleRow( + modifier: Modifier = Modifier, + onClickShowAllAlbum: () -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "앨범", + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + NekiTextButton( + onClick = onClickShowAllAlbum, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "전체 앨범", + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_right), + contentDescription = null, + tint = NekiTheme.colorScheme.gray500, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun AlbumTitleRowPreview() { + NekiTheme { + AlbumTitleRow() + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt new file mode 100644 index 000000000..c897679e9 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainAlbumList.kt @@ -0,0 +1,276 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.cardShadow +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.AlbumPreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +private const val VIEWPORT_W = 124f +private const val VIEWPORT_H = 65f + +@Composable +internal fun ArchiveMainAlbumList( + favoriteAlbum: AlbumPreview, + modifier: Modifier = Modifier, + albumList: ImmutableList = persistentListOf(), + onClickFavoriteAlbum: () -> Unit = {}, + onClickAlbumItem: (AlbumPreview) -> Unit = {}, +) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "favorite_album") { + ArchiveAlbumItem( + title = favoriteAlbum.title, + photoCount = favoriteAlbum.photoCount, + thumbnailImage = favoriteAlbum.thumbnailUrl, + isFavorite = true, + onClick = onClickFavoriteAlbum, + ) + } + items( + items = albumList, + key = { album -> album.id }, + ) { album -> + ArchiveAlbumItem( + title = album.title, + photoCount = album.photoCount, + thumbnailImage = album.thumbnailUrl, + onClick = { onClickAlbumItem(album) }, + ) + } + } +} + +@Composable +private fun ArchiveAlbumItem( + title: String, + photoCount: Int, + thumbnailImage: String? = null, + modifier: Modifier = Modifier, + isFavorite: Boolean = false, + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .height(166.dp) + .clip(RoundedCornerShape(8.dp)) + .noRippleClickable(onClick = onClick), + ) { + AsyncImage( + modifier = Modifier + .cardShadow(shape = RoundedCornerShape(8.dp)) + .matchParentSize() + .then( + if (!thumbnailImage.isNullOrBlank()) Modifier + else Modifier.background(color = NekiTheme.colorScheme.gray50), + ), + model = thumbnailImage, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + AlbumFolder( + modifier = Modifier.align(Alignment.BottomCenter), + title = title, + photoCount = photoCount, + isFavorite = isFavorite, + ) + } +} + +@Composable +private fun AlbumFolder( + title: String, + photoCount: Int, + modifier: Modifier = Modifier, + isFavorite: Boolean = false, +) { + AlbumFolderLayout( + modifier = modifier.width(124.dp), + color = if (isFavorite) NekiTheme.colorScheme.favoriteAlbumCover.copy(alpha = 0.9f) + else NekiTheme.colorScheme.defaultAlbumCover.copy(alpha = 0.92f), + ) { + Row( + modifier = Modifier + .padding( + top = 15.dp, + bottom = 10.dp, + start = 10.dp, + end = 8.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column( + modifier = Modifier.width(80.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "${photoCount}장", + style = NekiTheme.typography.caption12Medium, + color = NekiTheme.colorScheme.white.copy(alpha = 0.7f), + ) + Text( + modifier = Modifier.widthIn(max = 80.dp), + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = NekiTheme.typography.body14SemiBold, + color = NekiTheme.colorScheme.white, + ) + } + + if (isFavorite) { + Box( + modifier = Modifier + .padding(bottom = 3.dp) + .align(Alignment.Bottom) + .clip(CircleShape) + .background( + color = NekiTheme.colorScheme.gray25.copy(alpha = 0.19f), + shape = CircleShape, + ) + .padding(4.dp), + ) { + Icon( + modifier = Modifier.size(8.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_heart), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } + } + } +} + +@Composable +private fun AlbumFolderLayout( + modifier: Modifier = Modifier, + color: Color = Color(0xFFFF5647), + content: @Composable BoxScope.() -> Unit = {}, +) { + Box( + modifier = modifier + .drawBehind { + val scaleX = size.width / VIEWPORT_W + val scaleY = size.height / VIEWPORT_H + + scale(scaleX, scaleY, pivot = Offset.Zero) { + val path = Path().apply { + // 오른쪽 하단에서 시작 (코너 8만큼 위) + moveTo(124f, 57f) + // 오른쪽 하단 코너 + cubicTo(124f, 61.42f, 120.42f, 65f, 116f, 65f) + // 하단 직선 + lineTo(8f, 65f) + // 왼쪽 하단 코너 + cubicTo(3.58f, 65f, 0f, 61.42f, 0f, 57f) + // 왼쪽 직선 + lineTo(0f, 8f) + // 왼쪽 상단 코너 + cubicTo(0f, 3.58f, 3.58f, 0f, 8f, 0f) + // 상단 탭 모양 + lineTo(58.54f, 0f) + cubicTo(61.07f, 0f, 63.45f, 1.2f, 64.96f, 3.23f) + lineTo(69.2f, 8.93f) + cubicTo(70.7f, 10.95f, 73.06f, 12.15f, 75.58f, 12.16f) + lineTo(116.04f, 12.35f) + cubicTo(120.44f, 12.36f, 124f, 15.94f, 124f, 20.34f) + // 오른쪽 직선 + lineTo(124f, 57f) + close() + } + drawPath(path, color, style = Fill) + } + }, + content = content, + ) +} + +@ComponentPreview +@Composable +private fun ArchiveAlbumItemPreview() { + NekiTheme { + Box(modifier = Modifier.padding(8.dp)) { + ArchiveAlbumItem( + title = "네키 화이팅화이팅", + photoCount = 10, + ) + } + } +} + +@ComponentPreview +@Composable +private fun FavoriteAlbumFolderPreview() { + NekiTheme { + AlbumFolder( + isFavorite = true, + title = "즐겨찾는 사진", + photoCount = 12, + ) + } +} + +@ComponentPreview +@Composable +private fun DefaultAlbumFolderPreview() { + NekiTheme { + AlbumFolder( + isFavorite = false, + title = "네키 화이팅화이팅", + photoCount = 10, + ) + } +} + +@Preview +@Composable +private fun ArchiveMainAlbumListPreview() { + NekiTheme { + ArchiveMainAlbumList( + favoriteAlbum = AlbumPreview(), + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainPhotoItem.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainPhotoItem.kt new file mode 100644 index 000000000..e2fe110d8 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainPhotoItem.kt @@ -0,0 +1,52 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.photoBackground +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.PhotoComponent + +@Composable +internal fun ArchiveMainPhotoItem( + photo: Photo, + modifier: Modifier = Modifier, + onClickItem: (Photo) -> Unit = {}, +) { + PhotoComponent( + photo = photo, + modifier = modifier.photoBackground(), + onClickItem = onClickItem, + ) { + if (photo.isFavorite) { + Icon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 10.dp, end = 10.dp) + .size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_heart), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ArchiveMainPhotoGridContentPreview() { + NekiTheme { + ArchiveMainPhotoItem( + photo = Photo(), + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainTopBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainTopBar.kt new file mode 100644 index 000000000..0533bfee6 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ArchiveMainTopBar.kt @@ -0,0 +1,262 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.modifier.dropdownShadow +import com.neki.android.core.designsystem.topbar.NekiLeftTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.R as ArchiveR + +@Composable +internal fun ArchiveMainTopBar( + showAddPopup: Boolean, + modifier: Modifier = Modifier, + onClickPlusIcon: () -> Unit = {}, + onClickNotificationIcon: () -> Unit = {}, + onClickQRScan: () -> Unit = {}, + onClickGallery: () -> Unit = {}, + onClickNewAlbum: () -> Unit = {}, + onDismissPopup: () -> Unit = {}, + showTooltip: Boolean = true, +) { + NekiLeftTitleTopBar( + modifier = modifier, + title = { + Box( + modifier = Modifier + .height(28.dp) + .width(56.dp) + .background(color = Color(0xFFB7B9C3)), + ) + }, + actions = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + NekiIconButton( + onClick = onClickPlusIcon, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_plus_primary), + contentDescription = null, + tint = Color.Unspecified, + ) + } + + if (showAddPopup) { + AddPhotoPopup( + onDismissRequest = onDismissPopup, + onClickQRScan = onClickQRScan, + onClickGallery = onClickGallery, + onClickNewAlbum = onClickNewAlbum, + ) + } + if (showTooltip) { + ToolTip() + } + } + NekiIconButton( + onClick = onClickNotificationIcon, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_bell), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + }, + ) +} + +@Composable +private fun ToolTip() { + val caretColor = NekiTheme.colorScheme.gray800 + val density = LocalDensity.current + + val popupOffsetX = with(density) { 1.dp.toPx().toInt() } + val popupOffsetY = with(density) { 47.dp.toPx().toInt() } + + Popup( + alignment = Alignment.TopEnd, + offset = IntOffset(popupOffsetX, popupOffsetY), + ) { + Column( + modifier = Modifier.width(IntrinsicSize.Max), + ) { + // 꼬리 (오른쪽 정렬, 오른쪽에서 16dp) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Canvas(modifier = Modifier.size(width = 10.dp, height = 8.dp)) { + val cornerRadius = 2.dp.toPx() + val path = Path().apply { + // 왼쪽 하단에서 시작 + moveTo(0f, size.height) + // 왼쪽 하단 -> 꼭대기 (둥근 모서리) + lineTo( + size.width / 2 - cornerRadius, + cornerRadius, + ) + quadraticTo( + size.width / 2, + 0f, + size.width / 2 + cornerRadius, + cornerRadius, + ) + // 꼭대기 -> 오른쪽 하단 + lineTo(size.width, size.height) + close() + } + drawPath(path, caretColor) + } + } + + // 몸통 + Box( + modifier = Modifier + .background( + color = NekiTheme.colorScheme.gray800, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = "버튼을 눌러 네컷을 추가할 수 있어요", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.white, + ) + } + } + } +} + +@Composable +private fun AddPhotoPopup( + onDismissRequest: () -> Unit, + onClickQRScan: () -> Unit, + onClickGallery: () -> Unit, + onClickNewAlbum: () -> Unit, +) { + val density = LocalDensity.current + val popupOffsetX = with(density) { (-4).dp.toPx().toInt() } + val popupOffsetY = with(density) { 48.dp.toPx().toInt() } + + Popup( + offset = IntOffset(x = popupOffsetX, y = popupOffsetY), + alignment = Alignment.TopEnd, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier + .dropdownShadow(shape = RoundedCornerShape(12.dp)) + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(12.dp), + ) + .width(IntrinsicSize.Max) + .padding(vertical = 8.dp), + ) { + AddPhotoRow( + modifier = Modifier.fillMaxWidth(), + iconRes = ArchiveR.drawable.icon_qrcode_scan, + label = "QR 인식", + onClick = onClickQRScan, + ) + AddPhotoRow( + modifier = Modifier.fillMaxWidth(), + iconRes = ArchiveR.drawable.icon_upload_gallery, + label = "갤러리에서 추가", + onClick = onClickGallery, + ) + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + thickness = 1.dp, + color = NekiTheme.colorScheme.gray50, + ) + AddPhotoRow( + modifier = Modifier.fillMaxWidth(), + iconRes = ArchiveR.drawable.icon_add_new_album, + label = "새 앨범 추가", + onClick = onClickNewAlbum, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ArchiveMainTopBarPreview() { + NekiTheme { + Column( + modifier = Modifier.padding(bottom = 50.dp), + ) { + ArchiveMainTopBar( + modifier = Modifier.padding(start = 20.dp, end = 8.dp), + showAddPopup = false, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ArchiveMainTopBarWithTooltipPreview() { + NekiTheme { + Box(modifier = Modifier.height(200.dp)) { + ArchiveMainTopBar( + modifier = Modifier.padding(start = 20.dp, end = 8.dp), + showTooltip = true, + showAddPopup = false, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ArchiveMainTopBarWithPopupPreview() { + NekiTheme { + Box(modifier = Modifier.height(200.dp)) { + ArchiveMainTopBar( + modifier = Modifier.padding(start = 20.dp, end = 8.dp), + showAddPopup = true, + ) + } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ChooseWithAlbumDialog.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ChooseWithAlbumDialog.kt new file mode 100644 index 000000000..2d58cc9e3 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/ChooseWithAlbumDialog.kt @@ -0,0 +1,68 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun ChooseWithAlbumDialog( + onDismissRequest: () -> Unit = {}, + onClickUploadWithOutAlbum: () -> Unit = {}, + onClickUploadWithAlbum: () -> Unit = {}, +) { + Dialog( + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .widthIn(max = 400.dp) + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white) + .padding(vertical = 12.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = onClickUploadWithOutAlbum) + .padding(vertical = 14.dp), + text = "앨범 없이 업로드하기", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.gray800, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = onClickUploadWithAlbum) + .padding(vertical = 14.dp), + text = "앨범 선택 후 업로드하기", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.gray800, + textAlign = TextAlign.Center, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ChooseWithAlbumDialogPreview() { + NekiTheme { + ChooseWithAlbumDialog() + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/GotoTopButton.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/GotoTopButton.kt new file mode 100644 index 000000000..a615f7ddd --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/GotoTopButton.kt @@ -0,0 +1,63 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.R + +@Composable +internal fun GotoTopButton( + modifier: Modifier = Modifier, + visible: Boolean = false, + onClick: () -> Unit = {}, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier.clip(CircleShape), + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = fadeOut() + shrinkOut(shrinkTowards = Alignment.Center), + ) { + Box( + modifier = Modifier + .buttonShadow() + .background( + color = NekiTheme.colorScheme.gray800, + shape = CircleShape, + ) + .clickableSingle(onClick = onClick) + .padding(10.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_goto_top), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } +} + +@Preview +@Composable +private fun GotoTopButtonPreview() { + NekiTheme { + GotoTopButton { } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt new file mode 100644 index 000000000..cfc049feb --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt @@ -0,0 +1,47 @@ +package com.neki.android.feature.archive.impl.main.component + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun NoPhotoContent( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 70.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(104.dp) + .background( + color = NekiTheme.colorScheme.gray50, + shape = CircleShape, + ), + ) + Text( + text = "아직 등록된 사진이 없어요\n찍은 네컷을 네키에 저장해보세요!", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray300, + textAlign = TextAlign.Center, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/PhotoTitleRow.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/PhotoTitleRow.kt new file mode 100644 index 000000000..38fb4d530 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/PhotoTitleRow.kt @@ -0,0 +1,71 @@ +package com.neki.android.feature.archive.impl.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiTextButton +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_ROW_TEXT_BUTTON_PADDING + +@Composable +internal fun ArchiveMainTitleRow( + title: String, + textButtonTitle: String, + modifier: Modifier = Modifier, + onClickShowAllAlbum: () -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + NekiTextButton( + modifier = Modifier.offset(x = ARCHIVE_ROW_TEXT_BUTTON_PADDING.dp), + onClick = onClickShowAllAlbum, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = textButtonTitle, + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray500, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_right), + contentDescription = null, + tint = NekiTheme.colorScheme.gray500, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun ArchiveMainTitleRowPreview() { + NekiTheme { + ArchiveMainTitleRow( + title = "앨범", + textButtonTitle = "전체 앨범", + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/model/SelectMode.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/model/SelectMode.kt new file mode 100644 index 000000000..fdd5f6427 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/model/SelectMode.kt @@ -0,0 +1,6 @@ +package com.neki.android.feature.archive.impl.model + +enum class SelectMode { + DEFAULT, + SELECTING, +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt new file mode 100644 index 000000000..b0a30f01c --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt @@ -0,0 +1,152 @@ +package com.neki.android.feature.archive.impl.navigation + +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.core.navigation.result.ResultEffect +import com.neki.android.feature.archive.api.ArchiveNavKey +import com.neki.android.feature.archive.api.ArchiveResult +import com.neki.android.feature.archive.api.navigateToAlbumDetail +import com.neki.android.feature.archive.api.navigateToAllAlbum +import com.neki.android.feature.archive.api.navigateToAllPhoto +import com.neki.android.feature.archive.api.navigateToPhotoDetail +import com.neki.android.feature.archive.impl.album.AllAlbumRoute +import com.neki.android.feature.archive.impl.album_detail.AlbumDetailIntent +import com.neki.android.feature.archive.impl.album_detail.AlbumDetailRoute +import com.neki.android.feature.archive.impl.album_detail.AlbumDetailViewModel +import com.neki.android.feature.archive.impl.main.ArchiveMainIntent +import com.neki.android.feature.archive.impl.main.ArchiveMainRoute +import com.neki.android.feature.archive.impl.main.ArchiveMainViewModel +import com.neki.android.feature.archive.impl.photo.AllPhotoIntent +import com.neki.android.feature.archive.impl.photo.AllPhotoRoute +import com.neki.android.feature.archive.impl.photo.AllPhotoViewModel +import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailRoute +import com.neki.android.feature.archive.impl.photo_detail.PhotoDetailViewModel +import com.neki.android.feature.photo_upload.api.QRScanResult +import com.neki.android.feature.photo_upload.api.navigateToQRScan +import com.neki.android.feature.photo_upload.api.navigateToUploadAlbum +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object ArchiveEntryProviderModule { + + @IntoSet + @Provides + fun provideArchiveEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + archiveEntry(navigator) + } +} + +private fun EntryProviderScope.archiveEntry(navigator: Navigator) { + entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + ResultEffect(resultBus) { result -> + when (result) { + is QRScanResult.QRCodeScanned -> { + viewModel.store.onIntent(ArchiveMainIntent.QRCodeScanned(result.imageUrl)) + } + + QRScanResult.OpenGallery -> { + viewModel.store.onIntent(ArchiveMainIntent.ClickGalleryUploadRow) + } + } + } + ResultEffect(resultBus) { + viewModel.store.onIntent(ArchiveMainIntent.RefreshArchiveMainScreen) + } + + ArchiveMainRoute( + viewModel = viewModel, + navigateToQRScan = navigator::navigateToQRScan, + navigateToUploadAlbumWithGallery = navigator::navigateToUploadAlbum, + navigateToUploadAlbumWithQRScan = navigator::navigateToUploadAlbum, + navigateToAllAlbum = navigator::navigateToAllAlbum, + navigateToFavoriteAlbum = { id -> + navigator.navigateToAlbumDetail(id = id, isFavorite = true) + }, + navigateToAlbumDetail = { id, title -> + navigator.navigateToAlbumDetail(id = id, title = title, isFavorite = false) + }, + navigateToAllPhoto = navigator::navigateToAllPhoto, + navigateToPhotoDetail = navigator::navigateToPhotoDetail, + ) + } + + entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + + ResultEffect(resultBus) { result -> + when (result) { + is ArchiveResult.FavoriteChanged -> { + viewModel.store.onIntent(AllPhotoIntent.FavoriteChanged(result.photoId, result.isFavorite)) + } + + is ArchiveResult.PhotoDeleted -> { + viewModel.store.onIntent(AllPhotoIntent.PhotoDeleted(result.photoId)) + } + } + } + + AllPhotoRoute( + viewModel = viewModel, + navigateBack = navigator::goBack, + navigateToPhotoDetail = navigator::navigateToPhotoDetail, + ) + } + + entry { + AllAlbumRoute( + navigateBack = navigator::goBack, + navigateToFavoriteAlbum = { id -> + navigator.navigateToAlbumDetail(id = id, isFavorite = true) + }, + navigateToAlbumDetail = { id, title -> + navigator.navigateToAlbumDetail(id = id, title = title, isFavorite = false) + }, + ) + } + + entry { key -> + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(key.albumId, key.title, key.isFavorite) }, + ) + + ResultEffect(resultBus) { result -> + when (result) { + is ArchiveResult.FavoriteChanged -> { + viewModel.store.onIntent(AlbumDetailIntent.FavoriteChanged(result.photoId, result.isFavorite)) + } + + is ArchiveResult.PhotoDeleted -> { + viewModel.store.onIntent(AlbumDetailIntent.PhotoDeleted(result.photoId)) + } + } + } + + AlbumDetailRoute( + viewModel = viewModel, + navigateBack = navigator::goBack, + navigateToPhotoDetail = navigator::navigateToPhotoDetail, + ) + } + + entry { key -> + PhotoDetailRoute( + viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(key.photo) }, + ), + navigateBack = navigator::goBack, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt new file mode 100644 index 000000000..6471acd82 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoContract.kt @@ -0,0 +1,57 @@ +package com.neki.android.feature.archive.impl.photo + +import com.neki.android.core.model.Photo +import com.neki.android.feature.archive.impl.model.SelectMode +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class AllPhotoState( + val isLoading: Boolean = false, + val selectMode: SelectMode = SelectMode.DEFAULT, + val selectedPhotoFilter: PhotoFilter = PhotoFilter.NEWEST, + val selectedPhotos: ImmutableList = persistentListOf(), + val isFavoriteChipSelected: Boolean = false, + val isShowFilterDialog: Boolean = false, + val isShowDeleteDialog: Boolean = false, +) + +enum class PhotoFilter(val label: String) { + NEWEST("최신순"), + OLDEST("오래된순"), +} + +sealed interface AllPhotoIntent { + + data object EnterAllPhotoScreen : AllPhotoIntent + + // TopBar Intent + data object ClickTopBarBackIcon : AllPhotoIntent + data object ClickTopBarSelectIcon : AllPhotoIntent + data object ClickTopBarCancelIcon : AllPhotoIntent + data object OnBackPressed : AllPhotoIntent + + // Filter Intent + data object ClickFilterChip : AllPhotoIntent + data object DismissFilterPopup : AllPhotoIntent + data object ClickFavoriteFilterChip : AllPhotoIntent + data class ClickFilterPopupRow(val filter: PhotoFilter) : AllPhotoIntent + + // Photo Intent + data class ClickPhotoItem(val photo: Photo) : AllPhotoIntent + data object ClickDownloadIcon : AllPhotoIntent + data object ClickDeleteIcon : AllPhotoIntent + data object DismissDeleteDialog : AllPhotoIntent + data object ClickDeleteDialogConfirmButton : AllPhotoIntent + + // Result Intent + data class PhotoDeleted(val photoIds: List) : AllPhotoIntent + data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : AllPhotoIntent +} + +sealed interface AllPhotoSideEffect { + data object NavigateBack : AllPhotoSideEffect + data object ScrollToTop : AllPhotoSideEffect + data class NavigateToPhotoDetail(val photo: Photo) : AllPhotoSideEffect + data class ShowToastMessage(val message: String) : AllPhotoSideEffect + data class DownloadImages(val imageUrls: List) : AllPhotoSideEffect +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt new file mode 100644 index 000000000..8cf2fce06 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt @@ -0,0 +1,241 @@ +package com.neki.android.feature.archive.impl.photo + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.archive.impl.component.DeletePhotoDialog +import com.neki.android.feature.archive.impl.component.SelectablePhotoItem +import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING +import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRID_LAYOUT_TOP_PADDING +import com.neki.android.feature.archive.impl.model.SelectMode +import com.neki.android.feature.archive.impl.photo.component.AllPhotoFilterBar +import com.neki.android.feature.archive.impl.photo.component.AllPhotoTopBar +import com.neki.android.feature.archive.impl.photo.component.PhotoActionBar +import com.neki.android.feature.archive.impl.util.ImageDownloader +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@Composable +internal fun AllPhotoRoute( + viewModel: AllPhotoViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToPhotoDetail: (Photo) -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val pagingItems = viewModel.photoPagingData.collectAsLazyPagingItems() + val context = LocalContext.current + val lazyState = rememberLazyStaggeredGridState() + val coroutineScope = rememberCoroutineScope() + val nekiToast = remember { NekiToast(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + AllPhotoSideEffect.NavigateBack -> navigateBack() + AllPhotoSideEffect.ScrollToTop -> coroutineScope.launch { + snapshotFlow { pagingItems.loadState.refresh } + .dropWhile { it is LoadState.NotLoading } // 해당 이벤트 도착이, 새로운 pagingItems 조회보다 빠름. + .first { it is LoadState.NotLoading } + lazyState.scrollToItem(0) + } + + is AllPhotoSideEffect.NavigateToPhotoDetail -> navigateToPhotoDetail(sideEffect.photo) + is AllPhotoSideEffect.ShowToastMessage -> { + nekiToast.showToast(text = sideEffect.message) + } + + is AllPhotoSideEffect.DownloadImages -> { + ImageDownloader.downloadImages(context, sideEffect.imageUrls) + .onSuccess { + nekiToast.showToast(text = "사진을 갤러리에 다운로드했어요") + } + .onFailure { + nekiToast.showToast(text = "다운로드에 실패했어요") + } + } + } + } + + AllPhotoScreen( + uiState = uiState, + pagingItems = pagingItems, + lazyState = lazyState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +internal fun AllPhotoScreen( + uiState: AllPhotoState, + pagingItems: LazyPagingItems, + lazyState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + onIntent: (AllPhotoIntent) -> Unit = {}, +) { + val density = LocalDensity.current + var filterBarHeightPx by remember { mutableIntStateOf(0) } + val topPadding = with(density) { filterBarHeightPx.toDp() } + val showFilterBar by remember { + derivedStateOf { + !lazyState.canScrollBackward || + lazyState.lastScrolledBackward || + uiState.selectMode == SelectMode.SELECTING + } + } + + val isRefreshing by remember { + derivedStateOf { + pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 + } + } + + BackHandler(enabled = true) { + onIntent(AllPhotoIntent.OnBackPressed) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.white), + ) { + AllPhotoTopBar( + selectMode = uiState.selectMode, + onClickBack = { onIntent(AllPhotoIntent.ClickTopBarBackIcon) }, + onClickSelect = { onIntent(AllPhotoIntent.ClickTopBarSelectIcon) }, + onClickCancel = { onIntent(AllPhotoIntent.ClickTopBarCancelIcon) }, + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + LazyVerticalStaggeredGrid( + modifier = Modifier.fillMaxSize(), + columns = StaggeredGridCells.Fixed(2), + state = lazyState, + contentPadding = PaddingValues( + top = if (uiState.selectMode == SelectMode.SELECTING) PHOTO_GRID_LAYOUT_TOP_PADDING.dp else topPadding + PHOTO_GRID_LAYOUT_TOP_PADDING.dp, + start = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + end = PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING.dp, + bottom = PHOTO_GRAY_LAYOUT_BOTTOM_PADDING.dp, + ), + verticalItemSpacing = ARCHIVE_GRID_ITEM_SPACING.dp, + horizontalArrangement = Arrangement.spacedBy(ARCHIVE_GRID_ITEM_SPACING.dp), + ) { + items( + count = pagingItems.itemCount, + key = pagingItems.itemKey { it.id }, + ) { index -> + val photo = pagingItems[index] + if (photo != null) { + val isSelected = uiState.selectedPhotos.any { it.id == photo.id } + SelectablePhotoItem( + photo = photo, + isSelected = isSelected, + isSelectMode = uiState.selectMode == SelectMode.SELECTING, + onClickItem = { onIntent(AllPhotoIntent.ClickPhotoItem(photo)) }, + onClickSelect = { onIntent(AllPhotoIntent.ClickPhotoItem(photo)) }, + ) + } + } + + if (pagingItems.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = NekiTheme.colorScheme.primary500, + ) + } + } + } + } + + AllPhotoFilterBar( + showFilterPopup = uiState.isShowFilterDialog, + modifier = Modifier + .onSizeChanged { size -> + if (filterBarHeightPx != 0) return@onSizeChanged + filterBarHeightPx = size.height + }, + selectedFilter = uiState.selectedPhotoFilter, + isFavoriteSelected = uiState.isFavoriteChipSelected, + visible = uiState.selectMode == SelectMode.DEFAULT && showFilterBar, + onClickSortChip = { onIntent(AllPhotoIntent.ClickFilterChip) }, + onClickFavoriteChip = { onIntent(AllPhotoIntent.ClickFavoriteFilterChip) }, + onDismissPopup = { onIntent(AllPhotoIntent.DismissFilterPopup) }, + onClickFilterRow = { onIntent(AllPhotoIntent.ClickFilterPopupRow(it)) }, + ) + } + + PhotoActionBar( + visible = uiState.selectMode == SelectMode.SELECTING, + isEnabled = uiState.selectedPhotos.isNotEmpty(), + onClickDownload = { onIntent(AllPhotoIntent.ClickDownloadIcon) }, + onClickDelete = { onIntent(AllPhotoIntent.ClickDeleteIcon) }, + ) + } + + if (isRefreshing || uiState.isLoading) { + LoadingDialog() + } + + if (uiState.isShowDeleteDialog) { + DeletePhotoDialog( + onDismissRequest = { onIntent(AllPhotoIntent.DismissDeleteDialog) }, + onClickDelete = { onIntent(AllPhotoIntent.ClickDeleteDialogConfirmButton) }, + onClickCancel = { onIntent(AllPhotoIntent.DismissDeleteDialog) }, + ) + } +} + +@DevicePreview +@Composable +private fun AllPhotoScreenPreview() { + NekiTheme { + // Preview는 Paging 없이 간단히 표시 + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt new file mode 100644 index 000000000..fe0189c25 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt @@ -0,0 +1,233 @@ +package com.neki.android.feature.archive.impl.photo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.Photo +import com.neki.android.core.model.SortOrder +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.archive.impl.model.SelectMode +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AllPhotoViewModel @Inject constructor( + private val photoRepository: PhotoRepository, +) : ViewModel() { + + private val deletedPhotoIds = MutableStateFlow>(emptySet()) + private val updatedFavorites = MutableStateFlow>(emptyMap()) + private val _photoFilter = MutableStateFlow(PhotoFilter.NEWEST) + private val _isFavoriteOnly = MutableStateFlow(false) + + @OptIn(ExperimentalCoroutinesApi::class) + private val originalPagingData: Flow> = combine( + _photoFilter, + _isFavoriteOnly, + ) { filter, isFavoriteOnly -> + filter to isFavoriteOnly + }.flatMapLatest { (filter, isFavoriteOnly) -> + val sortOrder = when (filter) { + PhotoFilter.NEWEST -> SortOrder.DESC + PhotoFilter.OLDEST -> SortOrder.ASC + } + if (isFavoriteOnly) { + photoRepository.getFavoritePhotosFlow(sortOrder) + } else { + photoRepository.getPhotosFlow(sortOrder = sortOrder) + } + }.cachedIn(viewModelScope) + + val photoPagingData: Flow> = combine( + originalPagingData, + deletedPhotoIds, + updatedFavorites, + ) { pagingData, deletedIds, favorites -> + pagingData + .filter { photo -> photo.id !in deletedIds } + .map { photo -> + favorites[photo.id]?.let { isFavorite -> + photo.copy(isFavorite = isFavorite) + } ?: photo + } + } + + val store: MviIntentStore = + mviIntentStore( + initialState = AllPhotoState(), + onIntent = ::onIntent, + ) + + private fun onIntent( + intent: AllPhotoIntent, + state: AllPhotoState, + reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + when (intent) { + AllPhotoIntent.EnterAllPhotoScreen -> Unit + + // TopBar Intent + AllPhotoIntent.ClickTopBarBackIcon -> handleBackClick(state, reduce, postSideEffect) + AllPhotoIntent.ClickTopBarSelectIcon -> reduce { copy(selectMode = SelectMode.SELECTING) } + AllPhotoIntent.OnBackPressed -> handleBackClick(state, reduce, postSideEffect) + AllPhotoIntent.ClickTopBarCancelIcon -> reduce { + copy( + selectMode = SelectMode.DEFAULT, + selectedPhotos = emptyList().toImmutableList(), + ) + } + + // Filter Intent + AllPhotoIntent.ClickFilterChip -> reduce { copy(isShowFilterDialog = true) } + AllPhotoIntent.DismissFilterPopup -> reduce { copy(isShowFilterDialog = false) } + AllPhotoIntent.ClickFavoriteFilterChip -> handleFavoriteFilter(state, reduce, postSideEffect) + is AllPhotoIntent.ClickFilterPopupRow -> handleFilterRow(intent.filter, reduce, postSideEffect) + + // Photo Intent + is AllPhotoIntent.ClickPhotoItem -> handlePhotoClick(intent.photo, state, reduce, postSideEffect) + AllPhotoIntent.ClickDownloadIcon -> downloadSelectedPhotos(state, postSideEffect) + AllPhotoIntent.ClickDeleteIcon -> reduce { copy(isShowDeleteDialog = true) } + AllPhotoIntent.DismissDeleteDialog -> reduce { copy(isShowDeleteDialog = false) } + AllPhotoIntent.ClickDeleteDialogConfirmButton -> deleteSelectedPhotos(state, reduce, postSideEffect) + + // Result Intent + is AllPhotoIntent.PhotoDeleted -> { + deletedPhotoIds.update { it + intent.photoIds.toSet() } + } + is AllPhotoIntent.FavoriteChanged -> { + updatedFavorites.update { it + (intent.photoId to intent.isFavorite) } + } + } + } + + private fun handleBackClick( + state: AllPhotoState, + reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + when (state.selectMode) { + SelectMode.DEFAULT -> postSideEffect(AllPhotoSideEffect.NavigateBack) + SelectMode.SELECTING -> reduce { + copy( + selectMode = SelectMode.DEFAULT, + selectedPhotos = persistentListOf(), + ) + } + } + } + + private fun handleFavoriteFilter( + state: AllPhotoState, + reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + val newValue = !state.isFavoriteChipSelected + _isFavoriteOnly.value = newValue + reduce { copy(isFavoriteChipSelected = newValue) } + postSideEffect(AllPhotoSideEffect.ScrollToTop) + } + + private fun handleFilterRow( + filter: PhotoFilter, + reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + _photoFilter.value = filter + reduce { + copy( + isShowFilterDialog = false, + selectedPhotoFilter = filter, + ) + } + postSideEffect(AllPhotoSideEffect.ScrollToTop) + } + + private fun handlePhotoClick( + photo: Photo, + state: AllPhotoState, + reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + when (state.selectMode) { + SelectMode.DEFAULT -> { + postSideEffect(AllPhotoSideEffect.NavigateToPhotoDetail(photo)) + } + + SelectMode.SELECTING -> { + val isSelected = state.selectedPhotos.any { it.id == photo.id } + if (isSelected) { + reduce { + copy(selectedPhotos = selectedPhotos.filter { it.id != photo.id }.toImmutableList()) + } + } else { + reduce { + copy(selectedPhotos = (selectedPhotos + photo).toImmutableList()) + } + } + } + } + } + + private fun downloadSelectedPhotos( + state: AllPhotoState, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + if (state.selectedPhotos.isEmpty()) { + postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 선택해주세요.")) + return + } + postSideEffect(AllPhotoSideEffect.DownloadImages(state.selectedPhotos.map { it.imageUrl })) + } + + private fun deleteSelectedPhotos( + state: AllPhotoState, + reduce: (AllPhotoState.() -> AllPhotoState) -> Unit, + postSideEffect: (AllPhotoSideEffect) -> Unit, + ) { + if (state.selectedPhotos.isEmpty()) { + postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 선택해주세요.")) + return + } + + viewModelScope.launch { + val selectedPhotoIds = state.selectedPhotos.map { it.id } + reduce { copy(isLoading = true) } + + photoRepository.deletePhoto(photoIds = selectedPhotoIds) + .onSuccess { + Timber.d("삭제 성공") + deletedPhotoIds.update { it + selectedPhotoIds.toSet() } + reduce { + copy( + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteDialog = false, + isLoading = false, + ) + } + postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + postSideEffect(AllPhotoSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/AllPhotoFilterBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/AllPhotoFilterBar.kt new file mode 100644 index 000000000..b7bded7f7 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/AllPhotoFilterBar.kt @@ -0,0 +1,146 @@ +package com.neki.android.feature.archive.impl.photo.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.modifier.dropdownShadow +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.FilterBar +import com.neki.android.feature.archive.impl.photo.PhotoFilter + +@Composable +internal fun AllPhotoFilterBar( + showFilterPopup: Boolean, + selectedFilter: PhotoFilter, + isFavoriteSelected: Boolean, + modifier: Modifier = Modifier, + visible: Boolean = true, + onClickSortChip: () -> Unit = {}, + onClickFavoriteChip: () -> Unit = {}, + onDismissPopup: () -> Unit = {}, + onClickFilterRow: (PhotoFilter) -> Unit = {}, +) { + Box { + FilterBar( + isDownIconChipSelected = false, + isDefaultChipSelected = isFavoriteSelected, + downIconChipDisplayText = selectedFilter.label, + defaultChipDisplayText = "즐겨찾는", + modifier = modifier, + visible = visible, + onClickDownIconChip = onClickSortChip, + onClickDefaultChip = onClickFavoriteChip, + ) + if (showFilterPopup) { + PhotoFilterPopup( + selectedFilter = selectedFilter, + onDismissRequest = onDismissPopup, + onClickFilterRow = onClickFilterRow, + ) + } + } +} + +@Composable +private fun PhotoFilterPopup( + selectedFilter: PhotoFilter, + onDismissRequest: () -> Unit, + onClickFilterRow: (PhotoFilter) -> Unit, +) { + val density = LocalDensity.current + val popupOffsetX = with(density) { 20.dp.toPx().toInt() } + val popupOffsetY = with(density) { 46.dp.toPx().toInt() } + + Popup( + offset = IntOffset(x = popupOffsetX, y = popupOffsetY), + alignment = Alignment.TopStart, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier + .dropdownShadow(shape = RoundedCornerShape(8.dp)) + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(8.dp), + ) + .width(96.dp) + .padding(vertical = 6.dp), + ) { + PhotoFilter.entries.forEach { filter -> + Text( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (selectedFilter == filter) NekiTheme.colorScheme.gray50 + else NekiTheme.colorScheme.white, + ) + .clickableSingle { onClickFilterRow(filter) } + .padding(horizontal = 16.dp, vertical = 5.dp), + text = filter.label, + style = NekiTheme.typography.body14Medium, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun AllPhotoFilterBarDefaultPreview() { + NekiTheme { + AllPhotoFilterBar( + showFilterPopup = false, + selectedFilter = PhotoFilter.NEWEST, + isFavoriteSelected = false, + ) + } +} + +@ComponentPreview +@Composable +private fun AllPhotoFilterBarSelectedPreview() { + NekiTheme { + AllPhotoFilterBar( + showFilterPopup = false, + selectedFilter = PhotoFilter.OLDEST, + isFavoriteSelected = true, + ) + } +} + +@ComponentPreview +@Composable +private fun PhotoFilterPopupPreview() { + var showPopup by remember { mutableStateOf(true) } + + NekiTheme { + AllPhotoFilterBar( + showFilterPopup = showPopup, + selectedFilter = PhotoFilter.NEWEST, + isFavoriteSelected = false, + onClickSortChip = { showPopup = true }, + onClickFilterRow = { showPopup = false }, + onDismissPopup = { showPopup = false }, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/AllPhotoTopBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/AllPhotoTopBar.kt new file mode 100644 index 000000000..7c636bb33 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/AllPhotoTopBar.kt @@ -0,0 +1,55 @@ +package com.neki.android.feature.archive.impl.photo.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.topbar.BackTitleTextButtonTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.archive.impl.model.SelectMode + +@Composable +internal fun AllPhotoTopBar( + selectMode: SelectMode, + modifier: Modifier = Modifier, + onClickBack: () -> Unit = {}, + onClickSelect: () -> Unit = {}, + onClickCancel: () -> Unit = {}, +) { + BackTitleTextButtonTopBar( + modifier = modifier, + title = "모든 사진", + buttonLabel = when (selectMode) { + SelectMode.DEFAULT -> "선택" + SelectMode.SELECTING -> "취소" + }, + enabledTextColor = when (selectMode) { + SelectMode.DEFAULT -> NekiTheme.colorScheme.primary500 + SelectMode.SELECTING -> NekiTheme.colorScheme.gray800 + }, + onBack = onClickBack, + onClickTextButton = when (selectMode) { + SelectMode.DEFAULT -> onClickSelect + SelectMode.SELECTING -> onClickCancel + }, + ) +} + +@ComponentPreview +@Composable +private fun AllPhotoTopBarDefaultPreview() { + NekiTheme { + AllPhotoTopBar( + selectMode = SelectMode.DEFAULT, + ) + } +} + +@ComponentPreview +@Composable +private fun AllPhotoTopBarSelectingPreview() { + NekiTheme { + AllPhotoTopBar( + selectMode = SelectMode.SELECTING, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/PhotoActionBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/PhotoActionBar.kt new file mode 100644 index 000000000..f63120a05 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/component/PhotoActionBar.kt @@ -0,0 +1,96 @@ +package com.neki.android.feature.archive.impl.photo.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun PhotoActionBar( + visible: Boolean, + isEnabled: Boolean, + modifier: Modifier = Modifier, + onClickDownload: () -> Unit = {}, + onClickDelete: () -> Unit = {}, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = NekiTheme.colorScheme.gray75, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.noRippleClickableSingle( + enabled = isEnabled, + onClick = onClickDownload, + ), + imageVector = ImageVector.vectorResource(R.drawable.icon_download), + contentDescription = null, + tint = if (isEnabled) NekiTheme.colorScheme.gray600 else NekiTheme.colorScheme.gray200, + ) + Icon( + modifier = Modifier.noRippleClickableSingle( + enabled = isEnabled, + onClick = onClickDelete, + ), + imageVector = ImageVector.vectorResource(R.drawable.icon_trash), + contentDescription = null, + tint = if (isEnabled) NekiTheme.colorScheme.gray600 else NekiTheme.colorScheme.gray200, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun PhotoActionBarEnabledPreview() { + NekiTheme { + PhotoActionBar( + visible = true, + isEnabled = true, + ) + } +} + +@ComponentPreview +@Composable +private fun PhotoActionBarDisabledPreview() { + NekiTheme { + PhotoActionBar( + visible = true, + isEnabled = true, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt new file mode 100644 index 000000000..9cdde7136 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt @@ -0,0 +1,35 @@ +package com.neki.android.feature.archive.impl.photo_detail + +import com.neki.android.core.model.Photo +import com.neki.android.feature.archive.api.ArchiveResult + +data class PhotoDetailState( + val isLoading: Boolean = false, + val committedFavorite: Boolean = false, + val photo: Photo = Photo(), + val isShowDeleteDialog: Boolean = false, +) + +sealed interface PhotoDetailIntent { + // TopBar Intent + data object ClickBackIcon : PhotoDetailIntent + + // ActionBar Intent + data object ClickDownloadIcon : PhotoDetailIntent + data object ClickFavoriteIcon : PhotoDetailIntent + data class FavoriteCommitted(val newFavorite: Boolean) : PhotoDetailIntent + data class RevertFavorite(val originalFavorite: Boolean) : PhotoDetailIntent + data object ClickDeleteIcon : PhotoDetailIntent + + // Delete Dialog Intent + data object DismissDeleteDialog : PhotoDetailIntent + data object ClickDeleteDialogCancelButton : PhotoDetailIntent + data object ClickDeleteDialogConfirmButton : PhotoDetailIntent +} + +sealed interface PhotoDetailSideEffect { + data object NavigateBack : PhotoDetailSideEffect + data class NotifyPhotoUpdated(val result: ArchiveResult) : PhotoDetailSideEffect + data class ShowToastMessage(val message: String) : PhotoDetailSideEffect + data class DownloadImage(val imageUrl: String) : PhotoDetailSideEffect +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt new file mode 100644 index 000000000..f3256d625 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt @@ -0,0 +1,145 @@ +package com.neki.android.feature.archive.impl.photo_detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.topbar.BackTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.archive.impl.component.DeletePhotoDialog +import com.neki.android.feature.archive.impl.photo_detail.component.PhotoDetailActionBar +import com.neki.android.feature.archive.impl.util.ImageDownloader + +@Composable +internal fun PhotoDetailRoute( + viewModel: PhotoDetailViewModel, + navigateBack: () -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + PhotoDetailSideEffect.NavigateBack -> navigateBack() + is PhotoDetailSideEffect.NotifyPhotoUpdated -> resultEventBus.sendResult(result = sideEffect.result, allowDuplicate = false) + is PhotoDetailSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message) + is PhotoDetailSideEffect.DownloadImage -> { + ImageDownloader.downloadImage(context, sideEffect.imageUrl) + .onSuccess { + nekiToast.showToast(text = "사진을 갤러리에 다운로드했어요") + } + .onFailure { + nekiToast.showToast(text = "다운로드에 실패했어요") + } + } + } + } + + PhotoDetailScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +internal fun PhotoDetailScreen( + uiState: PhotoDetailState = PhotoDetailState(), + onIntent: (PhotoDetailIntent) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.white), + ) { + BackTitleTopBar( + title = uiState.photo.date, + onBack = { onIntent(PhotoDetailIntent.ClickBackIcon) }, + ) + + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + model = uiState.photo.imageUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = NekiTheme.colorScheme.gray75, + ) + + PhotoDetailActionBar( + isFavorite = uiState.photo.isFavorite, + onClickDownload = { onIntent(PhotoDetailIntent.ClickDownloadIcon) }, + onClickFavorite = { onIntent(PhotoDetailIntent.ClickFavoriteIcon) }, + onClickDelete = { onIntent(PhotoDetailIntent.ClickDeleteIcon) }, + ) + } + + if (uiState.isShowDeleteDialog) { + DeletePhotoDialog( + onDismissRequest = { onIntent(PhotoDetailIntent.DismissDeleteDialog) }, + onClickDelete = { onIntent(PhotoDetailIntent.ClickDeleteDialogConfirmButton) }, + onClickCancel = { onIntent(PhotoDetailIntent.ClickDeleteDialogCancelButton) }, + ) + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@DevicePreview +@Composable +private fun PhotoDetailScreenPreview() { + NekiTheme { + PhotoDetailScreen( + uiState = PhotoDetailState( + photo = Photo( + id = 1, + imageUrl = "https://picsum.photos/400/400", + isFavorite = false, + date = "2025-12-26", + ), + ), + ) + } +} + +@DevicePreview +@Composable +private fun PhotoDetailScreenFavoritePreview() { + NekiTheme { + PhotoDetailScreen( + uiState = PhotoDetailState( + photo = Photo( + id = 1, + imageUrl = "https://picsum.photos/400/400", + isFavorite = true, + date = "2025-12-26", + ), + ), + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt new file mode 100644 index 000000000..2ec30eaf7 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt @@ -0,0 +1,137 @@ +package com.neki.android.feature.archive.impl.photo_detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.common.coroutine.di.ApplicationScope +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.model.Photo +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.archive.api.ArchiveResult +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import timber.log.Timber + +@OptIn(FlowPreview::class) +@HiltViewModel(assistedFactory = PhotoDetailViewModel.Factory::class) +class PhotoDetailViewModel @AssistedInject constructor( + @Assisted private val photo: Photo, + private val photoRepository: PhotoRepository, + @ApplicationScope private val applicationScope: CoroutineScope, +) : ViewModel() { + + private val favoriteRequests = MutableSharedFlow(extraBufferCapacity = 64) + val store: MviIntentStore = + mviIntentStore( + initialState = PhotoDetailState(photo = photo, committedFavorite = photo.isFavorite), + onIntent = ::onIntent, + ) + + init { + viewModelScope.launch { + favoriteRequests + .debounce(500) + .collect { newFavorite -> + val committedFavorite = store.uiState.value.committedFavorite + if (committedFavorite != newFavorite) { + photoRepository.updateFavorite(photo.id, newFavorite) + .onSuccess { + Timber.d("updateFavorite success") + store.onIntent(PhotoDetailIntent.FavoriteCommitted(newFavorite)) + } + .onFailure { error -> + Timber.e(error, "updateFavorite failed") + store.onIntent(PhotoDetailIntent.RevertFavorite(committedFavorite)) + } + } + } + } + } + + @AssistedFactory + interface Factory { + fun create(photo: Photo): PhotoDetailViewModel + } + + private fun onIntent( + intent: PhotoDetailIntent, + state: PhotoDetailState, + reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, + postSideEffect: (PhotoDetailSideEffect) -> Unit, + ) { + when (intent) { + // TopBar Intent + PhotoDetailIntent.ClickBackIcon -> postSideEffect(PhotoDetailSideEffect.NavigateBack) + + // ActionBar Intent + PhotoDetailIntent.ClickDownloadIcon -> postSideEffect(PhotoDetailSideEffect.DownloadImage(state.photo.imageUrl)) + PhotoDetailIntent.ClickFavoriteIcon -> handleFavoriteToggle(state, reduce) + is PhotoDetailIntent.FavoriteCommitted -> { + reduce { copy(committedFavorite = intent.newFavorite) } + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated(ArchiveResult.FavoriteChanged(photo.id, intent.newFavorite))) + } + + is PhotoDetailIntent.RevertFavorite -> reduce { copy(photo = photo.copy(isFavorite = intent.originalFavorite)) } + PhotoDetailIntent.ClickDeleteIcon -> reduce { copy(isShowDeleteDialog = true) } + + // Delete Dialog Intent + PhotoDetailIntent.DismissDeleteDialog -> reduce { copy(isShowDeleteDialog = false) } + PhotoDetailIntent.ClickDeleteDialogCancelButton -> reduce { copy(isShowDeleteDialog = false) } + PhotoDetailIntent.ClickDeleteDialogConfirmButton -> handleDelete(state, reduce, postSideEffect) + } + } + + private fun handleFavoriteToggle( + state: PhotoDetailState, + reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, + ) { + val newFavoriteStatus = !state.photo.isFavorite + viewModelScope.launch { favoriteRequests.emit(newFavoriteStatus) } + reduce { + copy(photo = state.photo.copy(isFavorite = newFavoriteStatus)) + } + } + + private fun handleDelete( + state: PhotoDetailState, + reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, + postSideEffect: (PhotoDetailSideEffect) -> Unit, + ) { + reduce { copy(isLoading = true, isShowDeleteDialog = false) } + + viewModelScope.launch { + photoRepository.deletePhoto(state.photo.id) + .onSuccess { + reduce { copy(isLoading = false) } + postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated(ArchiveResult.PhotoDeleted(photo.id))) + postSideEffect(PhotoDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + postSideEffect(PhotoDetailSideEffect.NavigateBack) + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + postSideEffect(PhotoDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + } + } + + override fun onCleared() { + super.onCleared() + + val currentFavorite = store.uiState.value.photo.isFavorite + val committedFavorite = store.uiState.value.committedFavorite + + if (currentFavorite != committedFavorite) { + applicationScope.launch { + photoRepository.updateFavorite(photo.id, currentFavorite) + } + } + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt new file mode 100644 index 000000000..842e85689 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/component/PhotoDetailActionBar.kt @@ -0,0 +1,91 @@ +package com.neki.android.feature.archive.impl.photo_detail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun PhotoDetailActionBar( + isFavorite: Boolean, + modifier: Modifier = Modifier, + onClickDownload: () -> Unit = {}, + onClickFavorite: () -> Unit = {}, + onClickDelete: () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + modifier = Modifier + .size(28.dp) + .noRippleClickable { onClickDownload() }, + imageVector = ImageVector.vectorResource(R.drawable.icon_download), + contentDescription = null, + tint = NekiTheme.colorScheme.gray500, + ) + + Icon( + modifier = Modifier + .size(28.dp) + .noRippleClickable { onClickFavorite() }, + imageVector = if (isFavorite) { + ImageVector.vectorResource(R.drawable.icon_heart) + } else { + ImageVector.vectorResource(R.drawable.icon_heart_stroke) + }, + contentDescription = null, + tint = Color.Unspecified, + ) + } + + Icon( + modifier = Modifier + .size(28.dp) + .noRippleClickable { onClickDelete() }, + imageVector = ImageVector.vectorResource(R.drawable.icon_trash), + contentDescription = null, + tint = NekiTheme.colorScheme.gray600, + ) + } +} + +@ComponentPreview +@Composable +private fun PhotoDetailActionBarNotFavoritePreview() { + NekiTheme { + PhotoDetailActionBar( + isFavorite = false, + ) + } +} + +@ComponentPreview +@Composable +private fun PhotoDetailActionBarFavoritePreview() { + NekiTheme { + PhotoDetailActionBar( + isFavorite = true, + ) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/util/ImageDownloader.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/util/ImageDownloader.kt new file mode 100644 index 000000000..1f8f61655 --- /dev/null +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/util/ImageDownloader.kt @@ -0,0 +1,89 @@ +package com.neki.android.feature.archive.impl.util + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.toBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + +object ImageDownloader { + + suspend fun downloadImage(context: Context, imageUrl: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val bitmap = loadBitmapFromUrl(context, imageUrl) + saveBitmapToGallery(context, bitmap) + } + } + + suspend fun downloadImages(context: Context, imageUrls: List): Result = + withContext(Dispatchers.IO) { + var successCount = 0 + imageUrls.forEach { url -> + downloadImage(context, url).onSuccess { successCount++ } + } + if (successCount > 0) Result.success(successCount) + else Result.failure(IllegalStateException("다운로드에 실패했습니다.")) + } + + private suspend fun loadBitmapFromUrl(context: Context, imageUrl: String): Bitmap { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .build() + + val result = imageLoader.execute(request) + if (result is SuccessResult) { + return result.image.toBitmap() + } else { + error("이미지 로드에 실패했습니다.") + } + } + + private fun saveBitmapToGallery(context: Context, bitmap: Bitmap) { + val filename = "NEKI_${System.currentTimeMillis()}.jpg" + + val outputStream: OutputStream? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Neki") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ) ?: error("이미지 저장에 실패했습니다.") + + context.contentResolver.openOutputStream(uri)?.also { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) + contentValues.clear() + contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) + context.contentResolver.update(uri, contentValues, null, null) + } + } else { + val imagesDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES + "/Neki", + ) + if (!imagesDir.exists()) imagesDir.mkdirs() + + val imageFile = File(imagesDir, filename) + FileOutputStream(imageFile).also { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) + } + } + + outputStream?.close() + } +} diff --git a/feature/archive/impl/src/main/res/drawable/icon_add_new_album.xml b/feature/archive/impl/src/main/res/drawable/icon_add_new_album.xml new file mode 100644 index 000000000..1886990b4 --- /dev/null +++ b/feature/archive/impl/src/main/res/drawable/icon_add_new_album.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/archive/impl/src/main/res/drawable/icon_goto_top.xml b/feature/archive/impl/src/main/res/drawable/icon_goto_top.xml new file mode 100644 index 000000000..e10e5f64d --- /dev/null +++ b/feature/archive/impl/src/main/res/drawable/icon_goto_top.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/archive/impl/src/main/res/drawable/icon_qrcode_scan.xml b/feature/archive/impl/src/main/res/drawable/icon_qrcode_scan.xml new file mode 100644 index 000000000..a97d434ff --- /dev/null +++ b/feature/archive/impl/src/main/res/drawable/icon_qrcode_scan.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/archive/impl/src/main/res/drawable/icon_upload_gallery.xml b/feature/archive/impl/src/main/res/drawable/icon_upload_gallery.xml new file mode 100644 index 000000000..f6ba434c0 --- /dev/null +++ b/feature/archive/impl/src/main/res/drawable/icon_upload_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/auth/api/.gitignore b/feature/auth/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/auth/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/auth/api/build.gradle.kts b/feature/auth/api/build.gradle.kts new file mode 100644 index 000000000..3735db539 --- /dev/null +++ b/feature/auth/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.auth.api" +} diff --git a/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/LoginNavKey.kt b/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/LoginNavKey.kt new file mode 100644 index 000000000..7f4011c8e --- /dev/null +++ b/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/LoginNavKey.kt @@ -0,0 +1,15 @@ +package com.neki.android.feature.auth.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface LoginNavKey : NavKey { + + @Serializable + data object Login : LoginNavKey +} + +fun Navigator.navigateToLogin() { + navigate(LoginNavKey.Login) +} diff --git a/feature/auth/impl/.gitignore b/feature/auth/impl/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/auth/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/auth/impl/build.gradle.kts b/feature/auth/impl/build.gradle.kts new file mode 100644 index 000000000..9e8f836f0 --- /dev/null +++ b/feature/auth/impl/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.auth.impl" +} + +dependencies { + implementation(libs.androidx.activity.compose) + + implementation(projects.feature.auth.api) +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginContract.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginContract.kt new file mode 100644 index 000000000..5da06c3c4 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginContract.kt @@ -0,0 +1,18 @@ +package com.neki.android.feature.auth.impl + +data class LoginState( + val isLoading: Boolean = false, +) + +sealed interface LoginIntent { + data object EnterLoginScreen : LoginIntent + data object ClickKakaoLogin : LoginIntent + data class SuccessLogin(val idToken: String) : LoginIntent + data object FailLogin : LoginIntent +} + +sealed interface LoginSideEffect { + data object NavigateToHome : LoginSideEffect + data object NavigateToKakaoRedirectingUri : LoginSideEffect + data class ShowToastMessage(val message: String) : LoginSideEffect +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt new file mode 100644 index 000000000..0e9504ca8 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt @@ -0,0 +1,70 @@ +package com.neki.android.feature.auth.impl + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.common.kakao.KakaoAuthHelper +import com.neki.android.feature.auth.impl.component.LoginContent +import timber.log.Timber + +@Composable +fun LoginRoute( + viewModel: LoginViewModel = hiltViewModel(), + navigateToMain: () -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val kakaoAuthHelper = remember { KakaoAuthHelper(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + LoginSideEffect.NavigateToHome -> navigateToMain() + LoginSideEffect.NavigateToKakaoRedirectingUri -> { + kakaoAuthHelper.login( + onSuccess = { idToken -> + Timber.d("로그인 성공 $idToken") + viewModel.store.onIntent(LoginIntent.SuccessLogin(idToken)) + }, + onFailure = { message -> + Timber.d("로그인 실패 $message") + }, + ) + } + + is LoginSideEffect.ShowToastMessage -> { + Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + } + + LoginScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun LoginScreen( + uiState: LoginState = LoginState(), + onIntent: (LoginIntent) -> Unit = {}, +) { + LoginContent( + onClickKakaoLogin = { onIntent(LoginIntent.ClickKakaoLogin) }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun LoginScreenPreview() { + NekiTheme { + LoginScreen() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt new file mode 100644 index 000000000..9c89cffcd --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt @@ -0,0 +1,78 @@ +package com.neki.android.feature.auth.impl + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.auth.AuthEventManager +import com.neki.android.core.dataapi.repository.AuthRepository +import com.neki.android.core.dataapi.repository.TokenRepository +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authEventManager: AuthEventManager, + private val tokenRepository: TokenRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + val store: MviIntentStore = + mviIntentStore( + initialState = LoginState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(LoginIntent.EnterLoginScreen) }, + ) + + private fun onIntent( + intent: LoginIntent, + state: LoginState, + reduce: (LoginState.() -> LoginState) -> Unit, + postSideEffect: (LoginSideEffect) -> Unit, + ) { + when (intent) { + LoginIntent.EnterLoginScreen -> fetchInitialData(postSideEffect) + LoginIntent.ClickKakaoLogin -> postSideEffect(LoginSideEffect.NavigateToKakaoRedirectingUri) + is LoginIntent.SuccessLogin -> loginFromKakao(intent.idToken, reduce, postSideEffect) + LoginIntent.FailLogin -> postSideEffect(LoginSideEffect.ShowToastMessage("카카오 로그인에 실패했습니다.")) + } + } + + private fun fetchInitialData(postSideEffect: (LoginSideEffect) -> Unit) = viewModelScope.launch { + if (tokenRepository.isSavedTokens().first()) { + authRepository.updateAccessToken( + refreshToken = tokenRepository.getRefreshToken().first(), + ).onSuccess { + tokenRepository.saveTokens(it.accessToken, it.refreshToken) + postSideEffect(LoginSideEffect.NavigateToHome) + }.onFailure { exception -> + Timber.e(exception) + authEventManager.emitTokenExpired() + } + } else { + Timber.d("저장된 JWT 토큰이 없습니다.") + } + } + + private fun loginFromKakao( + idToken: String, + reduce: (LoginState.() -> LoginState) -> Unit, + postSideEffect: (LoginSideEffect) -> Unit, + ) = viewModelScope.launch { + reduce { copy(isLoading = true) } + authRepository.loginWithKakao(idToken) + .onSuccess { + tokenRepository.saveTokens( + accessToken = it.accessToken, + refreshToken = it.refreshToken, + ) + postSideEffect(LoginSideEffect.NavigateToHome) + } + .onFailure { exception -> + Timber.e(exception) + } + reduce { copy(isLoading = false) } + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/LoginContent.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/LoginContent.kt new file mode 100644 index 000000000..0f09dba9d --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/LoginContent.kt @@ -0,0 +1,30 @@ +package com.neki.android.feature.auth.impl.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp + +@Composable +fun LoginContent( + modifier: Modifier = Modifier, + onClickKakaoLogin: () -> Unit, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = onClickKakaoLogin, + ) { + Text( + text = "카카오 로그인", + fontSize = 22.sp, + ) + } + } +} diff --git a/feature/map/api/.gitignore b/feature/map/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/map/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/map/api/build.gradle.kts b/feature/map/api/build.gradle.kts new file mode 100644 index 000000000..673d2a9b5 --- /dev/null +++ b/feature/map/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.map.api" +} diff --git a/feature/map/api/src/main/java/com/neki/android/feature/map/api/MapNavKey.kt b/feature/map/api/src/main/java/com/neki/android/feature/map/api/MapNavKey.kt new file mode 100644 index 000000000..16472c6a2 --- /dev/null +++ b/feature/map/api/src/main/java/com/neki/android/feature/map/api/MapNavKey.kt @@ -0,0 +1,15 @@ +package com.neki.android.feature.map.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface MapNavKey : NavKey { + + @Serializable + data object Map : MapNavKey +} + +fun Navigator.navigateToMap() { + navigate(MapNavKey.Map) +} diff --git a/feature/map/impl/.gitignore b/feature/map/impl/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/map/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/map/impl/build.gradle.kts b/feature/map/impl/build.gradle.kts new file mode 100644 index 000000000..8cd9132a0 --- /dev/null +++ b/feature/map/impl/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.map.impl" +} + +dependencies { + implementation(projects.feature.map.api) + api(libs.map.sdk) + implementation(libs.naver.map.compose) + implementation(libs.play.services.location) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.coil.compose) + implementation(libs.androidx.activity.compose) +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt new file mode 100644 index 000000000..12421779a --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt @@ -0,0 +1,90 @@ +package com.neki.android.feature.map.impl + +import androidx.compose.ui.graphics.ImageBitmap +import com.neki.android.core.model.Brand +import com.neki.android.core.model.PhotoBooth +import com.neki.android.feature.map.impl.const.DirectionApp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +data class MapState( + val isLoading: Boolean = false, + val currentLocLatLng: LocLatLng? = null, + val isCameraOnCurrentLocation: Boolean = false, + val isVisibleRefreshButton: Boolean = false, + val dragLevel: DragLevel = DragLevel.FIRST, + val brands: ImmutableList = persistentListOf(), + val brandImageCache: ImmutableMap = persistentMapOf(), + val mapMarkers: ImmutableList = persistentListOf(), + val nearbyPhotoBooths: ImmutableList = persistentListOf(), + val isShowInfoDialog: Boolean = false, + val isShowDirectionBottomSheet: Boolean = false, + val isShowLocationPermissionDialog: Boolean = false, +) + +sealed interface MapIntent { + data object EnterMapScreen : MapIntent + + // in 지도 + data class UpdateCurrentLocation(val locLatLng: LocLatLng) : MapIntent + data object GrantedLocationPermission : MapIntent + data class LoadPhotoBoothsByBounds(val mapBounds: MapBounds) : MapIntent + data class ClickPhotoBoothMarker(val locLatLng: LocLatLng) : MapIntent + data class ClickRefreshButton(val mapBounds: MapBounds) : MapIntent + data object ClickDirectionIcon : MapIntent + data object GestureOnMap : MapIntent + + // in 패널 + data object ClickCurrentLocationIcon : MapIntent + data object ClickInfoIcon : MapIntent + data object ClickCloseInfoIcon : MapIntent + data object ClickToMapChip : MapIntent + data class ClickVerticalBrand(val brand: Brand) : MapIntent + data class ClickNearPhotoBooth(val photoBooth: PhotoBooth) : MapIntent + data class ClickPhotoBoothCard(val locLatLng: LocLatLng) : MapIntent + data object ClickClosePhotoBoothCard : MapIntent + data object OpenDirectionBottomSheet : MapIntent + data object CloseDirectionBottomSheet : MapIntent + data class ClickDirectionItem(val app: DirectionApp) : MapIntent + data class ChangeDragLevel(val dragLevel: DragLevel) : MapIntent + + // 위치 권한 + data object RequestLocationPermission : MapIntent + data object ShowLocationPermissionDialog : MapIntent + data object DismissLocationPermissionDialog : MapIntent + data object ConfirmLocationPermissionDialog : MapIntent +} + +sealed interface MapEffect { + data class MoveCameraToPosition( + val locLatLng: LocLatLng, + val isRequiredLoadPhotoBooths: Boolean = false, + ) : MapEffect + data object OpenDirectionBottomSheet : MapEffect + data class ShowToastMessage(val message: String) : MapEffect + + data class LaunchDirectionApp( + val app: DirectionApp, + val startLocLatLng: LocLatLng, + val endLocLatLng: LocLatLng, + ) : MapEffect + + data object NavigateToAppSettings : MapEffect + data object LaunchLocationPermission : MapEffect +} + +enum class DragLevel { FIRST, SECOND, THIRD, INVISIBLE } + +data class MapBounds( + val southWest: LocLatLng, + val northWest: LocLatLng, + val northEast: LocLatLng, + val southEast: LocLatLng, +) + +data class LocLatLng( + val latitude: Double, + val longitude: Double, +) diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt new file mode 100644 index 000000000..9c123bf89 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt @@ -0,0 +1,330 @@ +package com.neki.android.feature.map.impl + +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.CameraAnimation +import com.naver.maps.map.CameraPosition +import com.naver.maps.map.CameraUpdate +import com.naver.maps.map.compose.CameraPositionState +import com.naver.maps.map.compose.CameraUpdateReason +import com.naver.maps.map.compose.ExperimentalNaverMapApi +import com.naver.maps.map.compose.LocationTrackingMode +import com.naver.maps.map.compose.MapProperties +import com.naver.maps.map.compose.MapUiSettings +import com.naver.maps.map.compose.NaverMap +import com.naver.maps.map.compose.rememberCameraPositionState +import com.naver.maps.map.compose.rememberFusedLocationSource +import com.neki.android.core.common.permission.LocationPermissionManager +import com.neki.android.core.common.permission.navigateToAppSettings +import com.neki.android.core.designsystem.dialog.SingleButtonAlertDialog +import com.neki.android.core.designsystem.dialog.WarningDialog +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.map.impl.component.AnchoredDraggablePanel +import com.neki.android.feature.map.impl.component.DirectionBottomSheet +import com.neki.android.feature.map.impl.component.MapRefreshChip +import com.neki.android.feature.map.impl.component.PhotoBoothDetailContent +import com.neki.android.feature.map.impl.component.PhotoBoothMarker +import com.neki.android.feature.map.impl.component.ToMapChip +import com.neki.android.feature.map.impl.const.MapConst +import com.neki.android.feature.map.impl.util.DirectionHelper +import kotlinx.coroutines.launch + +@OptIn(ExperimentalNaverMapApi::class) +@Composable +fun MapRoute( + viewModel: MapViewModel = hiltViewModel(), +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val activity = LocalActivity.current!! + val scope = rememberCoroutineScope() + val nekiToast = remember { NekiToast(context) } + + var locationTrackingMode by remember { mutableStateOf(LocationTrackingMode.None) } + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition( + LatLng(MapConst.DEFAULT_LATITUDE, MapConst.DEFAULT_LONGITUDE), + MapConst.DEFAULT_ZOOM_LEVEL, + ) + } + var isNavigatedToSettings by remember { mutableStateOf(false) } + + val locationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val isGranted = permissions.values.any { it } + + if (isGranted) { + locationTrackingMode = LocationTrackingMode.NoFollow + viewModel.store.onIntent(MapIntent.GrantedLocationPermission) + } else { + cameraPositionState.contentBounds?.let { bounds -> + viewModel.store.onIntent( + MapIntent.LoadPhotoBoothsByBounds( + MapBounds( + southWest = LocLatLng(bounds.southWest.latitude, bounds.southWest.longitude), + northWest = LocLatLng(bounds.northWest.latitude, bounds.northWest.longitude), + northEast = LocLatLng(bounds.northEast.latitude, bounds.northEast.longitude), + southEast = LocLatLng(bounds.southEast.latitude, bounds.southEast.longitude), + ), + ), + ) + } + + // 영구 거부 + if (!LocationPermissionManager.shouldShowLocationRationale(activity)) { + viewModel.store.onIntent(MapIntent.ShowLocationPermissionDialog) + } + } + } + + LaunchedEffect(Unit) { + snapshotFlow { cameraPositionState.isMoving to cameraPositionState.cameraUpdateReason } + .collect { (isMoving, reason) -> + if (isMoving && reason == CameraUpdateReason.GESTURE) { + viewModel.store.onIntent(MapIntent.GestureOnMap) + } + } + } + + LifecycleResumeEffect(Unit) { + if (isNavigatedToSettings) { + if (LocationPermissionManager.isGrantedLocationPermission(context)) { + locationTrackingMode = LocationTrackingMode.NoFollow + viewModel.store.onIntent(MapIntent.GrantedLocationPermission) + } + isNavigatedToSettings = false + } + onPauseOrDispose { } + } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is MapEffect.OpenDirectionBottomSheet -> { + viewModel.store.onIntent(MapIntent.OpenDirectionBottomSheet) + } + + is MapEffect.MoveCameraToPosition -> { + scope.launch { + cameraPositionState.animate( + update = CameraUpdate.scrollAndZoomTo( + LatLng(sideEffect.locLatLng.latitude, sideEffect.locLatLng.longitude), + MapConst.DEFAULT_ZOOM_LEVEL, + ), + animation = CameraAnimation.Easing, + durationMs = MapConst.DEFAULT_CAMERA_ANIMATION_DURATIONS_MS, + ) + + if (sideEffect.isRequiredLoadPhotoBooths) { + locationTrackingMode = LocationTrackingMode.NoFollow + cameraPositionState.contentBounds?.let { bounds -> + viewModel.store.onIntent( + MapIntent.LoadPhotoBoothsByBounds( + MapBounds( + southWest = LocLatLng(bounds.southWest.latitude, bounds.southWest.longitude), + northWest = LocLatLng(bounds.northWest.latitude, bounds.northWest.longitude), + northEast = LocLatLng(bounds.northEast.latitude, bounds.northEast.longitude), + southEast = LocLatLng(bounds.southEast.latitude, bounds.southEast.longitude), + ), + ), + ) + } + } + } + } + + is MapEffect.LaunchDirectionApp -> { + DirectionHelper.navigateToUrl( + context = context, + app = sideEffect.app, + startLatitude = sideEffect.startLocLatLng.latitude, + startLongitude = sideEffect.startLocLatLng.longitude, + endLatitude = sideEffect.endLocLatLng.latitude, + endLongitude = sideEffect.endLocLatLng.longitude, + ) + } + + is MapEffect.NavigateToAppSettings -> { + isNavigatedToSettings = true + navigateToAppSettings(context) + } + + is MapEffect.LaunchLocationPermission -> { + locationPermissionLauncher.launch(LocationPermissionManager.LOCATION_PERMISSIONS) + } + + is MapEffect.ShowToastMessage -> nekiToast.showToast(sideEffect.message) + } + } + + MapScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + locationTrackingMode = locationTrackingMode, + cameraPositionState = cameraPositionState, + ) +} + +@OptIn(ExperimentalNaverMapApi::class) +@Composable +fun MapScreen( + uiState: MapState = MapState(), + onIntent: (MapIntent) -> Unit = {}, + locationTrackingMode: LocationTrackingMode = LocationTrackingMode.None, + cameraPositionState: CameraPositionState = rememberCameraPositionState { + position = CameraPosition(LatLng(MapConst.DEFAULT_LATITUDE, MapConst.DEFAULT_LONGITUDE), MapConst.DEFAULT_ZOOM_LEVEL) + }, +) { + val mapProperties = remember(locationTrackingMode) { + MapProperties( + locationTrackingMode = locationTrackingMode, + ) + } + val mapUiSettings = remember { + MapUiSettings( + isLocationButtonEnabled = false, + isZoomControlEnabled = false, + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + NaverMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + locationSource = rememberFusedLocationSource(), + properties = mapProperties, + uiSettings = mapUiSettings, + onMapLoaded = { onIntent(MapIntent.RequestLocationPermission) }, + onLocationChange = { location -> + onIntent(MapIntent.UpdateCurrentLocation(LocLatLng(location.latitude, location.longitude))) + }, + ) { + uiState.mapMarkers.filter { it.isCheckedBrand }.forEach { photoBooth -> + PhotoBoothMarker( + photoBooth = photoBooth, + cachedBitmap = uiState.brandImageCache[photoBooth.imageUrl], + onClick = { + onIntent(MapIntent.ClickPhotoBoothMarker(LocLatLng(photoBooth.latitude, photoBooth.longitude))) + }, + ) + } + } + + AnchoredDraggablePanel( + brands = uiState.brands, + nearbyPhotoBooths = uiState.nearbyPhotoBooths, + dragLevel = uiState.dragLevel, + onDragLevelChanged = { onIntent(MapIntent.ChangeDragLevel(it)) }, + isCurrentLocation = uiState.isCameraOnCurrentLocation, + onClickInfoIcon = { onIntent(MapIntent.ClickInfoIcon) }, + onClickCurrentLocation = { onIntent(MapIntent.ClickCurrentLocationIcon) }, + onClickBrand = { onIntent(MapIntent.ClickVerticalBrand(it)) }, + onClickNearPhotoBooth = { onIntent(MapIntent.ClickNearPhotoBooth(it)) }, + ) + + if ((uiState.dragLevel == DragLevel.FIRST || uiState.dragLevel == DragLevel.SECOND) && uiState.isVisibleRefreshButton) { + MapRefreshChip( + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 12.dp), + onClick = { + cameraPositionState.contentBounds?.let { bounds -> + onIntent( + MapIntent.ClickRefreshButton( + MapBounds( + southWest = LocLatLng(bounds.southWest.latitude, bounds.southWest.longitude), + northWest = LocLatLng(bounds.northWest.latitude, bounds.northWest.longitude), + northEast = LocLatLng(bounds.northEast.latitude, bounds.northEast.longitude), + southEast = LocLatLng(bounds.southEast.latitude, bounds.southEast.longitude), + ), + ), + ) + } + }, + ) + } + + if (uiState.dragLevel == DragLevel.THIRD) { + ToMapChip( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp), + onClick = { onIntent(MapIntent.ClickToMapChip) }, + ) + } else if (uiState.dragLevel == DragLevel.INVISIBLE) { + uiState.mapMarkers.find { it.isFocused }?.let { focusedPhotoBooth -> + PhotoBoothDetailContent( + photoBooth = focusedPhotoBooth, + modifier = Modifier.align(Alignment.BottomCenter), + isCurrentLocation = uiState.isCameraOnCurrentLocation, + onClickCurrentLocation = { onIntent(MapIntent.ClickCurrentLocationIcon) }, + onClickCloseCard = { onIntent(MapIntent.ClickClosePhotoBoothCard) }, + onClickCard = { + onIntent(MapIntent.ClickPhotoBoothCard(LocLatLng(focusedPhotoBooth.latitude, focusedPhotoBooth.longitude))) + }, + onClickDirection = { onIntent(MapIntent.ClickDirectionIcon) }, + ) + } + } + } + + if (uiState.isShowInfoDialog) { + WarningDialog( + content = "가까운 네컷 사진 브랜드는\n1km 기준으로 표시돼요.", + onDismissRequest = { onIntent(MapIntent.ClickCloseInfoIcon) }, + ) + } + + if (uiState.isShowDirectionBottomSheet) { + DirectionBottomSheet( + onDismissRequest = { onIntent(MapIntent.CloseDirectionBottomSheet) }, + onClickDirectionItem = { onIntent(MapIntent.ClickDirectionItem(it)) }, + ) + } + + if (uiState.isShowLocationPermissionDialog) { + SingleButtonAlertDialog( + title = "위치 권한", + content = "설정에서 위치 권한을 허용해주세요.", + buttonText = "확인", + onDismissRequest = { onIntent(MapIntent.DismissLocationPermissionDialog) }, + onClick = { onIntent(MapIntent.ConfirmLocationPermissionDialog) }, + ) + } + + if (uiState.isLoading) { + LoadingDialog( + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt new file mode 100644 index 000000000..f4785bd88 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt @@ -0,0 +1,393 @@ +package com.neki.android.feature.map.impl + +import android.content.Context +import androidx.compose.ui.graphics.asImageBitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap +import com.neki.android.core.common.permission.LocationPermissionManager +import com.neki.android.core.dataapi.repository.MapRepository +import com.neki.android.core.model.Brand +import com.neki.android.core.model.PhotoBooth +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.map.impl.const.DirectionApp +import com.neki.android.feature.map.impl.const.MapConst +import com.neki.android.feature.map.impl.util.LocationHelper +import com.neki.android.feature.map.impl.util.calculateDistance +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MapViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val mapRepository: MapRepository, +) : ViewModel() { + val store: MviIntentStore = mviIntentStore( + initialState = MapState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(MapIntent.EnterMapScreen) }, + ) + + private fun onIntent( + intent: MapIntent, + state: MapState, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + when (intent) { + MapIntent.EnterMapScreen -> fetchInitialData(reduce) + is MapIntent.GrantedLocationPermission -> getCurrentLocation(reduce, postSideEffect) + is MapIntent.LoadPhotoBoothsByBounds -> loadPhotoBoothsByPolygon(intent.mapBounds, state, reduce, postSideEffect) + MapIntent.ClickCurrentLocationIcon -> { + if (LocationPermissionManager.isGrantedLocationPermission(context)) { + moveCurrentLocation(state, reduce, postSideEffect) + } else { + postSideEffect(MapEffect.LaunchLocationPermission) + } + } + + MapIntent.GestureOnMap -> reduce { copy(isCameraOnCurrentLocation = false, isVisibleRefreshButton = true) } + is MapIntent.ClickRefreshButton -> { + reduce { copy(isVisibleRefreshButton = false) } + loadPhotoBoothsByPolygon(intent.mapBounds, state, reduce, postSideEffect) + } + is MapIntent.UpdateCurrentLocation -> handleUpdateCurrentLocation(state, intent.locLatLng, reduce) + MapIntent.ClickInfoIcon -> reduce { copy(isShowInfoDialog = true) } + MapIntent.ClickCloseInfoIcon -> reduce { copy(isShowInfoDialog = false) } + MapIntent.ClickToMapChip -> reduce { copy(dragLevel = DragLevel.FIRST) } + is MapIntent.ClickVerticalBrand -> handleClickBrand(intent.brand, reduce) + is MapIntent.ClickNearPhotoBooth -> handleClickNearPhotoBooth(intent.photoBooth, reduce, postSideEffect) + MapIntent.ClickClosePhotoBoothCard -> reduce { + copy( + dragLevel = DragLevel.SECOND, + mapMarkers = mapMarkers.map { it.copy(isFocused = false) }.toImmutableList(), + ) + } + + MapIntent.OpenDirectionBottomSheet -> reduce { copy(isShowDirectionBottomSheet = true) } + MapIntent.CloseDirectionBottomSheet -> reduce { copy(isShowDirectionBottomSheet = false) } + is MapIntent.ClickDirectionItem -> handleClickDirectionItem(state, intent.app, reduce, postSideEffect) + is MapIntent.ChangeDragLevel -> reduce { copy(dragLevel = intent.dragLevel) } + is MapIntent.ClickPhotoBoothMarker -> handleClickPhotoBoothMarker(intent.locLatLng, reduce, postSideEffect) + is MapIntent.ClickPhotoBoothCard -> handleClickPhotoBoothCard(intent.locLatLng, postSideEffect) + MapIntent.ClickDirectionIcon -> { + if (LocationPermissionManager.isGrantedLocationPermission(context)) { + postSideEffect(MapEffect.OpenDirectionBottomSheet) + } else { + postSideEffect(MapEffect.LaunchLocationPermission) + } + } + + MapIntent.RequestLocationPermission -> postSideEffect(MapEffect.LaunchLocationPermission) + MapIntent.ShowLocationPermissionDialog -> reduce { copy(isShowLocationPermissionDialog = true) } + MapIntent.DismissLocationPermissionDialog -> reduce { copy(isShowLocationPermissionDialog = false) } + MapIntent.ConfirmLocationPermissionDialog -> { + reduce { copy(isShowLocationPermissionDialog = false) } + postSideEffect(MapEffect.NavigateToAppSettings) + } + } + } + + private fun getCurrentLocation( + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + viewModelScope.launch { + LocationHelper.getCurrentLocation(context) + .onSuccess { location -> + reduce { copy(isCameraOnCurrentLocation = true, isVisibleRefreshButton = false) } + postSideEffect(MapEffect.MoveCameraToPosition(location, isRequiredLoadPhotoBooths = true)) + } + .onFailure { + // 위치 조회 실패 시 강남역으로 카메라 이동 + postSideEffect( + MapEffect.MoveCameraToPosition( + LocLatLng(MapConst.DEFAULT_LATITUDE, MapConst.DEFAULT_LONGITUDE), + isRequiredLoadPhotoBooths = true, + ), + ) + } + } + } + + private fun handleUpdateCurrentLocation( + state: MapState, + locLatLng: LocLatLng, + reduce: (MapState.() -> MapState) -> Unit, + ) { + reduce { copy(currentLocLatLng = locLatLng) } + + /** 위치가 이동하더라도 주변 네컷 사진 브랜드는 변경하지 않기 때문에 최초에만 요청 **/ + if (state.currentLocLatLng == null) { + loadNearbyPhotoBooths( + longitude = locLatLng.longitude, + latitude = locLatLng.latitude, + brandIds = state.brands.filter { it.isChecked }.map { it.id }, + reduce = reduce, + ) + } + } + + private fun moveCurrentLocation( + state: MapState, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + if (state.dragLevel == DragLevel.INVISIBLE) { + reduce { + copy( + dragLevel = DragLevel.FIRST, + mapMarkers = mapMarkers.map { it.copy(isFocused = false) }.toImmutableList(), + ) + } + } + + if (state.currentLocLatLng != null) { + reduce { copy(isCameraOnCurrentLocation = true, isVisibleRefreshButton = false) } + postSideEffect( + MapEffect.MoveCameraToPosition( + LocLatLng(state.currentLocLatLng.latitude, state.currentLocLatLng.longitude), + isRequiredLoadPhotoBooths = true, + ), + ) + } + } + + private fun handleClickBrand( + clickedBrand: Brand, + reduce: (MapState.() -> MapState) -> Unit, + ) { + reduce { + val updatedBrands = brands.map { brand -> + if (brand == clickedBrand) { + brand.copy(isChecked = !brand.isChecked) + } else { + brand + } + } + val checkedBrandNames = updatedBrands.filter { it.isChecked }.map { it.name } + + copy( + brands = updatedBrands.toImmutableList(), + mapMarkers = mapMarkers.map { photoBooth -> + photoBooth.copy( + isCheckedBrand = checkedBrandNames.isEmpty() || photoBooth.brandName in checkedBrandNames, + ) + }.toImmutableList(), + nearbyPhotoBooths = nearbyPhotoBooths.map { photoBooth -> + photoBooth.copy( + isCheckedBrand = checkedBrandNames.isEmpty() || photoBooth.brandName in checkedBrandNames, + ) + }.toImmutableList(), + ) + } + } + + private fun handleClickNearPhotoBooth( + photoBooth: PhotoBooth, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + reduce { + val isAlreadyInMarkers = mapMarkers.any { + it.latitude == photoBooth.latitude && it.longitude == photoBooth.longitude + } + val updatedMarkers = if (isAlreadyInMarkers) { + mapMarkers.map { marker -> + marker.copy(isFocused = marker.id == photoBooth.id) + } + } else { + mapMarkers.map { it.copy(isFocused = false) } + photoBooth.copy(isFocused = true) + } + copy( + dragLevel = DragLevel.INVISIBLE, + mapMarkers = updatedMarkers.toImmutableList(), + ) + } + postSideEffect(MapEffect.MoveCameraToPosition(LocLatLng(photoBooth.latitude, photoBooth.longitude))) + } + + private fun handleClickDirectionItem( + state: MapState, + app: DirectionApp, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + reduce { copy(isShowDirectionBottomSheet = false) } + if (state.currentLocLatLng == null) { + postSideEffect(MapEffect.ShowToastMessage("현재 위치를 가져올 수 없습니다.")) + return + } + state.mapMarkers.find { it.isFocused }?.let { focusedPhotoBooth -> + postSideEffect( + MapEffect.LaunchDirectionApp( + app = app, + startLocLatLng = state.currentLocLatLng, + endLocLatLng = LocLatLng(focusedPhotoBooth.latitude, focusedPhotoBooth.longitude), + ), + ) + } + } + + private fun handleClickPhotoBoothMarker( + locLatLng: LocLatLng, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + reduce { + val updatedMarkers = mapMarkers.map { marker -> + val isClicked = marker.latitude == locLatLng.latitude && marker.longitude == locLatLng.longitude + if (isClicked) { + val distance = currentLocLatLng?.let { location -> + calculateDistance(location.latitude, location.longitude, marker.latitude, marker.longitude) + } ?: 0 + marker.copy(isFocused = true, distance = distance) + } else { + marker.copy(isFocused = false) + } + } + copy( + dragLevel = DragLevel.INVISIBLE, + mapMarkers = updatedMarkers.toImmutableList(), + ) + } + postSideEffect(MapEffect.MoveCameraToPosition(locLatLng)) + } + + private fun handleClickPhotoBoothCard( + locLatLng: LocLatLng, + postSideEffect: (MapEffect) -> Unit, + ) { + postSideEffect(MapEffect.MoveCameraToPosition(locLatLng)) + } + + private fun fetchInitialData(reduce: (MapState.() -> MapState) -> Unit) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + mapRepository.getBrands() + .onSuccess { loadedBrands -> + reduce { copy(isLoading = false, brands = loadedBrands.toImmutableList()) } + cacheBrandImages(loadedBrands, reduce) + } + .onFailure { + reduce { copy(isLoading = false) } + } + } + } + + private fun cacheBrandImages( + brands: List, + reduce: (MapState.() -> MapState) -> Unit, + ) { + viewModelScope.launch { + val imageLoader = ImageLoader(context) + + val cache = brands + .filter { it.imageUrl.isNotEmpty() } + .map { brand -> + async(Dispatchers.IO) { + val request = ImageRequest.Builder(context) + .data(brand.imageUrl) + .allowHardware(false) + .build() + val result = imageLoader.execute(request) + if (result is SuccessResult) { + brand.imageUrl to result.image.toBitmap().asImageBitmap() + } else { + null + } + } + } + .awaitAll() + .filterNotNull() + .toMap() + + reduce { copy(brandImageCache = cache.toImmutableMap()) } + } + } + + private fun loadNearbyPhotoBooths( + longitude: Double?, + latitude: Double?, + radiusInMeters: Int = 1000, + brandIds: List = emptyList(), + reduce: (MapState.() -> MapState) -> Unit, + ) { + viewModelScope.launch { + mapRepository.getPhotoBoothsByPoint( + longitude = longitude, + latitude = latitude, + radiusInMeters = radiusInMeters, + brandIds = brandIds, + ).onSuccess { photoBooths -> + reduce { + copy( + nearbyPhotoBooths = photoBooths.map { photoBooth -> + photoBooth.copy( + imageUrl = brands.find { + it.name == photoBooth.brandName + }?.imageUrl.orEmpty(), + ) + }.toImmutableList(), + ) + } + } + } + } + + private fun loadPhotoBoothsByPolygon( + mapBounds: MapBounds, + state: MapState, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + val checkedBrandIds = state.brands.filter { it.isChecked }.map { it.id } + + // 좌상단 -> 우상단 -> 우하단 -> 좌하단 -> 좌상단 (닫힌 다각형) + val coordinates = listOf( + mapBounds.northWest.longitude to mapBounds.northWest.latitude, + mapBounds.northEast.longitude to mapBounds.northEast.latitude, + mapBounds.southEast.longitude to mapBounds.southEast.latitude, + mapBounds.southWest.longitude to mapBounds.southWest.latitude, + mapBounds.northWest.longitude to mapBounds.northWest.latitude, + ) + + viewModelScope.launch { + reduce { copy(isLoading = true) } + + mapRepository.getPhotoBoothsByPolygon( + coordinates = coordinates, + brandIds = checkedBrandIds, + ).onSuccess { photoBooths -> + reduce { + copy( + isLoading = false, + mapMarkers = photoBooths.map { photoBooth -> + photoBooth.copy( + imageUrl = brands.find { + it.name == photoBooth.brandName + }?.imageUrl.orEmpty(), + ) + }.toImmutableList(), + ) + } + }.onFailure { + reduce { copy(isLoading = false) } + postSideEffect(MapEffect.ShowToastMessage("포토부스 조회에 실패했습니다.")) + } + } + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt new file mode 100644 index 000000000..d15654b73 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt @@ -0,0 +1,312 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.animation.core.tween +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.bottomsheet.BottomSheetDragHandle +import com.neki.android.core.designsystem.modifier.dropdownShadow +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Brand +import com.neki.android.core.model.PhotoBooth +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.map.impl.DragLevel +import com.neki.android.feature.map.impl.const.MapConst +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlin.math.roundToInt + +@Composable +internal fun AnchoredDraggablePanel( + brands: ImmutableList = persistentListOf(), + nearbyPhotoBooths: ImmutableList = persistentListOf(), + dragLevel: DragLevel = DragLevel.FIRST, + isCurrentLocation: Boolean = false, + onDragLevelChanged: (DragLevel) -> Unit = {}, + onClickCurrentLocation: () -> Unit = {}, + onClickInfoIcon: () -> Unit = {}, + onClickBrand: (Brand) -> Unit = {}, + onClickNearPhotoBooth: (PhotoBooth) -> Unit = {}, +) { + val density = LocalDensity.current + val configuration = LocalConfiguration.current + + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } + val navigationBarHeightPx = with(density) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding().toPx() + } + val bottomPanelHeightPx = with(density) { + (MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT + + MapConst.PANEL_DRAG_LOCATION_HEIGHT + + MapConst.PANEL_DRAG_LEVEL_FIRST_HEIGHT).dp.toPx() + navigationBarHeightPx + } + val centerPanelHeightPx = with(density) { + (MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT + + MapConst.PANEL_DRAG_LOCATION_HEIGHT + + MapConst.PANEL_DRAG_LEVEL_SECOND_HEIGHT).dp.toPx() + navigationBarHeightPx + } + var isProgrammaticTransition by remember { mutableStateOf(false) } + + val state = remember { + val anchors = DraggableAnchors { + DragLevel.FIRST at screenHeightPx - bottomPanelHeightPx + DragLevel.SECOND at screenHeightPx - centerPanelHeightPx + DragLevel.THIRD at screenHeightPx * 0.05f + DragLevel.INVISIBLE at screenHeightPx + } + + AnchoredDraggableState( + initialValue = DragLevel.FIRST, + anchors = anchors, + positionalThreshold = { distance -> distance * 0.5f }, + velocityThreshold = { with(density) { 100.dp.toPx() } }, + snapAnimationSpec = tween(), + decayAnimationSpec = splineBasedDecay(density), + confirmValueChange = { newValue -> + newValue != DragLevel.INVISIBLE || isProgrammaticTransition + }, + ) + } + + LaunchedEffect(state.settledValue) { + if (state.settledValue != dragLevel) { + onDragLevelChanged(state.settledValue) + } + } + + LaunchedEffect(dragLevel) { + isProgrammaticTransition = true + state.animateTo(dragLevel) + isProgrammaticTransition = false + } + + Box( + modifier = Modifier + .fillMaxSize() + .offset { + val currentOffset = state.requireOffset() + val shouldConstrainOffset = state.currentValue == DragLevel.FIRST && !isProgrammaticTransition + val constrainedOffset = if (shouldConstrainOffset) { + currentOffset.coerceAtMost(state.anchors.positionOf(DragLevel.FIRST)) + } else { + currentOffset + } + IntOffset(0, constrainedOffset.roundToInt()) + } + .anchoredDraggable( + state = state, + orientation = Orientation.Vertical, + ), + ) { + Column { + CurrentLocationButton( + modifier = Modifier + .padding(start = 20.dp, bottom = 12.dp) + .alpha(alpha = if (dragLevel == DragLevel.THIRD) 0f else 1f), + isActiveCurrentLocation = isCurrentLocation, + onClick = onClickCurrentLocation, + ) + AnchoredPanelContent( + brands = brands, + nearbyPhotoBooths = nearbyPhotoBooths, + onClickInfoIcon = onClickInfoIcon, + onClickBrand = onClickBrand, + onClickPhotoBooth = onClickNearPhotoBooth, + ) + } + } +} + +@Composable +internal fun AnchoredPanelContent( + brands: ImmutableList = persistentListOf(), + nearbyPhotoBooths: ImmutableList = persistentListOf(), + onClickInfoIcon: () -> Unit = {}, + onClickBrand: (Brand) -> Unit = {}, + onClickPhotoBooth: (PhotoBooth) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .dropdownShadow(shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) + .background( + color = Color.White, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + BottomSheetDragHandle() + Text( + modifier = Modifier + .padding(vertical = 10.dp, horizontal = 20.dp), + text = "네컷 사진 브랜드", + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title18Bold, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(start = 20.dp), + ) { + items(brands) { brand -> + VerticalBrandItem( + brand = brand, + onClickItem = { onClickBrand(brand) }, + ) + } + } + } + VerticalSpacer(24.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = NekiTheme.colorScheme.primary400)) { + append("가까운") + } + append(" 네컷 사진 브랜드 📌") + }, + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title18Bold, + ) + Icon( + modifier = Modifier + .size(24.dp) + .noRippleClickableSingle(onClick = onClickInfoIcon) + .padding(2.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_info_gray_stroke), + contentDescription = null, + ) + } + VerticalSpacer(8.dp) + val filteredNearbyPhotoBooths = nearbyPhotoBooths.filter { it.isCheckedBrand } + if (filteredNearbyPhotoBooths.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 175.5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "1km 이내에 가까운 네컷 사진관이 없어요!", + color = NekiTheme.colorScheme.gray500, + style = NekiTheme.typography.body16Medium, + ) + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT.dp), + ) { + items(filteredNearbyPhotoBooths) { photoBooth -> + HorizontalBrandItem( + photoBooth = photoBooth, + onClickItem = { onClickPhotoBooth(photoBooth) }, + ) + } + } + } + } +} + +@ComponentPreview +@Composable +private fun AnchoredPanelContentPreview() { + NekiTheme { + AnchoredPanelContent( + brands = persistentListOf( + Brand(isChecked = false, name = "인생네컷", imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/LIFEFOURCUTS_LOGO_v1.png"), + Brand(isChecked = false, name = "포토그레이", imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/PHOTOGRAY_LOGO_v1.png"), + Brand(isChecked = false, name = "포토이즘", imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/PHOTOISM_LOGO_v1.png"), + Brand(isChecked = false, name = "하루필름", imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/HARUFILM_LOGO_v1.png"), + Brand( + isChecked = false, + name = "플랜비\n스튜디오", + imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/PLANB_STUDIO_LOGO_v1.png", + ), + Brand( + isChecked = false, + name = "포토시그니처", + imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/PHOTOSIGNATURE_LOGO_v1.png", + ), + ), + nearbyPhotoBooths = persistentListOf( + PhotoBooth( + brandName = "인생네컷", + branchName = "가산디지털점", + distance = 25, + latitude = 37.5272, + longitude = 126.8864, + ), + PhotoBooth( + brandName = "포토그레이", + branchName = "가산역점", + distance = 38, + latitude = 37.5268, + longitude = 126.8867, + ), + PhotoBooth( + brandName = "포토이즘", + branchName = "마리오점", + distance = 52, + latitude = 37.5274, + longitude = 126.8858, + ), + ), + ) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CloseButton.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CloseButton.kt new file mode 100644 index 000000000..c7105c66d --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CloseButton.kt @@ -0,0 +1,52 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun CloseButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .buttonShadow() + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.white, + ) + .clickable(onClick = onClick) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + contentDescription = null, + tint = NekiTheme.colorScheme.gray800, + ) + } +} + +@ComponentPreview +@Composable +private fun CloseButtonPreview() { + NekiTheme { + CloseButton() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CurrentLocationButton.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CurrentLocationButton.kt new file mode 100644 index 000000000..b294f3043 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CurrentLocationButton.kt @@ -0,0 +1,64 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun CurrentLocationButton( + isActiveCurrentLocation: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .buttonShadow() + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.white, + ) + .clickable(onClick = onClick) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource( + if (isActiveCurrentLocation) R.drawable.icon_current_location_on else R.drawable.icon_current_location_off, + ), + contentDescription = null, + tint = Color.Unspecified, + ) + } +} + +@ComponentPreview +@Composable +private fun CurrentLocationButtonOffPreview() { + NekiTheme { + CurrentLocationButton(isActiveCurrentLocation = false) + } +} + +@ComponentPreview +@Composable +private fun CurrentLocationButtonOnPreview() { + NekiTheme { + CurrentLocationButton(isActiveCurrentLocation = true) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/DirectionBottomSheet.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/DirectionBottomSheet.kt new file mode 100644 index 000000000..a4bb2c413 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/DirectionBottomSheet.kt @@ -0,0 +1,81 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.bottomsheet.BottomSheetDragHandle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.map.impl.const.DirectionApp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DirectionBottomSheet( + onDismissRequest: () -> Unit = {}, + onClickDirectionItem: (DirectionApp) -> Unit = {}, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + containerColor = NekiTheme.colorScheme.white, + dragHandle = { BottomSheetDragHandle() }, + ) { + DirectionBottomSheetContent( + onClickItem = onClickDirectionItem, + ) + } +} + +@Composable +private fun DirectionBottomSheetContent( + onClickItem: (DirectionApp) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = "길찾기 앱 선택", + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title20SemiBold, + ) + VerticalSpacer(10.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 18.5.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + DirectionItem( + app = DirectionApp.GOOGLE_MAP, + onClickItem = { onClickItem(DirectionApp.GOOGLE_MAP) }, + ) + DirectionItem( + app = DirectionApp.NAVER_MAP, + onClickItem = { onClickItem(DirectionApp.NAVER_MAP) }, + ) + DirectionItem( + app = DirectionApp.KAKAO_MAP, + onClickItem = { onClickItem(DirectionApp.KAKAO_MAP) }, + ) + } + VerticalSpacer(34.dp) + } +} + +@ComponentPreview +@Composable +private fun DirectionBottomSheetContentPreview() { + NekiTheme { + DirectionBottomSheetContent() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/DirectionItem.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/DirectionItem.kt new file mode 100644 index 000000000..1ae9d3f68 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/DirectionItem.kt @@ -0,0 +1,85 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.map.impl.const.DirectionApp + +@Composable +internal fun DirectionItem( + app: DirectionApp, + modifier: Modifier = Modifier, + onClickItem: (DirectionApp) -> Unit = {}, +) { + Column( + modifier = modifier + .noRippleClickable( + onClick = { onClickItem(app) }, + ) + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(56.dp) + .border( + width = 1.dp, + color = NekiTheme.colorScheme.gray50, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + model = app.appLogoRes, + contentDescription = null, + ) + } + VerticalSpacer(8.dp) + Text( + text = app.appName, + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.body14Medium, + textAlign = TextAlign.Center, + ) + } +} + +@ComponentPreview +@Composable +private fun GoogleMapDirectionItemPreview() { + NekiTheme { + DirectionItem(app = DirectionApp.GOOGLE_MAP) + } +} + +@ComponentPreview +@Composable +private fun NaverMapDirectionItemPreview() { + NekiTheme { + DirectionItem(app = DirectionApp.NAVER_MAP) + } +} + +@ComponentPreview +@Composable +private fun KakaoMapDirectionItemPreview() { + NekiTheme { + DirectionItem(app = DirectionApp.KAKAO_MAP) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/HorizontalBrandItem.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/HorizontalBrandItem.kt new file mode 100644 index 000000000..b34aaeb4a --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/HorizontalBrandItem.kt @@ -0,0 +1,85 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.layout.Column +import com.neki.android.feature.map.impl.util.formatDistance +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.painterResource +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.PhotoBooth +import com.neki.android.core.ui.compose.HorizontalSpacer +import com.neki.android.core.ui.compose.VerticalSpacer + +@Composable +internal fun HorizontalBrandItem( + photoBooth: PhotoBooth, + modifier: Modifier = Modifier, + onClickItem: () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickableSingle(onClick = onClickItem), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .size(64.dp), + model = photoBooth.imageUrl, + placeholder = painterResource(R.drawable.icon_life_four_cut), + error = painterResource(R.drawable.icon_life_four_cut), + contentDescription = null, + ) + HorizontalSpacer(16.dp) + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = photoBooth.brandName, + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title18Bold, + ) + HorizontalSpacer(4.dp) + Text( + text = photoBooth.branchName, + color = NekiTheme.colorScheme.gray600, + style = NekiTheme.typography.caption12Medium, + ) + } + VerticalSpacer(4.dp) + Text( + text = photoBooth.distance.formatDistance(), + color = NekiTheme.colorScheme.gray400, + style = NekiTheme.typography.caption12Medium, + ) + } + } +} + +@ComponentPreview +@Composable +private fun HorizontalBrandItemPreview() { + NekiTheme { + HorizontalBrandItem( + photoBooth = PhotoBooth( + brandName = "인생네컷", + branchName = "사당역점", + distance = 320, + ), + ) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/MapRefreshChip.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/MapRefreshChip.kt new file mode 100644 index 000000000..e86aff9c5 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/MapRefreshChip.kt @@ -0,0 +1,68 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun MapRefreshChip( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .buttonShadow() + .clip(CircleShape) + .clickableSingle(onClick = onClick) + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.white, + ) + .border( + width = 1.dp, + shape = CircleShape, + color = NekiTheme.colorScheme.gray100, + ) + .padding(horizontal = 13.09.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_rotation), + contentDescription = null, + tint = NekiTheme.colorScheme.primary400, + ) + Text( + text = "현 위치에서 탐색", + style = NekiTheme.typography.body14SemiBold, + color = NekiTheme.colorScheme.gray800, + ) + } +} + +@ComponentPreview +@Composable +private fun MapRefreshChipPreview() { + NekiTheme { + MapRefreshChip() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothDetailContent.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothDetailContent.kt new file mode 100644 index 000000000..db6693b54 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothDetailContent.kt @@ -0,0 +1,169 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.cardShadow +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.PhotoBooth +import com.neki.android.core.ui.compose.HorizontalSpacer +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.map.impl.util.formatDistance + +@Composable +internal fun PhotoBoothDetailContent( + photoBooth: PhotoBooth, + modifier: Modifier = Modifier, + isCurrentLocation: Boolean = false, + onClickCurrentLocation: () -> Unit = {}, + onClickCloseCard: () -> Unit = {}, + onClickCard: () -> Unit = {}, + onClickDirection: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + CurrentLocationButton( + isActiveCurrentLocation = isCurrentLocation, + onClick = onClickCurrentLocation, + ) + CloseButton(onClick = onClickCloseCard) + } + VerticalSpacer(12.dp) + PhotoBoothDetailCard( + photoBooth = photoBooth, + onClick = onClickCard, + onClickDirection = onClickDirection, + ) + } +} + +@Composable +private fun PhotoBoothDetailCard( + photoBooth: PhotoBooth, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onClickDirection: () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .cardShadow(shape = RoundedCornerShape(20.dp)) + .background( + shape = RoundedCornerShape(20.dp), + color = NekiTheme.colorScheme.white, + ) + .noRippleClickableSingle(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .size(64.dp), + model = photoBooth.imageUrl, + placeholder = painterResource(R.drawable.icon_life_four_cut), + error = painterResource(R.drawable.icon_life_four_cut), + contentDescription = null, + ) + HorizontalSpacer(16.dp) + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = photoBooth.brandName, + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title18Bold, + ) + HorizontalSpacer(4.dp) + Text( + text = photoBooth.branchName, + color = NekiTheme.colorScheme.gray600, + style = NekiTheme.typography.caption12Medium, + ) + } + VerticalSpacer(4.dp) + Text( + text = photoBooth.distance.formatDistance(), + color = NekiTheme.colorScheme.gray400, + style = NekiTheme.typography.caption12Medium, + ) + } + HorizontalSpacer(1f) + Box( + modifier = Modifier + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.gray900, + ) + .noRippleClickableSingle(onClick = onClickDirection) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_find_direction), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } +} + +@ComponentPreview +@Composable +private fun PhotoBoothDetailCardPreview() { + NekiTheme { + PhotoBoothDetailCard( + photoBooth = PhotoBooth( + brandName = "인생네컷", + branchName = "사당역점", + distance = 300, + ), + ) + } +} + +@ComponentPreview +@Composable +private fun PhotoBoothDetailContentPreview() { + NekiTheme { + PhotoBoothDetailContent( + photoBooth = PhotoBooth( + brandName = "인생네컷", + branchName = "사당역점", + distance = 300, + ), + ) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothMarker.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothMarker.kt new file mode 100644 index 000000000..1211d6193 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothMarker.kt @@ -0,0 +1,217 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.compose.ExperimentalNaverMapApi +import com.naver.maps.map.compose.MarkerComposable +import com.naver.maps.map.compose.rememberUpdatedMarkerState +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.pinShadow +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.PhotoBooth +import com.neki.android.feature.map.impl.const.MapConst.FOCUSED_MARKER_BACKGROUND_RADIUS +import com.neki.android.feature.map.impl.const.MapConst.FOCUSED_MARKER_IMAGE_RADIUS +import com.neki.android.feature.map.impl.const.MapConst.FOCUSED_MARKER_IMAGE_SIZE +import com.neki.android.feature.map.impl.const.MapConst.FOCUSED_MARKER_PADDING +import com.neki.android.feature.map.impl.const.MapConst.MARKER_BACKGROUND_RADIUS +import com.neki.android.feature.map.impl.const.MapConst.MARKER_IMAGE_RADIUS +import com.neki.android.feature.map.impl.const.MapConst.MARKER_IMAGE_SIZE +import com.neki.android.feature.map.impl.const.MapConst.MARKER_PADDING +import com.neki.android.feature.map.impl.const.MapConst.MARKER_TRIANGLE_HEIGHT +import com.neki.android.feature.map.impl.const.MapConst.MARKER_TRIANGLE_WIDTH + +@OptIn(ExperimentalNaverMapApi::class) +@Composable +internal fun PhotoBoothMarker( + photoBooth: PhotoBooth, + cachedBitmap: ImageBitmap? = null, + onClick: () -> Unit = {}, +) { + MarkerComposable( + keys = arrayOf("${photoBooth.id}", "${photoBooth.isFocused}", "$cachedBitmap"), + state = rememberUpdatedMarkerState( + position = LatLng(photoBooth.latitude, photoBooth.longitude), + ), + captionText = "${photoBooth.brandName}\n${photoBooth.branchName}", + captionTextSize = 12.sp, + captionColor = NekiTheme.colorScheme.gray900, + onClick = { + onClick() + true + }, + ) { + PhotoBoothMarkerContent( + cachedBitmap = cachedBitmap, + isFocused = photoBooth.isFocused, + ) + } +} + +@Composable +internal fun PhotoBoothMarkerContent( + modifier: Modifier = Modifier, + cachedBitmap: ImageBitmap? = null, + isFocused: Boolean = false, +) { + val caretColor = if (isFocused) NekiTheme.colorScheme.gray900 else NekiTheme.colorScheme.white + val bodySize = if (isFocused) { + FOCUSED_MARKER_IMAGE_SIZE + FOCUSED_MARKER_PADDING * 2 + } else MARKER_IMAGE_SIZE + MARKER_PADDING * 2 + val totalHeight = bodySize + MARKER_TRIANGLE_HEIGHT - 1 + + Box( + modifier = modifier + .padding(1.dp) + .size( + width = bodySize.dp, + height = totalHeight.dp, + ), + contentAlignment = Alignment.TopCenter, + ) { + // 브랜드 이미지 그림자 + Box( + modifier = Modifier + .size(bodySize.dp) + .pinShadow( + shape = RoundedCornerShape( + if (isFocused) FOCUSED_MARKER_BACKGROUND_RADIUS.dp else MARKER_BACKGROUND_RADIUS.dp, + ), + ), + ) + + // 꼬리 + Canvas( + modifier = Modifier + .align(Alignment.BottomCenter) + .size( + width = MARKER_TRIANGLE_WIDTH.dp, + height = MARKER_TRIANGLE_HEIGHT.dp, + ), + ) { + val shadowColor = Color.Black.copy(alpha = 0.4f) + val offsetY = 1.dp.toPx() + val blurRadius = 2.5.dp.toPx() + + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width / 2, size.height) + close() + } + + // 삼각형 그림자 + drawIntoCanvas { canvas -> + val shadowPaint = Paint().apply { + asFrameworkPaint().apply { + color = android.graphics.Color.TRANSPARENT + setShadowLayer( + blurRadius, + 0f, + offsetY, + shadowColor.toArgb(), + ) + } + } + canvas.drawPath(path, shadowPaint) + } + + drawPath(path, caretColor) + } + + // 브랜드 이미지 + Box( + modifier = Modifier + .then( + if (isFocused) { + Modifier + .background( + brush = Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.gray600, + NekiTheme.colorScheme.gray900, + ), + ), + shape = RoundedCornerShape(FOCUSED_MARKER_BACKGROUND_RADIUS.dp), + ) + .padding(FOCUSED_MARKER_PADDING.dp) + } else { + Modifier + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(MARKER_BACKGROUND_RADIUS.dp), + ) + .padding(MARKER_PADDING.dp) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (cachedBitmap != null) { + Image( + modifier = Modifier + .size(if (isFocused) FOCUSED_MARKER_IMAGE_SIZE.dp else MARKER_IMAGE_SIZE.dp) + .clip( + RoundedCornerShape( + if (isFocused) FOCUSED_MARKER_IMAGE_RADIUS.dp else MARKER_IMAGE_RADIUS.dp, + ), + ), + bitmap = cachedBitmap, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Image( + modifier = Modifier + .size(if (isFocused) FOCUSED_MARKER_IMAGE_SIZE.dp else MARKER_IMAGE_SIZE.dp) + .clip( + RoundedCornerShape( + if (isFocused) FOCUSED_MARKER_IMAGE_RADIUS.dp else MARKER_IMAGE_RADIUS.dp, + ), + ), + painter = painterResource(R.drawable.icon_info_gray_stroke), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun PhotoBoothMarkerPreview() { + NekiTheme { + PhotoBoothMarkerContent() + } +} + +@ComponentPreview +@Composable +private fun PhotoBoothMarkerSelectedPreview() { + NekiTheme { + PhotoBoothMarkerContent( + isFocused = true, + ) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt new file mode 100644 index 000000000..0914429f9 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt @@ -0,0 +1,61 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun ToMapChip( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .buttonShadow() + .clip(CircleShape) + .clickableSingle(onClick = onClick) + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.gray800, + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_map_pin), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + text = "지도로", + style = NekiTheme.typography.title18SemiBold, + color = NekiTheme.colorScheme.white, + ) + } +} + +@ComponentPreview +@Composable +private fun ToMapChipPreview() { + NekiTheme { + ToMapChip() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/VerticalBrandItem.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/VerticalBrandItem.kt new file mode 100644 index 000000000..189642d12 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/VerticalBrandItem.kt @@ -0,0 +1,113 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Brand +import com.neki.android.core.ui.compose.VerticalSpacer + +@Composable +internal fun VerticalBrandItem( + brand: Brand, + modifier: Modifier = Modifier, + onClickItem: () -> Unit = {}, +) { + Column( + modifier = modifier + .widthIn(max = 66.dp) + .noRippleClickable { onClickItem() }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .padding(horizontal = 5.dp) + .size(56.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + model = brand.imageUrl, + placeholder = painterResource(R.drawable.icon_life_four_cut), + error = painterResource(R.drawable.icon_life_four_cut), + contentDescription = null, + ) + + if (brand.isChecked) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + shape = CircleShape, + color = Color(0xFFFF5647).copy(alpha = 0.5f), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_check), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } + } + VerticalSpacer(8.dp) + Text( + text = brand.name, + color = if (brand.isChecked) NekiTheme.colorScheme.primary400 else NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.body14Medium, + textAlign = TextAlign.Center, + ) + } +} + +@ComponentPreview +@Composable +private fun CheckedVerticalBrandItemPreview() { + NekiTheme { + VerticalBrandItem( + brand = Brand( + isChecked = true, + name = "인생네컷", + ), + ) + } +} + +@ComponentPreview +@Composable +private fun NotCheckedVerticalBrandItemPreview() { + NekiTheme { + VerticalBrandItem( + brand = Brand( + isChecked = false, + name = "인생네컷", + imageUrl = "https://dev-yapp.suitestudy.com:4641/file/image/logo/LIFEFOURCUTS_LOGO_v1.png", + ), + ) + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/DirectionApp.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/DirectionApp.kt new file mode 100644 index 000000000..e5ce3c624 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/DirectionApp.kt @@ -0,0 +1,26 @@ +package com.neki.android.feature.map.impl.const + +import androidx.annotation.DrawableRes +import com.neki.android.core.designsystem.R + +enum class DirectionApp( + @DrawableRes val appLogoRes: Int, + val appName: String, + val packageName: String, +) { + GOOGLE_MAP( + appLogoRes = R.drawable.image_google_map, + appName = "구글맵", + packageName = "com.google.android.apps.maps", + ), + NAVER_MAP( + appLogoRes = R.drawable.image_naver_map, + appName = "네이버 지도", + packageName = "com.nhn.android.nmap", + ), + KAKAO_MAP( + appLogoRes = R.drawable.image_kakao_map, + appName = "카카오맵", + packageName = "net.daum.android.map", + ), +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt new file mode 100644 index 000000000..72bf578fd --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt @@ -0,0 +1,29 @@ +package com.neki.android.feature.map.impl.const + +internal object MapConst { + // 기본 위치 (강남역) + internal const val DEFAULT_LATITUDE = 37.498095 + internal const val DEFAULT_LONGITUDE = 127.027610 + + // 기본 줌 레벨 + internal const val DEFAULT_ZOOM_LEVEL = 17.0 + + internal const val DEFAULT_CAMERA_ANIMATION_DURATIONS_MS = 800 + + internal const val BOTTOM_NAVIGATION_BAR_HEIGHT = 52 + internal const val PANEL_DRAG_LOCATION_HEIGHT = 48 + internal const val PANEL_DRAG_LEVEL_FIRST_HEIGHT = 96 + internal const val PANEL_DRAG_LEVEL_SECOND_HEIGHT = 218 + + // 마커 + internal const val MARKER_BACKGROUND_RADIUS = 20 + internal const val MARKER_IMAGE_SIZE = 50 + internal const val MARKER_IMAGE_RADIUS = 18 + internal const val MARKER_PADDING = 2 + internal const val FOCUSED_MARKER_BACKGROUND_RADIUS = 26 + internal const val FOCUSED_MARKER_IMAGE_SIZE = 60 + internal const val FOCUSED_MARKER_IMAGE_RADIUS = 21 + internal const val FOCUSED_MARKER_PADDING = 6 + internal const val MARKER_TRIANGLE_WIDTH = 12 + internal const val MARKER_TRIANGLE_HEIGHT = 10 +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/navigation/MapEntryProvider.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/navigation/MapEntryProvider.kt new file mode 100644 index 000000000..75ba0cf3f --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/navigation/MapEntryProvider.kt @@ -0,0 +1,30 @@ +package com.neki.android.feature.map.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.feature.map.api.MapNavKey +import com.neki.android.feature.map.impl.MapRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object MapEntryProviderModule { + + @IntoSet + @Provides + fun provideMapEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + mapEntry(navigator) + } +} + +private fun EntryProviderScope.mapEntry(navigator: Navigator) { + entry { + MapRoute() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/DirectionHelper.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/DirectionHelper.kt new file mode 100644 index 000000000..33c50300c --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/DirectionHelper.kt @@ -0,0 +1,52 @@ +package com.neki.android.feature.map.impl.util + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.net.toUri +import com.neki.android.feature.map.impl.const.DirectionApp + +internal object DirectionHelper { + suspend fun navigateToUrl( + context: Context, + app: DirectionApp, + startLatitude: Double, + startLongitude: Double, + endLatitude: Double, + endLongitude: Double, + ) { + val url = when (app) { + DirectionApp.GOOGLE_MAP -> "google.navigation:q=$endLatitude,$endLongitude&mode=w" + DirectionApp.NAVER_MAP -> { + val startName = context.getPlaceName(startLatitude, startLongitude, "출발지") + val destName = context.getPlaceName(endLatitude, endLongitude, "도착지") + "nmap://route/walk?slat=$startLatitude&slng=$startLongitude&sname=$startName&dlat=$endLatitude&dlng=$endLongitude&dname=$destName" + } + DirectionApp.KAKAO_MAP -> "kakaomap://route?sp=$startLatitude,$startLongitude&ep=$endLatitude,$endLongitude&by=FOOT" + } + launchAppOrStore(context, url, app.packageName) + } + + private fun launchAppOrStore( + context: Context, + url: String, + packageName: String, + ) { + val intent = if (isInstalled(context.packageManager, packageName)) { + Intent(Intent.ACTION_VIEW, url.toUri()).apply { + setPackage(packageName) + } + } else { + Intent( + Intent.ACTION_VIEW, + "market://details?id=$packageName".toUri(), + ) + } + context.startActivity(intent) + } + + private fun isInstalled( + packageManager: PackageManager, + packageName: String, + ): Boolean = runCatching { packageManager.getPackageInfo(packageName, 0) }.isSuccess +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/Extension.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/Extension.kt new file mode 100644 index 000000000..f37453da4 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/Extension.kt @@ -0,0 +1,72 @@ +package com.neki.android.feature.map.impl.util + +import android.content.Context +import android.location.Geocoder +import android.os.Build +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.Locale +import kotlin.coroutines.resume + +/** 위경도에 대한 지명 조회 **/ +internal suspend fun Context.getPlaceName( + latitude: Double, + longitude: Double, + fallback: String, +): String = suspendCancellableCoroutine { coroutine -> + val geocoder = Geocoder(this, Locale.KOREAN) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + geocoder.getFromLocation(latitude, longitude, 1) { addresses -> + val address = addresses.firstOrNull() + ?.getAddressLine(0) + ?.removePrefix("대한민국 ") + ?: fallback + coroutine.resume(address) + } + } else { + try { + @Suppress("DEPRECATION") + val address = geocoder.getFromLocation(latitude, longitude, 1) + ?.firstOrNull() + ?.getAddressLine(0) + ?.removePrefix("대한민국 ") + ?: fallback + coroutine.resume(address) + } catch (e: Exception) { + e.printStackTrace() + coroutine.resume(fallback) + } + } +} + +/** 두 위경도 좌표 사이 거리 계산 (Haversine formula) **/ +internal fun calculateDistance( + startLatitude: Double, + startLongitude: Double, + endLatitude: Double, + endLongitude: Double, +): Int { + val earthRadius = 6371000.0 // meters + val dLat = Math.toRadians(endLatitude - startLatitude) + val dLng = Math.toRadians(endLongitude - startLongitude) + val a = kotlin.math.sin(dLat / 2) * kotlin.math.sin(dLat / 2) + + kotlin.math.cos(Math.toRadians(startLatitude)) * kotlin.math.cos(Math.toRadians(endLatitude)) * + kotlin.math.sin(dLng / 2) * kotlin.math.sin(dLng / 2) + val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a)) + return (earthRadius * c).toInt() +} + +/** 거리(m)를 단위 포함 문자열로 변환 **/ +internal fun Int.formatDistance(): String { + return if (this < 1000) { + "${this}m" + } else { + val km = this / 1000.0 + val roundedKm = kotlin.math.round(km * 10) / 10.0 + if (roundedKm == roundedKm.toLong().toDouble()) { + "${roundedKm.toLong()}km" + } else { + "${roundedKm}km" + } + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/LocationHelper.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/LocationHelper.kt new file mode 100644 index 000000000..0f971c5dd --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/util/LocationHelper.kt @@ -0,0 +1,31 @@ +package com.neki.android.feature.map.impl.util + +import android.annotation.SuppressLint +import android.content.Context +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import com.neki.android.feature.map.impl.LocLatLng +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +object LocationHelper { + @SuppressLint("MissingPermission") + suspend fun getCurrentLocation(context: Context): Result = + suspendCancellableCoroutine { coroutine -> + val cancellationTokenSource = CancellationTokenSource() + + coroutine.invokeOnCancellation { cancellationTokenSource.cancel() } + + LocationServices.getFusedLocationProviderClient(context) + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cancellationTokenSource.token) + .addOnSuccessListener { location -> + location?.let { + coroutine.resume(Result.success(LocLatLng(it.latitude, it.longitude))) + } ?: coroutine.resume(Result.failure(Exception("Location is null"))) + } + .addOnFailureListener { e -> + coroutine.resume(Result.failure(e)) + } + } +} diff --git a/feature/mypage/api/build.gradle.kts b/feature/mypage/api/build.gradle.kts new file mode 100644 index 000000000..6bd747526 --- /dev/null +++ b/feature/mypage/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.mypage.api" +} diff --git a/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt b/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt new file mode 100644 index 000000000..5f3e7cd40 --- /dev/null +++ b/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt @@ -0,0 +1,36 @@ +package com.neki.android.feature.mypage.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface MyPageNavKey : NavKey { + + @Serializable + data object MyPage : MyPageNavKey + + @Serializable + data object Permission : MyPageNavKey + + @Serializable + data object Profile : MyPageNavKey + + @Serializable + data object EditProfile : MyPageNavKey +} + +fun Navigator.navigateToMyPage() { + navigate(MyPageNavKey.MyPage) +} + +fun Navigator.navigateToPermission() { + navigate(MyPageNavKey.Permission) +} + +fun Navigator.navigateToProfile() { + navigate(MyPageNavKey.Profile) +} + +fun Navigator.navigateToEditProfile() { + navigate(MyPageNavKey.EditProfile) +} diff --git a/feature/mypage/impl/build.gradle.kts b/feature/mypage/impl/build.gradle.kts new file mode 100644 index 000000000..67243f0d0 --- /dev/null +++ b/feature/mypage/impl/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.mypage.impl" +} + +dependencies { + implementation(projects.feature.mypage.api) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.oss.licenses) +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt new file mode 100644 index 000000000..206845483 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt @@ -0,0 +1,91 @@ +package com.neki.android.feature.mypage.impl.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun PermissionSectionItem( + title: String, + subTitle: String, + isGranted: Boolean, + onClick: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickableSingle(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title18Medium, + ) + Text( + text = subTitle, + color = NekiTheme.colorScheme.gray400, + style = NekiTheme.typography.body14Medium, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isGranted) "허용됨" else "허용안됨", + color = NekiTheme.colorScheme.gray500, + style = NekiTheme.typography.body14Medium, + ) + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_right), + tint = NekiTheme.colorScheme.gray300, + contentDescription = null, + ) + } + } +} + +@ComponentPreview +@Composable +private fun PermissionSectionItemCameraPreview() { + NekiTheme { + PermissionSectionItem( + title = "카메라", + subTitle = "QR 촬영에 필요해요.", + isGranted = true, + ) + } +} + +@ComponentPreview +@Composable +private fun PermissionSectionItemLocationPreview() { + NekiTheme { + PermissionSectionItem( + title = "위치", + subTitle = "QR 촬영에 필요해요.", + isGranted = false, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt new file mode 100644 index 000000000..f52432d3f --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt @@ -0,0 +1,106 @@ +package com.neki.android.feature.mypage.impl.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun SectionItem( + text: String, + onClick: () -> Unit = {}, + trailingContent: @Composable (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickableSingle(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = text, + color = NekiTheme.colorScheme.gray900, + style = NekiTheme.typography.title18Medium, + ) + + trailingContent?.invoke() + } +} + +@Composable +fun SectionArrowItem( + text: String, + onClick: () -> Unit = {}, +) { + SectionItem( + text = text, + onClick = onClick, + trailingContent = { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_right), + tint = NekiTheme.colorScheme.gray300, + contentDescription = null, + ) + }, + ) +} + +@Composable +fun SectionVersionItem( + appVersion: String, +) { + SectionItem( + text = "앱 버전 정보", + trailingContent = { + Text( + text = "v$appVersion", + color = NekiTheme.colorScheme.gray500, + style = NekiTheme.typography.body14Medium, + ) + }, + ) +} + +@ComponentPreview +@Composable +private fun SectionItemPreview() { + NekiTheme { + SectionItem(text = "로그아웃") + } +} + +@ComponentPreview +@Composable +private fun SectionArrowItemPreview() { + NekiTheme { + SectionArrowItem( + text = "기기 권한", + ) + } +} + +@ComponentPreview +@Composable +private fun SectionVersionItemPreview() { + NekiTheme { + SectionVersionItem( + appVersion = "1.3.1", + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt new file mode 100644 index 000000000..39ec42321 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt @@ -0,0 +1,42 @@ +package com.neki.android.feature.mypage.impl.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun SectionTitleText( + text: String, + paddingTop: Dp = 12.dp, + paddingBottom: Dp = 4.dp, + paddingStart: Dp = 20.dp, + paddingEnd: Dp = 20.dp, +) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = paddingStart, + end = paddingEnd, + top = paddingTop, + bottom = paddingBottom, + ), + text = text, + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray400, + ) +} + +@ComponentPreview +@Composable +private fun SectionTitleTextPreview() { + SectionTitleText( + text = "권한 설정", + ) +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt new file mode 100644 index 000000000..43896cd48 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt @@ -0,0 +1,72 @@ +package com.neki.android.feature.mypage.impl.main + +import com.neki.android.core.model.UserInfo +import com.neki.android.feature.mypage.impl.main.const.ServiceInfoMenu +import com.neki.android.feature.mypage.impl.permission.const.NekiPermission +import com.neki.android.feature.mypage.impl.profile.model.EditProfileImageType + +data class MyPageState( + val isLoading: Boolean = false, + val userInfo: UserInfo = UserInfo(), + val appVersion: String = "", + val profileImageState: EditProfileImageType = EditProfileImageType.OriginalImageUrl(""), + val isShowLogoutDialog: Boolean = false, + val isShowWithdrawDialog: Boolean = false, + val isShowImageChooseDialog: Boolean = false, + // Permission + val isGrantedCamera: Boolean = false, + val isGrantedLocation: Boolean = false, + val isGrantedNotification: Boolean = false, + val isShowPermissionDialog: Boolean = false, + val clickedPermission: NekiPermission? = null, +) + +sealed interface MyPageIntent { + // Init + data object EnterMypageScreen : MyPageIntent + data class SetAppVersion(val appVersion: String) : MyPageIntent + + // MyPage Main + data object ClickNotificationIcon : MyPageIntent + data object ClickProfileCard : MyPageIntent + data object ClickPermission : MyPageIntent + data class ClickServiceInfoMenu(val menu: ServiceInfoMenu) : MyPageIntent + data object ClickOpenSourceLicense : MyPageIntent + + // Profile + data object ClickBackIcon : MyPageIntent + data object ClickEditIcon : MyPageIntent + data object ClickCameraIcon : MyPageIntent + data object DismissImageChooseDialog : MyPageIntent + data class SelectProfileImage(val image: EditProfileImageType) : MyPageIntent + data class ClickEditComplete(val nickname: String) : MyPageIntent + data object ClickLogout : MyPageIntent + data object DismissLogoutDialog : MyPageIntent + data object ConfirmLogout : MyPageIntent + data object ClickWithdraw : MyPageIntent + data object DismissWithdrawDialog : MyPageIntent + data object ConfirmWithdraw : MyPageIntent + + // Permission + data class ClickPermissionItem(val permission: NekiPermission) : MyPageIntent + data object DismissPermissionDialog : MyPageIntent + data object ConfirmPermissionDialog : MyPageIntent + data class UpdatePermissionState(val permission: NekiPermission, val isGranted: Boolean) : MyPageIntent + data class ShowPermissionDeniedDialog(val permission: NekiPermission) : MyPageIntent +} + +sealed interface MyPageEffect { + data object NavigateToNotification : MyPageEffect + data object NavigateToProfile : MyPageEffect + data object NavigateToEditProfile : MyPageEffect + data object NavigateToPermission : MyPageEffect + data class OpenExternalLink(val url: String) : MyPageEffect + data object NavigateBack : MyPageEffect + data object NavigateToLogin : MyPageEffect + data class MoveAppSettings(val permission: NekiPermission) : MyPageEffect + data class RequestPermission(val permission: NekiPermission) : MyPageEffect + data object OpenOssLicenses : MyPageEffect + data object LogoutWithKakao : MyPageEffect + data object UnlinkWithKakao : MyPageEffect + data class PreloadImageAndNavigateBack(val url: String) : MyPageEffect +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt new file mode 100644 index 000000000..f3a7c097b --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt @@ -0,0 +1,128 @@ +package com.neki.android.feature.mypage.impl.main + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.feature.mypage.impl.component.SectionArrowItem +import com.neki.android.feature.mypage.impl.component.SectionTitleText +import com.neki.android.feature.mypage.impl.component.SectionVersionItem +import com.neki.android.feature.mypage.impl.main.component.MainTopBar +import com.neki.android.feature.mypage.impl.main.component.ProfileCard +import com.neki.android.feature.mypage.impl.main.const.ServiceInfoMenu + +@Composable +internal fun MyPageRoute( + viewModel: MyPageViewModel = hiltViewModel(), + navigateToPermission: () -> Unit, + navigateToProfile: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + val appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" + viewModel.store.onIntent(MyPageIntent.SetAppVersion(appVersion)) + } + + viewModel.store.sideEffects.collectWithLifecycle { effect -> + when (effect) { + MyPageEffect.NavigateBack -> {} + MyPageEffect.NavigateToLogin -> {} + MyPageEffect.NavigateToNotification -> {} + MyPageEffect.NavigateToProfile -> navigateToProfile() + MyPageEffect.NavigateToPermission -> navigateToPermission() + is MyPageEffect.OpenExternalLink -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url))) + MyPageEffect.OpenOssLicenses -> { + OssLicensesMenuActivity.setActivityTitle("오픈소스 라이선스 목록") + context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) + } + + else -> {} + } + } + + MyPageScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun MyPageScreen( + uiState: MyPageState, + onIntent: (MyPageIntent) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + MainTopBar( + onClickIcon = { onIntent(MyPageIntent.ClickNotificationIcon) }, + ) + ProfileCard( + profileImageUrl = uiState.userInfo.profileImageUrl, + name = uiState.userInfo.nickname, + loginType = uiState.userInfo.loginType, + onClickCard = { onIntent(MyPageIntent.ClickProfileCard) }, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(11.dp) + .background(color = NekiTheme.colorScheme.gray25), + ) + Column { + SectionTitleText(text = "권한") + SectionArrowItem( + text = "권한 설정하기", + onClick = { onIntent(MyPageIntent.ClickPermission) }, + ) + } + Column { + SectionTitleText(text = "서비스 정보 및 지원") + ServiceInfoMenu.entries.forEach { menu -> + SectionArrowItem( + text = menu.text, + onClick = { onIntent(MyPageIntent.ClickServiceInfoMenu(menu)) }, + ) + } + SectionArrowItem( + text = "오픈소스 라이선스", + onClick = { onIntent(MyPageIntent.ClickOpenSourceLicense) }, + ) + SectionVersionItem(uiState.appVersion) + } + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@ComponentPreview +@Composable +private fun MyPageScreenPreview() { + NekiTheme { + MyPageScreen( + uiState = MyPageState(appVersion = "1.1.0"), + onIntent = {}, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt new file mode 100644 index 000000000..6c935dd3d --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt @@ -0,0 +1,201 @@ +package com.neki.android.feature.mypage.impl.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.AuthRepository +import com.neki.android.core.dataapi.repository.TokenRepository +import com.neki.android.core.dataapi.repository.UserRepository +import com.neki.android.core.domain.usecase.UploadProfileImageUseCase +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.mypage.impl.permission.const.NekiPermission +import com.neki.android.feature.mypage.impl.profile.model.EditProfileImageType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class MyPageViewModel @Inject constructor( + private val uploadProfileImageUseCase: UploadProfileImageUseCase, + private val userRepository: UserRepository, + private val authRepository: AuthRepository, + private val tokenRepository: TokenRepository, +) : ViewModel() { + + val store: MviIntentStore = + mviIntentStore( + initialState = MyPageState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(MyPageIntent.EnterMypageScreen) }, + ) + + private fun onIntent( + intent: MyPageIntent, + state: MyPageState, + reduce: (MyPageState.() -> MyPageState) -> Unit, + postSideEffect: (MyPageEffect) -> Unit, + ) { + when (intent) { + MyPageIntent.EnterMypageScreen -> fetchInitialData(reduce) + is MyPageIntent.SetAppVersion -> reduce { copy(appVersion = intent.appVersion) } + // MyPage Main + MyPageIntent.ClickNotificationIcon -> postSideEffect(MyPageEffect.NavigateToNotification) + MyPageIntent.ClickProfileCard -> postSideEffect(MyPageEffect.NavigateToProfile) + MyPageIntent.ClickPermission -> postSideEffect(MyPageEffect.NavigateToPermission) + is MyPageIntent.ClickServiceInfoMenu -> postSideEffect(MyPageEffect.OpenExternalLink(intent.menu.url)) + MyPageIntent.ClickOpenSourceLicense -> postSideEffect(MyPageEffect.OpenOssLicenses) + + // Profile + MyPageIntent.ClickBackIcon -> { + reduce { copy(profileImageState = EditProfileImageType.OriginalImageUrl(state.userInfo.profileImageUrl)) } + postSideEffect(MyPageEffect.NavigateBack) + } + + MyPageIntent.ClickEditIcon -> postSideEffect(MyPageEffect.NavigateToEditProfile) + MyPageIntent.ClickCameraIcon -> reduce { copy(isShowImageChooseDialog = true) } + MyPageIntent.DismissImageChooseDialog -> reduce { copy(isShowImageChooseDialog = false) } + is MyPageIntent.SelectProfileImage -> reduce { copy(profileImageState = intent.image, isShowImageChooseDialog = false) } + is MyPageIntent.ClickEditComplete -> { + val isNicknameChanged = state.userInfo.nickname != intent.nickname + val isProfileImageChanged = state.profileImageState !is EditProfileImageType.OriginalImageUrl + updateProfile(state, intent.nickname, isNicknameChanged, isProfileImageChanged, reduce, postSideEffect) + } + + MyPageIntent.ClickLogout -> reduce { copy(isShowLogoutDialog = true) } + MyPageIntent.DismissLogoutDialog -> reduce { copy(isShowLogoutDialog = false) } + MyPageIntent.ConfirmLogout -> { + reduce { copy(isShowLogoutDialog = false) } + logout(postSideEffect) + } + + MyPageIntent.ClickWithdraw -> reduce { copy(isShowWithdrawDialog = true) } + MyPageIntent.DismissWithdrawDialog -> reduce { copy(isShowWithdrawDialog = false) } + MyPageIntent.ConfirmWithdraw -> { + reduce { copy(isShowWithdrawDialog = false) } + withdrawAccount(reduce, postSideEffect) + } + + // Permission + is MyPageIntent.ClickPermissionItem -> { + postSideEffect(MyPageEffect.RequestPermission(intent.permission)) + } + + MyPageIntent.DismissPermissionDialog -> { + reduce { copy(isShowPermissionDialog = false, clickedPermission = null) } + } + + MyPageIntent.ConfirmPermissionDialog -> { + val permission = state.clickedPermission + reduce { copy(isShowPermissionDialog = false, clickedPermission = null) } + if (permission != null) { + postSideEffect(MyPageEffect.MoveAppSettings(permission)) + } + } + + is MyPageIntent.UpdatePermissionState -> { + when (intent.permission) { + NekiPermission.CAMERA -> reduce { copy(isGrantedCamera = intent.isGranted) } + NekiPermission.LOCATION -> reduce { copy(isGrantedLocation = intent.isGranted) } + NekiPermission.NOTIFICATION -> reduce { copy(isGrantedNotification = intent.isGranted) } + } + } + + is MyPageIntent.ShowPermissionDeniedDialog -> { + reduce { copy(isShowPermissionDialog = true, clickedPermission = intent.permission) } + } + } + } + + private fun fetchInitialData(reduce: (MyPageState.() -> MyPageState) -> Unit) = viewModelScope.launch { + reduce { copy(isLoading = true) } + userRepository.getUserInfo() + .onSuccess { user -> + reduce { + copy( + isLoading = false, + userInfo = user, + ) + } + } + .onFailure { + Timber.e(it) + reduce { copy(isLoading = false) } + } + } + + private fun updateProfile( + state: MyPageState, + nickname: String, + isNicknameChanged: Boolean, + isProfileImageChanged: Boolean, + reduce: (MyPageState.() -> MyPageState) -> Unit, + postSideEffect: (MyPageEffect) -> Unit, + ) = viewModelScope.launch { + if (!isNicknameChanged && !isProfileImageChanged) { + postSideEffect(MyPageEffect.NavigateBack) + return@launch + } + + reduce { copy(isLoading = true) } + + buildList { + if (isNicknameChanged) add(async { userRepository.updateUserInfo(nickname = nickname) }) + if (isProfileImageChanged) { + val uri = (state.profileImageState as? EditProfileImageType.ImageUri)?.uri + add(async { uploadProfileImageUseCase(uri = uri) }) + } + }.awaitAll() + + userRepository.getUserInfo() + .onSuccess { user -> + reduce { + copy( + isLoading = false, + profileImageState = EditProfileImageType.OriginalImageUrl(user.profileImageUrl), + userInfo = user, + ) + } + + if (isProfileImageChanged) { + postSideEffect(MyPageEffect.PreloadImageAndNavigateBack(user.profileImageUrl)) + } else { + postSideEffect(MyPageEffect.NavigateBack) + } + } + .onFailure { + Timber.e(it) + reduce { + copy( + isLoading = false, + profileImageState = EditProfileImageType.OriginalImageUrl(state.userInfo.profileImageUrl), + ) + } + postSideEffect(MyPageEffect.NavigateBack) + } + } + + private fun logout(postSideEffect: (MyPageEffect) -> Unit) = viewModelScope.launch { + tokenRepository.clearTokens() + postSideEffect(MyPageEffect.LogoutWithKakao) + } + + private fun withdrawAccount( + reduce: (MyPageState.() -> MyPageState) -> Unit, + postSideEffect: (MyPageEffect) -> Unit, + ) = viewModelScope.launch { + reduce { copy(isLoading = true) } + authRepository.withdrawAccount() + .onSuccess { + tokenRepository.clearTokens() + reduce { copy(isLoading = false) } + postSideEffect(MyPageEffect.UnlinkWithKakao) + } + .onFailure { + Timber.e(it) + reduce { copy(isLoading = false) } + } + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/MainTopBar.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/MainTopBar.kt new file mode 100644 index 000000000..4630830ef --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/MainTopBar.kt @@ -0,0 +1,57 @@ +package com.neki.android.feature.mypage.impl.main.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.topbar.NekiLeftTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun MainTopBar( + modifier: Modifier = Modifier, + onClickIcon: () -> Unit = {}, +) { + NekiLeftTitleTopBar( + modifier = modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp), + title = { + Text( + text = "마이페이지", + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + }, + actions = { + NekiIconButton( + onClick = onClickIcon, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_bell), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + ) +} + +@ComponentPreview +@Composable +private fun MainTopBarPreview() { + NekiTheme { + MainTopBar() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt new file mode 100644 index 000000000..bc7090b35 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt @@ -0,0 +1,84 @@ +package com.neki.android.feature.mypage.impl.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.HorizontalSpacer + +@Composable +internal fun ProfileCard( + profileImageUrl: String = "", + name: String, + loginType: String, + onClickCard: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = onClickCard) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .size(78.dp) + .clip(CircleShape), + model = profileImageUrl.ifEmpty { R.drawable.image_empty_profile_image }, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + HorizontalSpacer(16.dp) + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = name, + style = NekiTheme.typography.title18SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + Text( + text = "$loginType 로그인", + style = NekiTheme.typography.body14Regular, + color = NekiTheme.colorScheme.gray600, + ) + } + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_right), + contentDescription = null, + tint = NekiTheme.colorScheme.gray400, + ) + } +} + +@ComponentPreview +@Composable +private fun ProfileCardPreview() { + NekiTheme { + ProfileCard( + name = "오종석", + loginType = "KAKAO", + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt new file mode 100644 index 000000000..4ab6d0de5 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt @@ -0,0 +1,19 @@ +package com.neki.android.feature.mypage.impl.main.const + +enum class ServiceInfoMenu( + val text: String, + val url: String, +) { + INQUIRY( + text = "Neki에 문의하기", + url = "https://tally.so/r/obGpRX", + ), + TERMS_OF_SERVICE( + text = "이용약관", + url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807c8684ce3e2d4b8aca?source=copy_link", + ), + PRIVACY_POLICY( + text = "개인정보 처리방침", + url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807cb850f78145db6dd3?pvs=74", + ), +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt new file mode 100644 index 000000000..915afaeb2 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt @@ -0,0 +1,75 @@ +package com.neki.android.feature.mypage.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.HiltSharedViewModelStoreNavEntryDecorator +import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.root.RootNavKey +import com.neki.android.feature.mypage.api.MyPageNavKey +import com.neki.android.feature.mypage.api.navigateToEditProfile +import com.neki.android.feature.mypage.api.navigateToPermission +import com.neki.android.feature.mypage.api.navigateToProfile +import com.neki.android.feature.mypage.impl.main.MyPageRoute +import com.neki.android.feature.mypage.impl.permission.PermissionRoute +import com.neki.android.feature.mypage.impl.profile.EditProfileRoute +import com.neki.android.feature.mypage.impl.profile.ProfileSettingRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object MyPageEntryProviderModule { + + @IntoSet + @Provides + fun provideMyPageEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + myPageEntry(navigator) + } +} + +private fun EntryProviderScope.myPageEntry(navigator: Navigator) { + entry( + clazzContentKey = { key -> key.toString() }, + ) { + MyPageRoute( + navigateToPermission = navigator::navigateToPermission, + navigateToProfile = navigator::navigateToProfile, + ) + } + + entry( + metadata = HiltSharedViewModelStoreNavEntryDecorator.parent( + MyPageNavKey.MyPage.toString(), + ), + ) { + PermissionRoute( + navigateBack = navigator::goBack, + ) + } + + entry( + metadata = HiltSharedViewModelStoreNavEntryDecorator.parent( + MyPageNavKey.MyPage.toString(), + ), + ) { + ProfileSettingRoute( + navigateBack = navigator::goBack, + navigateToEditProfile = navigator::navigateToEditProfile, + navigateToLogin = { navigator.navigateRoot(RootNavKey.Login) }, + ) + } + + entry( + metadata = HiltSharedViewModelStoreNavEntryDecorator.parent( + MyPageNavKey.MyPage.toString(), + ), + ) { + EditProfileRoute( + navigateBack = navigator::goBack, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt new file mode 100644 index 000000000..19cacdd49 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt @@ -0,0 +1,167 @@ +package com.neki.android.feature.mypage.impl.permission + +import android.os.Build +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.common.permission.CameraPermissionManager +import com.neki.android.core.common.permission.LocationPermissionManager +import com.neki.android.core.common.permission.NotificationPermissionManager +import com.neki.android.core.common.permission.navigateToAppSettings +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog +import com.neki.android.core.designsystem.topbar.BackTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.feature.mypage.impl.component.PermissionSectionItem +import com.neki.android.feature.mypage.impl.component.SectionTitleText +import com.neki.android.feature.mypage.impl.main.MyPageEffect +import com.neki.android.feature.mypage.impl.main.MyPageIntent +import com.neki.android.feature.mypage.impl.main.MyPageState +import com.neki.android.feature.mypage.impl.main.MyPageViewModel +import com.neki.android.feature.mypage.impl.permission.const.NekiPermission + +@Composable +internal fun PermissionRoute( + viewModel: MyPageViewModel = hiltViewModel(), + navigateBack: () -> Unit = {}, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val activity = LocalActivity.current!! + val context = LocalContext.current + + fun checkPermissions() { + NekiPermission.entries.forEach { permission -> + viewModel.store.onIntent( + MyPageIntent.UpdatePermissionState( + permission = permission, + isGranted = when (permission) { + NekiPermission.CAMERA -> CameraPermissionManager.isGrantedCameraPermission(context) + NekiPermission.LOCATION -> LocationPermissionManager.isGrantedLocationPermission(context) + NekiPermission.NOTIFICATION -> NotificationPermissionManager.isGrantedNotificationPermission(context) + }, + ), + ) + } + } + + LaunchedEffect(Unit) { + checkPermissions() + } + + LifecycleResumeEffect(Unit) { + checkPermissions() + onPauseOrDispose {} + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val permission = when { + permissions.containsKey(CameraPermissionManager.CAMERA_PERMISSION) -> NekiPermission.CAMERA + LocationPermissionManager.LOCATION_PERMISSIONS.any { permissions.containsKey(it) } -> NekiPermission.LOCATION + permissions.containsKey(NotificationPermissionManager.NOTIFICATION_PERMISSION) -> NekiPermission.NOTIFICATION + else -> return@rememberLauncherForActivityResult + } + val isGranted = permissions.values.any { it } + viewModel.store.onIntent(MyPageIntent.UpdatePermissionState(permission, isGranted)) + + if (!isGranted) { + val shouldShowRationale = when (permission) { + NekiPermission.CAMERA -> CameraPermissionManager.shouldShowCameraRationale(activity) + NekiPermission.LOCATION -> LocationPermissionManager.shouldShowLocationRationale(activity) + NekiPermission.NOTIFICATION -> NotificationPermissionManager.shouldShowNotificationRationale(activity) + } + if (!shouldShowRationale) { + viewModel.store.onIntent(MyPageIntent.ShowPermissionDeniedDialog(permission)) + } + } + } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + MyPageEffect.NavigateBack -> navigateBack() + is MyPageEffect.RequestPermission -> { + when (sideEffect.permission) { + NekiPermission.CAMERA -> permissionLauncher.launch(arrayOf(CameraPermissionManager.CAMERA_PERMISSION)) + NekiPermission.LOCATION -> permissionLauncher.launch(LocationPermissionManager.LOCATION_PERMISSIONS) + NekiPermission.NOTIFICATION -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionLauncher.launch(arrayOf(NotificationPermissionManager.NOTIFICATION_PERMISSION)) + } else { + if (!NotificationPermissionManager.isGrantedNotificationPermission(context)) { + viewModel.store.onIntent(MyPageIntent.ShowPermissionDeniedDialog(NekiPermission.NOTIFICATION)) + } + } + } + } + } + + is MyPageEffect.MoveAppSettings -> navigateToAppSettings(context) + else -> {} + } + } + + PermissionScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun PermissionScreen( + uiState: MyPageState = MyPageState(), + onIntent: (MyPageIntent) -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + BackTitleTopBar( + title = "기기 권한", + onBack = { onIntent(MyPageIntent.ClickBackIcon) }, + ) + SectionTitleText(text = "권한 설정") + NekiPermission.entries.forEach { permission -> + PermissionSectionItem( + title = permission.title, + subTitle = permission.subTitle, + isGranted = when (permission) { + NekiPermission.CAMERA -> uiState.isGrantedCamera + NekiPermission.LOCATION -> uiState.isGrantedLocation + NekiPermission.NOTIFICATION -> uiState.isGrantedNotification + }, + onClick = { onIntent(MyPageIntent.ClickPermissionItem(permission)) }, + ) + } + } + + if (uiState.isShowPermissionDialog && uiState.clickedPermission != null) { + DoubleButtonAlertDialog( + title = uiState.clickedPermission.title, + content = uiState.clickedPermission.dialogContent, + grayButtonText = "취소", + primaryButtonText = "허용", + onDismissRequest = { onIntent(MyPageIntent.DismissPermissionDialog) }, + onClickGrayButton = { onIntent(MyPageIntent.DismissPermissionDialog) }, + onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmPermissionDialog) }, + ) + } +} + +@ComponentPreview +@Composable +private fun PermissionScreenPreview() { + NekiTheme { + PermissionScreen() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt new file mode 100644 index 000000000..85ce065b7 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt @@ -0,0 +1,23 @@ +package com.neki.android.feature.mypage.impl.permission.const + +enum class NekiPermission( + val title: String, + val subTitle: String, + val dialogContent: String, +) { + CAMERA( + title = "카메라", + subTitle = "QR 촬영에 필요해요.", + dialogContent = "카메라 권한을 허용해주세요.", + ), + LOCATION( + title = "위치", + subTitle = "주변 포토부스 탐색에 필요해요.", + dialogContent = "위치 권한을 허용해주세요.", + ), + NOTIFICATION( + title = "알림", + subTitle = "저장 사진 및 추억 리마인드에 필요해요.", + dialogContent = "알림 권한을 허용해주세요.", + ), +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt new file mode 100644 index 000000000..c8308f518 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt @@ -0,0 +1,202 @@ +package com.neki.android.feature.mypage.impl.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Text +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import com.neki.android.core.designsystem.R +import com.neki.android.feature.mypage.impl.main.MyPageEffect +import com.neki.android.feature.mypage.impl.main.MyPageIntent +import com.neki.android.feature.mypage.impl.main.MyPageState +import com.neki.android.feature.mypage.impl.main.MyPageViewModel +import com.neki.android.feature.mypage.impl.profile.model.EditProfileImageType +import com.neki.android.feature.mypage.impl.profile.component.EditProfileImage +import com.neki.android.feature.mypage.impl.profile.component.ProfileEditTopBar +import com.neki.android.feature.mypage.impl.profile.component.ProfileImageChooseDialog +import timber.log.Timber + +@Composable +internal fun EditProfileRoute( + viewModel: MyPageViewModel = hiltViewModel(), + navigateBack: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + MyPageEffect.NavigateBack -> navigateBack() + is MyPageEffect.PreloadImageAndNavigateBack -> { + val request = ImageRequest.Builder(context) + .data(sideEffect.url) + .build() + context.imageLoader.execute(request) + navigateBack() + } + else -> {} + } + } + + EditProfileScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun EditProfileScreen( + uiState: MyPageState = MyPageState(), + onIntent: (MyPageIntent) -> Unit = {}, +) { + var displayProfileImage by remember { + mutableStateOf(uiState.userInfo.profileImageUrl) + } + + LaunchedEffect(uiState.profileImageState) { + when (uiState.profileImageState) { + is EditProfileImageType.OriginalImageUrl -> {} + is EditProfileImageType.ImageUri -> displayProfileImage = uiState.profileImageState.uri + EditProfileImageType.Default -> displayProfileImage = R.drawable.image_empty_profile_image + } + } + + val textFieldState = rememberTextFieldState(uiState.userInfo.nickname) + + val photoPicker = rememberLauncherForActivityResult(contract = ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + onIntent(MyPageIntent.SelectProfileImage(EditProfileImageType.ImageUri(uri))) + } else { + Timber.d("No media selected") + } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileEditTopBar( + enabled = textFieldState.text.isNotEmpty(), + onBack = { onIntent(MyPageIntent.ClickBackIcon) }, + onClickComplete = { + onIntent(MyPageIntent.ClickEditComplete(nickname = textFieldState.text.toString())) + }, + ) + EditProfileImage( + profileImage = displayProfileImage, + onClickCameraIcon = { onIntent(MyPageIntent.ClickCameraIcon) }, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "닉네임", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray700, + ) + BasicTextField( + state = textFieldState, + modifier = Modifier + .fillMaxWidth() + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(8.dp), + ) + .border( + width = 1.dp, + color = if (textFieldState.text.isEmpty()) NekiTheme.colorScheme.gray75 else NekiTheme.colorScheme.gray700, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + textStyle = NekiTheme.typography.body16Medium.copy( + color = NekiTheme.colorScheme.gray900, + ), + inputTransformation = InputTransformation.maxLength(12), + cursorBrush = SolidColor(NekiTheme.colorScheme.gray800), + lineLimits = TextFieldLineLimits.SingleLine, + decorator = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (textFieldState.text.isEmpty()) { + Text( + text = "닉네임을 입력해주세요.", + style = NekiTheme.typography.body16Regular, + color = NekiTheme.colorScheme.gray300, + ) + } + innerTextField() + } + Text( + text = "${textFieldState.text.length}/12", + style = NekiTheme.typography.caption12Regular, + color = NekiTheme.colorScheme.gray300, + ) + } + }, + ) + } + } + + if (uiState.isShowImageChooseDialog) { + ProfileImageChooseDialog( + onDismissRequest = { onIntent(MyPageIntent.DismissImageChooseDialog) }, + onClickDefaultProfile = { onIntent(MyPageIntent.SelectProfileImage(EditProfileImageType.Default)) }, + onClickSelectPhoto = { + onIntent(MyPageIntent.DismissImageChooseDialog) + photoPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + ) + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@ComponentPreview +@Composable +private fun EditProfileScreenPreview() { + NekiTheme { + EditProfileScreen() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt new file mode 100644 index 000000000..6fcf446d9 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt @@ -0,0 +1,133 @@ +package com.neki.android.feature.mypage.impl.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.feature.mypage.impl.component.SectionItem +import com.neki.android.feature.mypage.impl.component.SectionTitleText +import com.neki.android.feature.mypage.impl.main.MyPageEffect +import com.neki.android.feature.mypage.impl.main.MyPageIntent +import com.neki.android.feature.mypage.impl.main.MyPageState +import com.neki.android.feature.mypage.impl.main.MyPageViewModel +import com.neki.android.feature.mypage.impl.profile.component.ProfileSettingTopBar +import com.neki.android.feature.mypage.impl.profile.component.SettingProfileImage +import com.neki.android.core.common.kakao.KakaoAuthHelper +import timber.log.Timber + +@Composable +internal fun ProfileSettingRoute( + viewModel: MyPageViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToEditProfile: () -> Unit, + navigateToLogin: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val kakaoAuthHelper = remember { KakaoAuthHelper(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + MyPageEffect.NavigateBack -> navigateBack() + MyPageEffect.NavigateToEditProfile -> navigateToEditProfile() + MyPageEffect.NavigateToLogin -> navigateToLogin() + MyPageEffect.LogoutWithKakao -> { + kakaoAuthHelper.logout( + onSuccess = { navigateToLogin() }, + onFailure = { Timber.e(it) }, + ) + } + MyPageEffect.UnlinkWithKakao -> { + kakaoAuthHelper.unlink( + onSuccess = { navigateToLogin() }, + onFailure = { Timber.e(it) }, + ) + } + else -> {} + } + } + + ProfileSettingScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun ProfileSettingScreen( + uiState: MyPageState = MyPageState(), + onIntent: (MyPageIntent) -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileSettingTopBar( + onBack = { onIntent(MyPageIntent.ClickBackIcon) }, + ) + SettingProfileImage( + nickname = uiState.userInfo.nickname, + profileImage = uiState.userInfo.profileImageUrl, + onClickEdit = { onIntent(MyPageIntent.ClickEditIcon) }, + ) + SectionTitleText(text = "서비스 정보 및 지원") + SectionItem( + text = "로그아웃", + onClick = { onIntent(MyPageIntent.ClickLogout) }, + ) + SectionItem( + text = "탈퇴하기", + onClick = { onIntent(MyPageIntent.ClickWithdraw) }, + ) + } + + if (uiState.isShowLogoutDialog) { + DoubleButtonAlertDialog( + title = "로그아웃을 하시겠습니까?", + content = "다시 로그인해야 서비스를 이용할 수 있어요.", + grayButtonText = "취소", + primaryButtonText = "확인", + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { onIntent(MyPageIntent.DismissLogoutDialog) }, + onClickGrayButton = { onIntent(MyPageIntent.DismissLogoutDialog) }, + onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmLogout) }, + ) + } + + if (uiState.isShowWithdrawDialog) { + DoubleButtonAlertDialog( + title = "정말 탈퇴하시겠어요?", + content = "계정을 탈퇴하면 사진과 정보가 모두 삭제되며,\n삭제된 데이터는 복구할 수 없어요.", + grayButtonText = "취소", + primaryButtonText = "탈퇴 확정", + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { onIntent(MyPageIntent.DismissWithdrawDialog) }, + onClickGrayButton = { onIntent(MyPageIntent.DismissWithdrawDialog) }, + onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmWithdraw) }, + ) + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@ComponentPreview +@Composable +private fun ProfileSettingScreenPreview() { + NekiTheme { + ProfileSettingScreen() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/EditProfileImage.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/EditProfileImage.kt new file mode 100644 index 000000000..4e37d9399 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/EditProfileImage.kt @@ -0,0 +1,75 @@ +package com.neki.android.feature.mypage.impl.profile.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun EditProfileImage( + profileImage: Any? = null, + imageSize: Dp = 142.dp, + onClickCameraIcon: () -> Unit, +) { + Box( + modifier = Modifier.padding(top = 20.dp, bottom = 28.dp), + ) { + AsyncImage( + modifier = Modifier + .size(imageSize) + .clip(CircleShape), + model = profileImage ?: R.drawable.image_empty_profile_image, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .border( + width = 1.dp, + shape = CircleShape, + color = NekiTheme.colorScheme.primary400, + ) + .background( + color = NekiTheme.colorScheme.white, + shape = CircleShape, + ) + .padding(8.dp) + .noRippleClickableSingle(onClick = onClickCameraIcon), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_camera), + contentDescription = null, + tint = NekiTheme.colorScheme.primary400, + ) + } + } +} + +@ComponentPreview +@Composable +private fun EditProfileImagePreview() { + NekiTheme { + EditProfileImage( + onClickCameraIcon = {}, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/ProfileImageChooseDialog.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/ProfileImageChooseDialog.kt new file mode 100644 index 000000000..d3d117c8e --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/ProfileImageChooseDialog.kt @@ -0,0 +1,72 @@ +package com.neki.android.feature.mypage.impl.profile.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun ProfileImageChooseDialog( + properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest: () -> Unit = {}, + onClickDefaultProfile: () -> Unit = {}, + onClickSelectPhoto: () -> Unit = {}, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .widthIn(max = 400.dp) + .background( + shape = RoundedCornerShape(20.dp), + color = NekiTheme.colorScheme.white, + ) + .padding(vertical = 12.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = onClickDefaultProfile) + .padding(vertical = 14.dp), + text = "기본 프로필로 바꾸기", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.gray800, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = onClickSelectPhoto) + .padding(vertical = 14.dp), + text = "사진 선택하기", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.gray800, + textAlign = TextAlign.Center, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ProfileImageChooseDialogPreview() { + NekiTheme { + ProfileImageChooseDialog() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/ProfileTopBar.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/ProfileTopBar.kt new file mode 100644 index 000000000..2995de53c --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/ProfileTopBar.kt @@ -0,0 +1,56 @@ +package com.neki.android.feature.mypage.impl.profile.component + +import androidx.compose.runtime.Composable +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.topbar.BackTitleTextButtonTopBar +import com.neki.android.core.designsystem.topbar.BackTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun ProfileSettingTopBar( + onBack: () -> Unit = {}, +) { + BackTitleTopBar( + title = "계정 설정", + onBack = onBack, + ) +} + +@Composable +internal fun ProfileEditTopBar( + enabled: Boolean = true, + onBack: () -> Unit = {}, + onClickComplete: () -> Unit = {}, +) { + BackTitleTextButtonTopBar( + title = "프로필 편집", + buttonLabel = "완료", + enabled = enabled, + onBack = onBack, + onClickTextButton = onClickComplete, + ) +} + +@ComponentPreview +@Composable +private fun ProfileSettingTopBarPreview() { + NekiTheme { + ProfileSettingTopBar() + } +} + +@ComponentPreview +@Composable +private fun ProfileEditTopBarPreview() { + NekiTheme { + ProfileEditTopBar() + } +} + +@ComponentPreview +@Composable +private fun ProfileEditTopBarDisabledPreview() { + NekiTheme { + ProfileEditTopBar(enabled = false) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt new file mode 100644 index 000000000..a3f1787e8 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt @@ -0,0 +1,76 @@ +package com.neki.android.feature.mypage.impl.profile.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer + +@Composable +internal fun SettingProfileImage( + nickname: String, + profileImage: Any? = null, + imageSize: Dp = 142.dp, + onClickEdit: () -> Unit, +) { + Column( + modifier = Modifier.padding(top = 20.dp, bottom = 27.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + modifier = Modifier + .size(imageSize) + .clip(CircleShape), + model = profileImage ?: R.drawable.image_empty_profile_image, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = nickname, + style = NekiTheme.typography.title20Medium, + color = NekiTheme.colorScheme.gray900, + ) + Icon( + modifier = Modifier.noRippleClickableSingle(onClick = onClickEdit), + imageVector = ImageVector.vectorResource(R.drawable.icon_edit), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } +} + +@ComponentPreview +@Composable +private fun SettingProfileImagePreview() { + NekiTheme { + SettingProfileImage( + nickname = "네키네키", + onClickEdit = {}, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/EditProfileImageType.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/EditProfileImageType.kt new file mode 100644 index 000000000..fa09955b8 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/EditProfileImageType.kt @@ -0,0 +1,9 @@ +package com.neki.android.feature.mypage.impl.profile.model + +import android.net.Uri + +sealed interface EditProfileImageType { + data class OriginalImageUrl(val url: String) : EditProfileImageType + data class ImageUri(val uri: Uri) : EditProfileImageType + data object Default : EditProfileImageType +} diff --git a/feature/photo-upload/api/.gitignore b/feature/photo-upload/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/photo-upload/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/photo-upload/api/build.gradle.kts b/feature/photo-upload/api/build.gradle.kts new file mode 100644 index 000000000..c00d614b9 --- /dev/null +++ b/feature/photo-upload/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.photo_upload.api" +} diff --git a/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/PhotoUploadNavKey.kt b/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/PhotoUploadNavKey.kt new file mode 100644 index 000000000..da605580b --- /dev/null +++ b/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/PhotoUploadNavKey.kt @@ -0,0 +1,29 @@ +package com.neki.android.feature.photo_upload.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface PhotoUploadNavKey : NavKey { + + @Serializable + data object QRScan : PhotoUploadNavKey + + @Serializable + data class UploadAlbum( + val imageUrl: String? = null, + val uriStrings: List = emptyList(), + ) : PhotoUploadNavKey +} + +fun Navigator.navigateToQRScan() { + navigate(PhotoUploadNavKey.QRScan) +} + +fun Navigator.navigateToUploadAlbum(uriStrings: List) { + navigate(PhotoUploadNavKey.UploadAlbum(uriStrings = uriStrings)) +} + +fun Navigator.navigateToUploadAlbum(imageUrl: String) { + navigate(PhotoUploadNavKey.UploadAlbum(imageUrl = imageUrl)) +} diff --git a/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/QRScanResult.kt b/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/QRScanResult.kt new file mode 100644 index 000000000..e705e0870 --- /dev/null +++ b/feature/photo-upload/api/src/main/java/com/neki/android/feature/photo_upload/api/QRScanResult.kt @@ -0,0 +1,6 @@ +package com.neki.android.feature.photo_upload.api + +sealed interface QRScanResult { + data class QRCodeScanned(val imageUrl: String) : QRScanResult + data object OpenGallery : QRScanResult +} diff --git a/feature/photo-upload/impl/.gitignore b/feature/photo-upload/impl/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/photo-upload/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/photo-upload/impl/build.gradle.kts b/feature/photo-upload/impl/build.gradle.kts new file mode 100644 index 000000000..9f0dac88c --- /dev/null +++ b/feature/photo-upload/impl/build.gradle.kts @@ -0,0 +1,44 @@ +import java.util.Properties +import kotlin.apply + +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +val localPropertiesFile = project.rootProject.file("local.properties") +val properties = Properties().apply { + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } +} + +android { + namespace = "com.neki.android.feature.photo_upload.impl" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "BRAND_PROPOSAL_URL", properties["BRAND_PROPOSAL_URL"].toString()) + buildConfigField("String", "PHOTOISM_URL", properties["PHOTOISM_URL"].toString()) + buildConfigField("String", "PHOTOISM_IMAGE_URL", properties["PHOTOISM_IMAGE_URL"].toString()) + buildConfigField("String", "PHOTOISM_IMG_URL_MIME_TYPE", properties["PHOTOISM_IMG_URL_MIME_TYPE"].toString()) + buildConfigField("String", "LIFE_FOUR_CUT_URL", properties["LIFE_FOUR_CUT_URL"].toString()) + buildConfigField("String", "LIFE_FOUR_CUT_IMAGE_URL", properties["LIFE_FOUR_CUT_IMAGE_URL"].toString()) + buildConfigField("String", "LIFE_FOUR_CUT_URL_MIME_TYPE", properties["LIFE_FOUR_CUT_URL_MIME_TYPE"].toString()) + } +} + +dependencies { + implementation(projects.feature.photoUpload.api) + implementation(projects.feature.archive.api) + + implementation(libs.androidx.activity.compose) + implementation(libs.mlkit.barcode.scanning) + + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.compose) + +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt new file mode 100644 index 000000000..923202208 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumContract.kt @@ -0,0 +1,39 @@ +package com.neki.android.feature.photo_upload.impl.album + +import android.net.Uri +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.model.UploadType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +data class UploadAlbumState( + val isLoading: Boolean = false, + val imageUrl: String? = null, + val selectedUris: ImmutableList = persistentListOf(), + val favoriteAlbum: AlbumPreview = AlbumPreview(), + val albums: ImmutableList = persistentListOf(), + val selectedAlbums: PersistentList = persistentListOf(), +) { + val count: Int + get() = if (imageUrl == null) selectedUris.size else 1 + val uploadType: UploadType + get() = if (imageUrl == null) UploadType.GALLERY else UploadType.QR_CODE +} + +sealed interface UploadAlbumIntent { + data object EnterUploadAlbumScreen : UploadAlbumIntent + + // TopBar Intent + data object ClickBackIcon : UploadAlbumIntent + data object ClickUploadButton : UploadAlbumIntent + + // Album Intent + data class ClickAlbumItem(val album: AlbumPreview) : UploadAlbumIntent +} + +sealed interface UploadAlbumSideEffect { + data object NavigateBack : UploadAlbumSideEffect + data class NavigateToAlbumDetail(val albumId: Long, val title: String) : UploadAlbumSideEffect + data class ShowToastMessage(val message: String) : UploadAlbumSideEffect +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt new file mode 100644 index 000000000..0c46218ce --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumScreen.kt @@ -0,0 +1,137 @@ +package com.neki.android.feature.photo_upload.impl.album + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.AlbumPreview +import com.neki.android.core.ui.component.AlbumRowComponent +import com.neki.android.core.ui.component.FavoriteAlbumRowComponent +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.photo_upload.impl.album.component.UploadAlbumTopBar +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun UploadAlbumRoute( + viewModel: UploadAlbumViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToAlbumDetail: (Long, String) -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + UploadAlbumSideEffect.NavigateBack -> navigateBack() + is UploadAlbumSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title) + is UploadAlbumSideEffect.ShowToastMessage -> nekiToast.showToast(sideEffect.message) + } + } + + UploadAlbumScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UploadAlbumScreen( + uiState: UploadAlbumState = UploadAlbumState(), + onIntent: (UploadAlbumIntent) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.white), + ) { + UploadAlbumTopBar( + count = uiState.count, + onClickBack = { onIntent(UploadAlbumIntent.ClickBackIcon) }, + onClickUpload = { onIntent(UploadAlbumIntent.ClickUploadButton) }, + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + item { + FavoriteAlbumRowComponent(album = uiState.favoriteAlbum) + } + + items( + items = uiState.albums, + key = { album -> album.id }, + ) { album -> + val isSelected = uiState.selectedAlbums.any { it.id == album.id } + AlbumRowComponent( + album = album, + isSelectable = true, + isSelected = isSelected, + onClick = { onIntent(UploadAlbumIntent.ClickAlbumItem(album)) }, + ) + } + } + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@DevicePreview +@Composable +private fun UploadAlbumScreenPreview() { + NekiTheme { + UploadAlbumScreen( + uiState = UploadAlbumState( + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + AlbumPreview(id = 4, title = "회사 송년회", photoCount = 5), + ), + ), + ) + } +} + +@DevicePreview +@Composable +private fun UploadAlbumScreenSelectingPreview() { + NekiTheme { + UploadAlbumScreen( + uiState = UploadAlbumState( + favoriteAlbum = AlbumPreview(id = 0, title = "즐겨찾는 사진", photoCount = 3), + albums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + AlbumPreview(id = 3, title = "대학 동기 모임", photoCount = 3), + ), + selectedAlbums = persistentListOf( + AlbumPreview(id = 1, title = "제주도 여행 2024", photoCount = 4), + AlbumPreview(id = 2, title = "가족 생일파티", photoCount = 2), + ), + ), + ) + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt new file mode 100644 index 000000000..b12957bd1 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt @@ -0,0 +1,186 @@ +package com.neki.android.feature.photo_upload.impl.album + +import android.net.Uri +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.FolderRepository +import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase +import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase +import com.neki.android.core.model.UploadType +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel(assistedFactory = UploadAlbumViewModel.Factory::class) +class UploadAlbumViewModel @AssistedInject constructor( + @Assisted private val imageUrl: String?, + @Assisted private val uriStrings: List, + private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, + private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, + private val photoRepository: PhotoRepository, + private val folderRepository: FolderRepository, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(imageUrl: String?, uriStrings: List): UploadAlbumViewModel + } + + val store: MviIntentStore = + mviIntentStore( + initialState = UploadAlbumState( + imageUrl = imageUrl, + selectedUris = uriStrings.map { it.toUri() }.toImmutableList(), + ), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(UploadAlbumIntent.EnterUploadAlbumScreen) }, + ) + + private fun onIntent( + intent: UploadAlbumIntent, + state: UploadAlbumState, + reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, + postSideEffect: (UploadAlbumSideEffect) -> Unit, + ) { + when (intent) { + UploadAlbumIntent.EnterUploadAlbumScreen -> fetchInitialData(reduce) + UploadAlbumIntent.ClickBackIcon -> postSideEffect(UploadAlbumSideEffect.NavigateBack) + UploadAlbumIntent.ClickUploadButton -> handleUploadButtonClick(state, reduce, postSideEffect) + is UploadAlbumIntent.ClickAlbumItem -> { + reduce { + copy( + selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) { + selectedAlbums.remove(intent.album) + } else { + selectedAlbums.add(intent.album) + }.toPersistentList(), + ) + } + } + } + } + + private fun fetchInitialData(reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + try { + awaitAll( + async { fetchFavoriteSummary(reduce) }, + async { fetchFolders(reduce) }, + ) + } finally { + reduce { copy(isLoading = false) } + } + } + } + + private suspend fun fetchFavoriteSummary(reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit) { + photoRepository.getFavoriteSummary() + .onSuccess { data -> + reduce { copy(favoriteAlbum = data) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private suspend fun fetchFolders(reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit) { + folderRepository.getFolders() + .onSuccess { data -> + reduce { copy(albums = data.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + } + } + + private fun handleUploadButtonClick( + state: UploadAlbumState, + reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, + postSideEffect: (UploadAlbumSideEffect) -> Unit, + ) { + val firstAlbum = state.selectedAlbums.firstOrNull() ?: return + val onSuccessAction = { + reduce { copy(isLoading = false) } + postSideEffect(UploadAlbumSideEffect.ShowToastMessage("이미지를 추가했어요")) + postSideEffect(UploadAlbumSideEffect.NavigateToAlbumDetail(firstAlbum.id, firstAlbum.title)) + } + val onFailureAction: (Throwable) -> Unit = { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + postSideEffect(UploadAlbumSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) + } + + if (state.uploadType == UploadType.QR_CODE) { + uploadSingleImage( + imageUrl = state.imageUrl ?: return, + albumId = firstAlbum.id, + reduce = reduce, + onSuccessAction = onSuccessAction, + onFailureAction = onFailureAction, + ) + } else { + uploadMultipleImages( + imageUris = state.selectedUris, + albumId = firstAlbum.id, + reduce = reduce, + onSuccessAction = onSuccessAction, + onFailureAction = onFailureAction, + ) + } + } + + private fun uploadSingleImage( + imageUrl: String, + albumId: Long, + reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, + onSuccessAction: () -> Unit, + onFailureAction: (Throwable) -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + uploadSinglePhotoUseCase( + imageUrl = imageUrl, + folderId = albumId, + ).onSuccess { data -> + Timber.d(data.toString()) + onSuccessAction() + }.onFailure { error -> + onFailureAction(error) + } + } + } + + private fun uploadMultipleImages( + imageUris: List, + albumId: Long, + reduce: (UploadAlbumState.() -> UploadAlbumState) -> Unit, + onSuccessAction: () -> Unit, + onFailureAction: (Throwable) -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + uploadMultiplePhotoUseCase( + imageUris = imageUris, + folderId = albumId, + ).onSuccess { + onSuccessAction() + }.onFailure { error -> + onFailureAction(error) + } + } + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/component/UploadAlbumTopBar.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/component/UploadAlbumTopBar.kt new file mode 100644 index 000000000..9f236284b --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/component/UploadAlbumTopBar.kt @@ -0,0 +1,34 @@ +package com.neki.android.feature.photo_upload.impl.album.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.topbar.BackTitleTextButtonTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun UploadAlbumTopBar( + count: Int, + modifier: Modifier = Modifier, + onClickBack: () -> Unit = {}, + onClickUpload: () -> Unit = {}, +) { + BackTitleTextButtonTopBar( + modifier = modifier, + title = "모든 앨범", + buttonLabel = "${count}장 업로드", + enabled = count != 0, + onBack = onClickBack, + onClickTextButton = onClickUpload, + ) +} + +@ComponentPreview +@Composable +private fun UploadAlbumTopBarPreview() { + NekiTheme { + UploadAlbumTopBar( + count = 5, + ) + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt new file mode 100644 index 000000000..977d2ca4b --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/di/PhotoUploadEntryProvider.kt @@ -0,0 +1,55 @@ +package com.neki.android.feature.photo_upload.impl.di + +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.feature.archive.api.navigateToAlbumDetail +import com.neki.android.feature.photo_upload.api.PhotoUploadNavKey +import com.neki.android.feature.photo_upload.api.QRScanResult +import com.neki.android.feature.photo_upload.impl.album.UploadAlbumRoute +import com.neki.android.feature.photo_upload.impl.album.UploadAlbumViewModel +import com.neki.android.feature.photo_upload.impl.qrscan.QRScanRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object PhotoUploadEntryProvider { + + @IntoSet + @Provides + fun providePhotoUploadEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + photoUploadEntry(navigator) + } +} + +private fun EntryProviderScope.photoUploadEntry(navigator: Navigator) { + entry { + val resultBus = LocalResultEventBus.current + + QRScanRoute( + navigateBack = navigator::goBack, + setQRResult = { resultBus.sendResult(result = it) }, + ) + } + entry { key -> + UploadAlbumRoute( + viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key.imageUrl, key.uriStrings) + }, + ), + navigateBack = navigator::goBack, + navigateToAlbumDetail = { id, title -> + navigator.remove(key) + navigator.navigateToAlbumDetail(id = id, title = title) + }, + ) + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt new file mode 100644 index 000000000..7cd80c42a --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanContract.kt @@ -0,0 +1,37 @@ +package com.neki.android.feature.photo_upload.impl.qrscan + +data class QRScanState( + val isLoading: Boolean = false, + val viewType: QRScanViewType = QRScanViewType.QR_SCAN, + val scannedUrl: String? = null, + val detectedImageUrl: String? = null, + val isShowShouldDownloadDialog: Boolean = false, + val isShowUnSupportedBrandDialog: Boolean = false, + val isTorchEnabled: Boolean = false, +) + +sealed interface QRScanIntent { + data object ToggleTorch : QRScanIntent + data object ClickCloseQRScan : QRScanIntent + data class ScanQRCode(val scannedUrl: String) : QRScanIntent + data class SetViewType(val viewType: QRScanViewType) : QRScanIntent + data class DetectImageUrl(val imageUrl: String) : QRScanIntent + data object DismissShouldDownloadDialog : QRScanIntent + data object ClickGoDownload : QRScanIntent + data object DismissUnSupportedBrandDialog : QRScanIntent + data object ClickUploadGallery : QRScanIntent + data object ClickProposeBrand : QRScanIntent +} + +sealed interface QRScanSideEffect { + data object NavigateBack : QRScanSideEffect + data class SetQRScannedResult(val imageUrl: String) : QRScanSideEffect + data class ShowToast(val message: String) : QRScanSideEffect + data object OpenBrandProposalUrl : QRScanSideEffect + data object SetOpenGalleryResult : QRScanSideEffect +} + +enum class QRScanViewType { + QR_SCAN, + WEB_VIEW, +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt new file mode 100644 index 000000000..09d5d491d --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanScreen.kt @@ -0,0 +1,119 @@ +package com.neki.android.feature.photo_upload.impl.qrscan + +import android.content.Intent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.dialog.SingleButtonAlertDialog +import com.neki.android.core.designsystem.dialog.SingleButtonWithTextButtonAlertDialog +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.photo_upload.api.QRScanResult +import com.neki.android.feature.photo_upload.impl.BuildConfig +import com.neki.android.feature.photo_upload.impl.qrscan.component.PhotoWebViewContent +import com.neki.android.feature.photo_upload.impl.qrscan.component.QRScannerContent + +@Composable +internal fun QRScanRoute( + viewModel: QRScanViewModel = hiltViewModel(), + navigateBack: () -> Unit, + setQRResult: (QRScanResult) -> Unit = {}, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + QRScanSideEffect.NavigateBack -> navigateBack() + is QRScanSideEffect.SetQRScannedResult -> { + setQRResult(QRScanResult.QRCodeScanned(sideEffect.imageUrl)) + navigateBack() + } + is QRScanSideEffect.ShowToast -> nekiToast.showToast(sideEffect.message) + QRScanSideEffect.OpenBrandProposalUrl -> { + val intent = Intent(Intent.ACTION_VIEW, BuildConfig.BRAND_PROPOSAL_URL.toUri()) + context.startActivity(intent) + } + + QRScanSideEffect.SetOpenGalleryResult -> { + setQRResult(QRScanResult.OpenGallery) + navigateBack() + } + } + } + QRScanScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +internal fun QRScanScreen( + uiState: QRScanState = QRScanState(), + onIntent: (QRScanIntent) -> Unit = {}, +) { + when (uiState.viewType) { + QRScanViewType.QR_SCAN -> { + QRScannerContent( + modifier = Modifier.fillMaxSize(), + isTorchEnabled = uiState.isTorchEnabled, + onClickTorch = { onIntent(QRScanIntent.ToggleTorch) }, + onClickClose = { onIntent(QRScanIntent.ClickCloseQRScan) }, + onQRCodeScanned = { url -> onIntent(QRScanIntent.ScanQRCode(url)) }, + ) + } + + QRScanViewType.WEB_VIEW -> { + if (uiState.scannedUrl != null) + PhotoWebViewContent( + scannedUrl = uiState.scannedUrl, + onDetectImageUrl = { imageUrl -> onIntent(QRScanIntent.DetectImageUrl(imageUrl)) }, + ) + else { + LaunchedEffect(Unit) { + onIntent(QRScanIntent.SetViewType(viewType = QRScanViewType.QR_SCAN)) + } + } + } + } + + if (uiState.isShowShouldDownloadDialog) { + SingleButtonAlertDialog( + title = "갤러리에 사진을 먼저 다운받아주세요", + content = "해당 브랜드는 웹사이트에서 사진을 저장해야\n네키에 자동으로 저장돼요", + buttonText = "사진 다운로드하러가기", + onDismissRequest = { onIntent(QRScanIntent.DismissShouldDownloadDialog) }, + onClick = { onIntent(QRScanIntent.ClickGoDownload) }, + ) + } + + if (uiState.isShowUnSupportedBrandDialog) { + SingleButtonWithTextButtonAlertDialog( + title = "지원하지 않는 브랜드예요", + content = "갤러리에서 사진을 추가해 바로 저장할 수 있어요\n원하는 브랜드가 있다면 제안해주세요!", + buttonText = "갤러리에서 추가하기", + textButtonText = "브랜드 제안하기", + onDismissRequest = { onIntent(QRScanIntent.DismissUnSupportedBrandDialog) }, + onButtonClick = { onIntent(QRScanIntent.ClickUploadGallery) }, + onTextButtonClick = { onIntent(QRScanIntent.ClickProposeBrand) }, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun QRScanScreenPreview() { + NekiTheme { + QRScanScreen() + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt new file mode 100644 index 000000000..081ae7321 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/QRScanViewModel.kt @@ -0,0 +1,81 @@ +package com.neki.android.feature.photo_upload.impl.qrscan + +import androidx.lifecycle.ViewModel +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.photo_upload.impl.BuildConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class QRScanViewModel @Inject constructor() : ViewModel() { + + val store: MviIntentStore = + mviIntentStore( + initialState = QRScanState(), + onIntent = ::onIntent, + ) + + private fun onIntent( + intent: QRScanIntent, + state: QRScanState, + reduce: (QRScanState.() -> QRScanState) -> Unit, + postSideEffect: (QRScanSideEffect) -> Unit, + ) { + when (intent) { + QRScanIntent.ToggleTorch -> reduce { copy(isTorchEnabled = !this.isTorchEnabled) } + QRScanIntent.ClickCloseQRScan -> postSideEffect(QRScanSideEffect.NavigateBack) + is QRScanIntent.ScanQRCode -> { + val scannedUrl = intent.scannedUrl + + if (isSupportedBrand(scannedUrl)) { + if (isShouldFirstDownloadBrand(scannedUrl)) { + reduce { + copy( + scannedUrl = intent.scannedUrl, + isShowShouldDownloadDialog = true, + ) + } + } else { + reduce { + copy( + scannedUrl = intent.scannedUrl, + viewType = QRScanViewType.WEB_VIEW, + ) + } + } + } else { + reduce { copy(isShowUnSupportedBrandDialog = true) } + } + } + + is QRScanIntent.SetViewType -> { + reduce { copy(viewType = intent.viewType) } + postSideEffect(QRScanSideEffect.ShowToast("QR코드를 인식하지 못했습니다.")) + } + + is QRScanIntent.DetectImageUrl -> postSideEffect(QRScanSideEffect.SetQRScannedResult(intent.imageUrl)) + + QRScanIntent.DismissShouldDownloadDialog -> reduce { copy(isShowShouldDownloadDialog = false) } + QRScanIntent.ClickGoDownload -> reduce { + copy( + viewType = QRScanViewType.WEB_VIEW, + isShowShouldDownloadDialog = false, + ) + } + + QRScanIntent.DismissUnSupportedBrandDialog -> reduce { copy(isShowUnSupportedBrandDialog = false) } + QRScanIntent.ClickUploadGallery -> postSideEffect(QRScanSideEffect.SetOpenGalleryResult) + QRScanIntent.ClickProposeBrand -> postSideEffect(QRScanSideEffect.OpenBrandProposalUrl) + } + } + + private fun isSupportedBrand(url: String): Boolean { + return url.startsWith(BuildConfig.PHOTOISM_URL) || + url.startsWith(BuildConfig.LIFE_FOUR_CUT_URL) + } + + private fun isShouldFirstDownloadBrand(url: String): Boolean { + return false + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/DimExceptContent.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/DimExceptContent.kt new file mode 100644 index 000000000..4d3ffbf22 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/DimExceptContent.kt @@ -0,0 +1,44 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.IntSize +import com.neki.android.feature.photo_upload.impl.qrscan.const.QRLayoutConst.DIM_COLOR + +@Composable +internal fun DimExceptContent( + offSet: Offset?, + size: IntSize?, + modifier: Modifier = Modifier, + dimColor: Color = Color(DIM_COLOR), +) { + Box( + modifier = modifier, + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }, + ) { + drawRect(dimColor) + + if (offSet == null || size == null) return@Canvas + + drawRoundRect( + color = Color.Transparent, + topLeft = offSet, + size = Size(size.width.toFloat(), size.height.toFloat()), + blendMode = BlendMode.Clear, + ) + } + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/PhotoWebViewContent.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/PhotoWebViewContent.kt new file mode 100644 index 000000000..e7a8548e2 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/PhotoWebViewContent.kt @@ -0,0 +1,62 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.component + +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.DialogProperties +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.feature.photo_upload.impl.qrscan.util.PhotoWebViewClient + +@Composable +internal fun PhotoWebViewContent( + scannedUrl: String, + onDetectImageUrl: (String) -> Unit, +) { + val webView = remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + + DisposableEffect(Unit) { + onDispose { + webView.value?.destroy() + } + } + + AndroidView( + factory = { context -> + WebView(context).apply { + webView.value = this + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + + webViewClient = PhotoWebViewClient { photoImageUrl -> + onDetectImageUrl(photoImageUrl) + isLoading = false + } + + loadUrl(scannedUrl) + } + }, + update = { webView -> + if (webView.url != scannedUrl && scannedUrl.isNotEmpty()) { + webView.loadUrl(scannedUrl) + } + }, + ) + + if (isLoading) { + LoadingDialog( + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + onDismissRequest = { isLoading = false }, + ) + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/QRScanner.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/QRScanner.kt new file mode 100644 index 000000000..325b19ea9 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/QRScanner.kt @@ -0,0 +1,158 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.component + +import android.graphics.RectF +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceOrientedMeteringPointFactory +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.lifecycle.awaitInstance +import androidx.camera.viewfinder.compose.MutableCoordinateTransformer +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.photo_upload.impl.qrscan.util.QRImageAnalyzer +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.concurrent.TimeUnit + +@Composable +internal fun QRScanner( + modifier: Modifier = Modifier, + isTorchEnabled: Boolean = false, + scanAreaRatio: () -> RectF? = { null }, + onQRCodeScanned: (String) -> Unit = {}, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var provider by remember { mutableStateOf(null) } + + var camera by remember { mutableStateOf(null) } + + val surfaceRequests = remember { MutableStateFlow(null) } + val surfaceRequest by surfaceRequests.collectAsState(initial = null) + + val coordinateTransformer = remember { MutableCoordinateTransformer() } + + LaunchedEffect(Unit) { + provider = ProcessCameraProvider.awaitInstance(context) + val preview = Preview.Builder().build().apply { + setSurfaceProvider { req -> surfaceRequests.value = req } + } + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build().apply { + setAnalyzer( + ContextCompat.getMainExecutor(context), + QRImageAnalyzer( + scanAreaRatio = scanAreaRatio, + onQRCodeScanned = { scannedUrl -> + if (scannedUrl.isNotEmpty()) { + onQRCodeScanned(scannedUrl) + } + }, + ), + ) + } + + provider?.unbindAll() + camera = provider?.bindToLifecycle( + lifecycleOwner = lifecycleOwner, + cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer, + ) + } + + DisposableEffect(lifecycleOwner) { + onDispose { + provider?.unbindAll() + } + } + + LaunchedEffect(isTorchEnabled, camera) { + camera?.cameraControl?.enableTorch(isTorchEnabled) + } + + Box(modifier = modifier) { + surfaceRequest?.let { req -> + CameraXViewfinder( + surfaceRequest = req, + implementationMode = ImplementationMode.EXTERNAL, + coordinateTransformer = coordinateTransformer, + modifier = Modifier + .fillMaxSize() + .pointerInput(camera) { + // Tap-to-focus + detectTapGestures { offset -> + val cam = camera ?: return@detectTapGestures + + // Transform Compose coordinates to camera surface + val surfacePoint = with(coordinateTransformer) { + offset.transform() + } + + val meteringFactory = SurfaceOrientedMeteringPointFactory( + req.resolution.width.toFloat(), + req.resolution.height.toFloat(), + ) + + val focusPoint = meteringFactory.createPoint( + surfacePoint.x, + surfacePoint.y, + ) + + val action = FocusMeteringAction.Builder( + focusPoint, + FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE, + ).setAutoCancelDuration(3, TimeUnit.SECONDS).build() + + cam.cameraControl.startFocusAndMetering(action) + } + } + .pointerInput(camera) { + // Pinch-to-zoom + detectTransformGestures { _, _, zoom, _ -> + val cam = camera ?: return@detectTransformGestures + val zoomState = cam.cameraInfo.zoomState.value ?: return@detectTransformGestures + + val newRatio = (zoomState.zoomRatio * zoom).coerceIn( + zoomState.minZoomRatio, + zoomState.maxZoomRatio, + ) + + cam.cameraControl.setZoomRatio(newRatio) + } + }, + ) + } + } +} + +@androidx.compose.ui.tooling.preview.Preview +@Composable +private fun QRScannerPreview() { + NekiTheme { + QRScanner() + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/QRScannerContent.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/QRScannerContent.kt new file mode 100644 index 000000000..a1c2fb881 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/QRScannerContent.kt @@ -0,0 +1,196 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.component + +import android.graphics.RectF +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun QRScannerContent( + modifier: Modifier = Modifier, + isTorchEnabled: Boolean = false, + onClickTorch: () -> Unit = {}, + onClickClose: () -> Unit = {}, + onQRCodeScanned: (String) -> Unit = {}, +) { + var frameOffset: Offset? by remember { mutableStateOf(null) } + var frameSize: IntSize? by remember { mutableStateOf(null) } + var containerSize: IntSize? by remember { mutableStateOf(null) } + + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + containerSize = coordinates.size + }, + ) { + QRScanner( + modifier = Modifier.fillMaxSize(), + isTorchEnabled = isTorchEnabled, + scanAreaRatio = { + val offset = frameOffset + val frame = frameSize + val container = containerSize + if (offset != null && frame != null && container != null && + container.width > 0 && container.height > 0 + ) { + RectF( + offset.x / container.width, + offset.y / container.height, + (offset.x + frame.width) / container.width, + (offset.y + frame.height) / container.height, + ) + } else { + null + } + }, + onQRCodeScanned = onQRCodeScanned, + ) + DimExceptContent( + modifier = Modifier.fillMaxSize(), + offSet = frameOffset, + size = frameSize, + ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 상단 영역 - weight(234f) + Column(modifier = Modifier.weight(234f)) { + // TopBar (고정 크기) + Box(modifier = Modifier.fillMaxWidth()) { + NekiIconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = onClickClose, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + contentDescription = null, + tint = Color.White, + ) + } + } + + // Scanner 상단 영역에서부터 (32-3)만큼 떨어짐 + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + QRCodeText( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 29.dp), + ) + } + } + + // ScanCornerFrame (고정 크기) + ScanCornerFrame( + modifier = Modifier + .size(304.dp) + .onGloballyPositioned { coordinates -> + frameOffset = coordinates.positionInParent() + frameSize = coordinates.size + }, + color = Color.White, + ) + + // 하단 영역 - weight(274f) + Box( + modifier = Modifier.weight(274f), + contentAlignment = Alignment.TopCenter, + ) { + // Scanner 하단 영역에서부터 (40-3)만큼 떨어짐 + NekiIconButton( + modifier = Modifier + .padding(top = 37.dp) + .size(48.dp) + .clip(CircleShape) + .background( + if (isTorchEnabled) Color.White + else Color.White.copy(alpha = 0.1f), + ), + onClick = onClickTorch, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_qr_light), + contentDescription = null, + tint = if (isTorchEnabled) Color.Black else Color.White, + ) + } + } + } + } +} + +@Composable +private fun QRCodeText(modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = buildAnnotatedString { + withStyle( + SpanStyle( + brush = Brush.linearGradient( + colors = listOf( + NekiTheme.colorScheme.primary500, + NekiTheme.colorScheme.primary100, + ), + ), + ), + ) { + append("QR을 스캔") + } + withStyle(SpanStyle(color = Color.White)) { + append("하면\n보관함에 자동 저장돼요!") + } + }, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 36.sp, + textAlign = TextAlign.Center, + ) +} + +@Preview(showBackground = true) +@Composable +private fun QRScannerContentPreview() { + NekiTheme { + QRScannerContent() + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/ScanCornerFrame.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/ScanCornerFrame.kt new file mode 100644 index 000000000..488fe1ddd --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/component/ScanCornerFrame.kt @@ -0,0 +1,120 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.photo_upload.impl.qrscan.const.QRLayoutConst.FRAME_CORNER_LENGTH +import com.neki.android.feature.photo_upload.impl.qrscan.const.QRLayoutConst.FRAME_CORNER_RADIUS +import com.neki.android.feature.photo_upload.impl.qrscan.const.QRLayoutConst.FRAME_STROKE_WIDTH + +@Composable +internal fun ScanCornerFrame( + modifier: Modifier = Modifier, + color: Color = Color.White, + strokeWidth: Dp = FRAME_STROKE_WIDTH.dp, + cornerRadius: Dp = FRAME_CORNER_RADIUS.dp, + cornerLength: Dp = FRAME_CORNER_LENGTH.dp, +) { + Canvas(modifier = modifier) { + val sw = strokeWidth.toPx() + val r = cornerRadius.toPx() + val len = cornerLength.toPx() + val m = (-strokeWidth / 2).toPx() + + fun drawCornerTL() { + val p = Path().apply { + moveTo(m, m + len) + lineTo(m, m + r) + quadraticTo(m, m, m + r, m) + lineTo(m + len, m) + } + drawPath( + path = p, + color = color, + style = Stroke(width = sw, cap = StrokeCap.Square, join = StrokeJoin.Round), + ) + } + + fun drawCornerTR() { + val w = size.width + val p = Path().apply { + moveTo(w - m, m + len) + lineTo(w - m, m + r) + quadraticTo(w - m, m, w - m - r, m) + lineTo(w - m - len, m) + } + drawPath( + path = p, + color = color, + style = Stroke(sw, cap = StrokeCap.Square, join = StrokeJoin.Round), + ) + } + + fun drawCornerBL() { + val h = size.height + val p = Path().apply { + moveTo(m, h - m - len) + lineTo(m, h - m - r) + quadraticTo(m, h - m, m + r, h - m) + lineTo(m + len, h - m) + } + drawPath( + path = p, + color = color, + style = Stroke(sw, cap = StrokeCap.Square, join = StrokeJoin.Round), + ) + } + + fun drawCornerBR() { + val w = size.width + val h = size.height + val p = Path().apply { + moveTo(w - m, h - m - len) + lineTo(w - m, h - m - r) + quadraticTo(w - m, h - m, w - m - r, h - m) + lineTo(w - m - len, h - m) + } + drawPath( + path = p, + color = color, + style = Stroke(sw, cap = StrokeCap.Square, join = StrokeJoin.Round), + ) + } + + drawCornerTL() + drawCornerTR() + drawCornerBL() + drawCornerBR() + } +} + +@Preview +@Composable +private fun ScanCornerFramePreview() { + NekiTheme { + Box( + modifier = Modifier + .padding(20.dp) + .size(340.dp) + .background(Color(0xFF5A5A66)), + ) { + ScanCornerFrame( + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/const/QRLayoutConst.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/const/QRLayoutConst.kt new file mode 100644 index 000000000..628a00dd3 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/const/QRLayoutConst.kt @@ -0,0 +1,12 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.const + +internal object QRLayoutConst { + internal const val DIM_COLOR = 0xE620252A + + internal const val CUTOUT_RADIUS = 20 + internal const val CUTOUT_WIDTH_FRACTION = 310 / 375f + + internal const val FRAME_STROKE_WIDTH = 6 + internal const val FRAME_CORNER_LENGTH = 53 + internal const val FRAME_CORNER_RADIUS = 28.96 +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/PhotoWebViewClient.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/PhotoWebViewClient.kt new file mode 100644 index 000000000..dbbc554d6 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/PhotoWebViewClient.kt @@ -0,0 +1,37 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.util + +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import com.neki.android.feature.photo_upload.impl.BuildConfig +import timber.log.Timber + +class PhotoWebViewClient( + private val onImageUrlDetected: (String) -> Unit, +) : WebViewClient() { + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + val url = request?.url.toString() + Timber.d(request?.url.toString()) + + when { + // 포토이즘 + url.startsWith(BuildConfig.PHOTOISM_IMAGE_URL) && url.endsWith(BuildConfig.PHOTOISM_IMG_URL_MIME_TYPE) -> { + Timber.d("포토이즘 이미지") + onImageUrlDetected(url) + } + + // 인생네컷 + url.startsWith(BuildConfig.LIFE_FOUR_CUT_IMAGE_URL) && url.endsWith(BuildConfig.LIFE_FOUR_CUT_URL_MIME_TYPE) -> { + Timber.d("인생네컷 이미지") + onImageUrlDetected(url) + } + } + + return super.shouldInterceptRequest(view, request) + } +} diff --git a/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt new file mode 100644 index 000000000..d5f9df675 --- /dev/null +++ b/feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/qrscan/util/QRImageAnalyzer.kt @@ -0,0 +1,70 @@ +package com.neki.android.feature.photo_upload.impl.qrscan.util + +import android.graphics.RectF +import androidx.annotation.OptIn +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import timber.log.Timber + +class QRImageAnalyzer( + private val scanAreaRatio: () -> RectF?, + private val onQRCodeScanned: (String) -> Unit, +) : ImageAnalysis.Analyzer { + + private val qrScannerOptions: BarcodeScannerOptions = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + + private val scanner = BarcodeScanning.getClient(qrScannerOptions) + + @OptIn(ExperimentalGetImage::class) + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image + if (mediaImage != null) { + val inputImage = InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees, + ) + + val rotationDegrees = imageProxy.imageInfo.rotationDegrees + val isRotated = rotationDegrees == 90 || rotationDegrees == 270 + + val rotatedWidth = if (isRotated) imageProxy.height.toFloat() else imageProxy.width.toFloat() + val rotatedHeight = if (isRotated) imageProxy.width.toFloat() else imageProxy.height.toFloat() + + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + if (barcode.valueType == Barcode.TYPE_URL) { + val url = barcode.url?.url ?: continue + val boundingBox = barcode.boundingBox ?: continue + + val centerXRatio = boundingBox.centerX() / rotatedWidth + val centerYRatio = boundingBox.centerY() / rotatedHeight + + val scanArea = scanAreaRatio() + if (scanArea == null || isInScanArea(centerXRatio, centerYRatio, scanArea)) { + onQRCodeScanned(url) + } + } + } + } + .addOnFailureListener { e -> Timber.e(e, "Barcode scanning failed") } + .addOnCompleteListener { imageProxy.close() } + } else { + imageProxy.close() + } + } + + private fun isInScanArea(xRatio: Float, yRatio: Float, scanArea: RectF): Boolean { + return xRatio >= scanArea.left && + xRatio <= scanArea.right && + yRatio >= scanArea.top && + yRatio <= scanArea.bottom + } +} diff --git a/feature/pose/api/.gitignore b/feature/pose/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/pose/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/pose/api/build.gradle.kts b/feature/pose/api/build.gradle.kts new file mode 100644 index 000000000..89699e514 --- /dev/null +++ b/feature/pose/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.neki.android.feature.api) +} + +android { + namespace = "com.neki.android.feature.pose.api" +} diff --git a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt new file mode 100644 index 000000000..15933b8a6 --- /dev/null +++ b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt @@ -0,0 +1,30 @@ +package com.neki.android.feature.pose.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.navigation.Navigator +import kotlinx.serialization.Serializable + +sealed interface PoseNavKey : NavKey { + + @Serializable + data object PoseMain : PoseNavKey + + @Serializable + data class RandomPose(val peopleCount: PeopleCount) : PoseNavKey + + @Serializable + data class PoseDetail(val poseId: Long) : PoseNavKey +} + +fun Navigator.navigateToPose() { + navigate(PoseNavKey.PoseMain) +} + +fun Navigator.navigateToRandomPose(peopleCount: PeopleCount) { + navigate(PoseNavKey.RandomPose(peopleCount)) +} + +fun Navigator.navigateToPoseDetail(poseId: Long) { + navigate(PoseNavKey.PoseDetail(poseId)) +} diff --git a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseResult.kt b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseResult.kt new file mode 100644 index 000000000..a2e93fea8 --- /dev/null +++ b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseResult.kt @@ -0,0 +1,5 @@ +package com.neki.android.feature.pose.api + +sealed interface PoseResult { + data class ScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseResult +} diff --git a/feature/pose/impl/.gitignore b/feature/pose/impl/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/pose/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/pose/impl/build.gradle.kts b/feature/pose/impl/build.gradle.kts new file mode 100644 index 000000000..9e2beb39e --- /dev/null +++ b/feature/pose/impl/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.neki.android.feature.impl) +} + +android { + namespace = "com.neki.android.feature.pose.impl" +} + +dependencies { + implementation(projects.feature.pose.api) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.paging.compose) +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt new file mode 100644 index 000000000..6f004339f --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt @@ -0,0 +1,11 @@ +package com.neki.android.feature.pose.impl.const + +internal object PoseConst { + internal const val INITIAL_POSE_LOAD_COUNT = 4 + internal const val POSE_PREFETCH_THRESHOLD = 3 + internal const val MAXIMUM_RANDOM_POSE_RETRY_COUNT = 7 + + internal const val POSE_LAYOUT_DEFAULT_TOP_PADDING = 12 + internal const val POSE_LAYOUT_BOTTOM_PADDING = 28 + internal const val POSE_LAYOUT_VERTICAL_SPACING = 12 +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt new file mode 100644 index 000000000..65444039e --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt @@ -0,0 +1,23 @@ +package com.neki.android.feature.pose.impl.detail + +import com.neki.android.core.model.Pose + +data class PoseDetailState( + val isLoading: Boolean = false, + val pose: Pose = Pose(), + val committedScrap: Boolean = false, +) + +sealed interface PoseDetailIntent { + data object EnterPoseDetailScreen : PoseDetailIntent + data object ClickBackIcon : PoseDetailIntent + data object ClickScrapIcon : PoseDetailIntent + data class ScrapCommitted(val newScrap: Boolean) : PoseDetailIntent + data class RevertScrap(val originalScrap: Boolean) : PoseDetailIntent +} + +sealed interface PoseDetailSideEffect { + data object NavigateBack : PoseDetailSideEffect + data class ShowToast(val message: String) : PoseDetailSideEffect + data class NotifyScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseDetailSideEffect +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt new file mode 100644 index 000000000..7e2059b8c --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt @@ -0,0 +1,137 @@ +package com.neki.android.feature.pose.impl.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.topbar.BackTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Pose +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.pose.api.PoseResult +import com.neki.android.core.designsystem.R + +@Composable +internal fun PoseDetailRoute( + viewModel: PoseDetailViewModel, + navigateBack: () -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + PoseDetailSideEffect.NavigateBack -> navigateBack() + is PoseDetailSideEffect.ShowToast -> { + nekiToast.showToast(sideEffect.message) + } + is PoseDetailSideEffect.NotifyScrapChanged -> { + resultEventBus.sendResult( + result = PoseResult.ScrapChanged(sideEffect.poseId, sideEffect.isScrapped), + allowDuplicate = false, + ) + } + } + } + + PoseDetailScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +internal fun PoseDetailScreen( + uiState: PoseDetailState = PoseDetailState(), + onIntent: (PoseDetailIntent) -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + BackTitleTopBar( + title = "포즈 상세", + onBack = { onIntent(PoseDetailIntent.ClickBackIcon) }, + ) + AsyncImage( + model = uiState.pose.poseImageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = NekiTheme.colorScheme.gray75, + ) + NekiIconButton( + modifier = Modifier + .align(Alignment.End) + .padding(8.dp), + onClick = { onIntent(PoseDetailIntent.ClickScrapIcon) }, + ) { + Icon( + imageVector = ImageVector.vectorResource( + if (uiState.pose.isScrapped) R.drawable.icon_scrap + else R.drawable.icon_scrap_unselected, + ), + contentDescription = null, + tint = NekiTheme.colorScheme.gray500, + ) + } + } +} + +@DevicePreview +@Composable +private fun PoseDetailScreenPreview() { + NekiTheme { + PoseDetailScreen( + uiState = PoseDetailState( + pose = Pose( + id = 1, + poseImageUrl = "https://picsum.photos/400/600", + isScrapped = false, + ), + ), + ) + } +} + +@DevicePreview +@Composable +private fun PoseDetailScreenScrappedPreview() { + NekiTheme { + PoseDetailScreen( + uiState = PoseDetailState( + pose = Pose( + id = 1, + poseImageUrl = "https://picsum.photos/400/600", + isScrapped = true, + ), + ), + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt new file mode 100644 index 000000000..b7572b0a4 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt @@ -0,0 +1,114 @@ +package com.neki.android.feature.pose.impl.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.common.coroutine.di.ApplicationScope +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import timber.log.Timber + +@OptIn(FlowPreview::class) +@HiltViewModel(assistedFactory = PoseDetailViewModel.Factory::class) +class PoseDetailViewModel @AssistedInject constructor( + @Assisted private val id: Long, + private val poseRepository: PoseRepository, + @ApplicationScope private val applicationScope: CoroutineScope, +) : ViewModel() { + + private val scrapRequests = MutableSharedFlow(extraBufferCapacity = 64) + + @AssistedFactory + interface Factory { + fun create(id: Long): PoseDetailViewModel + } + + val store: MviIntentStore = + mviIntentStore( + initialState = PoseDetailState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(PoseDetailIntent.EnterPoseDetailScreen) }, + ) + + init { + viewModelScope.launch { + scrapRequests + .debounce(500) + .collect { newScrap -> + val committedScrap = store.uiState.value.committedScrap + if (committedScrap != newScrap) { + poseRepository.updateScrap(id, newScrap) + .onSuccess { + Timber.d("updateScrap success") + store.onIntent(PoseDetailIntent.ScrapCommitted(newScrap)) + } + .onFailure { error -> + Timber.e(error, "updateScrap failed") + store.onIntent(PoseDetailIntent.RevertScrap(committedScrap)) + } + } + } + } + } + + private fun onIntent( + intent: PoseDetailIntent, + state: PoseDetailState, + reduce: (PoseDetailState.() -> PoseDetailState) -> Unit, + postSideEffect: (PoseDetailSideEffect) -> Unit, + ) { + when (intent) { + PoseDetailIntent.EnterPoseDetailScreen -> fetchPoseData(reduce) + PoseDetailIntent.ClickBackIcon -> postSideEffect(PoseDetailSideEffect.NavigateBack) + PoseDetailIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) + is PoseDetailIntent.ScrapCommitted -> { + reduce { copy(committedScrap = intent.newScrap) } + postSideEffect(PoseDetailSideEffect.NotifyScrapChanged(id, intent.newScrap)) + } + is PoseDetailIntent.RevertScrap -> reduce { copy(pose = pose.copy(isScrapped = intent.originalScrap)) } + } + } + + private fun handleScrapToggle( + state: PoseDetailState, + reduce: (PoseDetailState.() -> PoseDetailState) -> Unit, + ) { + val newScrapStatus = !state.pose.isScrapped + viewModelScope.launch { scrapRequests.emit(newScrapStatus) } + reduce { copy(pose = pose.copy(isScrapped = newScrapStatus)) } + } + + private fun fetchPoseData(reduce: (PoseDetailState.() -> PoseDetailState) -> Unit) { + viewModelScope.launch { + poseRepository.getPose(poseId = id) + .onSuccess { data -> + reduce { copy(pose = data, committedScrap = data.isScrapped) } + } + .onFailure { error -> + Timber.e(error) + } + } + } + + override fun onCleared() { + super.onCleared() + + val currentScrap = store.uiState.value.pose.isScrapped + val committedScrap = store.uiState.value.committedScrap + + if (currentScrap != committedScrap) { + applicationScope.launch { + poseRepository.updateScrap(id, currentScrap) + } + } + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt new file mode 100644 index 000000000..e1e9754e9 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt @@ -0,0 +1,38 @@ +package com.neki.android.feature.pose.impl.main + +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class PoseState( + val isLoading: Boolean = false, + val selectedPeopleCount: PeopleCount? = null, + val selectedRandomPosePeopleCount: PeopleCount? = null, + val isShowScrappedPose: Boolean = false, + val scrappedPoseList: ImmutableList = persistentListOf(), + val isShowPeopleCountBottomSheet: Boolean = false, + val isShowRandomPosePeopleCountBottomSheet: Boolean = false, +) + +sealed interface PoseIntent { + data object EnterPoseScreen : PoseIntent + data object ClickAlarmIcon : PoseIntent + data object ClickPeopleCountChip : PoseIntent + data object DismissPeopleCountBottomSheet : PoseIntent + data object DismissRandomPosePeopleCountBottomSheet : PoseIntent + data object ClickScrapChip : PoseIntent + data class ClickPoseItem(val item: Pose) : PoseIntent + data class ClickPeopleCountSheetItem(val peopleCount: PeopleCount) : PoseIntent + data object ClickRandomPoseRecommendation : PoseIntent + data class ClickRandomPosePeopleCountSheetItem(val peopleCount: PeopleCount) : PoseIntent + data object ClickRandomPoseBottomSheetSelectButton : PoseIntent + data class ScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseIntent +} + +sealed interface PoseEffect { + data object NavigateToNotification : PoseEffect + data class NavigateToRandomPose(val peopleCount: PeopleCount) : PoseEffect + data class NavigateToPoseDetail(val poseId: Long) : PoseEffect + data class ShowToast(val message: String) : PoseEffect +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt new file mode 100644 index 000000000..e752c235f --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt @@ -0,0 +1,174 @@ +package com.neki.android.feature.pose.impl.main + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.feature.pose.impl.const.PoseConst.POSE_LAYOUT_DEFAULT_TOP_PADDING +import com.neki.android.feature.pose.impl.main.component.FilterBar +import com.neki.android.feature.pose.impl.main.component.PeopleCountBottomSheet +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.feature.pose.impl.main.component.PoseListContent +import com.neki.android.feature.pose.impl.main.component.PoseTopBar +import com.neki.android.feature.pose.impl.main.component.RandomPosePeopleCountBottomSheet +import com.neki.android.feature.pose.impl.main.component.RecommendationChip + +@Composable +internal fun PoseRoute( + viewModel: PoseViewModel = hiltViewModel(), + navigateToPoseDetail: (Long) -> Unit, + navigateToRandomPose: (PeopleCount) -> Unit, + navigateToNotification: () -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val posePagingItems = viewModel.posePagingData.collectAsLazyPagingItems() + val context = LocalContext.current + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + PoseEffect.NavigateToNotification -> navigateToNotification() + is PoseEffect.NavigateToRandomPose -> navigateToRandomPose(sideEffect.peopleCount) + is PoseEffect.NavigateToPoseDetail -> navigateToPoseDetail(sideEffect.poseId) + is PoseEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + + PoseScreen( + uiState = uiState, + posePagingItems = posePagingItems, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun PoseScreen( + uiState: PoseState = PoseState(), + posePagingItems: LazyPagingItems, + onIntent: (PoseIntent) -> Unit = {}, +) { + val isRefreshing by remember { + derivedStateOf { + posePagingItems.loadState.refresh is LoadState.Loading && posePagingItems.itemCount == 0 + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + PoseContent( + selectedPeopleCount = uiState.selectedPeopleCount, + isScrapSelected = uiState.isShowScrappedPose, + posePagingItems = posePagingItems, + onClickAlarmIcon = { onIntent(PoseIntent.ClickAlarmIcon) }, + onClickPeopleCount = { onIntent(PoseIntent.ClickPeopleCountChip) }, + onClickScrap = { onIntent(PoseIntent.ClickScrapChip) }, + onClickPoseItem = { onIntent(PoseIntent.ClickPoseItem(it)) }, + ) + + RecommendationChip( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), + onClick = { onIntent(PoseIntent.ClickRandomPoseRecommendation) }, + ) + } + + if (isRefreshing) { + LoadingDialog() + } + + if (uiState.isShowPeopleCountBottomSheet) { + PeopleCountBottomSheet( + selectedItem = uiState.selectedPeopleCount, + onDismissRequest = { onIntent(PoseIntent.DismissPeopleCountBottomSheet) }, + onClickItem = { onIntent(PoseIntent.ClickPeopleCountSheetItem(it)) }, + ) + } + + if (uiState.isShowRandomPosePeopleCountBottomSheet) { + RandomPosePeopleCountBottomSheet( + selectedCount = uiState.selectedRandomPosePeopleCount, + onDismissRequest = { onIntent(PoseIntent.DismissRandomPosePeopleCountBottomSheet) }, + onOptionSelected = { onIntent(PoseIntent.ClickRandomPosePeopleCountSheetItem(it)) }, + onClickSelectButton = { onIntent(PoseIntent.ClickRandomPoseBottomSheetSelectButton) }, + ) + } +} + +@Composable +fun PoseContent( + modifier: Modifier = Modifier, + selectedPeopleCount: PeopleCount?, + isScrapSelected: Boolean, + posePagingItems: LazyPagingItems, + onClickAlarmIcon: () -> Unit = {}, + onClickPeopleCount: () -> Unit = {}, + onClickScrap: () -> Unit = {}, + onClickPoseItem: (Pose) -> Unit = {}, +) { + val lazyState = rememberLazyStaggeredGridState() + val density = LocalDensity.current + var filterBarHeightPx by remember { mutableIntStateOf(0) } + val topPadding = with(density) { filterBarHeightPx.toDp() } + val showFilterBar by remember { + derivedStateOf { + !lazyState.canScrollBackward || + lazyState.lastScrolledBackward + } + } + + Column( + modifier = modifier.fillMaxSize(), + ) { + PoseTopBar( + onClickIcon = onClickAlarmIcon, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + PoseListContent( + topPadding = topPadding + POSE_LAYOUT_DEFAULT_TOP_PADDING.dp, + posePagingItems = posePagingItems, + state = lazyState, + onClickItem = onClickPoseItem, + ) + FilterBar( + modifier = Modifier + .onSizeChanged { size -> + if (filterBarHeightPx != 0) return@onSizeChanged + else filterBarHeightPx = size.height + }, + peopleCount = selectedPeopleCount, + isScrapSelected = isScrapSelected, + visible = showFilterBar, + onClickPeopleCount = onClickPeopleCount, + onClickScrap = onClickScrap, + ) + } + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt new file mode 100644 index 000000000..ac0d30a90 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt @@ -0,0 +1,141 @@ +package com.neki.android.feature.pose.impl.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +internal class PoseViewModel @Inject constructor( + private val poseRepository: PoseRepository, +) : ViewModel() { + + private val _headCountFilter = MutableStateFlow(null) + private val _isScrapOnly = MutableStateFlow(false) + private val updatedScraps = MutableStateFlow>(emptyMap()) + + @OptIn(ExperimentalCoroutinesApi::class) + private val originalPagingData: Flow> = combine( + _headCountFilter, + _isScrapOnly, + ) { headCount, isScrapOnly -> + headCount to isScrapOnly + }.flatMapLatest { (headCount, isScrapOnly) -> + if (isScrapOnly) { + poseRepository.getScrappedPosesFlow() + } else { + poseRepository.getPosesFlow( + headCount = headCount, + sortOrder = SortOrder.DESC, + ) + } + }.cachedIn(viewModelScope) + + val posePagingData: Flow> = combine( + originalPagingData, + updatedScraps, + ) { pagingData, scraps -> + pagingData.map { pose -> + scraps[pose.id]?.let { isScrapped -> + pose.copy(isScrapped = isScrapped) + } ?: pose + } + } + + val store: MviIntentStore = + mviIntentStore( + initialState = PoseState(), + onIntent = ::onIntent, + ) + + private fun onIntent( + intent: PoseIntent, + state: PoseState, + reduce: (PoseState.() -> PoseState) -> Unit, + postSideEffect: (PoseEffect) -> Unit, + ) { + when (intent) { + // Pose Main + PoseIntent.EnterPoseScreen -> Unit + PoseIntent.ClickAlarmIcon -> postSideEffect(PoseEffect.NavigateToNotification) + PoseIntent.ClickPeopleCountChip -> reduce { copy(isShowPeopleCountBottomSheet = true) } + is PoseIntent.ClickPeopleCountSheetItem -> handlePeopleCountSheetItem(intent, state, reduce) + PoseIntent.DismissPeopleCountBottomSheet -> reduce { copy(isShowPeopleCountBottomSheet = false) } + PoseIntent.DismissRandomPosePeopleCountBottomSheet -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = false) } + PoseIntent.ClickScrapChip -> { + val newValue = !state.isShowScrappedPose + _isScrapOnly.value = newValue + _headCountFilter.value = null + reduce { + copy( + isShowScrappedPose = newValue, + selectedPeopleCount = null, + ) + } + } + + is PoseIntent.ClickPoseItem -> { + postSideEffect(PoseEffect.NavigateToPoseDetail(intent.item.id)) + } + + PoseIntent.ClickRandomPoseRecommendation -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = true) } + is PoseIntent.ClickRandomPosePeopleCountSheetItem -> reduce { copy(selectedRandomPosePeopleCount = intent.peopleCount) } + PoseIntent.ClickRandomPoseBottomSheetSelectButton -> { + val selectedCount = state.selectedRandomPosePeopleCount ?: return + reduce { + copy( + selectedRandomPosePeopleCount = null, + isShowRandomPosePeopleCountBottomSheet = false, + ) + } + postSideEffect(PoseEffect.NavigateToRandomPose(selectedCount)) + } + + is PoseIntent.ScrapChanged -> { + updatedScraps.update { it + (intent.poseId to intent.isScrapped) } + } + } + } + + private fun handlePeopleCountSheetItem( + intent: PoseIntent.ClickPeopleCountSheetItem, + state: PoseState, + reduce: (PoseState.() -> PoseState) -> Unit, + ) { + _isScrapOnly.value = false + if (intent.peopleCount == state.selectedPeopleCount) { + _headCountFilter.value = null + reduce { + copy( + isShowScrappedPose = false, + isShowPeopleCountBottomSheet = false, + selectedPeopleCount = null, + ) + } + } else { + _headCountFilter.value = intent.peopleCount + reduce { + copy( + isShowScrappedPose = false, + selectedPeopleCount = intent.peopleCount.takeIf { it != state.selectedPeopleCount }, + isShowPeopleCountBottomSheet = false, + ) + } + } + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/FilterBar.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/FilterBar.kt new file mode 100644 index 000000000..621ff146a --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/FilterBar.kt @@ -0,0 +1,127 @@ +package com.neki.android.feature.pose.impl.main.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.PeopleCount + +@Composable +internal fun FilterBar( + peopleCount: PeopleCount?, + isScrapSelected: Boolean, + modifier: Modifier = Modifier, + visible: Boolean = true, + onClickPeopleCount: () -> Unit = {}, + onClickScrap: () -> Unit = {}, +) { + AnimatedVisibility( + modifier = modifier + .background(NekiTheme.colorScheme.white) + .fillMaxWidth(), + visible = visible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PeopleCountFilter( + peopleCount = peopleCount, + onClick = onClickPeopleCount, + ) + ScrapFilter( + isSelected = isScrapSelected, + onClick = onClickScrap, + ) + } + } +} + +@Composable +private fun PeopleCountFilter( + modifier: Modifier = Modifier, + peopleCount: PeopleCount? = null, + onClick: () -> Unit = {}, +) { + val isSelected by remember(peopleCount) { derivedStateOf { peopleCount != null } } + Row( + modifier = modifier + .background( + shape = CircleShape, + color = if (isSelected) NekiTheme.colorScheme.gray800 + else NekiTheme.colorScheme.gray50, + ) + .padding(vertical = 7.dp, horizontal = 12.dp) + .noRippleClickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = peopleCount?.displayText ?: "인원수", + style = NekiTheme.typography.body14Medium, + color = if (isSelected) NekiTheme.colorScheme.white + else NekiTheme.colorScheme.gray700, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_down), + contentDescription = null, + tint = NekiTheme.colorScheme.gray400, + ) + } +} + +@Composable +private fun ScrapFilter( + modifier: Modifier = Modifier, + isSelected: Boolean = false, + onClick: () -> Unit = {}, +) { + Text( + modifier = modifier + .background( + shape = CircleShape, + color = if (isSelected) NekiTheme.colorScheme.gray800 + else NekiTheme.colorScheme.gray50, + ) + .padding(vertical = 7.dp, horizontal = 12.dp) + .noRippleClickable(onClick = onClick), + text = "스크랩", + style = NekiTheme.typography.body14Medium, + color = if (isSelected) NekiTheme.colorScheme.white + else NekiTheme.colorScheme.gray700, + ) +} + +@ComponentPreview +@Composable +private fun FilterBarPreview() { + NekiTheme { + FilterBar( + peopleCount = null, + isScrapSelected = false, + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt new file mode 100644 index 000000000..9ee314fd8 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt @@ -0,0 +1,115 @@ +package com.neki.android.feature.pose.impl.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.bottomsheet.BottomSheetDragHandle +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.ui.compose.VerticalSpacer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PeopleCountBottomSheet( + modifier: Modifier = Modifier, + selectedItem: PeopleCount? = null, + onDismissRequest: () -> Unit = {}, + onClickItem: (PeopleCount) -> Unit = {}, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + containerColor = NekiTheme.colorScheme.white, + dragHandle = { BottomSheetDragHandle() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + PeopleCountBottomSheetContent( + selectedItem = selectedItem, + onClickItem = onClickItem, + ) + } +} + +@Composable +private fun PeopleCountBottomSheetContent( + modifier: Modifier = Modifier, + selectedItem: PeopleCount? = null, + onClickItem: (PeopleCount) -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = "인원 수", + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + VerticalSpacer(16.dp) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PeopleCount.entries.forEach { item -> + Row( + modifier = Modifier.clickableSingle { onClickItem(item) }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item == selectedItem) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_check), + contentDescription = null, + tint = NekiTheme.colorScheme.primary500, + ) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = item.displayText, + style = if (item == selectedItem) NekiTheme.typography.body16SemiBold else NekiTheme.typography.body16Medium, + color = if (item == selectedItem) NekiTheme.colorScheme.gray900 else NekiTheme.colorScheme.gray500, + ) + } + } + } + } +} + +@ComponentPreview +@Composable +private fun PeopleCountBottomSheetContentPreview() { + NekiTheme { + PeopleCountBottomSheetContent( + selectedItem = PeopleCount.TWO, + ) + } +} + +@ComponentPreview +@Composable +private fun PeopleCountBottomSheetPreview() { + NekiTheme { + PeopleCountBottomSheet( + selectedItem = PeopleCount.TWO, + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt new file mode 100644 index 000000000..383c76065 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt @@ -0,0 +1,116 @@ +package com.neki.android.feature.pose.impl.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.modifier.poseBackground +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Pose +import com.neki.android.core.designsystem.R +import com.neki.android.feature.pose.impl.const.PoseConst.POSE_LAYOUT_BOTTOM_PADDING +import com.neki.android.feature.pose.impl.const.PoseConst.POSE_LAYOUT_VERTICAL_SPACING + +@Composable +internal fun PoseListContent( + topPadding: Dp, + modifier: Modifier = Modifier, + posePagingItems: LazyPagingItems, + state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + onClickItem: (Pose) -> Unit = {}, +) { + LazyVerticalStaggeredGrid( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + columns = StaggeredGridCells.Fixed(2), + state = state, + contentPadding = PaddingValues(top = topPadding, bottom = POSE_LAYOUT_BOTTOM_PADDING.dp), + verticalItemSpacing = POSE_LAYOUT_VERTICAL_SPACING.dp, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = posePagingItems.itemCount, + key = posePagingItems.itemKey { it.id }, + ) { index -> + posePagingItems[index]?.let { pose -> + PoseItem( + pose = pose, + onClickItem = onClickItem, + ) + } + } + } +} + +@Composable +private fun PoseItem( + pose: Pose, + modifier: Modifier = Modifier, + onClickItem: (Pose) -> Unit = {}, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .noRippleClickable { onClickItem(pose) }, + ) { + AsyncImage( + modifier = Modifier.fillMaxWidth(), + model = pose.poseImageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + ) + Box( + modifier = Modifier + .matchParentSize() + .poseBackground(shape = RectangleShape), + ) + if (pose.isScrapped) { + Icon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 10.dp, end = 10.dp) + .size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_scrap), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } +} + +@ComponentPreview +@Composable +private fun PoseItemPreview() { + NekiTheme { + PoseItem( + pose = Pose( + id = 1, + poseImageUrl = "", + ), + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseTopBar.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseTopBar.kt new file mode 100644 index 000000000..3e3d10516 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseTopBar.kt @@ -0,0 +1,57 @@ +package com.neki.android.feature.pose.impl.main.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.topbar.NekiLeftTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun PoseTopBar( + modifier: Modifier = Modifier, + onClickIcon: () -> Unit = {}, +) { + NekiLeftTitleTopBar( + modifier = modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp), + title = { + Text( + text = "포즈", + style = NekiTheme.typography.title20SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + }, + actions = { + NekiIconButton( + onClick = onClickIcon, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_bell), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + ) +} + +@ComponentPreview +@Composable +private fun SubTitleTopBarPreview() { + NekiTheme { + PoseTopBar() + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RandomPosePeopleCountBottomSheet.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RandomPosePeopleCountBottomSheet.kt new file mode 100644 index 000000000..9aa097dc8 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RandomPosePeopleCountBottomSheet.kt @@ -0,0 +1,39 @@ +package com.neki.android.feature.pose.impl.main.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.ui.component.DoubleButtonOptionBottomSheet +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RandomPosePeopleCountBottomSheet( + selectedCount: PeopleCount? = null, + onDismissRequest: () -> Unit = {}, + onOptionSelected: (PeopleCount) -> Unit = {}, + onClickSelectButton: () -> Unit = {}, +) { + DoubleButtonOptionBottomSheet( + title = "랜덤 포즈 추천을 위해\n촬영 중인 인원수를 선택해주세요", + options = PeopleCount.entries.toImmutableList(), + selectedOption = selectedCount, + primaryButtonText = "선택하기", + secondaryButtonText = "취소", + onDismissRequest = onDismissRequest, + onClickSecondaryButton = onDismissRequest, + onClickPrimaryButton = onClickSelectButton, + onOptionSelect = onOptionSelected, + buttonEnabled = selectedCount != null, + ) +} + +@ComponentPreview +@Composable +private fun RandomPosePeopleCountBottomSheetPreview() { + NekiTheme { + RandomPosePeopleCountBottomSheet() + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt new file mode 100644 index 000000000..ad1e724d7 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt @@ -0,0 +1,56 @@ +package com.neki.android.feature.pose.impl.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.designsystem.R + +@Composable +internal fun RecommendationChip( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .clip(CircleShape) + .clickableSingle(onClick = onClick) + .background(shape = CircleShape, color = NekiTheme.colorScheme.gray800) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_repeat_recommendation), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + text = "랜덤 포즈 추천", + style = NekiTheme.typography.title18SemiBold, + color = NekiTheme.colorScheme.white, + ) + } +} + +@ComponentPreview +@Composable +private fun RecommendationChipPreview() { + NekiTheme { + RecommendationChip(modifier = Modifier.padding(8.dp)) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt new file mode 100644 index 000000000..7c338421f --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt @@ -0,0 +1,81 @@ +package com.neki.android.feature.pose.impl.navigation + +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.EntryProviderInstaller +import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.core.navigation.result.ResultEffect +import com.neki.android.feature.pose.api.PoseNavKey +import com.neki.android.feature.pose.api.PoseResult +import com.neki.android.feature.pose.api.navigateToPoseDetail +import com.neki.android.feature.pose.api.navigateToRandomPose +import com.neki.android.feature.pose.impl.detail.PoseDetailRoute +import com.neki.android.feature.pose.impl.detail.PoseDetailViewModel +import com.neki.android.feature.pose.impl.main.PoseIntent +import com.neki.android.feature.pose.impl.main.PoseRoute +import com.neki.android.feature.pose.impl.main.PoseViewModel +import com.neki.android.feature.pose.impl.random.RandomPoseRoute +import com.neki.android.feature.pose.impl.random.RandomPoseViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object PoseEntryProviderModule { + + @IntoSet + @Provides + fun providePoseEntryBuilder(navigator: Navigator): EntryProviderInstaller = { + poseEntry(navigator) + } +} + +private fun EntryProviderScope.poseEntry(navigator: Navigator) { + entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + + ResultEffect(resultBus) { result -> + when (result) { + is PoseResult.ScrapChanged -> { + viewModel.store.onIntent(PoseIntent.ScrapChanged(result.poseId, result.isScrapped)) + } + } + } + + PoseRoute( + viewModel = viewModel, + navigateToPoseDetail = navigator::navigateToPoseDetail, + navigateToRandomPose = navigator::navigateToRandomPose, + navigateToNotification = {}, + ) + } + + entry { key -> + PoseDetailRoute( + viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key.poseId) + }, + ), + navigateBack = navigator::goBack, + ) + } + + entry { key -> + RandomPoseRoute( + viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key.peopleCount) + }, + ), + navigateBack = navigator::goBack, + navigateToPoseDetail = navigator::navigateToPoseDetail, + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt new file mode 100644 index 000000000..27e34861b --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt @@ -0,0 +1,43 @@ +package com.neki.android.feature.pose.impl.random + +import com.neki.android.core.model.Pose +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class RandomPoseUiState( + val isLoading: Boolean = false, + val isShowTutorial: Boolean = true, + val currentIndex: Int = 0, + val poseList: ImmutableList = persistentListOf(), + val committedScraps: Map = emptyMap(), +) { + val currentPose: Pose? + get() = poseList.getOrNull(currentIndex) + + val hasPrevious: Boolean + get() = currentIndex > 0 + + val randomPoseIds: Set + get() = poseList.map { it.id }.toSet() +} + +sealed interface RandomPoseIntent { + data object EnterRandomPoseScreen : RandomPoseIntent + + // 튜토리얼 + data object ClickStartRandomPose : RandomPoseIntent + + // 기본화면 + data object ClickCloseIcon : RandomPoseIntent + data object ClickGoToDetailIcon : RandomPoseIntent + data object ClickScrapIcon : RandomPoseIntent + data object ClickLeftSwipe : RandomPoseIntent + data object ClickRightSwipe : RandomPoseIntent +} + +sealed interface RandomPoseEffect { + data object NavigateBack : RandomPoseEffect + data class NavigateToDetail(val poseId: Long) : RandomPoseEffect + data class SwipePoseImage(val index: Int) : RandomPoseEffect + data class ShowToast(val message: String) : RandomPoseEffect +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt new file mode 100644 index 000000000..2ef4f7f47 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt @@ -0,0 +1,135 @@ +package com.neki.android.feature.pose.impl.random + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.topbar.CloseTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast +import com.neki.android.feature.pose.impl.random.component.RandomPoseFloatingBarContent +import com.neki.android.feature.pose.impl.random.component.RandomPoseImagePager +import com.neki.android.feature.pose.impl.random.component.RandomPoseTutorialOverlay +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun RandomPoseRoute( + viewModel: RandomPoseViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToPoseDetail: (Long) -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + val pagerState = rememberPagerState { uiState.poseList.size } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + RandomPoseEffect.NavigateBack -> navigateBack() + is RandomPoseEffect.NavigateToDetail -> navigateToPoseDetail(sideEffect.poseId) + is RandomPoseEffect.SwipePoseImage -> pagerState.animateScrollToPage( + page = sideEffect.index, + animationSpec = tween(durationMillis = 500), + ) + + is RandomPoseEffect.ShowToast -> nekiToast.showToast(sideEffect.message) + } + } + + RandomPoseScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + pagerState = pagerState, + ) +} + +@Composable +internal fun RandomPoseScreen( + uiState: RandomPoseUiState = RandomPoseUiState(), + onIntent: (RandomPoseIntent) -> Unit = {}, + pagerState: PagerState = rememberPagerState { uiState.poseList.size }, +) { + val hazeState = rememberHazeState() + + Box( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.gray50), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .hazeSource(state = hazeState), + ) { + CloseTitleTopBar( + title = "랜덤포즈", + onClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, + ) + VerticalSpacer(42.dp) + + RandomPoseImagePager( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + poseList = uiState.poseList, + pagerState = pagerState, + onLeftSwipe = { onIntent(RandomPoseIntent.ClickLeftSwipe) }, + onRightSwipe = { onIntent(RandomPoseIntent.ClickRightSwipe) }, + ) + + uiState.currentPose?.let { pose -> + RandomPoseFloatingBarContent( + modifier = Modifier.fillMaxWidth(), + isScrapped = pose.isScrapped, + onClickClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, + onClickGoToDetail = { onIntent(RandomPoseIntent.ClickGoToDetailIcon) }, + onClickScrap = { onIntent(RandomPoseIntent.ClickScrapIcon) }, + ) + } + VerticalSpacer(4.dp) + } + + if (uiState.isShowTutorial) { + RandomPoseTutorialOverlay( + hazeState = hazeState, + onClickStart = { onIntent(RandomPoseIntent.ClickStartRandomPose) }, + ) + } + } +} + +@DevicePreview +@Composable +private fun RandomPoseScreenPreview() { + NekiTheme { + RandomPoseScreen( + uiState = RandomPoseUiState(isShowTutorial = false), + ) + } +} + +@DevicePreview +@Composable +private fun RandomPoseScreenTutorialPreview() { + NekiTheme { + RandomPoseScreen( + uiState = RandomPoseUiState(isShowTutorial = true), + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt new file mode 100644 index 000000000..24d50dbd1 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -0,0 +1,217 @@ +package com.neki.android.feature.pose.impl.random + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.common.coroutine.di.ApplicationScope +import com.neki.android.core.common.exception.RandomPoseRetryExhaustedException +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.pose.impl.const.PoseConst +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel(assistedFactory = RandomPoseViewModel.Factory::class) +internal class RandomPoseViewModel @AssistedInject constructor( + @Assisted private val peopleCount: PeopleCount, + private val poseRepository: PoseRepository, + @ApplicationScope private val applicationScope: CoroutineScope, +) : ViewModel() { + + private val scrapJobs = mutableMapOf() + + @AssistedFactory + interface Factory { + fun create(peopleCount: PeopleCount): RandomPoseViewModel + } + + val store: MviIntentStore = + mviIntentStore( + initialState = RandomPoseUiState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(RandomPoseIntent.EnterRandomPoseScreen) }, + ) + + private fun onIntent( + intent: RandomPoseIntent, + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + when (intent) { + RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialPoses(reduce, postSideEffect) + + // 튜토리얼 + RandomPoseIntent.ClickLeftSwipe -> handleMovePrevious(state, reduce, postSideEffect) + RandomPoseIntent.ClickRightSwipe -> handleMoveNext(state, reduce, postSideEffect) + RandomPoseIntent.ClickStartRandomPose -> reduce { copy(isShowTutorial = false) } + + // 기본화면 + RandomPoseIntent.ClickCloseIcon -> postSideEffect(RandomPoseEffect.NavigateBack) + RandomPoseIntent.ClickGoToDetailIcon -> { + state.currentPose?.let { pose -> + postSideEffect(RandomPoseEffect.NavigateToDetail(pose.id)) + } + } + + RandomPoseIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) + } + } + + private fun handleScrapToggle( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + ) { + state.currentPose?.let { currentPose -> + val poseId = currentPose.id + val newScrapStatus = !currentPose.isScrapped + + // UI 즉시 업데이트 + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = newScrapStatus) + } else { + pose + } + }.toImmutableList(), + ) + } + + // 해당 포즈의 이전 Job 취소 후 새로운 Job 시작 + scrapJobs[poseId]?.cancel() + scrapJobs[poseId] = viewModelScope.launch { + delay(500) + val committedScrap = store.uiState.value.committedScraps[poseId] + if (committedScrap == newScrapStatus || committedScrap == null) return@launch + + poseRepository.updateScrap(poseId, newScrapStatus) + .onSuccess { + Timber.d("updateScrap success for poseId: $poseId") + reduce { + copy(committedScraps = committedScraps + (poseId to newScrapStatus)) + } + } + .onFailure { error -> + Timber.e(error, "updateScrap failed for poseId: $poseId") + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = committedScrap) + } else { + pose + } + }.toImmutableList(), + ) + } + } + } + } + } + + private fun handleMovePrevious( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + if (state.hasPrevious) { + val previousIndex = state.currentIndex - 1 + reduce { copy(currentIndex = previousIndex) } + postSideEffect(RandomPoseEffect.SwipePoseImage(previousIndex)) + } else { + postSideEffect(RandomPoseEffect.ShowToast("첫번째 포즈입니다.")) + } + } + + private fun handleMoveNext( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + // 마지막 인덱스에 도달 + if (state.currentIndex >= state.poseList.lastIndex) { + postSideEffect(RandomPoseEffect.ShowToast("모든 포즈를 불러왔어요")) + return + } + + val nextIndex = state.currentIndex + 1 + reduce { copy(currentIndex = nextIndex) } + postSideEffect(RandomPoseEffect.SwipePoseImage(nextIndex)) + + // 여분 포즈가 POSE_PREFETCH_THRESHOLD 이하이면 다음 포즈 미리 캐싱 + if (state.poseList.lastIndex - nextIndex < PoseConst.POSE_PREFETCH_THRESHOLD) { + viewModelScope.launch { + poseRepository.getSingleRandomPose( + headCount = peopleCount, + excludeIds = state.randomPoseIds, + maxRetry = PoseConst.MAXIMUM_RANDOM_POSE_RETRY_COUNT, + ).onSuccess { pose -> + reduce { copy(poseList = (poseList + pose).toImmutableList()) } + }.onFailure { error -> + if (error is RandomPoseRetryExhaustedException) + Timber.e(error, "중복 포즈") + else Timber.e(error) + } + } + } + } + + private fun fetchInitialPoses( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + // 초기에 INITIAL_POSE_LOAD_COUNT개 로드 + poseRepository.getMultipleRandomPose( + headCount = peopleCount, + excludeIds = emptySet(), + poseSize = PoseConst.INITIAL_POSE_LOAD_COUNT, + maxRetry = PoseConst.MAXIMUM_RANDOM_POSE_RETRY_COUNT, + ).onSuccess { data -> + reduce { + copy( + isLoading = false, + poseList = data.toImmutableList(), + committedScraps = data.associate { it.id to it.isScrapped }, + currentIndex = 0, + ) + } + }.onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + if (error is RandomPoseRetryExhaustedException) + Timber.e(error, "중복 포즈") + else Timber.e(error) + } + } + } + + override fun onCleared() { + super.onCleared() + + val state = store.uiState.value + state.poseList.forEach { pose -> + val currentScrap = pose.isScrapped + val committedScrap = state.committedScraps[pose.id] + + if (committedScrap != null && currentScrap != committedScrap) { + applicationScope.launch { + poseRepository.updateScrap(pose.id, currentScrap) + } + } + } + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt new file mode 100644 index 000000000..9cf25d328 --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt @@ -0,0 +1,217 @@ +package com.neki.android.feature.pose.impl.random.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.button.NekiIconButton +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.designsystem.R + +@Composable +internal fun RandomPoseFloatingBarContent( + modifier: Modifier = Modifier, + isScrapped: Boolean = false, + onClickClose: () -> Unit = {}, + onClickGoToDetail: () -> Unit = {}, + onClickScrap: () -> Unit = {}, +) { + Box( + modifier = modifier, + ) { + RandomPoseFloatingBarBackground( + modifier = Modifier.matchParentSize(), + ) + RandomPoseFloatingBar( + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 38.dp, bottom = 34.dp), + isScrapped = isScrapped, + onClickClose = onClickClose, + onClickGoToDetail = onClickGoToDetail, + onClickScrap = onClickScrap, + ) + } +} + +@Composable +private fun RandomPoseFloatingBarBackground( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background( + brush = Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.primary400.copy(alpha = 0f), + NekiTheme.colorScheme.primary400, + ), + ), + alpha = 0.24f, + ), + ) +} + +@Composable +private fun RandomPoseFloatingBar( + modifier: Modifier = Modifier, + isScrapped: Boolean = false, + onClickClose: () -> Unit = {}, + onClickGoToDetail: () -> Unit = {}, + onClickScrap: () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(CircleShape) + .background( + color = NekiTheme.colorScheme.white.copy(alpha = 0.6f), + shape = CircleShape, + ) + .border( + width = 1.dp, + brush = Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.white, + NekiTheme.colorScheme.white.copy(alpha = 0f), + ), + ), + shape = CircleShape, + ) + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + RandomPoseButton( + onClick = onClickClose, + backgroundColor = NekiTheme.colorScheme.gray25.copy(alpha = 0.9f), + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + contentDescription = "닫기", + tint = NekiTheme.colorScheme.gray800, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RandomPoseButton( + onClick = onClickGoToDetail, + backgroundColor = NekiTheme.colorScheme.primary400, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_top_right), + contentDescription = "상세 보기", + tint = NekiTheme.colorScheme.white, + ) + } + + RandomPoseButton( + onClick = onClickScrap, + backgroundColor = NekiTheme.colorScheme.primary400, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource( + if (isScrapped) R.drawable.icon_scrap + else R.drawable.icon_scrap_unselected, + ), + contentDescription = "스크랩", + tint = NekiTheme.colorScheme.white, + ) + } + } + } +} + +@Composable +private fun RandomPoseButton( + onClick: () -> Unit, + backgroundColor: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + NekiIconButton( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(backgroundColor), + onClick = onClick, + ) { + content() + } +} + +@ComponentPreview +@Composable +private fun RandomPoseFloatingBarBackgroundPreview() { + NekiTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + ) { + RandomPoseFloatingBarBackground( + modifier = Modifier.matchParentSize(), + ) + } + } +} + +@Preview +@Composable +private fun RandomPoseFloatingBarPreview() { + NekiTheme { + RandomPoseFloatingBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } +} + +@ComponentPreview +@Composable +private fun RandomPoseFloatingBarContentPreview() { + NekiTheme { + RandomPoseFloatingBarContent( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } +} + +@ComponentPreview +@Composable +private fun RandomPoseFloatingBarContentScrappedPreview() { + NekiTheme { + RandomPoseFloatingBarContent( + modifier = Modifier + .fillMaxWidth(), + isScrapped = true, + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseImagePager.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseImagePager.kt new file mode 100644 index 000000000..b1652c83d --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseImagePager.kt @@ -0,0 +1,108 @@ +package com.neki.android.feature.pose.impl.random.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.model.Pose +import com.neki.android.feature.pose.impl.const.PoseConst +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun RandomPoseImagePager( + pagerState: PagerState, + poseList: ImmutableList, + modifier: Modifier = Modifier, + onLeftSwipe: () -> Unit = {}, + onRightSwipe: () -> Unit = {}, +) { + HorizontalPager( + modifier = modifier, + state = pagerState, + beyondViewportPageCount = PoseConst.POSE_PREFETCH_THRESHOLD, + userScrollEnabled = false, + ) { index -> + RandomPoseImage( + pose = poseList[index], + onLeftSwipe = onLeftSwipe, + onRightSwipe = onRightSwipe, + ) + } +} + +@Composable +private fun RandomPoseImage( + pose: Pose, + modifier: Modifier = Modifier, + onLeftSwipe: () -> Unit = {}, + onRightSwipe: () -> Unit = {}, +) { + Box( + modifier = modifier.padding(horizontal = 10.dp), + ) { + AsyncImage( + model = pose.poseImageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white), + contentScale = ContentScale.FillWidth, + alignment = Alignment.Center, + ) + Row( + modifier = Modifier.matchParentSize(), + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickableSingle(onClick = onLeftSwipe), + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickableSingle(onClick = onRightSwipe), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RandomPoseImagePagerPreview() { + val poseList = persistentListOf( + Pose(id = 1L, poseImageUrl = "https://example.com/pose1.jpg"), + Pose(id = 2L, poseImageUrl = "https://example.com/pose2.jpg"), + ) + RandomPoseImagePager( + pagerState = rememberPagerState { poseList.size }, + poseList = poseList, + ) +} + +@Preview(showBackground = true) +@Composable +private fun RandomPoseImagePreview() { + RandomPoseImage( + pose = Pose(id = 1L, poseImageUrl = "https://example.com/pose.jpg"), + ) +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseTutorialOverlay.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseTutorialOverlay.kt new file mode 100644 index 000000000..c8952e9db --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseTutorialOverlay.kt @@ -0,0 +1,139 @@ +package com.neki.android.feature.pose.impl.random.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.backgroundHazeBlur +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.HorizontalSpacer +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.pose.impl.R +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun RandomPoseTutorialOverlay( + onClickStart: () -> Unit, + hazeState: HazeState = rememberHazeState(), + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.noRippleClickable {}, // 터치 이벤트 소비용 + ) { + Column( + modifier = Modifier + .backgroundHazeBlur(hazeState) + .fillMaxSize() + .padding(top = 60.dp, bottom = 34.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + HorizontalSpacer(40f) + TutorialGuideItem( + iconRes = R.drawable.icon_seek_before_pose, + label = "이전 포즈", + ) + HorizontalSpacer(45f) + VerticalDashedDivider( + modifier = Modifier.fillMaxHeight(), + ) + HorizontalSpacer(45f) + TutorialGuideItem( + iconRes = R.drawable.icon_seek_after_pose, + label = "다음 포즈", + ) + HorizontalSpacer(40f) + } + VerticalSpacer(20.dp) + + StartRandomPoseButton( + onClick = onClickStart, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.primary400.copy(alpha = 0f), + NekiTheme.colorScheme.primary400, + ), + startY = Float.POSITIVE_INFINITY, + endY = 0f, + ), + alpha = 0.24f, + ), + ) + } +} + +@Composable +private fun TutorialGuideItem( + @DrawableRes iconRes: Int, + label: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + VerticalSpacer(275f) + Icon( + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = Color.Unspecified, + ) + VerticalSpacer(6.dp) + Text( + text = label, + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.white, + ) + VerticalSpacer(243f) + } +} + +@ComponentPreview +@Composable +private fun RandomPoseTutorialOverlayPreview() { + NekiTheme { + val state = rememberHazeState() + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(state), + ) + RandomPoseTutorialOverlay( + hazeState = state, + onClickStart = {}, + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/StartRandomPoseButton.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/StartRandomPoseButton.kt new file mode 100644 index 000000000..7c380521f --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/StartRandomPoseButton.kt @@ -0,0 +1,70 @@ +package com.neki.android.feature.pose.impl.random.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun StartRandomPoseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background( + brush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to NekiTheme.colorScheme.primary400, + 0.53f to NekiTheme.colorScheme.primary600, + 0.96f to Color(0xFFFF334B), + ), + ), + shape = RoundedCornerShape(12.dp), + ) + .border( + brush = Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.primary300, + NekiTheme.colorScheme.primary500, + ), + ), + shape = RoundedCornerShape(12.dp), + width = 1.dp, + ) + .clickableSingle(onClick = onClick) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "랜덤 포즈 시작하기", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.white, + ) + } +} + +@ComponentPreview +@Composable +private fun StartRandomPoseButtonPreview() { + NekiTheme { + StartRandomPoseButton( + onClick = {}, + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/VerticalDashedDivider.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/VerticalDashedDivider.kt new file mode 100644 index 000000000..d300be93b --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/VerticalDashedDivider.kt @@ -0,0 +1,71 @@ +package com.neki.android.feature.pose.impl.random.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush.Companion.linearGradient +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun VerticalDashedDivider( + modifier: Modifier = Modifier, + strokeWidth: Dp = 1.dp, + dashOn: Dp = 5.dp, + dashOff: Dp = 5.dp, +) { + val white = NekiTheme.colorScheme.white + Canvas(modifier = modifier.width(strokeWidth)) { + val w = strokeWidth.toPx() + + val dash = PathEffect.dashPathEffect( + intervals = floatArrayOf(dashOn.toPx(), dashOff.toPx()), + phase = 0f, + ) + + val brush = linearGradient( + colorStops = arrayOf( + 0f to white.copy(alpha = 0f), + 0.5f to white.copy(alpha = 1f), + 1f to white.copy(alpha = 0f), + ), + start = Offset(0f, 0f), + end = Offset(0f, size.height), + ) + + drawLine( + brush = brush, + start = Offset(size.width / 2f, 0f), + end = Offset(size.width / 2f, size.height), + strokeWidth = w, + pathEffect = dash, + ) + } +} + +@ComponentPreview +@Composable +private fun VerticalDashedDividerPreview() { + NekiTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(NekiTheme.colorScheme.gray900.copy(alpha = 0.9f)), + contentAlignment = Alignment.Center, + ) { + VerticalDashedDivider( + modifier = Modifier.fillMaxHeight(0.8f), + ) + } + } +} diff --git a/feature/pose/impl/src/main/res/drawable/icon_seek_after_pose.xml b/feature/pose/impl/src/main/res/drawable/icon_seek_after_pose.xml new file mode 100644 index 000000000..54cd9ca88 --- /dev/null +++ b/feature/pose/impl/src/main/res/drawable/icon_seek_after_pose.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/feature/pose/impl/src/main/res/drawable/icon_seek_before_pose.xml b/feature/pose/impl/src/main/res/drawable/icon_seek_before_pose.xml new file mode 100644 index 000000000..69d2a9393 --- /dev/null +++ b/feature/pose/impl/src/main/res/drawable/icon_seek_before_pose.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/feature/sample/api/build.gradle.kts b/feature/sample/api/build.gradle.kts deleted file mode 100644 index 3b9f860f1..000000000 --- a/feature/sample/api/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - alias(libs.plugins.neki.android.library) -} - -android { - namespace = "com.neki.android.feature.sample.api" -} \ No newline at end of file diff --git a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/MyClass.kt b/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/MyClass.kt deleted file mode 100644 index d1d5f9f94..000000000 --- a/feature/sample/api/src/main/java/com/neki/android/feature/sample/api/MyClass.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.neki.android.feature.sample.api - -class MyClass { -} \ No newline at end of file diff --git a/feature/sample/impl/build.gradle.kts b/feature/sample/impl/build.gradle.kts deleted file mode 100644 index 9f36724af..000000000 --- a/feature/sample/impl/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - alias(libs.plugins.neki.android.feature) -} - -android { - namespace = "com.neki.android.feature.sample.impl" -} - -dependencies { - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.core.ktx) - -} \ No newline at end of file diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainActivity.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainActivity.kt deleted file mode 100644 index 26aa9bcaa..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.neki.android.feature.sample.impl - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.neki.android.core.designsystem.ui.theme.NekiTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - NekiTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } - } - } - - viewModel - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - NekiTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainViewModel.kt b/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainViewModel.kt deleted file mode 100644 index f6ee44861..000000000 --- a/feature/sample/impl/src/main/java/com/neki/android/feature/sample/impl/MainViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.neki.android.feature.sample.impl - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.neki.android.core.dataapi.repository.SampleRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MainViewModel @Inject constructor( - private val sampleRepository: SampleRepository -): ViewModel() { - - init { - getPost(id = 1) - } - - fun getPosts() { - viewModelScope.launch { - sampleRepository.getPosts() - } - } - - fun getPost( - id: Int - ) { - viewModelScope.launch { - sampleRepository.getPost(id = id) - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b17f43a6..cc46f00d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,8 @@ [versions] agp = "8.13.1" annotationExperimental = "1.5.1" +cameraX = "1.5.2" +composeStableMarker = "1.0.7" kotlin = "2.1.0" coreKtx = "1.15.0" junit = "4.13.2" @@ -14,12 +16,34 @@ ksp = "2.1.0-1.0.29" appcompat = "1.7.1" kotlinxSerializationJson = "1.9.0" jetbrainsKotlinJvmVersion = "2.1.0" -hilt = "2.51.1" +hilt = "2.54" ktor = "2.3.12" androidxDatastore = "1.1.2" +kotlinxCoroutines = "1.8.1" +paging = "3.3.6" +kotlinxCollectionsImmutable = "0.4.0" +kotlinxDatetime = "0.7.1" +mapSdk = "3.23.0" +naverMapCompose = "1.9.0" +playServicesLocation = "21.3.0" +securityCrypto = "1.1.0" timber = "5.0.1" +androidxNavigation3 = "1.0.0" +androidxLifecycleViewModel = "2.10.0" +androidxHiltLifecycleViewmodelCompose = "1.3.0-alpha02" +material = "1.13.0" +detekt = "1.23.8" +barcodeScanning = "17.3.0" +kakao = "2.23.1" +coil = "3.3.0" +haze = "1.7.1" +ossLicensesLib = "17.2.1" +ossLicensesPlugin = "0.10.7" [libraries] +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "cameraX" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -35,27 +59,63 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +compose-stable-marker = { module = "com.github.skydoves:compose-stable-marker", version.ref = "composeStableMarker" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "androidxNavigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "androidxNavigation3" } +androidx-lifecycle-viewModel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycleViewModel" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycleViewModel" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycleViewModel" } + +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } + +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } +kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-lifecycle-viewModel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHiltLifecycleViewmodelCompose" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } ktor-client-logging-jvm = { group = "io.ktor", name = "ktor-client-logging-jvm", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } androidx-annotation-experimental = { module = "androidx.annotation:annotation-experimental", version.ref = "annotationExperimental" } androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDatastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +map-sdk = { module = "com.naver.maps:map-sdk", version.ref = "mapSdk" } +naver-map-compose = { module = "io.github.fornewid:naver-map-compose", version.ref = "naverMapCompose" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } + +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" } + +kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" } + +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + +haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } +haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" } + +androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } + +oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicensesLib" } + # Dependencies of the included build-logic kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] # Plugins defined by this project @@ -63,7 +123,8 @@ neki-android-application = { id = "neki.android.application", version = "unspeci neki-android-application-compose = { id = "neki.android.application.compose", version = "unspecified" } neki-android-library = { id = "neki.android.library", version = "unspecified" } neki-android-library-compose = { id = "neki.android.library.compose", version = "unspecified" } -neki-android-feature = { id = "neki.android.feature", version = "unspecified" } +neki-android-feature-impl = { id = "neki.android.feature.impl", version = "unspecified" } +neki-android-feature-api = { id = "neki.android.feature.api", version = "unspecified" } neki-kotlin-library = { id = "neki.kotlin.library", version = "unspecified" } neki-hilt = { id = "neki.hilt", version = "unspecified" } @@ -74,4 +135,6 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvmVersion" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } - +android-library = { id = "com.android.library", version.ref = "agp" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d8bc5d032..65355d1e3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,8 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://devrepo.kakao.com/nexus/content/groups/public/") } + maven("https://repository.map.naver.com/archive/maven") } } @@ -30,5 +32,17 @@ include(":core:domain") include(":core:data") include(":core:data-api") include(":core:model") -include(":feature:sample:api") -include(":feature:sample:impl") +include(":core:navigation") +include(":feature:auth:api") +include(":feature:auth:impl") +include(":feature:pose:api") +include(":feature:pose:impl") +include(":feature:archive:api") +include(":feature:archive:impl") +include(":feature:map:api") +include(":feature:map:impl") +include(":feature:mypage:api") +include(":feature:mypage:impl") +include(":feature:photo-upload:api") +include(":feature:photo-upload:impl") +include(":core:ui")