diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7ef95bc..02144d2c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,11 +5,10 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) - id("kotlin-kapt") - id("com.google.dagger.hilt.android") + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) } - val properties = Properties().apply { load(project.rootProject.file("local.properties").inputStream()) } @@ -39,11 +38,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 //윈도우에서 hilt 사용 시 java 1.8은 에러가 발생하는 이슈가 있다고함 -> 17 사용 시 해결 + targetCompatibility = JavaVersion.VERSION_17 //윈도우에서 hilt 사용 시 java 1.8은 에러가 발생하는 이슈가 있다고함 -> 17 사용 시 해결 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" //윈도우에서 hilt 사용 시 java 1.8은 에러가 발생하는 이슈가 있다고함 -> 17 사용 시 해결 } buildFeatures { compose = true @@ -61,8 +60,8 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation(libs.androidx.navigation.runtime.ktx) - implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.espresso.core) + implementation(libs.androidx.compose.navigation) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -70,9 +69,10 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.material) + + //viewmodel + implementation (libs.androidx.lifecycle.viewmodel.compose) + // Network implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp) @@ -80,12 +80,11 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.kotlin.serialization.converter) implementation(libs.kotlinx.serialization.json) - //hilt - implementation(libs.hilt.android.v2511) - kapt(libs.hilt.compiler.v2511) - implementation(libs.androidx.hilt.navigation.compose) -} -kapt { - correctErrorTypes = true + // hilt + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54588e7b..1f0c37f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ - - + - + \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/MainActivity.kt b/app/src/main/java/org/sopt/and/MainActivity.kt deleted file mode 100644 index d28aa84e..00000000 --- a/app/src/main/java/org/sopt/and/MainActivity.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.sopt.and - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.Composable -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import org.sopt.and.navigation.SignNavigation -import org.sopt.and.signup.SignUpScreen - -import org.sopt.and.signin.SignInScreen - -import org.sopt.and.ui.theme.ANDANDROIDTheme -import org.sopt.and.viewmodel.SignViewModel - -import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.HiltAndroidApp - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - ANDANDROIDTheme { - val signViewModel: SignViewModel = viewModel() - MyApp(signViewModel) - } - } - } -} - -@Composable -fun MyApp(signViewModel: SignViewModel) { - val navController = rememberNavController() - - NavHost(navController, startDestination = SignNavigation.SignUp.route) { - composable(SignNavigation.SignUp.route) { - SignUpScreen( - signViewModel = signViewModel, -// onNavigateBack = { navController.popBackStack() }, - onNavigateToSignIn = { navController.navigate("signIn") }, - ) - } - composable(SignNavigation.SignIn.route) { - SignInScreen( - signViewModel = signViewModel, - onNavigateToMain = {navController.navigate("main"){ - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - }}, - onNavigateToSignUp = {navController.navigate("signUp")} - - ) - } - - composable(SignNavigation.Main.route) { - MainScreen(signViewModel = signViewModel) - } - } -} - diff --git a/app/src/main/java/org/sopt/and/MainScreen.kt b/app/src/main/java/org/sopt/and/MainScreen.kt deleted file mode 100644 index 81adb7a4..00000000 --- a/app/src/main/java/org/sopt/and/MainScreen.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.sopt.and - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBarItemDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.sp -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavHostController -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import org.sopt.and.navigation.BottomNavigationGraph -import org.sopt.and.navigation.BottomNavigation -import org.sopt.and.viewmodel.SignViewModel - -@Composable -fun MainScreen(signViewModel: SignViewModel) { - val navController = rememberNavController() - - Scaffold( - bottomBar = { BottomBar(navController = navController) } - ) { innerPadding -> - Box(Modifier.padding(innerPadding)) { - BottomNavigationGraph(navController = navController, signViewModel = signViewModel) - } - } -} - -@Composable -fun BottomBar(navController: NavHostController) { - val screens = listOf( - BottomNavigation.Home, - BottomNavigation.Search, - BottomNavigation.MY - ) - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - - NavigationBar{ - screens.forEach { screen -> - BottomNavItem( - item = screen, - isSelected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, - navController = navController - ) - } - } -} - -@Composable -fun RowScope.BottomNavItem( - item: BottomNavigation, - isSelected: Boolean?, - navController: NavHostController -) { - if (isSelected != null) { - NavigationBarItem( - label = { Text(text = stringResource(item.title), fontSize = 10.sp) }, - icon = { - Icon( - imageVector = item.icon, - contentDescription = stringResource(item.title) - ) - }, - selected = isSelected, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Color.White, - unselectedIconColor = Color.Gray - ), - onClick = { - navController.navigate(item.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - } - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/Route.kt b/app/src/main/java/org/sopt/and/Route.kt new file mode 100644 index 00000000..9bcb270b --- /dev/null +++ b/app/src/main/java/org/sopt/and/Route.kt @@ -0,0 +1,22 @@ +package org.sopt.and + +import kotlinx.serialization.Serializable + + +@Serializable +sealed class Route(val route: String) { + @Serializable + data object Home : Route("home") + + @Serializable + data object SignIn : Route("signIn") + + @Serializable + data object SignUp : Route("signUp") + + @Serializable + data object Search : Route("search") + + @Serializable + data object MyInfo : Route("myInfo") +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/MyApplication.kt b/app/src/main/java/org/sopt/and/WavveApplication.kt.kt similarity index 74% rename from app/src/main/java/org/sopt/and/MyApplication.kt rename to app/src/main/java/org/sopt/and/WavveApplication.kt.kt index e8980f3a..279903a1 100644 --- a/app/src/main/java/org/sopt/and/MyApplication.kt +++ b/app/src/main/java/org/sopt/and/WavveApplication.kt.kt @@ -4,4 +4,4 @@ import android.app.Application import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class MyApplication : Application() \ No newline at end of file +class WavveApplication : Application() \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/api/Auth.kt b/app/src/main/java/org/sopt/and/api/Auth.kt deleted file mode 100644 index a26a42db..00000000 --- a/app/src/main/java/org/sopt/and/api/Auth.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.sopt.and.api - -import org.sopt.and.dto.RequestLoginData -import org.sopt.and.dto.RequestUserRegistrationData -import org.sopt.and.dto.ResponseLogin -import org.sopt.and.dto.ResponseUserRegistration -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.POST - - -interface UserRegistrationService { - @POST("/user") - suspend fun postUserRegistration( - @Body userRequest: RequestUserRegistrationData - ): Response -} - -interface LoginService { - @POST("/login") - suspend fun postLogin( - @Body loginRequeset: RequestLoginData - ): Response -} - diff --git a/app/src/main/java/org/sopt/and/api/Hobby.kt b/app/src/main/java/org/sopt/and/api/Hobby.kt deleted file mode 100644 index c1784eb0..00000000 --- a/app/src/main/java/org/sopt/and/api/Hobby.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.and.api - -import org.sopt.and.dto.ResponseMyHobbyData -import retrofit2.Response -import retrofit2.http.GET -import retrofit2.http.Header - -interface HobbyService { - @GET("/user/my-hobby") - suspend fun getHobby( - @Header("token") token: String? - ): Response -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/ContentType.kt b/app/src/main/java/org/sopt/and/core/ContentType.kt new file mode 100644 index 00000000..a89345a0 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/ContentType.kt @@ -0,0 +1,18 @@ +package org.sopt.and.core + +import androidx.annotation.StringRes +import org.sopt.and.R + +enum class ContentType( + @StringRes val titleResId: Int +) { + NEW_CLASSIC(R.string.type_new_classic), + DRAMA(R.string.type_drama), + ENTERTAINMENT(R.string.type_entertainment), + MOVIE(R.string.type_movie), + ANIMATION(R.string.type_animation), + ABROAD_SERIES(R.string.type_abroad_series), + INFORMATION_CULTURE(R.string.type_information_culture), + KIDS(R.string.type_kids), + MOVIE_PLUS(R.string.type_movie_plus), +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/Route.kt b/app/src/main/java/org/sopt/and/core/Route.kt new file mode 100644 index 00000000..f420dbaa --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/Route.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core + +interface Route diff --git a/app/src/main/java/org/sopt/and/core/component/CloseTopBar.kt b/app/src/main/java/org/sopt/and/core/component/CloseTopBar.kt new file mode 100644 index 00000000..8c9c9cd8 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/CloseTopBar.kt @@ -0,0 +1,41 @@ +package org.sopt.and.core.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.ui.theme.WavveBg + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CloseTopBar( + title: String, + onCloseClicked: () -> Unit) { + CenterAlignedTopAppBar( + title = { + Text(text = title, fontSize = 20.sp) + }, + actions = { + IconButton(onClick = onCloseClicked) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close_top_bar_icon_description_close), + tint = White + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = WavveBg, + titleContentColor = White + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/component/HomeTobBar.kt b/app/src/main/java/org/sopt/and/core/component/HomeTobBar.kt new file mode 100644 index 00000000..44009feb --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/HomeTobBar.kt @@ -0,0 +1,44 @@ +package org.sopt.and.core.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import org.sopt.and.R +import org.sopt.and.ui.theme.WavveBg + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeTopBar( + onLiveButtonClick: () -> Unit +) { + TopAppBar( + title = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.wavve_logo), + contentDescription = stringResource(R.string.back_button_top_bar_icon_description_logo), + tint = White + ) + }, + actions = { + IconButton(onClick = onLiveButtonClick) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_cast_24), + contentDescription = stringResource(R.string.common_top_bar_icon_description_live), + tint = White + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = WavveBg, + titleContentColor = White + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/component/HomeTopBar.kt b/app/src/main/java/org/sopt/and/core/component/HomeTopBar.kt new file mode 100644 index 00000000..44009feb --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/HomeTopBar.kt @@ -0,0 +1,44 @@ +package org.sopt.and.core.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import org.sopt.and.R +import org.sopt.and.ui.theme.WavveBg + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeTopBar( + onLiveButtonClick: () -> Unit +) { + TopAppBar( + title = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.wavve_logo), + contentDescription = stringResource(R.string.back_button_top_bar_icon_description_logo), + tint = White + ) + }, + actions = { + IconButton(onClick = onLiveButtonClick) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_cast_24), + contentDescription = stringResource(R.string.common_top_bar_icon_description_live), + tint = White + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = WavveBg, + titleContentColor = White + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/navigation/Screen.kt b/app/src/main/java/org/sopt/and/core/navigation/Screen.kt new file mode 100644 index 00000000..3434d700 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/navigation/Screen.kt @@ -0,0 +1,21 @@ +package org.sopt.and.core.navigation + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Screen { + @Serializable + data object SignIn: Screen() + + @Serializable + data object SignUp : Screen() + + @Serializable + data object My: Screen() + + @Serializable + data object Home : Screen() + + @Serializable + data object Search : Screen() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/utils/PreferenceUtil.kt b/app/src/main/java/org/sopt/and/core/utils/PreferenceUtil.kt new file mode 100644 index 00000000..23d1adeb --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/utils/PreferenceUtil.kt @@ -0,0 +1,33 @@ +package org.sopt.and.core.utils + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + + +class PreferenceUtil @Inject constructor( + @ApplicationContext private val context: Context +) { + private val sharedPreferences = + context.getSharedPreferences("wavve_prefs", Context.MODE_PRIVATE) + + fun saveUserToken(token: String) { + sharedPreferences.edit().putString(USER_TOKEN, token).apply() + } + + fun getUserToken(): String? { + return sharedPreferences.getString(USER_TOKEN, null) + } + + fun clearUserToken() { + sharedPreferences.edit().remove(USER_TOKEN).apply() + } + + fun clearAll() { + sharedPreferences.edit().clear().apply() + } + + companion object { + private const val USER_TOKEN = "user_token" + } +} diff --git a/app/src/main/java/org/sopt/and/core/utils/SnackBarUtils.kt b/app/src/main/java/org/sopt/and/core/utils/SnackBarUtils.kt new file mode 100644 index 00000000..0335cb3d --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/utils/SnackBarUtils.kt @@ -0,0 +1,33 @@ +package org.sopt.and.core.utils + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult + +object SnackBarUtils { + private lateinit var snackBarHostState: SnackbarHostState + + fun init(snackBarHostState: SnackbarHostState) { + SnackBarUtils.snackBarHostState = snackBarHostState + } + + suspend fun showSnackBar( + message: String, + actionLabel: String? = null, + onActionClick: (() -> Unit)? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + ) { + if (this::snackBarHostState.isInitialized) { + val result = snackBarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = duration + ) + if (result == SnackbarResult.ActionPerformed) { + onActionClick?.invoke() + } + } else { + throw UninitializedPropertyAccessException("SnackBarHostState가 초기화 되지 않았습니다. init()을 먼저 호출하세요") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/utils/UiState.kt b/app/src/main/java/org/sopt/and/core/utils/UiState.kt new file mode 100644 index 00000000..e4159383 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/utils/UiState.kt @@ -0,0 +1,8 @@ +package org.sopt.and.core.utils + +sealed class UiState { + object Loading : UiState() + object Empty : UiState() + data class Success(val data: T) : UiState() + object Failure : UiState() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/utils/extension/Modifier.kt b/app/src/main/java/org/sopt/and/core/utils/extension/Modifier.kt new file mode 100644 index 00000000..cf406cc8 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/utils/extension/Modifier.kt @@ -0,0 +1,14 @@ +package org.sopt.and.core.utils.extension + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed { + this.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onClick() } +} diff --git a/app/src/main/java/org/sopt/and/data/api/UserService.kt b/app/src/main/java/org/sopt/and/data/api/UserService.kt new file mode 100644 index 00000000..76ecf1cc --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/api/UserService.kt @@ -0,0 +1,29 @@ +package org.sopt.and.data.api + +import org.sopt.and.data.model.request.SignInRequestDto +import org.sopt.and.data.model.request.SignUpRequestDto +import org.sopt.and.data.model.response.MyHobbyResponseDto +import org.sopt.and.data.model.response.SignInResponseDto +import org.sopt.and.data.model.response.SignUpResponseDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST + +interface UserService { + @POST("/user") + suspend fun registerUser( + @Body request: SignUpRequestDto + ): Response + + @POST("/login") + suspend fun loginUser( + @Body request: SignInRequestDto + ): Response + + @GET("/user/my-hobby") + suspend fun getMyHobby( + @Header("token") token: String + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/common/APICallType.kt b/app/src/main/java/org/sopt/and/data/common/APICallType.kt new file mode 100644 index 00000000..7bdc3557 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/common/APICallType.kt @@ -0,0 +1,11 @@ +package org.sopt.and.data.common + + +object APICallType { + const val REGISTER_USER = "registerUser" + const val LOGIN_USER = "loginUser" + const val GET_MY_HOBBY = "getMyHobby" +} +object ErrorTypeWithMessage { + const val INVALID_TOKEN = "다시 로그인 해주세요." //토큰이 유효하지 않거나 없음, 다시 로그인 하도록 유도함 +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/DataSourceModule.kt b/app/src/main/java/org/sopt/and/data/di/DataSourceModule.kt new file mode 100644 index 00000000..3c1113a9 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/DataSourceModule.kt @@ -0,0 +1,20 @@ +package org.sopt.and.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.sopt.and.data.api.UserService +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataSourceModule { + + @Provides + @Singleton + fun provideUserService(retrofit: Retrofit): UserService { + return retrofit.create(UserService::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/PreferenceModule.kt b/app/src/main/java/org/sopt/and/data/di/PreferenceModule.kt new file mode 100644 index 00000000..21b22816 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/PreferenceModule.kt @@ -0,0 +1,20 @@ +package org.sopt.and.data.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.sopt.and.core.utils.PreferenceUtil +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferenceModule { + @Provides + @Singleton + fun providePreferenceUtil(@ApplicationContext context: Context): PreferenceUtil { + return PreferenceUtil(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/RepositoryModule.kt b/app/src/main/java/org/sopt/and/data/di/RepositoryModule.kt new file mode 100644 index 00000000..c75c93dc --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/RepositoryModule.kt @@ -0,0 +1,39 @@ +package org.sopt.and.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.sopt.and.data.api.UserService +import org.sopt.and.data.datasource.MyHobbyDataSource +import org.sopt.and.data.datasource.SignUpDataSource +import org.sopt.and.data.repositoryimpl.MyHobbyRepositoryImpl +import org.sopt.and.data.repositoryimpl.SignInRepositoryImpl +import org.sopt.and.data.repositoryimpl.SignUpRepositoryImpl +import org.sopt.and.domain.repository.MyHobbyRepository +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.domain.repository.SignInRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RepositoryModule { + + @Provides + @Singleton + fun provideUserRegisterRepository(userService: UserService): SignUpRepository { + return SignUpRepositoryImpl(userService) + } + + @Provides + @Singleton + fun provideUserLoginRepository(userService: UserService): SignInRepository { + return SignInRepositoryImpl(userService) + } + + @Provides + @Singleton + fun provideGetMyHobbyRepository(userService: MyHobbyDataSource): MyHobbyRepository { + return MyHobbyRepositoryImpl(userService) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/api/Api.kt b/app/src/main/java/org/sopt/and/data/di/RetrofitModule.kt similarity index 53% rename from app/src/main/java/org/sopt/and/api/Api.kt rename to app/src/main/java/org/sopt/and/data/di/RetrofitModule.kt index 36ac5ad1..6878faf4 100644 --- a/app/src/main/java/org/sopt/and/api/Api.kt +++ b/app/src/main/java/org/sopt/and/data/di/RetrofitModule.kt @@ -1,4 +1,4 @@ -package org.sopt.and.api +package org.sopt.and.data.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module @@ -9,21 +9,25 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor -import org.sopt.and.BuildConfig +import org.sopt.and.BuildConfig.BASE_URL import retrofit2.Retrofit import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object ApiFactory { - private const val BASE_URL: String = BuildConfig.BASE_URL +object RetrofitModule { @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { - val loggingInterceptor = HttpLoggingInterceptor().apply { + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + } + + @Provides + @Singleton + fun provideOkHttpClient(loggingInterceptor: HttpLoggingInterceptor): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .build() @@ -31,30 +35,11 @@ object ApiFactory { @Provides @Singleton - fun provideRetrofit(client: OkHttpClient): Retrofit { + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { return Retrofit.Builder() .baseUrl(BASE_URL) - .client(client) + .client(okHttpClient) .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) .build() } - - @Provides - @Singleton - fun provideUserRegistrationService(retrofit: Retrofit): UserRegistrationService { - return retrofit.create(UserRegistrationService::class.java) - } - - @Provides - @Singleton - fun provideLoginService(retrofit: Retrofit): LoginService { - return retrofit.create(LoginService::class.java) - } - - @Provides - @Singleton - fun provideHobbyService(retrofit: Retrofit): HobbyService { - return retrofit.create(HobbyService::class.java) - } - } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/di/UseCaseModule.kt b/app/src/main/java/org/sopt/and/data/di/UseCaseModule.kt new file mode 100644 index 00000000..2dd55535 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/di/UseCaseModule.kt @@ -0,0 +1,36 @@ +package org.sopt.and.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.sopt.and.domain.repository.MyHobbyRepository +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.domain.repository.SignInRepository +import org.sopt.and.domain.usecase.MyHobbyUseCase +import org.sopt.and.domain.usecase.SignInUseCase +import org.sopt.and.domain.usecase.SignUpUseCase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UseCaseModule { + + @Provides + @Singleton + fun provideRegisterUserUseCase(userRepository: SignUpRepository): SignUpUseCase { + return SignUpUseCase(userRepository) + } + + @Provides + @Singleton + fun provideLoginUserUseCase(userRepository: SignInRepository): SignInUseCase { + return SignInUseCase(userRepository) + } + + @Provides + @Singleton + fun provideGetMyHobbyUseCase(userRepository: MyHobbyRepository): MyHobbyUseCase { + return MyHobbyUseCase(userRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/mapper/ErrorMapper.kt b/app/src/main/java/org/sopt/and/data/mapper/ErrorMapper.kt new file mode 100644 index 00000000..0b41d2c6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/mapper/ErrorMapper.kt @@ -0,0 +1,32 @@ +package org.sopt.and.data.mapper + +import org.sopt.and.data.common.APICallType + +object ErrorMapper { + private val errorMapByApi = mapOf( + APICallType.REGISTER_USER to mapOf( + Pair(400, "01") to "잘못된 요청입니다.", + Pair(400, "01") to "아이디, 비밀번호, 취미를 올바르게 입력해주세요.", + Pair(404, null) to "잘못된 요청입니다.", + Pair(409, "00") to "중복된 아이디입니다." + ), + APICallType.LOGIN_USER to mapOf( + Pair(400, "01") to "비밀번호를 올바르게 입력해주세요.", + Pair(400, "02") to "비밀번호를 올바르게 입력해주세요.", + Pair(403, "01") to "아이디 혹은 비밀번호가 틀렸습니다.", + Pair(404, "00") to "잘못된 요청입니다." + ), + APICallType.GET_MY_HOBBY to mapOf( + Pair(401, "00") to "잘못된 요청입니다.", + Pair(403, "00") to "잘못된 요청입니다.", + Pair(404, "00") to "잘못된 요청입니다." + ), + + ) + fun getErrorMessage(apiName: String, statusCode: Int?, errorCode: String?): String { + val apiErrorMap = errorMapByApi[apiName] + val message = apiErrorMap?.get(Pair(statusCode, errorCode)) + + return message ?: "알 수 없는 에러" + } +} diff --git a/app/src/main/java/org/sopt/and/data/mapper/Mapper.kt b/app/src/main/java/org/sopt/and/data/mapper/Mapper.kt new file mode 100644 index 00000000..e76077d6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/mapper/Mapper.kt @@ -0,0 +1,52 @@ +package org.sopt.and.data.mapper + +import org.sopt.and.data.model.request.SignInRequestDto +import org.sopt.and.data.model.request.SignUpRequestDto +import org.sopt.and.data.model.response.MyHobbyResponseResultDto +import org.sopt.and.data.model.response.SignInResponseDto +import org.sopt.and.data.model.response.SignUpResponseDto +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.model.MyHobbyEntity +import org.sopt.and.domain.model.SignInInformationEntity +import org.sopt.and.domain.model.SignInResponseEntity +import org.sopt.and.domain.model.SignUpInformationEntity +import org.sopt.and.domain.model.SignUpResponseEntity +import retrofit2.Response + +object Mapper { + fun toMyHobbyEntity(getHobbyResponseResultDto: MyHobbyResponseResultDto) = + MyHobbyEntity(myHobby = getHobbyResponseResultDto.myHobby) + + fun toSignUpResponseEntity(signUpResponseDto: Response) = + signUpResponseDto.body()?.result?.let { + SignUpResponseEntity( + no = it.no, + status = signUpResponseDto.code(), + code = signUpResponseDto.body()!!.code + ) + } + + fun toSignInResponseEntity(signInResponseDto: Response) = + signInResponseDto.body()?.result?.let { + SignInResponseEntity( + token = it.token, + status = signInResponseDto.code(), + code = signInResponseDto.body()!!.code + ) + } + + fun UserData.toUserLoginRequestDto(): SignInRequestDto = SignInRequestDto( + username = this.username, + password = this.password + ) + + fun UserData.toRegisterRequestDto(): SignUpRequestDto { + return SignUpRequestDto( + username = this.username, + password = this.password, + hobby = this.hobby + ) + } + +} + diff --git a/app/src/main/java/org/sopt/and/data/model/BaseResponse.kt b/app/src/main/java/org/sopt/and/data/model/BaseResponse.kt new file mode 100644 index 00000000..d81f16d5 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/BaseResponse.kt @@ -0,0 +1,25 @@ +package org.sopt.and.data.model + +import kotlinx.serialization.Serializable +import org.sopt.and.domain.entity.BaseResult + +@Serializable +sealed class BaseResponse { + data class Success(val data: T) : BaseResponse() + data class Failure( + val statusCode: Int?, + val errorCode: String?, + val message: String + ) : BaseResponse() +} +@Serializable +data class ErrorResponse( + val code: String +) + +fun BaseResponse.toBaseResult(): BaseResult { + return when (this) { + is BaseResponse.Success -> BaseResult.Success(this.data) + is BaseResponse.Failure -> BaseResult.Error(this.message, this.errorCode) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/model/request/SignInRequestDto.kt b/app/src/main/java/org/sopt/and/data/model/request/SignInRequestDto.kt new file mode 100644 index 00000000..b711c131 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/request/SignInRequestDto.kt @@ -0,0 +1,9 @@ +package org.sopt.and.data.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class SignInRequestDto( + val username: String, + val password: String +) diff --git a/app/src/main/java/org/sopt/and/data/model/request/SignUpRequestDto.kt b/app/src/main/java/org/sopt/and/data/model/request/SignUpRequestDto.kt new file mode 100644 index 00000000..8b85066f --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/request/SignUpRequestDto.kt @@ -0,0 +1,10 @@ +package org.sopt.and.data.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class SignUpRequestDto( + val username: String, + val password: String, + val hobby: String +) diff --git a/app/src/main/java/org/sopt/and/data/model/response/MyHobbyResponseDto.kt b/app/src/main/java/org/sopt/and/data/model/response/MyHobbyResponseDto.kt new file mode 100644 index 00000000..47663316 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/response/MyHobbyResponseDto.kt @@ -0,0 +1,16 @@ +package org.sopt.and.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MyHobbyResponseDto( + val result: MyHobbyResponseResultDto? = null, + val code: String? = null +) + +@Serializable +data class MyHobbyResponseResultDto( + @SerialName("hobby") + val myHobby: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/model/response/SignInResponseDto.kt b/app/src/main/java/org/sopt/and/data/model/response/SignInResponseDto.kt new file mode 100644 index 00000000..1e404b98 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/response/SignInResponseDto.kt @@ -0,0 +1,14 @@ +package org.sopt.and.data.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class SignInResponseDto( + val result: SignInResponseResultDto? = null, + val code: String? = null +) + +@Serializable +data class SignInResponseResultDto( + val token: String +) diff --git a/app/src/main/java/org/sopt/and/data/model/response/SignUpReponseDto.kt b/app/src/main/java/org/sopt/and/data/model/response/SignUpReponseDto.kt new file mode 100644 index 00000000..9e2b938e --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/response/SignUpReponseDto.kt @@ -0,0 +1,14 @@ +package org.sopt.and.data.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class SignUpResponseDto( + val result: SignUpResponseResultDto? = null, + val code: String? = null +) + +@Serializable +data class SignUpResponseResultDto( + val no: Int +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/repositoryimpl/MyHobbyRepositoryImpl.kt b/app/src/main/java/org/sopt/and/data/repositoryimpl/MyHobbyRepositoryImpl.kt new file mode 100644 index 00000000..02abea06 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/repositoryimpl/MyHobbyRepositoryImpl.kt @@ -0,0 +1,15 @@ +package org.sopt.and.data.repositoryimpl + +import org.sopt.and.data.datasource.MyHobbyDataSource +import org.sopt.and.data.mapper.Mapper +import org.sopt.and.domain.model.MyHobbyEntity +import org.sopt.and.domain.repository.MyHobbyRepository + +class MyHobbyRepositoryImpl( + private val getMyHobbyDataSource: MyHobbyDataSource +) : MyHobbyRepository { + override suspend fun getMyHobby(): Result = + runCatching { + getMyHobbyDataSource.getMyHobby().result?.let { Mapper.toMyHobbyEntity(it) }!! + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/repositoryimpl/SignInRepositoryImpl.kt b/app/src/main/java/org/sopt/and/data/repositoryimpl/SignInRepositoryImpl.kt new file mode 100644 index 00000000..d880c0c5 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/repositoryimpl/SignInRepositoryImpl.kt @@ -0,0 +1,63 @@ +package org.sopt.and.data.repositoryimpl + +import kotlinx.serialization.json.Json +import org.sopt.and.data.api.UserService +import org.sopt.and.data.common.APICallType +import org.sopt.and.data.mapper.ErrorMapper +import org.sopt.and.data.mapper.Mapper.toUserLoginRequestDto +import org.sopt.and.data.model.BaseResponse +import org.sopt.and.data.model.ErrorResponse +import org.sopt.and.data.model.toBaseResult +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.entity.UserLoginResult +import org.sopt.and.domain.repository.SignInRepository +import retrofit2.HttpException +import javax.inject.Inject + +class SignInRepositoryImpl @Inject constructor( + private val userService: UserService +) : SignInRepository { + override suspend fun loginUser(user: UserData): BaseResult { + val apiResult : BaseResponse = try { + val response = userService.loginUser(user.toUserLoginRequestDto()) + if (response.isSuccessful) { + response.body()?.result?.let { + BaseResponse.Success(UserLoginResult(it.token)) + } ?: BaseResponse.Failure(null, null, "응답에 실패했습니다.") + } else { + val errorCode = response.errorBody()?.string()?.let { errorBodyString -> + try { + Json.decodeFromString(errorBodyString).code + } catch (e: Exception) { + null + } + } + val errorMessage = ErrorMapper.getErrorMessage( + APICallType.LOGIN_USER, + response.code(), + errorCode + ) + BaseResponse.Failure(response.code(), errorCode, errorMessage) + } + } catch (e: HttpException) { + val errorCode = e.response()?.errorBody()?.string()?.let { errorBodyString -> + try { + Json.decodeFromString(errorBodyString).code + } catch (e: Exception) { + null + } + } + val errorMessage = ErrorMapper.getErrorMessage( + APICallType.LOGIN_USER, + e.response()?.code(), + errorCode + ) + BaseResponse.Failure(e.code(), errorCode, errorMessage) + } catch (e: Exception) { + BaseResponse.Failure(null, null, "네트워크 연결을 확인해주세요.") + } + + return apiResult.toBaseResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/repositoryimpl/SignUpRepositoryImpl.kt b/app/src/main/java/org/sopt/and/data/repositoryimpl/SignUpRepositoryImpl.kt new file mode 100644 index 00000000..6c01558d --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/repositoryimpl/SignUpRepositoryImpl.kt @@ -0,0 +1,64 @@ +package org.sopt.and.data.repositoryimpl + +import kotlinx.serialization.json.Json +import org.sopt.and.data.api.UserService +import org.sopt.and.data.common.APICallType +import org.sopt.and.data.mapper.ErrorMapper +import org.sopt.and.data.mapper.Mapper.toRegisterRequestDto +import org.sopt.and.data.model.BaseResponse +import org.sopt.and.data.model.ErrorResponse +import org.sopt.and.data.model.toBaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.entity.UserRegisterResult +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.domain.entity.BaseResult +import retrofit2.HttpException +import javax.inject.Inject + + +class SignUpRepositoryImpl @Inject constructor( + private val userService: UserService +) : SignUpRepository { + override suspend fun registerUser(user: UserData): BaseResult { + val apiResult : BaseResponse = try { + val response = userService.registerUser(user.toRegisterRequestDto()) + if (response.isSuccessful) { + response.body()?.result?.let { + BaseResponse.Success(UserRegisterResult(it.no)) + } ?: BaseResponse.Failure(null, null, "응답에 실패했습니다.") + } else { + val errorCode = response.errorBody()?.string()?.let { errorBodyString -> + try { + Json.decodeFromString(errorBodyString).code + } catch (e: Exception) { + null + } + } + val errorMessage = ErrorMapper.getErrorMessage( + APICallType.REGISTER_USER, + response.code(), + errorCode + ) + BaseResponse.Failure(response.code(), errorCode, errorMessage) + } + } catch (e: HttpException) { + val errorCode = e.response()?.errorBody()?.string()?.let { errorBodyString -> + try { + Json.decodeFromString(errorBodyString).code + } catch (e: Exception) { + null + } + } + val errorMessage = ErrorMapper.getErrorMessage( + APICallType.REGISTER_USER, + e.response()?.code(), + errorCode + ) + BaseResponse.Failure(e.code(), errorCode, errorMessage) + } catch (e: Exception) { + BaseResponse.Failure(null, null, "네트워크 연결을 확인해주세요.") + } + + return apiResult.toBaseResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/BaseResult.kt b/app/src/main/java/org/sopt/and/domain/entity/BaseResult.kt new file mode 100644 index 00000000..cca5795d --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/BaseResult.kt @@ -0,0 +1,9 @@ +package org.sopt.and.domain.entity + +sealed class BaseResult { + data class Success(val data: T) : BaseResult() + data class Error( + val message: String, + val errorCode: String? = null + ) : BaseResult() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/HomcCommonContent.kt b/app/src/main/java/org/sopt/and/domain/entity/HomcCommonContent.kt new file mode 100644 index 00000000..dde49486 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/HomcCommonContent.kt @@ -0,0 +1,6 @@ +package org.sopt.and.domain.entity + +data class HomeCommonContent( + val mainTitle: String, + val contentStates: List, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/HomeContent.kt b/app/src/main/java/org/sopt/and/domain/entity/HomeContent.kt new file mode 100644 index 00000000..81d81a19 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/HomeContent.kt @@ -0,0 +1,8 @@ +package org.sopt.and.domain.entity + +data class HomeContent( + val id: Int, + val title: String, + val image: Int, + val description: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/UserData.kt b/app/src/main/java/org/sopt/and/domain/entity/UserData.kt new file mode 100644 index 00000000..077a5634 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/UserData.kt @@ -0,0 +1,7 @@ +package org.sopt.and.domain.entity + +data class UserData( + val username: String, + val password: String, + val hobby: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/UserLoginResult.kt b/app/src/main/java/org/sopt/and/domain/entity/UserLoginResult.kt new file mode 100644 index 00000000..50395fcb --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/UserLoginResult.kt @@ -0,0 +1,5 @@ +package org.sopt.and.domain.entity + +data class UserLoginResult( + val token: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/entity/UserRegisterResult.kt b/app/src/main/java/org/sopt/and/domain/entity/UserRegisterResult.kt new file mode 100644 index 00000000..5ccccc1e --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/entity/UserRegisterResult.kt @@ -0,0 +1,5 @@ +package org.sopt.and.domain.entity + +data class UserRegisterResult( + val no: Int? +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/model/MyHobbyEntity.kt b/app/src/main/java/org/sopt/and/domain/model/MyHobbyEntity.kt new file mode 100644 index 00000000..5d97c017 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/model/MyHobbyEntity.kt @@ -0,0 +1,5 @@ +package org.sopt.and.domain.model + +data class MyHobbyEntity( + val myHobby: String +) diff --git a/app/src/main/java/org/sopt/and/domain/model/SignInEntity.kt b/app/src/main/java/org/sopt/and/domain/model/SignInEntity.kt new file mode 100644 index 00000000..fa33f839 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/model/SignInEntity.kt @@ -0,0 +1,6 @@ +package org.sopt.and.domain.model + +data class SignInInformationEntity( + val username: String, + val password: String +) diff --git a/app/src/main/java/org/sopt/and/domain/model/SignInResponseEntity.kt b/app/src/main/java/org/sopt/and/domain/model/SignInResponseEntity.kt new file mode 100644 index 00000000..d73a3290 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/model/SignInResponseEntity.kt @@ -0,0 +1,7 @@ +package org.sopt.and.domain.model + +data class SignInResponseEntity( + val token: String? = null, + val status: Int? = null, + val code: String? = null +) diff --git a/app/src/main/java/org/sopt/and/domain/model/SignUpEntity.kt b/app/src/main/java/org/sopt/and/domain/model/SignUpEntity.kt new file mode 100644 index 00000000..dcfcaab3 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/model/SignUpEntity.kt @@ -0,0 +1,7 @@ +package org.sopt.and.domain.model + +data class SignUpInformationEntity( + val username: String, + val password: String, + val hobby: String +) diff --git a/app/src/main/java/org/sopt/and/domain/model/SignUpReseponseEntity.kt b/app/src/main/java/org/sopt/and/domain/model/SignUpReseponseEntity.kt new file mode 100644 index 00000000..16dde9de --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/model/SignUpReseponseEntity.kt @@ -0,0 +1,7 @@ +package org.sopt.and.domain.model + +data class SignUpResponseEntity( + val no: Int? = null, + val code: String? = null, + val status: Int? = null +) diff --git a/app/src/main/java/org/sopt/and/domain/repository/DummyHomeRepository.kt b/app/src/main/java/org/sopt/and/domain/repository/DummyHomeRepository.kt new file mode 100644 index 00000000..30da163e --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/repository/DummyHomeRepository.kt @@ -0,0 +1,10 @@ +package org.sopt.and.domain.repository + +import org.sopt.and.domain.entity.HomeCommonContent +import org.sopt.and.domain.entity.HomeContent + +interface DummyHomeRepository { + fun getDummyMainContents(): List + fun getDummyCommonContents(): List + fun getDummyRankingContents(): HomeCommonContent +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/repository/MyHobbyRepository.kt b/app/src/main/java/org/sopt/and/domain/repository/MyHobbyRepository.kt new file mode 100644 index 00000000..bcadaf4b --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/repository/MyHobbyRepository.kt @@ -0,0 +1,20 @@ +package org.sopt.and.domain.repository + +import org.sopt.and.data.datasource.MyHobbyDataSource +import org.sopt.and.data.repositoryimpl.MyHobbyRepositoryImpl +import org.sopt.and.data.service.ServicePool +import org.sopt.and.domain.model.MyHobbyEntity + +interface MyHobbyRepository { + suspend fun getMyHobby(): Result + + companion object { + fun create(): MyHobbyRepositoryImpl { + return MyHobbyRepositoryImpl( + MyHobbyDataSource( + ServicePool.userService + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt b/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt new file mode 100644 index 00000000..1a6182bf --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/repository/SignInRepository.kt @@ -0,0 +1,10 @@ +package org.sopt.and.domain.repository + + +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserLoginResult + + +interface SignInRepository { + suspend fun loginUser(user: org.sopt.and.domain.entity.UserData): BaseResult +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/repository/SignUpRepository.kt b/app/src/main/java/org/sopt/and/domain/repository/SignUpRepository.kt new file mode 100644 index 00000000..d3ffed5a --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/repository/SignUpRepository.kt @@ -0,0 +1,10 @@ +package org.sopt.and.domain.repository + +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.entity.UserRegisterResult + + +interface SignUpRepository { + suspend fun registerUser(user : UserData): BaseResult +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/usecase/MyHobbyUseCase.kt b/app/src/main/java/org/sopt/and/domain/usecase/MyHobbyUseCase.kt new file mode 100644 index 00000000..cbcdb6f7 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/usecase/MyHobbyUseCase.kt @@ -0,0 +1,11 @@ +package org.sopt.and.domain.usecase + +import org.sopt.and.domain.model.MyHobbyEntity +import org.sopt.and.domain.repository.MyHobbyRepository + +class MyHobbyUseCase( + private val getMyHobbyRepository: MyHobbyRepository +) { + suspend operator fun invoke(): Result = + getMyHobbyRepository.getMyHobby() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt b/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt new file mode 100644 index 00000000..ce8bc1f5 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/usecase/SignInUseCase.kt @@ -0,0 +1,15 @@ +package org.sopt.and.domain.usecase + +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.entity.UserLoginResult +import org.sopt.and.domain.repository.SignInRepository +import javax.inject.Inject + +class SignInUseCase @Inject constructor( + private val userLoginRepository: SignInRepository +) { + suspend operator fun invoke(user: UserData): BaseResult { + return userLoginRepository.loginUser(user) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/usecase/SignUpUseCase.kt b/app/src/main/java/org/sopt/and/domain/usecase/SignUpUseCase.kt new file mode 100644 index 00000000..949babc9 --- /dev/null +++ b/app/src/main/java/org/sopt/and/domain/usecase/SignUpUseCase.kt @@ -0,0 +1,15 @@ +package org.sopt.and.domain.usecase + +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.entity.UserRegisterResult +import org.sopt.and.domain.repository.SignUpRepository +import javax.inject.Inject + +class SignUpUseCase @Inject constructor( + private val userRegisterRepository: SignUpRepository +) { + suspend operator fun invoke(user: UserData): BaseResult { + return userRegisterRepository.registerUser(user) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/dto/Auth.kt b/app/src/main/java/org/sopt/and/dto/Auth.kt deleted file mode 100644 index f26a1496..00000000 --- a/app/src/main/java/org/sopt/and/dto/Auth.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.sopt.and.dto - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/* 유저 등록 */ -@Serializable -data class RequestUserRegistrationData( - @SerialName("username") - val userName: String, - @SerialName("password") - val password: String, - @SerialName("hobby") - val hobby: String -) - -@Serializable -data class ResponseUserRegistration( - @SerialName("result") - val result: ResultUserNo -) - -@Serializable -data class ResultUserNo( - @SerialName("no") - val no: Int -) - -/* 로그인 */ -@Serializable -data class RequestLoginData( - @SerialName("username") - val userName: String, - @SerialName("password") - val password: String -) - -@Serializable -data class ResponseLogin( - @SerialName("result") - val result: ResultToken -) - -@Serializable -data class ResultToken( - @SerialName("token") - val token: String -) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/dto/My.kt b/app/src/main/java/org/sopt/and/dto/My.kt deleted file mode 100644 index 99e47962..00000000 --- a/app/src/main/java/org/sopt/and/dto/My.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.sopt.and.dto - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/* 내 취미 조회 */ -@Serializable -data class ResponseMyHobbyData( - @SerialName("result") - val result: ResponseMyHobbyDataResult -) - -@Serializable -data class ResponseMyHobbyDataResult( - @SerialName("hobby") - val hobby: String -) diff --git a/app/src/main/java/org/sopt/and/home/HomeScreen.kt b/app/src/main/java/org/sopt/and/home/HomeScreen.kt deleted file mode 100644 index f0a68ae7..00000000 --- a/app/src/main/java/org/sopt/and/home/HomeScreen.kt +++ /dev/null @@ -1,188 +0,0 @@ -package org.sopt.and.home - -import androidx.annotation.StringRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.delay -import org.sopt.and.R - -val Posters = List(9) { R.drawable.bee } -val categories = listOf("뉴클래식", "드라마", "예능", "영화", "애니", "해외시리즈") -val textColor = Color.White -val categoryColor = Color.Gray -val posterSize = Modifier.size(120.dp, 180.dp) -val roundedCorner = RoundedCornerShape(3.dp) - -@Composable -fun HomeScreen() { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(Color.Black), - verticalArrangement = Arrangement.spacedBy(15.dp) - ) { - item { - CategoryRow(categories = categories) - } - - item { - BannerView() - } - item { - Section(title = R.string.recommendation_label) { - PosterRow( - moviePosters = Posters, - itemComposable = { poster, _ -> RecommendPosterItem(poster) }) - } - } - item { - Section(title = R.string.ranking_label) { - PosterRow(moviePosters = Posters, itemComposable = { poster, rank -> - rank?.let { - TopPosterItem( - poster, - it - ) - } - }) - } - } - } -} - - -@Composable -fun CategoryRow(categories: List) { - LazyRow( - modifier = Modifier - .padding(top = 10.dp) - .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 15.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - items(categories) { category -> - Text( - text = category, - color = Color.LightGray, - fontSize = 18.sp - ) - } - } -} - -@Composable -fun Section(@StringRes title: Int, content: @Composable () -> Unit) { - Column(modifier = Modifier.padding(start = 15.dp)) { - Text( - text = stringResource(id = title), - color = textColor, - fontSize = 18.sp, - fontWeight = FontWeight.W600 - ) - content() - } -} - -@Composable -fun BannerView() { - val pagerState = rememberPagerState(pageCount = { 5 }) - - LaunchedEffect(pagerState) { - while (true) { - delay(2500) - val nextPage = (pagerState.currentPage + 1) % 5 - pagerState.animateScrollToPage(nextPage) - } - } - - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxWidth() - .height(400.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - pageSpacing = 8.dp - ) { _ -> - Image( - painter = painterResource(id = R.drawable.bee), - contentDescription = "Banner Image", - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(0.75f) - .background(Color.DarkGray) - ) - } -} - -@Composable -fun PosterRow(moviePosters: List, itemComposable: @Composable (Int, Int?) -> Unit) { - LazyRow( - contentPadding = PaddingValues(15.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - itemsIndexed(moviePosters) { index, poster -> - itemComposable(poster, index + 1) - } - } -} - - -@Composable -fun RecommendPosterItem(posterItem: Int) { - Image( - painter = painterResource(id = posterItem), - contentDescription = "영화 포스터", - modifier = posterSize.clip(roundedCorner), - contentScale = ContentScale.Crop - ) -} - -@Composable -fun TopPosterItem(posterItem: Int, rank: Int) { - Box( - modifier = Modifier.size(170.dp, 255.dp) - ) { - Image( - painter = painterResource(id = posterItem), - contentDescription = "영화 포스터", - modifier = Modifier - .size(150.dp, 225.dp) - .clip(roundedCorner), - contentScale = ContentScale.Crop - ) - Text( - text = "$rank", - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 10.dp), - color = textColor, - fontSize = 40.sp, - fontWeight = FontWeight.W800, - fontStyle = FontStyle.Italic - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/myinfo/MyScreen.kt b/app/src/main/java/org/sopt/and/myinfo/MyScreen.kt deleted file mode 100644 index e614936d..00000000 --- a/app/src/main/java/org/sopt/and/myinfo/MyScreen.kt +++ /dev/null @@ -1,180 +0,0 @@ -package org.sopt.and.myinfo - -import android.annotation.SuppressLint -import android.util.Log -import androidx.annotation.ColorRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Search -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.rememberCoroutineScope -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.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import org.sopt.and.viewmodel.SignViewModel - -enum class BottomNavItem(val icon: ImageVector, val description: String) { - Home(Icons.Default.Home, "홈"), - SEARCH(Icons.Default.Search, "검색"), - PROFILE(Icons.Default.AccountCircle, "내 정보") -} - -@SuppressLint("UnrememberedMutableState") -@Composable -fun MyScreen(modifier: Modifier = Modifier, signViewModel: SignViewModel) { - var hobby by mutableStateOf("") // 초기 값 확인 - val coroutineScope = rememberCoroutineScope() - - // 취미 데이터를 로드 - LaunchedEffect(Unit) { - coroutineScope.launch { - signViewModel.fetchHobby( - onSuccess = { /* 성공 시 처리할 로직 필요 없음 - 이미 상태가 업데이트됨 */ }, - onFailure = { errorMessage -> - Log.e("MyScreen", "취미 로드 실패: $errorMessage") - } - ) - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - MyHeader(hobby = hobby) // 최신 hobby 값을 전달 - Spacer(modifier = Modifier.height(20.dp)) - PurchseZone(title = "첫 결제 시 첫 달 100원!") - Spacer(modifier = Modifier.height(15.dp)) - PurchseZone(title = "현재 보유하신 이용권이 없습니다.") - Spacer(modifier = Modifier.height(20.dp)) - InfoZone(title = "전체 시청내역", message = "시청 내역이 없어요.") - Spacer(modifier = Modifier.height(30.dp)) - InfoZone(title = "관심 프로그램", message = "관심 프로그램이 없어요.") - Spacer(modifier = Modifier.weight(1f)) - BottomNavigation() - } -} - -@Composable -fun MyHeader(hobby: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color.DarkGray) - .padding(15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Icon( - imageVector = Icons.Default.AccountCircle, - contentDescription = "프로필 이미지", - modifier = Modifier.size(60.dp), - tint = Color.White - ) - Text( - text = hobby.ifEmpty { "sport" }, // 초기 값 및 업데이트된 값 반영 - fontSize = 15.sp, - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 10.dp) - ) - Row { - Text( - text = "🔔", - fontSize = 20.sp, - color = Color.White, - modifier = Modifier.padding(end = 20.dp) - ) - Text( - text = "⚙️", - fontSize = 20.sp, - color = Color.White - ) - } - } -} - -@Composable -fun PurchseZone(title: String) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(Color.DarkGray) - .padding(vertical = 15.dp, horizontal = 15.dp) - ) { - Text( - text = title, - fontSize = 18.sp, - color = Color.LightGray - ) - Text( - text = "구매하기 >", - fontSize = 18.sp, - color = Color.White - ) - } -} - -@Composable -fun InfoZone(title: String, message: String) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(Color.Black) - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = title, - fontSize = 23.sp, - fontWeight = FontWeight.W800, - color = Color.White - ) - Spacer(modifier = Modifier.height(50.dp)) - Text( - text = "⚠️", - fontSize = 50.sp, - color = Color.Gray - ) - Text( - text = message, - fontSize = 15.sp, - color = Color.Gray, - modifier = Modifier.padding(top = 15.dp) - ) - } -} - -@Composable -fun BottomNavigation() { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceAround - ) { - BottomNavItem.entries.forEach { item -> - Icon( - imageVector = item.icon, - contentDescription = item.description, - tint = Color.White - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/navigation/BottomNavigation.kt b/app/src/main/java/org/sopt/and/navigation/BottomNavigation.kt deleted file mode 100644 index 3b1bd21f..00000000 --- a/app/src/main/java/org/sopt/and/navigation/BottomNavigation.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.sopt.and.navigation - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Search -import androidx.compose.ui.graphics.vector.ImageVector -import org.sopt.and.R - -enum class BottomNavigation( - val route: String, - val title: Int, - val icon: ImageVector -) { - Home( - route = "Home", - title = R.string.home_label, - icon = Icons.Default.Home - ), - Search( - route = "Search", - title = R.string.search_label, - icon = Icons.Default.Search - ), - MY( - route = "MY", - title = R.string.my_label, - icon = Icons.Default.AccountCircle - ); -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/navigation/BottomNavigationGraph.kt b/app/src/main/java/org/sopt/and/navigation/BottomNavigationGraph.kt deleted file mode 100644 index 32a73af0..00000000 --- a/app/src/main/java/org/sopt/and/navigation/BottomNavigationGraph.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.sopt.and.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import org.sopt.and.home.HomeScreen -import org.sopt.and.myinfo.MyScreen -import org.sopt.and.search.SearchScreen -import org.sopt.and.viewmodel.SignViewModel - - -@Composable -fun BottomNavigationGraph(navController: NavHostController, signViewModel: SignViewModel) { - NavHost(navController = navController, startDestination = BottomNavigation.Home.route) { - composable(route = BottomNavigation.Home.route) { - HomeScreen() - } - composable(route = BottomNavigation.Search.route) { - SearchScreen() - } - composable(route = BottomNavigation.MY.route) { - MyScreen(signViewModel = signViewModel) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/components/CautionBox.kt b/app/src/main/java/org/sopt/and/presentation/components/CautionBox.kt new file mode 100644 index 00000000..0d954aca --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/components/CautionBox.kt @@ -0,0 +1,38 @@ +package org.sopt.and.presentation.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.ui.theme.Grey200 + +@Composable +fun CautionBox( + caution: Int, + contentDescription: Int +) { + Row { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(id = contentDescription), + tint = Grey200 + ) + + Spacer(modifier = Modifier.width(5.dp)) + + Text( + text = stringResource(id = caution), + color = Grey200, + style = TextStyle(fontSize = 11.sp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/components/EmptyInfoBox.kt b/app/src/main/java/org/sopt/and/presentation/components/EmptyInfoBox.kt new file mode 100644 index 00000000..95f93fd3 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/components/EmptyInfoBox.kt @@ -0,0 +1,64 @@ +package org.sopt.and.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.ui.theme.White100 + +@Composable +fun EmptyInfoBox( + title: String, + description: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = title, + color = White100, + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight(1000) + ) + ) + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Sharp.Warning, + contentDescription = description, + modifier = Modifier.size(40.dp), + tint = White100 + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = description, + color = White100 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/components/ShowOrToggle.kt b/app/src/main/java/org/sopt/and/presentation/components/ShowOrToggle.kt new file mode 100644 index 00000000..91af6133 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/components/ShowOrToggle.kt @@ -0,0 +1,25 @@ +package org.sopt.and.presentation.components + +import androidx.compose.foundation.clickable +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.res.stringResource +import androidx.compose.ui.unit.dp +import org.sopt.and.R +import org.sopt.and.ui.theme.White100 + +@Composable +fun ShowOrHideToggle( + isVisible: Boolean, + onVisibilityChange: () -> Unit +) { + Text( + text = stringResource(id = if (isVisible) R.string.hide_password_button else R.string.show_password_button), + color = White100, + modifier = Modifier + .padding(end = 12.dp) + .clickable(onClick = onVisibilityChange) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/components/SignButton.kt b/app/src/main/java/org/sopt/and/presentation/components/SignButton.kt similarity index 96% rename from app/src/main/java/org/sopt/and/components/SignButton.kt rename to app/src/main/java/org/sopt/and/presentation/components/SignButton.kt index 3d93b97f..91e9d4a7 100644 --- a/app/src/main/java/org/sopt/and/components/SignButton.kt +++ b/app/src/main/java/org/sopt/and/presentation/components/SignButton.kt @@ -1,4 +1,4 @@ -package org.sopt.and.components +package org.sopt.and.presentation.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding diff --git a/app/src/main/java/org/sopt/and/presentation/components/SignInOrSignUpTextField.kt b/app/src/main/java/org/sopt/and/presentation/components/SignInOrSignUpTextField.kt new file mode 100644 index 00000000..6cff831e --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/components/SignInOrSignUpTextField.kt @@ -0,0 +1,45 @@ +package org.sopt.and.presentation.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import org.sopt.and.ui.theme.Grey100 +import org.sopt.and.ui.theme.Grey200 + +@Composable +fun SignInOrSignUpTextField( + information: String, + onValueChange: (String) -> Unit, + placeholder: Int, + visualTransformation: VisualTransformation = VisualTransformation.None, + trailingIcon: @Composable (() -> Unit)? = null +) { + TextField( + value = information, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Grey100, + focusedContainerColor = Grey100, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + shape = RoundedCornerShape(10.dp), + placeholder = { + Text( + text = stringResource(id = placeholder), + color = Grey200 + ) + }, + visualTransformation = visualTransformation, + trailingIcon = trailingIcon + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/components/SignTextField.kt b/app/src/main/java/org/sopt/and/presentation/components/SignTextField.kt similarity index 98% rename from app/src/main/java/org/sopt/and/components/SignTextField.kt rename to app/src/main/java/org/sopt/and/presentation/components/SignTextField.kt index c81538f6..fbd3a7a6 100644 --- a/app/src/main/java/org/sopt/and/components/SignTextField.kt +++ b/app/src/main/java/org/sopt/and/presentation/components/SignTextField.kt @@ -1,4 +1,4 @@ -package org.sopt.and.components +package org.sopt.and.presentation.components import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text diff --git a/app/src/main/java/org/sopt/and/components/SignTopBar.kt b/app/src/main/java/org/sopt/and/presentation/components/SignTopBar.kt similarity index 97% rename from app/src/main/java/org/sopt/and/components/SignTopBar.kt rename to app/src/main/java/org/sopt/and/presentation/components/SignTopBar.kt index 605cb2e8..0c225e50 100644 --- a/app/src/main/java/org/sopt/and/components/SignTopBar.kt +++ b/app/src/main/java/org/sopt/and/presentation/components/SignTopBar.kt @@ -1,4 +1,4 @@ -package org.sopt.and.components +package org.sopt.and.presentation.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/org/sopt/and/presentation/components/SnsBox.kt b/app/src/main/java/org/sopt/and/presentation/components/SnsBox.kt new file mode 100644 index 00000000..f20f3525 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/components/SnsBox.kt @@ -0,0 +1,92 @@ +package org.sopt.and.presentation.components + +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.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.presentation.util.Utils +import org.sopt.and.ui.theme.ANDANDROIDTheme +import org.sopt.and.ui.theme.Grey200 + +@Composable +fun SnSBox( + title: String +) { + Column { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = Grey200 + ) + + Spacer(modifier = Modifier.size(4.dp)) + + Text( + text = title, + style = TextStyle(fontSize = 12.sp), + color = Grey200 + ) + + Spacer(modifier = Modifier.size(3.dp)) + + HorizontalDivider( + modifier = Modifier.weight(1f), + color = Grey200 + ) + } + + Spacer(modifier = Modifier.size(24.dp)) + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Utils.linkableSNS.forEach { item -> + Icon( + painter = painterResource(item.first), + contentDescription = stringResource(item.second), + tint = Color.Unspecified, + modifier = Modifier + .clip(CircleShape) + .size(42.dp) + ) + } + } + + Spacer(modifier = Modifier.size(16.dp)) + + Text( + text = stringResource(R.string.link_with_another_service_description), + color = Grey200, + style = TextStyle( + fontSize = 10.sp + ) + ) + } +} + diff --git a/app/src/main/java/org/sopt/and/presentation/home/HomeContract.kt b/app/src/main/java/org/sopt/and/presentation/home/HomeContract.kt new file mode 100644 index 00000000..0200cc36 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/HomeContract.kt @@ -0,0 +1,28 @@ +package org.sopt.and.presentation.home + +import org.sopt.and.core.ContentType +import org.sopt.and.domain.entity.HomeCommonContent +import org.sopt.and.domain.entity.HomeContent +import org.sopt.and.presentation.util.UiEffect +import org.sopt.and.presentation.util.UiEvent +import org.sopt.and.presentation.util.UiState + +class HomeContract { + data class HomeUiState( + val mainContents: List = emptyList(), + + val commonContents: List = emptyList(), + + val rankingContents: HomeCommonContent = HomeCommonContent( + mainTitle = "", + contentStates = emptyList() + ), + val selectedContentType: ContentType? = null + ) : UiState + + sealed class HomeUiEvent : UiEvent { + data class SetContentType(val contentType: ContentType) : HomeUiEvent() + } + + sealed class HomeUiEffect : UiEffect +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt b/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt new file mode 100644 index 00000000..ef7de055 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt @@ -0,0 +1,87 @@ +package org.sopt.and.presentation.home + +import org.sopt.and.core.ContentType +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +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.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.sopt.and.R +import org.sopt.and.presentation.home.components.HomeBannerPager +import org.sopt.and.presentation.home.components.RecommendList +import org.sopt.and.presentation.home.components.Top20List + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HomeScreen( + onContentTypeSelected: (ContentType) -> Unit, + modifier: Modifier = Modifier, + viewModel: HomeViewModel = hiltViewModel() +) { + val homeState by viewModel.uiState.collectAsStateWithLifecycle() + val mainPagerState = rememberPagerState(initialPage = Int.MAX_VALUE / 2) { + Int.MAX_VALUE // 페이지 수가 무한대 + } + LaunchedEffect(Unit) { + viewModel.getDummyHomeContent() + } + LazyColumn( + modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + item { + HomeBannerPager(homeState.mainContents) + } + + item { + Spacer(modifier = Modifier.height(20.dp)) + } + + item { + RecommendList( + title = stringResource(R.string.home_picks_of_editor_title), + items = homeState.commonContents + ) + } + + item { + Spacer(modifier = Modifier.height(20.dp)) + } + + item { + Top20List(homeState.rankingContents) + } + } +} + + +@Composable +fun AutoScrollEffect(pagerState: PagerState) { + LaunchedEffect(pagerState.currentPage) { + while (true) { + delay(3000) + withContext(NonCancellable) { + pagerState.animateScrollToPage( + page = pagerState.currentPage + 1, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/home/HomeUiState.kt b/app/src/main/java/org/sopt/and/presentation/home/HomeUiState.kt new file mode 100644 index 00000000..7a3b3a99 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/HomeUiState.kt @@ -0,0 +1,44 @@ +package org.sopt.and.presentation.home + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.sopt.and.R + +data class HomeUiState( + @StringRes + val genres: List = listOf( + R.string.genre_new_classic, + R.string.genre_drama, + R.string.genre_entertainment, + R.string.genre_movie, + R.string.genre_animation, + R.string.genre_foreign_country_series + ), + @DrawableRes + val banners: List = listOf( + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + ), + @DrawableRes + val recommends: List = listOf( + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + ), + @DrawableRes + val rankers: List = listOf( + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + R.drawable.bee, + ) +) diff --git a/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt b/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt new file mode 100644 index 00000000..05f322a7 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt @@ -0,0 +1,35 @@ +package org.sopt.and.presentation.home + +import dagger.hilt.android.lifecycle.HiltViewModel +import org.sopt.and.domain.repository.DummyHomeRepository +import org.sopt.and.presentation.home.HomeContract.HomeUiEffect +import org.sopt.and.presentation.home.HomeContract.HomeUiEvent +import org.sopt.and.presentation.home.HomeContract.HomeUiState +import org.sopt.and.presentation.util.BaseViewModel +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val dummyHomeContentRepository: DummyHomeRepository +) : BaseViewModel(HomeUiState()) { + + override fun reduceState(event: HomeUiEvent) { + when (event) { + is HomeUiEvent.SetContentType -> { + updateState( + currentState.copy( + selectedContentType = event.contentType + ) + ) + } + } + } + + fun getDummyHomeContent() = updateState( + currentState.copy( + mainContents = dummyHomeContentRepository.getDummyMainContents(), + commonContents = dummyHomeContentRepository.getDummyCommonContents(), + rankingContents = dummyHomeContentRepository.getDummyRankingContents() + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/home/components/HomeBannerPager.kt b/app/src/main/java/org/sopt/and/presentation/home/components/HomeBannerPager.kt new file mode 100644 index 00000000..35a4700e --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/components/HomeBannerPager.kt @@ -0,0 +1,133 @@ +package org.sopt.and.presentation.home.components + +import androidx.annotation.DrawableRes +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.domain.entity.HomeContent +import org.sopt.and.ui.theme.Grey200 +import org.sopt.and.ui.theme.White100 + +@Composable +fun HomeBannerPager(@DrawableRes banners: List) { + val pagerState = rememberPagerState(pageCount = { banners.size }) + + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 10.dp), + pageSpacing = 10.dp + ) { page -> + HomeBannerPage( + index = page, + banners = banners + ) + } +} + +@Composable +fun HomeBannerPage( + index: Int, + @DrawableRes banners: List +) { + Box( + Modifier + .fillMaxSize() + .clip(shape = RoundedCornerShape(16.dp)) + .border(1.dp, Grey200, shape = RoundedCornerShape(16.dp)) + ) { + Image( + painter = painterResource(banners[index]), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + + HomeBannerIndicator( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp), + index = index, + totalPage = banners.size + ) + } +} + +@Composable +fun HomeBannerIndicator( + modifier: Modifier, + index: Int, + totalPage: Int +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(Color.Black) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + buildAnnotatedString { + withStyle( + style = SpanStyle( + color = White100, + fontSize = 11.sp + ) + ) { + append( + stringResource( + R.string.home_banner_indicator_front, index + 1 + ) + ) + } + withStyle( + style = SpanStyle( + color = Grey200, + fontSize = 11.sp + ) + ) { + append( + stringResource( + R.string.home_banner_indicator_back, + totalPage + ) + ) + } + } + ) + } +} + +@Preview +@Composable +fun HomeBannerPagerPreview() { + Column( + modifier = Modifier + .fillMaxSize() + ) { + HomeBannerPager(listOf()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/home/components/HomeBottomCoupon.kt b/app/src/main/java/org/sopt/and/presentation/home/components/HomeBottomCoupon.kt new file mode 100644 index 00000000..1ea9aa4e --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/components/HomeBottomCoupon.kt @@ -0,0 +1,36 @@ +package org.sopt.and.presentation.home.components + +import androidx.compose.foundation.background +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.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.sopt.and.R + +@Composable +fun HomeBottomCoupon(modifier: Modifier = Modifier) { + Text( + text = stringResource(R.string.my_first_payment_text), + textAlign = TextAlign.Center, + color = Color.White, + modifier = modifier + .fillMaxWidth() + .background( + brush = Brush.linearGradient( + colors = listOf( + Color(0xFF0281ED), + Color(0xFF02B9B5) + ) + ), + shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp) + ) + .padding(vertical = 15.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/home/components/HomeTopBar.kt b/app/src/main/java/org/sopt/and/presentation/home/components/HomeTopBar.kt new file mode 100644 index 00000000..f5f5f60a --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/components/HomeTopBar.kt @@ -0,0 +1,93 @@ +package org.sopt.and.presentation.home.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.ui.theme.Grey200 +import org.sopt.and.ui.theme.White100 + +@Composable +fun HomeTopBar( + @StringRes genres: List +) { + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.wavve_logo), + contentDescription = "", + modifier = Modifier + .height(60.dp) + .width(100.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + painter = painterResource(R.drawable.ic_cast_24), + contentDescription = "", + modifier = Modifier.size(30.dp), + tint = White100 + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Icon( + painter = painterResource(R.drawable.ic_live_tv_24), + contentDescription = "", + modifier = Modifier.size(30.dp), + tint = White100 + ) + } + + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + items( + items = genres, + key = { it } + ) { genre -> + Text( + text = stringResource(genre), + color = Grey200, + fontSize = 14.sp + ) + } + } + } +} + +@Preview +@Composable +fun HomeTopBarPreview() { + HomeTopBar(genres = listOf(R.string.home_top20_title)) +} + diff --git a/app/src/main/java/org/sopt/and/presentation/home/components/RecommendList.kt b/app/src/main/java/org/sopt/and/presentation/home/components/RecommendList.kt new file mode 100644 index 00000000..68b44e32 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/components/RecommendList.kt @@ -0,0 +1,76 @@ +package org.sopt.and.presentation.home.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.sopt.and.domain.entity.HomeCommonContent +import org.sopt.and.ui.theme.Grey200 +import org.sopt.and.ui.theme.White100 + +@Composable +fun RecommendList( + title: String, + items: List +) { + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + color = White100, + fontWeight = FontWeight.W900 + ) + + Icon( + imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, + contentDescription = "", + tint = Grey200 + ) + } + + Spacer(Modifier.height(10.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + items( + items = items, + key = { it } + ) { item -> + Image( + painter = painterResource(item), + contentDescription = "", + modifier = Modifier + .width(110.dp) + .clip(shape = RoundedCornerShape(15.dp)) + ) + } + } + } +} + diff --git a/app/src/main/java/org/sopt/and/presentation/home/components/Top20List.kt b/app/src/main/java/org/sopt/and/presentation/home/components/Top20List.kt new file mode 100644 index 00000000..63903473 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/components/Top20List.kt @@ -0,0 +1,89 @@ +package org.sopt.and.presentation.home.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight.Companion.W900 +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.domain.entity.HomeCommonContent +import org.sopt.and.ui.theme.White100 + +@Composable +fun Top20List(@DrawableRes rankers: HomeCommonContent) { + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.home_top20_title), + color = White100, + fontWeight = W900 + ) + + Spacer(Modifier.height(10.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + items( + count = rankers.size, + key = { it } + ) { index -> + RankedItem(index, rankers) + } + } + } +} + +@Composable +fun RankedItem( + index: Int, + @DrawableRes rankers: List +) { + Box( + Modifier.height(240.dp) + ) { + Image( + painter = painterResource(rankers[index]), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .height(220.dp) + .clip(shape = RoundedCornerShape(15.dp)) + .align(Alignment.TopStart) + ) + + Text( + text = stringResource(R.string.home_rank_of_item, index + 1), + fontSize = 50.sp, + fontWeight = W900, + color = White100, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp) + ) + } +} + +@Preview +@Composable +fun Top20ListPreview() { + Top20List(listOf()) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainActivity.kt b/app/src/main/java/org/sopt/and/presentation/main/MainActivity.kt new file mode 100644 index 00000000..b7c918ce --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainActivity.kt @@ -0,0 +1,18 @@ +package org.sopt.and.presentation.main + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MainScreen() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainBottomNavigationBar.kt b/app/src/main/java/org/sopt/and/presentation/main/MainBottomNavigationBar.kt new file mode 100644 index 00000000..a41e8485 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainBottomNavigationBar.kt @@ -0,0 +1,83 @@ +package org.sopt.and.presentation.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.size +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import org.sopt.and.R +import org.sopt.and.core.navigation.Screen +import org.sopt.and.ui.theme.WavveBg +import org.sopt.and.ui.theme.WavveDisabled + + +@Composable +fun MainBottomNavigationBar( + navController: NavController, + currentRoute: String?, + colors: NavigationBarItemColors +) { + BottomAppBar( + containerColor = WavveBg, + contentColor = WavveDisabled + ) { + MainBottomTabs.items.forEach { tab -> + BottomNavigationItem( + navController = navController, + tab = tab, + currentRoute = currentRoute, + colors = colors + ) + } + } +} + +@Composable +fun RowScope.BottomNavigationItem( + navController: NavController, + tab: MainBottomTab, + currentRoute: String?, + colors: NavigationBarItemColors +) { + NavigationBarItem( + icon = { + if (tab.isProfileImage) { + Image( + painter = painterResource(tab.iconResId), + contentDescription = stringResource(R.string.my_page_image_description_profile), + modifier = Modifier.size(32.dp) + ) + } else { + Icon( + ImageVector.vectorResource(tab.iconResId), + contentDescription = stringResource(tab.labelResId), + modifier = Modifier.size(32.dp) + ) + } + }, + label = { Text(stringResource(tab.labelResId)) }, + selected = currentRoute == tab.screen.javaClass.canonicalName, + onClick = { navigateToScreen(navController, tab.screen) }, + colors = colors + ) +} + +private fun navigateToScreen(navController: NavController, screen: Screen) { + screen.javaClass.canonicalName?.let { + navController.navigate(it) { + screen.javaClass.canonicalName?.let { it1 -> popUpTo(it1) { inclusive = false } } + launchSingleTop = true + } + } +} diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainBottomTab.kt b/app/src/main/java/org/sopt/and/presentation/main/MainBottomTab.kt new file mode 100644 index 00000000..be41d87f --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainBottomTab.kt @@ -0,0 +1,34 @@ +package org.sopt.and.presentation.main + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.sopt.and.R +import org.sopt.and.core.navigation.Screen + +data class MainBottomTab( + val screen: Screen, + @DrawableRes val iconResId: Int, + @StringRes val labelResId: Int, + val isProfileImage: Boolean = false +) + +object MainBottomTabs { + val Home = MainBottomTab( + screen = Screen.Home, + iconResId = R.drawable.ic_home, + labelResId = R.string.title_home + ) + val Search = MainBottomTab( + screen = Screen.Search, + iconResId = R.drawable.ic_search, + labelResId = R.string.title_search + ) + val MyPage = MainBottomTab( + screen = Screen.My, + iconResId = R.drawable.profile_default, + labelResId = R.string.title_my, + isProfileImage = true + ) + + val items = listOf(Home, Search, MyPage) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainContract.kt b/app/src/main/java/org/sopt/and/presentation/main/MainContract.kt new file mode 100644 index 00000000..0ca4f193 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainContract.kt @@ -0,0 +1,18 @@ +package org.sopt.and.presentation.main + +import org.sopt.and.presentation.util.UiEffect +import org.sopt.and.presentation.util.UiEvent +import org.sopt.and.presentation.util.UiState + +class MainContract { + data class MainUiState( + val userToken: String? = null, + val isLoading: Boolean = false + ) : UiState + + sealed class MainUiEvent : UiEvent { + data object LoadUserToken : MainUiEvent() + } + + sealed class MainUiEffect : UiEffect +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainNavHost.kt b/app/src/main/java/org/sopt/and/presentation/main/MainNavHost.kt new file mode 100644 index 00000000..69392a2c --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainNavHost.kt @@ -0,0 +1,78 @@ +package org.sopt.and.presentation.main + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import org.sopt.and.core.navigation.Screen +import org.sopt.and.presentation.home.HomeScreen +import org.sopt.and.presentation.myinfo.MyInfo +import org.sopt.and.presentation.search.SearchScreen +import org.sopt.and.presentation.signin.SignInScreen +import org.sopt.and.presentation.signup.SignUpScreen +import org.sopt.and.ui.theme.WavveBg + +@Composable +fun MainNavHost( + navController: NavHostController, + startDestination: Screen +) { + NavHost( + navController = navController, + startDestination = startDestination, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None } + ) { + composable { + SignInScreen( + navigateToMy = { + navController.navigate(Screen.My) + }, + navigateToSignUp = { + navController.navigate(Screen.SignUp) + }, + navigateUp = { + navController.navigateUp() + } + ) + } + composable { + SignUpScreen( + navigateToSignIn = { + navController.navigate(Screen.SignIn) { + popUpTo { inclusive = true } + launchSingleTop = true + } + }, + navigateUp = { + navController.navigateUp() + } + ) + } + composable { + MyInfo( + navigateToSignIn = { + navController.navigate(Screen.SignIn) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + ) + } + composable { + HomeScreen( + onContentTypeSelected = { /* TODO: Screen 변경 가능 */ }, + modifier = Modifier.background(WavveBg) + ) + } + composable { + SearchScreen(modifier = Modifier.background(WavveBg)) + } + } +} diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt b/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt new file mode 100644 index 00000000..3eed222a --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt @@ -0,0 +1,81 @@ +package org.sopt.and.presentation.main + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.rememberNavController +import org.sopt.and.core.navigation.Screen +import org.sopt.and.core.utils.SnackBarUtils +import org.sopt.and.ui.theme.BottomNavigationItemUnselected +import org.sopt.and.ui.theme.White + +@Composable +fun MainScreen( + viewModel: MainScreenViewModel = hiltViewModel() +) { + val mainState by viewModel.uiState.collectAsStateWithLifecycle() + val navController = rememberNavController() + val colors = NavigationBarItemDefaults.colors( + selectedIconColor = White, + unselectedIconColor = BottomNavigationItemUnselected, + selectedTextColor = White, + unselectedTextColor = BottomNavigationItemUnselected, + indicatorColor = Color.Transparent + ) + + LaunchedEffect(Unit) { + viewModel.sendEvent(MainContract.MainUiEvent.LoadUserToken) + } + val startDestination = if (mainState.userToken.isNullOrBlank()) Screen.SignIn else Screen.My + + var currentRoute by remember { mutableStateOf(null) } + LaunchedEffect(navController) { + navController.currentBackStackEntryFlow.collect { backStackEntry -> + currentRoute = backStackEntry.destination.route + } + } + + val bottomBarScreens = listOf( + Screen.Home.javaClass.canonicalName, + Screen.Search.javaClass.canonicalName, + Screen.My.javaClass.canonicalName + ) + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + SnackBarUtils.init(snackbarHostState) + } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + if (currentRoute in bottomBarScreens) { + MainBottomNavigationBar( + navController = navController, + currentRoute = currentRoute, + colors = colors + ) + } + } + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding)) { + MainNavHost( + navController = navController, + startDestination = startDestination + ) + } + } +} diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainViewModel.kt b/app/src/main/java/org/sopt/and/presentation/main/MainViewModel.kt new file mode 100644 index 00000000..96b54dcd --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/main/MainViewModel.kt @@ -0,0 +1,39 @@ +package org.sopt.and.presentation.main + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.sopt.and.core.utils.PreferenceUtil +import org.sopt.and.presentation.util.BaseViewModel +import org.sopt.and.presentation.main.MainContract.MainUiEffect +import org.sopt.and.presentation.main.MainContract.MainUiEvent +import org.sopt.and.presentation.main.MainContract.MainUiState +import javax.inject.Inject + +@HiltViewModel +class MainScreenViewModel @Inject constructor( + private val preferenceUtil: PreferenceUtil +) : BaseViewModel(MainUiState()) { + override fun reduceState(event: MainUiEvent) { + when (event) { + MainUiEvent.LoadUserToken -> loadUserToken() + } + } + + private fun loadUserToken() { + updateState( + currentState.copy( + isLoading = true + ) + ) + viewModelScope.launch { + val token = preferenceUtil.getUserToken() + updateState( + currentState.copy( + isLoading = false, + userToken = token + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoContract.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoContract.kt new file mode 100644 index 00000000..a414d401 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoContract.kt @@ -0,0 +1,25 @@ +package org.sopt.and.presentation.myinfo + +import org.sopt.and.presentation.util.UiEffect +import org.sopt.and.presentation.util.UiEvent +import org.sopt.and.presentation.util.UiState + +class MyInfoContract { + data class MyPageUiState( + val hobby: String = "", + val isLoading: Boolean = false, + val isLoggedOut: Boolean = false, + val errorMessage: String? = null, + val tokenInvalid: Boolean = false + ) : UiState + + sealed class MyPageUiEvent : UiEvent { + data object Logout : MyPageUiEvent() + data object LoadHobby : MyPageUiEvent() + } + + sealed class MyPageUiEffect : UiEffect { + data class ShowErrorSnackBar(val message: String) : MyPageUiEffect() + data object NavigateToSignIn : MyPageUiEffect() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoScreen.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoScreen.kt new file mode 100644 index 00000000..21ca7343 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoScreen.kt @@ -0,0 +1,92 @@ +package org.sopt.and.presentation.myinfo + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import org.sopt.and.R +import org.sopt.and.core.utils.SnackBarUtils +import org.sopt.and.presentation.components.EmptyInfoBox +import org.sopt.and.presentation.myinfo.components.MyInfoPaymentInducementBox +import org.sopt.and.presentation.myinfo.components.MyInfoProfile +import org.sopt.and.presentation.mypage.viewmodel.MyViewModel +import org.sopt.and.ui.theme.ANDANDROIDTheme +import org.sopt.and.ui.theme.Black100 + +@Composable +fun MyInfo( + navigateToSignIn : () -> Unit, + modifier: Modifier = Modifier, + viewModel: MyViewModel = hiltViewModel() +){ + val context = LocalContext.current + + val myPageState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.sendEvent(MyInfoContract.MyPageUiEvent.LoadHobby) + viewModel.effect.collectLatest { effect -> + when (effect) { + is MyInfoContract.MyPageUiEffect.ShowErrorSnackBar -> { + SnackBarUtils.showSnackBar( + message = effect.message, + actionLabel = context.getString(R.string.sign_in_snackbar_action_close) + ) + } + + MyInfoContract.MyPageUiEffect.NavigateToSignIn -> { + navigateToSignIn() + } + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(color = Black100) + .padding(paddingValues) + ) { + MyInfoProfile( + myHobby = myHobby, + getMyHobby = getMyHobby, + modifier = Modifier.weight(0.16f) + ) + + MyInfoPaymentInducementBox( + paymentInducementText = stringResource(id = R.string.my_first_payment_text), + modifier = Modifier.weight(0.12f) + ) + + Spacer(modifier = Modifier.height(2.dp)) + + MyInfoPaymentInducementBox( + paymentInducementText = stringResource(id = R.string.my_no_ticket_text), + modifier = Modifier.weight(0.12f) + ) + + EmptyInfoBox( + title = stringResource(R.string.my_viewing_history_box_title), + description = stringResource(R.string.my_viewing_history_box_empty_text), + modifier = Modifier.weight(0.3f) + ) + + EmptyInfoBox( + title = stringResource(R.string.my_program_of_interest_box_title), + description = stringResource(R.string.my_program_of_interest_empty_text), + modifier = Modifier.weight(0.3f) + ) + } +} + diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoUiState.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoUiState.kt new file mode 100644 index 00000000..f352a6ff --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoUiState.kt @@ -0,0 +1,5 @@ +package org.sopt.and.presentation.myinfo + +data class MyInfoUiState( + val myHobby: String = "오류" +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoViewModel.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoViewModel.kt new file mode 100644 index 00000000..e897c2ab --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/MyInfoViewModel.kt @@ -0,0 +1,30 @@ +package org.sopt.and.presentation.myinfo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.sopt.and.domain.model.MyHobbyEntity +import org.sopt.and.domain.usecase.MyHobbyUseCase + +class MyInfoViewModel( + private val getMyHobbyUseCase: MyHobbyUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(MyInfoUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private fun setMyHobby(myHobby: String) { + _uiState.value = _uiState.value.copy(myHobby = myHobby) + } + + fun getMyHobby() { + viewModelScope.launch { + getMyHobbyUseCase().onSuccess { myHobbyEntity: MyHobbyEntity -> + setMyHobby(myHobbyEntity.myHobby) + }.onFailure { } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/MyViewModel.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/MyViewModel.kt new file mode 100644 index 00000000..56c8b31c --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/MyViewModel.kt @@ -0,0 +1,89 @@ +package org.sopt.and.presentation.mypage.viewmodel + +import android.util.Log +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.sopt.and.core.utils.PreferenceUtil +import org.sopt.and.data.common.ErrorTypeWithMessage +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.usecase.MyHobbyUseCase +import org.sopt.and.presentation.myinfo.MyInfoContract +import org.sopt.and.presentation.util.BaseViewModel + +import javax.inject.Inject + +@HiltViewModel +class MyViewModel @Inject constructor( + private val getMyHobbyUseCase: MyHobbyUseCase, + private val preferenceUtil: PreferenceUtil +) : BaseViewModel( + MyInfoContract.MyPageUiState() +) { + override fun reduceState(event: MyInfoContract.MyPageUiEvent) { + when (event) { + is MyInfoContract.MyPageUiEvent.LoadHobby -> { + loadHobby() + } + + MyInfoContract.MyPageUiEvent.Logout -> { + logout() + } + } + } + + private fun loadHobby() { + val token = preferenceUtil.getUserToken() + Log.d("my**", token.toString()) + if (token.isNullOrEmpty()) { + updateState( + currentState.copy( + tokenInvalid = true + ) + ) + postEffect(MyInfoContract.MyPageUiEffect.NavigateToSignIn) + return + } + updateState(currentState.copy(isLoading = true)) + viewModelScope.launch { + when (val result = getMyHobbyUseCase(token)) { + is BaseResult.Success -> { + updateState( + currentState.copy( + hobby = result.data.hobby, + isLoading = false, + errorMessage = null, + tokenInvalid = false + ) + ) + } + + is BaseResult.Error -> { + updateState( + currentState.copy( + isLoading = false, + errorMessage = result.message + ) + ) + if (result.errorCode == ErrorTypeWithMessage.INVALID_TOKEN) { + preferenceUtil.clearUserToken() + updateState( + currentState.copy( + tokenInvalid = true + ) + ) + postEffect(MyInfoContract.MyPageUiEffect.NavigateToSignIn) + } else { + postEffect(MyInfoContract.MyPageUiEffect.ShowErrorSnackBar(result.message)) + } + } + } + } + } + + private fun logout() { + preferenceUtil.clearUserToken() + updateState(currentState.copy(isLoggedOut = true)) + postEffect(MyInfoContract.MyPageUiEffect.NavigateToSignIn) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/components/MyInfoPaymenIntroducementBox.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/components/MyInfoPaymenIntroducementBox.kt new file mode 100644 index 00000000..00ab126e --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/components/MyInfoPaymenIntroducementBox.kt @@ -0,0 +1,43 @@ +package org.sopt.and.presentation.myinfo.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.sopt.and.R +import org.sopt.and.ui.theme.Grey100 +import org.sopt.and.ui.theme.Grey200 +import org.sopt.and.ui.theme.White100 + + +@Composable +fun MyInfoPaymentInducementBox( + paymentInducementText: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = Grey100) + .padding(16.dp) + ) { + Text( + text = paymentInducementText, + color = Grey200 + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(id = R.string.my_to_payment_button), + color = White100 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/myinfo/components/MyInfoProfile.kt b/app/src/main/java/org/sopt/and/presentation/myinfo/components/MyInfoProfile.kt new file mode 100644 index 00000000..9eb03038 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/myinfo/components/MyInfoProfile.kt @@ -0,0 +1,72 @@ +package org.sopt.and.presentation.myinfo.components + +import androidx.compose.foundation.background +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.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.sopt.and.R +import org.sopt.and.ui.theme.Grey100 +import org.sopt.and.ui.theme.White100 + +@Composable +fun MyInfoProfile( + myHobby: String, + getMyHobby: () -> Unit, + modifier: Modifier = Modifier +) { + getMyHobby() + + Row( + modifier = modifier + .fillMaxWidth() + .background(color = Grey100) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(id = R.string.my_account_icon_description), + modifier = Modifier.size(80.dp), + tint = White100 + ) + + Spacer(modifier = Modifier.width(5.dp)) + + Text( + text = myHobby.ifEmpty { "Loading..." }, + color = White100 + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = Icons.Outlined.Notifications, + contentDescription = stringResource(id = R.string.my_notification_icon_description), + modifier = Modifier.size(30.dp), + tint = White100 + ) + + Spacer(modifier = Modifier.width(24.dp)) + + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(id = R.string.my_setting_icon_description), + modifier = Modifier.size(30.dp), + tint = White100 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigation.kt b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigation.kt new file mode 100644 index 00000000..af53b4b2 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigation.kt @@ -0,0 +1,111 @@ +package org.sopt.and.presentation.navigation + +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import org.sopt.and.R +import org.sopt.and.ui.theme.Grey100 +import org.sopt.and.ui.theme.Grey200 + +@Composable +fun BottomNavigation( + items: List, + navController: NavController, + setNavigationSelectedScreenIndex: (Int) -> Unit, + navigationSelectedScreenIndex: Int +) { + NavigationBar( + modifier = Modifier.height(60.dp), + containerColor = Color.Black + ) { + items.forEachIndexed { index, bottomNavigationItem -> + NavigationBarItem( + selected = index == navigationSelectedScreenIndex, + onClick = { + setNavigationSelectedScreenIndex(index) + navController.navigate( + bottomNavigationItem.route, + navOptions = navOptions { + launchSingleTop + } + ) + }, + icon = { + Icon( + imageVector = bottomNavigationItem.icon, + contentDescription = "" + ) + }, + label = { + Text( + text = stringResource(bottomNavigationItem.label), + style = TextStyle( + fontSize = 12.sp + ) + ) + }, + colors = NavigationBarItemColors( + selectedIconColor = Color.White, + selectedTextColor = Color.White, + selectedIndicatorColor = Color.Transparent, + unselectedIconColor = Grey200, + unselectedTextColor = Grey200, + disabledIconColor = Grey100, + disabledTextColor = Grey100 + ) + ) + } + + } +} + +@Preview +@Composable +fun WavveBottomNavigationPreview() { + val index = remember { mutableIntStateOf(0) } + BottomNavigation( + listOf( + BottomNavigationItem( + label = R.string.bottom_navigation_home_label, + icon = Icons.Default.Home, + route = Routes.Home, + index = 0 + ), + BottomNavigationItem( + label = R.string.bottom_navigation_search_label, + icon = Icons.Default.Search, + route = Routes.Search, + index = 1 + ), + BottomNavigationItem( + label = R.string.bottom_navigation_my_info_label, + icon = Icons.Default.AccountCircle, + route = Routes.MyInfo, + index = 2 + ) + ), + navController = rememberNavController(), + setNavigationSelectedScreenIndex = { index.intValue = it }, + navigationSelectedScreenIndex = index.intValue, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationItem.kt b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationItem.kt new file mode 100644 index 00000000..87eb4a3f --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationItem.kt @@ -0,0 +1,10 @@ +package org.sopt.and.presentation.navigation + +import androidx.compose.ui.graphics.vector.ImageVector + +data class BottomNavigationItem( + val label: Int, + val icon: ImageVector, + val route: Routes, + val index: Int +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/Navigation.kt b/app/src/main/java/org/sopt/and/presentation/navigation/Navigation.kt new file mode 100644 index 00000000..53ef1dd8 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/navigation/Navigation.kt @@ -0,0 +1,96 @@ +package org.sopt.and.presentation.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import org.sopt.and.presentation.home.HomeScreen +import org.sopt.and.presentation.myinfo.MyInfoScreen +import org.sopt.and.presentation.myinfo.MyInfoViewModel +import org.sopt.and.presentation.search.SearchScreen +import org.sopt.and.presentation.signin.SignInScreen +import org.sopt.and.presentation.signup.SignUpScreen +import org.sopt.and.presentation.viewmodelfactory.MyInfoViewModelFactory +import org.sopt.and.ui.theme.WavveBg + +@Composable +fun Navigation( +) { + val navigationViewModel = viewModel() + val navigationUiState by navigationViewModel.uiState.collectAsStateWithLifecycle() + val navController = rememberNavController() + + val myInfoViewModel: MyInfoViewModel = viewModel( + factory = MyInfoViewModelFactory() + ) + val myInfoUiState by myInfoViewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (navigationUiState.isBottomNavigationVisible) { + BottomNavigation( + items = navigationUiState.BottomNavigationItems, + navController = navController, + setNavigationSelectedScreenIndex = navigationViewModel::setNavigationSelectedIndex, + navigationSelectedScreenIndex = navigationUiState.navigationSelectedIndex + ) + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Routes.SignIn + ) { + composable { + SignInScreen( + navigateToSignUp = { navController.navigate(route = Routes.SignUp) }, + navigateToMyInfo = { + navigationViewModel.changeBottomNavigationVisibility() + navController.navigate(Routes.MyInfo) + } + ) + } + + composable { + SignUpScreen( + navigateToSignIn = { + navController.navigate( + route = Routes.SignIn, + navOptions = navOptions { + popUpTo { + inclusive = true + } + } + ) + } + ) + } + + composable { + MyInfoScreen( + paddingValues = innerPadding, + myHobby = myInfoUiState.myHobby, + getMyHobby = myInfoViewModel::getMyHobby + ) + } + + composable { + HomeScreen( + innerPadding = innerPadding + ) + } + + composable { + SearchScreen(Modifier.Companion.background(WavveBg)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/NavigationUiState.kt b/app/src/main/java/org/sopt/and/presentation/navigation/NavigationUiState.kt new file mode 100644 index 00000000..0e3f14ad --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/navigation/NavigationUiState.kt @@ -0,0 +1,33 @@ +package org.sopt.and.presentation.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Search +import org.sopt.and.R +import org.sopt.and.presentation.util.Utils + +data class NavigationUiState( + val isBottomNavigationVisible: Boolean = false, + val navigationSelectedIndex: Int = Utils.MYINFO_SCREEN_INDEX, + val BottomNavigationItems: List = listOf( + BottomNavigationItem( + label = R.string.bottom_navigation_home_label, + icon = Icons.Default.Home, + route = Routes.Home, + index = 0 + ), + BottomNavigationItem( + label = R.string.bottom_navigation_search_label, + icon = Icons.Default.Search, + route = Routes.Search, + index = 1 + ), + BottomNavigationItem( + label = R.string.bottom_navigation_my_info_label, + icon = Icons.Default.AccountCircle, + route = Routes.MyInfo, + index = 2 + ) + ) +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/NavigationViewModel.kt b/app/src/main/java/org/sopt/and/presentation/navigation/NavigationViewModel.kt new file mode 100644 index 00000000..c9db0cf8 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/navigation/NavigationViewModel.kt @@ -0,0 +1,23 @@ +package org.sopt.and.presentation.navigation + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class NavigationViewModel : ViewModel() { + private val _uiState = MutableStateFlow(NavigationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun changeBottomNavigationVisibility() { + _uiState.value = _uiState.value.copy( + isBottomNavigationVisible = !_uiState.value.isBottomNavigationVisible + ) + } + + fun setNavigationSelectedIndex(index: Int) { + _uiState.value = _uiState.value.copy( + navigationSelectedIndex = index + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/Routes.kt b/app/src/main/java/org/sopt/and/presentation/navigation/Routes.kt new file mode 100644 index 00000000..540f8ffc --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/navigation/Routes.kt @@ -0,0 +1,20 @@ +package org.sopt.and.presentation.navigation + +import kotlinx.serialization.Serializable + +sealed class Routes { + @Serializable + object MyInfo : Routes() + + @Serializable + object SignIn : Routes() + + @Serializable + object SignUp : Routes() + + @Serializable + object Home : Routes() + + @Serializable + object Search : Routes() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/navigation/SignNavigation.kt b/app/src/main/java/org/sopt/and/presentation/navigation/SignNavigation.kt similarity index 80% rename from app/src/main/java/org/sopt/and/navigation/SignNavigation.kt rename to app/src/main/java/org/sopt/and/presentation/navigation/SignNavigation.kt index aa545daf..4bff8d0b 100644 --- a/app/src/main/java/org/sopt/and/navigation/SignNavigation.kt +++ b/app/src/main/java/org/sopt/and/presentation/navigation/SignNavigation.kt @@ -1,4 +1,4 @@ -package org.sopt.and.navigation +package org.sopt.and.presentation.navigation sealed class SignNavigation(val route: String) { diff --git a/app/src/main/java/org/sopt/and/search/SearchScreen.kt b/app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt similarity index 89% rename from app/src/main/java/org/sopt/and/search/SearchScreen.kt rename to app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt index 6521cdea..d1b78c55 100644 --- a/app/src/main/java/org/sopt/and/search/SearchScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package org.sopt.and.search +package org.sopt.and.presentation.search import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.sp @Composable -fun SearchScreen() { +fun SearchScreen(modifier: Modifier) { Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInContract.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInContract.kt new file mode 100644 index 00000000..b996a5c6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInContract.kt @@ -0,0 +1,29 @@ +package org.sopt.and.presentation.auth.signin + +import org.sopt.and.presentation.util.UiEffect +import org.sopt.and.presentation.util.UiEvent +import org.sopt.and.presentation.util.UiState + +class SignInContract { + data class SignInUiState( + val username: String = "", + val password: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null + ) : UiState + + sealed class SignInUiEvent : UiEvent { + data class UpdateUserName(val username: String) : SignInUiEvent() + data class UpdatePassword(val password: String) : SignInUiEvent() + data object SignInFormSubmit : SignInUiEvent() + data object NavigateUp : SignInUiEvent() + } + + sealed class SignInUiEffect : UiEffect { + data object ShowSuccessSnackBar : SignInUiEffect() + data class ShowErrorSnackBar(val message: String) : SignInUiEffect() + data object NavigateToSignUp : SignInUiEffect() + data object NavigateToMy : SignInUiEffect() + data object NavigateUp : SignInUiEffect() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt new file mode 100644 index 00000000..57925281 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt @@ -0,0 +1,132 @@ +package org.sopt.and.presentation.signin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.sopt.and.R +import org.sopt.and.core.utils.SnackBarUtils +import org.sopt.and.presentation.auth.signin.SignInContract +import org.sopt.and.presentation.auth.signin.component.SignInButton +import org.sopt.and.presentation.auth.signin.viewmodel.SignInViewModel +import org.sopt.and.presentation.components.SnSBox +import org.sopt.and.presentation.signin.components.* +import org.sopt.and.ui.theme.Black100 + + +@Composable +fun SignInScreen( + navigateToMy: () -> Unit, + navigateToSignUp: () -> Unit, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SignInViewModel = hiltViewModel(), +) { + val signInState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // Effect handling + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is SignInContract.SignInUiEffect.ShowSuccessSnackBar -> { + CoroutineScope(Dispatchers.Main).launch { + SnackBarUtils.showSnackBar( + message = context.getString(R.string.sign_in_snackbar_login_success), + actionLabel = context.getString(R.string.sign_in_snackbar_action_close) + ) + } + navigateToMy() + } + + is SignInContract.SignInUiEffect.ShowErrorSnackBar -> { + CoroutineScope(Dispatchers.Main).launch { + SnackBarUtils.showSnackBar( + message = effect.message, + actionLabel = context.getString(R.string.sign_in_snackbar_action_close) + ) + } + } + + is SignInContract.SignInUiEffect.NavigateToSignUp -> navigateToSignUp() + is SignInContract.SignInUiEffect.NavigateToMy -> navigateToMy() + is SignInContract.SignInUiEffect.NavigateUp -> navigateUp() + } + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = { viewModel.sendEvent(SignInContract.SignInUiEvent.NavigateUp) } + ) { innerPadding -> + Column( + modifier = modifier + .fillMaxSize() + .background(color = Black100) + .padding(innerPadding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SignInTopBar() + + Spacer(modifier = Modifier.height(60.dp)) + + SignInUsernameField( + signInUsername = signInState.username, + onSignInUsernameChange = { + viewModel.sendEvent( + SignInContract.SignInUiEvent.UpdateUserName( + it + ) + ) + } + ) + + Spacer(modifier = Modifier.height(5.dp)) + + SignInPasswordField( + signInPassword = signInState.password, + onSignInPasswordChange = { + viewModel.sendEvent( + SignInContract.SignInUiEvent.UpdatePassword( + it + ) + ) + }, + isSignInPasswordVisible = false, + ) + + Spacer(modifier = Modifier.height(30.dp)) + + SignInButton( + text = stringResource(R.string.sign_in_text_login), + onClick = { viewModel.signIn() }, + modifier = Modifier + ) + + + Spacer(modifier = Modifier.height(20.dp)) + + SignInToAdditionalFeatures(navigateToSignUp = navigateToSignUp) + + Spacer(modifier = Modifier.size(40.dp)) + + SnSBox(stringResource(R.string.sign_in_link_with_another_service_title)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInUiState.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInUiState.kt new file mode 100644 index 00000000..5398a6e0 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInUiState.kt @@ -0,0 +1,14 @@ +package org.sopt.and.presentation.signin + +data class SignInUiState( + val signInUsername: String = "", + val signInPassword: String = "", + val isSignInPasswordVisible: Boolean = false +) + +sealed class SignInResult { + object Initial : SignInResult() + object Success : SignInResult() + object FailurePasswordLength : SignInResult() + object FailureWrongPassword : SignInResult() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt new file mode 100644 index 00000000..452cdb7f --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt @@ -0,0 +1,82 @@ +package org.sopt.and.presentation.auth.signin.viewmodel + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.sopt.and.core.utils.PreferenceUtil +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.usecase.SignInUseCase +import org.sopt.and.presentation.auth.signin.SignInContract.SignInUiEffect +import org.sopt.and.presentation.auth.signin.SignInContract.SignInUiEvent +import org.sopt.and.presentation.auth.signin.SignInContract.SignInUiState +import org.sopt.and.presentation.util.BaseViewModel +import javax.inject.Inject + +@HiltViewModel +class SignInViewModel @Inject constructor( + private val loginUseCase: SignInUseCase, + private val preferenceUtil: PreferenceUtil +) : BaseViewModel(SignInUiState()) { + override fun reduceState(event: SignInUiEvent) { + when (event) { + is SignInUiEvent.UpdateUserName -> { + updateState( + currentState.copy( + username = event.username + ) + ) + } + + is SignInUiEvent.UpdatePassword -> { + updateState( + currentState.copy( + password = event.password + ) + ) + } + + is SignInUiEvent.SignInFormSubmit -> signIn() + + is SignInUiEvent.NavigateUp -> postEffect(SignInUiEffect.NavigateUp) + } + } + + fun signIn() { + updateState( + currentState.copy( + isLoading = true + ) + ) + viewModelScope.launch { + when ( + val result = loginUseCase( + with(currentState) { + UserData(username, password, "") + } + ) + ) { + is BaseResult.Success -> { + updateState( + currentState.copy( + isLoading = false + ) + ) + preferenceUtil.saveUserToken(result.data.token) + postEffect(SignInUiEffect.ShowSuccessSnackBar) + postEffect(SignInUiEffect.NavigateToMy) + } + + is BaseResult.Error -> { + updateState( + currentState.copy( + isLoading = false, + errorMessage = result.message + ) + ) + postEffect(SignInUiEffect.ShowErrorSnackBar(result.message)) + } + } + } + } +} diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt new file mode 100644 index 00000000..a4a40816 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInButton.kt @@ -0,0 +1,37 @@ +package org.sopt.and.presentation.auth.signin.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.ui.theme.Blue100 +import org.sopt.and.ui.theme.White100 + +@Composable +fun SignInButton( + text: String, + onClick : () -> Unit, + modifier: Modifier +) { + Button( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Blue100 + ), + onClick = onClick, + ) { + Text( + text = text, + color = White100, + fontSize = 16.sp, + modifier = modifier.padding(vertical = 8.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt new file mode 100644 index 00000000..e1fe0e15 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInPasswordField.kt @@ -0,0 +1,21 @@ +package org.sopt.and.presentation.signin.components + +import androidx.compose.runtime.Composable +import org.sopt.and.R +import org.sopt.and.presentation.components.ShowOrHideToggle +import org.sopt.and.presentation.components.SignInOrSignUpTextField +import org.sopt.and.presentation.util.Utils.transformationPasswordVisual + +@Composable +fun SignInPasswordField( + signInPassword: String, + onSignInPasswordChange: (String) -> Unit, + isSignInPasswordVisible: Boolean, +) { + SignInOrSignUpTextField( + information = signInPassword, + onValueChange = onSignInPasswordChange, + placeholder = R.string.sign_in_password_placeholder, + visualTransformation = transformationPasswordVisual(isSignInPasswordVisible), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInToAdditionalFeatures.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInToAdditionalFeatures.kt new file mode 100644 index 00000000..de38708c --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInToAdditionalFeatures.kt @@ -0,0 +1,65 @@ +package org.sopt.and.presentation.signin.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.ui.theme.Grey200 + +@Composable +fun SignInToAdditionalFeatures( + navigateToSignUp: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(0.6f), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.sign_in_to_find_id_button), + color = Grey200, + style = TextStyle( + fontSize = 11.sp + ) + ) + + Text( + text = stringResource(id = R.string.seperator), + color = Grey200, + style = TextStyle( + fontSize = 11.sp + ) + ) + + Text( + text = stringResource(id = R.string.sign_in_to_reset_password_button), + color = Grey200, + style = TextStyle( + fontSize = 11.sp + ) + ) + + Text( + text = stringResource(id = R.string.seperator), + color = Grey200, + style = TextStyle( + fontSize = 11.sp + ) + ) + + Text( + text = stringResource(id = R.string.sign_in_to_sign_up_button), + color = Grey200, + modifier = Modifier.clickable { navigateToSignUp() }, + style = TextStyle( + fontSize = 11.sp + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInTopBar.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInTopBar.kt new file mode 100644 index 00000000..75009e68 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInTopBar.kt @@ -0,0 +1,49 @@ +package org.sopt.and.presentation.signin.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.ui.theme.White100 + +@Composable +fun SignInTopBar() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.KeyboardArrowLeft, + contentDescription = stringResource(id = R.string.sign_in_to_back_screen_description), + modifier = Modifier + .size(48.dp), + tint = White100 + ) + + Text( + text = stringResource(id = R.string.app_name), + color = White100, + style = TextStyle( + fontSize = 30.sp, + fontWeight = FontWeight(800) + ) + ) + + Spacer(modifier = Modifier.size(48.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/components/SignInUsernameField.kt b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInUsernameField.kt new file mode 100644 index 00000000..ae46321c --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/components/SignInUsernameField.kt @@ -0,0 +1,17 @@ +package org.sopt.and.presentation.signin.components + +import androidx.compose.runtime.Composable +import org.sopt.and.R +import org.sopt.and.presentation.components.SignInOrSignUpTextField + +@Composable +fun SignInUsernameField( + signInUsername: String, + onSignInUsernameChange: (String) -> Unit +) { + SignInOrSignUpTextField( + information = signInUsername, + onValueChange = onSignInUsernameChange, + placeholder = R.string.sign_in_username_placeholder + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpContract.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpContract.kt new file mode 100644 index 00000000..3148e84c --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpContract.kt @@ -0,0 +1,42 @@ +package org.sopt.and.presentation.auth.signup + +import org.sopt.and.presentation.util.UiEffect +import org.sopt.and.presentation.util.UiEvent +import org.sopt.and.presentation.util.UiState + +class SignUpContract { + data class SignUpUiState( + val username: String = "", + val password: String = "", + val hobby: String = "", + val isUserNameValid: Boolean = false, + val isPasswordValid: Boolean = false, + val isHobbyValid: Boolean = false, + val isUserNameFieldFocused: Boolean = false, + val isPasswordFieldFocused: Boolean = false, + val isHobbyFieldFocused: Boolean = false, + val isValid: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null + ) : UiState + + sealed class SignUpUiEvent : UiEvent { + data class UpdateUserName(val username: String) : SignUpUiEvent() + data class UpdatePassword(val password: String) : SignUpUiEvent() + data class UpdateHobby(val hobby: String) : SignUpUiEvent() + data class UpdateFieldFocus(val field: Field, val isFocused: Boolean) : SignUpUiEvent() + data object SignUpFormSubmit : SignUpUiEvent() + data object Close : SignUpUiEvent() + } + + sealed class SignUpUiEffect : UiEffect { + data object ShowSuccessToast : SignUpUiEffect() + data class ShowErrorToast(val message: String) : SignUpUiEffect() + data object NavigateToSignIn : SignUpUiEffect() + data object NavigateUp : SignUpUiEffect() + } + + enum class Field { + UserName, Password, Hobby + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt new file mode 100644 index 00000000..52e22dc2 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt @@ -0,0 +1,145 @@ +package org.sopt.and.presentation.signup + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.sopt.and.R +import org.sopt.and.core.utils.extension.noRippleClickable +import org.sopt.and.presentation.auth.signup.SignUpContract +import org.sopt.and.presentation.auth.signup.viewmodel.SignUpViewModel +import org.sopt.and.presentation.components.SnSBox +import org.sopt.and.presentation.signup.components.SignUpButton +import org.sopt.and.presentation.signup.components.SignUpGreetingText +import org.sopt.and.presentation.signup.components.SignUpHobbyField +import org.sopt.and.presentation.signup.components.SignUpPasswordField +import org.sopt.and.presentation.signup.components.SignUpTopBar +import org.sopt.and.presentation.signup.components.SignUpUsernameField +import org.sopt.and.presentation.util.Utils.showToast +import org.sopt.and.ui.theme.Black100 +import org.sopt.and.ui.theme.Blue100 +import org.sopt.and.ui.theme.WavveDisabled +import org.sopt.and.ui.theme.White100 + +@Composable +fun SignUpScreen( + navigateToSignIn: () -> Unit, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SignUpViewModel = hiltViewModel() +) { + + val signUpState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is SignUpContract.SignUpUiEffect.ShowSuccessToast -> { + context.showToast(context.getString(R.string.sign_up_toast_success)) + navigateToSignIn() + } + + is SignUpContract.SignUpUiEffect.ShowErrorToast -> { + context.showToast(effect.message) + } + + is SignUpContract.SignUpUiEffect.NavigateToSignIn -> navigateToSignIn() + is SignUpContract.SignUpUiEffect.NavigateUp -> navigateUp() + } + } + } + Column( + modifier = modifier + .fillMaxSize() + .background(color = Black100) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .weight(1f) + .padding(16.dp) + ) { + SignUpTopBar() + + Spacer(modifier = Modifier.height(30.dp)) + + SignUpGreetingText(fontSize = 24) + + Spacer(modifier = Modifier.height(20.dp)) + + SignUpUsernameField( + signUpUsername = signUpState.username, + onSignUpUsernameChange = { + viewModel.sendEvent( + SignUpContract.SignUpUiEvent.UpdateUserName( + it + ) + ) + } + ) + Spacer(modifier = Modifier.height(20.dp)) + + SignUpPasswordField( + signUpPassword = signUpState.password, + onSignUpPasswordChange = { + viewModel.sendEvent( + SignUpContract.SignUpUiEvent.UpdatePassword( + it + ) + ) + } + ) + + Spacer(modifier = Modifier.height(20.dp)) + + + SignUpHobbyField( + signUpHobby = signUpState.hobby, + onSignUpHobbyChange = { + viewModel.sendEvent( + SignUpContract.SignUpUiEvent.UpdateHobby(it) + ) + } + ) + + Spacer(modifier = Modifier.size(40.dp)) + + SnSBox(stringResource(R.string.sign_in_link_with_another_service_title)) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .background( + color = if (signUpState.isValid) Blue100 else WavveDisabled + ) + .wrapContentHeight() + .noRippleClickable { viewModel.sendEvent(SignUpContract.SignUpUiEvent.SignUpFormSubmit) } + .padding(vertical = 14.dp) + ) { + Text( + text = stringResource(R.string.sign_up_text_wavve_sign_up), + color = White100 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpUiState.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpUiState.kt new file mode 100644 index 00000000..c5b93fd9 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpUiState.kt @@ -0,0 +1,15 @@ +package org.sopt.and.presentation.signup + +data class SignUpUiState( + val signUpUsername: String = "", + val signUpPassword: String = "", + val signUpHobby: String = "", + val isSignUpPasswordVisible: Boolean = false +) + +sealed class SignUpResult { + object Initial : SignUpResult() + object Success : SignUpResult() + object FailureInformationLength : SignUpResult() + object FailureDuplicateUsername : SignUpResult() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt new file mode 100644 index 00000000..1d4ea656 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt @@ -0,0 +1,134 @@ +package org.sopt.and.presentation.auth.signup.viewmodel + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.sopt.and.domain.entity.BaseResult +import org.sopt.and.domain.entity.UserData +import org.sopt.and.domain.usecase.SignUpUseCase +import org.sopt.and.presentation.auth.signup.SignUpContract +import org.sopt.and.presentation.auth.signup.SignUpContract.SignUpUiEffect +import org.sopt.and.presentation.auth.signup.SignUpContract.SignUpUiState +import org.sopt.and.presentation.auth.signup.SignUpContract.SignUpUiEvent +import org.sopt.and.presentation.util.BaseViewModel +import javax.inject.Inject + +@HiltViewModel +class SignUpViewModel @Inject constructor( + private val registerUserUseCase: SignUpUseCase +) : BaseViewModel(SignUpUiState()) { + override fun reduceState(event: SignUpUiEvent) { + when (event) { + is SignUpUiEvent.UpdateUserName -> { + val isValid = validateUserName(event.username) + updateState( + currentState.copy( + username = event.username, + isUserNameValid = isValid, + isValid = isValid && + currentState.isPasswordValid && + currentState.isHobbyValid + ) + ) + } + + is SignUpUiEvent.UpdatePassword -> { + val isValid = validatePassword(event.password) + updateState( + currentState.copy( + password = event.password, + isPasswordValid = isValid, + isValid = isValid && + currentState.username.isNotBlank() && + currentState.hobby.isNotBlank() + ) + ) + } + + is SignUpUiEvent.UpdateHobby -> { + val isValid = validateHobby(event.hobby) + updateState( + currentState.copy( + hobby = event.hobby, + isHobbyValid = isValid, + isValid = isValid && + currentState.username.isNotBlank() && + currentState.password.isNotBlank() + ) + ) + } + + is SignUpUiEvent.UpdateFieldFocus -> { + when (event.field) { + SignUpContract.Field.UserName -> updateState( + currentState.copy( + isUserNameFieldFocused = event.isFocused + ) + ) + + SignUpContract.Field.Password -> updateState( + currentState.copy( + isPasswordFieldFocused = event.isFocused + ) + ) + + SignUpContract.Field.Hobby -> updateState( + currentState.copy( + isHobbyFieldFocused = event.isFocused + ) + ) + } + } + + is SignUpUiEvent.SignUpFormSubmit -> signUp() + + is SignUpUiEvent.Close -> postEffect(SignUpUiEffect.NavigateUp) + } + } + + private fun signUp() { + updateState( + currentState.copy( + isLoading = true + ) + ) + viewModelScope.launch { + when (val result = registerUserUseCase( + UserData( + username = currentState.username, + password = currentState.password, + hobby = currentState.hobby + ) + )) { + is BaseResult.Success -> { + updateState( + currentState.copy( + isLoading = false + ) + ) + postEffect(SignUpUiEffect.ShowSuccessToast) + postEffect(SignUpUiEffect.NavigateToSignIn) + } + + is BaseResult.Error -> { + updateState( + currentState.copy( + isLoading = false, + errorMessage = result.message + ) + ) + postEffect(SignUpUiEffect.ShowErrorToast(result.message)) + } + } + } + } + + private fun validateUserName(username: String) = + username.isNotBlank() && username.length <= 8 + + private fun validatePassword(password: String) = + password.isNotBlank() && password.length <= 8 + + private fun validateHobby(hobby: String) = + hobby.isNotBlank() && hobby.length <= 8 +} diff --git a/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpButton.kt b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpButton.kt new file mode 100644 index 00000000..cbe6d095 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpButton.kt @@ -0,0 +1,64 @@ +package org.sopt.and.presentation.signup.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.sopt.and.R +import org.sopt.and.presentation.signup.SignUpViewModel +import org.sopt.and.ui.theme.Grey200 +import org.sopt.and.ui.theme.White100 + +@Composable +fun SignUpButton( + signUpUsername: String, + signUpPassword: String, + signUpHobby: String, + onSignUpComplete: () -> Unit, + signUpViewModel: SignUpViewModel +) { + val signUpResult by signUpViewModel.signUpResult.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(signUpResult) { + signUpViewModel.confirmSignUp( + context = context, + onSignUpComplete = onSignUpComplete + ) + } + + Button( + onClick = { + signUpViewModel.signUp( + signUpUsername = signUpUsername, + signUpPassword = signUpPassword, + signUpHobby = signUpHobby + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Grey200, + contentColor = White100 + ) + ) { + Text( + text = stringResource(id = R.string.sign_up_button), + style = TextStyle(fontSize = 18.sp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpEmailField.kt b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpEmailField.kt new file mode 100644 index 00000000..7ee66d72 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpEmailField.kt @@ -0,0 +1,32 @@ +package org.sopt.and.presentation.signup.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.sopt.and.R +import org.sopt.and.presentation.components.CautionBox +import org.sopt.and.presentation.components.SignInOrSignUpTextField + +@Composable +fun SignUpUsernameField( + signUpUsername: String, + onSignUpUsernameChange: (String) -> Unit +) { + Column { + SignInOrSignUpTextField( + information = signUpUsername, + onValueChange = onSignUpUsernameChange, + placeholder = R.string.sign_up_username_placeholder + ) + + Spacer(modifier = Modifier.height(10.dp)) + + CautionBox( + contentDescription = R.string.sign_up_username_description, + caution = R.string.sign_up_username_caution + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpGreeting.kt b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpGreeting.kt new file mode 100644 index 00000000..90bb89f6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpGreeting.kt @@ -0,0 +1,71 @@ +package org.sopt.and.presentation.signup.components + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.presentation.util.Utils +import org.sopt.and.ui.theme.Grey200 +import org.sopt.and.ui.theme.White100 + +@Composable +fun SignUpGreetingText(fontSize: Int) { + Text( + AnnotatedString( + text = stringResource(R.string.sign_up_welcome_text), + spanStyles = listOf( + AnnotatedString.Range( + item = SpanStyle( + color = White100, + fontSize = fontSize.sp + ), + start = Utils.GREETING_FIRST_LINE_FOCUS_START_INDEX, + end = Utils.GREETING_FIRST_LINE_FOCUS_END_INDEX + ), + AnnotatedString.Range( + item = SpanStyle( + color = Grey200, + fontSize = fontSize.sp + ), + start = Utils.GREETING_FIRST_LINE_FOCUS_END_INDEX, + end = Utils.GREETING_FIRST_LINE_END_INDEX + ), + AnnotatedString.Range( + item = SpanStyle( + color = White100, + fontSize = fontSize.sp + ), + start = Utils.GREETING_SECOND_LINE_FOCUS_START_INDEX, + end = Utils.GREETING_SECOND_LINE_FOCUS_END_INDEX + ), + AnnotatedString.Range( + item = SpanStyle( + color = Grey200, + fontSize = fontSize.sp + ), + start = Utils.GREETING_SECOND_LINE_FOCUS_END_INDEX, + end = Utils.GREETING_SECOND_LINE_END_INDEX + ), + ), + paragraphStyles = listOf( + AnnotatedString.Range( + item = ParagraphStyle( + lineHeight = 2.em, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Top, + trim = LineHeightStyle.Trim.Both + ) + ), + start = Utils.GREETING_FIRST_LINE_FOCUS_START_INDEX, + end = Utils.GREETING_SECOND_LINE_END_INDEX + ) + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpHobbyField.kt b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpHobbyField.kt new file mode 100644 index 00000000..0ca9551d --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpHobbyField.kt @@ -0,0 +1,17 @@ +package org.sopt.and.presentation.signup.components + +import androidx.compose.runtime.Composable +import org.sopt.and.R +import org.sopt.and.presentation.components.SignInOrSignUpTextField + +@Composable +fun SignUpHobbyField( + signUpHobby: String, + onSignUpHobbyChange: (String) -> Unit +) { + SignInOrSignUpTextField( + information = signUpHobby, + onValueChange = onSignUpHobbyChange, + placeholder = R.string.sign_up_hobby_placeholder + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpPasswordField.kt b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpPasswordField.kt new file mode 100644 index 00000000..56da9d63 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpPasswordField.kt @@ -0,0 +1,34 @@ +package org.sopt.and.presentation.signup.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.sopt.and.R +import org.sopt.and.presentation.components.CautionBox +import org.sopt.and.presentation.components.ShowOrHideToggle +import org.sopt.and.presentation.components.SignInOrSignUpTextField +import org.sopt.and.presentation.util.Utils.transformationPasswordVisual + +@Composable +fun SignUpPasswordField( + signUpPassword: String, + onSignUpPasswordChange: (String) -> Unit, +) { + Column { + SignInOrSignUpTextField( + information = signUpPassword, + onValueChange = onSignUpPasswordChange, + placeholder = R.string.sign_up_password_placeholder, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + CautionBox( + contentDescription = R.string.sign_up_password_description, + caution = R.string.sign_up_password_caution + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpTopBar.kt b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpTopBar.kt new file mode 100644 index 00000000..50de578e --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/components/SignUpTopBar.kt @@ -0,0 +1,47 @@ +package org.sopt.and.presentation.signup.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.and.R +import org.sopt.and.ui.theme.White100 + +@Composable +fun SignUpTopBar() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer( + modifier = Modifier.width(36.dp) + ) + + Text( + text = stringResource(id = R.string.sign_up_screen_title), + color = White100, + style = TextStyle(fontSize = 18.sp) + ) + + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = R.string.sign_up_close_description), + tint = White100, + modifier = Modifier.size(36.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/util/Utils.kt b/app/src/main/java/org/sopt/and/presentation/util/Utils.kt new file mode 100644 index 00000000..2f6289d7 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/util/Utils.kt @@ -0,0 +1,131 @@ +package org.sopt.and.presentation.util + +import android.annotation.SuppressLint +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.sopt.and.R + + +// Utility object for common functions +object Utils { + const val MIN_PASSWORD_LENGTH = 8 + const val MAX_PASSWORD_LENGTH = 20 + + const val MYINFO_SCREEN_INDEX = 2 + const val SEARCH_SCREEN_INDEX = 1 + const val HOME_SCREEN_INDEX = 0 + + const val GREETING_FIRST_LINE_FOCUS_START_INDEX = 0 + const val GREETING_FIRST_LINE_FOCUS_END_INDEX = 9 + const val GREETING_FIRST_LINE_END_INDEX = 12 + const val GREETING_SECOND_LINE_FOCUS_START_INDEX = 13 + const val GREETING_SECOND_LINE_FOCUS_END_INDEX = 24 + const val GREETING_SECOND_LINE_END_INDEX = 29 + + val linkableSNS = listOf>( + Pair(R.drawable.kakao_talk_icon, R.string.link_kakao_icon_description), + Pair(R.drawable.t_world_icon, R.string.link_tworld_icon_description), + Pair(R.drawable.naver_icon, R.string.link_naver_icon_description), + Pair(R.drawable.facebook_icon, R.string.link_facebook_icon_description), + Pair(R.drawable.apple_icon, R.string.link_apple_icon_description), + ) + + // Visual transformation for password visibility + fun transformationPasswordVisual(isVisible: Boolean): VisualTransformation = + if (isVisible) VisualTransformation.None else PasswordVisualTransformation() + + // Show a Toast message +// fun Context.showToast( +// @SuppressLint("SupportAnnotationUsage") @StringRes message: String +// ) = Toast.makeText( +// this, +// this.getString(message), +// Toast.LENGTH_SHORT +// ).show() + + // Show a Snackbar message + fun Context.showSnackbar( + scope: CoroutineScope, + snackbarHostState: SnackbarHostState, + @StringRes message: Int + ) = scope.launch { + snackbarHostState.showSnackbar(message = getString(message)) + } +} + + +// UiState, UiEvent, and UiEffect interfaces +interface UiState +interface UiEvent +interface UiEffect + +// Base ViewModel class with generic state, event, and effect +abstract class BaseViewModel( + initialState: State +) : ViewModel() { + + // UI State management + private val _uiState: MutableStateFlow = MutableStateFlow(initialState) + val currentState: State + get() = _uiState.value + + val uiState = _uiState.asStateFlow() + + // Event handling + private val _event: MutableSharedFlow = MutableSharedFlow() + val event = _event.asSharedFlow() + + // Effect handling + private val _effect: Channel = Channel() + val effect = _effect.receiveAsFlow() + + init { + subscribeEvents() + } + + // Abstract function to handle state changes based on events + protected abstract fun reduceState(event: Event) + + // Function to post effects (to be observed) + protected fun postEffect(effect: Effect) { + viewModelScope.launch { + _effect.send(effect) + } + } + + // Subscribe to events and call reduceState + private fun subscribeEvents() { + viewModelScope.launch { + event.collect { + reduceState(it) + } + } + } + + // Update the current state + protected fun updateState(currentState: State) { + _uiState.update { + currentState + } + } + + // Send events to trigger state changes + fun sendEvent(event: Event) { + viewModelScope.launch { _event.emit(event) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/MyInfoViewModelFactory.kt b/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/MyInfoViewModelFactory.kt new file mode 100644 index 00000000..d4445661 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/MyInfoViewModelFactory.kt @@ -0,0 +1,24 @@ +package org.sopt.and.presentation.viewmodelfactory + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.sopt.and.domain.repository.MyHobbyRepository +import org.sopt.and.domain.usecase.MyHobbyUseCase +import org.sopt.and.presentation.myinfo.MyInfoViewModel + +class MyInfoViewModelFactory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return when (modelClass) { + + MyInfoViewModel::class.java -> { + MyInfoViewModel( + MyHobbyUseCase( + getMyHobbyRepository = MyHobbyRepository.create() + ) + ) as T + } + + else -> throw IllegalArgumentException("Unknown ViewModel Class") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/SignInViewModelFactory.kt b/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/SignInViewModelFactory.kt new file mode 100644 index 00000000..ed2c8ff9 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/SignInViewModelFactory.kt @@ -0,0 +1,24 @@ +package org.sopt.and.presentation.viewmodelfactory + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.sopt.and.domain.repository.SignInRepository +import org.sopt.and.domain.usecase.SignInUseCase +import org.sopt.and.presentation.signin.SignInViewModel + +class SignInViewModelFactory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return when (modelClass) { + + SignInViewModel::class.java -> { + SignInViewModel( + SignInUseCase( + signInRepository = SignInRepository.create() + ) + ) as T + } + + else -> throw IllegalArgumentException("Unknown ViewModel Class") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/SignUpViewModelFactory.kt b/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/SignUpViewModelFactory.kt new file mode 100644 index 00000000..59c7ec86 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/viewmodelfactory/SignUpViewModelFactory.kt @@ -0,0 +1,24 @@ +package org.sopt.and.presentation.viewmodelfactory + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.domain.usecase.SignUpUseCase +import org.sopt.and.presentation.signup.SignUpViewModel + +class SignUpViewModelFactory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return when (modelClass) { + + SignUpViewModel::class.java -> { + SignUpViewModel( + SignUpUseCase( + signUpRepository = SignUpRepository.create() + ) + ) as T + } + + else -> throw IllegalArgumentException("Unknown ViewModel Class") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/signin/SignInScreen.kt b/app/src/main/java/org/sopt/and/signin/SignInScreen.kt deleted file mode 100644 index fcf209b9..00000000 --- a/app/src/main/java/org/sopt/and/signin/SignInScreen.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.sopt.and.signin - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch - -import org.sopt.and.R -import org.sopt.and.components.AuthSignButton -import org.sopt.and.viewmodel.SignViewModel - -import org.sopt.and.components.CustomTextField -import org.sopt.and.components.SignTopBar - -@Composable -fun SignInScreen( - signViewModel: SignViewModel, - onNavigateToMain: () -> Unit, - onNavigateToSignUp: () -> Unit -) { - val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - - var isPasswordVisible by remember { mutableStateOf(false) } - - Scaffold( - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .padding(paddingValues) - .padding(15.dp) - ) { - SignTopBar(isSignUp = false) - Spacer(modifier = Modifier.height(10.dp)) - - // 이메일 입력 필드 - CustomTextField( - labelResId = R.string.email_label, - textValue = signViewModel.email, - onTextChanged = { signViewModel.email = it }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(10.dp)) - - // 비밀번호 입력 필드 및 show/hide 버튼 - CustomTextField( - labelResId = R.string.password_label, - textValue = signViewModel.password, - onTextChanged = { signViewModel.password = it }, - isPasswordField = true, - isPasswordVisible = isPasswordVisible, - onPasswordToggle = { isPasswordVisible = !isPasswordVisible }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(20.dp)) - - // 로그인 버튼 - AuthSignButton( - buttonText = "로그인", - validateAction = { - // 입력 검증 - signViewModel.email.text.isNotEmpty() && signViewModel.password.text.isNotEmpty() - }, - onSuccess = { - coroutineScope.launch { - signViewModel.performLogin( - onSuccess = { - // 로그인 성공 시 snackbar 호출 - coroutineScope.launch { - snackbarHostState.showSnackbar("로그인 성공!") - onNavigateToMain() - } - }, - onFailure = { errorMessage -> - // 로그인 실패 시 snackbar 호출 - coroutineScope.launch { - snackbarHostState.showSnackbar(errorMessage) - } - } - ) - } - }, - onFailure = { - coroutineScope.launch { - snackbarHostState.showSnackbar("입력 값을 확인해주세요.") - } - } - ) - - Spacer(modifier = Modifier.height(20.dp)) - - // 하단 링크 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - Text( - text = "아이디 찾기", - color = Color.White - ) - - Text( - text = " | ", - modifier = Modifier.padding(horizontal = 8.dp), - color = Color.White - ) - - Text( - text = "비밀번호 재설정", - color = Color.White - ) - - Text( - text = " | ", - color = Color.White - ) - - Text( - text = "회원가입", - modifier = Modifier.clickable(onClick = onNavigateToSignUp), - color = Color.White - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/signup/SignUpScreen.kt b/app/src/main/java/org/sopt/and/signup/SignUpScreen.kt deleted file mode 100644 index a4ea66d2..00000000 --- a/app/src/main/java/org/sopt/and/signup/SignUpScreen.kt +++ /dev/null @@ -1,208 +0,0 @@ -package org.sopt.and.signup - -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.sopt.and.R -import org.sopt.and.components.AuthSignButton -import org.sopt.and.viewmodel.SignViewModel -import org.sopt.and.components.CustomTextField -import org.sopt.and.components.SignTopBar - -@Composable -fun SignUpScreen( - signViewModel: SignViewModel, - onNavigateToSignIn: () -> Unit -) { - val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - - var snackbarMessage by remember { mutableStateOf(null) } - - LaunchedEffect(snackbarMessage) { - snackbarMessage?.let { - snackbarHostState.showSnackbar(it) - snackbarMessage = null // 메시지 초기화 - } - } - - val coroutineScope = rememberCoroutineScope() - - // 상태값 관리 - var username by remember { mutableStateOf(TextFieldValue("")) } - var password by remember { mutableStateOf(TextFieldValue("")) } - var hobby by remember { mutableStateOf(TextFieldValue("")) } - - var usernameError by remember { mutableStateOf(false) } - var passwordError by remember { mutableStateOf(false) } - var hobbyError by remember { mutableStateOf(false) } - - Scaffold( - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .padding(paddingValues) - .padding(15.dp) - ) { - SignUpTobBar (onNavigateToSignIn) - Spacer(modifier = Modifier.height(20.dp)) - SignTopBar(isSignUp = true) - Spacer(modifier = Modifier.height(30.dp)) - - // Username 입력 필드 - CustomTextField( - labelResId = R.string.username_label, - textValue = username, - onTextChanged = { - username = it - usernameError = username.text.length > 8 - }, - showHint = true, - hintResId = R.string.sign_up_username_hint, - modifier = Modifier.fillMaxWidth() - ) - if (usernameError) { - Text( - text = "8자보다 크면 안됩니다.", - color = Color.Red, - modifier = Modifier.padding(top = 4.dp) - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - // Password 입력 필드 - CustomTextField( - labelResId = R.string.password_label, - textValue = password, - onTextChanged = { - password = it - passwordError = password.text.length > 8 - }, - isPasswordField = true, - isPasswordVisible = signViewModel.isPasswordVisible, - onPasswordToggle = { - signViewModel.isPasswordVisible = !signViewModel.isPasswordVisible - }, - showHint = true, - hintResId = R.string.sign_up_password_hint, - modifier = Modifier.fillMaxWidth() - ) - if (passwordError) { - Text( - text = "8자보다 크면 안됩니다.", - color = Color.Red, - modifier = Modifier.padding(top = 4.dp) - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - // Hobby 입력 필드 - CustomTextField( - labelResId = R.string.hobby_label, - textValue = hobby, - onTextChanged = { - hobby = it - hobbyError = hobby.text.length > 8 - }, - showHint = true, - hintResId = R.string.sign_up_hobby_hint, - modifier = Modifier.fillMaxWidth() - ) - if (hobbyError) { - Text( - text = "8자보다 크면 안됩니다.", - color = Color.Red, - modifier = Modifier.padding(top = 4.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - // 회원가입 버튼 - AuthSignButton( - buttonText = "회원가입", - validateAction = { - // 전체 유효성 검사 - - - // 모든 조건을 만족해야 회원가입 요청 실행 - !usernameError && !passwordError && !hobbyError - }, - onSuccess = { - signViewModel.performSignUp( - username.text, - password.text, - hobby.text, - onSuccess = { - coroutineScope.launch { - withContext(Dispatchers.Main) { - snackbarHostState.showSnackbar("회원가입 성공") // 메인 스레드에서 호출 - } - } - onNavigateToSignIn() - }, - onFailure = { errorMessage -> - coroutineScope.launch { - withContext(Dispatchers.Main) { - snackbarHostState.showSnackbar(errorMessage) // 메인 스레드에서 호출 - } - } - } - ) - }, - onFailure = { - coroutineScope.launch { - withContext(Dispatchers.Main) { - snackbarHostState.showSnackbar("입력 값을 확인해주세요.") // 메인 스레드에서 호출 - } - } - } - ) - } - } -} - -@Composable -fun SignUpTobBar(onNavigateToSignIn: () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "회원가입", - color = Color.White, - fontSize = 20.sp, - textAlign = TextAlign.Center - ) - Text( - text = "❌", - color = Color.White, - modifier = Modifier - .align(Alignment.CenterEnd) - .clickable { onNavigateToSignIn() } - ) - } -} - - diff --git a/app/src/main/java/org/sopt/and/ui/theme/Color.kt b/app/src/main/java/org/sopt/and/ui/theme/Color.kt index ecdcf5e3..0e47d672 100644 --- a/app/src/main/java/org/sopt/and/ui/theme/Color.kt +++ b/app/src/main/java/org/sopt/and/ui/theme/Color.kt @@ -8,4 +8,32 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +val Black100 = Color(0xFF1B1B1B) +val Grey100 = Color(0xFF252525) +val Grey200 = Color(0xFFa8a8a8) +val White100 = Color(0xFFF2F2F2) +val Blue100 = Color(0xFF4557F0) + +val WavveDisabled = Color(0xFF717171) + +val WavvePrimary = Color(0xFF1351F9) +val WavveBg = Color(0xFF121212) +val BottomNavigationItemUnselected = Color(0xFFA5A5A5) + +val White = Color(0xFFFFFFFF) +val Black = Color(0xFF000000) + +val Gray = Color(0xFFF0F0F0) +val Gray1 = Color(0xFFF1F1F1) +val Gray2 = Color(0xFFDDDDDD) +val Gray3 = Color(0xFF8B8B8B) +val Gray4 = Color(0xFF434343) +val Gray5 = Color(0xFF252525) + +val KaKaoBg = Color(0xFFF6E405) +val TBg = Color(0xFF3617CD) +val NaverBg = Color(0xFF27AF11) +val AppleBg = Color(0xFFFFFFFF) +val FacebookBg = Color(0xFF3A5899) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/viewmodel/SignViewModel.kt b/app/src/main/java/org/sopt/and/viewmodel/SignViewModel.kt deleted file mode 100644 index 833f4e08..00000000 --- a/app/src/main/java/org/sopt/and/viewmodel/SignViewModel.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.sopt.and.viewmodel - -import android.app.Application -import android.content.SharedPreferences -import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.sopt.and.api.HobbyService -import org.sopt.and.api.LoginService -import org.sopt.and.api.UserRegistrationService -import org.sopt.and.dto.RequestLoginData -import org.sopt.and.dto.RequestUserRegistrationData -import org.sopt.and.dto.ResponseUserRegistration -import javax.inject.Inject - -@HiltViewModel -class SignViewModel @Inject constructor( - application: Application, - private val userRegistrationService: UserRegistrationService, // Hilt로 주입 - private val loginService: LoginService, // Hilt로 주입 - private val hobbyService: HobbyService // Hilt로 주입 -) : AndroidViewModel(application) { - - private val preferences: SharedPreferences by lazy { - application.getSharedPreferences("user_prefs", Application.MODE_PRIVATE) - } - - var email by mutableStateOf(TextFieldValue("")) // TextFieldValue 사용 - var password by mutableStateOf(TextFieldValue("")) // TextFieldValue 사용 - var hobby by mutableStateOf("") // hobby 추가 - var isPasswordVisible by mutableStateOf(false) - - private var emailError by mutableStateOf("") - private var passwordError by mutableStateOf("") - - /** 회원가입 API 요청 */ - fun performSignUp( - username: String, - password: String, - hobby: String, - onSuccess: (ResponseUserRegistration) -> Unit, - onFailure: (String) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val request = RequestUserRegistrationData(username, password, hobby) - val response = userRegistrationService.postUserRegistration(request) // 주입된 서비스 사용 - if (response.isSuccessful) { - response.body()?.let { onSuccess(it) } ?: onFailure("서버 응답이 비어있습니다.") - } else { - onFailure("회원가입 실패: ${response.code()} - ${response.message()}") - } - } catch (e: Exception) { - onFailure("에러 발생: ${e.localizedMessage}") - } - } - } - - /** 로그인 API 요청 */ - fun performLogin( - onSuccess: () -> Unit, - onFailure: (String) -> Unit - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val request = RequestLoginData( - userName = email.text, - password = password.text - ) - Log.d("SignViewModel", "로그인 요청 데이터: $request") // 요청 데이터 로그 - - val response = loginService.postLogin(request) // Hilt로 주입된 서비스 사용 - if (response.isSuccessful) { - response.body()?.let { - val token = it.result.token - Log.d("SignViewModel", "로그인 성공, 토큰: $token") // 성공 응답 로그 - preferences.edit().putString("auth_token", token).apply() - onSuccess() - } ?: onFailure("서버 응답이 비어있습니다.") - } else { - Log.e( - "SignViewModel", - "로그인 실패: ${response.code()} - ${response.message()} - ${response.errorBody()?.string()}" - ) // 실패 로그 - onFailure("로그인 실패: ${response.code()} - ${response.message()}") - } - } catch (e: Exception) { - Log.e("SignViewModel", "로그인 요청 중 에러 발생: ${e.localizedMessage}", e) // 예외 로그 - onFailure("에러 발생: ${e.localizedMessage}") - } - } - } - - /** 취미 조회 API 요청 */ - fun fetchHobby( - onSuccess: () -> Unit, - onFailure: (String) -> Unit - ) { - val token = preferences.getString("auth_token", null) - if (token.isNullOrEmpty()) { - onFailure("유효한 토큰이 없습니다.") - return - } - - viewModelScope.launch(Dispatchers.IO) { - try { - val response = hobbyService.getHobby(token) - if (response.isSuccessful) { - response.body()?.let { - hobby = it.result.hobby // 상태값 업데이트 - Log.d("SignViewModel", "취미 조회 성공: $hobby") - onSuccess() - } ?: onFailure("서버 응답이 비어있습니다.") - } else { - Log.e( - "SignViewModel", - "취미 조회 실패: ${response.code()} - ${response.message()} - ${response.errorBody()?.string()}" - ) - onFailure("취미 조회 실패: ${response.code()} - ${response.message()}") - } - } catch (e: Exception) { - Log.e("SignViewModel", "취미 조회 요청 중 에러 발생: ${e.localizedMessage}", e) - onFailure("에러 발생: ${e.localizedMessage}") - } - } - } - - /** Validate inputs for sign-up */ - fun validateSignUpInputs(username: String, password: String, hobby: String): Boolean { - return username.length <= 8 && password.length <= 8 && hobby.length <= 8 - } - - /** Validate email and password during sign-in */ - fun validateSignInInputs(): Boolean { - return email.text.isNotEmpty() && password.text.isNotEmpty() - } - - companion object Constants { - const val MIN_PASSWORD_LENGTH = 8 - const val MAX_PASSWORD_LENGTH = 20 - const val PASSWORD_CRITERIA_COUNT = 3 - val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") - val LOWER_CASE_REGEX = Regex("[a-z]") - val UPPER_CASE_REGEX = Regex("[A-Z]") - val DIGIT_REGEX = Regex("[0-9]") - val SPECIAL_REGEX = Regex("[!@#\$%^&*(),.?\\\":{}|<>]") - } - - private fun isPasswordComplexEnough(password: String): Boolean { - val criteriaCount = listOf( - Constants.LOWER_CASE_REGEX.containsMatchIn(password), - Constants.UPPER_CASE_REGEX.containsMatchIn(password), - Constants.DIGIT_REGEX.containsMatchIn(password), - Constants.SPECIAL_REGEX.containsMatchIn(password) - ).count { it } - return criteriaCount >= Constants.PASSWORD_CRITERIA_COUNT - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable/apple_icon.png b/app/src/main/res/drawable/apple_icon.png new file mode 100644 index 00000000..552babf0 Binary files /dev/null and b/app/src/main/res/drawable/apple_icon.png differ diff --git a/app/src/main/res/drawable/facebook_icon.png b/app/src/main/res/drawable/facebook_icon.png new file mode 100644 index 00000000..8be54be7 Binary files /dev/null and b/app/src/main/res/drawable/facebook_icon.png differ diff --git a/app/src/main/res/drawable/ic_cast_24.xml b/app/src/main/res/drawable/ic_cast_24.xml new file mode 100644 index 00000000..7e998416 --- /dev/null +++ b/app/src/main/res/drawable/ic_cast_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_live_tv_24.xml b/app/src/main/res/drawable/ic_live_tv_24.xml new file mode 100644 index 00000000..e878e129 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_tv_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/kakao_talk_icon.png b/app/src/main/res/drawable/kakao_talk_icon.png new file mode 100644 index 00000000..90f7a6ff Binary files /dev/null and b/app/src/main/res/drawable/kakao_talk_icon.png differ diff --git a/app/src/main/res/drawable/logo_apple.jpg b/app/src/main/res/drawable/logo_apple.jpg deleted file mode 100644 index 1b4d5a39..00000000 Binary files a/app/src/main/res/drawable/logo_apple.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/logo_facebook.jpg b/app/src/main/res/drawable/logo_facebook.jpg deleted file mode 100644 index bd29916b..00000000 Binary files a/app/src/main/res/drawable/logo_facebook.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/logo_kakao.jpg b/app/src/main/res/drawable/logo_kakao.jpg deleted file mode 100644 index 6931b374..00000000 Binary files a/app/src/main/res/drawable/logo_kakao.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/logo_naver.jpg b/app/src/main/res/drawable/logo_naver.jpg deleted file mode 100644 index dea9c1a0..00000000 Binary files a/app/src/main/res/drawable/logo_naver.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/logo_wavve.jpg b/app/src/main/res/drawable/logo_wavve.jpg deleted file mode 100644 index 697eee38..00000000 Binary files a/app/src/main/res/drawable/logo_wavve.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/naver_icon.jpeg b/app/src/main/res/drawable/naver_icon.jpeg new file mode 100644 index 00000000..de5277c5 Binary files /dev/null and b/app/src/main/res/drawable/naver_icon.jpeg differ diff --git a/app/src/main/res/drawable/t_world_icon.png b/app/src/main/res/drawable/t_world_icon.png new file mode 100644 index 00000000..4a0fa45a Binary files /dev/null and b/app/src/main/res/drawable/t_world_icon.png differ diff --git a/app/src/main/res/drawable/wavve_logo.png b/app/src/main/res/drawable/wavve_logo.png new file mode 100644 index 00000000..46949c00 Binary files /dev/null and b/app/src/main/res/drawable/wavve_logo.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb5a9d97..a57cddee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - AND-ANDROID + 이메일 주소 또는 아이디 비밀번호 로그인 @@ -20,4 +20,172 @@ 취미를 입력해주세요. 취미를 입력해주세요. + + + + 검색 + My + + + + wavve + | + hide + show + 카카오 계정과 연동하는 아이콘 + T World 계정과 연동하는 아이콘 + 네이버 계정과 연동하는 아이콘 + 페이스북 계정과 연동하는 아이콘 + 애플 계정과 연동하는 아이콘 + • SNS계정으로 간편하게 가입하여 서비스를 이용하실 수 있습니다.\n + 기존 POOQ 계정 또는 Wavve 계정과는 연동되지 않으니 이용에 참고하세요. + + + + 회원가입 + wavve Username 설정 + wavve 비밀번호 설정 + 취미입력 ex)soccer + Wavve 회원가입 + 회원가입에 성공했습니다~ + Username이 중복되었습니다! + username, 비밀번호, 취미는 8자 이하로 입력해주세요! + 비밀번호는 8자 이하로 입력해 주세요 + Username는 8자 이하로 입력해 주세요 + Username 입력시 주의사항 + 비밀번호 입력시 주의사항 + 회원가입 화면 닫기 + 이메일과 비밀번호만으로\nWavve를 즐길 수 있어요! + 또는 다른 서비스 계정으로 가입 + + + 로그인 + 아이디 찾기 + 비밀번호 재설정 + 회원가입 + Username + 비밀번호 + 로그인 성공. 환영합니다~ + 로그인 실패. 비밀번호는 8자리 이하입니다! + 로그인 실패. 비밀번호가 일치하지 않습니다! + 이전 화면으로 가기 + 또는 다른 서비스 계정으로 로그인 + + + + 내 프로필 + 알림 + 설정 + 구매하기 > + 현재 보유하신 이용권이 없습니다. + 첫 결제 시 첫 달 100원! + 전체 시청내역 + 시청내역이 없어요. + 관심 프로그램 + 관심 프로그램이 없어요. + + + 믿고 보는 웨이브 에디터 추천작 + 오늘의 TOP 20 + %1$s  + | %1$s + + + + + + 뉴클래식 + 드라마 + 예능 + 영화 + 애니 + 해외시리즈 + %1$s + + + + + 사용자 이름 + 비밀번호 + 로그인 + 아이디 찾기 + | + 비밀번호 재설정 + 회원가입 + 또는 다른 서비스 계정으로 로그인 + SNS계정으로 간편하게 가입하여 서비스를 이용하실 수 있습니다. 기존 POOQ 계정 또는 Wavve 계정과는 연동되지 않으니 이용에 참고하세요. + 로그인 성공 + 아이디와 비밀번호를 입력해주세요 + 아이디 혹은 비밀번호가 틀렸습니다. + 닫기 + hide + show + + + 회원가입 + 입력하신 아이디가 형식에 맞지 않습니다. 아이디는 8자 이내로 입력해주세요. + 아이디는 8자 이내로 입력해주세요. + 입력하신 비밀번호가 형식에 맞지 않습니다. 비밀번호는 8자 이내로 입력해주세요. + 비밀번호는 8자 이내로 입력해주세요. + 입력하신 취미가 형식에 맞지 않습니다. 취미는 8자 이내로 입력해 주세요. + 취미는 8자 이내로 입력해주세요. + Wavve 비밀번호 설정 + 또는 다른 서비스 계정으로 로그인 + SNS계정으로 간편하게 가입하여 서비스를 이용하실 수 있습니다. 기존 POOQ 계정 또는 Wavve 계정과는 연동되지 않으니 이용에 참고하세요. + 회원가입을 완료하였습니다. + 아이디, 비밀번호, 취미를 양식에 맞게 작성해주세요 + Wavve 회원가입 + 아이디와 비밀번호, 취미 만으로\nWavve를 즐길 수 있어요! + hide + show + 아이디 + 취미 + + KaKao Account + T Account + Naver Account + FaceBook Account + Apple Account + + + Logo + back + Close + + + profile image + notification + setting + 첫 결제 시 첫 달 100원! + 현재 보유하신 이용권이 없습니다. + 전체 시청내역 + 시청내역이 없어요. + 관심 프로그램 + 관심 프로그램이 없어요. + 로그아웃 + 로그아웃 되었습니다. + + 구매하기 + purchase + No Content + more + + + 회원가입 + + MY + 검색 + 뉴클래식 + 드라마 + 예능 + 영화 + 애니 + 해외시리즈 + 시사교양 + 키즈 + 영화플러스 + live button + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8952fb35..a064be53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,11 +3,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false - id("com.google.dagger.hilt.android") version "2.51.1" apply false -} - -buildscript { - dependencies { - classpath(libs.hilt.android.gradle.plugin) - } -} + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ff8a150..af7ed861 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,43 +1,30 @@ [versions] -agp = "8.7.1" -hiltAndroid = "2.51.1" -hiltAndroidGradlePlugin = "2.38.1" -hiltCompiler = "2.51.1" -hiltNavigationCompose = "1.2.0" +agp = "8.7.0" kotlin = "2.0.0" -coreKtx = "1.13.1" +coreKtx = "1.10.1" junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -lifecycleRuntimeKtx = "2.8.6" -activityCompose = "1.9.3" -composeBom = "2024.10.00" -lifecycleViewmodelCompose = "2.8.6" -material = "1.7.4" -navigationCompose = "2.8.3" -navigationRuntimeKtx = "2.8.3" -# Third Party +junitVersion = "1.1.5" +espressoCore = "3.5.1" +lifecycleRuntimeKtx = "2.6.1" +activityCompose = "1.8.0" +composeBom = "2024.04.01" +androidxComposeNavigation = "2.8.2" okhttp = "4.11.0" retrofit = "2.9.0" retrofitKotlinSerializationConverter = "1.0.0" -kotlinxSerializationJson = "1.6.3" -runtimeLivedata = "1.7.5" +kotlinxSerializationJson = "1.7.3" +hilt = "2.51.1" +hilt-navigation-compose = "1.2.0" +ksp = "2.0.0-1.0.22" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } -androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidGradlePlugin" } -hilt-android-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hiltAndroidGradlePlugin" } -hilt-android-v2511 = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } -hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltAndroidGradlePlugin" } -hilt-compiler-v2511 = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltCompiler" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -47,7 +34,11 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } +androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +hilt-navigation-compose = {group="androidx.hilt", name="hilt-navigation-compose", version.ref = "hilt-navigation-compose"} # Third Party okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp" } @@ -55,10 +46,11 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinSerializationConverter" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } -androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file