diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c26475c..0d48ba7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,6 +52,9 @@ android { buildConfig = true compose = true } + androidResources { + noCompress += "mp4" + } } dependencies { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d47e8a1..5aa6877 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,16 +30,21 @@ + android:theme="@style/Theme.JapKor.Splash"> + diff --git a/app/src/main/java/com/apptive/japkor/LoginCallbackActivity.kt b/app/src/main/java/com/apptive/japkor/LoginCallbackActivity.kt index c5aa01c..823d890 100644 --- a/app/src/main/java/com/apptive/japkor/LoginCallbackActivity.kt +++ b/app/src/main/java/com/apptive/japkor/LoginCallbackActivity.kt @@ -81,9 +81,9 @@ class LoginCallbackActivity : ComponentActivity() { val startRoute = when (userStatus) { UserStatus.INCOMPLETE_PROFILE -> Screen.RequiredInfo.route + UserStatus.CONNECTING -> Screen.Home.route UserStatus.PENDING_APPROVAL, UserStatus.APPROVED, - UserStatus.CONNECTING, UserStatus.CONNECTED, UserStatus.BLACKLISTED -> Screen.RequiredInfoComplete.route null -> when (needsProfileCompletion) { diff --git a/app/src/main/java/com/apptive/japkor/SplashActivity.kt b/app/src/main/java/com/apptive/japkor/SplashActivity.kt new file mode 100644 index 0000000..72eaca0 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/SplashActivity.kt @@ -0,0 +1,48 @@ +package com.apptive.japkor + +import android.content.Intent +import android.media.MediaPlayer +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import com.apptive.japkor.widget.FullscreenVideoView + +class SplashActivity : ComponentActivity() { + private var hasNavigated = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_splash) + + val videoView = findViewById(R.id.splashVideo) + val videoUri = Uri.parse("android.resource://${packageName}/${R.raw.n_splash}") + videoView.setVideoURI(videoUri) + videoView.setOnPreparedListener { mediaPlayer -> + videoView.updateVideoSize(mediaPlayer.videoWidth, mediaPlayer.videoHeight) + mediaPlayer.isLooping = false + mediaPlayer.setVideoScalingMode( + MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + ) + videoView.start() + } + videoView.setOnCompletionListener { + videoView.stopPlayback() + navigateToMain() + } + videoView.setOnErrorListener { _, _, _ -> + navigateToMain() + true + } + } + + private fun navigateToMain() { + if (hasNavigated) return + hasNavigated = true + val intent = Intent(this, MainActivity::class.java) + intent.putExtras(this.intent) + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + startActivity(intent) + overridePendingTransition(0, 0) + finish() + } +} diff --git a/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt b/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt new file mode 100644 index 0000000..56fc795 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt @@ -0,0 +1,15 @@ +package com.apptive.japkor.data.api + +import com.apptive.japkor.data.model.MatchingResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface MatchingService { + @GET("members/matchings/female") + fun getFemaleMatchings(): Call> + + @POST("members/matchings/{matchingId}/select") + fun selectMatching(@Path("matchingId") matchingId: Long): Call +} diff --git a/app/src/main/java/com/apptive/japkor/data/api/ServiceFactory.kt b/app/src/main/java/com/apptive/japkor/data/api/ServiceFactory.kt index 5abddce..552d5c7 100644 --- a/app/src/main/java/com/apptive/japkor/data/api/ServiceFactory.kt +++ b/app/src/main/java/com/apptive/japkor/data/api/ServiceFactory.kt @@ -8,4 +8,8 @@ object ServiceFactory { val requiredInfoApiService: RequiredInfoApiService by lazy { ApiClient.retrofit.create(RequiredInfoApiService::class.java) } -} \ No newline at end of file + + val matchingService: MatchingService by lazy { + ApiClient.retrofit.create(MatchingService::class.java) + } +} diff --git a/app/src/main/java/com/apptive/japkor/data/local/DataStoreManager.kt b/app/src/main/java/com/apptive/japkor/data/local/DataStoreManager.kt index e5f756a..6241f35 100644 --- a/app/src/main/java/com/apptive/japkor/data/local/DataStoreManager.kt +++ b/app/src/main/java/com/apptive/japkor/data/local/DataStoreManager.kt @@ -87,6 +87,15 @@ class DataStoreManager(private val context: Context) { } } + suspend fun clearUserInfo() { + context.dataStore.edit { prefs -> + prefs.remove(KEY_MEMBER_ID) + prefs.remove(KEY_NAME) + prefs.remove(KEY_TOKEN) + prefs.remove(KEY_STATUS) + } + } + suspend fun clear() { context.dataStore.edit { it.clear() } } diff --git a/app/src/main/java/com/apptive/japkor/data/model/Matching.kt b/app/src/main/java/com/apptive/japkor/data/model/Matching.kt new file mode 100644 index 0000000..5153742 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/data/model/Matching.kt @@ -0,0 +1,13 @@ +package com.apptive.japkor.data.model + +data class MatchingResponse( + val matchingId: Long, + val maleMemberId: Long, + val maleName: String, + val maleEmail: String, + val height: Int?, + val weight: Int?, + val residenceArea: String?, + val matchingOrder: Int, + val status: String +) diff --git a/app/src/main/java/com/apptive/japkor/navigation/AppNavHost.kt b/app/src/main/java/com/apptive/japkor/navigation/AppNavHost.kt index c2136ce..635d0b1 100644 --- a/app/src/main/java/com/apptive/japkor/navigation/AppNavHost.kt +++ b/app/src/main/java/com/apptive/japkor/navigation/AppNavHost.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.apptive.japkor.ui.main.MainRouteScreen import com.apptive.japkor.ui.language.LanguageScreen import com.apptive.japkor.ui.login.LoginScreen import com.apptive.japkor.ui.requiredinfo.RequiredInfoCompleteScreen @@ -19,6 +20,8 @@ sealed class Screen(val route: String) { object Language : Screen("language") object SignUp : Screen("signup") + object Home : Screen("home") + object RequiredInfo : Screen("requiredinfo") object RequiredInfoComplete : Screen("requiredinfo_complete") diff --git a/app/src/main/java/com/apptive/japkor/ui/localization/Localization.kt b/app/src/main/java/com/apptive/japkor/ui/localization/Localization.kt index 125f318..b804ef2 100644 --- a/app/src/main/java/com/apptive/japkor/ui/localization/Localization.kt +++ b/app/src/main/java/com/apptive/japkor/ui/localization/Localization.kt @@ -33,6 +33,9 @@ object AppLocalizer { "로그인 성공! 환영합니다." to "ログイン成功!ようこそ。", "로그인 실패! 이메일과 비밀번호를 확인해주세요." to "ログイン失敗。メールアドレスとパスワードを確認してください。", "로그인" to "ログイン", + "로그아웃" to "ログアウト", + "정말 로그아웃 하시겠어요?" to "本当にログアウトしますか?", + "취소" to "キャンセル", "아이디 찾기" to "IDを探す", "비밀번호 찾기" to "パスワードを探す", "회원가입" to "会員登録", diff --git a/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt new file mode 100644 index 0000000..a8c60d9 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt @@ -0,0 +1,301 @@ +package com.apptive.japkor.ui.main + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.apptive.japkor.R +import com.apptive.japkor.data.local.DataStoreManager +import com.apptive.japkor.data.local.TokenProvider +import com.apptive.japkor.navigation.Screen +import com.apptive.japkor.ui.components.CustomText +import com.apptive.japkor.ui.components.CustomTextType +import com.apptive.japkor.ui.components.LoadingDialog +import com.apptive.japkor.ui.components.LocalToastManager +import com.apptive.japkor.ui.components.ToastType +import com.apptive.japkor.ui.main.chat.ChattingScreen +import com.apptive.japkor.ui.main.home.HomeScreen +import com.apptive.japkor.ui.main.home.HomeUiEvent +import com.apptive.japkor.ui.main.home.HomeViewModel +import com.apptive.japkor.ui.main.mypage.MypageScreen +import com.apptive.japkor.ui.main.setting.SettingScreen +import com.apptive.japkor.ui.theme.CustomColor +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +private data class HomeTab( + val route: String, + val label: String, + val iconResId: Int, + val selectedIconResId: Int +) + +private object HomeRoute { + const val Chat = "home_chat" + const val Main = "home_main" + const val MyPage = "home_mypage" + const val Setting = "home_setting" +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +fun MainRouteScreen( + navController: NavController, + viewModel: HomeViewModel = viewModel() +) { + val homeNavController = rememberNavController() + val navBackStackEntry by homeNavController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route ?: HomeRoute.Main + val isMainScreen = currentRoute == HomeRoute.Main + val isSettingScreen = currentRoute == HomeRoute.Setting + val showBottomBar = currentRoute in setOf(HomeRoute.Chat, HomeRoute.Main, HomeRoute.MyPage) + val tabs = listOf( + HomeTab( + route = HomeRoute.Chat, + label = "채팅", + iconResId = R.drawable.ic_chat, + selectedIconResId = R.drawable.ic_chat_filled + ), + HomeTab( + route = HomeRoute.Main, + label = "홈", + iconResId = R.drawable.ic_n, + selectedIconResId = R.drawable.ic_n + ), + HomeTab( + route = HomeRoute.MyPage, + label = "내정보", + iconResId = R.drawable.ic_user, + selectedIconResId = R.drawable.ic_user_filled + ) + ) + + var showLogoutDialog by remember { mutableStateOf(false) } + val toastManager = LocalToastManager.current + val uiState by viewModel.uiState.collectAsState() + val matchings = uiState.matchings + val selectedMatching = uiState.selectedMatching + val pagerState = rememberPagerState(pageCount = { matchings.size }) + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager(context) } + val coroutineScope = rememberCoroutineScope() + + BackHandler(enabled = isMainScreen && selectedMatching != null) { + viewModel.hideDetails() + } + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is HomeUiEvent.ShowToast -> { + when (event.type) { + ToastType.INFO -> toastManager.info(event.message) + ToastType.SUCCESS -> toastManager.success(event.message) + ToastType.ERROR -> toastManager.error(event.message) + } + } + } + } + } + + LaunchedEffect(matchings.size) { + if (matchings.isNotEmpty() && pagerState.currentPage >= matchings.size) { + pagerState.scrollToPage(0) + } + } + + if (uiState.isLoading) { + LoadingDialog() + } + + Scaffold( + containerColor = CustomColor.white, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + when { + isSettingScreen -> { + IconButton(onClick = { homeNavController.popBackStack() }) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = "뒤로가기" + ) + } + } + isMainScreen && selectedMatching != null -> { + IconButton(onClick = { viewModel.hideDetails() }) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = "뒤로가기" + ) + } + } + } + }, + actions = { + if (!isSettingScreen) { + IconButton(onClick = { homeNavController.navigate(HomeRoute.Setting) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = "설정" + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = CustomColor.white + ) + ) + }, + bottomBar = { + if (showBottomBar) { + NavigationBar( + containerColor = CustomColor.white + ) { + tabs.forEach { tab -> + val isSelected = currentRoute == tab.route + NavigationBarItem( + selected = isSelected, + onClick = { + homeNavController.navigate(tab.route) { + popUpTo(homeNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + painter = painterResource( + if (isSelected) tab.selectedIconResId else tab.iconResId + ), + contentDescription = tab.label + ) + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = CustomColor.primary600, + selectedTextColor = CustomColor.primary600, + unselectedIconColor = CustomColor.gray400, + unselectedTextColor = CustomColor.gray400, + indicatorColor = CustomColor.white + ) + ) + } + } + } + } + ) { innerPadding -> + NavHost( + navController = homeNavController, + startDestination = HomeRoute.Main, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + composable(HomeRoute.Chat) { + ChattingScreen() + } + composable(HomeRoute.Main) { + HomeScreen( + uiState = uiState, + pagerState = pagerState, + onShowDetails = { viewModel.showDetails(it) }, + onNoMatch = { viewModel.noMatchSelected() }, + onConfirm = { viewModel.selectMatching(it) }, + modifier = Modifier.fillMaxSize() + ) + } + composable(HomeRoute.MyPage) { + MypageScreen() + } + composable(HomeRoute.Setting) { + SettingScreen( + onLogoutClick = { showLogoutDialog = true }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { + CustomText( + text = "로그아웃", + type = CustomTextType.title, + color = CustomColor.black + ) + }, + text = { + CustomText( + text = "정말 로그아웃 하시겠어요?", + type = CustomTextType.body, + color = CustomColor.gray400 + ) + }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + TokenProvider.clearToken() + coroutineScope.launch { + dataStoreManager.clearUserInfo() + } + navController.navigate(Screen.Login.route) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + } + ) { + CustomText( + text = "로그아웃", + type = CustomTextType.body, + color = CustomColor.primary600 + ) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + CustomText( + text = "취소", + type = CustomTextType.body, + color = CustomColor.gray400 + ) + } + } + ) + } +} diff --git a/app/src/main/java/com/apptive/japkor/ui/main/chat/ChattingScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/chat/ChattingScreen.kt new file mode 100644 index 0000000..4470072 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/chat/ChattingScreen.kt @@ -0,0 +1,24 @@ +package com.apptive.japkor.ui.main.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.apptive.japkor.ui.components.CustomText +import com.apptive.japkor.ui.components.CustomTextType +import com.apptive.japkor.ui.theme.CustomColor + +@Composable +fun ChattingScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CustomText( + text = "매칭을 기다려주세요..", + type = CustomTextType.body, + color = CustomColor.gray400 + ) + } +} diff --git a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt new file mode 100644 index 0000000..93c6096 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt @@ -0,0 +1,387 @@ +package com.apptive.japkor.ui.main.home + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.apptive.japkor.R +import com.apptive.japkor.data.model.MatchingResponse +import com.apptive.japkor.ui.components.CustomText +import com.apptive.japkor.ui.components.CustomTextType +import com.apptive.japkor.ui.theme.CustomColor +import kotlin.math.absoluteValue + +@Composable +fun HomeScreen( + uiState: HomeUiState, + pagerState: PagerState, + onShowDetails: (MatchingResponse) -> Unit, + onNoMatch: () -> Unit, + onConfirm: (Long) -> Unit, + modifier: Modifier = Modifier +) { + val matchings = uiState.matchings + val selectedMatching = uiState.selectedMatching + + Box( + modifier = modifier + ) { + when { + selectedMatching != null -> { + MatchingDetailContent( + matching = selectedMatching, + onConfirm = { onConfirm(selectedMatching.matchingId) } + ) + } + uiState.isWaiting || matchings.isEmpty() -> { + WaitingContent(modifier = Modifier.fillMaxSize()) + } + else -> { + MatchingCarouselContent( + matchings = matchings, + pagerState = pagerState, + onShowDetails = onShowDetails, + onNoMatch = onNoMatch + ) + } + } + } +} + +@Composable +private fun WaitingContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + CustomText( + text = "매칭 진행 중입니다..", + type = CustomTextType.body, + color = CustomColor.gray400 + ) + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun MatchingCarouselContent( + matchings: List, + pagerState: PagerState, + onShowDetails: (MatchingResponse) -> Unit, + onNoMatch: () -> Unit +) { + val currentMatching = matchings.getOrNull(pagerState.currentPage) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CustomText( + text = "매칭된 남성", + type = CustomTextType.title, + color = CustomColor.black + ) + Spacer(modifier = Modifier.height(20.dp)) + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 32.dp), + pageSpacing = 16.dp, + modifier = Modifier + .fillMaxWidth() + .height(360.dp) + ) { page -> + val matching = matchings[page] + val pageOffset = ( + (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + ).absoluteValue + val scale = lerp(0.92f, 1f, 1f - pageOffset.coerceIn(0f, 1f)) + + MatchingCard( + matching = matching, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + PagerIndicator( + total = matchings.size, + current = pagerState.currentPage + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { currentMatching?.let { onShowDetails(it) } }, + enabled = currentMatching != null, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.primary600, + disabledContainerColor = CustomColor.primary300 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "이 분 프로필이 궁금해요", + type = CustomTextType.body, + color = Color.White + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onNoMatch, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.gray100 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "마음에 드는 상대가 없어요", + type = CustomTextType.body, + color = CustomColor.black + ) + } + } +} + +@Composable +private fun MatchingCard( + matching: MatchingResponse, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = CustomColor.gray100) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(160.dp) + .background(CustomColor.primary100, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_user), + contentDescription = "프로필 이미지", + tint = CustomColor.primary600, + modifier = Modifier.size(72.dp) + ) + } + Spacer(modifier = Modifier.height(24.dp)) + CustomText( + text = matching.maleName, + type = CustomTextType.headline, + color = CustomColor.black, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + CustomText( + text = "프로필을 확인해보세요", + type = CustomTextType.body, + color = CustomColor.gray400 + ) + } + } +} + +@Composable +private fun PagerIndicator(total: Int, current: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + repeat(total) { index -> + val color = if (index == current) CustomColor.primary600 else CustomColor.gray200 + Box( + modifier = Modifier + .size(8.dp) + .background(color, CircleShape) + ) + if (index != total - 1) { + Spacer(modifier = Modifier.width(6.dp)) + } + } + } +} + +@Composable +private fun MatchingDetailContent( + matching: MatchingResponse, + onConfirm: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + ProfileHeader(matching = matching) + } + item { + DetailCard(matching = matching) + } + } + Button( + onClick = onConfirm, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.primary600 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "마음에 들어요 매칭해주세요", + type = CustomTextType.body, + color = Color.White + ) + } + } +} + +@Composable +private fun ProfileHeader(matching: MatchingResponse) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(72.dp) + .background(CustomColor.primary100, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_user), + contentDescription = "프로필 이미지", + tint = CustomColor.primary600, + modifier = Modifier.size(36.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + CustomText( + text = matching.maleName, + type = CustomTextType.headline, + color = CustomColor.black + ) + CustomText( + text = matching.maleEmail, + type = CustomTextType.body, + color = CustomColor.gray400 + ) + } + } +} + +@Composable +private fun DetailCard(matching: MatchingResponse) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = CustomColor.gray100) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CustomText( + text = "상세 정보", + type = CustomTextType.title, + color = CustomColor.black + ) + DetailItem(label = "매칭 ID", value = matching.matchingId.toString()) + DetailItem(label = "남성 회원 ID", value = matching.maleMemberId.toString()) + DetailItem(label = "이름", value = matching.maleName) + DetailItem(label = "이메일", value = matching.maleEmail) + DetailItem(label = "키", value = formatHeight(matching.height)) + DetailItem(label = "몸무게", value = formatWeight(matching.weight)) + DetailItem(label = "거주지역", value = formatText(matching.residenceArea)) + DetailItem(label = "매칭 순서", value = matching.matchingOrder.toString()) + DetailItem(label = "상태", value = matching.status) + } + } +} + +@Composable +private fun DetailItem(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth()) { + CustomText( + text = label, + type = CustomTextType.label, + color = CustomColor.gray400 + ) + CustomText( + text = value, + type = CustomTextType.body, + color = CustomColor.black + ) + } +} + +private fun formatHeight(value: Int?): String { + return value?.let { "${it}cm" } ?: "미입력" +} + +private fun formatWeight(value: Int?): String { + return value?.let { "${it}kg" } ?: "미입력" +} + +private fun formatText(value: String?): String { + return value?.takeIf { it.isNotBlank() } ?: "미입력" +} diff --git a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt new file mode 100644 index 0000000..455f595 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt @@ -0,0 +1,109 @@ +package com.apptive.japkor.ui.main.home + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.apptive.japkor.data.api.MatchingService +import com.apptive.japkor.data.api.ServiceFactory +import com.apptive.japkor.data.model.MatchingResponse +import com.apptive.japkor.ui.components.ToastType +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.awaitResponse + +sealed class HomeUiEvent { + data class ShowToast(val message: String, val type: ToastType = ToastType.ERROR) : HomeUiEvent() +} + +data class HomeUiState( + val isLoading: Boolean = false, + val matchings: List = emptyList(), + val selectedMatching: MatchingResponse? = null, + val isWaiting: Boolean = false +) + +class HomeViewModel( + private val matchingService: MatchingService = ServiceFactory.matchingService +) : ViewModel() { + + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + init { + fetchFemaleMatchings() + } + + fun fetchFemaleMatchings() { + if (_uiState.value.isWaiting) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + runCatching { + matchingService.getFemaleMatchings().awaitResponse() + }.onSuccess { response -> + Log.d(TAG, "getFemaleMatchings success=${response.isSuccessful} code=${response.code()}") + if (response.isSuccessful) { + val data = response.body().orEmpty() + if (data.isEmpty()) { + _uiState.value = HomeUiState(isWaiting = true) + } else { + _uiState.value = HomeUiState(matchings = data) + } + } else { + _uiState.value = HomeUiState(isWaiting = true) + _events.tryEmit(HomeUiEvent.ShowToast("매칭 목록을 불러오지 못했습니다.")) + } + }.onFailure { throwable -> + Log.e(TAG, "getFemaleMatchings failed", throwable) + _uiState.value = HomeUiState(isWaiting = true) + _events.tryEmit(HomeUiEvent.ShowToast("네트워크 오류로 매칭을 불러올 수 없습니다.")) + } + } + } + + fun showDetails(matching: MatchingResponse) { + _uiState.update { it.copy(selectedMatching = matching) } + } + + fun hideDetails() { + _uiState.update { it.copy(selectedMatching = null) } + } + + fun selectMatching(matchingId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + runCatching { + matchingService.selectMatching(matchingId).awaitResponse() + }.onSuccess { response -> + Log.d(TAG, "selectMatching success=${response.isSuccessful} code=${response.code()}") + if (response.isSuccessful) { + _uiState.value = HomeUiState(isWaiting = true) + } else { + _uiState.update { it.copy(isLoading = false) } + _events.tryEmit(HomeUiEvent.ShowToast("매칭 선택에 실패했습니다.")) + } + }.onFailure { throwable -> + Log.e(TAG, "selectMatching failed", throwable) + _uiState.update { it.copy(isLoading = false) } + _events.tryEmit(HomeUiEvent.ShowToast("네트워크 오류로 매칭을 선택할 수 없습니다.")) + } + } + } + + fun noMatchSelected() { + _uiState.value = HomeUiState(isWaiting = true) + } + + companion object { + private const val TAG = "HomeViewModel" + } +} diff --git a/app/src/main/java/com/apptive/japkor/ui/main/mypage/MypageScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/mypage/MypageScreen.kt new file mode 100644 index 0000000..4d4c23d --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/mypage/MypageScreen.kt @@ -0,0 +1,24 @@ +package com.apptive.japkor.ui.main.mypage + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.apptive.japkor.ui.components.CustomText +import com.apptive.japkor.ui.components.CustomTextType +import com.apptive.japkor.ui.theme.CustomColor + +@Composable +fun MypageScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CustomText( + text = "내정보 페이지입니다.", + type = CustomTextType.body, + color = CustomColor.gray400 + ) + } +} diff --git a/app/src/main/java/com/apptive/japkor/ui/main/setting/SettingScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/setting/SettingScreen.kt new file mode 100644 index 0000000..021aace --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/setting/SettingScreen.kt @@ -0,0 +1,46 @@ +package com.apptive.japkor.ui.main.setting + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.apptive.japkor.ui.components.CustomText +import com.apptive.japkor.ui.components.CustomTextType +import com.apptive.japkor.ui.theme.CustomColor + +@Composable +fun SettingScreen( + onLogoutClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.Top + ) { + Button( + onClick = onLogoutClick, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.primary600 + ) + ) { + CustomText( + text = "로그아웃", + type = CustomTextType.body, + color = Color.White + ) + } + } +} diff --git a/app/src/main/java/com/apptive/japkor/utils/required_info/RequiredInfoMapper.kt b/app/src/main/java/com/apptive/japkor/utils/required_info/RequiredInfoMapper.kt index 47a8671..c325ad7 100644 --- a/app/src/main/java/com/apptive/japkor/utils/required_info/RequiredInfoMapper.kt +++ b/app/src/main/java/com/apptive/japkor/utils/required_info/RequiredInfoMapper.kt @@ -42,7 +42,7 @@ object RequiredInfoMapper { "불교" -> "BUDDHISM" "기독교" -> "CHRISTIANITY" "천주교" -> "CATHOLICISM" - "신토" -> "SHINTO" + "신토" -> "TOISM" "기타" -> "OTHER" else -> null } diff --git a/app/src/main/java/com/apptive/japkor/widget/FullscreenVideoView.kt b/app/src/main/java/com/apptive/japkor/widget/FullscreenVideoView.kt new file mode 100644 index 0000000..d08f861 --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/widget/FullscreenVideoView.kt @@ -0,0 +1,43 @@ +package com.apptive.japkor.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.VideoView + +class FullscreenVideoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : VideoView(context, attrs, defStyleAttr) { + private var videoWidth = 0 + private var videoHeight = 0 + + fun updateVideoSize(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + videoWidth = width + videoHeight = height + requestLayout() + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val viewWidth = MeasureSpec.getSize(widthMeasureSpec) + val viewHeight = MeasureSpec.getSize(heightMeasureSpec) + + if (videoWidth == 0 || videoHeight == 0 || viewWidth == 0 || viewHeight == 0) { + setMeasuredDimension(viewWidth, viewHeight) + return + } + + val viewRatio = viewWidth.toFloat() / viewHeight + val videoRatio = videoWidth.toFloat() / videoHeight + + if (videoRatio > viewRatio) { + val scaledWidth = (viewHeight * videoRatio).toInt() + setMeasuredDimension(scaledWidth, viewHeight) + } else { + val scaledHeight = (viewWidth / videoRatio).toInt() + setMeasuredDimension(viewWidth, scaledHeight) + } + } +} diff --git a/app/src/main/res/drawable/ic_chat.xml b/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000..9141302 --- /dev/null +++ b/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_chat_filled.xml b/app/src/main/res/drawable/ic_chat_filled.xml new file mode 100644 index 0000000..dcfcaaf --- /dev/null +++ b/app/src/main/res/drawable/ic_chat_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_n.xml b/app/src/main/res/drawable/ic_n.xml new file mode 100644 index 0000000..9990e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_n.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..b0e49e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..afa155f --- /dev/null +++ b/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_user_filled.xml b/app/src/main/res/drawable/ic_user_filled.xml new file mode 100644 index 0000000..dbeeba1 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..8ea8fbb --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/raw/n_splash.mp4 b/app/src/main/res/raw/n_splash.mp4 new file mode 100644 index 0000000..64d7076 Binary files /dev/null and b/app/src/main/res/raw/n_splash.mp4 differ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 8882875..7ebb500 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,4 +2,13 @@ +