diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d88adf2..ac214353 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + plugins { alias(libs.plugins.spot.android.application) alias(libs.plugins.kotlin.android) @@ -9,9 +11,12 @@ android { // signingConfigs { // getByName("debug") { -// keyAlias = "androiddebugkey" -// keyPassword = "android" -// storeFile = file("debug.keystore") +// val props = gradleLocalProperties(rootDir, providers) +// +// storeFile = file(props.getProperty("DEBUG_STORE_FILE")) +// storePassword = props.getProperty("DEBUG_STORE_PASSWORD") +// keyAlias = props.getProperty("DEBUG_KEY_ALIAS") +// keyPassword = props.getProperty("DEBUG_KEY_PASSWORD") // } // } @@ -38,8 +43,6 @@ dependencies { implementation(projects.feature.study) implementation(projects.feature.signup) - implementation(projects.domain.home) - implementation(projects.core.ui) implementation(projects.core.network) implementation(projects.core.model) @@ -53,4 +56,13 @@ dependencies { implementation(projects.data.study) implementation(projects.data.alert) implementation(projects.data.board) + implementation(projects.data.user) + implementation(projects.data.login) + + implementation(libs.kakao.common) + implementation(libs.kakao.login) + implementation(libs.kakao.auth) + + implementation(libs.naver.oauth) +// implementation(libs.naver.jdk) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8fc3813f..fde638d1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,5 +31,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/umcspot/spot/SpotApplication.kt b/app/src/main/java/com/umcspot/spot/SpotApplication.kt index 0b442c23..f79c28e0 100644 --- a/app/src/main/java/com/umcspot/spot/SpotApplication.kt +++ b/app/src/main/java/com/umcspot/spot/SpotApplication.kt @@ -1,11 +1,19 @@ package com.umcspot.spot import android.app.Application +import android.util.Log +import com.umcspot.spot.buildconfig.BuildConfig +import com.kakao.sdk.common.KakaoSdk +import com.kakao.sdk.common.util.Utility +import com.navercorp.nid.NidOAuth import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class SpotApplication : Application() { override fun onCreate() { super.onCreate() + + KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_KEY) + NidOAuth.initialize(this, BuildConfig.NAVER_CLIENT_ID, BuildConfig.NAVER_CLIENT_SECRET, BuildConfig.APP_NAME) } } \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/umcspot/spot/convention/BuildConfig.kt b/build-logic/convention/src/main/java/com/umcspot/spot/convention/BuildConfig.kt index 94a5dd54..d34a2da3 100644 --- a/build-logic/convention/src/main/java/com/umcspot/spot/convention/BuildConfig.kt +++ b/build-logic/convention/src/main/java/com/umcspot/spot/convention/BuildConfig.kt @@ -7,19 +7,46 @@ import org.gradle.api.Project internal fun Project.configureBuildConfig( commonExtension: CommonExtension<*, *, *, *, *, *>, ) { + val properties = gradleLocalProperties(rootDir, providers) + + val baseUrl = properties.getProperty("BASE_URL") ?: "" + val kakaoNativeKey = properties.getProperty("KAKAO_NATIVE_KEY") ?: "" + val naverClientId = properties.getProperty("NAVER_CLIENT_ID") ?: "" + val naverClientSecret = properties.getProperty("NAVER_CLIENT_SECRET") ?: "" + val appName = properties.getProperty("APP_NAME") ?: "SPOT" + + commonExtension.apply { defaultConfig { - val properties = gradleLocalProperties(rootDir, providers) buildConfigField( "String", "BASE_URL", - "\"${properties.getProperty("base.url") ?: "https://default-url.com/"}\"" + "\"$baseUrl\"" ) + buildConfigField( "String", "KAKAO_NATIVE_KEY", - "\"${properties.getProperty("kakao.native.key") ?: ""}\"" + "\"$kakaoNativeKey\"" + ) + + buildConfigField( + "String", + "NAVER_CLIENT_ID", + "\"$naverClientId\"" + ) + + buildConfigField( + "String", + "NAVER_CLIENT_SECRET", + "\"$naverClientSecret\"" + ) + + buildConfigField( + "String", + "APP_NAME", + "\"$appName\"" ) } @@ -27,4 +54,4 @@ internal fun Project.configureBuildConfig( buildConfig = true } } -} \ No newline at end of file +} diff --git a/core/buildconfig/build.gradle.kts b/core/buildconfig/build.gradle.kts index 0417476c..0dcb577c 100644 --- a/core/buildconfig/build.gradle.kts +++ b/core/buildconfig/build.gradle.kts @@ -8,6 +8,8 @@ plugins { android { namespace = "com.umcspot.spot.buildconfig" } + + dependencies { implementation(projects.core.common) } \ No newline at end of file diff --git a/core/datastore/src/main/java/com/umcspot/spot/datastore/SpotTokenData.kt b/core/datastore/src/main/java/com/umcspot/spot/datastore/SpotTokenData.kt index e9018ffd..5c7f3aa8 100644 --- a/core/datastore/src/main/java/com/umcspot/spot/datastore/SpotTokenData.kt +++ b/core/datastore/src/main/java/com/umcspot/spot/datastore/SpotTokenData.kt @@ -1,7 +1,6 @@ @file:OptIn(kotlinx.serialization.InternalSerializationApi::class) package com.umcspot.spot.datastore - import kotlinx.serialization.Serializable @Serializable diff --git a/core/datastore/src/main/java/com/umcspot/spot/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/umcspot/spot/datastore/di/DataStoreModule.kt index 2f4b76f4..018d8b82 100644 --- a/core/datastore/src/main/java/com/umcspot/spot/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/umcspot/spot/datastore/di/DataStoreModule.kt @@ -1,11 +1,9 @@ -package com.umcspot.spot.datastore.di +package com.umcspot.spot.datastore import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile -import com.umcspot.spot.datastore.SpotSecureDataStoreSerializer -import com.umcspot.spot.datastore.SpotTokenData import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,17 +14,16 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DataStoreModule { + @Provides @Singleton - fun providesDataStore( + fun provideSpotTokenDataStore( @ApplicationContext context: Context, - spotSecureDataStoreSerializer: SpotSecureDataStoreSerializer - ): DataStore = - DataStoreFactory.create( - serializer = spotSecureDataStoreSerializer - ) { - context.dataStoreFile(DATASTORE_PREFERENCES) - } - - private const val DATASTORE_PREFERENCES = "com.umcspot.spot.datastore" -} \ No newline at end of file + serializer: SpotSecureDataStoreSerializer + ): DataStore { + return DataStoreFactory.create( + serializer = serializer, + produceFile = { context.dataStoreFile("spot_tokens.secure") } + ) + } +} diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/GageBar.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/GageBar.kt index ffd71329..1dfd6967 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/GageBar.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/GageBar.kt @@ -34,7 +34,7 @@ import com.umcspot.spot.designsystem.theme.* * @param borderColor 테두리 색 */ @Composable -fun GaugeBar( +fun GageBar( value: Float, modifier: Modifier = Modifier, height: Dp = 12.dp, @@ -77,7 +77,7 @@ fun GaugeBar( @Composable fun Preview_GaugeBar_15() { Column(modifier = Modifier.padding(16.dp)) { - GaugeBar( + GageBar( value = 0.15f, // 퍼센트 조절 가능 height = 12.dp, trackColor = SpotTheme.colors.G100, @@ -90,7 +90,7 @@ fun Preview_GaugeBar_15() { @Composable fun Preview_GaugeBar_75() { Column(modifier = Modifier.padding(16.dp)) { - GaugeBar( + GageBar( value = 0.75f, // 퍼센트 조절 가능 height = 12.dp, trackColor = SpotTheme.colors.G100, diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt index a60eda10..1a48b81d 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt @@ -2,65 +2,60 @@ package com.umcspot.spot.designsystem.component.study.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.MultiButton import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.ui.extension.screenHeightDp import com.umcspot.spot.ui.extension.screenWidthDp -import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.component.button.MultiButton import kotlinx.collections.immutable.ImmutableList @Composable fun ActivityThemeSection( - activityTheme: StudyTheme?, - onSelect: (StudyTheme) -> Unit + selectedTheme: StudyTheme?, + onSelect: (StudyTheme) -> Unit, + modifier: Modifier = Modifier ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)), - verticalArrangement = Arrangement.spacedBy(screenHeightDp(16.dp)) - ) { - StudyTheme.entries.forEach { theme -> - val iconRes = when (theme) { - StudyTheme.LANGUAGE -> painterResource(R.drawable.language) - StudyTheme.LICENSE -> painterResource(R.drawable.license) - StudyTheme.EMPLOYMENT -> painterResource(R.drawable.employment) - StudyTheme.DISCUSSION -> painterResource(R.drawable.discussion) - StudyTheme.NEWS -> painterResource(R.drawable.news) - StudyTheme.SELFSTUDY -> painterResource(R.drawable.self_study) - StudyTheme.PROJECT -> painterResource(R.drawable.project) - StudyTheme.CONTEST -> painterResource(R.drawable.contest) - StudyTheme.MAJOR -> painterResource(R.drawable.major) - StudyTheme.ETC -> painterResource(R.drawable.resource_else) - } - - MultiButton( - text = theme.title, - painter = iconRes, - checked = activityTheme == theme, - onClick = { onSelect(theme) }, - ) - } - } - } + BaseActivityThemeSection( + modifier = modifier, + isThemeSelected = { it == selectedTheme }, + isThemeEnabled = { true }, + onSelect = onSelect + ) } @Composable fun ActivityThemeSection( selectedThemes: ImmutableList, onSelect: (StudyTheme) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + maxSelection: Int = 3 +) { + val isMaxSelected = selectedThemes.size >= maxSelection + + BaseActivityThemeSection( + modifier = modifier, + isThemeSelected = { selectedThemes.contains(it) }, + + isThemeEnabled = { theme -> !isMaxSelected || selectedThemes.contains(theme) }, + onSelect = onSelect + ) +} + +@Composable +private fun BaseActivityThemeSection( + modifier: Modifier, + isThemeSelected: (StudyTheme) -> Boolean, + isThemeEnabled: (StudyTheme) -> Boolean, + onSelect: (StudyTheme) -> Unit ) { val themesInRows = StudyTheme.entries.chunked(2) - val isMaxSelected = selectedThemes.size >= 3 Column( modifier = modifier, @@ -72,31 +67,34 @@ fun ActivityThemeSection( horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)) ) { themesInRow.forEach { theme -> - val iconRes = getIconForTheme(theme) - val isChecked = selectedThemes.contains(theme) - MultiButton( + modifier = Modifier.weight(1f), text = theme.title, - painter = iconRes, - checked = isChecked, - enabled = !isMaxSelected || isChecked, + painter = getIconForTheme(theme), + checked = isThemeSelected(theme), + enabled = isThemeEnabled(theme), onClick = { onSelect(theme) }, ) } + + if (themesInRow.size < 2) { + Spacer(modifier = Modifier.weight(1f)) + } } } } } + @Composable private fun getIconForTheme(theme: StudyTheme) = when (theme) { StudyTheme.LANGUAGE -> painterResource(R.drawable.language) - StudyTheme.LICENSE -> painterResource(R.drawable.license) - StudyTheme.EMPLOYMENT -> painterResource(R.drawable.employment) - StudyTheme.DISCUSSION -> painterResource(R.drawable.discussion) - StudyTheme.NEWS -> painterResource(R.drawable.news) - StudyTheme.SELFSTUDY -> painterResource(R.drawable.self_study) + StudyTheme.CERTIFICATION -> painterResource(R.drawable.license) + StudyTheme.CAREER -> painterResource(R.drawable.employment) + StudyTheme.DEBATE -> painterResource(R.drawable.discussion) + StudyTheme.CURRENT_AFFAIRS -> painterResource(R.drawable.news) + StudyTheme.SELF_STUDY -> painterResource(R.drawable.self_study) StudyTheme.PROJECT -> painterResource(R.drawable.project) - StudyTheme.CONTEST -> painterResource(R.drawable.contest) - StudyTheme.MAJOR -> painterResource(R.drawable.major) - StudyTheme.ETC -> painterResource(R.drawable.resource_else) + StudyTheme.COMPETITION -> painterResource(R.drawable.contest) + StudyTheme.MAJOR_CAREER -> painterResource(R.drawable.major) + StudyTheme.OTHER -> painterResource(R.drawable.resource_else) } \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/shapes/Shapes.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/shapes/Shapes.kt index f728d65c..8222b422 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/shapes/Shapes.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/shapes/Shapes.kt @@ -176,7 +176,6 @@ fun StateCardActive( borderWidth: Dp = 1.dp, modifier: Modifier = Modifier ) = ShapeBox( - shape = shape, color = color, borderWidth = borderWidth, diff --git a/core/model/src/main/java/com/umcspot/spot/model/Global.kt b/core/model/src/main/java/com/umcspot/spot/model/Global.kt index d7609ff7..3babae72 100644 --- a/core/model/src/main/java/com/umcspot/spot/model/Global.kt +++ b/core/model/src/main/java/com/umcspot/spot/model/Global.kt @@ -48,13 +48,20 @@ enum class StudyTheme( val title: String ) { LANGUAGE("어학"), - LICENSE("자격증"), - EMPLOYMENT("취업"), - DISCUSSION("토론"), - NEWS("시사 / 뉴스"), - SELFSTUDY("자율 학습"), + CERTIFICATION("자격증"), + CAREER("취업"), + DEBATE("토론"), + CURRENT_AFFAIRS("시사 / 뉴스"), + SELF_STUDY("자율 학습"), PROJECT("프로젝트"), - CONTEST("공모전"), - MAJOR("전공 / 진로 학습"), - ETC("기타") + COMPETITION("공모전"), + MAJOR_CAREER("전공 / 진로 학습"), + OTHER("기타") +} + +enum class SocialLoginType( + val title: String +) { + KAKAO("kakao"), + NAVER("naver"), } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 4e05fdbb..4aaee96f 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -9,9 +9,11 @@ android { namespace = "com.umcspot.spot.network" } dependencies { + implementation(libs.bundles.datastore) implementation(projects.core.common) implementation(projects.core.model) + implementation(projects.core.datastore) implementation(libs.kotlinx.serialization.json) implementation(libs.retrofit.core) implementation(libs.retrofit.kotlin.serialization) diff --git a/core/network/src/main/java/com/umcspot/spot/network/AuthInterceptor.kt b/core/network/src/main/java/com/umcspot/spot/network/AuthInterceptor.kt new file mode 100644 index 00000000..bc71e817 --- /dev/null +++ b/core/network/src/main/java/com/umcspot/spot/network/AuthInterceptor.kt @@ -0,0 +1,34 @@ +package com.umcspot.spot.network + +import androidx.datastore.core.DataStore +import com.umcspot.spot.datastore.SpotTokenData +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class AuthInterceptor @Inject constructor( + private val spotTokenDataStore: DataStore +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + + val accessToken = runBlocking { + spotTokenDataStore.data.first().accessToken + } + + // 토큰 없으면 그냥 원래 요청 진행 + if (accessToken.isBlank()) { + return chain.proceed(original) + } + + // 토큰 있으면 Authorization 헤더 추가 + val newRequest = original.newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + + return chain.proceed(newRequest) + } +} diff --git a/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt b/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt index 730a7247..4ced1415 100644 --- a/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/umcspot/spot/network/di/NetworkModule.kt @@ -2,11 +2,11 @@ package com.umcspot.spot.network.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.umcspot.spot.common.BuildConfigFieldProvider +import com.umcspot.spot.network.AuthInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -14,6 +14,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Converter import retrofit2.Retrofit +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -27,9 +28,13 @@ object NetworkModule { @Provides @Singleton - fun providesOkHttpClient(loggingInterceptor: HttpLoggingInterceptor): OkHttpClient = + fun providesOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + authInterceptor: AuthInterceptor + ): OkHttpClient = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) + .addInterceptor(authInterceptor) .build() @OptIn(ExperimentalSerializationApi::class) diff --git a/core/network/src/main/java/com/umcspot/spot/network/model/BaseResponse.kt b/core/network/src/main/java/com/umcspot/spot/network/model/BaseResponse.kt index 87f66168..26c59dae 100644 --- a/core/network/src/main/java/com/umcspot/spot/network/model/BaseResponse.kt +++ b/core/network/src/main/java/com/umcspot/spot/network/model/BaseResponse.kt @@ -5,10 +5,22 @@ import kotlinx.serialization.Serializable @Serializable data class BaseResponse( - @SerialName("success") - val success: Boolean, + @SerialName("isSuccess") + val isSuccess: Boolean, + @SerialName("code") + val code : String, @SerialName("message") val message: String, - @SerialName("data") - val data: T + @SerialName("result") + val result: T +) + +@Serializable +data class NullResultResponse( + @SerialName("isSuccess") + val isSuccess: Boolean, + @SerialName("code") + val code : String, + @SerialName("message") + val message: String ) \ No newline at end of file diff --git a/data/alert/src/main/java/com/umcspot/spot/alert/repositoryimpl/AlertRepositoryImpl.kt b/data/alert/src/main/java/com/umcspot/spot/alert/repositoryimpl/AlertRepositoryImpl.kt index eac1a5ce..37507538 100644 --- a/data/alert/src/main/java/com/umcspot/spot/alert/repositoryimpl/AlertRepositoryImpl.kt +++ b/data/alert/src/main/java/com/umcspot/spot/alert/repositoryimpl/AlertRepositoryImpl.kt @@ -13,7 +13,7 @@ class AlertRepositoryImpl @Inject constructor( override suspend fun getAlerts(): Result = runCatching { val response = studyService.getAlerts() - response.data.toDomainList() + response.result.toDomainList() }.recoverCatching { getAlertDummies() } @@ -24,7 +24,7 @@ class AlertRepositoryImpl @Inject constructor( override suspend fun getAppliedAlerts(): Result = runCatching { val response = studyService.getAppliedAlerts() - response.data.toDomainList() + response.result.toDomainList() }.recoverCatching { getAppliedAlertDummies() } diff --git a/data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt b/data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt index faa9efa0..f850644d 100644 --- a/data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt +++ b/data/board/src/main/java/com/umcspot/spot/board/repositoryimpl/BoardRepositoryImpl.kt @@ -15,7 +15,7 @@ class BoardRepositoryImpl @Inject constructor( override suspend fun getTagBoardData(sortType: SortType): Result = runCatching { val res = boardService.getTagBoardInfo(sortType) - res.data.toDomainList() + res.result.toDomainList() }.recoverCatching { rankedListDummies(sortType) // 태그 보드도 랭크 리스트 형태로 더미 복구 } @@ -23,7 +23,7 @@ class BoardRepositoryImpl @Inject constructor( override suspend fun getRankedBoardData(): Result = runCatching { val res = boardService.getRankedBoardInfo() - res.data.toDomainList() + res.result.toDomainList() }.recoverCatching { rankedListDummies() } @@ -31,7 +31,7 @@ class BoardRepositoryImpl @Inject constructor( override suspend fun getLabeledBoardData(): Result = runCatching { val res = boardService.getLabeledBoardInfo() - res.data.toDomainList() + res.result.toDomainList() }.recoverCatching { labeledListDummies() } diff --git a/domain/signup/.gitignore b/data/login/.gitignore similarity index 100% rename from domain/signup/.gitignore rename to data/login/.gitignore diff --git a/data/login/build.gradle.kts b/data/login/build.gradle.kts new file mode 100644 index 00000000..704ea29d --- /dev/null +++ b/data/login/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.spot.data) +} + +android { + namespace = "com.umcspot.spot.login" +} +dependencies { + implementation(projects.core.model) + implementation(projects.core.network) + implementation(projects.core.datastore) + implementation(projects.domain.token) + + implementation(libs.datastore.core) +} diff --git a/data/login/consumer-rules.pro b/data/login/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/data/login/proguard-rules.pro b/data/login/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/data/login/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# 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 diff --git a/data/login/src/main/AndroidManifest.xml b/data/login/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/data/login/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt b/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt new file mode 100644 index 00000000..79494c1e --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt @@ -0,0 +1,10 @@ +package com.umcspot.spot.login.datasource + +import com.umcspot.spot.login.dto.response.TokenResponseDto +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.network.model.BaseResponse + +interface LoginDataSource { + suspend fun finishSocialLogin(type : String, accessToken : String): BaseResponse + +} \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt b/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt new file mode 100644 index 00000000..29541bfe --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt @@ -0,0 +1,19 @@ +package com.umcspot.spot.login.datasourceimpl + +import androidx.datastore.core.DataStore +import com.umcspot.spot.login.datasource.LoginDataSource +import com.umcspot.spot.login.dto.response.TokenResponseDto +import com.umcspot.spot.login.service.LoginService +import com.umcspot.spot.network.model.BaseResponse +import javax.inject.Inject + +class LoginDataSourceImpl @Inject constructor( + private val loginService: LoginService +) : LoginDataSource { + + override suspend fun finishSocialLogin( + type: String, + accessToken: String + ): BaseResponse = + loginService.getCallBackToken(type, accessToken) +} \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/di/LoginDataModule.kt b/data/login/src/main/java/com/umcspot/spot/login/di/LoginDataModule.kt new file mode 100644 index 00000000..7936f7c4 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/di/LoginDataModule.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.login.di + +import com.umcspot.spot.login.datasource.LoginDataSource +import com.umcspot.spot.login.datasourceimpl.LoginDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class LoginDataModule { + @Binds + @Singleton + abstract fun bindDummyRemoteDataSource(impl: LoginDataSourceImpl): LoginDataSource +} \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/di/LoginRepositoryModule.kt b/data/login/src/main/java/com/umcspot/spot/login/di/LoginRepositoryModule.kt new file mode 100644 index 00000000..6ea1247f --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/di/LoginRepositoryModule.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.login.di + +import com.umcspot.spot.login.repositoryimpl.LoginRepositoryImpl +import com.umcspot.spot.token.repository.TokenRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class LoginRepositoryModule { + @Binds + @Singleton + abstract fun bindsDummyRepository(dummyRepositoryImpl: LoginRepositoryImpl): TokenRepository +} \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/di/LoginServiceModule.kt b/data/login/src/main/java/com/umcspot/spot/login/di/LoginServiceModule.kt new file mode 100644 index 00000000..4eb8fa62 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/di/LoginServiceModule.kt @@ -0,0 +1,19 @@ +package com.umcspot.spot.login.di + +import com.umcspot.spot.login.service.LoginService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object LoginServiceModule { + @Provides + @Singleton + fun provideOAuthApi(retrofit: Retrofit): LoginService = + retrofit.create(LoginService::class.java) + +} \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/dto/request/TokenRequestDto.kt b/data/login/src/main/java/com/umcspot/spot/login/dto/request/TokenRequestDto.kt new file mode 100644 index 00000000..7f2d3597 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/dto/request/TokenRequestDto.kt @@ -0,0 +1,11 @@ +package com.umcspot.spot.login.dto.request + +import com.umcspot.spot.model.SocialLoginType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TokenRequestDto( + @SerialName("type") + val type : SocialLoginType +) diff --git a/data/login/src/main/java/com/umcspot/spot/login/dto/response/TokenResponseDto.kt b/data/login/src/main/java/com/umcspot/spot/login/dto/response/TokenResponseDto.kt new file mode 100644 index 00000000..ebb9da6e --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/dto/response/TokenResponseDto.kt @@ -0,0 +1,14 @@ +package com.umcspot.spot.login.dto.response + +import android.annotation.SuppressLint +import com.umcspot.spot.model.BoardType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TokenResponseDto( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String +) \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/mapper/TokenMapper.kt b/data/login/src/main/java/com/umcspot/spot/login/mapper/TokenMapper.kt new file mode 100644 index 00000000..e68648a7 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/mapper/TokenMapper.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.login.mapper + +import com.umcspot.spot.login.dto.request.TokenRequestDto +import com.umcspot.spot.login.dto.response.TokenResponseDto +import com.umcspot.spot.token.model.TokenResult +import com.umcspot.spot.token.model.TokenType + +fun TokenType.toDto() : TokenRequestDto = + TokenRequestDto ( + type = this.type + ) + +fun TokenResponseDto.toDomain() : TokenResult = + TokenResult ( + accessToken = this.accessToken, + refreshToken = this.refreshToken + ) diff --git a/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt b/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt new file mode 100644 index 00000000..d16a11c6 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt @@ -0,0 +1,37 @@ +package com.umcspot.spot.login.repositoryimpl + +import androidx.datastore.core.DataStore +import com.umcspot.spot.datastore.SpotTokenData +import com.umcspot.spot.login.mapper.toDomain +import com.umcspot.spot.login.service.LoginService +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.token.model.TokenResult +import com.umcspot.spot.token.repository.TokenRepository +import javax.inject.Inject + +class LoginRepositoryImpl @Inject constructor( + private val studyService: LoginService, + private val spotTokenDataStore: DataStore +) : TokenRepository { + + override suspend fun finishSocialLogin( + type: SocialLoginType, + accessToken: String + ): Result = + runCatching { + // 1) 소셜 로그인 콜백 토큰 서버로부터 받기 + val response = studyService.getCallBackToken(type.title, accessToken) + val tokenResult: TokenResult = response.result.toDomain() + + // 2) DataStore에 access / refresh 저장 + spotTokenDataStore.updateData { current -> + current.copy( + accessToken = tokenResult.accessToken, + refreshToken = tokenResult.refreshToken + ) + } + + // 3) ViewModel 에서 추가 처리 필요하면 쓰라고 그대로 반환 + tokenResult + } +} \ No newline at end of file diff --git a/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt b/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt new file mode 100644 index 00000000..947e19da --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.login.service + +import com.umcspot.spot.login.dto.response.TokenResponseDto +import com.umcspot.spot.network.model.BaseResponse +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface LoginService { + + @GET("/api/oauth/client/{type}") + suspend fun getCallBackToken( + @Path("type") type : String, + @Query("accessToken") accessToken : String + ) : BaseResponse + +} diff --git a/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt b/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt index 7681fcc5..f093851f 100644 --- a/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt +++ b/data/study/src/main/java/com/umcspot/spot/study/repositoryimpl/StudyRepositoryImpl.kt @@ -16,7 +16,7 @@ class StudyRepositoryImpl @Inject constructor( override suspend fun getPopularStudies(): Result = runCatching { val response = studyService.getPopularStudies() - response.data.toDomainList() + response.result!!.toDomainList() }.recoverCatching { setPopularDummies() } @@ -28,7 +28,7 @@ class StudyRepositoryImpl @Inject constructor( override suspend fun getRecommendStudies(): Result = runCatching { val response = studyService.getPopularStudies() - response.data.toDomainList() + response.result!!.toDomainList() }.recoverCatching { setRecommendDummies() } @@ -40,7 +40,7 @@ class StudyRepositoryImpl @Inject constructor( override suspend fun getRecruitingStudies(sortType : RecruitingStudySort, activityType: ActivityType?, theme: StudyTheme?, feeRange: FeeRange?): Result = runCatching { val response = studyService.getRecruitingStudies(sortType = sortType, activityType = activityType, theme = theme, feeRange = feeRange) - response.data.toDomainList() + response.result!!.toDomainList() }.recoverCatching { setRecommendDummies(30) } @@ -48,7 +48,7 @@ class StudyRepositoryImpl @Inject constructor( override suspend fun getPreferLocationStudies(sortType : RecruitingStudySort, activityType: ActivityType?, theme: StudyTheme?, feeRange: FeeRange?): Result = runCatching { val response = studyService.getRecruitingStudies(sortType = sortType, activityType = activityType, theme = theme, feeRange = feeRange) - response.data.toDomainList() + response.result!!.toDomainList() }.recoverCatching { setRecommendDummies(0) } diff --git a/data/user/.gitignore b/data/user/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/data/user/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/user/build.gradle.kts b/data/user/build.gradle.kts new file mode 100644 index 00000000..43cf3db4 --- /dev/null +++ b/data/user/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.spot.data) +} + +android { + namespace = "com.umcspot.spot.user" +} +dependencies { + implementation(projects.domain.user) +} \ No newline at end of file diff --git a/data/user/consumer-rules.pro b/data/user/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/data/user/proguard-rules.pro b/data/user/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/data/user/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# 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 diff --git a/data/user/src/main/AndroidManifest.xml b/data/user/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/data/user/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/datasource/UserDataSource.kt b/data/user/src/main/java/com/umcspot/spot/user/datasource/UserDataSource.kt new file mode 100644 index 00000000..352abf67 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/datasource/UserDataSource.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.user.datasource + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.NullResultResponse +import com.umcspot.spot.user.dto.request.UserNameRequestDto +import com.umcspot.spot.user.dto.request.UserThemeRequestDto +import com.umcspot.spot.user.dto.response.UserResponseDto +import com.umcspot.spot.user.dto.response.UserThemeResponseDto + +interface UserDataSource { + suspend fun getUser(): BaseResponse + + suspend fun setUserName(name : UserNameRequestDto) : NullResultResponse + suspend fun setUserTheme(themes : UserThemeRequestDto): NullResultResponse + +} \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/datasourceimpl/UserDataSourceImpl.kt b/data/user/src/main/java/com/umcspot/spot/user/datasourceimpl/UserDataSourceImpl.kt new file mode 100644 index 00000000..f5abb230 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/datasourceimpl/UserDataSourceImpl.kt @@ -0,0 +1,34 @@ +package com.umcspot.spot.user.datasourceimpl + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.NullResultResponse +import com.umcspot.spot.user.datasource.UserDataSource +import com.umcspot.spot.user.dto.request.UserNameRequestDto +import com.umcspot.spot.user.dto.request.UserThemeRequestDto +import com.umcspot.spot.user.dto.response.UserResponseDto +import com.umcspot.spot.user.dto.response.UserThemeResponseDto +import com.umcspot.spot.user.service.UserService +import javax.inject.Inject + + +class UserDataSourceImpl @Inject constructor( + private val userService: UserService +) : UserDataSource { + + override suspend fun getUser( + + ): BaseResponse = + userService.getUser() + + override suspend fun setUserName( + name : UserNameRequestDto + ): NullResultResponse = + userService.setUserName(name) + + override suspend fun setUserTheme( + themes: UserThemeRequestDto + ): NullResultResponse = + userService.setUserTheme(themes) + +} \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/di/UserDataModule.kt b/data/user/src/main/java/com/umcspot/spot/user/di/UserDataModule.kt new file mode 100644 index 00000000..8c3cfdbc --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/di/UserDataModule.kt @@ -0,0 +1,18 @@ +package com.umcspot.spot.user.di + +import com.umcspot.spot.user.datasource.UserDataSource +import com.umcspot.spot.user.datasourceimpl.UserDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +abstract class UserDataModule { + @Binds + @Singleton + abstract fun bindDummyRemoteDataSource(impl: UserDataSourceImpl): UserDataSource +} \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/di/UserRepositoryModule.kt b/data/user/src/main/java/com/umcspot/spot/user/di/UserRepositoryModule.kt new file mode 100644 index 00000000..3007aec4 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/di/UserRepositoryModule.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.user.di + +import com.umcspot.spot.user.repository.UserRepository +import com.umcspot.spot.user.repositoryimpl.UserRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class UserRepositoryModule { + @Binds + @Singleton + abstract fun bindsDummyRepository(impl: UserRepositoryImpl): UserRepository +} \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/di/UserServiceModule.kt b/data/user/src/main/java/com/umcspot/spot/user/di/UserServiceModule.kt new file mode 100644 index 00000000..0bbee0e7 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/di/UserServiceModule.kt @@ -0,0 +1,19 @@ +package com.umcspot.spot.user.di + +import com.umcspot.spot.user.service.UserService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UserServiceModule { + @Provides + @Singleton + fun providesUserService(retrofit: Retrofit): UserService = retrofit.create( + UserService::class.java + ) +} \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/dto/request/UserRequestDto.kt b/data/user/src/main/java/com/umcspot/spot/user/dto/request/UserRequestDto.kt new file mode 100644 index 00000000..52f1530d --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/dto/request/UserRequestDto.kt @@ -0,0 +1,17 @@ +package com.umcspot.spot.user.dto.request + +import com.umcspot.spot.model.StudyTheme +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserThemeRequestDto( + @SerialName("userThemes") + val userThemes: List +) + +@Serializable +data class UserNameRequestDto( + @SerialName("name") + val name: String +) \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/dto/response/UserResponseDto.kt b/data/user/src/main/java/com/umcspot/spot/user/dto/response/UserResponseDto.kt new file mode 100644 index 00000000..17dcc2f2 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/dto/response/UserResponseDto.kt @@ -0,0 +1,13 @@ +package com.umcspot.spot.user.dto.response + +import android.annotation.SuppressLint +import com.umcspot.spot.model.WeatherType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class UserResponseDto( + @SerialName("name") + val name: String +) \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/dto/response/UserThemeResponseDto.kt b/data/user/src/main/java/com/umcspot/spot/user/dto/response/UserThemeResponseDto.kt new file mode 100644 index 00000000..37b950a7 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/dto/response/UserThemeResponseDto.kt @@ -0,0 +1,13 @@ +package com.umcspot.spot.user.dto.response + +import android.annotation.SuppressLint +import com.umcspot.spot.model.StudyTheme +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@SuppressLint("UnsafeOptInUsageError") +@Serializable +data class UserThemeResponseDto ( + @SerialName("userThemes") + val userThemes : List +) \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/mapper/UserMapper.kt b/data/user/src/main/java/com/umcspot/spot/user/mapper/UserMapper.kt new file mode 100644 index 00000000..30882c80 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/mapper/UserMapper.kt @@ -0,0 +1,31 @@ +package com.umcspot.spot.user.mapper + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.user.dto.request.UserNameRequestDto +import com.umcspot.spot.user.dto.request.UserThemeRequestDto +import com.umcspot.spot.user.model.UserResult +import com.umcspot.spot.user.dto.response.UserResponseDto +import com.umcspot.spot.user.dto.response.UserThemeResponseDto +import com.umcspot.spot.user.model.UserTheme +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.util.Locale + +fun List.toRequestDto(): UserThemeRequestDto = + UserThemeRequestDto(userThemes = this) + +fun String.toRequestDto(): UserNameRequestDto = + UserNameRequestDto(name = this) + +// DTO -> Domain +fun UserResponseDto.toDomain(): UserResult = + UserResult( + name = this.name + ) + +fun UserThemeResponseDto.toDomain(): UserTheme = + UserTheme( + userThemes = userThemes + ) + diff --git a/data/user/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt b/data/user/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt new file mode 100644 index 00000000..a6393a53 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.umcspot.spot.user.repositoryimpl + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.user.mapper.toDomain +import com.umcspot.spot.user.mapper.toRequestDto +import com.umcspot.spot.user.model.UserResult +import com.umcspot.spot.user.model.UserTheme +import com.umcspot.spot.user.repository.UserRepository +import com.umcspot.spot.user.service.UserService +import javax.inject.Inject + +class UserRepositoryImpl @Inject constructor( + private val userService: UserService +) : UserRepository { + override suspend fun getUserName(): Result = + runCatching { + val userName = userService.getUser() + userName.result.toDomain() + }.recoverCatching { + UserResult(name = "123") + } + + override suspend fun setUserName(name: String): Result = + runCatching { + userService.setUserName(name.toRequestDto()) + } + + + override suspend fun setUserTheme(theme: List): Result = + runCatching { + userService.setUserTheme(theme.toRequestDto()) + } +} \ No newline at end of file diff --git a/data/user/src/main/java/com/umcspot/spot/user/service/UserService.kt b/data/user/src/main/java/com/umcspot/spot/user/service/UserService.kt new file mode 100644 index 00000000..f357bb36 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/service/UserService.kt @@ -0,0 +1,26 @@ +package com.umcspot.spot.user.service + +import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.network.model.NullResultResponse +import com.umcspot.spot.user.dto.request.UserNameRequestDto +import com.umcspot.spot.user.dto.request.UserThemeRequestDto +import com.umcspot.spot.user.dto.response.UserResponseDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface UserService { + @GET("/api/members/name") + suspend fun getUser( + ): BaseResponse + + @POST("/api/members/name") + suspend fun setUserName( + @Body request : UserNameRequestDto + ): NullResultResponse + + @POST("/api/members/preferred-categories") + suspend fun setUserTheme( + @Body request : UserThemeRequestDto + ): NullResultResponse +} \ No newline at end of file diff --git a/data/weather/src/main/java/com/umcspot/spot/weather/repositoryimpl/WeatherRepositoryImpl.kt b/data/weather/src/main/java/com/umcspot/spot/weather/repositoryimpl/WeatherRepositoryImpl.kt index 6857dbb6..2aed3747 100644 --- a/data/weather/src/main/java/com/umcspot/spot/weather/repositoryimpl/WeatherRepositoryImpl.kt +++ b/data/weather/src/main/java/com/umcspot/spot/weather/repositoryimpl/WeatherRepositoryImpl.kt @@ -14,7 +14,7 @@ class WeatherRepositoryImpl @Inject constructor( override suspend fun getWeather(request: Weather): Result = runCatching { val response = weatherService.getWeather(request.toData()) - response.data.toDomain() + response.result.toDomain() }.recoverCatching { // API 미연결/예외 시 더미로 복구 WeatherResult.dummyFrom() diff --git a/domain/token/.gitignore b/domain/token/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/domain/token/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/signup/build.gradle.kts b/domain/token/build.gradle.kts similarity index 98% rename from domain/signup/build.gradle.kts rename to domain/token/build.gradle.kts index 14f8d7b6..e56aaf9a 100644 --- a/domain/signup/build.gradle.kts +++ b/domain/token/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.spot.android.java.library) } + + dependencies { implementation(projects.core.model) implementation(libs.bundles.coroutine) diff --git a/domain/token/consumer-rules.pro b/domain/token/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/domain/token/proguard-rules.pro b/domain/token/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/domain/token/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# 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 diff --git a/domain/token/src/main/java/com/umcspot/spot/token/model/TokenResult.kt b/domain/token/src/main/java/com/umcspot/spot/token/model/TokenResult.kt new file mode 100644 index 00000000..a0490372 --- /dev/null +++ b/domain/token/src/main/java/com/umcspot/spot/token/model/TokenResult.kt @@ -0,0 +1,6 @@ +package com.umcspot.spot.token.model + +data class TokenResult ( + val accessToken : String, + val refreshToken : String +) \ No newline at end of file diff --git a/domain/token/src/main/java/com/umcspot/spot/token/model/Tokentype.kt b/domain/token/src/main/java/com/umcspot/spot/token/model/Tokentype.kt new file mode 100644 index 00000000..ae06e8d9 --- /dev/null +++ b/domain/token/src/main/java/com/umcspot/spot/token/model/Tokentype.kt @@ -0,0 +1,7 @@ +package com.umcspot.spot.token.model + +import com.umcspot.spot.model.SocialLoginType + +data class TokenType ( + val type : SocialLoginType +) \ No newline at end of file diff --git a/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt b/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt new file mode 100644 index 00000000..7df52445 --- /dev/null +++ b/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt @@ -0,0 +1,8 @@ +package com.umcspot.spot.token.repository + +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.token.model.TokenResult + +interface TokenRepository { + suspend fun finishSocialLogin(type : SocialLoginType, accessToken : String) : Result +} \ No newline at end of file diff --git a/domain/user/.gitignore b/domain/user/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/domain/user/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/user/build.gradle.kts b/domain/user/build.gradle.kts new file mode 100644 index 00000000..58a06150 --- /dev/null +++ b/domain/user/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.spot.android.java.library) +} + + + +dependencies { + implementation(projects.core.model) + implementation(libs.bundles.coroutine) +} \ No newline at end of file diff --git a/domain/user/src/main/java/com/umcspot/spot/user/model/UserResult.kt b/domain/user/src/main/java/com/umcspot/spot/user/model/UserResult.kt new file mode 100644 index 00000000..b1fe7d50 --- /dev/null +++ b/domain/user/src/main/java/com/umcspot/spot/user/model/UserResult.kt @@ -0,0 +1,11 @@ +package com.umcspot.spot.user.model + +import com.umcspot.spot.model.StudyTheme + +data class UserResult ( + val name : String +) + +data class UserTheme( + val userThemes : List +) \ No newline at end of file diff --git a/domain/user/src/main/java/com/umcspot/spot/user/model/UserTheme.kt b/domain/user/src/main/java/com/umcspot/spot/user/model/UserTheme.kt new file mode 100644 index 00000000..cff7af27 --- /dev/null +++ b/domain/user/src/main/java/com/umcspot/spot/user/model/UserTheme.kt @@ -0,0 +1,7 @@ +package com.umcspot.spot.user.model + +import com.umcspot.spot.model.StudyTheme + +data class StudyThemeRequestBody( + val categories : List +) \ No newline at end of file diff --git a/domain/user/src/main/java/com/umcspot/spot/user/repository/UserRepository.kt b/domain/user/src/main/java/com/umcspot/spot/user/repository/UserRepository.kt new file mode 100644 index 00000000..6fdcac22 --- /dev/null +++ b/domain/user/src/main/java/com/umcspot/spot/user/repository/UserRepository.kt @@ -0,0 +1,11 @@ +package com.umcspot.spot.user.repository + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.user.model.UserResult +import com.umcspot.spot.user.model.UserTheme + +interface UserRepository { + suspend fun getUserName() : Result + suspend fun setUserName(name : String) : Result + suspend fun setUserTheme(theme : List) : Result +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainActivity.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainActivity.kt index 111c6531..c61949df 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainActivity.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainActivity.kt @@ -1,15 +1,21 @@ package com.umcspot.spot.main +import android.content.Intent import android.graphics.Color +import android.net.Uri import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.core.view.WindowCompat import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.landing.LandingScreen +import com.umcspot.spot.landing.LandingViewModel +import com.umcspot.spot.landing.navigation.Landing import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -21,6 +27,7 @@ class MainActivity : ComponentActivity() { statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) ) + setContent { SpotTheme { MainScreen() diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt index 81837466..d754b140 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt @@ -7,15 +7,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost +import androidx.navigation.navOptions import com.umcspot.spot.alert.navigation.alertGraph import com.umcspot.spot.alert.navigation.appliedAlertGraph import com.umcspot.spot.category.navigation.categoryGraph +import com.umcspot.spot.checkList.navigation.checkListGraph import com.umcspot.spot.feature.board.navigation.boardGraph import com.umcspot.spot.home.navigation.homeGraph import com.umcspot.spot.jjim.navigation.jjimGraph -import com.umcspot.spot.landing.landingGraph +import com.umcspot.spot.landing.navigation.landingGraph +import com.umcspot.spot.landing.navigation.navigateToSaving +import com.umcspot.spot.landing.navigation.savingGraph import com.umcspot.spot.model.QuickMenuType import com.umcspot.spot.mypage.navigation.mypageGraph +import com.umcspot.spot.signup.navigation.signupGraph import com.umcspot.spot.study.my.navigation.myStudyGraph import com.umcspot.spot.study.preferLocation.navigation.preferLocationStudyGraph import com.umcspot.spot.study.recruiting.navigation.recruitingStudyFilterGraph @@ -29,6 +34,11 @@ fun MainNavHost( contentPadding: PaddingValues = PaddingValues(0.dp), onRegisterScrollToTop: ((() -> Unit)?) -> Unit, ) { + val clearStackNavOptions = navOptions { + popUpTo(0) { inclusive = true } + launchSingleTop = true + restoreState = false + } NavHost( navController = navigator.navController, startDestination = navigator.startDestination, @@ -39,11 +49,27 @@ fun MainNavHost( popExitTransition = { ExitTransition.None }, ) { landingGraph( - onKakaoClick = { - navigator.navigateToHomeAfterLogin() - }, - onNaverClick = { - navigator.navigateToHomeAfterLogin() + onLoginSuccess = { + navigator.navigateToSignUp(clearStackNavOptions) + } + ) + + signupGraph( + contentPadding = contentPadding, + navController = navigator.navController, + onNextClick = { navigator.navigateToCheckList() } + ) + + checkListGraph( + contentPadding = contentPadding, + navController = navigator.navController, + onNextClick = { navigator.navController.navigateToSaving() } + ) + + savingGraph( + contentPadding = contentPadding, + onFinished = { + navigator.navigateToHome(clearStackNavOptions) } ) diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt index 48fffbf0..6fbcbe63 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainNavigator.kt @@ -15,14 +15,19 @@ import com.umcspot.spot.alert.navigation.AppliedAlert import com.umcspot.spot.alert.navigation.navigateToAlert import com.umcspot.spot.alert.navigation.navigateToAppliedAlert import com.umcspot.spot.category.navigation.navigateToCategory +import com.umcspot.spot.checkList.navigation.CheckList +import com.umcspot.spot.checkList.navigation.navigateToCheckList import com.umcspot.spot.feature.board.navigation.Board import com.umcspot.spot.feature.board.navigation.navigateToBoard import com.umcspot.spot.home.navigation.navigateToHome import com.umcspot.spot.jjim.navigation.navigateToJJim -import com.umcspot.spot.landing.Landing +import com.umcspot.spot.landing.navigation.Landing +import com.umcspot.spot.landing.navigation.Saving import com.umcspot.spot.mypage.navigation.navigateToMypage -import com.umcspot.spot.study.recruiting.navigation.Recruiting +import com.umcspot.spot.signup.navigation.SignUp +import com.umcspot.spot.signup.navigation.navigateToSignUp import com.umcspot.spot.study.my.navigation.navigateToMyStudy +import com.umcspot.spot.study.recruiting.navigation.Recruiting import com.umcspot.spot.study.preferLocation.navigation.PreferLocation import com.umcspot.spot.study.preferLocation.navigation.navigateToPreferLocationStudy import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter @@ -76,21 +81,15 @@ class MainNavigator( } @Composable - fun isInLanding(): Boolean = inAnyGraph(Landing::class) + fun isInLanding(): Boolean = inAnyGraph(Landing::class, Saving::class) - /** 상단 뒤로가기 TopBar 노출 조건 */ @Composable - fun showBackTopBar(): Boolean = - inAnyGraph(Alert::class, AppliedAlert::class, RecruitingFilter::class, RegisterStudy::class) + fun showBackTopBar(): Boolean = inAnyGraph(Alert::class, AppliedAlert::class, RecruitingFilter::class, + SignUp::class, CheckList::class,RegisterStudy::class) - /** 스크롤-투-탑 FAB 노출 조건 */ @Composable - fun showToTopFab(): Boolean = inAnyGraph( - Alert::class, AppliedAlert::class, Recruiting::class, - PreferLocation::class, - ) + fun showToTopFab(): Boolean = inAnyGraph(Alert::class, AppliedAlert::class, Recruiting::class,PreferLocation::class) - /** 멀티 FAB(게시판 등) 노출 조건 */ @Composable fun showMultipleFab(): Boolean = inAnyGraph(Board::class) @@ -110,7 +109,6 @@ class MainNavigator( return hierarchy.any { h -> graphs.any { k -> h.hasRoute(k) } } } - fun navigateUp() { navController.navigateUp() } @@ -119,6 +117,14 @@ class MainNavigator( navController.popBackStack() } + fun navigateToSignUp(navOptions: NavOptions? = null) { + navController.navigateToSignUp(navOptions) + } + + fun navigateToCheckList(navOptions: NavOptions? = null) { + navController.navigateToCheckList(navOptions) + } + fun navigateToHome(navOptions: NavOptions? = null) { navController.navigateToHome(navOptions) } diff --git a/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt index d5c03879..2e8e25a6 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/MainScreen.kt @@ -23,11 +23,13 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.umcspot.spot.alert.navigation.Alert import com.umcspot.spot.alert.navigation.AppliedAlert import com.umcspot.spot.alert.navigation.navigateToAlert +import com.umcspot.spot.checkList.navigation.CheckList import com.umcspot.spot.designsystem.component.FloatingMultipleButton import com.umcspot.spot.designsystem.component.FloatingToUpButton import com.umcspot.spot.designsystem.component.appBar.AppBarHome import com.umcspot.spot.designsystem.component.appBar.BackTopBar import com.umcspot.spot.main.component.MainBottomBar +import com.umcspot.spot.signup.navigation.SignUp import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter import com.umcspot.spot.study.register.navigation.RegisterStudy import kotlinx.collections.immutable.toImmutableList @@ -51,6 +53,8 @@ fun MainScreen( dest?.hasRoute(Alert::class) == true -> "알림" dest?.hasRoute(AppliedAlert::class) == true -> "신청한 알림" dest?.hasRoute(RecruitingFilter::class) == true -> "모집중인 스터디" + dest?.hasRoute(SignUp::class) == true -> "회원가입" + dest?.hasRoute(CheckList::class) == true -> "체크리스트" else -> "" } BackTopBar( @@ -74,11 +78,9 @@ fun MainScreen( FabStack( showToTop = navigator.showToTopFab(), onClickToTop = { scrollToTop?.invoke() }, - showMultiple = navigator.showMultipleFab(), onClickMultiple = { /* TODO */ }, - - spacing = 12.dp, + spacing = 12.dp, ) }, bottomBar = { diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts index b14eeda7..79939de2 100644 --- a/feature/signup/build.gradle.kts +++ b/feature/signup/build.gradle.kts @@ -3,5 +3,21 @@ plugins { } android { - namespace = "com.umcspot.spot.signup" + namespace = "com.umcspot.spot.user" +} + +dependencies { + implementation(projects.domain.token) + implementation(projects.core.designsystem) + implementation(projects.domain.user) + + implementation(libs.naver.oauth) + implementation(libs.androidx.browser) // jdk 17 + + implementation(libs.kakao.login) + implementation(libs.kakao.auth) + implementation(libs.kakao.common) + + implementation(libs.naver.oauth) +// implementation(libs.naver.jdk) } \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt new file mode 100644 index 00000000..d256a731 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt @@ -0,0 +1,67 @@ +package com.umcspot.spot.checkList + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.component.button.TextButton +import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.signup.SignUpViewModel + +@Composable +fun CheckListScreen( + contentPadding: PaddingValues, + onNextClick: () -> Unit, + signUpViewModel: SignUpViewModel = hiltViewModel(), + viewmodel: CheckListViewModel = hiltViewModel() +) { + val topPad = contentPadding.calculateTopPadding() + val bottomPad = contentPadding.calculateBottomPadding() + + + val themes by viewmodel.themes.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + .padding(top = topPad, start = 14.dp, end = 14.dp, bottom = bottomPad), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(70.dp)) + + Text( + text = "내가 원하는 스터디를 선택해주세요", + style = SpotTheme.typography.h3, + color = SpotTheme.colors.black + ) + + Spacer(Modifier.height(50.dp)) + + ActivityThemeSection( + selectedThemes = themes, + onSelect = viewmodel::toggleTheme, + modifier = Modifier.fillMaxWidth(), + maxSelection = 10 + ) + + Spacer(Modifier.weight(1f)) + + TextButton( + text = "다음", + enabled = themes.isNotEmpty(), + onClick = { + signUpViewModel.saveNameIfChanged() + viewmodel.submitThemes() + onNextClick() + } + ) + } +} \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt new file mode 100644 index 00000000..d116a036 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt @@ -0,0 +1,63 @@ +package com.umcspot.spot.checkList + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.user.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CheckListViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val userRepository: UserRepository +) : ViewModel() { + + private val KEY_THEMES = "recruit_filter_themes" + private val MAX_SELECTION_COUNT = 10 + + private val _themes = MutableStateFlow>( + savedStateHandle.get>(KEY_THEMES)?.toPersistentList() ?: persistentListOf() + ) + + val themes: StateFlow> = _themes.asStateFlow() + + fun toggleTheme(theme: StudyTheme) { + _themes.update { currentList -> + + val newList = if (currentList.contains(theme)) { + currentList.remove(theme) + } else { + if (currentList.size < MAX_SELECTION_COUNT) { + currentList.add(theme) + } else { + currentList + } + } + saveToHandle(newList) + newList + } + } + + private fun saveToHandle(list: List) { + savedStateHandle[KEY_THEMES] = ArrayList(list) + } + + fun submitThemes() { + val selected = _themes.value + if (selected.isEmpty()) return + + viewModelScope.launch { + userRepository.setUserTheme(selected) + } + } +} \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/checkList/navigation/CheckListNavigation.kt b/feature/signup/src/main/java/com/umcspot/spot/checkList/navigation/CheckListNavigation.kt new file mode 100644 index 00000000..7e3d46a8 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/navigation/CheckListNavigation.kt @@ -0,0 +1,41 @@ +package com.umcspot.spot.checkList.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.umcspot.spot.checkList.CheckListScreen +import com.umcspot.spot.navigation.Route +import com.umcspot.spot.signup.SignUpViewModel +import kotlinx.serialization.Serializable + +fun NavController.navigateToCheckList(navOptions: NavOptions? = null) { + navigate(CheckList, navOptions) +} + +fun NavGraphBuilder.checkListGraph( + navController: NavHostController, + contentPadding : PaddingValues, + onNextClick: () -> Unit, +) { + composable { backStackEntry -> + + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry(navController.graph.id) + } + val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry) + + CheckListScreen( + contentPadding = contentPadding, + signUpViewModel= signUpViewModel, + onNextClick = onNextClick + ) + } +} + +@Serializable +data object CheckList : Route \ No newline at end of file diff --git a/feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt similarity index 70% rename from feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt rename to feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt index cb307cc2..e73a0a0d 100644 --- a/feature/main/src/main/java/com/umcspot/spot/landing/LandingScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt @@ -1,5 +1,6 @@ package com.umcspot.spot.landing +import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,24 +15,61 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.component.button.KakaoStartButton import com.umcspot.spot.designsystem.component.button.NaverStartButton import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.SocialLoginType @Composable fun LandingScreen( - onKakaoClick : () -> Unit, - onNaverClick : () -> Unit + onLoginSuccess: () -> Unit, + viewModel: LandingViewModel = hiltViewModel(), +) { + val activity = LocalContext.current as? Activity + + LaunchedEffect(Unit) { + viewModel.events.collect { ev -> + when (ev) { + is LandingViewModel.LoginEvent.LoginSucceeded -> { + onLoginSuccess() + } + is LandingViewModel.LoginEvent.ShowError -> { + } + } + } + } + + LandingScreenContent( + onKakaoClick = { + activity?.let { act -> + viewModel.startSocialLogin(SocialLoginType.KAKAO, act) + } + }, + onNaverClick = { + activity?.let { act -> + viewModel.startSocialLogin(SocialLoginType.NAVER, act) + } + }, + ) +} + +@Composable +fun LandingScreenContent( + onKakaoClick: () -> Unit, + onNaverClick: () -> Unit, ) { Surface( modifier = Modifier @@ -46,7 +84,6 @@ fun LandingScreen( .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // 상단 여백 + 중앙 컨텐츠 Spacer(Modifier.height(48.dp)) Column( @@ -73,19 +110,14 @@ fun LandingScreen( ) } - // 하단 액션 버튼들 Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 60.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - KakaoStartButton( - onClick = onKakaoClick - ) - NaverStartButton( - onClick = onNaverClick - ) + KakaoStartButton(onClick = onKakaoClick) + NaverStartButton(onClick = onNaverClick) } } } @@ -93,9 +125,9 @@ fun LandingScreen( @Preview(showBackground = true) @Composable -fun LandingScreenPreview() { +private fun LandingScreenPreview() { SpotTheme { - LandingScreen( + LandingScreenContent( onKakaoClick = {}, onNaverClick = {} ) diff --git a/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt new file mode 100644 index 00000000..36c0b276 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -0,0 +1,122 @@ +package com.umcspot.spot.landing + +import android.app.Activity +import android.content.ContentValues.TAG +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kakao.sdk.user.UserApiClient +import com.navercorp.nid.NidOAuth +import com.navercorp.nid.oauth.util.NidOAuthCallback +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.token.repository.TokenRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LandingViewModel @Inject constructor( + private val loginRepository: TokenRepository, + @ApplicationContext private val context: Context +) : ViewModel() { + + private var lastSocialLoginType: SocialLoginType? = null + + sealed interface LoginEvent { + data object LoginSucceeded : LoginEvent + data class ShowError(val message: String) : LoginEvent + } + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + fun startSocialLogin( + type: SocialLoginType, + activity: Activity, + ) = viewModelScope.launch { + lastSocialLoginType = type + + if(type == SocialLoginType.KAKAO) { + try { + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + if (error != null) { + Log.e(TAG, "로그인 실패", error) + } else if (token != null) { + Log.i(TAG, "로그인 성공 ${token.accessToken}") + viewModelScope.launch { + runCatching { + // 보통은 accessToken만 넘김 + loginRepository.finishSocialLogin( + type = lastSocialLoginType!!, + accessToken = token.accessToken + ) + }.onSuccess { + _events.emit(LoginEvent.LoginSucceeded) + }.onFailure { e -> + Log.e(TAG, "서버 로그인 실패", e) + _events.emit(LoginEvent.ShowError("서버 로그인 실패: ${e.message}")) + } + } + } + } + } catch (e: Exception) { + + } + } else if (type == SocialLoginType.NAVER) { + val nidOAuthCallback = object : NidOAuthCallback { + override fun onSuccess() { + val accessToken = NidOAuth.getAccessToken() + + if (accessToken.isNullOrBlank()) { + Log.e(TAG, "네이버 로그인 성공했지만 accessToken이 null/blank") + viewModelScope.launch { + _events.emit(LoginEvent.ShowError("네이버 로그인 토큰을 가져오지 못했습니다.")) + } + return + } + + Log.i(TAG, "네이버 로그인 성공 accessToken = $accessToken") + + // 카카오와 동일하게 서버 로그인 처리 + viewModelScope.launch { + runCatching { + loginRepository.finishSocialLogin( + type = lastSocialLoginType!!, + accessToken = accessToken + ) + }.onSuccess { + _events.emit(LoginEvent.LoginSucceeded) + }.onFailure { e -> + Log.e(TAG, "네이버 서버 로그인 실패", e) + _events.emit(LoginEvent.ShowError("서버 로그인 실패: ${e.message}")) + } + } + } + + override fun onFailure(errorCode: String, errorDesc: String) { + Log.e(TAG, "네이버 로그인 실패: $errorCode, $errorDesc") + viewModelScope.launch { + _events.emit(LoginEvent.ShowError("네이버 로그인 실패: $errorDesc")) + } + } + } + + try { + NidOAuth.requestLogin(activity, nidOAuthCallback) + } catch (e: Exception) { + Log.e(TAG, "네이버 로그인 요청 중 예외", e) + viewModelScope.launch { + _events.emit(LoginEvent.ShowError("네이버 로그인 오류: ${e.message}")) + } + } + } + } +} + diff --git a/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt new file mode 100644 index 00000000..3e7b45bf --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt @@ -0,0 +1,116 @@ +package com.umcspot.spot.landing + + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +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.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.GageBar +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.coroutines.delay + +@Composable +fun SavingScreen( + contentPadding: PaddingValues, + autoProgress: Boolean = true, + autoDurationMs: Int = 1800, + blockBackPress: Boolean = true, + onFinished: () -> Unit, +) { + val topPad = contentPadding.calculateTopPadding() + val bottomPad = contentPadding.calculateBottomPadding() + + BackHandler(enabled = blockBackPress) { /* no-op: 뒤로가기 무시 */ } + + val internal = remember { Animatable(0f) } + var isDone by remember { mutableStateOf(false) } + + LaunchedEffect(autoProgress) { + if (autoProgress) { + internal.snapTo(0f) + internal.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = autoDurationMs, easing = LinearEasing) + ) + isDone = true + delay(3000) + onFinished() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + .padding(top = topPad, bottom = bottomPad, start = 16.dp, end = 16.dp) + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .wrapContentSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.spot_logo), + contentDescription = null, + modifier = Modifier.size(screenWidthDp(40.dp)) + ) + Spacer(Modifier.height(screenHeightDp(16.dp))) + Text( + text = "당신의 스터디 파트너 \n 스팟, SPOT", + style = SpotTheme.typography.h2, + color = SpotTheme.colors.B500, + textAlign = TextAlign.Center + ) + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = screenHeightDp(8.dp)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (isDone) "등록 완료!" else "내 정보 저장 중..", + style = SpotTheme.typography.h4, + color = SpotTheme.colors.B500 + ) + Spacer(Modifier.height(screenHeightDp(8.dp))) + GageBar( + value = internal.value, + ) + } + } +} diff --git a/feature/main/src/main/java/com/umcspot/spot/landing/LandingNavitation.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt similarity index 58% rename from feature/main/src/main/java/com/umcspot/spot/landing/LandingNavitation.kt rename to feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt index 23f6810d..c04673ff 100644 --- a/feature/main/src/main/java/com/umcspot/spot/landing/LandingNavitation.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt @@ -1,14 +1,11 @@ -package com.umcspot.spot.landing +package com.umcspot.spot.landing.navigation -import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.umcspot.spot.home.HomeScreen -import com.umcspot.spot.model.QuickMenuType -import com.umcspot.spot.navigation.MainTabRoute import com.umcspot.spot.navigation.Route +import com.umcspot.spot.landing.LandingScreen import kotlinx.serialization.Serializable fun NavController.navigateToLanding(navOptions: NavOptions? = null) { @@ -16,13 +13,11 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) { } fun NavGraphBuilder.landingGraph( - onKakaoClick: () -> Unit, - onNaverClick: () -> Unit + onLoginSuccess : () -> Unit ) { composable { LandingScreen( - onKakaoClick = onKakaoClick, - onNaverClick = onNaverClick + onLoginSuccess = onLoginSuccess ) } } diff --git a/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/SavingNavigation.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/SavingNavigation.kt new file mode 100644 index 00000000..46464483 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/SavingNavigation.kt @@ -0,0 +1,29 @@ +package com.umcspot.spot.landing.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.umcspot.spot.landing.SavingScreen +import com.umcspot.spot.navigation.Route +import kotlinx.serialization.Serializable + +fun NavController.navigateToSaving(navOptions: NavOptions? = null) { + navigate(Saving, navOptions) +} + +fun NavGraphBuilder.savingGraph( + contentPadding : PaddingValues, + onFinished : () -> Unit +) { + composable { + SavingScreen( + contentPadding = contentPadding, + onFinished = onFinished + ) + } +} + +@Serializable +data object Saving : Route \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/AgreementModal.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/AgreementModal.kt new file mode 100644 index 00000000..2e8587b9 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/AgreementModal.kt @@ -0,0 +1,271 @@ +package com.umcspot.spot.signup + +import androidx.compose.foundation.BorderStroke +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.TextButton +import com.umcspot.spot.designsystem.component.button.TextButtonM +import com.umcspot.spot.designsystem.component.button.TextButtonS +import com.umcspot.spot.designsystem.shapes.SpotShapes +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp + +@Composable +fun PrivacyConsentDialog( + open: Boolean, + onAgree: () -> Unit, + onDismiss: () -> Unit, +) { + if (!open) return + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = SpotShapes.Hard, + tonalElevation = 2.dp, + color = SpotTheme.colors.white + ) { + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Spacer(Modifier.weight(1f)) + + IconButton( + onClick = onDismiss, + modifier = Modifier + .size(16.dp) + ) { + Icon( + painter = painterResource(R.drawable.dismiss), + contentDescription = "닫기", + modifier = Modifier.size(16.dp) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "개인정보 이용 및 활용 동의", + style = SpotTheme.typography.h2, + color = SpotTheme.colors.black, + ) + } + + Spacer(Modifier.height(screenHeightDp(20.dp))) + + Surface( + shape = SpotShapes.Hard, + border = BorderStroke(1.dp, SpotTheme.colors.gray300), + color = SpotTheme.colors.white, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 160.dp, max = 380.dp), + ) { + val scroll = rememberScrollState() + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scroll) + ) { + + Text( + text = "제1조 (개인정보 수집 및 이용 목적)", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + Spacer(Modifier.height(6.dp)) + val bullet = SpotTheme.typography.small_500 + NumberedLine(1, "회원 가입 및 관리: 본인 확인, 회원 서비스 제공", bullet) + NumberedLine(2, "서비스 제공 및 운영: 커뮤니티 기능 제공, 맞춤형 콘텐츠 추천", bullet) + NumberedLine(3, "고객지원: 문의사항 응대 및 서비스 개선", bullet) + NumberedLine(4, "서비스 개선 및 분석: 이용 통계 분석, 부정 이용 방지", bullet) + + Spacer(Modifier.height(12.dp)) + + + Text( + text = "제2조 (수집하는 개인정보 항목)", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + Spacer(Modifier.height(6.dp)) + BulletLine("필수항목: 이름, 이메일, 생년월일, 성별", bullet) + BulletLine("자동 수집 항목: 접속 로그, 서비스 이용 기록, 기기 정보 등", bullet) + + Spacer(Modifier.height(8.dp)) + } + } + + Spacer(Modifier.height(16.dp)) + + + TextButtonM( + text = "동의", + onClick = onAgree + ) + } + } + } +} + +@Composable +fun UniqueConsentDialog( + open: Boolean, + onAgree: () -> Unit, + onDismiss: () -> Unit, +) { + if (!open) return + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = SpotShapes.Hard, + tonalElevation = 2.dp, + color = SpotTheme.colors.white + ) { + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Spacer(Modifier.weight(1f)) + + IconButton( + onClick = onDismiss, + modifier = Modifier + .size(16.dp) + ) { + Icon( + painter = painterResource(R.drawable.dismiss), + contentDescription = "닫기", + modifier = Modifier.size(16.dp) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "고유식별정보 처리 동의", + style = SpotTheme.typography.h2, + color = SpotTheme.colors.black, + ) + } + + Spacer(Modifier.height(screenHeightDp(20.dp))) + + + Surface( + shape = SpotShapes.Hard, + border = BorderStroke(1.dp, SpotTheme.colors.gray300), + color = SpotTheme.colors.white, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 160.dp, max = 380.dp), + ) { + val scroll = rememberScrollState() + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scroll) + ) { + + Text( + text = "제1조 (개인정보 수집 및 이용 목적)", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + Spacer(Modifier.height(6.dp)) + val bullet = SpotTheme.typography.small_500 + NumberedLine(1, "회원 가입 및 관리: 본인 확인, 회원 서비스 제공", bullet) + NumberedLine(2, "서비스 제공 및 운영: 커뮤니티 기능 제공, 맞춤형 콘텐츠 추천", bullet) + NumberedLine(3, "고객지원: 문의사항 응대 및 서비스 개선", bullet) + NumberedLine(4, "서비스 개선 및 분석: 이용 통계 분석, 부정 이용 방지", bullet) + + Spacer(Modifier.height(12.dp)) + + + Text( + text = "제2조 (수집하는 개인정보 항목)", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black + ) + Spacer(Modifier.height(6.dp)) + BulletLine("필수항목: 이름, 이메일, 생년월일, 성별", bullet) + BulletLine("자동 수집 항목: 접속 로그, 서비스 이용 기록, 기기 정보 등", bullet) + + Spacer(Modifier.height(8.dp)) + } + } + + Spacer(Modifier.height(16.dp)) + + + TextButtonM( + text = "동의", + onClick = onAgree + ) + } + } + } +} + +@Composable +private fun NumberedLine(n: Int, text: String, style: androidx.compose.ui.text.TextStyle) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text("$n.", style = style, color = SpotTheme.colors.black) + Spacer(Modifier.width(6.dp)) + Text(text, style = style, color = SpotTheme.colors.black) + } +} + +@Composable +private fun BulletLine(text: String, style: androidx.compose.ui.text.TextStyle) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text("•", style = style, color = SpotTheme.colors.black) + Spacer(Modifier.width(6.dp)) + Text(text, style = style, color = SpotTheme.colors.black) + } +} diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpScreen.kt index 1c956c24..3f5f5f18 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpScreen.kt @@ -1,24 +1,385 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.umcspot.spot.signup +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.Scaffold +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuDefaults.outlinedTextFieldColors +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.umcspot.spot.designsystem.component.appBar.BackTopBar +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.component.button.MultiButton +import com.umcspot.spot.designsystem.component.button.TextButton +import com.umcspot.spot.designsystem.shapes.SpotShapes +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.state.UiState + @Composable fun SignUpScreen( + contentPadding: PaddingValues, + onNextClick: () -> Unit, + viewmodel: SignUpViewModel = hiltViewModel(), +) { + val uiState by viewmodel.name.collectAsStateWithLifecycle() + val focusManager = LocalFocusManager.current + val topPad = contentPadding.calculateTopPadding() + val bottomPad = contentPadding.calculateBottomPadding() + + var privacyChecked by rememberSaveable { mutableStateOf(false) } + var uniqueChecked by rememberSaveable { mutableStateOf(false) } + + var showPrivacyDialog by rememberSaveable { mutableStateOf(false) } + var showUniqueDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(uiState.user) { + if (uiState.user is UiState.Empty) { + viewmodel.load() + } + } + + when (val state = uiState.user) { + is UiState.Loading -> { + Text(text = "로딩 중...", color = Color.Gray) + } + + is UiState.Failure -> { + Text(text = "에러: ${state.msg}", color = Color.Red) + } + + is UiState.Empty -> { + Text(text = "데이터가 없습니다.") + } + + is UiState.Success -> { + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + .padding(top = topPad, start = 14.dp, end = 14.dp, bottom = bottomPad) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { focusManager.clearFocus(force = true) } + ) { + + Spacer(Modifier.height(screenHeightDp(68.dp))) + Text( + text = "스팟에서는 안전한 스터디 매칭을 위해\n실명 활동제를 도입하고 있어요.", + style = SpotTheme.typography.h3, + color = SpotTheme.colors.B500 + ) + + Spacer(Modifier.height(screenHeightDp(33.dp))) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .padding(horizontal = 4.dp, vertical = 8.dp) + ) { + EditableNameRow( + name = state.data, + onNameChange = { viewmodel.setName(it) } + ) + } + + Spacer(Modifier.weight(1f)) + + + AgreementConfirm( + privacyChecked = privacyChecked, + uniqueChecked = uniqueChecked, + onOpenPrivacyDialog = { + if (privacyChecked) privacyChecked = false else showPrivacyDialog = true + }, + onOpenUniqueDialog = { + if (uniqueChecked) uniqueChecked = false else showUniqueDialog = true + }, + ) + + Spacer(Modifier.height(screenHeightDp(24.dp))) + + TextButton( + text = "다음", + enabled = privacyChecked && uniqueChecked, + onClick = onNextClick + ) + } + + PrivacyConsentDialog( + open = showPrivacyDialog, + onAgree = { + privacyChecked = true + showPrivacyDialog = false + }, + onDismiss = { showPrivacyDialog = false } + ) + UniqueConsentDialog( + open = showUniqueDialog, + onAgree = { + uniqueChecked = true + showUniqueDialog = false + }, + onDismiss = { showUniqueDialog = false } + ) + } + } +} + +@Composable +fun AgreementConfirm( + privacyChecked: Boolean, + uniqueChecked: Boolean, + onOpenPrivacyDialog: () -> Unit, + onOpenUniqueDialog: () -> Unit, +) { + Column { + Text( + text = "약관 동의", + style = SpotTheme.typography.h3 + ) + Spacer(Modifier.height(screenHeightDp(7.dp))) + + ConsentItem( + title = "개인정보 이용 및 활용 동의", + checked = privacyChecked, + onClick = onOpenPrivacyDialog + ) + Spacer(Modifier.height(screenHeightDp(4.dp))) + + ConsentItem( + title = "고유식별정보 처리 동의", + checked = uniqueChecked, + onClick = onOpenUniqueDialog + ) + } +} + +@Composable +fun EditableNameRow( + modifier: Modifier = Modifier, + name: String, + onNameChange: (String) -> Unit, ) { -// Scaffold ( -// topBar = BackTopBar( -// title = "회원가입", -// onBackClick = { }, -// modifier = Modifier -// .statusBarsPadding() -// ) -// ) { -// -// } -} \ No newline at end of file + var editing by rememberSaveable { mutableStateOf(false) } + var draft by rememberSaveable { mutableStateOf(name) } + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + var hadFocus by remember { mutableStateOf(false) } + + LaunchedEffect(editing) { + if (editing) { + hadFocus = false + focusRequester.requestFocus() + keyboard?.show() + } + } + Column( + modifier = modifier + .fillMaxWidth() + ) { + if (editing) { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + OutlinedTextField( + colors = outlinedTextFieldColors( + focusedBorderColor = SpotTheme.colors.B500, + unfocusedBorderColor = Color.Transparent, + focusedLabelColor = SpotTheme.colors.B500, + unfocusedLabelColor = Color.Transparent, + cursorColor = SpotTheme.colors.B500, + focusedTextColor = SpotTheme.colors.black, + unfocusedTextColor = SpotTheme.colors.black, + focusedContainerColor = SpotTheme.colors.white, + unfocusedContainerColor = SpotTheme.colors.white + ), + value = draft, + onValueChange = { new -> + when { + new.length <= 15 -> { + draft = new + } + + new.length > draft.length -> { + val last = new.last() + draft = draft.take(14) + last + } + + else -> { + draft = new.take(15) + } + } + }, + singleLine = true, + shape = SpotShapes.Hard, + textStyle = SpotTheme.typography.h2, + placeholder = { Text("이름을 입력하세요", style = SpotTheme.typography.h2) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + + focusManager.clearFocus(force = true) + }), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { state -> + if (hadFocus && !state.isFocused && editing) { + val final = draft.trim() + if (final.isNotEmpty()) { + onNameChange(final) + } else { + draft = name + } + editing = false + keyboard?.hide() + } + hadFocus = state.isFocused + } + ) + } + Spacer(Modifier.height(screenHeightDp(8.dp))) + + Text( + text = "공백 포함 15자까지 입력 가능해요.", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.B500, + modifier = Modifier.padding(start = 10.dp) + ) + } else { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = name, + style = SpotTheme.typography.h2 + ) + + MultiButton( + text = "수정", + painter = painterResource(R.drawable.write), + modifier = Modifier.width(85.dp), + onClick = { + draft = name + editing = true + } + ) + + } + Spacer(Modifier.height(8.dp)) + Text( + text = "실명이 맞나요? 이름을 확인해주세요.", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.gray400 + ) + } + } +} + +@Composable +private fun ConsentItem( + title: String, + checked: Boolean, + onClick: () -> Unit, +) { + val border = if (checked) SpotTheme.colors.B500 else SpotTheme.colors.G300 + val checkTint = if (checked) SpotTheme.colors.B500 else SpotTheme.colors.black + val background = if (checked) SpotTheme.colors.B100 else SpotTheme.colors.white + + Surface( + shape = SpotShapes.Soft, + color = background, + border = BorderStroke(1.dp, border), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + role = Role.Checkbox, + onClick = onClick + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.black, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon( + painter = painterResource(R.drawable.success_default), + modifier = Modifier.size(15.dp), + contentDescription = if (checked) "동의됨" else "미동의", + tint = checkTint + ) + } + } +} diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpViewModel.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpViewModel.kt index 17044c1a..44f9386c 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpViewModel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpViewModel.kt @@ -1,15 +1,91 @@ package com.umcspot.spot.signup +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.ui.state.UiState +import com.umcspot.spot.user.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SignUpViewModel @Inject constructor( -// private val signUpRepository: SignUpRepository + private val userRepository: UserRepository ) : ViewModel() { + data class SignupUiState( + val user: UiState = UiState.Empty, + val originalName: String? = null, // 서버에서 가져온 원래 이름 + val currentName: String? = null // 현재 수정 중인 이름 + ) + private val _name = MutableStateFlow(SignupUiState()) + val name: StateFlow = _name.asStateFlow() + fun load() { + _name.update { it.copy(user = UiState.Loading) } + + viewModelScope.launch { + val result = userRepository.getUserName() + + val newState = result.fold( + onSuccess = { userResult -> + UiState.Success(userResult.name) + }, + onFailure = { e -> + UiState.Failure(e.message ?: e.toString()) + } + ) + + _name.update { + it.copy( + user = newState, + originalName = (newState as? UiState.Success)?.data, + currentName = (newState as? UiState.Success)?.data + ) + } + } + } + fun setName(newName: String) { + _name.update { + it.copy( + user = UiState.Success(newName), + currentName = newName + ) + } + } + + fun saveNameIfChanged() { + val (original, current) = getNamePair() + + if (current.isNullOrBlank() || current == original) return + + viewModelScope.launch { + userRepository.setUserName(current) + .onSuccess { + Log.d("ChangeName", "이름 변경 성공") + // 서버에도 저장됐으니, 로컬 state도 맞춰주기 + _name.update { + it.copy( + user = UiState.Success(current), + originalName = current, + currentName = current + ) + } + } + .onFailure { e -> + Log.e("ChangeName", "이름 변경 실패", e) + } + } + } + + fun getNamePair(): Pair { + Log.d("NamePair", "${_name.value.originalName} ::: ${_name.value.currentName}") + return _name.value.originalName to _name.value.currentName } } \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt index 2a73d28e..f2896053 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/navigation/SignUpNavigation.kt @@ -1,21 +1,38 @@ package com.umcspot.spot.signup.navigation +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.umcspot.spot.navigation.Route import com.umcspot.spot.signup.SignUpScreen +import com.umcspot.spot.signup.SignUpViewModel import kotlinx.serialization.Serializable fun NavController.navigateToSignUp(navOptions: NavOptions? = null) { navigate(SignUp, navOptions) } -fun NavGraphBuilder.signupGraph() { - composable { - SignUpScreen( +fun NavGraphBuilder.signupGraph( + navController: NavHostController, + contentPadding : PaddingValues, + onNextClick : () -> Unit +) { + composable { backStackEntry -> + val parentEntry = remember(backStackEntry) { + // 🔹 NavHost 루트 그래프 기준으로 ViewModel 스코프 + navController.getBackStackEntry(navController.graph.id) + } + val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry) + SignUpScreen( + contentPadding = contentPadding, + viewmodel = signUpViewModel, + onNextClick = onNextClick ) } } diff --git a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt index a85abf99..b6993f51 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,8 +16,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text @@ -26,7 +25,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics @@ -39,12 +37,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.umcspot.spot.designsystem.component.button.TextButton import com.umcspot.spot.designsystem.component.button.TextToggleButton import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection -import com.umcspot.spot.designsystem.component.study.section.ActivityTypeSection import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.model.ActivityType import com.umcspot.spot.model.FeeRange import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.ImmutableList @Composable fun RecruitingStudyFilterScreen( @@ -52,16 +51,15 @@ fun RecruitingStudyFilterScreen( onAcceptFilterClick: () -> Unit, vm: RecruitingStudyFilterViewModel = hiltViewModel(), ) { - val activityType by vm.activity.collectAsStateWithLifecycle() - val fee by vm.fee.collectAsStateWithLifecycle() - val theme by vm.theme.collectAsStateWithLifecycle() - val acceptEnabled by vm.notNull.collectAsStateWithLifecycle() + val activities by vm.activities.collectAsStateWithLifecycle() + val fees by vm.fees.collectAsStateWithLifecycle() + val themes by vm.themes.collectAsStateWithLifecycle() + val acceptEnabled by vm.notNull.collectAsStateWithLifecycle() val topPad = contentPadding.calculateTopPadding() val bottomPad = contentPadding.calculateBottomPadding() - // 적용 이벤트 수신 LaunchedEffect(Unit) { vm.events.collect { ev -> when (ev) { @@ -73,13 +71,13 @@ fun RecruitingStudyFilterScreen( RecruitingStudyFilterScreenContent( modifier = Modifier .padding(top = topPad, bottom = bottomPad), - activityType = activityType, - fee = fee, - theme = theme, + selectedActivities = activities, + selectedFees = fees, + selectedThemes = themes, buttonEnabled = acceptEnabled, - onSetActivity = vm::setActivity, - onSetFee = vm::setFee, - onSetTheme = vm::setTheme, + onToggleActivity = vm::toggleActivity, + onToggleFee = vm::toggleFee, + onToggleTheme = vm::toggleTheme, onReset = vm::reset, onApply = vm::apply ) @@ -87,13 +85,13 @@ fun RecruitingStudyFilterScreen( @Composable fun RecruitingStudyFilterScreenContent( - activityType: ActivityType?, // ✅ 단일 값 (nullable) - fee: FeeRange?, // ✅ 단일 값 (nullable) - theme: StudyTheme?, // ✅ 단일 값 (nullable) + selectedActivities: ImmutableList, + selectedFees: ImmutableList, + selectedThemes: ImmutableList, buttonEnabled: Boolean, - onSetActivity: (ActivityType) -> Unit, // ✅ set* 로직 (같은 값 다시 누르면 해제는 VM이 처리) - onSetFee: (FeeRange?) -> Unit, - onSetTheme: (StudyTheme) -> Unit, + onToggleActivity: (ActivityType) -> Unit, + onToggleFee: (FeeRange) -> Unit, + onToggleTheme: (StudyTheme) -> Unit, onReset: () -> Unit, onApply: () -> Unit, modifier: Modifier = Modifier @@ -106,7 +104,7 @@ fun RecruitingStudyFilterScreenContent( Column( modifier = modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) // ✅ 스크롤 + .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { Text( @@ -115,22 +113,23 @@ fun RecruitingStudyFilterScreenContent( color = SpotTheme.colors.black ) - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(screenHeightDp(10.dp))) + - ActivityTypeSection( - activityType = activityType, - onSelect = onSetActivity + ActivityTypeMultiSection( + selectedTypes = selectedActivities, + onToggle = onToggleActivity ) - Spacer(modifier = Modifier.height(30.dp)) + Spacer(modifier = Modifier.height(screenHeightDp(30.dp))) ActivityFeeSection( - activityFee = fee, - onSelect = onSetFee + selectedFees = selectedFees, + onToggle = onToggleFee ) - Spacer(modifier = Modifier.height(30.dp)) + Spacer(modifier = Modifier.height(screenHeightDp(30.dp))) Text( text = "스터디 테마", @@ -140,19 +139,19 @@ fun RecruitingStudyFilterScreenContent( Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) - ActivityThemeSection( - activityTheme = theme, - onSelect = onSetTheme + selectedThemes = selectedThemes, + onSelect = onToggleTheme, + maxSelection = 10 ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) ResetFilterText( onClick = onReset ) - Spacer(Modifier.height(80.dp)) + Spacer(Modifier.height(screenHeightDp(80.dp))) } Box( @@ -161,9 +160,8 @@ fun RecruitingStudyFilterScreenContent( .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = 16.dp, vertical = 12.dp) - .zIndex(1f) // 항상 앞 + .zIndex(1f) ) { - TextButton( text = "검색 결과 보기", enabled = buttonEnabled, @@ -173,10 +171,30 @@ fun RecruitingStudyFilterScreenContent( } } +@Composable +fun ActivityTypeMultiSection( + selectedTypes: ImmutableList, + onToggle: (ActivityType) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + ActivityType.entries.forEach { type -> + TextToggleButton( + modifier = Modifier.weight(1f), + text = type.label, + checked = selectedTypes.contains(type), + onClick = { onToggle(type) } + ) + } + } +} + @Composable fun ActivityFeeSection( - activityFee: FeeRange?, - onSelect: (FeeRange) -> Unit // 누르면 VM의 setActivity 호출 + selectedFees: ImmutableList, + onToggle: (FeeRange) -> Unit ) { Column( modifier = Modifier @@ -198,15 +216,14 @@ fun ActivityFeeSection( TextToggleButton( text = fee.label, width = 71.dp, - checked = activityFee == fee, - onClick = { onSelect(fee) }, + checked = selectedFees.contains(fee), + onClick = { onToggle(fee) }, ) } } } } - @Composable fun ResetFilterText( onClick: () -> Unit, @@ -223,10 +240,9 @@ fun ResetFilterText( .semantics { role = Role.Button } .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = null, // 리플 없애려면 유지, 리플 원하면 제거 + indication = null, onClick = onClick ) - .padding(vertical = 4.dp) // 터치 여유 + .padding(vertical = 4.dp) ) -} - +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterViewmodel.kt b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterViewmodel.kt index fba9f5b3..269eff7c 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterViewmodel.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyFilterViewmodel.kt @@ -2,8 +2,14 @@ package com.umcspot.spot.study.recruiting import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.model.FeeRange +import com.umcspot.spot.model.StudyTheme import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -11,98 +17,101 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import java.io.Serializable -import com.umcspot.spot.model.ActivityType -import com.umcspot.spot.model.FeeRange -import com.umcspot.spot.model.StudyTheme +import javax.inject.Inject @HiltViewModel class RecruitingStudyFilterViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle ) : ViewModel() { - // ── 저장 키 - private val KEY_ACTIVITY = "recruit_filter_activity" - private val KEY_FEE = "recruit_filter_fee" - private val KEY_THEME = "recruit_filter_theme" + private val KEY_ACTIVITIES = "recruit_filter_activities" + private val KEY_FEES = "recruit_filter_fees" + private val KEY_THEMES = "recruit_filter_themes" - // ── 단일 값 StateFlow - private val _activity = MutableStateFlow(savedStateHandle.get(KEY_ACTIVITY)) - val activity: StateFlow = _activity.asStateFlow() + private val _activities = MutableStateFlow>( + savedStateHandle.get>(KEY_ACTIVITIES)?.toPersistentList() ?: persistentListOf() + ) + val activities: StateFlow> = _activities.asStateFlow() - private val _fee = MutableStateFlow(savedStateHandle.get(KEY_FEE)) - val fee: StateFlow = _fee.asStateFlow() + private val _fees = MutableStateFlow>( + savedStateHandle.get>(KEY_FEES)?.toPersistentList() ?: persistentListOf() + ) + val fees: StateFlow> = _fees.asStateFlow() - private val _theme = MutableStateFlow(savedStateHandle.get(KEY_THEME)) - val theme: StateFlow = _theme.asStateFlow() + private val _themes = MutableStateFlow>( + savedStateHandle.get>(KEY_THEMES)?.toPersistentList() ?: persistentListOf() + ) + val themes: StateFlow> = _themes.asStateFlow() - // ✅ 하나라도 선택되었는지 여부 private fun calcNotNull(): Boolean = - (_activity.value != null) || (_fee.value != null) || (_theme.value != null) + _activities.value.isNotEmpty() || _fees.value.isNotEmpty() || _themes.value.isNotEmpty() private val _notNull = MutableStateFlow(calcNotNull()) val notNull: StateFlow = _notNull.asStateFlow() - // ── 1회성 이벤트 sealed interface Event : Serializable { data class Applied(val filter: RecruitingStudyFilter) : Event } private val _events = MutableSharedFlow(extraBufferCapacity = 1) val events: SharedFlow = _events - // ── 합쳐서 쓰는 DTO (단일값 버전) data class RecruitingStudyFilter( - val activity: ActivityType?, - val fee: FeeRange?, - val theme: StudyTheme? + val activities: List, + val fees: List, + val themes: List ) : Serializable { - fun isEmpty() = activity == null && fee == null && theme == null + fun isEmpty() = activities.isEmpty() && fees.isEmpty() && themes.isEmpty() } - fun setActivity(type: ActivityType) { - _activity.value = _activity.value?.let { cur -> if (cur == type) null else type } ?: type - savedStateHandle[KEY_ACTIVITY] = _activity.value - _notNull.value = calcNotNull() // ✅ 갱신 + fun toggleActivity(type: ActivityType) { + _activities.update { current -> + + val newList = if (current.contains(type)) current.remove(type) else current.add(type) + savedStateHandle[KEY_ACTIVITIES] = ArrayList(newList) + newList + } + _notNull.value = calcNotNull() } - fun setFee(fee: FeeRange?) { - _fee.value = fee - savedStateHandle[KEY_FEE] = fee - _notNull.value = calcNotNull() // ✅ 갱신 + fun toggleFee(fee: FeeRange) { + _fees.update { current -> + val newList = if (current.contains(fee)) current.remove(fee) else current.add(fee) + savedStateHandle[KEY_FEES] = ArrayList(newList) + newList + } + _notNull.value = calcNotNull() } - fun setTheme(theme: StudyTheme) { - _theme.value = _theme.value?.let { cur -> if (cur == theme) null else theme } ?: theme - savedStateHandle[KEY_THEME] = _theme.value - _notNull.value = calcNotNull() // ✅ 갱신 + fun toggleTheme(theme: StudyTheme) { + _themes.update { current -> + val newList = if (current.contains(theme)) current.remove(theme) else current.add(theme) + savedStateHandle[KEY_THEMES] = ArrayList(newList) + newList + } + _notNull.value = calcNotNull() } - fun reset() { - _activity.value = null - _fee.value = null - _theme.value = null - savedStateHandle[KEY_ACTIVITY] = null - savedStateHandle[KEY_FEE] = null - savedStateHandle[KEY_THEME] = null - _notNull.value = calcNotNull() // ✅ 갱신 + _activities.value = persistentListOf() + _fees.value = persistentListOf() + _themes.value = persistentListOf() + + savedStateHandle[KEY_ACTIVITIES] = null + savedStateHandle[KEY_FEES] = null + savedStateHandle[KEY_THEMES] = null + + _notNull.value = false } fun apply() { _events.tryEmit( Event.Applied( RecruitingStudyFilter( - activity = _activity.value, - fee = _fee.value, - theme = _theme.value + activities = _activities.value, + fees = _fees.value, + themes = _themes.value ) ) ) } - - fun getCurrentFilter(): RecruitingStudyFilter = - RecruitingStudyFilter( - activity = _activity.value, - fee = _fee.value, - theme = _theme.value - ) -} +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyScreen.kt index 3d502875..4090f315 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/RecruitingStudyScreen.kt @@ -1,5 +1,6 @@ package com.umcspot.spot.study.recruiting +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -57,6 +58,7 @@ import com.umcspot.spot.designsystem.shapes.SpotShapes import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.G300 import com.umcspot.spot.study.recruiting.RecruitingStudyViewModel +import timber.log.Timber @Composable fun RecruitingStudyScreen( @@ -71,7 +73,6 @@ fun RecruitingStudyScreen( var showSortSheet by remember { mutableStateOf(false) } - val listState = rememberLazyListState() val scope = rememberCoroutineScope() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d446b4ad..a2342695 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,7 +90,7 @@ timber = "5.0.1" coil = "2.7.0" lottie = "6.4.1" jsoup = "1.17.2" -kakao-login = "2.19.0" +kakao = "2.23.0" process-pheonix = "3.0.0" preference = "1.2.1" collapsing-toolbar = "2.3.5" @@ -98,6 +98,11 @@ runtimeAndroid = "1.7.8" flexibleBottomSheet = "0.1.5" annotationJvm = "1.9.1" +# naver login +browser = "1.8.0" +naver-oauth = "5.11.0" +datastoreCore = "1.1.7" + [plugins] # Gradle Plugins ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -251,7 +256,11 @@ coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } lottie = { group = "com.airbnb.android", name = "lottie", version.ref = "lottie" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } -kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-login" } +kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } +kakao-auth = { group = "com.kakao.sdk", name = "v2-auth", version.ref = "kakao" } +kakao-common = { group = "com.kakao.sdk", name = "v2-all", version.ref = "kakao" } + + process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "process-pheonix" } collapsing-toolbar = { group = "me.onebone", name = "toolbar-compose", version.ref = "collapsing-toolbar" } androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtimeAndroid" } @@ -260,6 +269,12 @@ flexible-bottomsheet = { group = "com.github.skydoves", name = "flexible-bottoms #CalendarLibrary kizitonwose-calendar-compose = { module = "com.kizitonwose.calendar:compose", version.ref = "kizitonwose-calendar" } +#naver login +naver-oauth = { module = "com.navercorp.nid:oauth", version.ref = "naver-oauth" } +naver-jdk = { module = "com.navercorp.nid:oauth-jdk8", version.ref = "naver-oauth" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } +datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } + [bundles] coil = ["coil", "coil-svg", "coil-gif"] firebase = ["firebase-analytics", "firebase-database", "firebase-messaging", "firebase-remoteConfig"] diff --git a/settings.gradle.kts b/settings.gradle.kts index 571267c0..19e2ed9a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") } } } @@ -50,4 +51,7 @@ include(":domain:alert") include(":data:board") include(":feature:study") include(":feature:signup") -include(":domain:signup") +include(":domain:user") +include(":data:user") +include(":data:login") +include(":domain:token")