From 530d1c4713797ab8c57ab4533b544bba246e668d Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 27 Oct 2025 17:44:44 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat=20:=20SignUp=20Screen=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 +- .../spot/designsystem/component/GageBar.kt | 6 +- .../spot/designsystem/shapes/Shapes.kt | 1 - {domain/signup => data/user}/.gitignore | 0 data/user/build.gradle.kts | 10 + data/user/consumer-rules.pro | 0 data/user/proguard-rules.pro | 21 + data/user/src/main/AndroidManifest.xml | 4 + .../spot/user/datasource/UserDataSource.kt | 14 + .../user/datasourceimpl/UserDataSourceImpl.kt | 28 ++ .../umcspot/spot/user/di/UserDataModule.kt | 18 + .../spot/user/di/UserRepositoryModule.kt | 17 + .../umcspot/spot/user/di/UserServiceModule.kt | 19 + .../spot/user/dto/request/UserRequestDto.kt | 10 + .../spot/user/dto/response/UserResponseDto.kt | 13 + .../user/dto/response/UserThemeResponseDto.kt | 13 + .../umcspot/spot/user/mapper/UserMapper.kt | 27 ++ .../user/repositoryimpl/UserRepositoryImpl.kt | 32 ++ .../umcspot/spot/user/service/UserService.kt | 21 + domain/user/.gitignore | 1 + domain/{signup => user}/build.gradle.kts | 3 + .../com/umcspot/spot/user/model/UserResult.kt | 5 + .../com/umcspot/spot/user/model/UserTheme.kt | 7 + .../spot/user/repository/UserRepository.kt | 10 + .../com/umcspot/spot/main/MainActivity.kt | 1 - .../java/com/umcspot/spot/main/MainNavHost.kt | 47 ++- .../com/umcspot/spot/main/MainNavigator.kt | 13 +- .../java/com/umcspot/spot/main/MainScreen.kt | 4 + feature/signup/build.gradle.kts | 7 +- .../umcspot/spot/checkList/CheckListScreen.kt | 108 +++++ .../spot/checkList/CheckListViewModel.kt | 48 +++ .../navigation/CheckListNavigation.kt | 29 ++ .../com/umcspot/spot/landing/LandingScreen.kt | 32 +- .../com/umcspot/spot/landing/SavingScreen.kt | 116 ++++++ .../landing/navigation/LandingNavigation.kt} | 7 +- .../landing/navigation/SavingNavigation.kt | 29 ++ .../com/umcspot/spot/signup/AgreementModal.kt | 271 ++++++++++++ .../com/umcspot/spot/signup/SignUpScreen.kt | 392 +++++++++++++++++- .../umcspot/spot/signup/SignUpViewModel.kt | 35 +- .../signup/navigation/SignUpNavigation.kt | 9 +- .../recruiting/RecruitingStudyFilterScreen.kt | 13 +- .../study/recruiting/RecruitingStudyScreen.kt | 3 +- .../RecruitingStudyFilterNavigation.kt | 7 +- settings.gradle.kts | 3 +- 44 files changed, 1362 insertions(+), 95 deletions(-) rename {domain/signup => data/user}/.gitignore (100%) create mode 100644 data/user/build.gradle.kts create mode 100644 data/user/consumer-rules.pro create mode 100644 data/user/proguard-rules.pro create mode 100644 data/user/src/main/AndroidManifest.xml create mode 100644 data/user/src/main/java/com/umcspot/spot/user/datasource/UserDataSource.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/datasourceimpl/UserDataSourceImpl.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/di/UserDataModule.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/di/UserRepositoryModule.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/di/UserServiceModule.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/dto/request/UserRequestDto.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/dto/response/UserResponseDto.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/dto/response/UserThemeResponseDto.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/mapper/UserMapper.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt create mode 100644 data/user/src/main/java/com/umcspot/spot/user/service/UserService.kt create mode 100644 domain/user/.gitignore rename domain/{signup => user}/build.gradle.kts (98%) create mode 100644 domain/user/src/main/java/com/umcspot/spot/user/model/UserResult.kt create mode 100644 domain/user/src/main/java/com/umcspot/spot/user/model/UserTheme.kt create mode 100644 domain/user/src/main/java/com/umcspot/spot/user/repository/UserRepository.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/checkList/navigation/CheckListNavigation.kt rename feature/{main => signup}/src/main/java/com/umcspot/spot/landing/LandingScreen.kt (68%) create mode 100644 feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt rename feature/{main/src/main/java/com/umcspot/spot/landing/LandingNavitation.kt => signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt} (74%) create mode 100644 feature/signup/src/main/java/com/umcspot/spot/landing/navigation/SavingNavigation.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/signup/AgreementModal.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d88adf2..b689806a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,8 +38,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 +51,5 @@ dependencies { implementation(projects.data.study) implementation(projects.data.alert) implementation(projects.data.board) + implementation(projects.data.user) } \ No newline at end of file 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/shapes/Shapes.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/shapes/Shapes.kt index 99687826..47805631 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 @@ -177,7 +177,6 @@ fun StateCardActive( borderWidth: Dp = 1.dp, modifier: Modifier = Modifier ) = ShapeBox( - shape = shape, color = color, borderWidth = borderWidth, diff --git a/domain/signup/.gitignore b/data/user/.gitignore similarity index 100% rename from domain/signup/.gitignore rename to data/user/.gitignore 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..c843a613 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/datasource/UserDataSource.kt @@ -0,0 +1,14 @@ +package com.umcspot.spot.user.datasource + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.network.model.BaseResponse +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 setUserTheme(themes : UserThemeRequestDto): BaseResponse + +} \ 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..642af8ef --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/datasourceimpl/UserDataSourceImpl.kt @@ -0,0 +1,28 @@ +package com.umcspot.spot.user.datasourceimpl + +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.network.model.BaseResponse +import com.umcspot.spot.user.datasource.UserDataSource +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 setUserTheme( + themes: UserThemeRequestDto + ): BaseResponse = + 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..a7a8bf19 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/dto/request/UserRequestDto.kt @@ -0,0 +1,10 @@ +package com.umcspot.spot.user.dto.request + +import com.umcspot.spot.model.StudyTheme +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +data class UserThemeRequestDto( + @SerialName("userThemes") + val userThemes: List +) \ 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..09378bf4 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/mapper/UserMapper.kt @@ -0,0 +1,27 @@ +package com.umcspot.spot.user.mapper + +import com.umcspot.spot.model.StudyTheme +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) + +// 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..719be092 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt @@ -0,0 +1,32 @@ +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.data.toDomain() + }.recoverCatching { + UserResult(name = "추연우") + } + + override suspend fun setUserTheme(theme: List): Result = + runCatching { + val response = userService.setUserTheme(theme.toRequestDto()) + response.data.toDomain() + }.recoverCatching { + UserTheme( + userThemes = listOf(StudyTheme.DISCUSSION, StudyTheme.SELFSTUDY) + ) + } +} \ 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..6837a3c5 --- /dev/null +++ b/data/user/src/main/java/com/umcspot/spot/user/service/UserService.kt @@ -0,0 +1,21 @@ +package com.umcspot.spot.user.service + +import com.umcspot.spot.network.model.BaseResponse +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.model.UserTheme +import retrofit2.http.Body +import retrofit2.http.GET + +interface UserService { + @GET("/api/v1/service") + suspend fun getUser( + + ): BaseResponse + + @GET("/api/v1/service") + suspend fun setUserTheme( + @Body request : UserThemeRequestDto + ): BaseResponse +} \ 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/signup/build.gradle.kts b/domain/user/build.gradle.kts similarity index 98% rename from domain/signup/build.gradle.kts rename to domain/user/build.gradle.kts index 14f8d7b6..58a06150 100644 --- a/domain/signup/build.gradle.kts +++ b/domain/user/build.gradle.kts @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.spot.android.java.library) } + + + dependencies { implementation(projects.core.model) implementation(libs.bundles.coroutine) 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..30ab04df --- /dev/null +++ b/domain/user/src/main/java/com/umcspot/spot/user/model/UserResult.kt @@ -0,0 +1,5 @@ +package com.umcspot.spot.user.model + +data class UserResult ( + val name : String +) \ 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..5da5ba9a --- /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 UserTheme( + val userThemes : 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..7cb4b510 --- /dev/null +++ b/domain/user/src/main/java/com/umcspot/spot/user/repository/UserRepository.kt @@ -0,0 +1,10 @@ +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 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 a2b53efc..b198799b 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 @@ -7,7 +7,6 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.landing.LandingScreen import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint 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 8e710da9..e772a7d3 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 @@ -3,27 +3,34 @@ package com.umcspot.spot.main import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.navOptions -import androidx.navigation.navigation import com.umcspot.spot.alert.navigation.alertGraph import com.umcspot.spot.alert.navigation.appliedAlertGraph import com.umcspot.spot.alert.navigation.navigateToAppliedAlert import com.umcspot.spot.category.navigation.categoryGraph +import com.umcspot.spot.checkList.navigation.CheckList +import com.umcspot.spot.checkList.navigation.checkListGraph +import com.umcspot.spot.checkList.navigation.navigateToCheckList import com.umcspot.spot.feature.board.navigation.boardGraph import com.umcspot.spot.feature.board.navigation.navigateToBoard import com.umcspot.spot.home.navigation.homeGraph import com.umcspot.spot.home.navigation.navigateToHome import com.umcspot.spot.jjim.navigation.jjimGraph -import com.umcspot.spot.landing.Landing -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.SignUp +import com.umcspot.spot.signup.navigation.navigateToSignUp +import com.umcspot.spot.signup.navigation.signupGraph import com.umcspot.spot.study.my.navigation.myStudyGraph -import com.umcspot.spot.study.recruiting.navigation.Recruiting import com.umcspot.spot.study.recruiting.navigation.navigateToRecruitingStudy import com.umcspot.spot.study.recruiting.navigation.navigateToRecruitingStudyFilter import com.umcspot.spot.study.recruiting.navigation.recruitingStudyFilterGraph @@ -46,19 +53,29 @@ fun MainNavHost( startDestination = navigator.startDestination ) { landingGraph( - onKakaoClick = { navigator.navController.navigateToHome( - navOptions { - popUpTo(Landing) { inclusive = true } - launchSingleTop = true - restoreState = true - } - ) }, - onNaverClick = { navigator.navController.navigateToHome( + onKakaoClick = { navigator.navController.navigateToSignUp() }, + onNaverClick = { navigator.navController.navigateToSignUp() } + ) + + signupGraph( + contentPadding = contentPadding, + onNextClick = { navigator.navController.navigateToCheckList() } + ) + + checkListGraph( + contentPadding = contentPadding, + onNextClick = { navigator.navController.navigateToSaving() } + ) + + savingGraph( + contentPadding = contentPadding, + onFinished = { navigator.navController.navigateToHome( navOptions { - // Landing을 백스택에서 제거 - popUpTo(Landing) { inclusive = true } + popUpTo(navigator.navController.graph.findStartDestination().id) { + inclusive = true + } launchSingleTop = true - restoreState = true + restoreState = false } ) } ) 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 63f2e6b6..3c8db33a 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 @@ -12,13 +12,17 @@ import androidx.navigation.navOptions import com.umcspot.spot.alert.navigation.Alert import com.umcspot.spot.alert.navigation.AppliedAlert import com.umcspot.spot.category.navigation.navigateToCategory +import com.umcspot.spot.checkList.navigation.CheckList import com.umcspot.spot.feature.board.navigation.Board 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.SavingScreen +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.study.my.navigation.navigateToMyStudy +import com.umcspot.spot.study.recruiting.navigation.Recruiting import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter import kotlin.reflect.KClass @@ -66,11 +70,12 @@ 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) + fun showBackTopBar(): Boolean = inAnyGraph(Alert::class, AppliedAlert::class, RecruitingFilter::class, + SignUp::class, CheckList::class) /** 스크롤-투-탑 FAB 노출 조건 */ @Composable 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 977f1a4a..8e085aa1 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.Recruiting import com.umcspot.spot.study.recruiting.navigation.RecruitingFilter 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( diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts index b14eeda7..30c50d29 100644 --- a/feature/signup/build.gradle.kts +++ b/feature/signup/build.gradle.kts @@ -3,5 +3,10 @@ plugins { } android { - namespace = "com.umcspot.spot.signup" + namespace = "com.umcspot.spot.user" +} + +dependencies { + implementation(projects.domain.user) + implementation(projects.core.designsystem) } \ 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..b30653dd --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt @@ -0,0 +1,108 @@ +package com.umcspot.spot.checkList + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +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.res.painterResource +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.component.button.TextButtonM +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.StudyTheme + +@Composable +fun CheckListScreen( + contentPadding: PaddingValues, + onNextClick: () -> Unit, + viewmodel: CheckListViewModel = hiltViewModel() +) { + val topPad = contentPadding.calculateTopPadding() + val bottomPad = contentPadding.calculateBottomPadding() + + // ✅ VM의 선택 상태 구독 + val theme by viewmodel.themes.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + .padding(top = topPad, start = 14.dp, end = 14.dp, bottom = bottomPad), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(70.dp)) + Text( + text = "내가 원하는 스터디를 선택해주세요", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + color = SpotTheme.colors.black + ) + + Spacer(Modifier.height(50.dp)) + + ActivityThemeSection( + selected = theme, + onSelect = viewmodel::toggleTheme + ) + + Spacer(Modifier.weight(1f)) + + // ✅ 다음 버튼: 선택이 있어야 활성화 + TextButton( + text = "다음", + enabled = theme != null, + onClick = { + viewmodel.submitThemes() + onNextClick() + } + ) + } +} + +@Composable +fun ActivityThemeSection( + selected: Set, + onSelect: (StudyTheme) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(14.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 = theme in selected, + width = 156.dp, + onClick = { onSelect(theme) } + ) + } + } + } +} 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..29082b64 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt @@ -0,0 +1,48 @@ +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.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 CheckListViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val userRepository: UserRepository +) : ViewModel() { + + private val KEY_THEMES = "recruit_filter_themes" // ✅ 복수형 + + private val _themes = MutableStateFlow( + (savedStateHandle.get>(KEY_THEMES) ?: emptyList()).toCollection(LinkedHashSet()) + ) + val themes: StateFlow> = _themes.asStateFlow() + + /** 단일 선택 토글 (같은 걸 누르면 해제) */ + fun toggleTheme(theme: StudyTheme) { + val next = LinkedHashSet(_themes.value) + if (!next.add(theme)) next.remove(theme) + _themes.value = next + savedStateHandle[KEY_THEMES] = next.toList() // ✅ 저장 + } + + /** 선택된 테마를 서버에 저장 */ + fun submitThemes() { + val selected = _themes.value.toList() + if (selected.isEmpty()) return + viewModelScope.launch { + userRepository.setUserTheme(selected) // List 전달 + // .onSuccess { ... } / .onFailure { ... } 필요 시 처리 + } + } +} \ 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..9ded9b53 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/navigation/CheckListNavigation.kt @@ -0,0 +1,29 @@ +package com.umcspot.spot.checkList.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.checkList.CheckListScreen +import com.umcspot.spot.navigation.Route +import kotlinx.serialization.Serializable + +fun NavController.navigateToCheckList(navOptions: NavOptions? = null) { + navigate(CheckList, navOptions) +} + +fun NavGraphBuilder.checkListGraph( + contentPadding : PaddingValues, + onNextClick: () -> Unit, +) { + composable { + CheckListScreen( + contentPadding = contentPadding, + 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 68% 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 a1017e0f..b5c9327e 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,13 +1,9 @@ -package com.umcspot.spot.landing +package com.umcspot.spot.signup.landing -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,48 +11,22 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.FabPosition -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.VerticalAlignmentLine 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.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavDestination.Companion.hasRoute -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.designsystem.R -import com.umcspot.spot.designsystem.component.FloatingMultipleButton -import com.umcspot.spot.designsystem.component.FloatingToUpButton -import com.umcspot.spot.designsystem.component.KakaoLoginButton import com.umcspot.spot.designsystem.component.KakaoStartButton import com.umcspot.spot.designsystem.component.NaverStartButton -import com.umcspot.spot.designsystem.component.appBar.AppBarHome -import com.umcspot.spot.designsystem.component.appBar.BackTopBar import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.main.MainNavHost -import com.umcspot.spot.main.MainNavTab -import com.umcspot.spot.main.MainNavigator -import com.umcspot.spot.main.component.MainBottomBar -import com.umcspot.spot.main.rememberMainNavigator -import kotlinx.collections.immutable.toImmutableList @Composable fun LandingScreen( 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..6e6f4cab --- /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.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 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) // 3초 대기 + 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(40.dp) + ) + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.Text( + text = "당신의 스터디 파트너 \n 스팟, SPOT", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), + color = SpotTheme.colors.B500, + textAlign = TextAlign.Center + ) + } + + // 하단 진행 영역 + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + androidx.compose.material3.Text( + text = if (isDone) "등록 완료!" else "내 정보 저장 중..", + style = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp), + color = SpotTheme.colors.B500 + ) + Spacer(Modifier.height(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 74% 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..6101806c 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.signup.landing.LandingScreen import kotlinx.serialization.Serializable fun NavController.navigateToLanding(navOptions: NavOptions? = null) { 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..e5ef7c36 --- /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 + +@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), // 없으면 Icons.Default.Close + contentDescription = "닫기", + modifier = Modifier.size(16.dp) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "개인정보 이용 및 활용 동의", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), + color = SpotTheme.colors.black, + ) + } + + Spacer(Modifier.height(30.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) + ) { + // 제1조 + Text( + text = "제1조 (개인정보 수집 및 이용 목적)", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + color = SpotTheme.colors.black + ) + Spacer(Modifier.height(6.dp)) + val bullet = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp) + NumberedLine(1, "회원 가입 및 관리: 본인 확인, 회원 서비스 제공", bullet) + NumberedLine(2, "서비스 제공 및 운영: 커뮤니티 기능 제공, 맞춤형 콘텐츠 추천", bullet) + NumberedLine(3, "고객지원: 문의사항 응대 및 서비스 개선", bullet) + NumberedLine(4, "서비스 개선 및 분석: 이용 통계 분석, 부정 이용 방지", bullet) + + Spacer(Modifier.height(12.dp)) + + // 제2조 + Text( + text = "제2조 (수집하는 개인정보 항목)", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + 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), // 없으면 Icons.Default.Close + contentDescription = "닫기", + modifier = Modifier.size(16.dp) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "고유식별정보 처리 동의", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), + color = SpotTheme.colors.black, + ) + } + + Spacer(Modifier.height(30.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) + ) { + // 제1조 + Text( + text = "제1조 (개인정보 수집 및 이용 목적)", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + color = SpotTheme.colors.black + ) + Spacer(Modifier.height(6.dp)) + val bullet = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp) + NumberedLine(1, "회원 가입 및 관리: 본인 확인, 회원 서비스 제공", bullet) + NumberedLine(2, "서비스 제공 및 운영: 커뮤니티 기능 제공, 맞춤형 콘텐츠 추천", bullet) + NumberedLine(3, "고객지원: 문의사항 응대 및 서비스 개선", bullet) + NumberedLine(4, "서비스 개선 및 분석: 이용 통계 분석, 부정 이용 방지", bullet) + + Spacer(Modifier.height(12.dp)) + + // 제2조 + Text( + text = "제2조 (수집하는 개인정보 항목)", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + 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..d89e4cd0 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,388 @@ +@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.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() + + // ✅ 동의 체크 상태 (초기 false 권장) + 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(70.dp)) + Text( + text = "스팟에서는 안전한 스터디 매칭을 위해\n실명 활동제를 도입하고 있어요.", + style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + color = SpotTheme.colors.B500 + ) + + Spacer(Modifier.height(24.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(10.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 = MaterialTheme.typography.titleMedium.copy(fontSize = 20.sp) + ) + Spacer(Modifier.height(12.dp)) + + ConsentItem( + title = "개인정보 이용 및 활용 동의", + checked = privacyChecked, + onClick = onOpenPrivacyDialog + ) + Spacer(Modifier.height(8.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 -> { + // 15자 이하면 그대로 반영 + draft = new + } + // 15자 초과: 기존보다 늘어난 타이핑이라면 끝 글자만 교체 + new.length > draft.length -> { + val last = new.last() // 마지막에 입력된 글자(간단 버전) + draft = draft.take(14) + last // 14 + 새 글자 = 15자 유지 + } + else -> { + // 그 외(중간 수정/붙여넣기 등)는 안전하게 15자 컷 + draft = new.take(15) + } + } + }, + singleLine = true, + shape = SpotShapes.Hard, + textStyle = SpotTheme.typography.bodySmall400.copy(fontSize = 20.sp), + placeholder = { Text("이름을 입력하세요", style = SpotTheme.typography.bodySmall400) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + // IME Done도 포커스 아웃과 동일 처리 + 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(8.dp)) + Text( + text = "공백 포함 15자까지 입력 가능해요.", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.sp, 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.bodySmall400.copy(fontSize = 20.sp) + ) + + 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 = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.sp + ), + ) + } + } +} + +@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.bodySmall400.copy(fontSize = 14.sp), + 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..7d760e9f 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,48 @@ package com.umcspot.spot.signup import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.ui.state.UiState +import com.umcspot.spot.user.model.UserResult +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) + + 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) } + } + } + fun setName(newName: String) { + _name.update { it.copy(user = UiState.Success(newName)) } // 사용하는 상태 구조에 맞게 반영 } } \ 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..e6c8fc76 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,5 +1,6 @@ package com.umcspot.spot.signup.navigation +import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions @@ -12,10 +13,14 @@ fun NavController.navigateToSignUp(navOptions: NavOptions? = null) { navigate(SignUp, navOptions) } -fun NavGraphBuilder.signupGraph() { +fun NavGraphBuilder.signupGraph( + contentPadding : PaddingValues, + onNextClick : () -> Unit +) { composable { SignUpScreen( - + contentPadding = contentPadding, + 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 b3bfd67d..44298997 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 @@ -1,5 +1,8 @@ -package com.umcspot.spot.study.filter +package com.umcspot.spot.study.recruiting +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -15,7 +18,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.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -32,8 +34,6 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex @@ -41,14 +41,14 @@ 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.MultiButtonM import com.umcspot.spot.designsystem.component.button.TextButton -import com.umcspot.spot.designsystem.component.button.TextButtonM import com.umcspot.spot.designsystem.component.button.TextToggleButton 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.study.filter.RecruitingStudyFilterViewModel +import timber.log.Timber @Composable fun RecruitingStudyFilterScreen( @@ -61,7 +61,6 @@ fun RecruitingStudyFilterScreen( val theme by vm.theme.collectAsStateWithLifecycle() val acceptEnabled by vm.notNull.collectAsStateWithLifecycle() - val topPad = contentPadding.calculateTopPadding() val bottomPad = contentPadding.calculateBottomPadding() 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 74fc513e..b116333f 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/feature/study/src/main/java/com/umcspot/spot/study/recruiting/navigation/RecruitingStudyFilterNavigation.kt b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/navigation/RecruitingStudyFilterNavigation.kt index cddcb5ce..ecc18efb 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/recruiting/navigation/RecruitingStudyFilterNavigation.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/recruiting/navigation/RecruitingStudyFilterNavigation.kt @@ -1,17 +1,12 @@ package com.umcspot.spot.study.recruiting.navigation import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.umcspot.spot.navigation.Route -import com.umcspot.spot.study.filter.RecruitingStudyFilterScreen -import com.umcspot.spot.study.model.StudyResult +import com.umcspot.spot.study.recruiting.RecruitingStudyFilterScreen import kotlinx.serialization.Serializable fun NavController.navigateToRecruitingStudyFilter(navOptions: NavOptions? = null) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 571267c0..817b33e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,4 +50,5 @@ include(":domain:alert") include(":data:board") include(":feature:study") include(":feature:signup") -include(":domain:signup") +include(":domain:user") +include(":data:user") From 74ca75957ff7b7a702300783184a95e83a66d897 Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 17 Nov 2025 09:46:34 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat=20:=20=EC=86=8C=EC=85=9C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84=5F1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../umcspot/spot/convention/BuildConfig.kt | 3 +- core/buildconfig/build.gradle.kts | 7 ++ .../umcspot/spot/datastore/SpotTokenData.kt | 1 - .../spot/datastore/di/DataStoreModule.kt | 25 +++---- .../java/com/umcspot/spot/model/Global.kt | 7 ++ .../umcspot/spot/network/di/NetworkModule.kt | 4 +- .../spot/network/model/BaseResponse.kt | 10 ++- .../repositoryimpl/AlertRepositoryImpl.kt | 4 +- .../repositoryimpl/BoardRepositoryImpl.kt | 6 +- data/login/.gitignore | 1 + data/login/build.gradle.kts | 12 +++ data/login/consumer-rules.pro | 0 data/login/proguard-rules.pro | 21 ++++++ data/login/src/main/AndroidManifest.xml | 4 + .../spot/login/datasource/LoginDataSource.kt | 12 +++ .../datasourceimpl/LoginDataSourceImpl.kt | 23 ++++++ .../umcspot/spot/login/di/LoginDataModule.kt | 17 +++++ .../spot/login/di/LoginRepositoryModule.kt | 17 +++++ .../spot/login/di/LoginServiceModule.kt | 19 +++++ .../spot/login/dto/request/TokenRequestDto.kt | 11 +++ .../login/dto/response/TokenResponseDto.kt | 14 ++++ .../umcspot/spot/login/mapper/TokenMapper.kt | 17 +++++ .../repositoryimpl/LoginRepositoryImpl.kt | 30 ++++++++ .../spot/login/service/LoginService.kt | 18 +++++ .../repositoryimpl/StudyRepositoryImpl.kt | 8 +- .../user/repositoryimpl/UserRepositoryImpl.kt | 4 +- .../repositoryimpl/WeatherRepositoryImpl.kt | 2 +- domain/token/.gitignore | 1 + domain/token/build.gradle.kts | 9 +++ domain/token/consumer-rules.pro | 0 domain/token/proguard-rules.pro | 21 ++++++ .../umcspot/spot/token/model/TokenResult.kt | 6 ++ .../com/umcspot/spot/token/model/Tokentype.kt | 7 ++ .../spot/token/repository/TokenRepository.kt | 11 +++ .../com/umcspot/spot/main/MainActivity.kt | 23 ++++++ .../java/com/umcspot/spot/main/MainNavHost.kt | 13 +++- feature/signup/build.gradle.kts | 6 +- .../com/umcspot/spot/landing/LandingScreen.kt | 54 ++++++++++---- .../umcspot/spot/landing/LoginViewmodel.kt | 73 +++++++++++++++++++ .../landing/navigation/LandingNavigation.kt | 8 +- .../umcspot/spot/signup/SignUpViewModel.kt | 1 - .../recruiting/RecruitingStudyFilterScreen.kt | 2 - gradle/libs.versions.toml | 8 ++ settings.gradle.kts | 2 + 45 files changed, 483 insertions(+), 60 deletions(-) create mode 100644 data/login/.gitignore create mode 100644 data/login/build.gradle.kts create mode 100644 data/login/consumer-rules.pro create mode 100644 data/login/proguard-rules.pro create mode 100644 data/login/src/main/AndroidManifest.xml create mode 100644 data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/di/LoginDataModule.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/di/LoginRepositoryModule.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/di/LoginServiceModule.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/dto/request/TokenRequestDto.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/dto/response/TokenResponseDto.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/mapper/TokenMapper.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt create mode 100644 data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt create mode 100644 domain/token/.gitignore create mode 100644 domain/token/build.gradle.kts create mode 100644 domain/token/consumer-rules.pro create mode 100644 domain/token/proguard-rules.pro create mode 100644 domain/token/src/main/java/com/umcspot/spot/token/model/TokenResult.kt create mode 100644 domain/token/src/main/java/com/umcspot/spot/token/model/Tokentype.kt create mode 100644 domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt create mode 100644 feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b689806a..963acfb6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,4 +52,5 @@ dependencies { implementation(projects.data.alert) implementation(projects.data.board) implementation(projects.data.user) + implementation(projects.data.login) } \ 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..b8c33875 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 @@ -14,8 +14,9 @@ internal fun Project.configureBuildConfig( buildConfigField( "String", "BASE_URL", - "\"${properties.getProperty("base.url") ?: "https://default-url.com/"}\"" + "\"${properties.getProperty("base.url") ?: "https://api-spot.site/"}\"" ) + buildConfigField( "String", "KAKAO_NATIVE_KEY", diff --git a/core/buildconfig/build.gradle.kts b/core/buildconfig/build.gradle.kts index 0417476c..ea0b3a9f 100644 --- a/core/buildconfig/build.gradle.kts +++ b/core/buildconfig/build.gradle.kts @@ -7,7 +7,14 @@ plugins { android { namespace = "com.umcspot.spot.buildconfig" + + defaultConfig { + buildConfigField("String", "BASE_URL", "\"https://api-spot.site/\"") + } + } + + 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/model/src/main/java/com/umcspot/spot/model/Global.kt b/core/model/src/main/java/com/umcspot/spot/model/Global.kt index d7609ff7..29b3d80d 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 @@ -58,3 +58,10 @@ enum class StudyTheme( MAJOR("전공 / 진로 학습"), ETC("기타") } + +enum class SocialLoginType( + val title: String +) { + KAKAO("kakao"), + NAVER("naver"), +} 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..3c8cedfb 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 @@ -6,7 +6,6 @@ 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 +13,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Converter import retrofit2.Retrofit +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -47,7 +47,7 @@ object NetworkModule { buildConfigProvider: BuildConfigFieldProvider ): Retrofit = Retrofit.Builder() - .baseUrl(buildConfigProvider.get().baseUrl) + .baseUrl(/*buildConfigProvider.get().baseUrl*/"https://api-spot.site/") .client(client) .addConverterFactory(converterFactory) .build() 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..d173d49f 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,12 @@ 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 ) \ 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/data/login/.gitignore b/data/login/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/data/login/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/login/build.gradle.kts b/data/login/build.gradle.kts new file mode 100644 index 00000000..b72a70b4 --- /dev/null +++ b/data/login/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.spot.data) +} + +android { + namespace = "com.umcspot.spot.login" +} +dependencies { + implementation(projects.core.model) + implementation(projects.core.network) + implementation(projects.domain.token) +} 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..c4526014 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/datasource/LoginDataSource.kt @@ -0,0 +1,12 @@ +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 getRedirectUrl(type : String): BaseResponse + + suspend fun getCallBackToken(type : String, code : 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..427c69fd --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt @@ -0,0 +1,23 @@ +package com.umcspot.spot.login.datasourceimpl + +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 getRedirectUrl( + type : String + ): BaseResponse = + loginService.getRedirectUrl(type) + + override suspend fun getCallBackToken( + type : String, + code : String + ): BaseResponse = + loginService.getCallBackToken(type, code) + +} \ 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..06c9e4d3 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/repositoryimpl/LoginRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.umcspot.spot.login.repositoryimpl + +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 +) : TokenRepository { + override suspend fun getRedirectUrl( + type : SocialLoginType + ): Result = + runCatching { + val response = studyService.getRedirectUrl(type.title) + response.result + } + + + override suspend fun getCallBackToken( + type : SocialLoginType, + code : String + ): Result = + runCatching { + val response = studyService.getCallBackToken(type.title, code) + response.result.toDomain() + } +} \ 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..c12e0340 --- /dev/null +++ b/data/login/src/main/java/com/umcspot/spot/login/service/LoginService.kt @@ -0,0 +1,18 @@ +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/redirect-url/{type}") + suspend fun getRedirectUrl(@Path("type") type: String): BaseResponse + + @GET("/api/oauth/callback/{type}") + suspend fun getCallBackToken( + @Path("type") type : String, + @Query("code") code : 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..d634d833 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/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt b/data/user/src/main/java/com/umcspot/spot/user/repositoryimpl/UserRepositoryImpl.kt index 719be092..ce668e8a 100644 --- 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 @@ -15,7 +15,7 @@ class UserRepositoryImpl @Inject constructor( override suspend fun getUserName(): Result = runCatching { val userName = userService.getUser() - userName.data.toDomain() + userName.result.toDomain() }.recoverCatching { UserResult(name = "추연우") } @@ -23,7 +23,7 @@ class UserRepositoryImpl @Inject constructor( override suspend fun setUserTheme(theme: List): Result = runCatching { val response = userService.setUserTheme(theme.toRequestDto()) - response.data.toDomain() + response.result.toDomain() }.recoverCatching { UserTheme( userThemes = listOf(StudyTheme.DISCUSSION, StudyTheme.SELFSTUDY) 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/token/build.gradle.kts b/domain/token/build.gradle.kts new file mode 100644 index 00000000..e56aaf9a --- /dev/null +++ b/domain/token/build.gradle.kts @@ -0,0 +1,9 @@ +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/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..7d80984a --- /dev/null +++ b/domain/token/src/main/java/com/umcspot/spot/token/repository/TokenRepository.kt @@ -0,0 +1,11 @@ +package com.umcspot.spot.token.repository + +import com.umcspot.spot.model.SocialLoginType +import com.umcspot.spot.token.model.TokenResult + +interface TokenRepository { + + suspend fun getRedirectUrl(type : SocialLoginType) : Result + + suspend fun getCallBackToken(type : SocialLoginType, code : String) : 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 77730a9c..43553f49 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,17 +1,27 @@ 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.LandingViewModel +import com.umcspot.spot.landing.navigation.Landing import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue @AndroidEntryPoint class MainActivity : ComponentActivity() { + + private val landingViewModel : LandingViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -20,10 +30,23 @@ class MainActivity : ComponentActivity() { statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) ) + setContent { SpotTheme { MainScreen() } } + + intent?.data?.let { uri -> + Log.d("DeepLink", "onCreate uri = $uri") + landingViewModel.onSocialDeepLink(uri) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val uri = intent.data ?: return + Log.d("DeepLink", "onNewIntent uri = $uri") + landingViewModel.onSocialDeepLink(uri) } } \ No newline at end of file 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 e772a7d3..c8ba26b1 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 @@ -53,8 +53,17 @@ fun MainNavHost( startDestination = navigator.startDestination ) { landingGraph( - onKakaoClick = { navigator.navController.navigateToSignUp() }, - onNaverClick = { navigator.navController.navigateToSignUp() } + onLoginSuccess= { + navigator.navController.navigateToSignUp( + navOptions { + popUpTo(navigator.navController.graph.findStartDestination().id) { + inclusive = true + } + launchSingleTop = true + restoreState = false + } + ) + } ) signupGraph( diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts index 30c50d29..fe2f93ca 100644 --- a/feature/signup/build.gradle.kts +++ b/feature/signup/build.gradle.kts @@ -7,6 +7,10 @@ android { } dependencies { - implementation(projects.domain.user) + implementation(projects.domain.token) implementation(projects.core.designsystem) + implementation(projects.domain.user) + + implementation(libs.naver.oauth) + implementation(libs.androidx.browser) // jdk 17 } \ No newline at end of file diff --git a/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt index b5c9327e..15b181d0 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt @@ -1,4 +1,4 @@ -package com.umcspot.spot.signup.landing +package com.umcspot.spot.landing import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -14,6 +14,9 @@ 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.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -22,16 +25,44 @@ 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.KakaoStartButton import com.umcspot.spot.designsystem.component.NaverStartButton import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.SocialLoginType +/** + * 1) Route: 상태/이벤트 처리 + UI 호출 + */ @Composable fun LandingScreen( - onKakaoClick : () -> Unit, - onNaverClick : () -> Unit + onLoginSuccess: () -> Unit, + viewModel: LandingViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.events.collect { ev -> + when (ev) { + is LandingViewModel.LoginEvent.LoginSucceeded -> { + onLoginSuccess() + } + is LandingViewModel.LoginEvent.ShowError -> { + } + } + } + } + + LandingScreenContent( + onKakaoClick = { viewModel.startSocialLogin(SocialLoginType.KAKAO) }, + onNaverClick = { viewModel.startSocialLogin(SocialLoginType.NAVER) }, + ) +} + +@Composable +fun LandingScreenContent( + onKakaoClick: () -> Unit, + onNaverClick: () -> Unit, ) { Surface( modifier = Modifier @@ -46,7 +77,6 @@ fun LandingScreen( .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // 상단 여백 + 중앙 컨텐츠 Spacer(Modifier.height(48.dp)) Column( @@ -59,8 +89,7 @@ fun LandingScreen( Image( painter = painterResource(R.drawable.spot_logo), contentDescription = "SPOT 로고", - modifier = Modifier - .size(33.dp), + modifier = Modifier.size(33.dp), contentScale = ContentScale.Fit ) Spacer(Modifier.height(52.dp)) @@ -73,19 +102,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 +117,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..f8694638 --- /dev/null +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -0,0 +1,73 @@ +package com.umcspot.spot.landing + +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.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) = viewModelScope.launch { + lastSocialLoginType = type // 🔹 어떤 소셜인지 기억해 둠 + + try { + val res = loginRepository.getRedirectUrl(type) + val url = res.getOrNull() + + if (res.isSuccess && !url.isNullOrBlank()) { + + // Custom Tabs 오픈 + CustomTabsIntent.Builder().build().apply { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }.launchUrl(context, Uri.parse(url)) + + } + } catch (e: Exception) { + + } + } + + fun onSocialDeepLink(uri: Uri) = viewModelScope.launch { + val code = uri.getQueryParameter("code") + + if (!code.isNullOrBlank()) { + val tokenResponse = loginRepository.getCallBackToken(lastSocialLoginType!!, code) + tokenResponse.onSuccess { tokens -> + + Log.d("AccessToken" , tokens.accessToken) + Log.d("RefreshToken" , tokens.refreshToken) + }.onFailure { + + } + + } else { + + } + } +} diff --git a/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt index 6101806c..c04673ff 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/navigation/LandingNavigation.kt @@ -5,7 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.umcspot.spot.navigation.Route -import com.umcspot.spot.signup.landing.LandingScreen +import com.umcspot.spot.landing.LandingScreen import kotlinx.serialization.Serializable fun NavController.navigateToLanding(navOptions: NavOptions? = null) { @@ -13,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/signup/SignUpViewModel.kt b/feature/signup/src/main/java/com/umcspot/spot/signup/SignUpViewModel.kt index 7d760e9f..c5de14b4 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 @@ -3,7 +3,6 @@ package com.umcspot.spot.signup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.umcspot.spot.ui.state.UiState -import com.umcspot.spot.user.model.UserResult import com.umcspot.spot.user.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow 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 44298997..54655297 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 @@ -47,8 +47,6 @@ 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.study.filter.RecruitingStudyFilterViewModel -import timber.log.Timber @Composable fun RecruitingStudyFilterScreen( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d446b4ad..7ee55ac8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -98,6 +98,10 @@ runtimeAndroid = "1.7.8" flexibleBottomSheet = "0.1.5" annotationJvm = "1.9.1" +# naver login +browser = "1.8.0" +naver-oauth = "5.11.0" + [plugins] # Gradle Plugins ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -260,6 +264,10 @@ 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" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } + [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 817b33e4..b6560931 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,3 +52,5 @@ include(":feature:study") include(":feature:signup") include(":domain:user") include(":data:user") +include(":data:login") +include(":domain:token") From 985d7c54ac3a1f9c0db71c3ad76e8cbb68e97303 Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 17 Nov 2025 14:22:46 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix=20:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 19 +++++---- app/src/main/AndroidManifest.xml | 14 +++++++ .../java/com/umcspot/spot/SpotApplication.kt | 9 ++++ core/buildconfig/build.gradle.kts | 1 + .../com/umcspot/spot/main/MainActivity.kt | 15 ------- feature/signup/build.gradle.kts | 4 ++ .../umcspot/spot/landing/LoginViewmodel.kt | 42 ++++++------------- gradle/libs.versions.toml | 8 +++- settings.gradle.kts | 1 + 9 files changed, 59 insertions(+), 54 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 963acfb6..ccac21d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,13 +7,14 @@ plugins { android { namespace = "com.umcspot.spot" -// signingConfigs { -// getByName("debug") { -// keyAlias = "androiddebugkey" -// keyPassword = "android" -// storeFile = file("debug.keystore") -// } -// } + signingConfigs { + getByName("debug") { + storeFile = file("key/SpotKey") + storePassword = "spotspot" + keyAlias = "spotkey0" + keyPassword = "spotspot" + } + } buildTypes { debug { @@ -53,4 +54,8 @@ dependencies { implementation(projects.data.board) implementation(projects.data.user) implementation(projects.data.login) + + implementation(libs.kakao.common) + implementation(libs.kakao.login) + implementation(libs.kakao.auth) } \ 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..bd301fd0 100644 --- a/app/src/main/java/com/umcspot/spot/SpotApplication.kt +++ b/app/src/main/java/com/umcspot/spot/SpotApplication.kt @@ -1,11 +1,20 @@ 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 dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class SpotApplication : Application() { override fun onCreate() { super.onCreate() + + KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_KEY) + + val keyHash = Utility.getKeyHash(this) + Log.d("KAKAO_KEY_HASH", "keyHash = $keyHash") } } \ No newline at end of file diff --git a/core/buildconfig/build.gradle.kts b/core/buildconfig/build.gradle.kts index ea0b3a9f..ad303849 100644 --- a/core/buildconfig/build.gradle.kts +++ b/core/buildconfig/build.gradle.kts @@ -10,6 +10,7 @@ android { defaultConfig { buildConfigField("String", "BASE_URL", "\"https://api-spot.site/\"") + buildConfigField("String", "KAKAO_NATIVE_KEY", "\"7878cb24cec56458df067991de5e7786\"") } } 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 43553f49..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 @@ -19,9 +19,6 @@ import kotlin.getValue @AndroidEntryPoint class MainActivity : ComponentActivity() { - - private val landingViewModel : LandingViewModel by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -36,17 +33,5 @@ class MainActivity : ComponentActivity() { MainScreen() } } - - intent?.data?.let { uri -> - Log.d("DeepLink", "onCreate uri = $uri") - landingViewModel.onSocialDeepLink(uri) - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - val uri = intent.data ?: return - Log.d("DeepLink", "onNewIntent uri = $uri") - landingViewModel.onSocialDeepLink(uri) } } \ No newline at end of file diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts index fe2f93ca..e47cccf6 100644 --- a/feature/signup/build.gradle.kts +++ b/feature/signup/build.gradle.kts @@ -13,4 +13,8 @@ dependencies { implementation(libs.naver.oauth) implementation(libs.androidx.browser) // jdk 17 + + implementation(libs.kakao.login) + implementation(libs.kakao.auth) + implementation(libs.kakao.common) } \ No newline at end of file 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 index f8694638..7d205c3a 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -1,5 +1,6 @@ package com.umcspot.spot.landing +import android.content.ContentValues.TAG import android.content.Context import android.content.Intent import android.net.Uri @@ -7,6 +8,7 @@ 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.umcspot.spot.model.SocialLoginType import com.umcspot.spot.token.repository.TokenRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -36,38 +38,18 @@ class LandingViewModel @Inject constructor( fun startSocialLogin(type: SocialLoginType) = viewModelScope.launch { lastSocialLoginType = type // 🔹 어떤 소셜인지 기억해 둠 - try { - val res = loginRepository.getRedirectUrl(type) - val url = res.getOrNull() - - if (res.isSuccess && !url.isNullOrBlank()) { - - // Custom Tabs 오픈 - CustomTabsIntent.Builder().build().apply { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }.launchUrl(context, Uri.parse(url)) - - } - } catch (e: Exception) { - - } - } - - fun onSocialDeepLink(uri: Uri) = viewModelScope.launch { - val code = uri.getQueryParameter("code") - - if (!code.isNullOrBlank()) { - val tokenResponse = loginRepository.getCallBackToken(lastSocialLoginType!!, code) - tokenResponse.onSuccess { tokens -> - - Log.d("AccessToken" , tokens.accessToken) - Log.d("RefreshToken" , tokens.refreshToken) - }.onFailure { + 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}") + } + } + } catch (e: Exception) { } - - } else { - } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ee55ac8..4d84c9a6 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" @@ -255,7 +255,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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index b6560931..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/") } } } From 37a8d61e6e9d4a5cf0aad8c67620fab4ec5c107b Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 17 Nov 2025 16:22:33 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix=20:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=88=98=EC=A0=95=20(=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/login/datasource/LoginDataSource.kt | 4 +--- .../login/datasourceimpl/LoginDataSourceImpl.kt | 13 ++++--------- .../login/repositoryimpl/LoginRepositoryImpl.kt | 16 ++++------------ .../umcspot/spot/login/service/LoginService.kt | 6 ++---- .../spot/token/repository/TokenRepository.kt | 5 +---- .../com/umcspot/spot/landing/LoginViewmodel.kt | 16 +++++++++++++++- 6 files changed, 27 insertions(+), 33 deletions(-) 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 index c4526014..79494c1e 100644 --- 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 @@ -5,8 +5,6 @@ import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.network.model.BaseResponse interface LoginDataSource { - suspend fun getRedirectUrl(type : String): BaseResponse - - suspend fun getCallBackToken(type : String, code : String): BaseResponse + 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 index 427c69fd..d13c8935 100644 --- 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 @@ -9,15 +9,10 @@ import javax.inject.Inject class LoginDataSourceImpl @Inject constructor( private val loginService: LoginService ) : LoginDataSource { - override suspend fun getRedirectUrl( - type : String - ): BaseResponse = - loginService.getRedirectUrl(type) - override suspend fun getCallBackToken( - type : String, - code : String + override suspend fun finishSocialLogin( + type: String, + accessToken: String ): BaseResponse = - loginService.getCallBackToken(type, code) - + loginService.getCallBackToken(type, accessToken) } \ No newline at end of file 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 index 06c9e4d3..d90ce052 100644 --- 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 @@ -10,21 +10,13 @@ import javax.inject.Inject class LoginRepositoryImpl @Inject constructor( private val studyService: LoginService ) : TokenRepository { - override suspend fun getRedirectUrl( - type : SocialLoginType - ): Result = - runCatching { - val response = studyService.getRedirectUrl(type.title) - response.result - } - - override suspend fun getCallBackToken( - type : SocialLoginType, - code : String + override suspend fun finishSocialLogin( + type: SocialLoginType, + accessToken: String ): Result = runCatching { - val response = studyService.getCallBackToken(type.title, code) + val response = studyService.getCallBackToken(type.title, accessToken) response.result.toDomain() } } \ 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 index c12e0340..ab5f3e2e 100644 --- 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 @@ -7,12 +7,10 @@ import retrofit2.http.Path import retrofit2.http.Query interface LoginService { - @GET("/api/oauth/redirect-url/{type}") - suspend fun getRedirectUrl(@Path("type") type: String): BaseResponse - @GET("/api/oauth/callback/{type}") + @GET("/api/oauth/client/{type}") suspend fun getCallBackToken( @Path("type") type : String, - @Query("code") code : String + @Query("accessToken") accessToken : String ) : BaseResponse } 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 index 7d80984a..7df52445 100644 --- 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 @@ -4,8 +4,5 @@ import com.umcspot.spot.model.SocialLoginType import com.umcspot.spot.token.model.TokenResult interface TokenRepository { - - suspend fun getRedirectUrl(type : SocialLoginType) : Result - - suspend fun getCallBackToken(type : SocialLoginType, code : String) : Result + suspend fun finishSocialLogin(type : SocialLoginType, accessToken : String) : Result } \ No newline at end of file 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 index 7d205c3a..b452fed4 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -44,7 +44,21 @@ class LandingViewModel @Inject constructor( if (error != null) { Log.e(TAG, "로그인 실패", error) } else if (token != null) { - Log.i(TAG, "로그인 성공 ${token.accessToken}") +// 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) { From 0fe4563e6706ab950f6a9abc950c2e44182c11ac Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 17 Nov 2025 17:16:24 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix=20:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=ED=9B=84=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=A7=84=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/network/build.gradle.kts | 2 ++ .../umcspot/spot/network/AuthInterceptor.kt | 34 +++++++++++++++++++ .../umcspot/spot/network/di/NetworkModule.kt | 7 +++- data/login/build.gradle.kts | 3 ++ .../datasourceimpl/LoginDataSourceImpl.kt | 1 + .../repositoryimpl/LoginRepositoryImpl.kt | 19 +++++++++-- .../spot/login/service/LoginService.kt | 1 + .../user/repositoryimpl/UserRepositoryImpl.kt | 2 +- .../umcspot/spot/user/service/UserService.kt | 3 +- .../umcspot/spot/landing/LoginViewmodel.kt | 2 +- gradle/libs.versions.toml | 2 ++ 11 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 core/network/src/main/java/com/umcspot/spot/network/AuthInterceptor.kt 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 3c8cedfb..2b61df59 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,6 +2,7 @@ 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 @@ -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/data/login/build.gradle.kts b/data/login/build.gradle.kts index b72a70b4..704ea29d 100644 --- a/data/login/build.gradle.kts +++ b/data/login/build.gradle.kts @@ -8,5 +8,8 @@ android { 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/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt b/data/login/src/main/java/com/umcspot/spot/login/datasourceimpl/LoginDataSourceImpl.kt index d13c8935..29541bfe 100644 --- 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 @@ -1,5 +1,6 @@ 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 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 index d90ce052..d16a11c6 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -8,7 +10,8 @@ import com.umcspot.spot.token.repository.TokenRepository import javax.inject.Inject class LoginRepositoryImpl @Inject constructor( - private val studyService: LoginService + private val studyService: LoginService, + private val spotTokenDataStore: DataStore ) : TokenRepository { override suspend fun finishSocialLogin( @@ -16,7 +19,19 @@ class LoginRepositoryImpl @Inject constructor( accessToken: String ): Result = runCatching { + // 1) 소셜 로그인 콜백 토큰 서버로부터 받기 val response = studyService.getCallBackToken(type.title, accessToken) - response.result.toDomain() + 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 index ab5f3e2e..947e19da 100644 --- 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 @@ -13,4 +13,5 @@ interface LoginService { @Path("type") type : String, @Query("accessToken") accessToken : String ) : BaseResponse + } 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 index ce668e8a..913137bc 100644 --- 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 @@ -17,7 +17,7 @@ class UserRepositoryImpl @Inject constructor( val userName = userService.getUser() userName.result.toDomain() }.recoverCatching { - UserResult(name = "추연우") + UserResult(name = "123") } override suspend fun setUserTheme(theme: List): Result = 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 index 6837a3c5..8fa07392 100644 --- 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 @@ -9,9 +9,8 @@ import retrofit2.http.Body import retrofit2.http.GET interface UserService { - @GET("/api/v1/service") + @GET("/api/members/name") suspend fun getUser( - ): BaseResponse @GET("/api/v1/service") 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 index b452fed4..6c98110b 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -44,7 +44,7 @@ class LandingViewModel @Inject constructor( if (error != null) { Log.e(TAG, "로그인 실패", error) } else if (token != null) { -// Log.i(TAG, "로그인 성공 ${token.accessToken}") + Log.i(TAG, "로그인 성공 ${token.accessToken}") viewModelScope.launch { runCatching { // 보통은 accessToken만 넘김 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d84c9a6..27b73461 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,6 +101,7 @@ annotationJvm = "1.9.1" # naver login browser = "1.8.0" naver-oauth = "5.11.0" +datastoreCore = "1.1.7" [plugins] # Gradle Plugins @@ -271,6 +272,7 @@ kizitonwose-calendar-compose = { module = "com.kizitonwose.calendar:compose", ve #naver login naver-oauth = { module = "com.navercorp.nid:oauth", 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"] From e19707a8ac839deb9a4d775570605822d0a9e88b Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 17 Nov 2025 18:29:20 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat=20:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 + .../java/com/umcspot/spot/SpotApplication.kt | 5 +- .../umcspot/spot/convention/BuildConfig.kt | 34 +++++++++-- core/buildconfig/build.gradle.kts | 6 -- feature/signup/build.gradle.kts | 3 + .../com/umcspot/spot/landing/LandingScreen.kt | 19 ++++-- .../umcspot/spot/landing/LoginViewmodel.kt | 58 ++++++++++++++++++- gradle/libs.versions.toml | 1 + 8 files changed, 110 insertions(+), 19 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ccac21d2..21afc598 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,4 +58,7 @@ dependencies { 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/java/com/umcspot/spot/SpotApplication.kt b/app/src/main/java/com/umcspot/spot/SpotApplication.kt index bd301fd0..f79c28e0 100644 --- a/app/src/main/java/com/umcspot/spot/SpotApplication.kt +++ b/app/src/main/java/com/umcspot/spot/SpotApplication.kt @@ -5,6 +5,7 @@ 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 @@ -13,8 +14,6 @@ class SpotApplication : Application() { super.onCreate() KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_KEY) - - val keyHash = Utility.getKeyHash(this) - Log.d("KAKAO_KEY_HASH", "keyHash = $keyHash") + 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 b8c33875..bd2fb329 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,20 +7,46 @@ import org.gradle.api.Project internal fun Project.configureBuildConfig( commonExtension: CommonExtension<*, *, *, *, *, *>, ) { + val properties = gradleLocalProperties(rootDir, providers) + + val baseUrl = properties.getProperty("BASE_URL") ?: "https://api-spot.site/" + 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://api-spot.site/"}\"" + "\"$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\"" ) } @@ -28,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 ad303849..0dcb577c 100644 --- a/core/buildconfig/build.gradle.kts +++ b/core/buildconfig/build.gradle.kts @@ -7,12 +7,6 @@ plugins { android { namespace = "com.umcspot.spot.buildconfig" - - defaultConfig { - buildConfigField("String", "BASE_URL", "\"https://api-spot.site/\"") - buildConfigField("String", "KAKAO_NATIVE_KEY", "\"7878cb24cec56458df067991de5e7786\"") - } - } diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts index e47cccf6..79939de2 100644 --- a/feature/signup/build.gradle.kts +++ b/feature/signup/build.gradle.kts @@ -17,4 +17,7 @@ dependencies { 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/landing/LandingScreen.kt b/feature/signup/src/main/java/com/umcspot/spot/landing/LandingScreen.kt index 15b181d0..2f124286 100644 --- a/feature/signup/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 @@ -20,6 +21,7 @@ import androidx.compose.runtime.getValue 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 @@ -33,14 +35,13 @@ import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.model.SocialLoginType -/** - * 1) Route: 상태/이벤트 처리 + UI 호출 - */ @Composable fun LandingScreen( onLoginSuccess: () -> Unit, viewModel: LandingViewModel = hiltViewModel(), ) { + val activity = LocalContext.current as? Activity + LaunchedEffect(Unit) { viewModel.events.collect { ev -> when (ev) { @@ -54,8 +55,16 @@ fun LandingScreen( } LandingScreenContent( - onKakaoClick = { viewModel.startSocialLogin(SocialLoginType.KAKAO) }, - onNaverClick = { viewModel.startSocialLogin(SocialLoginType.NAVER) }, + onKakaoClick = { + activity?.let { act -> + viewModel.startSocialLogin(SocialLoginType.KAKAO, act) + } + }, + onNaverClick = { + activity?.let { act -> + viewModel.startSocialLogin(SocialLoginType.NAVER, act) + } + }, ) } 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 index 6c98110b..b71adbed 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -1,5 +1,6 @@ package com.umcspot.spot.landing +import android.app.Activity import android.content.ContentValues.TAG import android.content.Context import android.content.Intent @@ -9,6 +10,8 @@ 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 @@ -35,7 +38,10 @@ class LandingViewModel @Inject constructor( val events = _events.asSharedFlow() /** 카카오/네이버 공용 시작 함수 */ - fun startSocialLogin(type: SocialLoginType) = viewModelScope.launch { + fun startSocialLogin( + type: SocialLoginType, + activity: Activity, // ← Activity를 받기 + ) = viewModelScope.launch { lastSocialLoginType = type // 🔹 어떤 소셜인지 기억해 둠 if(type == SocialLoginType.KAKAO) { @@ -64,6 +70,56 @@ class LandingViewModel @Inject constructor( } catch (e: Exception) { } + } else if (type == SocialLoginType.NAVER) { + val nidOAuthCallback = object : NidOAuthCallback { + override fun onSuccess() { + // 네이버 SDK 내부에서 로그인 성공 + 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!!, // NAVER + accessToken = accessToken // 네이버 access token + ) + }.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 { + // context: @ApplicationContext 주입받은 Context + NidOAuth.requestLogin(activity, nidOAuthCallback) + } catch (e: Exception) { + Log.e(TAG, "네이버 로그인 요청 중 예외", e) + viewModelScope.launch { + _events.emit(LoginEvent.ShowError("네이버 로그인 오류: ${e.message}")) + } + } } } } + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27b73461..a2342695 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -271,6 +271,7 @@ kizitonwose-calendar-compose = { module = "com.kizitonwose.calendar:compose", ve #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" } From 3090741409cc3e88b81a1f8cd6ace6dd5f6c5961 Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Mon, 17 Nov 2025 18:29:55 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix=20:=20=EA=B2=BD=EB=A1=9C=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/umcspot/spot/convention/BuildConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bd2fb329..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 @@ -9,7 +9,7 @@ internal fun Project.configureBuildConfig( ) { val properties = gradleLocalProperties(rootDir, providers) - val baseUrl = properties.getProperty("BASE_URL") ?: "https://api-spot.site/" + 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") ?: "" From 8d0d0fb05a445c7b245c0d5431c727c4cbccb44c Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Tue, 18 Nov 2025 12:18:44 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix=20:=20=EC=9D=B4=EB=A6=84=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/umcspot/spot/model/Global.kt | 16 ++--- .../umcspot/spot/network/di/NetworkModule.kt | 2 +- .../spot/network/model/BaseResponse.kt | 10 ++++ .../repositoryimpl/StudyRepositoryImpl.kt | 8 +-- .../spot/user/datasource/UserDataSource.kt | 5 +- .../user/datasourceimpl/UserDataSourceImpl.kt | 8 ++- .../spot/user/dto/request/UserRequestDto.kt | 7 +++ .../umcspot/spot/user/mapper/UserMapper.kt | 4 ++ .../user/repositoryimpl/UserRepositoryImpl.kt | 16 ++++- .../umcspot/spot/user/service/UserService.kt | 14 +++-- .../com/umcspot/spot/user/model/UserResult.kt | 6 ++ .../com/umcspot/spot/user/model/UserTheme.kt | 4 +- .../spot/user/repository/UserRepository.kt | 3 +- .../java/com/umcspot/spot/main/MainNavHost.kt | 2 + .../umcspot/spot/checkList/CheckListScreen.kt | 24 ++++---- .../spot/checkList/CheckListViewModel.kt | 9 +-- .../navigation/CheckListNavigation.kt | 14 ++++- .../umcspot/spot/landing/LoginViewmodel.kt | 11 ++-- .../com/umcspot/spot/landing/SavingScreen.kt | 12 ++-- .../com/umcspot/spot/signup/SignUpScreen.kt | 2 +- .../umcspot/spot/signup/SignUpViewModel.kt | 58 ++++++++++++++++--- .../signup/navigation/SignUpNavigation.kt | 14 ++++- .../recruiting/RecruitingStudyFilterScreen.kt | 16 ++--- 23 files changed, 191 insertions(+), 74 deletions(-) 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 29b3d80d..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,15 +48,15 @@ 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( 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 2b61df59..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 @@ -52,7 +52,7 @@ object NetworkModule { buildConfigProvider: BuildConfigFieldProvider ): Retrofit = Retrofit.Builder() - .baseUrl(/*buildConfigProvider.get().baseUrl*/"https://api-spot.site/") + .baseUrl(buildConfigProvider.get().baseUrl) .client(client) .addConverterFactory(converterFactory) .build() 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 d173d49f..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 @@ -13,4 +13,14 @@ data class BaseResponse( val message: String, @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/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 d634d833..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.result.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.result.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.result.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.result.toDomainList() + response.result!!.toDomainList() }.recoverCatching { setRecommendDummies(0) } 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 index c843a613..352abf67 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -9,6 +11,7 @@ import com.umcspot.spot.user.dto.response.UserThemeResponseDto interface UserDataSource { suspend fun getUser(): BaseResponse - suspend fun setUserTheme(themes : UserThemeRequestDto): 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 index 642af8ef..f5abb230 100644 --- 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 @@ -2,7 +2,9 @@ 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 @@ -19,10 +21,14 @@ class UserDataSourceImpl @Inject constructor( ): BaseResponse = userService.getUser() + override suspend fun setUserName( + name : UserNameRequestDto + ): NullResultResponse = + userService.setUserName(name) override suspend fun setUserTheme( themes: UserThemeRequestDto - ): BaseResponse = + ): NullResultResponse = userService.setUserTheme(themes) } \ 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 index a7a8bf19..52f1530d 100644 --- 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 @@ -4,7 +4,14 @@ 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/mapper/UserMapper.kt b/data/user/src/main/java/com/umcspot/spot/user/mapper/UserMapper.kt index 09378bf4..30882c80 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -14,6 +15,9 @@ 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( 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 index 913137bc..aa1a57dc 100644 --- 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 @@ -20,13 +20,23 @@ class UserRepositoryImpl @Inject constructor( UserResult(name = "123") } - override suspend fun setUserTheme(theme: List): Result = + override suspend fun setUserName(name: String): Result = + runCatching { + val response = userService.setUserName(name.toRequestDto()) + + if (!response.isSuccess) { + throw IllegalStateException("API 실패: code=${response.code}, msg=${response.message}") + } + } + + + override suspend fun setUserTheme(theme: List): Result = runCatching { val response = userService.setUserTheme(theme.toRequestDto()) - response.result.toDomain() +// response.result.toDomain() }.recoverCatching { UserTheme( - userThemes = listOf(StudyTheme.DISCUSSION, StudyTheme.SELFSTUDY) + userThemes = listOf(StudyTheme.MAJOR_CAREER, StudyTheme.SELF_STUDY) ) } } \ 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 index 8fa07392..f357bb36 100644 --- 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 @@ -1,20 +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 com.umcspot.spot.user.dto.response.UserThemeResponseDto -import com.umcspot.spot.user.model.UserTheme import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST interface UserService { @GET("/api/members/name") suspend fun getUser( ): BaseResponse - @GET("/api/v1/service") + @POST("/api/members/name") + suspend fun setUserName( + @Body request : UserNameRequestDto + ): NullResultResponse + + @POST("/api/members/preferred-categories") suspend fun setUserTheme( @Body request : UserThemeRequestDto - ): BaseResponse + ): NullResultResponse } \ 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 index 30ab04df..b1fe7d50 100644 --- 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 @@ -1,5 +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 index 5da5ba9a..cff7af27 100644 --- 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 @@ -2,6 +2,6 @@ package com.umcspot.spot.user.model import com.umcspot.spot.model.StudyTheme -data class UserTheme( - val userThemes : List +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 index 7cb4b510..6fdcac22 100644 --- 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 @@ -6,5 +6,6 @@ import com.umcspot.spot.user.model.UserTheme interface UserRepository { suspend fun getUserName() : Result - suspend fun setUserTheme(theme : List) : 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/MainNavHost.kt b/feature/main/src/main/java/com/umcspot/spot/main/MainNavHost.kt index c8ba26b1..7ec2b3fe 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 @@ -68,11 +68,13 @@ fun MainNavHost( signupGraph( contentPadding = contentPadding, + navController = navigator.navController, onNextClick = { navigator.navController.navigateToCheckList() } ) checkListGraph( contentPadding = contentPadding, + navController = navigator.navController, onNextClick = { navigator.navController.navigateToSaving() } ) 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 index b30653dd..d63d5386 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt @@ -20,17 +20,18 @@ import com.umcspot.spot.designsystem.component.button.TextButton import com.umcspot.spot.designsystem.component.button.TextButtonM import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.model.StudyTheme +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() - // ✅ VM의 선택 상태 구독 val theme by viewmodel.themes.collectAsStateWithLifecycle() Column( @@ -57,11 +58,12 @@ fun CheckListScreen( Spacer(Modifier.weight(1f)) - // ✅ 다음 버튼: 선택이 있어야 활성화 + TextButton( text = "다음", - enabled = theme != null, + enabled = theme.isNotEmpty(), onClick = { + signUpViewModel.saveNameIfChanged() viewmodel.submitThemes() onNextClick() } @@ -84,15 +86,15 @@ fun ActivityThemeSection( 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.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) } MultiButton( 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 index 29082b64..954221e0 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt @@ -21,28 +21,25 @@ class CheckListViewModel @Inject constructor( private val userRepository: UserRepository ) : ViewModel() { - private val KEY_THEMES = "recruit_filter_themes" // ✅ 복수형 + private val KEY_THEMES = "recruit_filter_themes" private val _themes = MutableStateFlow( (savedStateHandle.get>(KEY_THEMES) ?: emptyList()).toCollection(LinkedHashSet()) ) val themes: StateFlow> = _themes.asStateFlow() - /** 단일 선택 토글 (같은 걸 누르면 해제) */ fun toggleTheme(theme: StudyTheme) { val next = LinkedHashSet(_themes.value) if (!next.add(theme)) next.remove(theme) _themes.value = next - savedStateHandle[KEY_THEMES] = next.toList() // ✅ 저장 + savedStateHandle[KEY_THEMES] = next.toList() } - /** 선택된 테마를 서버에 저장 */ fun submitThemes() { val selected = _themes.value.toList() if (selected.isEmpty()) return viewModelScope.launch { - userRepository.setUserTheme(selected) // List 전달 - // .onSuccess { ... } / .onFailure { ... } 필요 시 처리 + 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 index 9ded9b53..7e3d46a8 100644 --- 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 @@ -1,12 +1,16 @@ 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) { @@ -14,12 +18,20 @@ fun NavController.navigateToCheckList(navOptions: NavOptions? = null) { } fun NavGraphBuilder.checkListGraph( + navController: NavHostController, contentPadding : PaddingValues, onNextClick: () -> Unit, ) { - composable { + composable { backStackEntry -> + + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry(navController.graph.id) + } + val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry) + CheckListScreen( contentPadding = contentPadding, + signUpViewModel= signUpViewModel, onNextClick = onNextClick ) } 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 index b71adbed..36c0b276 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/LoginViewmodel.kt @@ -37,12 +37,11 @@ class LandingViewModel @Inject constructor( private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - /** 카카오/네이버 공용 시작 함수 */ fun startSocialLogin( type: SocialLoginType, - activity: Activity, // ← Activity를 받기 + activity: Activity, ) = viewModelScope.launch { - lastSocialLoginType = type // 🔹 어떤 소셜인지 기억해 둠 + lastSocialLoginType = type if(type == SocialLoginType.KAKAO) { try { @@ -73,7 +72,6 @@ class LandingViewModel @Inject constructor( } else if (type == SocialLoginType.NAVER) { val nidOAuthCallback = object : NidOAuthCallback { override fun onSuccess() { - // 네이버 SDK 내부에서 로그인 성공 val accessToken = NidOAuth.getAccessToken() if (accessToken.isNullOrBlank()) { @@ -90,8 +88,8 @@ class LandingViewModel @Inject constructor( viewModelScope.launch { runCatching { loginRepository.finishSocialLogin( - type = lastSocialLoginType!!, // NAVER - accessToken = accessToken // 네이버 access token + type = lastSocialLoginType!!, + accessToken = accessToken ) }.onSuccess { _events.emit(LoginEvent.LoginSucceeded) @@ -111,7 +109,6 @@ class LandingViewModel @Inject constructor( } try { - // context: @ApplicationContext 주입받은 Context NidOAuth.requestLogin(activity, nidOAuthCallback) } catch (e: Exception) { Log.e(TAG, "네이버 로그인 요청 중 예외", e) 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 index 6e6f4cab..6f454fe5 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt @@ -18,6 +18,7 @@ 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 @@ -42,12 +43,11 @@ fun SavingScreen( autoProgress: Boolean = true, autoDurationMs: Int = 1800, blockBackPress: Boolean = true, - onFinished: () -> Unit, // ⬅️ 끝나면 호출 + onFinished: () -> Unit, ) { val topPad = contentPadding.calculateTopPadding() val bottomPad = contentPadding.calculateBottomPadding() - // 뒤로가기 방지 (원하면 끄기 가능) BackHandler(enabled = blockBackPress) { /* no-op: 뒤로가기 무시 */ } val internal = remember { Animatable(0f) } @@ -61,7 +61,7 @@ fun SavingScreen( animationSpec = tween(durationMillis = autoDurationMs, easing = LinearEasing) ) isDone = true - delay(3000) // 3초 대기 + delay(3000) onFinished() } } @@ -72,7 +72,6 @@ fun SavingScreen( .background(SpotTheme.colors.white) .padding(top = topPad, bottom = bottomPad, start = 16.dp, end = 16.dp) ) { - // 중앙 컨텐츠 Column( modifier = Modifier .align(Alignment.Center) @@ -85,7 +84,7 @@ fun SavingScreen( modifier = Modifier.size(40.dp) ) Spacer(Modifier.height(16.dp)) - androidx.compose.material3.Text( + Text( text = "당신의 스터디 파트너 \n 스팟, SPOT", style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), color = SpotTheme.colors.B500, @@ -93,7 +92,6 @@ fun SavingScreen( ) } - // 하단 진행 영역 Column( modifier = Modifier .align(Alignment.BottomCenter) @@ -102,7 +100,7 @@ fun SavingScreen( .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - androidx.compose.material3.Text( + Text( text = if (isDone) "등록 완료!" else "내 정보 저장 중..", style = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp), color = SpotTheme.colors.B500 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 d89e4cd0..f81f9c08 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 @@ -271,7 +271,7 @@ fun EditableNameRow( }, singleLine = true, shape = SpotShapes.Hard, - textStyle = SpotTheme.typography.bodySmall400.copy(fontSize = 20.sp), + textStyle = SpotTheme.typography.bodySmall400, placeholder = { Text("이름을 입력하세요", style = SpotTheme.typography.bodySmall400) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { 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 c5de14b4..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,5 +1,6 @@ package com.umcspot.spot.signup +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.umcspot.spot.ui.state.UiState @@ -17,8 +18,11 @@ class SignUpViewModel @Inject constructor( private val userRepository: UserRepository ) : ViewModel() { - data class SignupUiState(val user: UiState = UiState.Empty) - + 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() @@ -30,18 +34,58 @@ class SignUpViewModel @Inject constructor( val newState = result.fold( onSuccess = { userResult -> - UiState.Success(userResult.name) // ✅ 성공 시 이름 문자열 전달 + UiState.Success(userResult.name) }, onFailure = { e -> - UiState.Failure(e.message ?: e.toString()) // ✅ 실패 시 에러 메시지 + UiState.Failure(e.message ?: e.toString()) } ) - _name.update { it.copy(user = newState) } + _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)) } // 사용하는 상태 구조에 맞게 반영 + _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 e6c8fc76..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,12 +1,16 @@ 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) { @@ -14,12 +18,20 @@ fun NavController.navigateToSignUp(navOptions: NavOptions? = null) { } fun NavGraphBuilder.signupGraph( + navController: NavHostController, contentPadding : PaddingValues, onNextClick : () -> Unit ) { - composable { + 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 54655297..0035e3fe 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 @@ -249,15 +249,15 @@ fun ActivityThemeSection( 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.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) } MultiButton( From 24c51ec01599cbcf783a61f55e3e05d124832711 Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Tue, 18 Nov 2025 12:19:59 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix=20:=20=EC=9D=B4=EB=A6=84=20API=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/user/repositoryimpl/UserRepositoryImpl.kt | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) 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 index aa1a57dc..a6393a53 100644 --- 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 @@ -22,21 +22,12 @@ class UserRepositoryImpl @Inject constructor( override suspend fun setUserName(name: String): Result = runCatching { - val response = userService.setUserName(name.toRequestDto()) - - if (!response.isSuccess) { - throw IllegalStateException("API 실패: code=${response.code}, msg=${response.message}") - } + userService.setUserName(name.toRequestDto()) } override suspend fun setUserTheme(theme: List): Result = runCatching { - val response = userService.setUserTheme(theme.toRequestDto()) -// response.result.toDomain() - }.recoverCatching { - UserTheme( - userThemes = listOf(StudyTheme.MAJOR_CAREER, StudyTheme.SELF_STUDY) - ) + userService.setUserTheme(theme.toRequestDto()) } } \ No newline at end of file From 69cbe37f5598139d2829184e39bc7ded51782b87 Mon Sep 17 00:00:00 2001 From: yeonwoo Date: Tue, 18 Nov 2025 13:13:41 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix=20:=20debug=20build=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21afc598..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) @@ -7,14 +9,16 @@ plugins { android { namespace = "com.umcspot.spot" - signingConfigs { - getByName("debug") { - storeFile = file("key/SpotKey") - storePassword = "spotspot" - keyAlias = "spotkey0" - keyPassword = "spotspot" - } - } +// signingConfigs { +// getByName("debug") { +// 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") +// } +// } buildTypes { debug { From 863d048fb0802985482ad6dc15e9201533c258f9 Mon Sep 17 00:00:00 2001 From: fredLeeJH Date: Fri, 28 Nov 2025 16:25:37 +0900 Subject: [PATCH 11/11] =?UTF-8?q?#9[feat]=20:=20conflick=201=EC=B0=A8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/designsystem/build.gradle.kts | 2 +- .../spot/designsystem/component/Calender.kt | 6 +- .../spot/designsystem/component/DateHeader.kt | 4 +- .../spot/designsystem/component/HeaderBar.kt | 2 +- .../designsystem/component/appBar/AppBar.kt | 22 +- .../bottomsheet/LocationBottomSheet.kt | 376 +++++++++++++++++ .../component/button/ImageButton.kt | 2 +- .../component/button/ImageTextButton.kt | 232 ++++------ .../{ => button}/SocialLoginButton.kt | 5 +- .../component/button/SpotActivationButton.kt | 57 +++ .../component/button/TextButton.kt | 6 +- .../component/empty/EmptyAlert.kt | 4 +- .../component/modal/AcceptModal.kt | 4 +- .../component/modal/RejectModal.kt | 4 +- .../component/study/EnrollStudyItem.kt | 4 +- .../designsystem/component/study/StudyItem.kt | 6 +- .../study/section/ActivityThemeSection.kt | 100 +++++ .../study/section/ActivityTypeSection.kt | 40 ++ .../designsystem/component/weather/Weather.kt | 4 +- .../umcspot/spot/designsystem/theme/Color.kt | 105 ++--- .../spot/designsystem/theme/TypoGraphy.kt | 395 +++++++++++------- .../src/main/res/drawable/arrow_left.xml | 10 +- .../src/main/res/drawable/dismiss.xml | 12 +- .../src/main/res/drawable/ic_location.xml | 9 + .../com/umcspot/spot/alert/AlertScreen.kt | 14 +- .../umcspot/spot/feature/board/BoardScreen.kt | 16 +- .../java/com/umcspot/spot/home/HomeScreen.kt | 10 +- .../spot/home/navigation/HomeNavigation.kt | 5 +- .../java/com/umcspot/spot/main/MainNavHost.kt | 37 +- .../com/umcspot/spot/main/MainNavigator.kt | 14 +- .../spot/main/component/MainBottomBar.kt | 2 +- .../umcspot/spot/checkList/CheckListScreen.kt | 65 +-- .../spot/checkList/CheckListViewModel.kt | 38 +- .../com/umcspot/spot/landing/SavingScreen.kt | 14 +- .../com/umcspot/spot/signup/AgreementModal.kt | 46 +- .../com/umcspot/spot/signup/SignUpScreen.kt | 79 ++-- .../PreferLocationBottomSheet.kt | 318 -------------- .../PreferLocationStudyScreen.kt | 13 +- .../recruiting/RecruitingStudyFilterScreen.kt | 203 ++++----- .../RecruitingStudyFilterViewmodel.kt | 117 +++--- .../study/recruiting/RecruitingStudyScreen.kt | 8 +- .../study/register/RegisterStudyScreen.kt | 223 ++++++++++ .../study/register/RegisterStudyViewModel.kt | 178 ++++++++ .../register/component/BinaryChoiceRow.kt | 55 +++ .../register/component/FeeInputSection.kt | 49 +++ .../register/component/MemberCountSelector.kt | 157 +++++++ .../register/component/PriceTextField.kt | 79 ++++ .../component/SelectedRegionsSection.kt | 129 ++++++ .../study/register/component/SelectionChip.kt | 54 +++ .../register/component/StepProgressBar.kt | 48 +++ .../register/component/StudyNameTextField.kt | 108 +++++ .../register/model/RegisterStudyState.kt | 35 ++ .../navigation/RegisterStudyNavigation.kt | 38 ++ .../register/screen/StudyCategoryScreen.kt | 68 +++ .../study/register/screen/StudyInfoScreen.kt | 104 +++++ .../register/screen/StudyIntroduceScreen.kt | 154 +++++++ .../study/register/screen/StudyPlaceScreen.kt | 76 ++++ 57 files changed, 2860 insertions(+), 1105 deletions(-) create mode 100644 core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt rename core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/{ => button}/SocialLoginButton.kt (96%) create mode 100644 core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt create mode 100644 core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt create mode 100644 core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityTypeSection.kt create mode 100644 core/designsystem/src/main/res/drawable/ic_location.xml delete mode 100644 feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/StepProgressBar.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/component/StudyNameTextField.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt create mode 100644 feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 0cd67c48..f2b3a718 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -10,7 +10,7 @@ dependencies { implementation(projects.core.model) implementation(libs.flexible.bottomsheet) implementation(libs.kizitonwose.calendar.compose) - + implementation(projects.core.common) implementation(projects.domain.weather) implementation(projects.domain.study) implementation(projects.domain.alert) diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt index fa231366..45ffa80e 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/Calender.kt @@ -103,7 +103,7 @@ fun SpotMonthCalendar( Text( text = "${currentYm.year}년 ${currentYm.monthValue}월", - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.small_500, fontSize = 26.sp, modifier = Modifier.weight(1f), textAlign = TextAlign.Center @@ -152,7 +152,7 @@ private fun WeekdayRow() { labels.forEachIndexed { idx, label -> Text( text = label, - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.small_500, fontSize = 16.sp, // ✅ 일요일 컬럼은 헤더도 B500 color = if (idx == 6) SpotTheme.colors.B500 else SpotTheme.colors.black, @@ -193,7 +193,7 @@ private fun DayCellStyled( onClick = onClick ) - val dateTextStyle = SpotTheme.typography.bodyMedium600.copy(fontSize = 18.sp) + val dateTextStyle = SpotTheme.typography.small_500.copy(fontSize = 18.sp) val measurer = rememberTextMeasurer() val density = LocalDensity.current val textHeightDp = remember(day.date.dayOfMonth) { diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt index 1269cf47..6b00fb9c 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/DateHeader.kt @@ -123,7 +123,7 @@ fun CompactDateTimeRow( ) { Text( text = label, - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.small_500, fontSize = 12.sp, color = SpotTheme.colors.G400, modifier = Modifier.weight(1f) @@ -163,7 +163,7 @@ private fun PillChip( ) { Text( text = text, - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.small_500, fontSize = 12.sp ) } diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/HeaderBar.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/HeaderBar.kt index 4b28cdd4..49c376bc 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/HeaderBar.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/HeaderBar.kt @@ -26,7 +26,7 @@ fun SheetTopBar( title: String, onCloseClick: () -> Unit, modifier: Modifier = Modifier, - titleStyle: TextStyle = SpotTheme.typography.header05, + titleStyle: TextStyle = SpotTheme.typography.h5, backgroundColor: Color = SpotTheme.colors.G100, @DrawableRes closeIconRes: Int = R.drawable.dismiss ) { diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt index 8659a886..1b7f125c 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/appBar/AppBar.kt @@ -38,6 +38,7 @@ import com.umcspot.spot.designsystem.theme.G300 import com.umcspot.spot.designsystem.theme.G400 import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.designsystem.theme.White +import com.umcspot.spot.ui.extension.screenWidthDp @Composable @@ -55,7 +56,7 @@ fun AppBarHome ( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - // 로고 + Image( painter = painterResource(id = R.drawable.spot_logo), contentDescription = "App Logo", @@ -119,25 +120,24 @@ fun BackTopBar( modifier = modifier .fillMaxWidth() .background(SpotTheme.colors.white) - .padding(vertical = 6.dp), + .padding(start = 5.dp, top = 16.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start // ✅ 왼쪽 정렬 고정verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.Start ) { IconButton( onClick = onBackClick, ) { Icon( painter = painterResource(id = R.drawable.arrow_left), - contentDescription = "Back", - modifier = Modifier.size(25.dp) + contentDescription = "Back" ) } - Spacer(Modifier.width(8.dp)) + Spacer(modifier = Modifier.width((-2).dp)) + Text( text = title, - style = SpotTheme.typography.bodyLarge500, - fontSize = 18.sp + style = SpotTheme.typography.h5 ) } } @@ -165,7 +165,7 @@ fun SearchTopBar( borderWidth : Dp = 1.dp, borderColor: Color = SpotTheme.colors.G300, backgroundColor: Color = White, - modifier: Modifier = Modifier, // ✅ 추가 + modifier: Modifier = Modifier, ) { Row( modifier = modifier @@ -174,7 +174,7 @@ fun SearchTopBar( .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { - // Back + IconButton(onClick = onBackClick) { Icon( painter = painterResource(id = R.drawable.arrow_left), @@ -185,7 +185,7 @@ fun SearchTopBar( Spacer(Modifier.width(8.dp)) - // Search pill + Box( modifier = Modifier.weight(1f) ) { diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt new file mode 100644 index 00000000..7311819e --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/bottomsheet/LocationBottomSheet.kt @@ -0,0 +1,376 @@ +package com.umcspot.spot.designsystem.component.bottomsheet + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.designsystem.R +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.G200 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationBottomSheet( + visible: Boolean, + query: String, + results: List, + onQueryChange: (String) -> Unit, + onDismiss: () -> Unit, + selected: List, + onAddSelected: (String) -> Unit, + onRemoveSelected: (String) -> Unit +) { + if (!visible) return + + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val blurFocusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } + + val density = LocalDensity.current + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + + val sheetHeight = screenHeightDp(533.dp) + val scope = rememberCoroutineScope() + val sheetOffset = remember { Animatable(with(density) { screenHeight.toPx() }) } + val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + + fun animateAndDismiss() { + scope.launch { + blurFocusRequester.requestFocus() + keyboard?.hide() + sheetOffset.animateTo( + targetValue = with(density) { screenHeight.toPx() }, + animationSpec = tween(250) + ) + onDismiss() + } + } + + LaunchedEffect(visible) { + if (visible) { + val screenHeightPx = with(density) { screenHeight.toPx() } + val sheetHeightPx = with(density) { sheetHeight.toPx() } + val openY = screenHeightPx - sheetHeightPx + sheetOffset.snapTo(screenHeightPx) + sheetOffset.animateTo(openY, animationSpec = tween(300)) + } + } + if (visible) { + Dialog( + onDismissRequest = { animateAndDismiss() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + BackHandler(enabled = true) { animateAndDismiss() } + + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .matchParentSize() + .background(SpotTheme.colors.black.copy(alpha = 0.4f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + animateAndDismiss() + } + ) + + Box( + Modifier + .fillMaxWidth() + .height(sheetHeight) + .offset { IntOffset(0, sheetOffset.value.roundToInt()) } + .clip(SpotShapes.SoftTop) + .background(SpotTheme.colors.white) + .imePadding() + .focusRequester(blurFocusRequester) + .focusable() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + + blurFocusRequester.requestFocus() + keyboard?.hide() + } + ) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = screenWidthDp(17.dp)) + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(16.dp)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.width(screenWidthDp(24.dp))) + Text( + text = "스터디 지역", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black + ) + IconButton(onClick = { animateAndDismiss() }) { + Icon( + painter = painterResource(R.drawable.dismiss), + contentDescription = "닫기", + ) + } + } + + Spacer(Modifier.height(screenHeightDp(18.dp))) + + Text( + text = "스터디를 진행하고 싶은 지역을 추가해주세요.", + style = SpotTheme.typography.h3, + color = SpotTheme.colors.black + ) + + Spacer(Modifier.height(screenHeightDp(6.dp))) + + Text( + text = "최대 3개까지 추가할 수 있어요", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.gray400 + ) + + Spacer(Modifier.height(screenHeightDp(20.dp))) + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + textStyle = SpotTheme.typography.h5, + placeholder = { + Text( + text = "OO시, OO구, OO동", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.gray400 + ) + }, + trailingIcon = { + IconButton( + onClick = { + if (isFocused) { + blurFocusRequester.requestFocus() + keyboard?.hide() + } else { + focusRequester.requestFocus() + keyboard?.show() + } + }) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = "검색", + modifier = Modifier.size(18.dp) + ) + } + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = SpotTheme.colors.B500, + unfocusedBorderColor = SpotTheme.colors.gray400, + cursorColor = SpotTheme.colors.B500 + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { fs -> + isFocused = fs.isFocused + if (isFocused) keyboard?.show() + } + ) + + SelectedChips( + items = selected, + onRemove = onRemoveSelected + ) + + if (results.isNotEmpty()) { + val isMaxSelected = selected.size >= 3 + + HorizontalDivider(thickness = 0.5.dp, color = SpotTheme.colors.G200) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = navBarPadding) + .background(SpotTheme.colors.white), + ) { + itemsIndexed(results, key = { _, row -> row.code }) { index, row -> + val isAlreadySelected = selected.contains(row.name) + ListItem( + headlineContent = { + Text( + text = row.name, + style = SpotTheme.typography.h5, + maxLines = 1, + color = if (isMaxSelected && !isAlreadySelected) { + SpotTheme.colors.gray400 + } else { + LocalContentColor.current + } + ) + }, + colors = ListItemDefaults.colors( + containerColor = SpotTheme.colors.white + ), + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = !isMaxSelected || isAlreadySelected, + onClick = { + if (!isAlreadySelected) { + onAddSelected(row.name) + } + } + ) + ) + + HorizontalDivider( + thickness = 0.5.dp, + color = SpotTheme.colors.G200 + ) + } + } + } else { + if (query.isNotBlank()) { + Text( + + text = "검색 결과가 없습니다.", + color = SpotTheme.colors.gray400, + style = SpotTheme.typography.small_300, + modifier = Modifier.padding(top = 14.dp), + ) + } + } + } + } + } + } + } +} + + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedChips( + items: List, + onRemove: (String) -> Unit +) { + if (items.isEmpty()) return + + val scrollState = rememberScrollState() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = screenHeightDp(13.dp)) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(7.dp)) + ) { + items.forEach { name -> + AssistChip( + onClick = {}, + label = { + Text( + name, + style = SpotTheme.typography.small_400 + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.dismiss), + contentDescription = "삭제", + tint = SpotTheme.colors.B500, + modifier = Modifier + .size(14.dp) + .clickable { onRemove(name) } + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = SpotTheme.colors.B100, + labelColor = SpotTheme.colors.B500 + ), + border = BorderStroke(1.dp, SolidColor(SpotTheme.colors.B100)), + shape = RoundedCornerShape(6.dp) + ) + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageButton.kt index a167f881..3789444f 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageButton.kt @@ -167,7 +167,7 @@ fun preview() { Spacer(Modifier.height(6.dp)) Text( text = "내 지역", - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.small_500, fontSize = 14.sp, color = Black, maxLines = 1 diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt index b216383d..58ca5dc6 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/ImageTextButton.kt @@ -5,15 +5,15 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,36 +22,31 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.Dp.Companion.Unspecified -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.unit.sp import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.shapes.ShapeBox -import com.umcspot.spot.designsystem.shapes.SpotShapes import com.umcspot.spot.designsystem.theme.B100 import com.umcspot.spot.designsystem.theme.B200 import com.umcspot.spot.designsystem.theme.B400 +import com.umcspot.spot.designsystem.theme.B500 import com.umcspot.spot.designsystem.theme.Black import com.umcspot.spot.designsystem.theme.G400 import com.umcspot.spot.designsystem.theme.SpotTheme import com.umcspot.spot.designsystem.theme.White +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp data class MultiButtonColors( val bg: Color, val icon: Color, - val text : Color, + val text: Color, ) enum class MultiButtonState( @@ -74,12 +69,12 @@ enum class MultiButtonState( pressed = MultiButtonColors( bg = B200, icon = B400, - text = B400 + text = B500, ), selected = MultiButtonColors( bg = B100, icon = B400, - text = B400 + text = B500 ) ) } @@ -90,173 +85,118 @@ fun MultiButtonState.resolveColors( checked: Boolean ): MultiButtonColors = when { !enabled -> disabled - checked -> selected + checked -> selected isPressed -> pressed else -> normal } -/** 이미지 전용 버튼 크기 토큰 */ -enum class MultiButtonSize( - val size: Dp, - val icon: Dp, - val text: TextUnit -) { - XL(56.dp, 28.dp, 15.sp), - L (52.dp, 24.dp, 15.sp), - M (48.dp, 22.dp, 15.sp), - S (44.dp, 20.dp, 15.sp), - XS(40.dp, 18.dp, 15.sp); -} -@Composable -fun MultiButtonSize.textStyle(): TextStyle = when (this) { - MultiButtonSize.XL, MultiButtonSize.L -> SpotTheme.typography.header03 - MultiButtonSize.M -> SpotTheme.typography.header04 - MultiButtonSize.S, MultiButtonSize.XS -> SpotTheme.typography.header05 -} - -private val MultiButtonSize.horizontalPadding: Dp get() = when (this) { - MultiButtonSize.XL, MultiButtonSize.L -> 16.dp - MultiButtonSize.M -> 14.dp - MultiButtonSize.S -> 12.dp - MultiButtonSize.XS -> 10.dp -} -private val MultiButtonSize.gap: Dp get() = when (this) { - MultiButtonSize.XL, MultiButtonSize.L -> 8.dp - MultiButtonSize.M, MultiButtonSize.S -> 6.dp - MultiButtonSize.XS -> 4.dp -} - -@Composable -fun MultiButtonSize.shape(): Shape = SpotShapes.Hard - @Composable fun MultiButton( text: String, modifier: Modifier = Modifier, - size: MultiButtonSize = MultiButtonSize.M, enabled: Boolean = true, state: MultiButtonState = MultiButtonState.XOUTLINEState, - // 토글 유지 checked: Boolean = false, - width : Dp = Unspecified, - // ✅ 단일 콜백: 컴포넌트가 계산한 newChecked 전달 onClick: (newChecked: Boolean) -> Unit, - // 아이콘 지정: painter가 우선 painter: Painter? = null, - - // 아이콘 틴트 사용 여부 (true면 state의 icon 색상 사용) tintIcon: Boolean = false, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val isPressed by interactionSource.collectIsPressedAsState() - val widthMod = if (width.isSpecified) Modifier.width(width) else Modifier.fillMaxWidth() - val colors = state.resolveColors( enabled = enabled, isPressed = isPressed, checked = checked ) - // 접근성: 토글 가능 시 selected 노출 - val clickableModifier = modifier - .then(widthMod) - .heightIn(min = size.size) - .semantics { - role = Role.Button - selected = checked - } - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null - ) { - val newChecked = checked - onClick(newChecked) - } - - // 컨테이너: 보더 없이 배경만 - ShapeBox( - shape = SpotShapes.Hard, - color = colors.bg, - borderWidth = 0.dp, - borderColor = null, - modifier = clickableModifier.height(size.size) // ✅ 동일 로직 적용 - + Box( + modifier = modifier + .semantics { + role = Role.Button + selected = checked + } + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null + ) { + onClick(!checked) + } ) { - Row( + ShapeBox( + shape = RoundedCornerShape(10.dp), + color = colors.bg, + borderWidth = 0.dp, + borderColor = null, modifier = Modifier - .height(size.size) - .fillMaxWidth() - .padding(horizontal = size.horizontalPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start + .width(screenWidthDp(156.dp)) + .height(screenHeightDp(43.dp)) ) { - painter?.let { - if (tintIcon) { - Icon( - painter = it, - contentDescription = null, - tint = colors.icon, - modifier = Modifier.size(size.icon) - ) - } else { - Image( - painter = it, - contentDescription = null, - modifier = Modifier.size(size.icon) + Box( + modifier = Modifier.matchParentSize(), + contentAlignment = Alignment.CenterStart + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = screenWidthDp(8.dp)) + ) { + painter?.let { + val iconModifier = Modifier.size(screenHeightDp(33.dp)) + if (tintIcon) { + Icon( + painter = it, + contentDescription = null, + tint = colors.icon, + modifier = iconModifier + ) + } else { + Image( + painter = it, + contentDescription = null, + modifier = iconModifier + ) + } + Spacer(Modifier.width(screenWidthDp(8.dp))) + } + Text( + text = text, + style = SpotTheme.typography.h4, + color = colors.text, + maxLines = 1 ) } - Spacer(Modifier.width(size.gap)) } - - // 텍스트 - Text( - text = text, - style = size.textStyle(), - fontSize = size.text, - color = colors.text, - maxLines = 1 - ) } } } -@Composable -fun MultiButtonM( - text: String, - checked: Boolean, - onClick: (Boolean) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - width : Dp = Unspecified, - state: MultiButtonState = MultiButtonState.XOUTLINEState, - painter: Painter? = null, - tintIcon: Boolean = false -) = MultiButton( - text = text, - modifier = modifier, - size = MultiButtonSize.M, - enabled = enabled, - state = state, - width = width, - checked = checked, - onClick = onClick, - painter = painter, - tintIcon = tintIcon -) - @Preview(showBackground = true) @Composable fun MultiButtonPreview() { SpotTheme { - MultiButtonM( - text = "온라인", - onClick = {}, - modifier = Modifier.padding(10.dp), - checked = false, - width = 156.dp, - painter = painterResource(R.drawable.language), - tintIcon = false - ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MultiButton( + text = "어학", + onClick = {}, + checked = true, + painter = painterResource(R.drawable.language), + ) + MultiButton( + text = "자격증", + onClick = {}, + checked = false, + painter = painterResource(R.drawable.license), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MultiButton( + text = "프로젝트", + onClick = {}, + checked = false, + painter = painterResource(R.drawable.project), + ) + } + } } -} \ No newline at end of file +} diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SocialLoginButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt similarity index 96% rename from core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SocialLoginButton.kt rename to core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt index af8fd6ef..556bb3db 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/SocialLoginButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SocialLoginButton.kt @@ -1,4 +1,4 @@ -package com.umcspot.spot.designsystem.component +package com.umcspot.spot.designsystem.component.button import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -22,7 +22,6 @@ import androidx.compose.ui.unit.sp import com.umcspot.spot.designsystem.theme.* import com.umcspot.spot.designsystem.R import com.umcspot.spot.designsystem.shapes.SpotShapes -import com.umcspot.spot.designsystem.theme.SpotTypography // ===== 공통 베이스 버튼 ===== @Composable @@ -67,7 +66,7 @@ private fun SocialSignButton( // 텍스트: 항상 가운데 정렬 Text( text = text, - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.h4, fontSize = 18.sp, modifier = Modifier.align(Alignment.Center) ) diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt new file mode 100644 index 00000000..104e6927 --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/SpotActivationButton.kt @@ -0,0 +1,57 @@ +package com.umcspot.spot.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.G300 +import com.umcspot.spot.designsystem.theme.G400 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp + +@Composable +fun SpotActivationButton( + buttonText: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = false +) { + val borderColor = if (isEnabled) SpotTheme.colors.B500 else SpotTheme.colors.G300 + val textColor = if (isEnabled) SpotTheme.colors.B500 else SpotTheme.colors.G400 + val backgroundColor = if (isEnabled) Color.Transparent else Color.Transparent + val shape = RoundedCornerShape(10.dp) + + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .border( + width = 1.dp, + color = borderColor, + shape = shape + ) + .background(color = backgroundColor) + .noRippleClickable(onClick = { if (isEnabled) onClick() }) + .padding(vertical = screenHeightDp(10.dp)), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = buttonText, + style = SpotTheme.typography.h3, + color = textColor + ) + } +} diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt index 5aa03bc0..8af2fb76 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/button/TextButton.kt @@ -179,9 +179,9 @@ enum class TextButtonSize( @Composable fun TextButtonSize.textStyle(): TextStyle = when (this) { - TextButtonSize.XL, TextButtonSize.L -> SpotTheme.typography.header03 - TextButtonSize.M -> SpotTheme.typography.header04 - TextButtonSize.S, TextButtonSize.XS -> SpotTheme.typography.header05 + TextButtonSize.XL, TextButtonSize.L -> SpotTheme.typography.h3 + TextButtonSize.M -> SpotTheme.typography.h4 + TextButtonSize.S, TextButtonSize.XS -> SpotTheme.typography.h5 } @Composable diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/empty/EmptyAlert.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/empty/EmptyAlert.kt index 52be675b..87ee3aeb 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/empty/EmptyAlert.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/empty/EmptyAlert.kt @@ -50,7 +50,7 @@ fun EmptyAlert( Spacer(Modifier.height(12.dp)) Text( text = alertTitle, - style = SpotTheme.typography.header05, + style = SpotTheme.typography.h5, fontSize = 30.sp, color = SpotTheme.colors.B500, textAlign = TextAlign.Center @@ -58,7 +58,7 @@ fun EmptyAlert( Spacer(Modifier.height(15.dp)) Text( text = alertDes, - style = SpotTheme.typography.header05, + style = SpotTheme.typography.h5, color = SpotTheme.colors.G400, fontSize = 25.sp, textAlign = TextAlign.Center diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/AcceptModal.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/AcceptModal.kt index 4b4e3dc7..05ccf8e3 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/AcceptModal.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/AcceptModal.kt @@ -63,13 +63,13 @@ fun AcceptModal( ) { Text( text = modalTitle, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 16.sp), maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = modalDes, - style = SpotTheme.typography.bodySmall500.copy(fontSize = 12.sp) + style = SpotTheme.typography.small_500.copy(fontSize = 12.sp) ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt index 14fc7433..370eb8f3 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/modal/RejectModal.kt @@ -60,13 +60,13 @@ fun RejectModal( ) { Text( text = modalTitle, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 16.sp), maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = modalDes, - style = SpotTheme.typography.bodySmall500.copy(fontSize = 12.sp) + style = SpotTheme.typography.small_500.copy(fontSize = 12.sp) ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/EnrollStudyItem.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/EnrollStudyItem.kt index 90f9da38..17cf9fb7 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/EnrollStudyItem.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/EnrollStudyItem.kt @@ -55,13 +55,13 @@ fun EnrollStudyListItem( ) { Text( text = item.title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 16.sp), maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = "호스트가 스터디 참여를 수락했어요.\n스터디 참여 여부를 최종 픽스해주세요.", - style = SpotTheme.typography.bodySmall500.copy(fontSize = 12.sp) + style = SpotTheme.typography.small_500.copy(fontSize = 12.sp) ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt index b2a0cc43..b1c64fcc 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/StudyItem.kt @@ -58,13 +58,13 @@ fun StudyListItem( ) { Text( text = item.title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 16.sp), maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = item.goal, - style = SpotTheme.typography.bodySmall500.copy(fontSize = 14.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -110,7 +110,7 @@ private fun Stat( modifier = Modifier.size(14.dp) ) - Text(text = display, style = SpotTheme.typography.bodySmall500.copy(fontSize = 12.sp)) + Text(text = display, style = SpotTheme.typography.small_500.copy(fontSize = 12.sp)) } } 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 new file mode 100644 index 00000000..1a48b81d --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityThemeSection.kt @@ -0,0 +1,100 @@ +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.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 kotlinx.collections.immutable.ImmutableList + +@Composable +fun ActivityThemeSection( + selectedTheme: StudyTheme?, + onSelect: (StudyTheme) -> Unit, + modifier: Modifier = Modifier +) { + BaseActivityThemeSection( + modifier = modifier, + isThemeSelected = { it == selectedTheme }, + isThemeEnabled = { true }, + onSelect = onSelect + ) +} + +@Composable +fun ActivityThemeSection( + selectedThemes: ImmutableList, + onSelect: (StudyTheme) -> Unit, + 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) + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(screenHeightDp(16.dp)) + ) { + themesInRows.forEach { themesInRow -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)) + ) { + themesInRow.forEach { theme -> + MultiButton( + modifier = Modifier.weight(1f), + text = theme.title, + 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.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.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/component/study/section/ActivityTypeSection.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityTypeSection.kt new file mode 100644 index 00000000..0b2f0bc0 --- /dev/null +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/study/section/ActivityTypeSection.kt @@ -0,0 +1,40 @@ +package com.umcspot.spot.designsystem.component.study.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.ActivityType +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun ActivityTypeSection( + activityType: ActivityType?, + onSelect: (ActivityType) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(screenWidthDp(14.dp)) + ) { + ActivityType.entries.forEach { type -> + val iconRes = when (type) { + ActivityType.ONLINE -> painterResource(R.drawable.online) + ActivityType.OFFLINE -> painterResource(R.drawable.offline) + } + + MultiButton( + text = type.label, + painter = iconRes, + checked = activityType == type, + onClick = { + onSelect(type) + }, + ) + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/weather/Weather.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/weather/Weather.kt index 7c98987c..64024aa6 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/weather/Weather.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/component/weather/Weather.kt @@ -92,14 +92,14 @@ fun WeatherCard( Spacer(Modifier.width(8.dp)) Text( text = "${"%.1f".format(temperature?.toFloat())} °C", - style = SpotTheme.typography.header01, + style = SpotTheme.typography.h1, fontSize = 25.sp, color = SpotTheme.colors.white ) } Text( text = message, - style = SpotTheme.typography.bodySmall500, + style = SpotTheme.typography.small_500, fontSize = 14.sp, color = SpotTheme.colors.white ) diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt index 9f518473..0bf0c88d 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/Color.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -/****************** base color ******************/ + val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) @@ -16,24 +16,15 @@ val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) - -/****************** SPOT COLOR ******************/ - -/** Primary **/ - val B500 = Color(0xFF005BFF) val B400 = Color(0xFF337BFF) -/** Secondary **/ - val Y400 = Color(0xFFFD8653) val R500 = Color(0xFFF34343) val B200 = Color(0xFFD3E1FD) val B100 = Color(0xFFEDF4FF) -/** Gray Scale **/ - val Black = Color(0xFF1E1E1E) val G500 = Color(0xFF4F4F56) val G400 = Color(0xFF8F8F99) @@ -46,44 +37,26 @@ val NaverGreen = Color(0xFF03CF5D) val KakaoYellow = Color(0xFFFFEC00) val KakaoText = Color(0xFF3C1E1E) -/** Gradiant **/ - val BlueGradient = Brush.linearGradient( colors = listOf(B400, B500), - start = Offset(0f, 0f), // A 지점 - end = Offset(1000f, 500f) // B 지점 (대각선) + start = Offset(0f, 0f), + end = Offset(1000f, 500f) ) val GrayGradient = Brush.linearGradient( colors = listOf(G300, G500), - start = Offset(0f, 0f), // A 지점 - end = Offset(1000f, 500f) // B 지점 + start = Offset(0f, 0f), + end = Offset(1000f, 500f) ) -/* - Gradiant 적용 예시 - - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .background(BlueGradient) // 또는 GrayGradient - ) - - */ - - -// --- Primary / Blue --- val SpotColors.B500: Color get() = primary val SpotColors.B400: Color get() = primaryStrong val SpotColors.B200: Color get() = primarySoft val SpotColors.B100: Color get() = primarySoftest -// --- Secondary / Error --- val SpotColors.Y400: Color get() = secondary val SpotColors.R500: Color get() = error -// --- Gray Scale --- val SpotColors.Black: Color get() = black val SpotColors.White: Color get() = white val SpotColors.G500: Color get() = gray500 @@ -91,24 +64,17 @@ val SpotColors.G400: Color get() = gray400 val SpotColors.G300: Color get() = gray300 val SpotColors.G200: Color get() = gray200 val SpotColors.G100: Color get() = gray100 +val SpotColors.Default: Color get() = default -// --- Brand etc. --- val SpotColors.NaverGreen: Color get() = naverGreen val SpotColors.KakaoYellow: Color get() = kakaoYellow val SpotColors.KakaoText: Color get() = kakaoText -// --- Alpha precomputed (이름을 토큰처럼 노출) --- val SpotColors.BlackA80: Color get() = blackA80 val SpotColors.BlackA50: Color get() = blackA50 val SpotColors.WhiteA50: Color get() = whiteA50 val SpotColors.WhiteA10: Color get() = whiteA10 -/* ----------------------------------------------------------- - * 테마 인지형 그라디언트 - * - 기존 정적 BlueGradient/GrayGradient를 대체 가능 - * - 다크/동적 테마 대응 - * ----------------------------------------------------------- */ - fun SpotColors.blueGradient( start: Offset = Offset(0f, 0f), end: Offset = Offset(1000f, 500f) @@ -121,61 +87,62 @@ fun SpotColors.grayGradient( @Stable class SpotColors( - primary: Color, // 주 브랜드 컬러(진) - primaryStrong: Color, // 보조 진한 파랑 - primarySoft: Color, // 연파랑 1 - primarySoftest: Color, // 연파랑 2 - secondary: Color, // 포인트(오렌지/옐로우) - error: Color, // 에러 + primary: Color, + primaryStrong: Color, + primarySoft: Color, + primarySoftest: Color, + secondary: Color, + error: Color, gray500: Color, gray400: Color, gray300: Color, gray200: Color, gray100: Color, + default: Color, black: Color, white: Color, naverGreen: Color, kakaoYellow: Color, kakaoText: Color, - // 자주 쓰는 알파 색상은 내부에서 계산해둠 blackAlpha80: Color, blackAlpha50: Color, whiteAlpha50: Color, whiteAlpha10: Color, isLight: Boolean ) { - var primary by mutableStateOf(primary); private set + var primary by mutableStateOf(primary); private set var primaryStrong by mutableStateOf(primaryStrong); private set - var primarySoft by mutableStateOf(primarySoft); private set + var primarySoft by mutableStateOf(primarySoft); private set var primarySoftest by mutableStateOf(primarySoftest); private set - var secondary by mutableStateOf(secondary); private set - var error by mutableStateOf(error); private set + var secondary by mutableStateOf(secondary); private set + var error by mutableStateOf(error); private set - var gray500 by mutableStateOf(gray500); private set - var gray400 by mutableStateOf(gray400); private set - var gray300 by mutableStateOf(gray300); private set - var gray200 by mutableStateOf(gray200); private set - var gray100 by mutableStateOf(gray100); private set + var gray500 by mutableStateOf(gray500); private set + var gray400 by mutableStateOf(gray400); private set + var gray300 by mutableStateOf(gray300); private set + var gray200 by mutableStateOf(gray200); private set + var gray100 by mutableStateOf(gray100); private set + var default by mutableStateOf(default); private set - var black by mutableStateOf(black); private set - var white by mutableStateOf(white); private set + var black by mutableStateOf(black); private set + var white by mutableStateOf(white); private set - var naverGreen by mutableStateOf(naverGreen); private set - var kakaoYellow by mutableStateOf(kakaoYellow); private set - var kakaoText by mutableStateOf(kakaoText); private set + var naverGreen by mutableStateOf(naverGreen); private set + var kakaoYellow by mutableStateOf(kakaoYellow); private set + var kakaoText by mutableStateOf(kakaoText); private set - var blackA80 by mutableStateOf(blackAlpha80); private set - var blackA50 by mutableStateOf(blackAlpha50); private set - var whiteA50 by mutableStateOf(whiteAlpha50); private set - var whiteA10 by mutableStateOf(whiteAlpha10); private set + var blackA80 by mutableStateOf(blackAlpha80); private set + var blackA50 by mutableStateOf(blackAlpha50); private set + var whiteA50 by mutableStateOf(whiteAlpha50); private set + var whiteA10 by mutableStateOf(whiteAlpha10); private set var isLight by mutableStateOf(isLight) fun copy() = SpotColors( primary, primaryStrong, primarySoft, primarySoftest, secondary, error, - gray500, gray400, gray300, gray200, gray100, + gray500, gray400, gray300, gray200, gray100, default, black, white, naverGreen, kakaoYellow, kakaoText, blackA80, blackA50, whiteA50, whiteA10, @@ -196,6 +163,7 @@ class SpotColors( gray300 = colors.gray300 gray200 = colors.gray200 gray100 = colors.gray100 + default = colors.default black = colors.black white = colors.white @@ -213,7 +181,6 @@ class SpotColors( } } -/** 상단 토큰을 그대로 꽂아넣는 라이트 팔레트 */ fun SpotDayColors(): SpotColors = SpotColors( primary = B500, primaryStrong = B400, @@ -228,6 +195,7 @@ fun SpotDayColors(): SpotColors = SpotColors( gray300 = G300, gray200 = G200, gray100 = G100, + default = G300, black = Black, white = White, @@ -236,11 +204,10 @@ fun SpotDayColors(): SpotColors = SpotColors( kakaoYellow = KakaoYellow, kakaoText = KakaoText, - // 알파 컬러는 여기서 계산 blackAlpha80 = Black.copy(alpha = 0.8f), blackAlpha50 = Black.copy(alpha = 0.5f), whiteAlpha50 = White.copy(alpha = 0.5f), whiteAlpha10 = White.copy(alpha = 0.1f), isLight = true -) +) \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/TypoGraphy.kt b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/TypoGraphy.kt index e24e6d81..b0156c40 100644 --- a/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/TypoGraphy.kt +++ b/core/designsystem/src/main/java/com/umcspot/spot/designsystem/theme/TypoGraphy.kt @@ -7,13 +7,20 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.umcspot.spot.designsystem.R @@ -28,175 +35,271 @@ val Pretendard = FontFamily( Font(R.font.pretendard_extrabold, weight = FontWeight.W800), ) -/** - * SpotTheme가 CompositionLocal로 제공/갱신할 실제 타입. - * var + update(other) 형태로 remember{copy()}/update() 패턴을 지원한다. - */ -data class SpotTypography( - var header01: TextStyle, - var header02: TextStyle, - var header03: TextStyle, - var header04: TextStyle, - var header05: TextStyle, - var bodyLarge600: TextStyle, - var bodyLarge500: TextStyle, - var bodyMedium600: TextStyle, - var bodyMedium500: TextStyle, - var bodyRegular500: TextStyle, - var bodyRegular400: TextStyle, - var bodySmall500: TextStyle, - var bodySmall400: TextStyle, - var bodySmall300: TextStyle, +private fun SpotTextStyle( + fontWeight: FontWeight, + fontSize: TextUnit, + lineHeight: TextUnit, + letterSpacing: TextUnit +): TextStyle = TextStyle( + fontFamily = Pretendard, + fontWeight = fontWeight, + fontSize = fontSize, + lineHeight = lineHeight, + letterSpacing = letterSpacing, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None + ) +) + +@Stable +class SpotTypography internal constructor( + h1: TextStyle, + h2: TextStyle, + h3: TextStyle, + h4: TextStyle, + h5: TextStyle, + large_500: TextStyle, + large_400: TextStyle, + medium_500: TextStyle, + medium_400: TextStyle, + regular_500: TextStyle, + regular_400: TextStyle, + small_500: TextStyle, + small_400: TextStyle, + small_300: TextStyle, ) { + var h1 by mutableStateOf(h1) + private set + var h2 by mutableStateOf(h2) + private set + var h3 by mutableStateOf(h3) + private set + var h4 by mutableStateOf(h4) + private set + var h5 by mutableStateOf(h5) + private set + var large_500 by mutableStateOf(large_500) + private set + var large_400 by mutableStateOf(large_400) + private set + var medium_500 by mutableStateOf(medium_500) + private set + var medium_400 by mutableStateOf(medium_400) + private set + var regular_500 by mutableStateOf(regular_500) + private set + var regular_400 by mutableStateOf(regular_400) + private set + var small_500 by mutableStateOf(small_500) + private set + var small_400 by mutableStateOf(small_400) + private set + var small_300 by mutableStateOf(small_300) + private set + + fun copy(): SpotTypography = SpotTypography( + h1 = h1, + h2 = h2, + h3 = h3, + h4 = h4, + h5 = h5, + large_500 = large_500, + large_400 = large_400, + medium_500 = medium_500, + medium_400 = medium_400, + regular_500 = regular_500, + regular_400 = regular_400, + small_500 = small_500, + small_400 = small_400, + small_300 = small_300, + ) + fun update(other: SpotTypography) { - header01 = other.header01 - header02 = other.header02 - header03 = other.header03 - header04 = other.header04 - header05 = other.header05 - bodyLarge600 = other.bodyLarge600 - bodyLarge500 = other.bodyLarge500 - bodyMedium600 = other.bodyMedium600 - bodyMedium500 = other.bodyMedium500 - bodyRegular500 = other.bodyRegular500 - bodyRegular400 = other.bodyRegular400 - bodySmall500 = other.bodySmall500 - bodySmall400 = other.bodySmall400 - bodySmall300 = other.bodySmall300 + h1 = other.h1 + h2 = other.h2 + h3 = other.h3 + h4 = other.h4 + h5 = other.h5 + large_500 = other.large_500 + large_400 = other.large_400 + medium_500 = other.medium_500 + medium_400 = other.medium_400 + regular_500 = other.regular_500 + regular_400 = other.regular_400 + small_500 = other.small_500 + small_400 = other.small_400 + small_300 = other.small_300 } } -/** 기본 SpotTypography 세트를 생성하는 팩토리 함수 */ -fun SpotTypography(): SpotTypography = SpotTypography( - header01 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Bold, - fontSize = 85.sp, // 64pt ≈ 85sp - ), - header02 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Bold, - fontSize = 69.sp, // 52pt ≈ 69sp - ), - header03 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Bold, - fontSize = 48.sp, // 48pt ≈ 64sp - ), - header04 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Bold, - fontSize = 44.sp, // 44pt ≈ 58sp - ), - header05 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Bold, - fontSize = 40.sp, // 40pt ≈ 53sp - ), - bodyLarge600 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.SemiBold, - fontSize = 44.sp, - ), - bodyLarge500 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Medium, - fontSize = 44.sp, - ), - bodyMedium600 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.SemiBold, - fontSize = 40.sp, - ), - bodyMedium500 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Medium, - fontSize = 40.sp, - ), - bodyRegular500 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Medium, - fontSize = 36.sp, - ), - bodyRegular400 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Normal, - fontSize = 36.sp, - ), - bodySmall500 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Medium, - fontSize = 32.sp, - ), - bodySmall400 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Normal, - fontSize = 32.sp, - ), - bodySmall300 = TextStyle( - fontFamily = Pretendard, - fontWeight = FontWeight.Light, - fontSize = 32.sp, +@Composable +fun SpotTypography(): SpotTypography { + + val bodyLetterSpacing = (-0.022).em + + return SpotTypography( + + h1 = SpotTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = (24 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + h2 = SpotTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = (20 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + h3 = SpotTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = (18 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + h4 = SpotTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = (16 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + h5 = SpotTextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = (14 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + large_500 = SpotTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = (16 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + large_400 = SpotTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = (16 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + medium_500 = SpotTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = (14 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + medium_400 = SpotTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = (14 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + regular_500 = SpotTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = (12 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + regular_400 = SpotTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = (12 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + + small_500 = SpotTextStyle( + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = (10 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + small_400 = SpotTextStyle( + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = (10 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ), + small_300 = SpotTextStyle( + fontWeight = FontWeight.Light, + fontSize = 10.sp, + lineHeight = (10 * 1.5).sp, + letterSpacing = bodyLetterSpacing + ) ) -) +} -/** - * M3 Typography 매핑. - * - Material 컴포넌트에서 사용할 수 있게 연결 - * - SpotTheme() 내부에서 typography = AppTypography 로 전달됨 - */ -private val DefaultSpotTypography = SpotTypography() +@Composable +private fun DefaultSpotTypography() = SpotTypography() -val AppTypography: Typography = Typography( - displayLarge = DefaultSpotTypography.header01, - displayMedium = DefaultSpotTypography.header02, - displaySmall = DefaultSpotTypography.header03, +val AppTypography: Typography + @Composable + get() = Typography( + + displayLarge = DefaultSpotTypography().h1, + displayMedium = DefaultSpotTypography().h2, + displaySmall = DefaultSpotTypography().h3, - headlineLarge = DefaultSpotTypography.header04, - headlineMedium = DefaultSpotTypography.header05, + + headlineLarge = DefaultSpotTypography().h4, + headlineMedium = DefaultSpotTypography().h5, + headlineSmall = DefaultSpotTypography().large_500, - titleLarge = DefaultSpotTypography.bodyLarge600, - titleMedium = DefaultSpotTypography.bodyMedium600, - titleSmall = DefaultSpotTypography.bodySmall500, + + titleLarge = DefaultSpotTypography().large_500, + titleMedium = DefaultSpotTypography().medium_500, + titleSmall = DefaultSpotTypography().regular_500, - bodyLarge = DefaultSpotTypography.bodyLarge500, - bodyMedium = DefaultSpotTypography.bodyMedium500, - bodySmall = DefaultSpotTypography.bodySmall400, + + bodyLarge = DefaultSpotTypography().large_400, + bodyMedium = DefaultSpotTypography().medium_400, + bodySmall = DefaultSpotTypography().regular_400, - labelLarge = DefaultSpotTypography.bodyRegular500, - labelMedium = DefaultSpotTypography.bodyRegular400, - labelSmall = DefaultSpotTypography.bodySmall300 -) + + labelLarge = DefaultSpotTypography().regular_500, + labelMedium = DefaultSpotTypography().small_500, + labelSmall = DefaultSpotTypography().small_400 + ) -/** 미리보기 */ @Composable fun TypographyPreviewContent() { Surface { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(16.dp)) { val t = SpotTheme.typography - Text("Header01 - 64sp Bold", style = t.header01) - Text("Header02 - 52sp Bold", style = t.header02) - Text("Header03 - 48sp Bold", style = t.header03) - Text("Header04 - 44sp Bold", style = t.header04) - Text("Header05 - 40sp Bold", style = t.header05) - - Text("BodyLarge600 - 44sp SemiBold", style = t.bodyLarge600) - Text("BodyLarge500 - 44sp Medium", style = t.bodyLarge500) - Text("BodyMedium600 - 40sp SemiBold", style = t.bodyMedium600) - Text("BodyMedium500 - 40sp Medium", style = t.bodyMedium500) - Text("BodyRegular500 - 36sp Medium", style = t.bodyRegular500) - Text("BodyRegular400 - 36sp Normal", style = t.bodyRegular400) - Text("BodySmall500 - 32sp Medium", style = t.bodySmall500) - Text("BodySmall400 - 32sp Normal", style = t.bodySmall400) - Text("BodySmall300 - 32sp Light", style = t.bodySmall300) + Text("H1 - 24sp Bold / 150%", style = t.h1) + Text("H2 - 20sp Bold / 150%", style = t.h2) + Text("H3 - 18sp Bold / 150%", style = t.h3) + Text("H4 - 16sp Bold / 150%", style = t.h4) + Text("H5 - 14sp Bold / 150%", style = t.h5) + + Text("--- (Letter Spacing: -2.2%) ---", style = t.regular_400) + + Text("Large 500 - 16sp Medium / 150%", style = t.large_500) + Text("Large 400 - 16sp Normal / 150%", style = t.large_400) + Text("Medium 500 - 14sp Medium / 150%", style = t.medium_500) + Text("Medium 400 - 14sp Normal / 150%", style = t.medium_400) + Text("Regular 500 - 12sp Medium / 150%", style = t.regular_500) + Text("Regular 400 - 12sp Normal / 150%", style = t.regular_400) + Text("Small 500 - 10sp Medium / 150%", style = t.small_500) + Text("Small 400 - 10sp Normal / 150%", style = t.small_400) + Text("Small 300 - 10sp Light / 150%", style = t.small_300) } } } -@Preview(showBackground = true, widthDp = 400, heightDp = 3000) +@Preview(showBackground = true, widthDp = 400, heightDp = 800) @Composable fun TypographyPreview() { SpotTheme { TypographyPreviewContent() } -} +} \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/arrow_left.xml b/core/designsystem/src/main/res/drawable/arrow_left.xml index 45d9dd33..e09eda53 100644 --- a/core/designsystem/src/main/res/drawable/arrow_left.xml +++ b/core/designsystem/src/main/res/drawable/arrow_left.xml @@ -1,9 +1,9 @@ + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> diff --git a/core/designsystem/src/main/res/drawable/dismiss.xml b/core/designsystem/src/main/res/drawable/dismiss.xml index 1a374c48..405ad0dd 100644 --- a/core/designsystem/src/main/res/drawable/dismiss.xml +++ b/core/designsystem/src/main/res/drawable/dismiss.xml @@ -1,9 +1,9 @@ + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + android:pathData="M3.666,3.795L3.726,3.725C3.832,3.619 3.973,3.554 4.122,3.543C4.271,3.532 4.419,3.575 4.54,3.664L4.61,3.725L10.001,9.115L15.393,3.724C15.451,3.664 15.52,3.617 15.596,3.584C15.672,3.551 15.754,3.534 15.837,3.533C15.92,3.533 16.003,3.548 16.079,3.58C16.156,3.611 16.226,3.658 16.285,3.716C16.343,3.775 16.39,3.845 16.421,3.922C16.452,3.999 16.468,4.081 16.467,4.164C16.467,4.247 16.449,4.329 16.417,4.405C16.384,4.481 16.336,4.55 16.276,4.608L10.886,10L16.277,15.391C16.383,15.497 16.447,15.638 16.458,15.787C16.469,15.936 16.426,16.084 16.337,16.205L16.276,16.275C16.171,16.381 16.03,16.445 15.881,16.456C15.732,16.467 15.583,16.424 15.463,16.336L15.393,16.275L10.001,10.884L4.61,16.275C4.492,16.389 4.334,16.452 4.17,16.451C4.006,16.449 3.849,16.383 3.734,16.267C3.618,16.152 3.552,15.995 3.551,15.831C3.549,15.667 3.613,15.509 3.726,15.391L9.117,10L3.726,4.608C3.62,4.502 3.556,4.362 3.545,4.212C3.534,4.063 3.577,3.915 3.666,3.795Z" + android:fillColor="#4F4F56"/> diff --git a/core/designsystem/src/main/res/drawable/ic_location.xml b/core/designsystem/src/main/res/drawable/ic_location.xml new file mode 100644 index 00000000..b0b2a04d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_location.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/alert/src/main/java/com/umcspot/spot/alert/AlertScreen.kt b/feature/alert/src/main/java/com/umcspot/spot/alert/AlertScreen.kt index d3998395..2199a123 100644 --- a/feature/alert/src/main/java/com/umcspot/spot/alert/AlertScreen.kt +++ b/feature/alert/src/main/java/com/umcspot/spot/alert/AlertScreen.kt @@ -177,13 +177,13 @@ fun EnrollStudyCard( Column { Text( text = "신청 스터디", - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.medium_500, fontSize = 20.sp ) Spacer(Modifier.height(4.dp)) Text( text = "신청 스터디의 수락 알림입니다.\n클릭하여 스터디 참여를 확인해주세요.", - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.medium_500, fontSize = 15.sp, lineHeight = 22.sp, color = SpotTheme.colors.Black @@ -242,7 +242,7 @@ fun PopularPostAlert( Column(modifier = Modifier.weight(1f)) { Text( text = "실시간 인기글", - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.medium_500, maxLines = 1, fontSize = 15.sp, overflow = TextOverflow.Ellipsis @@ -250,7 +250,7 @@ fun PopularPostAlert( Spacer(Modifier.height(4.dp)) Text( text = data.title, - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.medium_500, color = SpotTheme.colors.Black, maxLines = 1, fontSize = 15.sp, @@ -323,7 +323,7 @@ fun StudyNotiAlert( Column(modifier = Modifier.weight(1f)) { Text( text = primary, - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.medium_500, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -331,7 +331,7 @@ fun StudyNotiAlert( Spacer(Modifier.height(4.dp)) Text( text = secondary, - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.medium_500, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -356,7 +356,7 @@ fun NewBadge( Text( text = "N", color = SpotTheme.colors.white, - style = SpotTheme.typography.bodyMedium500, + style = SpotTheme.typography.medium_500, fontSize = 16.sp, modifier = Modifier.align(Alignment.Center) ) diff --git a/feature/board/src/main/java/com/umcspot/spot/feature/board/BoardScreen.kt b/feature/board/src/main/java/com/umcspot/spot/feature/board/BoardScreen.kt index da082e7c..a6f0a483 100644 --- a/feature/board/src/main/java/com/umcspot/spot/feature/board/BoardScreen.kt +++ b/feature/board/src/main/java/com/umcspot/spot/feature/board/BoardScreen.kt @@ -172,7 +172,7 @@ private fun BoardTabChip( ) { Text( text = text, - style = SpotTheme.typography.bodySmall500.copy(fontSize = 13.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 13.sp), color = fg ) } @@ -216,7 +216,7 @@ private fun SectionHeader( ) { Text( text = title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 18.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 18.sp), modifier = Modifier.weight(1f) ) IconButton(onClick = onMoreClick, modifier = Modifier.size(28.dp)) { @@ -287,14 +287,14 @@ private fun LabeledCardList( // 왼쪽 라벨 Text( text = item.label.korean, - style = SpotTheme.typography.bodySmall500.copy(fontSize = 14.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 14.sp), color = SpotTheme.colors.B500, modifier = Modifier.widthIn(min = 56.dp) ) // 제목 Text( text = item.title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -302,7 +302,7 @@ private fun LabeledCardList( // 카운트 Text( text = "(${cap(item.count)})", - style = SpotTheme.typography.bodySmall500.copy(fontSize = 14.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 14.sp), color = SpotTheme.colors.B500 ) } @@ -332,20 +332,20 @@ private fun RankRow( ) { Text( text = rank.toString().padStart(2, '0'), - style = SpotTheme.typography.bodySmall500.copy(fontSize = 14.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 14.sp), color = SpotTheme.colors.B500, modifier = Modifier.width(28.dp) ) Text( text = title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) Text( text = "(${cap(count)})", - style = SpotTheme.typography.bodySmall500.copy(fontSize = 14.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 14.sp), color = SpotTheme.colors.B500 ) } diff --git a/feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt b/feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt index c37765b1..849a62f9 100644 --- a/feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/umcspot/spot/home/HomeScreen.kt @@ -163,7 +163,7 @@ fun QuickMenu( Spacer(Modifier.height(6.dp)) Text( text = item.label, - style = SpotTheme.typography.bodyMedium600, + style = SpotTheme.typography.medium_500, fontSize = 14.sp, color = Black, maxLines = 1 @@ -224,7 +224,7 @@ fun PopularPostNow( Row(verticalAlignment = Alignment.CenterVertically) { Text( text = title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 16.sp), color = Black ) Spacer(Modifier.width(4.dp)) @@ -238,7 +238,7 @@ fun PopularPostNow( } Text( text = subtitle, - style = SpotTheme.typography.bodySmall500.copy(fontSize = 14.sp), + style = SpotTheme.typography.small_500.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clickable(onClick = onContentClick), @@ -278,7 +278,7 @@ fun PopularStudyNow( ) { Text( text = title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 18.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 18.sp), modifier = Modifier.weight(1f) ) @@ -328,7 +328,7 @@ fun RecommendStudyNow( ) { Text( text = title, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 18.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 18.sp), modifier = Modifier.weight(1f) ) IconButton(onClick = onRefreshClick, modifier = Modifier.size(18.dp)) { diff --git a/feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt index 03bd8056..87494298 100644 --- a/feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/umcspot/spot/home/navigation/HomeNavigation.kt @@ -1,6 +1,7 @@ package com.umcspot.spot.home.navigation import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.Modifier import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions @@ -21,8 +22,8 @@ fun NavGraphBuilder.homeGraph( ) { composable { HomeScreen( - onQuickMenuClick = onQuickMenuClick, // ⬅️ 여기서 콜백 주입 - contentPadding = contentPadding + contentPadding = contentPadding, + onQuickMenuClick = onQuickMenuClick ) } } 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 570d6ea8..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 @@ -6,24 +6,20 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation.NavGraph.Companion.findStartDestination 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.checkList.navigation.navigateToCheckList import com.umcspot.spot.feature.board.navigation.boardGraph import com.umcspot.spot.home.navigation.homeGraph -import com.umcspot.spot.home.navigation.navigateToHome import com.umcspot.spot.jjim.navigation.jjimGraph 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.navigateToSignUp import com.umcspot.spot.signup.navigation.signupGraph import com.umcspot.spot.study.my.navigation.myStudyGraph import com.umcspot.spot.study.preferLocation.navigation.preferLocationStudyGraph @@ -35,9 +31,14 @@ import com.umcspot.spot.study.register.navigation.registerStudyGraph fun MainNavHost( navigator: MainNavigator, modifier: Modifier = Modifier, - contentPadding : PaddingValues = PaddingValues(0.dp), + 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, @@ -48,23 +49,15 @@ fun MainNavHost( popExitTransition = { ExitTransition.None }, ) { landingGraph( - onLoginSuccess= { - navigator.navController.navigateToSignUp( - navOptions { - popUpTo(navigator.navController.graph.findStartDestination().id) { - inclusive = true - } - launchSingleTop = true - restoreState = false - } - ) + onLoginSuccess = { + navigator.navigateToSignUp(clearStackNavOptions) } ) signupGraph( contentPadding = contentPadding, navController = navigator.navController, - onNextClick = { navigator.navController.navigateToCheckList() } + onNextClick = { navigator.navigateToCheckList() } ) checkListGraph( @@ -75,15 +68,9 @@ fun MainNavHost( savingGraph( contentPadding = contentPadding, - onFinished = { navigator.navController.navigateToHome( - navOptions { - popUpTo(navigator.navController.graph.findStartDestination().id) { - inclusive = true - } - launchSingleTop = true - restoreState = false - } - ) } + onFinished = { + navigator.navigateToHome(clearStackNavOptions) + } ) homeGraph( 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 09115a70..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 @@ -16,15 +16,16 @@ 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.SavingScreen 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.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 @@ -82,16 +83,13 @@ class MainNavigator( @Composable fun isInLanding(): Boolean = inAnyGraph(Landing::class, Saving::class) - /** 상단 뒤로가기 TopBar 노출 조건 */ @Composable 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) - /** 멀티 FAB(게시판 등) 노출 조건 */ @Composable fun showMultipleFab(): Boolean = inAnyGraph(Board::class) @@ -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/component/MainBottomBar.kt b/feature/main/src/main/java/com/umcspot/spot/main/component/MainBottomBar.kt index d85e95de..4021a54a 100644 --- a/feature/main/src/main/java/com/umcspot/spot/main/component/MainBottomBar.kt +++ b/feature/main/src/main/java/com/umcspot/spot/main/component/MainBottomBar.kt @@ -84,7 +84,7 @@ fun RowScope.MainBottomBarItem( onClick: () -> Unit ) { val bottomItemColor = if (selected) B400 else Black - val bottomTextStyle = if (selected) SpotTheme.typography.bodyRegular400 else SpotTheme.typography.bodyRegular400 + val bottomTextStyle = if (selected) SpotTheme.typography.regular_400 else SpotTheme.typography.regular_400 Column( modifier = modifier 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 index d63d5386..d256a731 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListScreen.kt @@ -2,24 +2,17 @@ package com.umcspot.spot.checkList import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.FlowRow 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.res.painterResource 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.component.button.TextButtonM +import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection import com.umcspot.spot.designsystem.theme.SpotTheme -import com.umcspot.spot.model.StudyTheme import com.umcspot.spot.signup.SignUpViewModel @Composable @@ -32,36 +25,38 @@ fun CheckListScreen( val topPad = contentPadding.calculateTopPadding() val bottomPad = contentPadding.calculateBottomPadding() - val theme by viewmodel.themes.collectAsStateWithLifecycle() + + 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), - verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(Modifier.height(70.dp)) + Text( text = "내가 원하는 스터디를 선택해주세요", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.h3, color = SpotTheme.colors.black ) Spacer(Modifier.height(50.dp)) ActivityThemeSection( - selected = theme, - onSelect = viewmodel::toggleTheme + selectedThemes = themes, + onSelect = viewmodel::toggleTheme, + modifier = Modifier.fillMaxWidth(), + maxSelection = 10 ) Spacer(Modifier.weight(1f)) - TextButton( text = "다음", - enabled = theme.isNotEmpty(), + enabled = themes.isNotEmpty(), onClick = { signUpViewModel.saveNameIfChanged() viewmodel.submitThemes() @@ -69,42 +64,4 @@ fun CheckListScreen( } ) } -} - -@Composable -fun ActivityThemeSection( - selected: Set, - onSelect: (StudyTheme) -> Unit -) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - StudyTheme.entries.forEach { theme -> - val iconRes = when (theme) { - StudyTheme.LANGUAGE -> painterResource(R.drawable.language) - 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.COMPETITION -> painterResource(R.drawable.contest) - StudyTheme.MAJOR_CAREER -> painterResource(R.drawable.major) - StudyTheme.OTHER -> painterResource(R.drawable.resource_else) - } - - MultiButton( - text = theme.title, - painter = iconRes, - checked = theme in selected, - width = 156.dp, - onClick = { onSelect(theme) } - ) - } - } - } -} +} \ 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 index 954221e0..d116a036 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/checkList/CheckListViewModel.kt @@ -4,9 +4,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.umcspot.spot.model.StudyTheme -import com.umcspot.spot.ui.state.UiState 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 @@ -14,7 +16,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject - @HiltViewModel class CheckListViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, @@ -22,22 +23,39 @@ class CheckListViewModel @Inject constructor( ) : ViewModel() { private val KEY_THEMES = "recruit_filter_themes" + private val MAX_SELECTION_COUNT = 10 - private val _themes = MutableStateFlow( - (savedStateHandle.get>(KEY_THEMES) ?: emptyList()).toCollection(LinkedHashSet()) + private val _themes = MutableStateFlow>( + savedStateHandle.get>(KEY_THEMES)?.toPersistentList() ?: persistentListOf() ) - val themes: StateFlow> = _themes.asStateFlow() + + val themes: StateFlow> = _themes.asStateFlow() fun toggleTheme(theme: StudyTheme) { - val next = LinkedHashSet(_themes.value) - if (!next.add(theme)) next.remove(theme) - _themes.value = next - savedStateHandle[KEY_THEMES] = next.toList() + _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.toList() + val selected = _themes.value if (selected.isEmpty()) return + viewModelScope.launch { userRepository.setUserTheme(selected) } 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 index 6f454fe5..3e7b45bf 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/landing/SavingScreen.kt @@ -35,6 +35,8 @@ 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 @@ -81,12 +83,12 @@ fun SavingScreen( Image( painter = painterResource(R.drawable.spot_logo), contentDescription = null, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(screenWidthDp(40.dp)) ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(screenHeightDp(16.dp))) Text( text = "당신의 스터디 파트너 \n 스팟, SPOT", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), + style = SpotTheme.typography.h2, color = SpotTheme.colors.B500, textAlign = TextAlign.Center ) @@ -97,15 +99,15 @@ fun SavingScreen( .align(Alignment.BottomCenter) .fillMaxWidth() .navigationBarsPadding() - .padding(bottom = 8.dp), + .padding(bottom = screenHeightDp(8.dp)), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = if (isDone) "등록 완료!" else "내 정보 저장 중..", - style = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp), + style = SpotTheme.typography.h4, color = SpotTheme.colors.B500 ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(screenHeightDp(8.dp))) GageBar( value = internal.value, ) 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 index e5ef7c36..2e8587b9 100644 --- a/feature/signup/src/main/java/com/umcspot/spot/signup/AgreementModal.kt +++ b/feature/signup/src/main/java/com/umcspot/spot/signup/AgreementModal.kt @@ -33,6 +33,7 @@ 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( @@ -59,7 +60,7 @@ fun PrivacyConsentDialog( .fillMaxWidth(), verticalAlignment = Alignment.Top ) { - Spacer(Modifier.weight(1f)) // 왼쪽 공간 채우기 + Spacer(Modifier.weight(1f)) IconButton( onClick = onDismiss, @@ -67,7 +68,7 @@ fun PrivacyConsentDialog( .size(16.dp) ) { Icon( - painter = painterResource(R.drawable.dismiss), // 없으면 Icons.Default.Close + painter = painterResource(R.drawable.dismiss), contentDescription = "닫기", modifier = Modifier.size(16.dp) ) @@ -80,13 +81,12 @@ fun PrivacyConsentDialog( ) { Text( text = "개인정보 이용 및 활용 동의", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), + style = SpotTheme.typography.h2, color = SpotTheme.colors.black, ) } - Spacer(Modifier.height(30.dp)) - + Spacer(Modifier.height(screenHeightDp(20.dp))) Surface( shape = SpotShapes.Hard, @@ -94,7 +94,7 @@ fun PrivacyConsentDialog( color = SpotTheme.colors.white, modifier = Modifier .fillMaxWidth() - .heightIn(min = 160.dp, max = 380.dp), // 스크롤 높이 제한 + .heightIn(min = 160.dp, max = 380.dp), ) { val scroll = rememberScrollState() Column( @@ -102,14 +102,14 @@ fun PrivacyConsentDialog( .padding(16.dp) .verticalScroll(scroll) ) { - // 제1조 + Text( text = "제1조 (개인정보 수집 및 이용 목적)", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + style = SpotTheme.typography.regular_500, color = SpotTheme.colors.black ) Spacer(Modifier.height(6.dp)) - val bullet = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp) + val bullet = SpotTheme.typography.small_500 NumberedLine(1, "회원 가입 및 관리: 본인 확인, 회원 서비스 제공", bullet) NumberedLine(2, "서비스 제공 및 운영: 커뮤니티 기능 제공, 맞춤형 콘텐츠 추천", bullet) NumberedLine(3, "고객지원: 문의사항 응대 및 서비스 개선", bullet) @@ -117,10 +117,10 @@ fun PrivacyConsentDialog( Spacer(Modifier.height(12.dp)) - // 제2조 + Text( text = "제2조 (수집하는 개인정보 항목)", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + style = SpotTheme.typography.regular_500, color = SpotTheme.colors.black ) Spacer(Modifier.height(6.dp)) @@ -133,7 +133,7 @@ fun PrivacyConsentDialog( Spacer(Modifier.height(16.dp)) - // 동의 버튼 + TextButtonM( text = "동의", onClick = onAgree @@ -168,7 +168,7 @@ fun UniqueConsentDialog( .fillMaxWidth(), verticalAlignment = Alignment.Top ) { - Spacer(Modifier.weight(1f)) // 왼쪽 공간 채우기 + Spacer(Modifier.weight(1f)) IconButton( onClick = onDismiss, @@ -176,7 +176,7 @@ fun UniqueConsentDialog( .size(16.dp) ) { Icon( - painter = painterResource(R.drawable.dismiss), // 없으면 Icons.Default.Close + painter = painterResource(R.drawable.dismiss), contentDescription = "닫기", modifier = Modifier.size(16.dp) ) @@ -189,12 +189,12 @@ fun UniqueConsentDialog( ) { Text( text = "고유식별정보 처리 동의", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 20.sp), + style = SpotTheme.typography.h2, color = SpotTheme.colors.black, ) } - Spacer(Modifier.height(30.dp)) + Spacer(Modifier.height(screenHeightDp(20.dp))) Surface( @@ -203,7 +203,7 @@ fun UniqueConsentDialog( color = SpotTheme.colors.white, modifier = Modifier .fillMaxWidth() - .heightIn(min = 160.dp, max = 380.dp), // 스크롤 높이 제한 + .heightIn(min = 160.dp, max = 380.dp), ) { val scroll = rememberScrollState() Column( @@ -211,14 +211,14 @@ fun UniqueConsentDialog( .padding(16.dp) .verticalScroll(scroll) ) { - // 제1조 + Text( text = "제1조 (개인정보 수집 및 이용 목적)", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + style = SpotTheme.typography.regular_500, color = SpotTheme.colors.black ) Spacer(Modifier.height(6.dp)) - val bullet = SpotTheme.typography.bodySmall400.copy(fontSize = 13.sp) + val bullet = SpotTheme.typography.small_500 NumberedLine(1, "회원 가입 및 관리: 본인 확인, 회원 서비스 제공", bullet) NumberedLine(2, "서비스 제공 및 운영: 커뮤니티 기능 제공, 맞춤형 콘텐츠 추천", bullet) NumberedLine(3, "고객지원: 문의사항 응대 및 서비스 개선", bullet) @@ -226,10 +226,10 @@ fun UniqueConsentDialog( Spacer(Modifier.height(12.dp)) - // 제2조 + Text( text = "제2조 (수집하는 개인정보 항목)", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 14.sp), + style = SpotTheme.typography.regular_500, color = SpotTheme.colors.black ) Spacer(Modifier.height(6.dp)) @@ -242,7 +242,7 @@ fun UniqueConsentDialog( Spacer(Modifier.height(16.dp)) - // 동의 버튼 + TextButtonM( text = "동의", onClick = onAgree 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 f81f9c08..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 @@ -61,6 +61,7 @@ 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 @@ -75,11 +76,9 @@ fun SignUpScreen( val topPad = contentPadding.calculateTopPadding() val bottomPad = contentPadding.calculateBottomPadding() - // ✅ 동의 체크 상태 (초기 false 권장) var privacyChecked by rememberSaveable { mutableStateOf(false) } var uniqueChecked by rememberSaveable { mutableStateOf(false) } - // ✅ 모달 표시 상태 var showPrivacyDialog by rememberSaveable { mutableStateOf(false) } var showUniqueDialog by rememberSaveable { mutableStateOf(false) } @@ -114,23 +113,21 @@ fun SignUpScreen( ) { focusManager.clearFocus(force = true) } ) { - Spacer(Modifier.height(70.dp)) + Spacer(Modifier.height(screenHeightDp(68.dp))) Text( text = "스팟에서는 안전한 스터디 매칭을 위해\n실명 활동제를 도입하고 있어요.", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 16.sp), + style = SpotTheme.typography.h3, color = SpotTheme.colors.B500 ) - Spacer(Modifier.height(24.dp)) + 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) } @@ -140,16 +137,18 @@ fun SignUpScreen( 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 }, + onOpenPrivacyDialog = { + if (privacyChecked) privacyChecked = false else showPrivacyDialog = true + }, + onOpenUniqueDialog = { + if (uniqueChecked) uniqueChecked = false else showUniqueDialog = true + }, ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(screenHeightDp(24.dp))) TextButton( text = "다음", @@ -158,7 +157,6 @@ fun SignUpScreen( ) } - // ✅ 모달들 PrivacyConsentDialog( open = showPrivacyDialog, onAgree = { @@ -190,16 +188,17 @@ fun AgreementConfirm( Column { Text( text = "약관 동의", - style = MaterialTheme.typography.titleMedium.copy(fontSize = 20.sp) + style = SpotTheme.typography.h3 ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(screenHeightDp(7.dp))) ConsentItem( title = "개인정보 이용 및 활용 동의", checked = privacyChecked, onClick = onOpenPrivacyDialog ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(screenHeightDp(4.dp))) + ConsentItem( title = "고유식별정보 처리 동의", checked = uniqueChecked, @@ -241,41 +240,40 @@ fun EditableNameRow( 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, // 포커스 시 텍스트 + 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, // 내부 배경색 + focusedContainerColor = SpotTheme.colors.white, unfocusedContainerColor = SpotTheme.colors.white ), value = draft, onValueChange = { new -> when { new.length <= 15 -> { - // 15자 이하면 그대로 반영 draft = new } - // 15자 초과: 기존보다 늘어난 타이핑이라면 끝 글자만 교체 + new.length > draft.length -> { - val last = new.last() // 마지막에 입력된 글자(간단 버전) - draft = draft.take(14) + last // 14 + 새 글자 = 15자 유지 + val last = new.last() + draft = draft.take(14) + last } + else -> { - // 그 외(중간 수정/붙여넣기 등)는 안전하게 15자 컷 draft = new.take(15) } } }, singleLine = true, shape = SpotShapes.Hard, - textStyle = SpotTheme.typography.bodySmall400, - placeholder = { Text("이름을 입력하세요", style = SpotTheme.typography.bodySmall400) }, + textStyle = SpotTheme.typography.h2, + placeholder = { Text("이름을 입력하세요", style = SpotTheme.typography.h2) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { - // IME Done도 포커스 아웃과 동일 처리 + focusManager.clearFocus(force = true) }), modifier = Modifier @@ -296,12 +294,12 @@ fun EditableNameRow( } ) } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(screenHeightDp(8.dp))) + Text( text = "공백 포함 15자까지 입력 가능해요.", - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 12.sp, color = SpotTheme.colors.B500 - ), + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.B500, modifier = Modifier.padding(start = 10.dp) ) } else { @@ -313,7 +311,7 @@ fun EditableNameRow( ) { Text( text = name, - style = SpotTheme.typography.bodySmall400.copy(fontSize = 20.sp) + style = SpotTheme.typography.h2 ) MultiButton( @@ -330,9 +328,8 @@ fun EditableNameRow( Spacer(Modifier.height(8.dp)) Text( text = "실명이 맞나요? 이름을 확인해주세요.", - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 12.sp - ), + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.gray400 ) } } @@ -346,7 +343,7 @@ private fun ConsentItem( ) { 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 + val background = if (checked) SpotTheme.colors.B100 else SpotTheme.colors.white Surface( shape = SpotShapes.Soft, @@ -372,7 +369,7 @@ private fun ConsentItem( ) { Text( text = title, - style = SpotTheme.typography.bodySmall400.copy(fontSize = 14.sp), + style = SpotTheme.typography.regular_500, color = SpotTheme.colors.black, maxLines = 1, overflow = TextOverflow.Ellipsis diff --git a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt b/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt deleted file mode 100644 index f8ec2779..00000000 --- a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationBottomSheet.kt +++ /dev/null @@ -1,318 +0,0 @@ -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.umcspot.spot.common.location.LocationRow -import com.umcspot.spot.designsystem.R -import com.umcspot.spot.designsystem.shapes.SpotShapes -import com.umcspot.spot.designsystem.theme.B100 -import com.umcspot.spot.designsystem.theme.B400 -import com.umcspot.spot.designsystem.theme.B500 -import com.umcspot.spot.designsystem.theme.SpotTheme -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PreferLocationBottomSheet( - contentPadding : PaddingValues, - visible: Boolean, - query: String, - results: List, // ✅ 결과 리스트 전달받음 - onQueryChange: (String) -> Unit, - onDismiss: () -> Unit, - selected: List, // ✅ 현재 선택된 지역 칩들 - onAddSelected: (String) -> Unit, // ✅ 선택 추가 - onRemoveSelected: (String) -> Unit // ✅ 선택 제거, -) { - if (!visible) return - - val keyboard = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } - val blurFocusRequester = remember { FocusRequester() } // 🔑 바깥 클릭 시 포커스 이동용 - var isFocused by remember { mutableStateOf(false) } - - val density = LocalDensity.current - val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val sheetHeight = 533.dp - val scope = rememberCoroutineScope() - val sheetOffset = remember { Animatable(with(density) { screenHeight.toPx() }) } - val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - - - LaunchedEffect(Unit) { - val screenHeightPx = with(density) { screenHeight.toPx() } - val sheetHeightPx = with(density) { sheetHeight.toPx() } - val openY = screenHeightPx - sheetHeightPx // 👈 바닥에 딱 붙이기 - sheetOffset.animateTo(openY, animationSpec = tween(300)) - } - - fun animateAndDismiss() { - scope.launch { - sheetOffset.animateTo( - targetValue = with(density) { screenHeight.toPx() }, - animationSpec = tween(250) - ) - onDismiss() - } - } - - Dialog( - onDismissRequest = { animateAndDismiss() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, // ✅ 가로 전체 - dismissOnBackPress = false, // 우리가 직접 처리 - dismissOnClickOutside = false // 우리가 스크림에서 처리 - ) - ) { - - BackHandler(enabled = true) { animateAndDismiss() } - - Box(Modifier.fillMaxSize()) { - // 스크림 (배경) - Box( - Modifier - .matchParentSize() - .background(SpotTheme.colors.black.copy(alpha = 0.4f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - blurFocusRequester.requestFocus() - keyboard?.hide() - animateAndDismiss() - } - ) - - // 시트 본체 (위 레이어) - Box( - Modifier - .fillMaxWidth() - .height(sheetHeight) - .offset { IntOffset(0, sheetOffset.value.roundToInt()) } - .clip(SpotShapes.SoftTop) - .background(SpotTheme.colors.white) - .imePadding() // 키보드 올라와도 시트 고정, 내부만 패딩 - .focusRequester(blurFocusRequester) - .focusable() - // ⬇︎ 시트 빈 공간 탭 -> 포커스 이동 + 키보드 닫기 (dismiss는 안 함) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - blurFocusRequester.requestFocus() - keyboard?.hide() - } - ) { - // === 네 기존 UI 그대로 === - Column( - Modifier - .fillMaxSize() - .padding(16.dp) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - blurFocusRequester.requestFocus() - keyboard?.hide() - }, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(Modifier.width(24.dp)) - Text( - text = "스터디 지역", - style = SpotTheme.typography.bodySmall400.copy(fontSize = 18.sp), - color = SpotTheme.colors.black - ) - IconButton(onClick = { animateAndDismiss() }) { - Icon( - painter = painterResource(com.umcspot.spot.designsystem.R.drawable.dismiss), - contentDescription = "닫기", - tint = SpotTheme.colors.black - ) - } - } - - Spacer(Modifier.height(6.dp)) - - Text( - text = "스터디를 진행하고 싶은 지역을 추가해주세요.", - style = SpotTheme.typography.bodyMedium600.copy(fontSize = 15.sp), - color = SpotTheme.colors.gray500 - ) - Text( - text = "최대 10개까지 추가할 수 있어요", - style = SpotTheme.typography.bodySmall300.copy(fontSize = 13.sp), - color = SpotTheme.colors.gray500 - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = SpotTheme.typography.bodySmall300.copy( - fontSize = 14.sp - ), - placeholder = { - Text( - text = "OO시, OO구, OO동", - style = SpotTheme.typography.bodySmall300.copy(fontSize = 14.sp), - color = SpotTheme.colors.gray400 - ) - }, - trailingIcon = { - IconButton( - onClick = { - // 포커스 있으면 키보드 닫기 - if (isFocused) { - blurFocusRequester.requestFocus() // ← 포커스를 빼앗아온다 - keyboard?.hide() - } else { - focusRequester.requestFocus() - keyboard?.show() - } - }) { - Icon( - painter = painterResource(com.umcspot.spot.designsystem.R.drawable.search), - contentDescription = "검색", - tint = SpotTheme.colors.gray500, - modifier = Modifier.size(15.dp) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { fs -> - isFocused = fs.isFocused - if (isFocused) keyboard?.show() - } - ) - - SelectedChips( - items = selected, - onRemove = onRemoveSelected - ) - - if (results.isNotEmpty()) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = navBarPadding) - .background(SpotTheme.colors.white), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(results, key = { it.code }) { row -> - ListItem( - headlineContent = { - Text( - text = row.name, - fontSize = 18.sp, - maxLines = 1 - ) - }, - colors = ListItemDefaults.colors( - containerColor = SpotTheme.colors.white - ), - modifier = Modifier - .fillMaxWidth() - .clickable { - onAddSelected(row.name) - } - ) - HorizontalDivider() - } - } - } else { - Text( - text = "검색 결과가 없습니다.", - color = SpotTheme.colors.gray400, - style = SpotTheme.typography.bodySmall300, - modifier = Modifier.padding(top = 14.dp), - ) - } - } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun SelectedChips( - items: List, - onRemove: (String) -> Unit -) { - if (items.isEmpty()) return - - val scrollState = rememberScrollState() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .horizontalScroll(scrollState), // 👈 가로 스크롤 추가 - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - items.forEach { name -> - AssistChip( - onClick = { /* no-op */ }, - label = { Text(name, style = SpotTheme.typography.bodySmall300.copy(fontSize = 14.sp)) }, - trailingIcon = { - Icon( - painter = painterResource(R.drawable.dismiss), - contentDescription = "삭제", - tint = SpotTheme.colors.B500, - modifier = Modifier - .size(14.dp) - .clickable { onRemove(name) } - - ) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = SpotTheme.colors.B100, - labelColor = SpotTheme.colors.B500 - ), - border = BorderStroke(1.dp, SolidColor(SpotTheme.colors.B100)), - shape = RoundedCornerShape(percent = 50) // 👈 타원(캡슐) 모양 - ) - } - } -} - diff --git a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt index 911aff54..22c49002 100644 --- a/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt +++ b/feature/study/src/main/java/com/umcspot/spot/study/preferLocation/PreferLocationStudyScreen.kt @@ -1,6 +1,5 @@ package com.umcspot.spot.study.preferLocation -import PreferLocationBottomSheet import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -29,6 +28,7 @@ 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.bottomsheet.LocationBottomSheet import com.umcspot.spot.designsystem.component.empty.EmptyAlertWithButton import com.umcspot.spot.designsystem.component.study.StudyListItem import com.umcspot.spot.designsystem.shapes.SpotShapes @@ -108,7 +108,7 @@ fun PreferLocationStudyScreen( // 타이틀 Text( text = "내 지역 스터디", - style = SpotTheme.typography.bodySmall400.copy(fontSize = 20.sp) + style = SpotTheme.typography.small_400.copy(fontSize = 20.sp) ) Spacer(Modifier.height(8.dp)) @@ -152,8 +152,7 @@ fun PreferLocationStudyScreen( } // 지역 선택 바텀시트 - PreferLocationBottomSheet( - contentPadding = contentPadding, + LocationBottomSheet( visible = showSheet, query = query, onQueryChange = { viewmodel.searchLocation(it) }, @@ -204,7 +203,7 @@ private fun HeaderRow( ) { Text( text = "%02d건".format(size), - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 12.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 12.sp), color = SpotTheme.colors.gray500 ) @@ -218,7 +217,7 @@ private fun HeaderRow( Text( text = sortType.label, color = SpotTheme.colors.black, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 12.sp) + style = SpotTheme.typography.medium_500.copy(fontSize = 12.sp) ) Spacer(Modifier.width(5.dp)) Icon( @@ -305,7 +304,7 @@ private fun SelectedLocationTabs( ) { Text( text = name, - style = SpotTheme.typography.bodyMedium600.copy(fontSize = 14.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 14.sp), modifier = Modifier.padding(horizontal = horizPad, vertical = 10.dp) ) } 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 0035e3fe..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 @@ -1,8 +1,5 @@ package com.umcspot.spot.study.recruiting -import android.util.Log -import android.widget.Toast -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -11,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 @@ -18,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 @@ -29,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,30 +34,32 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex 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.component.button.TextToggleButton +import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection 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( - contentPadding : PaddingValues, + contentPadding: PaddingValues, onAcceptFilterClick: () -> Unit, vm: RecruitingStudyFilterViewModel = hiltViewModel(), ) { - val activityType by vm.activity.collectAsStateWithLifecycle() - val fee by vm.fee.collectAsStateWithLifecycle() - val theme by vm.theme.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) { @@ -74,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 ) @@ -88,13 +85,13 @@ fun RecruitingStudyFilterScreen( @Composable fun RecruitingStudyFilterScreenContent( - activityType: ActivityType?, // ✅ 단일 값 (nullable) - fee: FeeRange?, // ✅ 단일 값 (nullable) - theme: StudyTheme?, // ✅ 단일 값 (nullable) - buttonEnabled : Boolean, - onSetActivity: (ActivityType) -> Unit, // ✅ set* 로직 (같은 값 다시 누르면 해제는 VM이 처리) - onSetFee: (FeeRange?) -> Unit, - onSetTheme: (StudyTheme) -> Unit, + selectedActivities: ImmutableList, + selectedFees: ImmutableList, + selectedThemes: ImmutableList, + buttonEnabled: Boolean, + onToggleActivity: (ActivityType) -> Unit, + onToggleFee: (FeeRange) -> Unit, + onToggleTheme: (StudyTheme) -> Unit, onReset: () -> Unit, onApply: () -> Unit, modifier: Modifier = Modifier @@ -107,36 +104,54 @@ fun RecruitingStudyFilterScreenContent( Column( modifier = modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) // ✅ 스크롤 + .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { - ActivityTypeSection( - activityType = activityType, - onSelect = onSetActivity + Text( + text = "활동", + style = SpotTheme.typography.medium_500.copy(fontSize = 15.sp), + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(screenHeightDp(10.dp))) + + + 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(screenHeightDp(30.dp))) + + Text( + text = "스터디 테마", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black ) - Spacer(modifier = Modifier.height(30.dp)) + 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( @@ -145,9 +160,8 @@ fun RecruitingStudyFilterScreenContent( .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = 16.dp, vertical = 12.dp) - .zIndex(1f) // 항상 앞 + .zIndex(1f) ) { - TextButton( text = "검색 결과 보기", enabled = buttonEnabled, @@ -158,45 +172,29 @@ fun RecruitingStudyFilterScreenContent( } @Composable -fun ActivityTypeSection( - activityType: ActivityType?, - onSelect: (ActivityType) -> Unit +fun ActivityTypeMultiSection( + selectedTypes: ImmutableList, + onToggle: (ActivityType) -> Unit ) { - Column( - modifier = Modifier - .wrapContentSize() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp) ) { - Text( - text = "활동", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 15.sp), - color = SpotTheme.colors.black - ) - - Spacer(modifier = Modifier.height(10.dp)) - - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(ActivityType.entries) { type -> - val iconRes = when (type) { - ActivityType.ONLINE -> painterResource( R.drawable.online) - ActivityType.OFFLINE -> painterResource(R.drawable.offline) - } - - MultiButton( - text = type.label, - painter = iconRes, - width = 140.dp, - checked = activityType == type, - onClick = { onSelect(type) }, - ) - } + 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 @@ -205,7 +203,7 @@ fun ActivityFeeSection( ) { Text( text = "활동비", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 15.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 15.sp), color = SpotTheme.colors.black ) Spacer(modifier = Modifier.height(10.dp)) @@ -213,59 +211,13 @@ fun ActivityFeeSection( FlowRow( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp) - ){ + ) { FeeRange.entries.forEach { fee -> TextToggleButton( text = fee.label, width = 71.dp, - checked = activityFee == fee, - onClick = { onSelect(fee) }, - ) - } - } - } -} - -@Composable -fun ActivityThemeSection( - activityTheme: StudyTheme?, - onSelect : (StudyTheme) -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - ) { - Text( - text = "스터디 테마", - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 15.sp), - color = SpotTheme.colors.black - ) - Spacer(modifier = Modifier.height(10.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ){ - StudyTheme.entries.forEach { theme -> - val iconRes = when (theme) { - StudyTheme.LANGUAGE -> painterResource(R.drawable.language) - 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.COMPETITION -> painterResource(R.drawable.contest) - StudyTheme.MAJOR_CAREER -> painterResource(R.drawable.major) - StudyTheme.OTHER -> painterResource(R.drawable.resource_else) - } - - MultiButton( - text = theme.title, - painter = iconRes, - checked = activityTheme == theme, - width = 156.dp, - onClick = { onSelect(theme) }, + checked = selectedFees.contains(fee), + onClick = { onToggle(fee) }, ) } } @@ -280,7 +232,7 @@ fun ResetFilterText( Text( text = "필터 초기화", color = SpotTheme.colors.gray400, - style = SpotTheme.typography.bodySmall400.copy( + style = SpotTheme.typography.small_400.copy( fontSize = 13.sp, textDecoration = TextDecoration.Underline ), @@ -288,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 b116333f..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 @@ -146,7 +146,7 @@ private fun RecruitingStudyScreenContent( // 타이틀 Text( text = "모집중 스터디", - style = SpotTheme.typography.bodySmall400.copy(fontSize = 20.sp) + style = SpotTheme.typography.small_400.copy(fontSize = 20.sp) ) Spacer(Modifier.height(8.dp)) @@ -204,7 +204,7 @@ fun HeaderRow( ) { Text( text = "%02d건".format(size), - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 12.sp), + style = SpotTheme.typography.medium_500.copy(fontSize = 12.sp), color = SpotTheme.colors.gray500 ) @@ -218,7 +218,7 @@ fun HeaderRow( Text( text = sortType.label, color = SpotTheme.colors.black, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 12.sp) + style = SpotTheme.typography.medium_500.copy(fontSize = 12.sp) ) Spacer(Modifier.width(5.dp)) Icon( @@ -271,7 +271,7 @@ fun SortTypeBottomSheet( headlineContent = { Text( text = option.label, - style = SpotTheme.typography.bodyMedium500.copy(fontSize = 15.sp) + style = SpotTheme.typography.medium_500.copy(fontSize = 15.sp) ) }, trailingContent = { diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt new file mode 100644 index 00000000..58f00ae0 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyScreen.kt @@ -0,0 +1,223 @@ +package com.umcspot.spot.study.register + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +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.appBar.BackTopBar +import com.umcspot.spot.designsystem.component.button.SpotActivationButton +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.register.component.StepProgressBar +import com.umcspot.spot.study.register.model.RegisterStudySideEffect +import com.umcspot.spot.study.register.model.RegisterStudyState +import com.umcspot.spot.study.register.screen.StudyCategoryScreen +import com.umcspot.spot.study.register.screen.StudyInfoScreen +import com.umcspot.spot.study.register.screen.StudyIntroduceScreen +import com.umcspot.spot.study.register.screen.StudyPlaceScreen +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Composable +fun RegisterStudyRoute( + contentPadding: PaddingValues, + onBackClick: () -> Unit, + navigateToHome: () -> Unit, + modifier: Modifier = Modifier, + viewModel: RegisterStudyViewModel = hiltViewModel() +) { + val pagerState = rememberPagerState(pageCount = { 4 }) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + + val handleBackPress: () -> Unit = { + if (pagerState.currentPage > 0) { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } else { + onBackClick() + } + } + + BackHandler { handleBackPress() } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { effect -> + when (effect) { + is RegisterStudySideEffect.NavigateToHome -> navigateToHome() + else -> {} + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + ) { + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + + BackTopBar( + title = "스터디 만들기", + onBackClick = handleBackPress, + modifier = Modifier.fillMaxWidth() + ) + + RegisterStudyScreen( + pagerState = pagerState, + uiState = uiState, + contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + isStepValid = viewModel::isStepValid, + onSubmit = viewModel::submit, + modifier = modifier, + onStudyNameChange = viewModel::onStudyNameChange, + onThemeSelect = { theme -> + val currentThemes = uiState.studyThemes.toMutableList() + if (currentThemes.contains(theme)) currentThemes.remove(theme) + else if (currentThemes.size < 3) currentThemes.add(theme) + viewModel.onCategorySelect(currentThemes) + }, + onActivityTypeSelect = viewModel::onActivityTypeSelect, + onQueryChange = viewModel::onLocationQueryChange, + onSheetOpen = viewModel::openLocationSheet, + onSheetDismiss = viewModel::dismissLocationSheet, + onAddSelected = viewModel::addSelectedRegion, + onRemoveSelected = viewModel::removeSelectedRegion, + onMemberCountChange = viewModel::onMemberCountChange, + onFeeInfoChange = viewModel::onFeeInfoChange, + onPersonalityChange = viewModel::onPersonalityChange, + onDescriptionChange = viewModel::onDescriptionChange + ) + } +} + +@Composable +private fun RegisterStudyScreen( + pagerState: PagerState, + uiState: RegisterStudyState, + contentPadding: PaddingValues, + isStepValid: (Int) -> Boolean, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, + onStudyNameChange: (String) -> Unit, + onThemeSelect: (com.umcspot.spot.model.StudyTheme) -> Unit, + onActivityTypeSelect: (com.umcspot.spot.model.ActivityType) -> Unit, + onQueryChange: (String) -> Unit, + onSheetOpen: () -> Unit, + onSheetDismiss: () -> Unit, + onAddSelected: (String) -> Unit, + onRemoveSelected: (String) -> Unit, + onMemberCountChange: (Int) -> Unit, + onFeeInfoChange: (Boolean?, String) -> Unit, + onPersonalityChange: (Int, Int) -> Unit, + onDescriptionChange: (String) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = modifier + .fillMaxSize() + .background(SpotTheme.colors.white) + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + StepProgressBar(currentStep = pagerState.currentPage + 1) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = screenWidthDp(17.dp)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + userScrollEnabled = false + ) { page -> + when (page) { + 0 -> StudyCategoryScreen( + studyName = uiState.studyName, + selectedThemes = uiState.studyThemes.toImmutableList(), + onStudyNameChange = onStudyNameChange, + onThemeSelect = onThemeSelect + ) + 1 -> StudyPlaceScreen( + activityType = uiState.activityType, + isSheetVisible = uiState.isSheetVisible, + query = uiState.locationQuery, + searchResults = uiState.locationResults, + selectedRegions = uiState.selectedRegions.toImmutableList(), + onActivityTypeSelect = onActivityTypeSelect, + onQueryChange = onQueryChange, + onSheetOpen = onSheetOpen, + onSheetDismiss = onSheetDismiss, + onAddSelected = onAddSelected, + onRemoveSelected = onRemoveSelected + ) + 2 -> StudyInfoScreen( + memberCount = uiState.memberCount, + onMemberCountChange = onMemberCountChange, + hasFee = uiState.hasFee, + feeAmount = uiState.feeAmount, + onFeeInfoChange = onFeeInfoChange, + preferences = persistentListOf( + uiState.networkingPreference, + uiState.goalDurationPreference, + uiState.discussionPreference, + uiState.learningPreference, + uiState.flexibilityPreference + ), + onPersonalityChange = onPersonalityChange + ) + 3 -> StudyIntroduceScreen( + description = uiState.description, + onDescriptionChange = onDescriptionChange, + onIntroduceValid = { } + ) + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + val isValid = isStepValid(pagerState.currentPage) + + SpotActivationButton( + modifier = Modifier.fillMaxWidth(), + buttonText = if (pagerState.currentPage == 3) "스터디 만들기" else "다음", + isEnabled = isValid, + onClick = { + coroutineScope.launch { + if (pagerState.currentPage < 3) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } else { + onSubmit() + } + } + } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(13.dp))) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt new file mode 100644 index 00000000..3ca2a74e --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/RegisterStudyViewModel.kt @@ -0,0 +1,178 @@ +package com.umcspot.spot.study.register + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.common.location.LocationStore +import com.umcspot.spot.common.location.searchLocations +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.study.register.model.RegisterStudySideEffect +import com.umcspot.spot.study.register.model.RegisterStudyState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RegisterStudyViewModel @Inject constructor( + @ApplicationContext private val appContext: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(RegisterStudyState()) + val uiState = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + private var allLocations: List = emptyList() + + private var searchJob: Job? = null + + init { + loadLocationData() + } + + fun onCategorySelect(themes: List) { + _uiState.update { it.copy(studyThemes = themes) } + } + + fun onStudyNameChange(name: String) { + _uiState.update { it.copy(studyName = name) } + } + + fun onActivityTypeSelect(type: ActivityType) { + _uiState.update { state -> + if (type == ActivityType.OFFLINE) { + state.copy( + activityType = type, + isSheetVisible = true + ) + } else { + state.copy( + activityType = type, + selectedRegions = emptyList(), + isSheetVisible = false + ) + } + } + } + + fun onLocationQueryChange(query: String) { + _uiState.update { it.copy(locationQuery = query) } + searchJob?.cancel() + searchJob = viewModelScope.launch(Dispatchers.IO) { + delay(300L) + searchLocation(query) + } + } + + fun addSelectedRegion(region: String) { + if (_uiState.value.selectedRegions.size < 10 && !_uiState.value.selectedRegions.contains(region)) { + _uiState.update { + it.copy( + selectedRegions = it.selectedRegions + region + ) + } + } + } + + fun removeSelectedRegion(region: String) { + _uiState.update { currentState -> + val updatedRegions = currentState.selectedRegions.toMutableList().apply { + remove(region) + } + currentState.copy(selectedRegions = updatedRegions) + } + } + + fun dismissLocationSheet() { + _uiState.update { it.copy(isSheetVisible = false) } + } + + fun openLocationSheet() { + _uiState.update { it.copy(isSheetVisible = true) } + } + + private fun loadLocationData() { + viewModelScope.launch(Dispatchers.IO) { + allLocations = LocationStore.load(appContext) + } + } + + private suspend fun searchLocation(query: String) { + if (query.isBlank()) { + _uiState.update { it.copy(locationResults = emptyList()) } + return + } + + if (allLocations.isEmpty()) { + allLocations = LocationStore.load(appContext) + } + + val filtered = searchLocations(query, allLocations) + _uiState.update { it.copy(locationResults = filtered) } + } + + fun onMemberCountChange(count: Int) { + _uiState.update { it.copy(memberCount = count) } + } + + fun onFeeInfoChange(hasFee: Boolean?, amount: String) { + _uiState.update { it.copy(hasFee = hasFee, feeAmount = amount) } + } + + fun onPersonalityChange(categoryIndex: Int, value: Int) { + _uiState.update { + when (categoryIndex) { + 0 -> it.copy(networkingPreference = value) + 1 -> it.copy(goalDurationPreference = value) + 2 -> it.copy(discussionPreference = value) + 3 -> it.copy(learningPreference = value) + 4 -> it.copy(flexibilityPreference = value) + else -> it + } + } + } + + fun onDescriptionChange(desc: String) { + _uiState.update { it.copy(description = desc) } + } + + fun isStepValid(step: Int): Boolean { + val state = _uiState.value + return when (step) { + 0 -> state.studyName.isNotBlank() && state.studyThemes.isNotEmpty() + 1 -> { + if (state.activityType == null) return false + if (state.activityType == ActivityType.OFFLINE) state.selectedRegions.isNotEmpty() else true + } + 2 -> { + val isFeeValid = state.hasFee != null && (!state.hasFee || state.feeAmount.isNotBlank()) + val isPersonalityValid = state.networkingPreference != null && + state.goalDurationPreference != null && + state.discussionPreference != null && + state.learningPreference != null && + state.flexibilityPreference != null + + state.memberCount > 1 && isFeeValid && isPersonalityValid + } + 3 -> state.description.isNotBlank() + else -> false + } + } + + fun submit() { + viewModelScope.launch { + _sideEffect.emit(RegisterStudySideEffect.NavigateToHome) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt new file mode 100644 index 00000000..1cbac74e --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/BinaryChoiceRow.kt @@ -0,0 +1,55 @@ +package com.umcspot.spot.study.register.component + +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.width +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun BinaryChoiceRow( + leftText: String, + rightText: String, + selectedIndex: Int?, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionChip( + text = leftText, + isSelected = selectedIndex == 0, + onClick = { onSelect(0) }, + modifier = Modifier.weight(1f) + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(screenWidthDp(6.5.dp))) + VerticalDivider( + modifier = Modifier.height(screenHeightDp(12.dp)), + thickness = 1.dp, + color = SpotTheme.colors.gray300 + ) + Spacer(modifier = Modifier.width(screenWidthDp(6.5.dp))) + } + + SelectionChip( + text = rightText, + isSelected = selectedIndex == 1, + onClick = { onSelect(1) }, + modifier = Modifier.weight(1f) + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt new file mode 100644 index 00000000..7c73282f --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/FeeInputSection.kt @@ -0,0 +1,49 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun FeeInputSection( + hasFee: Boolean?, + feeAmount: String, + onFeeTypeChange: (Boolean) -> Unit, + onFeeAmountChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + if (hasFee == true) screenWidthDp(12.dp) else screenWidthDp(14.dp) + ), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionChip( + text = "없음", + isSelected = hasFee == false, + onClick = { onFeeTypeChange(false) }, + modifier = Modifier.weight(1f) + ) + + SelectionChip( + text = "있음", + isSelected = hasFee == true, + onClick = { onFeeTypeChange(true) }, + modifier = Modifier.weight(1f) + ) + + if (hasFee == true) { + PriceTextField( + value = feeAmount, + onValueChange = onFeeAmountChange, + modifier = Modifier.weight(1f) + ) + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt new file mode 100644 index 00000000..6ca17dd1 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/MemberCountSelector.kt @@ -0,0 +1,157 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun MemberCountSelector( + memberCount: Int, + onMemberCountChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val memberOptions = remember { persistentListOf(2, 3, 4, 5) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = screenHeightDp(4.5.dp)), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier.height(screenHeightDp(30.dp)), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "총 인원 (팀장 포함)", + style = SpotTheme.typography.h5 + ) + } + + Row(verticalAlignment = Alignment.Top) { + Column( + modifier = Modifier.width(screenWidthDp(71.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(30.dp)) + .border( + width = 1.dp, + color = SpotTheme.colors.gray200, + shape = RoundedCornerShape(6.dp) + ) + .clip(RoundedCornerShape(6.dp)) + .noRippleClickable { expanded = !expanded } + .padding(horizontal = screenWidthDp(10.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = memberCount.toString(), + style = SpotTheme.typography.regular_500, + ) + + Icon( + painter = painterResource( + id = if (expanded) R.drawable.arrow_up else R.drawable.arrow_down + ), + contentDescription = null, + tint = SpotTheme.colors.B500, + modifier = Modifier.size(screenWidthDp(14.dp)) + ) + } + + if (expanded) { + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = SpotTheme.colors.white, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = SpotTheme.colors.gray200, + shape = RoundedCornerShape(6.dp) + ) + .clip(RoundedCornerShape(6.dp)) + ) { + memberOptions.forEachIndexed { index, selectionOption -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(30.dp)) + .noRippleClickable { + onMemberCountChange(selectionOption) + expanded = false + }, + contentAlignment = Alignment.Center + ) { + Text( + text = selectionOption.toString(), + style = SpotTheme.typography.regular_500, + textAlign = TextAlign.Center + ) + } + + if (index < memberOptions.lastIndex) { + HorizontalDivider( + color = SpotTheme.colors.gray300, + thickness = 0.5.dp, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.width(screenWidthDp(7.dp))) + + Box( + modifier = Modifier.height(screenHeightDp(30.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = "명", + style = SpotTheme.typography.regular_500 + ) + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt new file mode 100644 index 00000000..8806569c --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/PriceTextField.kt @@ -0,0 +1,79 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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 + +@Composable +fun PriceTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.height(screenHeightDp(35.dp)), + textStyle = SpotTheme.typography.medium_500.copy( + color = SpotTheme.colors.black, + textAlign = TextAlign.Start + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .background( + color = SpotTheme.colors.white, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = SpotTheme.colors.B500, + shape = RoundedCornerShape(6.dp) + ) + .padding(end = screenWidthDp(7.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = screenWidthDp(10.dp)) + ) { + innerTextField() + } + + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + + Text( + text = "원", + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.black + ) + } + } + ) +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt new file mode 100644 index 00000000..1f1cd21c --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectedRegionsSection.kt @@ -0,0 +1,129 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.Black +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SelectedRegionsSection( + selectedRegions: ImmutableList, + onRemoveClick: (String) -> Unit, + onAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(screenHeightDp(10.dp)) + ) { + selectedRegions.forEach { region -> + RegionItem( + regionName = region, + onRemoveClick = { onRemoveClick(region) } + ) + } + + if (selectedRegions.size < 3) { + AddRegionButton(onClick = onAddClick) + } + } +} + +@Composable +private fun RegionItem( + regionName: String, + onRemoveClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = SpotTheme.colors.B500, + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = screenHeightDp(10.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_location), + contentDescription = "지역", + tint = SpotTheme.colors.B500, + modifier = Modifier.size(screenWidthDp(18.dp)) + ) + Spacer(modifier = Modifier.size(screenWidthDp(8.dp))) + Text( + text = regionName, + style = SpotTheme.typography.h5, + color = SpotTheme.colors.B500 + ) + } + IconButton(onClick = onRemoveClick) { + Icon( + painter = painterResource(id = R.drawable.dismiss), + contentDescription = "삭제", + tint = SpotTheme.colors.B500, + modifier = Modifier.size(screenWidthDp(18.dp)) + ) + } + } +} + +@Composable +private fun AddRegionButton( + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = screenWidthDp(5.dp), + vertical = screenHeightDp(9.dp) + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.multiple), + contentDescription = "추가", + tint = SpotTheme.colors.Black, + modifier = Modifier.size(screenWidthDp(14.dp)) + ) + Spacer(modifier = Modifier.size(screenWidthDp(4.dp))) + Text( + text = "지역 추가", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.Black + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt new file mode 100644 index 00000000..ac059b82 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/SelectionChip.kt @@ -0,0 +1,54 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B100 +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp + +@Composable +fun SelectionChip( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(screenHeightDp(35.dp)) + .background( + color = if (isSelected) SpotTheme.colors.B100 else SpotTheme.colors.white, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = SpotTheme.colors.gray200, + shape = RoundedCornerShape(6.dp) + ) + .clip(RoundedCornerShape(6.dp)) + .noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = SpotTheme.typography.medium_500, + color = if (isSelected) SpotTheme.colors.B500 else SpotTheme.colors.black, + modifier = Modifier.padding(vertical = screenHeightDp(7.dp)), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/StepProgressBar.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/StepProgressBar.kt new file mode 100644 index 00000000..924352ea --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/StepProgressBar.kt @@ -0,0 +1,48 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B400 +import com.umcspot.spot.designsystem.theme.G100 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp + +@Composable +fun StepProgressBar( + currentStep: Int, + totalSteps: Int = 4, + activeColor: Color = SpotTheme.colors.B400, + inactiveColor: Color = SpotTheme.colors.G100 +) { + val targetProgress = (currentStep.toFloat() / totalSteps.toFloat()).coerceIn(0.0f, 1.0f) + + val animatedProgress by animateFloatAsState( + targetValue = targetProgress, + label = "ProgressBarAnimation" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenHeightDp(3.dp)) + .clip(RoundedCornerShape(12.dp)) + .background(inactiveColor) + ) { + Box( + modifier = Modifier + .fillMaxWidth(animatedProgress) + .height(screenHeightDp(3.dp)) + .background(activeColor) + ) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/component/StudyNameTextField.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/component/StudyNameTextField.kt new file mode 100644 index 00000000..04b560a4 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/component/StudyNameTextField.kt @@ -0,0 +1,108 @@ +package com.umcspot.spot.study.register.component + +import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.G400 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + + +private val CONSONANTS_VOWELS_ONLY_REGEX = Regex("^[ㄱ-ㅎㅏ-ㅣ]+$") + +@Composable +fun StudyNameTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val isValid = remember(value) { + value.isNotBlank() && !CONSONANTS_VOWELS_ONLY_REGEX.matches(value) + } + var isFocused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val shape = RoundedCornerShape(screenWidthDp(6.dp)) + + + val guideColor = SpotTheme.colors.G400 + val validColor = SpotTheme.colors.B500 + val borderColor = if (isFocused) SpotTheme.colors.B500 else SpotTheme.colors.G400 + + Column(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxWidth() + .border(screenWidthDp(1.dp), borderColor, shape) + .clip(shape) + .padding(horizontal = screenWidthDp(10.dp), vertical = screenHeightDp(7.dp)), + contentAlignment = Alignment.Center + ) { + BasicTextField( + value = value, + onValueChange = { if (it.length <= 15) onValueChange(it) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> isFocused = focusState.isFocused }, + textStyle = SpotTheme.typography.medium_500, + singleLine = true, + cursorBrush = SolidColor(SpotTheme.colors.B500), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + decorationBox = { innerTextField -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + + Box(modifier = Modifier.weight(1f)) { + innerTextField() + } + + Text( + text = "(${value.length}/15)", + style = SpotTheme.typography.regular_500, + color = guideColor + ) + } + } + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + val message = when { + value.isBlank() -> "스터디 이름을 입력해주세요." + isValid -> "멋진 스터디 이름이에요!" + else -> "자음/모음만으로는 이름을 만들 수 없어요." + } + val messageColor = if (isValid) validColor else guideColor + + Text( + text = message, + style = SpotTheme.typography.regular_500, + color = messageColor + ) + } +} diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt new file mode 100644 index 00000000..8ddfd3bd --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/model/RegisterStudyState.kt @@ -0,0 +1,35 @@ +package com.umcspot.spot.study.register.model + +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.model.ActivityType +import com.umcspot.spot.model.StudyTheme + +data class RegisterStudyState( + + val studyName: String = "", + val studyThemes: List = emptyList(), + + val activityType: ActivityType? = null, + val isSheetVisible: Boolean = false, + val locationQuery: String = "", + val locationResults: List = emptyList(), + val selectedRegions: List = emptyList(), + + val memberCount: Int = 2, + val hasFee: Boolean? = null, + val feeAmount: String = "", + + val networkingPreference: Int? = null, + val goalDurationPreference: Int? = null, + val discussionPreference: Int? = null, + val learningPreference: Int? = null, + val flexibilityPreference: Int? = null, + + val description: String = "", + val studyImageUri: String? = null +) + +sealed interface RegisterStudySideEffect { + data object NavigateToHome : RegisterStudySideEffect + data class ShowSnackBar(val message: String) : RegisterStudySideEffect +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt new file mode 100644 index 00000000..b552447d --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/navigation/RegisterStudyNavigation.kt @@ -0,0 +1,38 @@ +package com.umcspot.spot.study.register.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.navigation.Route +import com.umcspot.spot.study.register.RegisterStudyRoute +import kotlinx.serialization.Serializable + +fun NavController.navigateToRegisterStudy(navOptions: NavOptions? = null) { + + navigate(RegisterStudy, navOptions) +} + + +fun NavGraphBuilder.registerStudyGraph( + contentPadding : PaddingValues, + onBackClick: () -> Unit, +// navigateToNextScreen: (Long) -> Unit + navigateToHome: () -> Unit +) { + composable { + + RegisterStudyRoute( + contentPadding = contentPadding, + onBackClick = onBackClick, +// navigateToNext = { createdStudyId -> +// navigateToNextScreen(createdStudyId) +// } + navigateToHome = navigateToHome + ) + } +} + +@Serializable +data object RegisterStudy : Route diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt new file mode 100644 index 00000000..8b01874b --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyCategoryScreen.kt @@ -0,0 +1,68 @@ +package com.umcspot.spot.study.register.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.component.study.section.ActivityThemeSection +import com.umcspot.spot.designsystem.theme.G400 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.model.StudyTheme +import com.umcspot.spot.study.register.component.StudyNameTextField +import com.umcspot.spot.ui.extension.screenHeightDp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun StudyCategoryScreen( + studyName: String, + selectedThemes: ImmutableList, + onStudyNameChange: (String) -> Unit, + onThemeSelect: (StudyTheme) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + ) { + Text( + text = "어떤 스터디인가요?", + style = SpotTheme.typography.h3 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + StudyNameTextField( + value = studyName, + onValueChange = onStudyNameChange + ) + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + + Text( + text = "카테고리를 선택해주세요", + style = SpotTheme.typography.h3, + color = SpotTheme.colors.black + ) + + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + Text( + text = "최대 3개까지 선택할 수 있어요", + style = SpotTheme.typography.regular_500, + color = SpotTheme.colors.G400 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ActivityThemeSection( + selectedThemes = selectedThemes, + onSelect = onThemeSelect + ) + } +} diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt new file mode 100644 index 00000000..2e420cde --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyInfoScreen.kt @@ -0,0 +1,104 @@ +package com.umcspot.spot.study.register.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.study.register.component.BinaryChoiceRow +import com.umcspot.spot.study.register.component.FeeInputSection +import com.umcspot.spot.study.register.component.MemberCountSelector +import com.umcspot.spot.ui.extension.screenHeightDp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun StudyInfoScreen( + memberCount: Int, + onMemberCountChange: (Int) -> Unit, + hasFee: Boolean?, + feeAmount: String, + onFeeInfoChange: (Boolean?, String) -> Unit, + preferences: ImmutableList, + onPersonalityChange: (Int, Int) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + .verticalScroll(scrollState) + ) { + Text( + text = "모집 정보를 작성해주세요.", + style = SpotTheme.typography.h3 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + MemberCountSelector( + memberCount = memberCount, + onMemberCountChange = onMemberCountChange + ) + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + + Text( + text = "활동비", + style = SpotTheme.typography.h5 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + FeeInputSection( + hasFee = hasFee, + feeAmount = feeAmount, + onFeeTypeChange = { type -> + onFeeInfoChange(type, if (type == false) "" else feeAmount) + }, + onFeeAmountChange = { amount -> + onFeeInfoChange(hasFee, amount) + } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + + Text( + text = "스터디의 성격을 표현하는 단어를 모두 선택해봐요.", + style = SpotTheme.typography.h5 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + val choiceLabels = persistentListOf( + "네트워킹 중시" to "목표/규율 중시", + "단기 목표" to "장기 목표", + "개인 학습 + 함께 토론형" to "공동 학습 + 동시 진행형", + "학습형" to "토론형", + "가볍게 + 유연하게" to "규칙적인 + 계획적인" + ) + + choiceLabels.forEachIndexed { index, (left, right) -> + BinaryChoiceRow( + leftText = left, + rightText = right, + selectedIndex = preferences[index], + onSelect = { value -> onPersonalityChange(index, value) } + ) + if (index < choiceLabels.lastIndex) { + Spacer(modifier = Modifier.height(screenHeightDp(12.dp))) + } + } + + Spacer(modifier = Modifier.height(screenHeightDp(40.dp))) + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt new file mode 100644 index 00000000..e833e4db --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyIntroduceScreen.kt @@ -0,0 +1,154 @@ +package com.umcspot.spot.study.register.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.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.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.umcspot.spot.designsystem.R +import com.umcspot.spot.designsystem.theme.B500 +import com.umcspot.spot.designsystem.theme.Default +import com.umcspot.spot.designsystem.theme.G100 +import com.umcspot.spot.designsystem.theme.G400 +import com.umcspot.spot.designsystem.theme.SpotTheme +import com.umcspot.spot.ui.extension.noRippleClickable +import com.umcspot.spot.ui.extension.screenHeightDp +import com.umcspot.spot.ui.extension.screenWidthDp + +@Composable +fun StudyIntroduceScreen( + description: String, + onDescriptionChange: (String) -> Unit, + onIntroduceValid: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + var isImageSelected by remember { mutableStateOf(false) } + + LaunchedEffect(description) { + onIntroduceValid(description.isNotEmpty()) + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + ) { + Text( + text = "마지막으로. 이 스터디에 대해\n자세히 소개할 것이 있다면 작성해주세요.", + style = SpotTheme.typography.h3 + ) + Spacer(modifier = Modifier.height(screenHeightDp(4.dp))) + + Text( + text = "자세히 적을수록, 모집 확률은 올라가요.", + color = SpotTheme.colors.G400, + style = SpotTheme.typography.regular_500 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + BasicTextField( + value = description, + onValueChange = onDescriptionChange, + modifier = Modifier.fillMaxWidth(), + textStyle = SpotTheme.typography.medium_500.copy( + color = SpotTheme.colors.black + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = screenHeightDp(134.dp)) + .border( + width = 1.dp, + color = if (description.isEmpty()) SpotTheme.colors.Default else SpotTheme.colors.B500, + shape = RoundedCornerShape(6.dp) + ) + .padding( + horizontal = screenWidthDp(10.dp), + vertical = screenHeightDp(7.dp) + ) + ) { + if (description.isEmpty()) { + Text( + text = "이 스터디의 목표, 선호하는 스터디원, 앞으로의 진행 방식 등 ", + style = SpotTheme.typography.medium_500, + color = SpotTheme.colors.Default + ) + } + innerTextField() + } + } + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "스터디 대표 이미지", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.black + ) + Spacer(modifier = Modifier.width(screenWidthDp(4.dp))) + Text( + text = "(선택)", + style = SpotTheme.typography.h5, + color = SpotTheme.colors.G400 + ) + } + + Spacer(modifier = Modifier.height(screenHeightDp(16.dp))) + + Box( + modifier = Modifier + .size(width = screenWidthDp(80.dp), height = screenHeightDp(80.dp)) + .clip(RoundedCornerShape(6.dp)) + .background(if (isImageSelected) SpotTheme.colors.black else SpotTheme.colors.G100) // 이미지가 없을 때 배경색 지정 (G100) + .noRippleClickable { + isImageSelected = !isImageSelected + }, + contentAlignment = Alignment.Center + ) { + if (isImageSelected) { + Image( + painter = painterResource(id = R.drawable.image), + contentDescription = "Selected Study Image", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painter = painterResource(id = R.drawable.image), + contentDescription = "Upload Image", + tint = SpotTheme.colors.G400, + modifier = Modifier.size(screenWidthDp(24.dp)) + ) + } + } + } +} \ No newline at end of file diff --git a/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt new file mode 100644 index 00000000..8b073066 --- /dev/null +++ b/feature/study/src/main/java/com/umcspot/spot/study/register/screen/StudyPlaceScreen.kt @@ -0,0 +1,76 @@ +package com.umcspot.spot.study.register.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.umcspot.spot.common.location.LocationRow +import com.umcspot.spot.designsystem.component.bottomsheet.LocationBottomSheet +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.study.register.component.SelectedRegionsSection +import com.umcspot.spot.ui.extension.screenHeightDp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun StudyPlaceScreen( + activityType: ActivityType?, + isSheetVisible: Boolean, + query: String, + searchResults: List, + selectedRegions: ImmutableList, + onActivityTypeSelect: (ActivityType) -> Unit, + onQueryChange: (String) -> Unit, + onSheetOpen: () -> Unit, + onSheetDismiss: () -> Unit, + onAddSelected: (String) -> Unit, + onRemoveSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + LocationBottomSheet( + visible = isSheetVisible, + query = query, + results = searchResults, + onQueryChange = onQueryChange, + onDismiss = onSheetDismiss, + selected = selectedRegions, + onAddSelected = onAddSelected, + onRemoveSelected = onRemoveSelected + ) + + Column( + modifier = modifier + .fillMaxSize() + .padding(top = screenHeightDp(65.dp)) + ) { + Text( + text = "스터디는 어디서 진행하나요?", + style = SpotTheme.typography.h3 + ) + + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + + ActivityTypeSection( + activityType = activityType, + onSelect = onActivityTypeSelect + ) + + AnimatedVisibility(visible = activityType == ActivityType.OFFLINE && selectedRegions.isNotEmpty()) { + Column { + Spacer(modifier = Modifier.height(screenHeightDp(20.dp))) + SelectedRegionsSection( + selectedRegions = selectedRegions, + onRemoveClick = onRemoveSelected, + onAddClick = onSheetOpen + ) + } + } + } +}