diff --git a/.editorconfig b/.editorconfig index 4df92550..6dc46cbb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,6 +3,7 @@ root = true [*.{kt,kts}] ktlint_standard_filename = enabled ktlint_standard_no-consecutive-comments = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fabcbf1b..2ff19d2c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,11 @@ dependencies { implementation(libs.androidx.navigation.fragment.ktx) implementation(libs.androidx.navigation.ui.ktx) + // WorkManager (FCM 토큰 전송 예약) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + "ksp"(libs.androidx.hilt.compiler) + implementation(libs.appauth) // Kakao Map diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 86b9abff..ce261c91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -44,6 +45,18 @@ android:name="com.kakao.sdk.AppKey" android:value="${KAKAO_NATIVE_APP_KEY}" /> + + + + + Unit + is TokenRefreshResult.Error -> { + val ex = refresh.exception + val invalidGrant = + ex?.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR && + ex.error == "invalid_grant" + // 세션 정리는 refreshAccessToken() 내부가 함 + return if (FcmTokenSyncPolicy.shouldRetryAfterRefreshFailure(invalidGrant, runAttemptCount)) { + Result.retry() + } else { + Result.failure() + } + } + is TokenRefreshResult.NoRefreshToken, + is TokenRefreshResult.ConfigError, + -> return Result.failure() + } + } + + val syncResult = syncFcmTokenUseCase() + Timber.tag(TAG).d("FCM 토큰 전송 결과: $syncResult (시도 ${runAttemptCount + 1}회)") + + return when (syncResult) { + FcmTokenSyncResult.SUCCESS -> Result.success() + // 로그인 전이거나 보낼 토큰이 없는 상태 - 재시도해도 같으므로 성공 처리 + FcmTokenSyncResult.SKIPPED -> Result.success() + FcmTokenSyncResult.FAILURE -> + if (FcmTokenSyncPolicy.shouldRetry(runAttemptCount)) Result.retry() else Result.failure() + } + } + + companion object { + private const val TAG = "FcmTokenSyncWorker" + const val WORK_NAME = "fcm_token_sync" + private const val BACKOFF_DELAY_SECONDS = 30L + + /** + * 토큰 등록을 예약합니다 + * 대기 중에 토큰이 다시 갱신되면 새 요청으로 바꿔 백오프를 처음부터 다시 셉니다 + * 워커는 실행 시점에 저장소에서 토큰을 읽으므로 마지막 값이 올라갑니다 + */ + fun enqueue(context: Context) { + val constraints = + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BACKOFF_DELAY_SECONDS, + TimeUnit.SECONDS, + ) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + /** 예약된 등록을 취소합니다. 세션이 정리되면 보낼 회원ID가 없어 실행할 이유가 없습니다 */ + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } + } diff --git a/app/src/main/java/com/project200/undabang/main/EntryState.kt b/app/src/main/java/com/project200/undabang/main/EntryState.kt new file mode 100644 index 00000000..4255ecba --- /dev/null +++ b/app/src/main/java/com/project200/undabang/main/EntryState.kt @@ -0,0 +1,11 @@ +package com.project200.undabang.main + +sealed class EntryState { + object Loading : EntryState() + + object Content : EntryState() + + object Login : EntryState() + + data class ForceUpdate(val fromReconnect: Boolean) : EntryState() +} diff --git a/app/src/main/java/com/project200/undabang/main/MainActivity.kt b/app/src/main/java/com/project200/undabang/main/MainActivity.kt index 54b11464..81c6b76e 100644 --- a/app/src/main/java/com/project200/undabang/main/MainActivity.kt +++ b/app/src/main/java/com/project200/undabang/main/MainActivity.kt @@ -14,12 +14,12 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController -import com.project200.domain.model.BaseResult -import com.project200.domain.model.UpdateCheckResult import com.project200.presentation.navigator.ActivityNavigator import com.project200.presentation.navigator.BottomNavigationController import com.project200.presentation.update.UpdateDialogFragment @@ -28,8 +28,6 @@ import com.project200.presentation.utils.KeyboardUtils.hideKeyboardOnTouchOutsid import com.project200.undabang.R import com.project200.undabang.databinding.ActivityMainBinding import com.project200.undabang.oauth.AuthManager -import com.project200.undabang.oauth.AuthStateManager -import com.project200.undabang.oauth.TokenRefreshResult import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -42,13 +40,12 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { @Inject lateinit var authManager: AuthManager - @Inject lateinit var authStateManager: AuthStateManager - @Inject lateinit var appNavigator: ActivityNavigator private lateinit var navController: NavController - private var isLoading = true // 스플래시 화면 유지를 위한 플래그 + private lateinit var binding: ActivityMainBinding private lateinit var requestNotificationPermissionLauncher: ActivityResultLauncher + private var contentShown = false override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -56,7 +53,7 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { super.onCreate(savedInstanceState) // 스플래시 화면을 계속 보여줄 조건 설정 - splashScreen.setKeepOnScreenCondition { isLoading } + splashScreen.setKeepOnScreenCondition { viewModel.entryState.value is EntryState.Loading } requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> @@ -70,55 +67,24 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { checkNotificationPermission() setupObservers() - viewModel.checkForUpdate() observeAuthEvents() } - private fun performRouting() { - lifecycleScope.launch { - val currentAuthState = authStateManager.getCurrent() - if (currentAuthState.isAuthorized) { - if (currentAuthState.needsTokenRefresh) { - Timber.i("Token needs refresh. Attempting refresh...") - when (val refreshResult = authManager.refreshAccessToken()) { - is TokenRefreshResult.Success -> { - Timber.i("Token refresh successful.") - viewModel.login() - } - is TokenRefreshResult.Error, - is TokenRefreshResult.NoRefreshToken, - is TokenRefreshResult.ConfigError, - -> { - Timber.w("Token refresh failed or not possible. Navigating to Login.") - if (refreshResult is TokenRefreshResult.Error) Timber.e(refreshResult.exception) - navigateToLogin() - } - } - } else { - Timber.i("User is authorized and token is fresh.") - viewModel.login() - } - } else { - Timber.i("User is not authorized.") - navigateToLogin() - } - } - } - - private fun proceedToContent() { - isLoading = false // 스플래시 종료 - + private fun showContentOnce() { + if (contentShown) return + contentShown = true binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - navController = (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment).navController - setupViews() - } + // 오프라인 진입 시 시작 목적지를 운동 기록 탭으로 교체(그래프는 여기서 1회만 설정) + val graph = navController.navInflater.inflate(R.navigation.bottom_nav_graph) + if (viewModel.isOfflineEntry()) { + graph.setStartDestination(com.project200.undabang.feature.exercise.R.id.exercise_nav_graph) + } + navController.graph = graph - private fun navigateToLogin() { - isLoading = false // 스플래시 종료 - appNavigator.navigateToLogin(this) + setupViews() } private fun setupViews() { @@ -170,46 +136,31 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { } private fun setupObservers() { - viewModel.updateCheckResult.observe(this) { result -> - when (result) { - is UpdateCheckResult.UpdateAvailable -> { - showUpdateDialog(result.isForceUpdate) - if (result.isForceUpdate) { - isLoading = false - } else { - performRouting() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.entryState.collect { state -> + when (state) { + EntryState.Loading -> Unit + EntryState.Content -> showContentOnce() + EntryState.Login -> { + appNavigator.navigateToLogin(this@MainActivity) + finish() + } + is EntryState.ForceUpdate -> showUpdateDialog(isForceUpdate = true) + } } } - is UpdateCheckResult.NoUpdateNeeded -> { - Timber.d("업데이트 불필요") - performRouting() - } - else -> { - // 필요한 경우 다른 상태 처리 - Timber.d("UpdateCheckResult: Unhandled state or null") - performRouting() - } - } - } - - viewModel.loginResult.observe(this) { result -> - when (result) { - is BaseResult.Success -> { - proceedToContent() - } - is BaseResult.Error -> { - navigateToLogin() + launch { + viewModel.optionalUpdate.collect { show -> + if (show) { + showUpdateDialog(isForceUpdate = false) + viewModel.onOptionalUpdateShown() + } + } } } } - - viewModel.showBottomNavigation.observe(this) { show -> - if (show) { - showBottomNavigation() - } else { - hideBottomNavigation() - } - } } private fun checkNotificationPermission() { @@ -240,17 +191,14 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { lifecycleScope.launch { authManager.forceLogoutFlow.collectLatest { Timber.d("토큰 재발급 실패, 강제 로그아웃 이벤트 수신") - runOnUiThread { - Toast.makeText(this@MainActivity, R.string.error_token_refresh_failed, Toast.LENGTH_SHORT).show() - navigateToLogin() - } + Toast.makeText(this@MainActivity, R.string.error_token_refresh_failed, Toast.LENGTH_SHORT).show() } } } private fun showUpdateDialog(isForceUpdate: Boolean) { if (supportFragmentManager.findFragmentByTag(UpdateDialogFragment::class.java.simpleName) == null) { - val dialog = UpdateDialogFragment(isForceUpdate) + val dialog = UpdateDialogFragment.newInstance(isForceUpdate) dialog.show(supportFragmentManager, UpdateDialogFragment::class.java.simpleName) } } diff --git a/app/src/main/java/com/project200/undabang/main/MainViewModel.kt b/app/src/main/java/com/project200/undabang/main/MainViewModel.kt index 015e6304..fa74e743 100644 --- a/app/src/main/java/com/project200/undabang/main/MainViewModel.kt +++ b/app/src/main/java/com/project200/undabang/main/MainViewModel.kt @@ -1,71 +1,175 @@ package com.project200.undabang.main -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.project200.domain.model.BaseResult +import com.project200.common.utils.NetworkMonitor +import com.project200.domain.manager.FcmTokenSyncScheduler import com.project200.domain.model.UpdateCheckResult import com.project200.domain.usecase.CheckForUpdateUseCase -import com.project200.domain.usecase.CheckIsRegisteredUseCase -import com.project200.domain.usecase.LoginUseCase +import com.project200.domain.usecase.ClearSessionUseCase +import com.project200.domain.usecase.GetMemberIdUseCase +import com.project200.undabang.oauth.AuthManager +import com.project200.undabang.oauth.AuthStateManager +import com.project200.undabang.oauth.TokenRefreshResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationException import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class MainViewModel @Inject constructor( private val checkForUpdateUseCase: CheckForUpdateUseCase, - private val checkIsRegisteredUseCase: CheckIsRegisteredUseCase, - private val loginUseCase: LoginUseCase, + private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, + private val networkMonitor: NetworkMonitor, + private val authManager: AuthManager, + private val authStateManager: AuthStateManager, + private val clearSessionUseCase: ClearSessionUseCase, + private val getMemberIdUseCase: GetMemberIdUseCase, ) : ViewModel() { - private val _updateCheckResult = MutableLiveData() - val updateCheckResult: LiveData = _updateCheckResult + private val _entryState = MutableStateFlow(EntryState.Loading) + val entryState: StateFlow = _entryState.asStateFlow() - private val _loginResult = MutableLiveData>() - val loginResult: LiveData> = _loginResult + // 선택(비강제) 업데이트 다이얼로그용 + private val _optionalUpdate = MutableStateFlow(false) + val optionalUpdate: StateFlow = _optionalUpdate.asStateFlow() - private val _showBottomNavigation = MutableLiveData() - val showBottomNavigation: LiveData = _showBottomNavigation + private var wasOffline = !networkMonitor.isCurrentlyConnected() - // 업데이트 확인 - fun checkForUpdate() { - if (_updateCheckResult.value != null) { - return - } // 이미 체크했다면 스킵 + init { + observeNetworkReconnection() + observeForceLogout() + startEntry() + } + /** + * 진입 시 업데이트 확인 -> 토큰 확인 + * 이후 진입 상태를 업데이트하여 라우팅 + */ + private fun startEntry() { viewModelScope.launch { - checkForUpdateUseCase() - .onSuccess { result -> - _updateCheckResult.value = result - when (result) { - is UpdateCheckResult.UpdateAvailable -> Timber.d("업데이트 가능 isForce: ${result.isForceUpdate}") - is UpdateCheckResult.NoUpdateNeeded -> Timber.d("업데이트 불가능") + try { + val update = + checkForUpdateUseCase().getOrElse { + Timber.e(it, "업데이트 확인 실패 - NoUpdateNeeded로 진행") + UpdateCheckResult.NoUpdateNeeded } + if (update is UpdateCheckResult.UpdateAvailable) { + if (update.isForceUpdate) { + _entryState.compareAndSet(EntryState.Loading, EntryState.ForceUpdate(fromReconnect = false)) + return@launch + } + _optionalUpdate.value = true } - .onFailure { error -> - Timber.e(error, "ViewModel: 업데이트 확인 실패") + routeByAuth() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "진입 플로우 실패 - 로그인 화면으로 폴백") + transitionToLogin() + } + } + } + + private suspend fun routeByAuth() { + val state = authStateManager.getCurrent() + // 토큰 만료 시 refresh 시도, 실패 시 로그인 화면으로 + if (!state.isAuthorized) { + transitionToLogin() + return + } + if (getMemberIdUseCase().isNullOrBlank()) { + transitionToLogin() + return + } + + if (state.needsTokenRefresh) { + when (val result = authManager.refreshAccessToken()) { + is TokenRefreshResult.Success -> Unit + is TokenRefreshResult.Error -> { + val ex = result.exception + val invalidGrant = + ex?.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR && + ex.error == "invalid_grant" + if (invalidGrant) { + transitionToLogin() + return + } + Timber.w("Token refresh 일시 실패 - 오프라인 진입") } + + is TokenRefreshResult.NoRefreshToken, + is TokenRefreshResult.ConfigError, + -> { + transitionToLogin() + return + } + } + } + // 등록은 워커에 맡김. 재시도와 백오프, 프로세스 종료 후 재개를 WorkManager가 처리 + fcmTokenSyncScheduler.schedule() + _entryState.compareAndSet(EntryState.Loading, EntryState.Content) + } + + /** 사용 중 invalid_grant */ + private fun observeForceLogout() { + viewModelScope.launch { + authManager.forceLogoutFlow.collect { + Timber.w("강제 로그아웃 이벤트 - Login 전이") + transitionToLogin() + } } } - // 로그인 - fun login() { + /** 선택 업데이트 다이얼로그 표시 완료 */ + fun onOptionalUpdateShown() { + _optionalUpdate.value = false + } + + /** 진입 시점 오프라인 여부 — 시작 탭 결정용 */ + fun isOfflineEntry(): Boolean = !networkMonitor.isCurrentlyConnected() + + private fun observeNetworkReconnection() { viewModelScope.launch { - val result = loginUseCase() - checkIsRegisteredUseCase() - _loginResult.value = result + networkMonitor.networkState.collect { isOnline -> + if (isOnline && wasOffline && _entryState.value is EntryState.Content) { + recheckForUpdateOnReconnect() + } + wasOffline = !isOnline + } } } - fun showBottomNavigation() { - _showBottomNavigation.value = true + private suspend fun recheckForUpdateOnReconnect() { + // onAvailable 직후 일시적 불안정 대비 딜레이 + delay(RECONNECT_UPDATE_CHECK_DELAY_MS.milliseconds) + checkForUpdateUseCase() + .onSuccess { result -> + if (result is UpdateCheckResult.UpdateAvailable && result.isForceUpdate) { + _entryState.compareAndSet( + EntryState.Content, + EntryState.ForceUpdate(fromReconnect = true), + ) + } + } + .onFailure { Timber.w(it, "재연결 후 업데이트 확인 실패 - 무시") } + } + + private suspend fun transitionToLogin() { + runCatching { clearSessionUseCase() } + .onFailure { Timber.e(it, "세션 정리 실패 - 전이는 계속") } + _entryState.value = EntryState.Login } - fun hideBottomNavigation() { - _showBottomNavigation.value = false + companion object { + private const val RECONNECT_UPDATE_CHECK_DELAY_MS = 1500L } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 13038685..5bf2edb9 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -17,8 +17,7 @@ app:layout_constraintBottom_toTopOf="@+id/bottom_navigation" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:navGraph="@navigation/bottom_nav_graph" /> + app:layout_constraintTop_toTopOf="parent" /> + private lateinit var forceLogoutFlow: MutableSharedFlow private val testDispatcher = StandardTestDispatcher() @Before fun setUp() { Dispatchers.setMain(testDispatcher) + networkStateFlow = MutableSharedFlow() + forceLogoutFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { mockNetworkMonitor.networkState } returns networkStateFlow + every { mockNetworkMonitor.isCurrentlyConnected() } returns true + every { mockAuthManager.forceLogoutFlow } returns forceLogoutFlow + every { mockFcmTokenSyncScheduler.schedule() } just Runs + coEvery { mockClearSessionUseCase() } just Runs } @After @@ -56,174 +85,408 @@ class MainViewModelTest { private fun createViewModel(): MainViewModel { return MainViewModel( checkForUpdateUseCase = mockCheckForUpdateUseCase, - checkIsRegisteredUseCase = mockCheckIsRegisteredUseCase, - loginUseCase = mockLoginUseCase, + fcmTokenSyncScheduler = mockFcmTokenSyncScheduler, + networkMonitor = mockNetworkMonitor, + authManager = mockAuthManager, + authStateManager = mockAuthStateManager, + clearSessionUseCase = mockClearSessionUseCase, + getMemberIdUseCase = mockGetMemberIdUseCase, ) } + /** 토큰 상태 스텁. AuthState는 AppAuth 클래스라 mockk로 만든다. + * isAuthorized가 true면 회원ID 조회도 함께 스텁한다(기본값 "member-1", memberId로 override 가능). */ + private fun stubAuthState( + isAuthorized: Boolean, + needsRefresh: Boolean = false, + memberId: String? = "member-1", + ) { + val authState = mockk() + every { authState.isAuthorized } returns isAuthorized + every { authState.needsTokenRefresh } returns needsRefresh + every { mockAuthStateManager.getCurrent() } returns authState + if (isAuthorized) { + coEvery { mockGetMemberIdUseCase() } returns memberId + } + } + + private fun stubNoUpdate() { + coEvery { mockCheckForUpdateUseCase() } returns Result.success(UpdateCheckResult.NoUpdateNeeded) + } + + // ── 진입 라우팅 ────────────────────────────────────────────── + @Test - fun `checkForUpdate - 업데이트가 필요하면 UpdateAvailable 결과를 반환한다`() = + fun `진입 - 토큰이 유효하면 Content로 전이하고 FCM 등록을 1회 예약한다`() = runTest { // Given - val updateResult = UpdateCheckResult.UpdateAvailable(isForceUpdate = false) - coEvery { mockCheckForUpdateUseCase() } returns Result.success(updateResult) + stubNoUpdate() + stubAuthState(isAuthorized = true, needsRefresh = false) + // When: init이 진입을 시작한다 — 별도 호출 없음 viewModel = createViewModel() - - // When - viewModel.checkForUpdate() testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.updateCheckResult.value).isEqualTo(updateResult) + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) coVerify(exactly = 1) { mockCheckForUpdateUseCase() } + verify(exactly = 1) { mockFcmTokenSyncScheduler.schedule() } } @Test - fun `checkForUpdate - 강제 업데이트가 필요하면 isForceUpdate가 true인 결과를 반환한다`() = + fun `진입 - 미인증이면 Login으로 전이하고 FCM 등록을 예약하지 않는다`() = runTest { // Given - val updateResult = UpdateCheckResult.UpdateAvailable(isForceUpdate = true) - coEvery { mockCheckForUpdateUseCase() } returns Result.success(updateResult) - - viewModel = createViewModel() + stubNoUpdate() + stubAuthState(isAuthorized = false) // When - viewModel.checkForUpdate() + viewModel = createViewModel() testDispatcher.scheduler.advanceUntilIdle() // Then - val result = viewModel.updateCheckResult.value - assertThat(result).isInstanceOf(UpdateCheckResult.UpdateAvailable::class.java) - assertThat((result as UpdateCheckResult.UpdateAvailable).isForceUpdate).isTrue() + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) + verify(exactly = 0) { mockFcmTokenSyncScheduler.schedule() } } @Test - fun `checkForUpdate - 업데이트가 불필요하면 NoUpdateNeeded 결과를 반환한다`() = + fun `진입 - 토큰 갱신 성공이면 Content로 전이한다`() = runTest { // Given - coEvery { mockCheckForUpdateUseCase() } returns Result.success(UpdateCheckResult.NoUpdateNeeded) + stubNoUpdate() + stubAuthState(isAuthorized = true, needsRefresh = true) + coEvery { mockAuthManager.refreshAccessToken() } returns TokenRefreshResult.Success(mockk()) + // When viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) + } + + @Test + fun `진입 - 갱신이 invalid_grant면 Login으로 전이한다`() = + runTest { + // Given + stubNoUpdate() + stubAuthState(isAuthorized = true, needsRefresh = true) + coEvery { mockAuthManager.refreshAccessToken() } returns + TokenRefreshResult.Error(AuthorizationException.TokenRequestErrors.INVALID_GRANT) // When - viewModel.checkForUpdate() + viewModel = createViewModel() testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.updateCheckResult.value).isEqualTo(UpdateCheckResult.NoUpdateNeeded) + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) + verify(exactly = 0) { mockFcmTokenSyncScheduler.schedule() } } @Test - fun `checkForUpdate - 이미 체크했으면 다시 호출하지 않는다`() = + fun `진입 - 갱신이 네트워크 오류면 오프라인으로 Content 진입한다`() = runTest { // Given - coEvery { mockCheckForUpdateUseCase() } returns Result.success(UpdateCheckResult.NoUpdateNeeded) + stubNoUpdate() + stubAuthState(isAuthorized = true, needsRefresh = true) + coEvery { mockAuthManager.refreshAccessToken() } returns + TokenRefreshResult.Error(AuthorizationException.GeneralErrors.NETWORK_ERROR) + // When viewModel = createViewModel() - viewModel.checkForUpdate() testDispatcher.scheduler.advanceUntilIdle() + // Then + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) + } + + @Test + fun `진입 - 리프레시 토큰이 없으면 Login으로 전이한다`() = + runTest { + // Given + stubNoUpdate() + stubAuthState(isAuthorized = true, needsRefresh = true) + coEvery { mockAuthManager.refreshAccessToken() } returns TokenRefreshResult.NoRefreshToken + // When - viewModel.checkForUpdate() + viewModel = createViewModel() testDispatcher.scheduler.advanceUntilIdle() // Then - coVerify(exactly = 1) { mockCheckForUpdateUseCase() } + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) } @Test - fun `checkForUpdate - 실패해도 크래시하지 않는다`() = + fun `진입 - 토큰은 유효하지만 회원ID가 없으면 Login으로 전이한다`() = runTest { // Given - coEvery { mockCheckForUpdateUseCase() } returns Result.failure(Exception("Network error")) + stubNoUpdate() + stubAuthState(isAuthorized = true, memberId = null) + // When viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) + verify(exactly = 0) { mockFcmTokenSyncScheduler.schedule() } + } + + @Test + fun `진입 - 예상 밖 예외가 나도 크래시 없이 Login으로 폴백한다`() = + runTest { + // Given: apiCallBuilder 밖 스택(AuthStateManager)에서 예외 + stubNoUpdate() + every { mockAuthStateManager.getCurrent() } throws RuntimeException("keystore corrupted") // When - viewModel.checkForUpdate() + viewModel = createViewModel() testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.updateCheckResult.value).isNull() + // Then: 진입 코루틴은 반드시 상태 전이로 끝난다 + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) } + // ── 업데이트 게이트 ────────────────────────────────────────── + @Test - fun `login - 성공하면 Success 결과를 반환한다`() = + fun `진입 - 강제 업데이트면 ForceUpdate로 전이하고 라우팅을 진행하지 않는다`() = runTest { // Given - coEvery { mockLoginUseCase() } returns BaseResult.Success(Unit) - coEvery { mockCheckIsRegisteredUseCase() } returns true + coEvery { mockCheckForUpdateUseCase() } returns + Result.success(UpdateCheckResult.UpdateAvailable(isForceUpdate = true)) + // When viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 게이트 닫힘 — 토큰 판정·FCM 등록 미진행 + assertThat(viewModel.entryState.value).isEqualTo(EntryState.ForceUpdate(fromReconnect = false)) + verify(exactly = 0) { mockFcmTokenSyncScheduler.schedule() } + } + + @Test + fun `진입 - 선택 업데이트면 다이얼로그 플래그를 켜고 라우팅은 병행한다`() = + runTest { + // Given + coEvery { mockCheckForUpdateUseCase() } returns + Result.success(UpdateCheckResult.UpdateAvailable(isForceUpdate = false)) + stubAuthState(isAuthorized = true) // When - viewModel.login() + viewModel = createViewModel() testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.loginResult.value).isInstanceOf(BaseResult.Success::class.java) - coVerify { mockLoginUseCase() } - coVerify { mockCheckIsRegisteredUseCase() } + assertThat(viewModel.optionalUpdate.value).isTrue() + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) + + // 표시 완료 소비 + viewModel.onOptionalUpdateShown() + assertThat(viewModel.optionalUpdate.value).isFalse() } @Test - fun `login - 실패하면 Error 결과를 반환한다`() = + fun `진입 - 업데이트 확인이 실패해도 라우팅이 진행된다`() = runTest { // Given - coEvery { mockLoginUseCase() } returns BaseResult.Error("ERROR", "로그인 실패") - coEvery { mockCheckIsRegisteredUseCase() } returns false + coEvery { mockCheckForUpdateUseCase() } returns Result.failure(Exception("Network error")) + stubAuthState(isAuthorized = true) + // When viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() - // When - viewModel.login() + // Then: 스플래시에 갇히지 않는다 + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) + } + + // ── forceLogout ───────────────────────────────────────────── + + @Test + fun `forceLogout - Content 중 수신하면 Login으로 전이한다`() = + runTest { + // Given: 정상 진입 완료 + stubNoUpdate() + stubAuthState(isAuthorized = true) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) + + // When: 사용 중 invalid_grant (TokenAuthenticator 경유) + forceLogoutFlow.emit(Unit) testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.loginResult.value).isInstanceOf(BaseResult.Error::class.java) + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) } @Test - fun `showBottomNavigation - 호출하면 true가 설정된다`() = + fun `forceLogout - 진입 도중 수신해도 뒤늦은 Content가 Login을 덮지 않는다`() = runTest { - // Given - viewModel = createViewModel() + // Given: refresh 도중 forceLogout이 발행되는 시나리오 (invalid_grant와 동일 타이밍) + stubNoUpdate() + stubAuthState(isAuthorized = true, needsRefresh = true) + coEvery { mockAuthManager.refreshAccessToken() } coAnswers { + forceLogoutFlow.emit(Unit) // 갱신 도중 강제 로그아웃 발생 + TokenRefreshResult.Error(AuthorizationException.GeneralErrors.NETWORK_ERROR) // 진입은 오프라인 경로로 완주 시도 + } // When - viewModel.showBottomNavigation() + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 진입 코루틴이 완주해 Content를 쓰려 해도 compareAndSet(Loading, Content)이 실패한다 + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) + } + + // ── 재연결 ────────────────────────────────────────────────── + + @Test + fun `재연결 - 오프라인 콜드스타트 후 첫 온라인 전환에 업데이트를 재확인한다`() = + runTest { + // Given: 오프라인 상태로 시작해 Content 진입 (진입 시 확인 1회) + every { mockNetworkMonitor.isCurrentlyConnected() } returns false + stubNoUpdate() + stubAuthState(isAuthorized = true) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // When: 망이 붙어 첫 emit이 true로 옴 (false emit 없음) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 진입 1회 + 재확인 1회 + coVerify(exactly = 2) { mockCheckForUpdateUseCase() } + } + + @Test + fun `재연결 - 온라인 시작이면 첫 onAvailable emit에 재확인이 헛발 실행되지 않는다`() = + runTest { + // Given: 온라인 시작(setUp 기본값)으로 Content 진입 + stubNoUpdate() + stubAuthState(isAuthorized = true) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // When: registerNetworkCallback 직후 시스템이 쏘는 onAvailable + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 재확인 없음 (진입 1회뿐) + coVerify(exactly = 1) { mockCheckForUpdateUseCase() } + + // 그리고 정상 재연결(false → true)은 동작한다 — 경로가 죽어서 1회인 게 아님을 고정 + networkStateFlow.emit(false) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 2) { mockCheckForUpdateUseCase() } + } + + @Test + fun `재연결 - Content가 아니면 재확인을 트리거하지 않는다`() = + runTest { + // Given: 미인증으로 Login에 머무는 상태 + stubNoUpdate() + stubAuthState(isAuthorized = false) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Login) + + // When: offline → online 전환 + networkStateFlow.emit(false) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 진입 1회뿐 + coVerify(exactly = 1) { mockCheckForUpdateUseCase() } + } + + @Test + fun `재연결 - 재확인에서 강제 업데이트면 ForceUpdate로 전이한다`() = + runTest { + // Given: Content 진입 (진입 시엔 업데이트 없음, 재확인에선 강제 업데이트) + coEvery { mockCheckForUpdateUseCase() } returns + Result.success(UpdateCheckResult.NoUpdateNeeded) andThen + Result.success(UpdateCheckResult.UpdateAvailable(isForceUpdate = true)) + stubAuthState(isAuthorized = true) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // When: offline → online 전환 + networkStateFlow.emit(false) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.showBottomNavigation.value).isTrue() + assertThat(viewModel.entryState.value).isEqualTo(EntryState.ForceUpdate(fromReconnect = true)) } @Test - fun `hideBottomNavigation - 호출하면 false가 설정된다`() = + fun `재연결 - 재확인이 실패하면 무시하고 Content를 유지한다`() = runTest { // Given + coEvery { mockCheckForUpdateUseCase() } returns + Result.success(UpdateCheckResult.NoUpdateNeeded) andThen + Result.failure(Exception("Network error")) + stubAuthState(isAuthorized = true) viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() // When - viewModel.hideBottomNavigation() + networkStateFlow.emit(false) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.showBottomNavigation.value).isFalse() + assertThat(viewModel.entryState.value).isEqualTo(EntryState.Content) } + // ── FCM 등록 ──────────────────────────────────────────────── + @Test - fun `showBottomNavigation과 hideBottomNavigation - 토글이 정상 동작한다`() = + fun `FCM 등록 - 재연결에는 다시 예약하지 않는다`() = + runTest { + // Given: 진입에서 1회 예약된 상태. 재시도는 WorkManager 백오프가 맡는다 + stubNoUpdate() + stubAuthState(isAuthorized = true) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // When: offline → online 재연결 + networkStateFlow.emit(false) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 진입 1회뿐 — ViewModel은 재시도를 손으로 다시 구현하지 않는다 + verify(exactly = 1) { mockFcmTokenSyncScheduler.schedule() } + } + + @Test + fun `isOfflineEntry - 온라인이면 false를 반환한다`() = runTest { // Given + stubNoUpdate() + stubAuthState(isAuthorized = true) + every { mockNetworkMonitor.isCurrentlyConnected() } returns true viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() // When & Then - viewModel.showBottomNavigation() - assertThat(viewModel.showBottomNavigation.value).isTrue() + assertThat(viewModel.isOfflineEntry()).isFalse() + } - viewModel.hideBottomNavigation() - assertThat(viewModel.showBottomNavigation.value).isFalse() + @Test + fun `isOfflineEntry - 오프라인이면 true를 반환한다`() = + runTest { + // Given + stubNoUpdate() + stubAuthState(isAuthorized = true) + every { mockNetworkMonitor.isCurrentlyConnected() } returns false + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() - viewModel.showBottomNavigation() - assertThat(viewModel.showBottomNavigation.value).isTrue() + // When & Then + assertThat(viewModel.isOfflineEntry()).isTrue() } } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index db795ad7..aef5af64 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -32,6 +32,10 @@ gradlePlugin { id = "convention.android.library" implementationClass = "com.project200.convention.AndroidLibraryConventionPlugin" } + register("conventionAndroidCompose") { + id = "convention.android.compose" + implementationClass = "com.project200.convention.AndroidComposeConventionPlugin" + } register("conventionAndroidHilt") { id = "convention.android.hilt" implementationClass = "com.project200.convention.AndroidHiltConventionPlugin" diff --git a/build-logic/convention/src/main/java/com/project200/convention/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/java/com/project200/convention/AndroidComposeConventionPlugin.kt new file mode 100644 index 00000000..c8432c6c --- /dev/null +++ b/build-logic/convention/src/main/java/com/project200/convention/AndroidComposeConventionPlugin.kt @@ -0,0 +1,41 @@ +package com.project200.convention + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + extensions.configure { + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.version("composeCompiler") + } + } + + dependencies { + val bom = libs.library("compose-bom") + "implementation"(platform(bom)) + "implementation"(libs.library("compose-ui")) + "implementation"(libs.library("compose-ui-graphics")) + "implementation"(libs.library("compose-ui-tooling-preview")) + "implementation"(libs.library("compose-foundation")) + "implementation"(libs.library("compose-material3")) + "implementation"(libs.library("activity-compose")) + "implementation"(libs.library("lifecycle-viewmodel-compose")) + "implementation"(libs.library("lifecycle-runtime-compose")) + + "debugImplementation"(libs.library("compose-ui-tooling")) + "debugImplementation"(libs.library("compose-ui-test-manifest")) + + "androidTestImplementation"(platform(bom)) + "androidTestImplementation"(libs.library("compose-ui-test-junit4")) + } + } + } +} diff --git a/data/src/main/java/com/project200/data/dto/FcmTokenDTO.kt b/data/src/main/java/com/project200/data/dto/FcmTokenDTO.kt deleted file mode 100644 index d22d61e8..00000000 --- a/data/src/main/java/com/project200/data/dto/FcmTokenDTO.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.project200.data.dto - -data class FcmTokenRequest( - val token: String, -) diff --git a/data/src/main/java/com/project200/data/impl/AppUpdateRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/AppUpdateRepositoryImpl.kt index a5ec3a65..419711c6 100644 --- a/data/src/main/java/com/project200/data/impl/AppUpdateRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/AppUpdateRepositoryImpl.kt @@ -20,16 +20,19 @@ class AppUpdateRepositoryImpl override suspend fun getUpdateInfo(): Result { return runCatching { // Remote Config 값 가져오기 및 활성화 - val activated = firebaseRemoteConfig.fetchAndActivate().await() - if (activated) { - Timber.tag("RemoteConfig") - .d("RemoteConfig 가져오기 성공 및 활성화 완료") - } else { - Timber.tag("RemoteConfig") - .d("RemoteConfig 가져오기 실패 또는 변경 없음") + // 오프라인 등으로 fetch 실패 시 로컬 캐시/기본값(remote_config_defaults.xml)으로 계속 진행 + try { + val activated = firebaseRemoteConfig.fetchAndActivate().await() + if (activated) { + Timber.tag("RemoteConfig").d("RemoteConfig 가져오기 성공 및 활성화 완료") + } else { + Timber.tag("RemoteConfig").d("RemoteConfig 가져오기 실패 또는 변경 없음") + } + } catch (e: Exception) { + Timber.tag("RemoteConfig").w(e, "fetchAndActivate 실패 - 캐시/기본값 사용") } - // 값 읽어오기 + // 값 읽어오기 (fetch 실패 시 캐시 또는 remote_config_defaults.xml 반환) val latestVersion = firebaseRemoteConfig.getLong(RemoteConfigKeys.LATEST_VERSION_CODE) val minRequiredVersion = firebaseRemoteConfig.getLong(RemoteConfigKeys.MIN_REQUIRED_VERSION_CODE) Timber.tag("RemoteConfig") diff --git a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt index 74d7f51a..ec57917e 100644 --- a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt @@ -3,13 +3,13 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService import com.project200.data.dto.GetIsNicknameDuplicated -import com.project200.data.dto.PostLoginRequest import com.project200.data.dto.PostSignUpRequest import com.project200.data.local.PreferenceManager import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult +import com.project200.domain.model.RegistrationStatus import com.project200.domain.repository.AuthRepository -import kotlinx.coroutines.CancellationException +import com.project200.undabang.oauth.AuthStateManager import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import timber.log.Timber @@ -21,28 +21,38 @@ class AuthRepositoryImpl constructor( private val apiService: ApiService, private val spManager: PreferenceManager, + private val authStateManager: AuthStateManager, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : AuthRepository { - override suspend fun checkIsRegistered(): Boolean = - withContext(ioDispatcher) { - try { - val response = apiService.getIsRegistered() - response.data?.let { spManager.saveMemberId(it.memberId) } - response.data?.isRegistered ?: false - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.tag(TAG).d("checkIsRegistered failed: $e") - false - } - } + override suspend fun checkIsRegistered(): RegistrationStatus { + val result = + apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { apiService.getIsRegistered() }, + mapper = { data -> + // 가입 확인된 경우에만 저장 + if (data != null && data.isRegistered) { + spManager.saveMemberId(data.memberId) + true + } else { + false + } + }, + ) - override suspend fun login(): BaseResult { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { apiService.postLogin(PostLoginRequest("ANDROID", "APP")) }, - mapper = { Unit }, - ) + return when (result) { + is BaseResult.Success -> + if (result.data) RegistrationStatus.Registered else RegistrationStatus.Unregistered + is BaseResult.Error -> + // 미가입자는 인터셉터가 401 AUTHENTICATION_FAILED로 막는다 + if (result.errorCode == "AUTHENTICATION_FAILED") { + RegistrationStatus.Unregistered + } else { + // 게이트웨이 401("401") / USER_ID_HEADER_MISSING / NETWORK_ERROR / 500 → 판별 불가 + Timber.tag(TAG).w("checkIsRegistered failed: ${result.errorCode} ${result.message}") + RegistrationStatus.Indeterminate + } + } } override suspend fun logout(): BaseResult { @@ -82,8 +92,15 @@ class AuthRepositoryImpl ) } - override suspend fun getMemberId(): String { - return spManager.getMemberId().toString() + override suspend fun getMemberId(): String? { + return spManager.getMemberId() + } + + override suspend fun clearSession() { + withContext(ioDispatcher) { + authStateManager.clearAuthState() + spManager.clearMemberId() + } } companion object { diff --git a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt index f6be85f4..2163b394 100644 --- a/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/ChatSocketRepositoryImpl.kt @@ -61,7 +61,7 @@ class ChatSocketRepositoryImpl private val _opponentStatusChanges = MutableSharedFlow() override val opponentStatusChanges = _opponentStatusChanges.asSharedFlow() - private val memberId = spManager.getMemberId().toString() + private val memberId: String? = spManager.getMemberId() private var currentChatRoomId: Long = -1L private var isUserInChatRoom = false private var retryCount = AtomicInteger(0) diff --git a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt index 682693be..18a58630 100644 --- a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt @@ -5,12 +5,13 @@ import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.di.IoDispatcher import com.project200.common.utils.EncryptedPrefs import com.project200.data.api.ApiService -import com.project200.data.dto.BaseResponse +import com.project200.data.dto.PostLoginRequest import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult import com.project200.domain.repository.FcmRepository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject class FcmRepositoryImpl @@ -20,37 +21,36 @@ class FcmRepositoryImpl @EncryptedPrefs private val sharedPreferences: SharedPreferences, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : FcmRepository { - // FCM 토큰을 SharedPreferences에서 가져오는 함수 - override suspend fun getFcmTokenFromPrefs(): String? { + override suspend fun getSavedToken(): String? { return withContext(ioDispatcher) { sharedPreferences.getString(KEY_FCM_TOKEN, null) } } - override suspend fun sendFcmToken(): BaseResult { - val token = getFcmTokenFromPrefs() - - if (token.isNullOrBlank()) { + /** + * 토큰 전용 엔드포인트가 없어 당분간 POST /login을 씁니다 + * 서버는 이 요청의 X-Fcm-Token 헤더로 기기 토큰을 등록합니다 + * 헤더가 없으면 등록을 건너뛰고 LOGIN_SUCCESS를 돌려주므로 토큰이 없으면 보내지 않습니다 + */ + override suspend fun registerToken(): BaseResult { + if (getSavedToken().isNullOrBlank()) { + Timber.tag(TAG).w("FCM 토큰이 없어 등록 요청을 보내지 않습니다") return BaseResult.Error( - NO_FCM_TOKEN_ERROR_CODE, - NO_FCM_TOKEN_ERROR_MESSAGE, - NoSuchElementException(NO_FCM_TOKEN_ERROR_MESSAGE), + errorCode = NO_FCM_TOKEN_ERROR_CODE, + message = NO_FCM_TOKEN_ERROR_MESSAGE, ) } - // TODO: FCM 토큰을 서버로 전송하는 API 호출 return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { - /** apiService.sendFcmToken(FcmTokenRequest(token)) */ - BaseResponse(code = "", message = "", succeed = true, data = Unit) - }, + apiCall = { apiService.postLogin(PostLoginRequest("ANDROID", "APP")) }, mapper = { Unit }, ) } companion object { - private const val NO_FCM_TOKEN_ERROR_CODE = "-1" - private const val NO_FCM_TOKEN_ERROR_MESSAGE = "fcm_token_not_found" + private const val TAG = "FcmRepositoryImpl" + const val NO_FCM_TOKEN_ERROR_CODE = "NO_FCM_TOKEN" + private const val NO_FCM_TOKEN_ERROR_MESSAGE = "FCM 토큰이 없어 등록 요청을 보내지 않았습니다" } } diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt index 509decff..4db9bbee 100644 --- a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -23,7 +23,7 @@ class NotificationRepositoryImpl return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { - val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") + val fcmTokenResult = fcmRepository.getSavedToken() ?: throw NoSuchElementException("FCM token is missing.") apiService.getNotiState(fcmTokenResult) }, mapper = { dtoList -> @@ -36,7 +36,7 @@ class NotificationRepositoryImpl return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { - val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") + val fcmTokenResult = fcmRepository.getSavedToken() ?: throw NoSuchElementException("FCM token is missing.") apiService.patchNotiState( fcmToken = fcmTokenResult, notiRequest = notiState.map { it.toDTO() }, diff --git a/data/src/main/java/com/project200/data/utils/ApiCallBuilder.kt b/data/src/main/java/com/project200/data/utils/ApiCallBuilder.kt index e10ee793..9b83706e 100644 --- a/data/src/main/java/com/project200/data/utils/ApiCallBuilder.kt +++ b/data/src/main/java/com/project200/data/utils/ApiCallBuilder.kt @@ -60,8 +60,8 @@ suspend fun apiCallBuilder( ) } val errorBody = exception.response()?.errorBody()?.string() - var errorMessage = errorBody // 기본값은 파싱 전 원본 문자열 + var serverCode: String? = null Timber.d("errorBody: $errorBody") if (!errorBody.isNullOrBlank()) { @@ -75,16 +75,19 @@ suspend fun apiCallBuilder( val errorResponse = adapter.fromJson(errorBody) // 파싱 성공 후 메시지가 있다면 사용 - if (errorResponse != null && !errorResponse.message.isNullOrEmpty()) { - Timber.d("Parsed error message: ${errorResponse.message}") - errorMessage = errorResponse.message + if (errorResponse != null) { + if (!errorResponse.message.isNullOrEmpty()) { + Timber.d("Parsed error message: ${errorResponse.message}") + errorMessage = errorResponse.message + } + serverCode = errorResponse.code } } catch (e: Exception) { Timber.e(e, "Failed to parse error body JSON with Moshi.") } } BaseResult.Error( - errorCode = exception.code().toString(), + errorCode = serverCode ?: exception.code().toString(), message = errorMessage, // 추출한 메시지 또는 원본 문자열 사용 cause = exception, ) diff --git a/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt b/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt index 8f0afb0c..d82b7dca 100644 --- a/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt +++ b/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt @@ -3,35 +3,19 @@ package com.project200.data.utils import android.content.SharedPreferences import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.utils.EncryptedPrefs -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * FCM 토큰을 읽는 단일 창구입니다 + * 토큰은 FcmService.onNewToken()이 저장소에 직접 씁니다 + * 메모리에 캐시하면 갱신 이후에도 옛 토큰이 헤더에 실리므로 호출할 때마다 저장소를 읽습니다 + */ @Singleton class FcmTokenProvider @Inject constructor( @EncryptedPrefs private val prefs: SharedPreferences, ) { - private var fcmToken: String? = null - - init { - loadTokenFromPrefs() - } - - private fun loadTokenFromPrefs() { - fcmToken = prefs.getString(KEY_FCM_TOKEN, null) - Timber.tag("FcmTokenProvider").d("FCM 토큰 : $fcmToken") - } - - fun getFcmToken(): String? { - if (fcmToken == null) { - loadTokenFromPrefs() - } - return fcmToken - } - - fun refreshToken() { - loadTokenFromPrefs() - } + fun getFcmToken(): String? = prefs.getString(KEY_FCM_TOKEN, null) } diff --git a/domain/src/main/java/com/project200/domain/manager/FcmTokenSyncScheduler.kt b/domain/src/main/java/com/project200/domain/manager/FcmTokenSyncScheduler.kt new file mode 100644 index 00000000..15434dcc --- /dev/null +++ b/domain/src/main/java/com/project200/domain/manager/FcmTokenSyncScheduler.kt @@ -0,0 +1,11 @@ +package com.project200.domain.manager + +/** + * FCM 토큰 등록 작업의 예약과 취소를 맡습니다. + * 구현은 WorkManager를 쓰는 app 모듈에 있습니다. + */ +interface FcmTokenSyncScheduler { + fun schedule() + + fun cancel() +} diff --git a/domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt b/domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt new file mode 100644 index 00000000..b51d8d0b --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt @@ -0,0 +1,8 @@ +package com.project200.domain.model + +/** FCM 토큰 서버 반영 결과 */ +enum class FcmTokenSyncResult { + SUCCESS, // 서버에 반영됨 + SKIPPED, // 로그인 전이거나 보낼 토큰이 없음. 재시도해도 같음 + FAILURE, // 전송을 시도했으나 실패. 재시도 대상 +} diff --git a/domain/src/main/java/com/project200/domain/model/RegistrationStatus.kt b/domain/src/main/java/com/project200/domain/model/RegistrationStatus.kt new file mode 100644 index 00000000..50ac611d --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/RegistrationStatus.kt @@ -0,0 +1,7 @@ +package com.project200.domain.model + +sealed class RegistrationStatus { + object Registered : RegistrationStatus() + object Unregistered : RegistrationStatus() + object Indeterminate : RegistrationStatus() // 네트워크 오류 등으로 확인 불가 +} diff --git a/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt b/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt index af68feb9..92c51405 100644 --- a/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt @@ -1,13 +1,14 @@ package com.project200.domain.repository import com.project200.domain.model.BaseResult +import com.project200.domain.model.RegistrationStatus import java.time.LocalDate interface AuthRepository { - suspend fun checkIsRegistered(): Boolean - suspend fun login(): BaseResult + suspend fun checkIsRegistered(): RegistrationStatus suspend fun logout(): BaseResult suspend fun signUp(gender: String, nickname: String, birth: LocalDate): BaseResult suspend fun checkNicknameDuplicated(nickname: String): BaseResult - suspend fun getMemberId(): String + suspend fun getMemberId(): String? + suspend fun clearSession() } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt b/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt index b5fbf1f2..f060fa9f 100644 --- a/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt @@ -3,6 +3,9 @@ package com.project200.domain.repository import com.project200.domain.model.BaseResult interface FcmRepository { - suspend fun sendFcmToken(): BaseResult - suspend fun getFcmTokenFromPrefs(): String? + /** 기기에 저장된 FCM 토큰. 없으면 null */ + suspend fun getSavedToken(): String? + + /** 저장된 FCM 토큰을 서버에 등록합니다. 토큰이 없으면 보내지 않고 실패를 반환합니다 */ + suspend fun registerToken(): BaseResult } diff --git a/domain/src/main/java/com/project200/domain/usecase/CheckIsRegisteredUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/CheckIsRegisteredUseCase.kt index d9394f93..4ac8c0d7 100644 --- a/domain/src/main/java/com/project200/domain/usecase/CheckIsRegisteredUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/CheckIsRegisteredUseCase.kt @@ -1,12 +1,13 @@ package com.project200.domain.usecase +import com.project200.domain.model.RegistrationStatus import com.project200.domain.repository.AuthRepository import javax.inject.Inject class CheckIsRegisteredUseCase @Inject constructor( private val authRepository: AuthRepository ) { - suspend operator fun invoke(): Boolean { + suspend operator fun invoke(): RegistrationStatus { return authRepository.checkIsRegistered() } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt new file mode 100644 index 00000000..c8a34dd1 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt @@ -0,0 +1,18 @@ +package com.project200.domain.usecase + +import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.repository.AuthRepository +import javax.inject.Inject + +class ClearSessionUseCase +@Inject +constructor( + private val authRepository: AuthRepository, + private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, +) { + suspend operator fun invoke() { + authRepository.clearSession() + // 회원ID가 지워져 실행돼도 SKIPPED로 끝나지만 예약을 남겨둘 이유가 없다 + fcmTokenSyncScheduler.cancel() + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/GetMemberIdUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetMemberIdUseCase.kt index 06f7c9b9..f7f5e4b8 100644 --- a/domain/src/main/java/com/project200/domain/usecase/GetMemberIdUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/GetMemberIdUseCase.kt @@ -6,7 +6,7 @@ import javax.inject.Inject class GetMemberIdUseCase @Inject constructor( private val authRepository: AuthRepository ) { - suspend operator fun invoke(): String { + suspend operator fun invoke(): String? { return authRepository.getMemberId() } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/LoginUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/LoginUseCase.kt deleted file mode 100644 index fee27622..00000000 --- a/domain/src/main/java/com/project200/domain/usecase/LoginUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.project200.domain.usecase - -import com.project200.domain.model.BaseResult -import com.project200.domain.repository.AuthRepository -import javax.inject.Inject - -class LoginUseCase @Inject constructor( - private val userRepository: AuthRepository -) { - - suspend operator fun invoke(): BaseResult { - return userRepository.login() - } -} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/SendFcmTokenUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/SendFcmTokenUseCase.kt deleted file mode 100644 index 0fdf9203..00000000 --- a/domain/src/main/java/com/project200/domain/usecase/SendFcmTokenUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.project200.domain.usecase - -import com.project200.domain.model.BaseResult -import com.project200.domain.repository.FcmRepository -import javax.inject.Inject - -class SendFcmTokenUseCase @Inject constructor( - private val fcmRepository: FcmRepository -) { - suspend operator fun invoke(): BaseResult = fcmRepository.sendFcmToken() -} diff --git a/domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt new file mode 100644 index 00000000..e8f690c0 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt @@ -0,0 +1,31 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.FcmTokenSyncResult +import com.project200.domain.repository.AuthRepository +import com.project200.domain.repository.FcmRepository +import javax.inject.Inject + +/** + * 저장된 FCM 토큰을 서버에 반영합니다 + * 토큰은 호출 시점에 저장소에서 읽습니다. 예약과 실행 사이에 토큰이 다시 갱신될 수 있습니다 + */ +class SyncFcmTokenUseCase @Inject constructor( + private val authRepository: AuthRepository, + private val fcmRepository: FcmRepository +) { + suspend operator fun invoke(): FcmTokenSyncResult { + if (authRepository.getMemberId().isNullOrBlank()) { + return FcmTokenSyncResult.SKIPPED + } + + if (fcmRepository.getSavedToken().isNullOrBlank()) { + return FcmTokenSyncResult.SKIPPED + } + + return when (fcmRepository.registerToken()) { + is BaseResult.Success -> FcmTokenSyncResult.SUCCESS + is BaseResult.Error -> FcmTokenSyncResult.FAILURE + } + } +} diff --git a/domain/src/test/java/com/project200/domain/usecase/CheckIsRegisteredUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/CheckIsRegisteredUseCaseTest.kt index cc6dae2b..0659b47b 100644 --- a/domain/src/test/java/com/project200/domain/usecase/CheckIsRegisteredUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/CheckIsRegisteredUseCaseTest.kt @@ -1,6 +1,7 @@ package com.project200.domain.usecase import com.google.common.truth.Truth.assertThat +import com.project200.domain.model.RegistrationStatus import com.project200.domain.repository.AuthRepository import io.mockk.coEvery import io.mockk.coVerify @@ -29,28 +30,41 @@ class CheckIsRegisteredUseCaseTest { } @Test - fun `invoke 호출 시 등록된 사용자면 true 반환`() = runTest { + fun `invoke 호출 시 등록된 사용자면 Registered 반환`() = runTest { // Given - coEvery { mockRepository.checkIsRegistered() } returns true + coEvery { mockRepository.checkIsRegistered() } returns RegistrationStatus.Registered // When val result = useCase() // Then coVerify(exactly = 1) { mockRepository.checkIsRegistered() } - assertThat(result).isTrue() + assertThat(result).isEqualTo(RegistrationStatus.Registered) } @Test - fun `invoke 호출 시 등록되지 않은 사용자면 false 반환`() = runTest { + fun `invoke 호출 시 등록되지 않은 사용자면 Unregistered 반환`() = runTest { // Given - coEvery { mockRepository.checkIsRegistered() } returns false + coEvery { mockRepository.checkIsRegistered() } returns RegistrationStatus.Unregistered // When val result = useCase() // Then coVerify(exactly = 1) { mockRepository.checkIsRegistered() } - assertThat(result).isFalse() + assertThat(result).isEqualTo(RegistrationStatus.Unregistered) + } + + @Test + fun `invoke 호출 시 확인 불가면 Indeterminate 반환`() = runTest { + // Given + coEvery { mockRepository.checkIsRegistered() } returns RegistrationStatus.Indeterminate + + // When + val result = useCase() + + // Then + coVerify(exactly = 1) { mockRepository.checkIsRegistered() } + assertThat(result).isEqualTo(RegistrationStatus.Indeterminate) } } diff --git a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt new file mode 100644 index 00000000..43a0e3d4 --- /dev/null +++ b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt @@ -0,0 +1,63 @@ +package com.project200.domain.usecase + +import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.repository.AuthRepository +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class ClearSessionUseCaseTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var mockRepository: AuthRepository + + @MockK + private lateinit var mockFcmTokenSyncScheduler: FcmTokenSyncScheduler + + private lateinit var useCase: ClearSessionUseCase + + @Before + fun setUp() { + useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler) + } + + @Test + fun `invoke 호출 시 repository clearSession 호출`() = runTest { + // Given + coEvery { mockRepository.clearSession() } just Runs + every { mockFcmTokenSyncScheduler.cancel() } just Runs + + // When + useCase() + + // Then + coVerify(exactly = 1) { mockRepository.clearSession() } + } + + @Test + fun `invoke 호출 시 예약된 FCM 토큰 등록을 취소한다`() = runTest { + // Given + coEvery { mockRepository.clearSession() } just Runs + every { mockFcmTokenSyncScheduler.cancel() } just Runs + + // When + useCase() + + // Then + verify(exactly = 1) { mockFcmTokenSyncScheduler.cancel() } + } +} diff --git a/domain/src/test/java/com/project200/domain/usecase/GetMemberIdUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/GetMemberIdUseCaseTest.kt index 409ad52e..69c814ce 100644 --- a/domain/src/test/java/com/project200/domain/usecase/GetMemberIdUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/GetMemberIdUseCaseTest.kt @@ -55,4 +55,17 @@ class GetMemberIdUseCaseTest { coVerify(exactly = 1) { mockRepository.getMemberId() } assertThat(result).isEmpty() } + + @Test + fun `invoke 호출 시 memberId가 없으면 null 반환`() = runTest { + // Given + coEvery { mockRepository.getMemberId() } returns null + + // When + val result = useCase() + + // Then + coVerify(exactly = 1) { mockRepository.getMemberId() } + assertThat(result).isNull() + } } diff --git a/domain/src/test/java/com/project200/domain/usecase/LoginUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/LoginUseCaseTest.kt deleted file mode 100644 index e76c3875..00000000 --- a/domain/src/test/java/com/project200/domain/usecase/LoginUseCaseTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.project200.domain.usecase - -import com.google.common.truth.Truth.assertThat -import com.project200.domain.model.BaseResult -import com.project200.domain.repository.AuthRepository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.MockK -import io.mockk.junit4.MockKRule -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class LoginUseCaseTest { - - @get:Rule - val mockkRule = MockKRule(this) - - @MockK - private lateinit var mockRepository: AuthRepository - - private lateinit var useCase: LoginUseCase - - @Before - fun setUp() { - useCase = LoginUseCase(mockRepository) - } - - @Test - fun `invoke 호출 시 repository login 호출 및 성공 결과 반환`() = runTest { - // Given - val successResult = BaseResult.Success(Unit) - coEvery { mockRepository.login() } returns successResult - - // When - val result = useCase() - - // Then - coVerify(exactly = 1) { mockRepository.login() } - assertThat(result).isEqualTo(successResult) - } - - @Test - fun `invoke 호출 시 repository login 호출 및 실패 결과 반환`() = runTest { - // Given - val errorResult = BaseResult.Error("ERR", "Login failed") - coEvery { mockRepository.login() } returns errorResult - - // When - val result = useCase() - - // Then - coVerify(exactly = 1) { mockRepository.login() } - assertThat(result).isEqualTo(errorResult) - } -} diff --git a/domain/src/test/java/com/project200/domain/usecase/SendFcmTokenUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/SendFcmTokenUseCaseTest.kt deleted file mode 100644 index 35d24c46..00000000 --- a/domain/src/test/java/com/project200/domain/usecase/SendFcmTokenUseCaseTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.project200.domain.usecase - -import com.google.common.truth.Truth.assertThat -import com.project200.domain.model.BaseResult -import com.project200.domain.repository.FcmRepository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.MockK -import io.mockk.junit4.MockKRule -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class SendFcmTokenUseCaseTest { - - @get:Rule - val mockkRule = MockKRule(this) - - @MockK - private lateinit var mockRepository: FcmRepository - - private lateinit var useCase: SendFcmTokenUseCase - - @Before - fun setUp() { - useCase = SendFcmTokenUseCase(mockRepository) - } - - @Test - fun `invoke 호출 시 FCM 토큰 전송 성공`() = runTest { - // Given - val successResult = BaseResult.Success(Unit) - coEvery { mockRepository.sendFcmToken() } returns successResult - - // When - val result = useCase() - - // Then - coVerify(exactly = 1) { mockRepository.sendFcmToken() } - assertThat(result).isEqualTo(successResult) - } - - @Test - fun `FCM 토큰 전송 실패`() = runTest { - // Given - val errorResult = BaseResult.Error("SEND_FAILED", "Failed to send FCM token") - coEvery { mockRepository.sendFcmToken() } returns errorResult - - // When - val result = useCase() - - // Then - assertThat(result).isEqualTo(errorResult) - assertThat((result as BaseResult.Error).errorCode).isEqualTo("SEND_FAILED") - } - - @Test - fun `네트워크 오류로 전송 실패`() = runTest { - // Given - val networkError = BaseResult.Error("NETWORK_ERROR", "Network connection failed") - coEvery { mockRepository.sendFcmToken() } returns networkError - - // When - val result = useCase() - - // Then - assertThat((result as BaseResult.Error).message).isEqualTo("Network connection failed") - } - - @Test - fun `인증 오류로 전송 실패`() = runTest { - // Given - val authError = BaseResult.Error("UNAUTHORIZED", "User not authenticated") - coEvery { mockRepository.sendFcmToken() } returns authError - - // When - val result = useCase() - - // Then - assertThat((result as BaseResult.Error).errorCode).isEqualTo("UNAUTHORIZED") - } -} diff --git a/domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt new file mode 100644 index 00000000..7f49ccd1 --- /dev/null +++ b/domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt @@ -0,0 +1,123 @@ +package com.project200.domain.usecase + +import com.google.common.truth.Truth.assertThat +import com.project200.domain.model.BaseResult +import com.project200.domain.model.FcmTokenSyncResult +import com.project200.domain.repository.AuthRepository +import com.project200.domain.repository.FcmRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class SyncFcmTokenUseCaseTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var mockAuthRepository: AuthRepository + + @MockK + private lateinit var mockFcmRepository: FcmRepository + + private lateinit var useCase: SyncFcmTokenUseCase + + @Before + fun setUp() { + useCase = SyncFcmTokenUseCase(mockAuthRepository, mockFcmRepository) + } + + @Test + fun `로그인 전이면 전송하지 않고 SKIPPED를 반환한다`() = runTest { + // Given: 회원ID가 없다 = 로그인 전 + coEvery { mockAuthRepository.getMemberId() } returns null + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.SKIPPED) + coVerify(exactly = 0) { mockFcmRepository.registerToken() } + } + + @Test + fun `저장된 토큰이 없으면 전송하지 않고 SKIPPED를 반환한다`() = runTest { + // Given + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getSavedToken() } returns null + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.SKIPPED) + coVerify(exactly = 0) { mockFcmRepository.registerToken() } + } + + @Test + fun `전송에 성공하면 SUCCESS를 반환한다`() = runTest { + // Given + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getSavedToken() } returns "token-1" + coEvery { mockFcmRepository.registerToken() } returns BaseResult.Success(Unit) + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.SUCCESS) + coVerify(exactly = 1) { mockFcmRepository.registerToken() } + } + + @Test + fun `전송에 실패하면 FAILURE를 반환한다`() = runTest { + // Given + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getSavedToken() } returns "token-1" + coEvery { mockFcmRepository.registerToken() } returns BaseResult.Error("NETWORK_ERROR", "네트워크 오류") + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.FAILURE) + } + + @Test + fun `토큰이 없어 등록이 막히면 FAILURE를 반환한다`() = runTest { + // Given: 조회 직후 토큰이 지워져 registerToken()이 NO_FCM_TOKEN으로 막힌 경우 + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getSavedToken() } returns "token-1" + coEvery { mockFcmRepository.registerToken() } returns + BaseResult.Error("NO_FCM_TOKEN", "FCM 토큰이 없어 등록 요청을 보내지 않았습니다") + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.FAILURE) + } + + @Test + fun `대기 중 토큰이 갱신되면 호출 시점의 값을 읽는다`() = runTest { + // Given: 예약 시점의 token-1이 아니라 실행 시점의 token-2가 저장소에 있다 + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getSavedToken() } returns "token-1" andThen "token-2" + coEvery { mockFcmRepository.registerToken() } returns BaseResult.Success(Unit) + useCase() // 첫 실행이 token-1을 읽는다 + + // When: 두 번째 실행 + val result = useCase() + + // Then: 저장소를 매번 다시 읽는다 + assertThat(result).isEqualTo(FcmTokenSyncResult.SUCCESS) + coVerify(exactly = 2) { mockFcmRepository.getSavedToken() } + } +} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 29d376a4..5c62ef0d 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("convention.android.library") id("convention.android.hilt") + id("convention.android.compose") alias(libs.plugins.navigation.safeargs) } diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginActivity.kt b/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginActivity.kt index 1e3c71b4..d769691c 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginActivity.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginActivity.kt @@ -9,7 +9,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope -import com.project200.domain.model.BaseResult +import com.project200.domain.model.RegistrationStatus import com.project200.presentation.base.BindingActivity import com.project200.presentation.navigator.ActivityNavigator import com.project200.undabang.auth.register.RegisterActivity @@ -25,6 +25,7 @@ import net.openid.appauth.AuthorizationService import net.openid.appauth.TokenResponse import timber.log.Timber import javax.inject.Inject +import com.project200.undabang.presentation.R as PresentationR @AndroidEntryPoint class LoginActivity : BindingActivity() { @@ -63,7 +64,6 @@ class LoginActivity : BindingActivity() { override fun onSuccess(tokenResponse: TokenResponse) { Timber.tag(TAG).i(getString(R.string.login_success_with_token, tokenResponse.accessToken)) - viewModel.sendFcmToken() viewModel.checkIsRegistered() } @@ -138,20 +138,13 @@ class LoginActivity : BindingActivity() { } override fun setupObservers() { - viewModel.loginResult.observe(this) { result -> - when (result) { - is BaseResult.Success -> appNavigator.navigateToMain(this) - is BaseResult.Error -> startActivity(Intent(this@LoginActivity, RegisterActivity::class.java)) - } - } - viewModel.fcmTokenEvent.observe(this) { result -> - when (result) { - is BaseResult.Success -> { - Timber.d(getString(R.string.fcm_token_send_success)) - } - is BaseResult.Error -> { - Timber.d(getString(R.string.fcm_token_not_found)) - } + viewModel.registrationResult.observe(this) { status -> + when (status) { + RegistrationStatus.Registered -> appNavigator.navigateToMain(this) + RegistrationStatus.Unregistered -> + startActivity(Intent(this@LoginActivity, RegisterActivity::class.java)) + RegistrationStatus.Indeterminate -> + Toast.makeText(this, PresentationR.string.network_error, Toast.LENGTH_SHORT).show() } } } diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginViewModel.kt b/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginViewModel.kt index 281f9280..5a4e8004 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginViewModel.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/login/LoginViewModel.kt @@ -4,9 +4,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.project200.domain.model.BaseResult -import com.project200.domain.usecase.LoginUseCase -import com.project200.domain.usecase.SendFcmTokenUseCase +import com.project200.domain.model.RegistrationStatus +import com.project200.domain.usecase.CheckIsRegisteredUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -15,26 +14,14 @@ import javax.inject.Inject class LoginViewModel @Inject constructor( - private val loginUseCase: LoginUseCase, - private val sendFcmTokenUseCase: SendFcmTokenUseCase, + private val checkIsRegisteredUseCase: CheckIsRegisteredUseCase, ) : ViewModel() { - private val _loginResult = MutableLiveData>() - val loginResult: LiveData> = _loginResult - - private val _fcmTokenEvent = MutableLiveData>() - val fcmTokenEvent: LiveData> = _fcmTokenEvent + private val _registrationResult = MutableLiveData() + val registrationResult: LiveData = _registrationResult fun checkIsRegistered() { viewModelScope.launch { - _loginResult.value = loginUseCase() - } - } - - // fcm 토큰 전송 - fun sendFcmToken() { - viewModelScope.launch { - val result = sendFcmTokenUseCase() - _fcmTokenEvent.value = result + _registrationResult.value = checkIsRegisteredUseCase() } } } diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionFragment.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionFragment.kt index e5afb229..6a778990 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionFragment.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionFragment.kt @@ -4,17 +4,20 @@ import android.Manifest import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController -import com.project200.presentation.base.BindingFragment // BaseFragment 경로 확인 및 수정 필요 +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.auth.R -import com.project200.undabang.feature.auth.databinding.FragmentPermissionBinding import timber.log.Timber -class PermissionFragment : BindingFragment(R.layout.fragment_permission) { +class PermissionFragment : Fragment() { private lateinit var requestMultiplePermissionsLauncher: ActivityResultLauncher> private lateinit var requestNotificationPermissionLauncher: ActivityResultLauncher @@ -51,9 +54,22 @@ class PermissionFragment : BindingFragment(R.layout.f } } - override fun getViewBinding(view: View): FragmentPermissionBinding { - return FragmentPermissionBinding.bind(view) - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + PermissionScreen( + onNextClick = { + if (isAdded && findNavController().currentDestination?.id == R.id.permissionFragment) { + findNavController().navigate(R.id.action_permissionFragment_to_termsFragment) + } + }, + ) + } + } override fun onViewCreated( view: View, @@ -64,14 +80,6 @@ class PermissionFragment : BindingFragment(R.layout.f requestNeededPermissions() } - override fun setupViews() { - binding.permissionNextBtn.setOnClickListener { - if (isAdded && findNavController().currentDestination?.id == R.id.permissionFragment) { - findNavController().navigate(R.id.action_permissionFragment_to_termsFragment) - } - } - } - private fun requestNeededPermissions() { requiredPermissions.clear() diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionScreen.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionScreen.kt new file mode 100644 index 00000000..868fae20 --- /dev/null +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/PermissionScreen.kt @@ -0,0 +1,130 @@ +package com.project200.undabang.auth.register + +import androidx.annotation.DrawableRes +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +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 com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.header +import com.project200.presentation.compose.theme.subtext12 +import com.project200.undabang.feature.auth.R +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun PermissionScreen( + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + bottomBar = { + PrimaryButton( + text = stringResource(PresentationR.string.confirm), + onClick = onNextClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .padding(horizontal = 20.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(114.dp)) + + Text( + text = stringResource(R.string.permission_title), + style = MaterialTheme.typography.header, + ) + + Spacer(Modifier.height(77.dp)) + + PermissionRow( + iconRes = R.drawable.ic_permission_location, + title = stringResource(R.string.location_title), + desc = stringResource(R.string.location_desc), + ) + + Spacer(Modifier.height(24.dp)) + + PermissionRow( + iconRes = R.drawable.ic_permission_notify, + title = stringResource(R.string.notify_title), + desc = stringResource(R.string.notify_desc), + ) + + Spacer(Modifier.height(24.dp)) + + PermissionRow( + iconRes = R.drawable.ic_permission_gallery, + title = stringResource(R.string.gallery_title), + desc = stringResource(R.string.gallery_desc), + ) + } + } +} + +@Composable +private fun PermissionRow( + @DrawableRes iconRes: Int, + title: String, + desc: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + ) + Spacer(Modifier.width(24.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.contentBold, + ) + Text( + text = desc, + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + ) + } + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun PermissionScreenPreview() { + AppTheme { + PermissionScreen(onNextClick = {}) + } +} diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterFragment.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterFragment.kt index 572e5c5a..9c7eb08b 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterFragment.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterFragment.kt @@ -1,24 +1,30 @@ package com.project200.undabang.auth.register -// import timber.log.Timber // Timber 사용하지 않으면 제거 +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.widget.doAfterTextChanged +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.project200.common.utils.ClockProvider import com.project200.domain.model.BaseResult -import com.project200.presentation.base.BindingFragment import com.project200.presentation.base.DatePickerDialogFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.presentation.navigator.ActivityNavigator import com.project200.undabang.feature.auth.R -import com.project200.undabang.feature.auth.databinding.FragmentRegisterBinding import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class RegisterFragment : BindingFragment(R.layout.fragment_register) { +class RegisterFragment : Fragment() { private val viewModel: RegisterViewModel by viewModels() @Inject @@ -27,108 +33,76 @@ class RegisterFragment : BindingFragment(R.layout.fragm @Inject lateinit var clockProvider: ClockProvider - override fun getViewBinding(view: View): FragmentRegisterBinding { - return FragmentRegisterBinding.bind(view) - } - - override fun setupViews() = - with(binding) { - setupMargin() - - nicknameEt.doAfterTextChanged { - viewModel.updateNickname(it.toString()) - } - - birthDayTv.setOnClickListener { - val datePicker = - DatePickerDialogFragment( - initialDateString = viewModel.birth.value, - maxDate = clockProvider.now().minusDays(1), - ) { selectedDate -> - viewModel.updateBirth(selectedDate) - } - datePicker.show(parentFragmentManager, DatePickerDialogFragment::class.java.simpleName) - } - - genderMaleLl.setOnClickListener { - viewModel.selectGender(MALE) - } - - genderFemaleLl.setOnClickListener { - viewModel.selectGender(FEMALE) - } - - genderHiddenLl.setOnClickListener { - viewModel.selectGender(HIDDEN) - } - - registerCompleteBtn.setOnClickListener { - viewModel.signUp() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val birth by viewModel.birth.collectAsStateWithLifecycle() + val gender by viewModel.gender.collectAsStateWithLifecycle() + val isFormValid by viewModel.isFormValid.collectAsStateWithLifecycle() + + RegisterScreen( + nickname = nickname, + birth = birth, + gender = gender, + isFormValid = isFormValid, + onNicknameChange = viewModel::updateNickname, + onBirthClick = ::showDatePicker, + onGenderSelect = viewModel::selectGender, + onCompleteClick = viewModel::signUp, + ) } } - override fun setupObservers() = - with(viewModel) { - birth.observe(viewLifecycleOwner) { birthDay -> - binding.birthDayTv.text = birthDay ?: getString(R.string.birth_default) - } - - gender.observe(viewLifecycleOwner) { gender -> - binding.maleBtnImv.isSelected = gender == MALE - binding.femaleBtnImv.isSelected = gender == FEMALE - binding.hiddenBtnImv.isSelected = gender == HIDDEN - } - - isFormValid.observe(viewLifecycleOwner) { isValid -> - binding.registerCompleteBtn.isEnabled = isValid - } - - signUpResult.observe(viewLifecycleOwner) { result -> - when (result) { - is BaseResult.Success -> { - Toast.makeText(requireContext(), getString(R.string.register_success), Toast.LENGTH_SHORT).show() - appNavigator.navigateToMain(requireContext()) - } - is BaseResult.Error -> { - when (result.errorCode) { - NICKNAME_DUPLICATE_ERROR -> { - Toast.makeText(requireContext(), getString(R.string.error_nickname_duplicated), Toast.LENGTH_LONG).show() - } - ERROR_CODE_INVALID_NICKNAME -> { - Toast.makeText(requireContext(), getString(R.string.error_nickname_invalid), Toast.LENGTH_LONG).show() - } - FORM_INCOMPLETE -> { - Toast.makeText(requireContext(), getString(R.string.error_form_incomplete), Toast.LENGTH_LONG).show() - } - else -> { - Toast.makeText(requireContext(), getString(R.string.error_unknown), Toast.LENGTH_LONG).show() - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.signUpResult.collect { result -> + when (result) { + is BaseResult.Success -> { + Toast.makeText(requireContext(), getString(R.string.register_success), Toast.LENGTH_SHORT).show() + appNavigator.navigateToMain(requireContext()) + } + is BaseResult.Error -> { + val messageResId = + when (result.errorCode) { + NICKNAME_DUPLICATE_ERROR -> R.string.error_nickname_duplicated + ERROR_CODE_INVALID_NICKNAME -> R.string.error_nickname_invalid + FORM_INCOMPLETE -> R.string.error_form_incomplete + else -> R.string.error_unknown + } + Toast.makeText(requireContext(), getString(messageResId), Toast.LENGTH_LONG).show() } } } } } + } - // 시스템 폰트 크기에 따라 marginStart 조정 - private fun setupMargin() { - val fontScale = resources.configuration.fontScale - Timber.tag("RegisterFragment").d("Font scale: $fontScale") - - val layoutParams = binding.nicknameEt.layoutParams as ConstraintLayout.LayoutParams - layoutParams.marginStart = - resources.getDimensionPixelSize( - if (fontScale > FONT_SCALE_THRESHOLD) R.dimen.nickname_horizontal_margin_large_font else R.dimen.nickname_horizontal_margin, - ) - binding.nicknameEt.layoutParams = layoutParams - binding.nicknameEt.requestLayout() + private fun showDatePicker() { + DatePickerDialogFragment.show( + fragmentManager = parentFragmentManager, + initialDateString = viewModel.birth.value, + maxDate = clockProvider.now().minusDays(1), + ) { selectedDate -> + viewModel.updateBirth(selectedDate) + } } companion object { - const val NICKNAME_DUPLICATE_ERROR = "409" + const val NICKNAME_DUPLICATE_ERROR = "MEMBER_NICKNAME_DUPLICATED" const val MALE = "M" const val FEMALE = "F" const val HIDDEN = "U" - const val FONT_SCALE_THRESHOLD = 1.5f // 폰트 크기 임계값 const val ERROR_CODE_INVALID_NICKNAME = "INVALID_NICKNAME" const val FORM_INCOMPLETE = "FORM_INCOMPLETE" } diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterScreen.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterScreen.kt new file mode 100644 index 00000000..42c5793f --- /dev/null +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterScreen.kt @@ -0,0 +1,306 @@ +package com.project200.undabang.auth.register + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +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.dp +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.input.TextFieldVariant +import com.project200.presentation.compose.components.input.UndabangTextField +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.contentRegular +import com.project200.presentation.compose.theme.header +import com.project200.presentation.compose.theme.subtext14 +import com.project200.undabang.auth.register.RegisterFragment.Companion.FEMALE +import com.project200.undabang.auth.register.RegisterFragment.Companion.HIDDEN +import com.project200.undabang.auth.register.RegisterFragment.Companion.MALE +import com.project200.undabang.feature.auth.R +import com.project200.undabang.presentation.R as PresentationR + +private const val LARGE_FONT_SCALE_THRESHOLD = 1.5f + +@Composable +fun RegisterScreen( + nickname: String, + birth: String?, + gender: String?, + isFormValid: Boolean, + onNicknameChange: (String) -> Unit, + onBirthClick: () -> Unit, + onGenderSelect: (String) -> Unit, + onCompleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val fontScale = LocalConfiguration.current.fontScale + val nicknameMargin = if (fontScale > LARGE_FONT_SCALE_THRESHOLD) 60.dp else 100.dp + + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite300) + .padding(horizontal = 20.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(114.dp)) + + Text( + text = stringResource(R.string.register_title), + style = MaterialTheme.typography.header, + color = ColorBlack, + ) + + Spacer(Modifier.height(90.dp)) + + NicknameSection( + nickname = nickname, + nicknameMargin = nicknameMargin, + onNicknameChange = onNicknameChange, + ) + + Spacer(Modifier.height(32.dp)) + + BirthSection( + birth = birth, + nicknameMargin = nicknameMargin, + onClick = onBirthClick, + ) + + Spacer(Modifier.height(32.dp)) + + GenderSection( + gender = gender, + nicknameMargin = nicknameMargin, + onSelect = onGenderSelect, + ) + + Spacer(Modifier.height(40.dp)) + } + + PrimaryButton( + text = stringResource(PresentationR.string.complete), + onClick = onCompleteClick, + enabled = isFormValid, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun NicknameSection( + nickname: String, + nicknameMargin: Dp, + onNicknameChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val labelText = stringResource(PresentationR.string.nickname) + + Column(modifier = modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = labelText, + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Spacer(Modifier.width(nicknameMargin)) + UndabangTextField( + value = nickname, + onValueChange = onNicknameChange, + variant = TextFieldVariant.Underline, + maxLength = 30, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(4.dp)) + Row { + Spacer(Modifier.width(nicknameMargin)) + Text( + text = stringResource(R.string.register_nickname_rule), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + } +} + +@Composable +private fun BirthSection( + birth: String?, + nicknameMargin: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(PresentationR.string.birth), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Spacer(Modifier.width(nicknameMargin)) + Text( + text = birth ?: stringResource(R.string.birth_default), + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + modifier = + Modifier + .weight(1f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(vertical = 4.dp), + ) + } +} + +@Composable +private fun GenderSection( + gender: String?, + nicknameMargin: Dp, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Text( + text = stringResource(PresentationR.string.gender), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Spacer(Modifier.width(nicknameMargin)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + GenderItem( + label = stringResource(R.string.male), + selected = gender == MALE, + onClick = { onSelect(MALE) }, + ) + Spacer(Modifier.width(12.dp)) + GenderItem( + label = stringResource(R.string.female), + selected = gender == FEMALE, + onClick = { onSelect(FEMALE) }, + ) + } + Spacer(Modifier.height(12.dp)) + GenderItem( + label = stringResource(R.string.hidden), + selected = gender == HIDDEN, + onClick = { onSelect(HIDDEN) }, + ) + } + } +} + +@Composable +private fun GenderItem( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + Spacer(Modifier.width(4.dp)) + Image( + painter = + painterResource( + if (selected) R.drawable.ic_check else R.drawable.ic_uncheck, + ), + contentDescription = null, + ) + } +} + +@Preview(showBackground = true, heightDp = 900) +@Composable +private fun RegisterScreenPreview() { + AppTheme { + var nickname by remember { mutableStateOf("운다방테스터") } + var birth by remember { mutableStateOf("1990-01-01") } + var gender by remember { mutableStateOf(MALE) } + RegisterScreen( + nickname = nickname, + birth = birth, + gender = gender, + isFormValid = true, + onNicknameChange = { nickname = it }, + onBirthClick = {}, + onGenderSelect = { gender = it }, + onCompleteClick = {}, + ) + } +} + +@Preview(showBackground = true, heightDp = 900) +@Composable +private fun RegisterScreenEmptyPreview() { + AppTheme { + RegisterScreen( + nickname = "", + birth = null, + gender = null, + isFormValid = false, + onNicknameChange = {}, + onBirthClick = {}, + onGenderSelect = {}, + onCompleteClick = {}, + ) + } +} diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterViewModel.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterViewModel.kt index 6f1076e7..12d0c397 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterViewModel.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/RegisterViewModel.kt @@ -1,8 +1,5 @@ package com.project200.undabang.auth.register -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.common.utils.toLocalDate @@ -10,6 +7,15 @@ import com.project200.domain.model.BaseResult import com.project200.domain.usecase.SignUpUseCase import com.project200.domain.usecase.ValidateNicknameUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.LocalDate import javax.inject.Inject @@ -21,29 +27,26 @@ class RegisterViewModel private val signUpUseCase: SignUpUseCase, private val validateNicknameUseCase: ValidateNicknameUseCase, ) : ViewModel() { - private val _nickname = MutableLiveData("") - val nickname: LiveData = _nickname + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() - private val _birth = MutableLiveData(null) - val birth: LiveData = _birth + private val _birth = MutableStateFlow(null) + val birth: StateFlow = _birth.asStateFlow() - private val _gender = MutableLiveData(null) - val gender: LiveData = _gender + private val _gender = MutableStateFlow(null) + val gender: StateFlow = _gender.asStateFlow() - val isFormValid: LiveData = - MediatorLiveData().apply { - fun validateFormInputs() { - val isNicknameEntered = _nickname.value.orEmpty().isNotEmpty() - value = isNicknameEntered && !_birth.value.isNullOrEmpty() && !_gender.value.isNullOrEmpty() - } - addSource(_nickname) { validateFormInputs() } - addSource(_birth) { validateFormInputs() } - addSource(_gender) { validateFormInputs() } - validateFormInputs() - } + val isFormValid: StateFlow = + combine(_nickname, _birth, _gender) { nickname, birth, gender -> + nickname.isNotEmpty() && !birth.isNullOrEmpty() && !gender.isNullOrEmpty() + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) - private val _signUpResult = MutableLiveData>() - val signUpResult: LiveData> = _signUpResult + private val _signUpResult = MutableSharedFlow>() + val signUpResult: SharedFlow> = _signUpResult.asSharedFlow() fun updateNickname(value: String) { _nickname.value = value @@ -58,44 +61,47 @@ class RegisterViewModel } fun signUp() { - val currentNickname = _nickname.value.orEmpty() - val currentGender = _gender.value - val currentBirthStr = _birth.value + viewModelScope.launch { + val currentNickname = _nickname.value + val currentGender = _gender.value + val currentBirthStr = _birth.value - if (!validateNicknameUseCase(currentNickname)) { - _signUpResult.value = - BaseResult.Error( - errorCode = ERROR_CODE_INVALID_NICKNAME, - message = "", - cause = null, + if (!validateNicknameUseCase(currentNickname)) { + _signUpResult.emit( + BaseResult.Error( + errorCode = ERROR_CODE_INVALID_NICKNAME, + message = "", + cause = null, + ), ) - return - } + return@launch + } - if (currentGender == null || currentBirthStr == null) { - _signUpResult.value = - BaseResult.Error( - errorCode = FORM_INCOMPLETE, - message = "", - cause = null, + if (currentGender == null || currentBirthStr == null) { + _signUpResult.emit( + BaseResult.Error( + errorCode = FORM_INCOMPLETE, + message = "", + cause = null, + ), ) - return - } + return@launch + } - viewModelScope.launch { val birthDate = currentBirthStr.toLocalDate() ?: LocalDate.now() when (val result = signUpUseCase(currentGender, currentNickname, birthDate)) { is BaseResult.Success -> { - _signUpResult.value = BaseResult.Success(Unit) + _signUpResult.emit(BaseResult.Success(Unit)) } is BaseResult.Error -> { - _signUpResult.value = + _signUpResult.emit( BaseResult.Error( errorCode = result.errorCode, message = result.message, cause = result.cause, - ) + ), + ) } } } diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsFragment.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsFragment.kt index 10c2aeb1..77074d8e 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsFragment.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsFragment.kt @@ -1,69 +1,55 @@ package com.project200.undabang.auth.register +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.presentation.terms.TermsDialogFragment import com.project200.presentation.terms.TermsDialogFragment.Companion.PRIVACY import com.project200.presentation.terms.TermsDialogFragment.Companion.TERMS import com.project200.undabang.feature.auth.R -import com.project200.undabang.feature.auth.databinding.FragmentTermsBinding -import timber.log.Timber +import dagger.hilt.android.AndroidEntryPoint -class TermsFragment : BindingFragment(R.layout.fragment_terms) { +@AndroidEntryPoint +class TermsFragment : Fragment() { private val viewModel: TermsViewModel by viewModels() - override fun getViewBinding(view: View): FragmentTermsBinding { - return FragmentTermsBinding.bind(view) - } - - override fun setupViews() = - with(binding) { - // 체크 토글 - serviceBtnImv.setOnClickListener { viewModel.toggleService() } - privacyBtnImv.setOnClickListener { viewModel.togglePrivacy() } - /*locationBtnImv.setOnClickListener { viewModel.toggleLocation() } - notifyBtnImv.setOnClickListener { viewModel.toggleNotify() }*/ - - serviceTitleTv.setOnClickListener { - showTermsDialog(TERMS) - } - privacyTitleTv.setOnClickListener { - showTermsDialog(PRIVACY) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val serviceChecked by viewModel.serviceChecked.collectAsStateWithLifecycle() + val privacyChecked by viewModel.privacyChecked.collectAsStateWithLifecycle() + val isAllRequiredChecked by viewModel.isAllRequiredChecked.collectAsStateWithLifecycle() + + TermsScreen( + serviceChecked = serviceChecked, + privacyChecked = privacyChecked, + isAllRequiredChecked = isAllRequiredChecked, + onToggleService = { viewModel.toggleService() }, + onTogglePrivacy = { viewModel.togglePrivacy() }, + onServiceClick = { showTermsDialog(TERMS) }, + onPrivacyClick = { showTermsDialog(PRIVACY) }, + onNextClick = { + if (isAdded && findNavController().currentDestination?.id == R.id.termsFragment) { + findNavController().navigate(R.id.action_termsFragment_to_registerFragment) + } + }, + ) } - - termsNextBtn.setOnClickListener { - if (isAdded && findNavController().currentDestination?.id == R.id.termsFragment) { - findNavController().navigate(R.id.action_termsFragment_to_registerFragment) - } - } - } - - override fun setupObservers() { - viewModel.serviceChecked.observe(viewLifecycleOwner) { checked -> - binding.serviceBtnImv.isSelected = checked } - viewModel.privacyChecked.observe(viewLifecycleOwner) { checked -> - binding.privacyBtnImv.isSelected = checked - } - - /*viewModel.locationChecked.observe(viewLifecycleOwner) { checked -> - binding.locationBtnImv.isSelected = checked - } - - viewModel.notifyChecked.observe(viewLifecycleOwner) { checked -> - binding.notifyBtnImv.isSelected = checked - }*/ - - viewModel.isAllRequiredChecked.observe(viewLifecycleOwner) { allChecked -> - Timber.d("isAllRequiredChecked $allChecked") - binding.termsNextBtn.isEnabled = allChecked - } - } - private fun showTermsDialog(termsType: String) { - TermsDialogFragment(termsType).show(parentFragmentManager, TermsDialogFragment::class.java.simpleName) + TermsDialogFragment.newInstance(termsType).show(parentFragmentManager, TermsDialogFragment::class.java.simpleName) } } diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsScreen.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsScreen.kt new file mode 100644 index 00000000..7d6ed730 --- /dev/null +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsScreen.kt @@ -0,0 +1,173 @@ +package com.project200.undabang.auth.register + +import androidx.compose.foundation.clickable +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorGray100 +import com.project200.presentation.compose.theme.header +import com.project200.presentation.compose.theme.subtext14 +import com.project200.undabang.feature.auth.R +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun TermsScreen( + serviceChecked: Boolean, + privacyChecked: Boolean, + isAllRequiredChecked: Boolean, + onToggleService: () -> Unit, + onTogglePrivacy: () -> Unit, + onServiceClick: () -> Unit, + onPrivacyClick: () -> Unit, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + bottomBar = { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + TermsRow( + title = stringResource(R.string.terms_required_service), + checked = serviceChecked, + onTitleClick = onServiceClick, + onToggle = onToggleService, + ) + Spacer(Modifier.height(16.dp)) + TermsRow( + title = stringResource(R.string.terms_required_privacy), + checked = privacyChecked, + onTitleClick = onPrivacyClick, + onToggle = onTogglePrivacy, + ) + Spacer(Modifier.height(77.dp)) + PrimaryButton( + text = stringResource(PresentationR.string.confirm), + onClick = onNextClick, + modifier = Modifier.fillMaxWidth(), + enabled = isAllRequiredChecked, + ) + Spacer(Modifier.height(32.dp)) + } + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .padding(horizontal = 20.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(114.dp)) + + Text( + text = stringResource(R.string.terms_title), + style = MaterialTheme.typography.header, + ) + } + } +} + +@Composable +private fun TermsRow( + title: String, + checked: Boolean, + onTitleClick: () -> Unit, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.clickable(onClick = onTitleClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.subtext14, + color = ColorGray100, + ) + Spacer(Modifier.size(4.dp)) + Icon( + painter = painterResource(PresentationR.drawable.ic_arrow_right), + contentDescription = null, + tint = Color.Unspecified, + ) + } + Icon( + painter = + painterResource( + if (checked) PresentationR.drawable.ic_select_check else R.drawable.ic_uncheck, + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.clickable(onClick = onToggle), + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun TermsScreenPreview() { + AppTheme { + var service by remember { mutableStateOf(false) } + var privacy by remember { mutableStateOf(false) } + TermsScreen( + serviceChecked = service, + privacyChecked = privacy, + isAllRequiredChecked = service && privacy, + onToggleService = { service = !service }, + onTogglePrivacy = { privacy = !privacy }, + onServiceClick = {}, + onPrivacyClick = {}, + onNextClick = {}, + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun TermsScreenAllCheckedPreview() { + AppTheme { + TermsScreen( + serviceChecked = true, + privacyChecked = true, + isAllRequiredChecked = true, + onToggleService = {}, + onTogglePrivacy = {}, + onServiceClick = {}, + onPrivacyClick = {}, + onNextClick = {}, + ) + } +} diff --git a/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsViewModel.kt b/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsViewModel.kt index 07c7d7c6..5a37722d 100644 --- a/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsViewModel.kt +++ b/feature/auth/src/main/java/com/project200/undabang/auth/register/TermsViewModel.kt @@ -1,55 +1,36 @@ package com.project200.undabang.auth.register -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class TermsViewModel @Inject constructor() : ViewModel() { - private val _serviceChecked = MutableLiveData(false) - val serviceChecked: LiveData = _serviceChecked + private val _serviceChecked = MutableStateFlow(false) + val serviceChecked: StateFlow = _serviceChecked.asStateFlow() - private val _privacyChecked = MutableLiveData(false) - val privacyChecked: LiveData = _privacyChecked + private val _privacyChecked = MutableStateFlow(false) + val privacyChecked: StateFlow = _privacyChecked.asStateFlow() - /* private val _locationChecked = MutableLiveData(false) - val locationChecked: LiveData = _locationChecked - - private val _notifyChecked = MutableLiveData(false) - val notifyChecked: LiveData = _notifyChecked */ - - val isAllRequiredChecked: LiveData = - MediatorLiveData().apply { - fun update() { - value = ( - _serviceChecked.value == true && - _privacyChecked.value == true - ) - } - - addSource(_serviceChecked) { update() } - addSource(_privacyChecked) { update() } - update() - } + val isAllRequiredChecked: StateFlow = + combine(_serviceChecked, _privacyChecked) { service, privacy -> + service && privacy + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) fun toggleService() { - _serviceChecked.value = !(_serviceChecked.value ?: false) + _serviceChecked.value = !_serviceChecked.value } fun togglePrivacy() { - _privacyChecked.value = !(_privacyChecked.value ?: false) + _privacyChecked.value = !_privacyChecked.value } - - /*fun toggleLocation() { - _locationChecked.value = !(_locationChecked.value ?: false) - } - - fun toggleNotify() { - _notifyChecked.value = !(_notifyChecked.value ?: false) - }*/ } diff --git a/feature/auth/src/main/res/drawable/selector_terms_check.xml b/feature/auth/src/main/res/drawable/selector_terms_check.xml deleted file mode 100644 index cc0619b6..00000000 --- a/feature/auth/src/main/res/drawable/selector_terms_check.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/auth/src/main/res/layout/fragment_permission.xml b/feature/auth/src/main/res/layout/fragment_permission.xml deleted file mode 100644 index c78a65ef..00000000 --- a/feature/auth/src/main/res/layout/fragment_permission.xml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/auth/src/main/res/layout/fragment_register.xml b/feature/auth/src/main/res/layout/fragment_register.xml deleted file mode 100644 index 7401ff4d..00000000 --- a/feature/auth/src/main/res/layout/fragment_register.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/auth/src/main/res/layout/fragment_terms.xml b/feature/auth/src/main/res/layout/fragment_terms.xml deleted file mode 100644 index 3b75c0fe..00000000 --- a/feature/auth/src/main/res/layout/fragment_terms.xml +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/auth/src/main/res/navigation/onboardong_nav_graph.xml b/feature/auth/src/main/res/navigation/onboardong_nav_graph.xml index 93cfcad1..1d752c7e 100644 --- a/feature/auth/src/main/res/navigation/onboardong_nav_graph.xml +++ b/feature/auth/src/main/res/navigation/onboardong_nav_graph.xml @@ -8,8 +8,7 @@ + android:label="fragment_permission" > @@ -17,8 +16,7 @@ + android:label="fragment_terms" > @@ -26,6 +24,5 @@ + android:label="fragment_sign_up" /> \ No newline at end of file diff --git a/feature/auth/src/main/res/values/dimens.xml b/feature/auth/src/main/res/values/dimens.xml deleted file mode 100644 index 8cbaf026..00000000 --- a/feature/auth/src/main/res/values/dimens.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - 100dp - 60dp - \ No newline at end of file diff --git a/feature/auth/src/test/java/com/project200/undabang/auth/LoginViewModelTest.kt b/feature/auth/src/test/java/com/project200/undabang/auth/LoginViewModelTest.kt index 747be445..6ca93d8e 100644 --- a/feature/auth/src/test/java/com/project200/undabang/auth/LoginViewModelTest.kt +++ b/feature/auth/src/test/java/com/project200/undabang/auth/LoginViewModelTest.kt @@ -2,9 +2,8 @@ package com.project200.undabang.auth import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat -import com.project200.domain.model.BaseResult -import com.project200.domain.usecase.LoginUseCase -import com.project200.domain.usecase.SendFcmTokenUseCase +import com.project200.domain.model.RegistrationStatus +import com.project200.domain.usecase.CheckIsRegisteredUseCase import com.project200.undabang.auth.login.LoginViewModel import io.mockk.coEvery import io.mockk.coVerify @@ -30,10 +29,7 @@ class LoginViewModelTest { val instantTaskExecutorRule = InstantTaskExecutorRule() @MockK - private lateinit var loginUseCase: LoginUseCase - - @MockK - private lateinit var sendFcmTokenUseCase: SendFcmTokenUseCase + private lateinit var checkIsRegisteredUseCase: CheckIsRegisteredUseCase private lateinit var viewModel: LoginViewModel @@ -50,14 +46,14 @@ class LoginViewModelTest { } private fun createViewModel(): LoginViewModel { - return LoginViewModel(loginUseCase, sendFcmTokenUseCase) + return LoginViewModel(checkIsRegisteredUseCase) } @Test - fun `checkIsRegistered - 로그인 성공 시 Success 결과를 반환한다`() = + fun `checkIsRegistered - 가입된 사용자면 Registered 결과를 반환한다`() = runTest { // Given - coEvery { loginUseCase() } returns BaseResult.Success(Unit) + coEvery { checkIsRegisteredUseCase() } returns RegistrationStatus.Registered viewModel = createViewModel() // When @@ -65,15 +61,15 @@ class LoginViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.loginResult.value).isInstanceOf(BaseResult.Success::class.java) - coVerify(exactly = 1) { loginUseCase() } + assertThat(viewModel.registrationResult.value).isEqualTo(RegistrationStatus.Registered) + coVerify(exactly = 1) { checkIsRegisteredUseCase() } } @Test - fun `checkIsRegistered - 로그인 실패 시 Error 결과를 반환한다`() = + fun `checkIsRegistered - 미가입 사용자면 Unregistered 결과를 반환한다`() = runTest { // Given - coEvery { loginUseCase() } returns BaseResult.Error("ERROR", "Login failed") + coEvery { checkIsRegisteredUseCase() } returns RegistrationStatus.Unregistered viewModel = createViewModel() // When @@ -81,43 +77,23 @@ class LoginViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - val result = viewModel.loginResult.value - assertThat(result).isInstanceOf(BaseResult.Error::class.java) - assertThat((result as BaseResult.Error).errorCode).isEqualTo("ERROR") - } - - @Test - fun `sendFcmToken - FCM 토큰 전송 성공 시 Success 이벤트를 발생시킨다`() = - runTest { - // Given - coEvery { loginUseCase() } returns BaseResult.Success(Unit) - coEvery { sendFcmTokenUseCase() } returns BaseResult.Success(Unit) - viewModel = createViewModel() - - // When - viewModel.sendFcmToken() - testDispatcher.scheduler.advanceUntilIdle() - - // Then - assertThat(viewModel.fcmTokenEvent.value).isInstanceOf(BaseResult.Success::class.java) - coVerify(exactly = 1) { sendFcmTokenUseCase() } + assertThat(viewModel.registrationResult.value).isEqualTo(RegistrationStatus.Unregistered) + coVerify(exactly = 1) { checkIsRegisteredUseCase() } } @Test - fun `sendFcmToken - FCM 토큰 전송 실패 시 Error 이벤트를 발생시킨다`() = + fun `checkIsRegistered - 확인 불가면 Indeterminate 결과를 반환한다`() = runTest { // Given - coEvery { loginUseCase() } returns BaseResult.Success(Unit) - coEvery { sendFcmTokenUseCase() } returns BaseResult.Error("FCM_ERROR", "FCM token send failed") + coEvery { checkIsRegisteredUseCase() } returns RegistrationStatus.Indeterminate viewModel = createViewModel() // When - viewModel.sendFcmToken() + viewModel.checkIsRegistered() testDispatcher.scheduler.advanceUntilIdle() // Then - val result = viewModel.fcmTokenEvent.value - assertThat(result).isInstanceOf(BaseResult.Error::class.java) - assertThat((result as BaseResult.Error).errorCode).isEqualTo("FCM_ERROR") + assertThat(viewModel.registrationResult.value).isEqualTo(RegistrationStatus.Indeterminate) + coVerify(exactly = 1) { checkIsRegisteredUseCase() } } } diff --git a/feature/auth/src/test/java/com/project200/undabang/auth/RegisterViewModelTest.kt b/feature/auth/src/test/java/com/project200/undabang/auth/RegisterViewModelTest.kt index 421c623b..0a015032 100644 --- a/feature/auth/src/test/java/com/project200/undabang/auth/RegisterViewModelTest.kt +++ b/feature/auth/src/test/java/com/project200/undabang/auth/RegisterViewModelTest.kt @@ -1,6 +1,6 @@ package com.project200.undabang.auth -import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult import com.project200.domain.usecase.SignUpUseCase @@ -28,9 +28,6 @@ class RegisterViewModelTest { @get:Rule val mockkRule = MockKRule(this) - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @MockK private lateinit var signUpUseCase: SignUpUseCase @@ -53,179 +50,160 @@ class RegisterViewModelTest { } @Test - fun `updateNickname - 닉네임 업데이트 시 LiveData가 변경된다`() { - // Given + fun `updateNickname - 닉네임 업데이트 시 StateFlow가 변경된다`() { val testNickname = "테스트닉네임" - // When viewModel.updateNickname(testNickname) - // Then assertThat(viewModel.nickname.value).isEqualTo(testNickname) } @Test - fun `updateBirth - 생년월일 업데이트 시 LiveData가 변경된다`() { - // Given + fun `updateBirth - 생년월일 업데이트 시 StateFlow가 변경된다`() { val testBirth = "1990-01-01" - // When viewModel.updateBirth(testBirth) - // Then assertThat(viewModel.birth.value).isEqualTo(testBirth) } @Test - fun `selectGender - 성별 선택 시 LiveData가 변경된다`() { - // Given + fun `selectGender - 성별 선택 시 StateFlow가 변경된다`() { val testGender = "MALE" - // When viewModel.selectGender(testGender) - // Then assertThat(viewModel.gender.value).isEqualTo(testGender) } @Test - fun `isFormValid - 모든 필드가 입력되면 true를 반환한다`() { - // Given - observe to activate MediatorLiveData - viewModel.isFormValid.observeForever {} - - // When - viewModel.updateNickname("테스트") - viewModel.updateBirth("1990-01-01") - viewModel.selectGender("MALE") + fun `isFormValid - 모든 필드가 입력되면 true를 반환한다`() = + runTest { + viewModel.updateNickname("테스트") + viewModel.updateBirth("1990-01-01") + viewModel.selectGender("MALE") + testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.isFormValid.value).isTrue() - } + assertThat(viewModel.isFormValid.value).isTrue() + } @Test - fun `isFormValid - 닉네임이 비어있으면 false를 반환한다`() { - // Given & When - viewModel.updateNickname("") - viewModel.updateBirth("1990-01-01") - viewModel.selectGender("MALE") - - // Then - assertThat(viewModel.isFormValid.value).isFalse() - } + fun `isFormValid - 닉네임이 비어있으면 false를 반환한다`() = + runTest { + viewModel.updateNickname("") + viewModel.updateBirth("1990-01-01") + viewModel.selectGender("MALE") + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.isFormValid.value).isFalse() + } @Test - fun `isFormValid - 생년월일이 비어있으면 false를 반환한다`() { - // Given & When - viewModel.updateNickname("테스트") - viewModel.selectGender("MALE") + fun `isFormValid - 생년월일이 비어있으면 false를 반환한다`() = + runTest { + viewModel.updateNickname("테스트") + viewModel.selectGender("MALE") + testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.isFormValid.value).isFalse() - } + assertThat(viewModel.isFormValid.value).isFalse() + } @Test - fun `isFormValid - 성별이 선택되지 않으면 false를 반환한다`() { - // Given & When - viewModel.updateNickname("테스트") - viewModel.updateBirth("1990-01-01") + fun `isFormValid - 성별이 선택되지 않으면 false를 반환한다`() = + runTest { + viewModel.updateNickname("테스트") + viewModel.updateBirth("1990-01-01") + testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.isFormValid.value).isFalse() - } + assertThat(viewModel.isFormValid.value).isFalse() + } @Test fun `signUp - 닉네임 유효성 검사 실패 시 INVALID_NICKNAME 에러를 반환한다`() = runTest { - // Given every { validateNicknameUseCase(any()) } returns false viewModel.updateNickname("잘못된닉네임") viewModel.updateBirth("1990-01-01") viewModel.selectGender("MALE") - // When - viewModel.signUp() - testDispatcher.scheduler.advanceUntilIdle() - - // Then - val result = viewModel.signUpResult.value - assertThat(result).isInstanceOf(BaseResult.Error::class.java) - assertThat((result as BaseResult.Error).errorCode).isEqualTo(RegisterViewModel.ERROR_CODE_INVALID_NICKNAME) + viewModel.signUpResult.test { + viewModel.signUp() + testDispatcher.scheduler.advanceUntilIdle() + val result = awaitItem() + assertThat(result).isInstanceOf(BaseResult.Error::class.java) + assertThat((result as BaseResult.Error).errorCode).isEqualTo(RegisterViewModel.ERROR_CODE_INVALID_NICKNAME) + cancelAndIgnoreRemainingEvents() + } } @Test fun `signUp - 성별이 null이면 FORM_INCOMPLETE 에러를 반환한다`() = runTest { - // Given every { validateNicknameUseCase(any()) } returns true viewModel.updateNickname("테스트닉네임") viewModel.updateBirth("1990-01-01") - // gender is not set - - // When - viewModel.signUp() - testDispatcher.scheduler.advanceUntilIdle() - // Then - val result = viewModel.signUpResult.value - assertThat(result).isInstanceOf(BaseResult.Error::class.java) - assertThat((result as BaseResult.Error).errorCode).isEqualTo(RegisterViewModel.FORM_INCOMPLETE) + viewModel.signUpResult.test { + viewModel.signUp() + testDispatcher.scheduler.advanceUntilIdle() + val result = awaitItem() + assertThat(result).isInstanceOf(BaseResult.Error::class.java) + assertThat((result as BaseResult.Error).errorCode).isEqualTo(RegisterViewModel.FORM_INCOMPLETE) + cancelAndIgnoreRemainingEvents() + } } @Test fun `signUp - 생년월일이 null이면 FORM_INCOMPLETE 에러를 반환한다`() = runTest { - // Given every { validateNicknameUseCase(any()) } returns true viewModel.updateNickname("테스트닉네임") viewModel.selectGender("MALE") - // birth is not set - - // When - viewModel.signUp() - testDispatcher.scheduler.advanceUntilIdle() - // Then - val result = viewModel.signUpResult.value - assertThat(result).isInstanceOf(BaseResult.Error::class.java) - assertThat((result as BaseResult.Error).errorCode).isEqualTo(RegisterViewModel.FORM_INCOMPLETE) + viewModel.signUpResult.test { + viewModel.signUp() + testDispatcher.scheduler.advanceUntilIdle() + val result = awaitItem() + assertThat(result).isInstanceOf(BaseResult.Error::class.java) + assertThat((result as BaseResult.Error).errorCode).isEqualTo(RegisterViewModel.FORM_INCOMPLETE) + cancelAndIgnoreRemainingEvents() + } } @Test fun `signUp - 회원가입 성공 시 Success 결과를 반환한다`() = runTest { - // Given every { validateNicknameUseCase(any()) } returns true coEvery { signUpUseCase(any(), any(), any()) } returns BaseResult.Success(Unit) viewModel.updateNickname("테스트닉네임") viewModel.updateBirth("1990-01-01") viewModel.selectGender("MALE") - // When - viewModel.signUp() - testDispatcher.scheduler.advanceUntilIdle() - - // Then - assertThat(viewModel.signUpResult.value).isInstanceOf(BaseResult.Success::class.java) + viewModel.signUpResult.test { + viewModel.signUp() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isInstanceOf(BaseResult.Success::class.java) + cancelAndIgnoreRemainingEvents() + } coVerify(exactly = 1) { signUpUseCase("MALE", "테스트닉네임", LocalDate.of(1990, 1, 1)) } } @Test fun `signUp - 회원가입 실패 시 Error 결과를 반환한다`() = runTest { - // Given every { validateNicknameUseCase(any()) } returns true coEvery { signUpUseCase(any(), any(), any()) } returns BaseResult.Error("SIGNUP_ERROR", "SignUp failed") viewModel.updateNickname("테스트닉네임") viewModel.updateBirth("1990-01-01") viewModel.selectGender("MALE") - // When - viewModel.signUp() - testDispatcher.scheduler.advanceUntilIdle() - - // Then - val result = viewModel.signUpResult.value - assertThat(result).isInstanceOf(BaseResult.Error::class.java) - assertThat((result as BaseResult.Error).errorCode).isEqualTo("SIGNUP_ERROR") + viewModel.signUpResult.test { + viewModel.signUp() + testDispatcher.scheduler.advanceUntilIdle() + val result = awaitItem() + assertThat(result).isInstanceOf(BaseResult.Error::class.java) + assertThat((result as BaseResult.Error).errorCode).isEqualTo("SIGNUP_ERROR") + cancelAndIgnoreRemainingEvents() + } } } diff --git a/feature/auth/src/test/java/com/project200/undabang/auth/TermsViewModelTest.kt b/feature/auth/src/test/java/com/project200/undabang/auth/TermsViewModelTest.kt index d5ef54b8..1e76e6ed 100644 --- a/feature/auth/src/test/java/com/project200/undabang/auth/TermsViewModelTest.kt +++ b/feature/auth/src/test/java/com/project200/undabang/auth/TermsViewModelTest.kt @@ -1,128 +1,121 @@ package com.project200.undabang.auth -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat import com.project200.undabang.auth.register.TermsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test +@ExperimentalCoroutinesApi class TermsViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - + private val testDispatcher = StandardTestDispatcher() private lateinit var viewModel: TermsViewModel @Before fun setUp() { + Dispatchers.setMain(testDispatcher) viewModel = TermsViewModel() } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun `초기 상태에서 서비스 약관은 체크되지 않음`() { - // Then assertThat(viewModel.serviceChecked.value).isFalse() } @Test fun `초기 상태에서 개인정보 약관은 체크되지 않음`() { - // Then assertThat(viewModel.privacyChecked.value).isFalse() } @Test fun `초기 상태에서 필수 약관 전체 동의는 false`() { - // Then assertThat(viewModel.isAllRequiredChecked.value).isFalse() } @Test fun `toggleService - 서비스 약관 토글 시 상태가 변경된다`() { - // Given assertThat(viewModel.serviceChecked.value).isFalse() - // When viewModel.toggleService() - // Then assertThat(viewModel.serviceChecked.value).isTrue() } @Test fun `toggleService - 두 번 토글 시 원래 상태로 돌아온다`() { - // When viewModel.toggleService() viewModel.toggleService() - // Then assertThat(viewModel.serviceChecked.value).isFalse() } @Test fun `togglePrivacy - 개인정보 약관 토글 시 상태가 변경된다`() { - // Given assertThat(viewModel.privacyChecked.value).isFalse() - // When viewModel.togglePrivacy() - // Then assertThat(viewModel.privacyChecked.value).isTrue() } @Test fun `togglePrivacy - 두 번 토글 시 원래 상태로 돌아온다`() { - // When viewModel.togglePrivacy() viewModel.togglePrivacy() - // Then assertThat(viewModel.privacyChecked.value).isFalse() } @Test - fun `isAllRequiredChecked - 서비스 약관만 체크하면 false`() { - // When - viewModel.toggleService() + fun `isAllRequiredChecked - 서비스 약관만 체크하면 false`() = + runTest { + viewModel.toggleService() + testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.isAllRequiredChecked.value).isFalse() - } + assertThat(viewModel.isAllRequiredChecked.value).isFalse() + } @Test - fun `isAllRequiredChecked - 개인정보 약관만 체크하면 false`() { - // When - viewModel.togglePrivacy() + fun `isAllRequiredChecked - 개인정보 약관만 체크하면 false`() = + runTest { + viewModel.togglePrivacy() + testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.isAllRequiredChecked.value).isFalse() - } + assertThat(viewModel.isAllRequiredChecked.value).isFalse() + } @Test - fun `isAllRequiredChecked - 모든 필수 약관 체크 시 true`() { - // Given - viewModel.isAllRequiredChecked.observeForever {} - - // When - viewModel.toggleService() - viewModel.togglePrivacy() + fun `isAllRequiredChecked - 모든 필수 약관 체크 시 true`() = + runTest { + viewModel.toggleService() + viewModel.togglePrivacy() + testDispatcher.scheduler.advanceUntilIdle() - // Then - assertThat(viewModel.isAllRequiredChecked.value).isTrue() - } + assertThat(viewModel.isAllRequiredChecked.value).isTrue() + } @Test - fun `isAllRequiredChecked - 모든 약관 체크 후 하나 해제하면 false`() { - // Given - viewModel.isAllRequiredChecked.observeForever {} - viewModel.toggleService() - viewModel.togglePrivacy() - assertThat(viewModel.isAllRequiredChecked.value).isTrue() - - // When - viewModel.toggleService() - - // Then - assertThat(viewModel.isAllRequiredChecked.value).isFalse() - } + fun `isAllRequiredChecked - 모든 약관 체크 후 하나 해제하면 false`() = + runTest { + viewModel.toggleService() + viewModel.togglePrivacy() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isAllRequiredChecked.value).isTrue() + + viewModel.toggleService() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.isAllRequiredChecked.value).isFalse() + } } diff --git a/feature/exercise/build.gradle.kts b/feature/exercise/build.gradle.kts index 98254ecd..f345a7be 100644 --- a/feature/exercise/build.gradle.kts +++ b/feature/exercise/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("convention.android.library") id("convention.android.hilt") + id("convention.android.compose") alias(libs.plugins.navigation.safeargs) id("convention.android.jacoco") } @@ -43,6 +44,9 @@ dependencies { implementation(libs.glide) ksp(libs.glide.compiler.ksp) + // Coil (Compose) + implementation(libs.coil.compose) + // CirlceIndicator implementation(libs.circleindicator) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt index e1b2ce72..5c3e90db 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt @@ -1,91 +1,94 @@ package com.project200.feature.exercise.detail +import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.TextView +import android.view.ViewGroup import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import com.project200.common.utils.CommonDateTimeFormatters import com.project200.domain.model.BaseResult -import com.project200.domain.model.ExerciseRecord -import com.project200.presentation.base.BaseAlertDialog -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.components.feedback.UndabangAlertDialogFragment +import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.UiState import com.project200.presentation.utils.mapFailureToString import com.project200.undabang.feature.exercise.R -import com.project200.undabang.feature.exercise.databinding.FragmentExerciseDetailBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class ExerciseDetailFragment : BindingFragment(R.layout.fragment_exercise_detail) { +class ExerciseDetailFragment : Fragment() { private val viewModel: ExerciseDetailViewModel by viewModels() private val args: ExerciseDetailFragmentArgs by navArgs() - private var currentRecord: ExerciseRecord? = null - override fun getViewBinding(view: View): FragmentExerciseDetailBinding { - return FragmentExerciseDetailBinding.bind(view) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.getExerciseRecord(args.recordId) } - override fun setupViews() { - viewModel.getExerciseRecord(args.recordId) - binding.baseToolbar.apply { - setTitle(getString(R.string.exercise_detail)) - showBackButton(true) { findNavController().navigateUp() } - setSecondarySubButton(R.drawable.ic_share) { - val record = currentRecord - if (record?.pictures.isNullOrEmpty()) { - Toast.makeText(requireContext(), R.string.share_image_required, Toast.LENGTH_SHORT).show() - } else { - findNavController().navigate( - ExerciseDetailFragmentDirections.actionExerciseDetailFragmentToExerciseShareEditFragment(args.recordId), - ) - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val state by viewModel.exerciseRecord.collectAsStateWithLifecycle() + ExerciseDetailScreen( + state = state, + onBackClick = { findNavController().navigateUp() }, + onShareClick = ::handleShareClick, + onMenuClick = ::showExerciseDetailMenu, + ) } - setSubButton(R.drawable.ic_menu) { showExerciseDetailMenu() } } - } - override fun setupObservers() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.exerciseRecord.collect { state -> - binding.shimmerLayout.visibility = if (state is UiState.Loading) View.VISIBLE else View.GONE - binding.scrollView.visibility = if (state is UiState.Success) View.VISIBLE else View.GONE + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) - when (state) { - is UiState.Loading -> { - binding.shimmerLayout.startShimmer() - } - is UiState.Success -> { - binding.shimmerLayout.stopShimmer() - currentRecord = state.data - bindExerciseRecordData(state.data) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.exerciseRecord.collect { state -> + // 에러 시 토스트 + if (state is UiState.Error) { + Toast.makeText( + requireContext(), + requireContext().mapFailureToString(state.failure), + Toast.LENGTH_SHORT, + ).show() } - is UiState.Error -> { - binding.shimmerLayout.stopShimmer() - Toast.makeText(requireContext(), requireContext().mapFailureToString(state.failure), Toast.LENGTH_SHORT).show() + } + } + launch { + viewModel.deleteResult.collect { result -> + when (result) { + is BaseResult.Success -> findNavController().popBackStack() + is BaseResult.Error -> { + Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + } } } } } } - viewModel.deleteResult.observe(viewLifecycleOwner) { result -> - when (result) { - is BaseResult.Success -> { - findNavController().popBackStack() - } - is BaseResult.Error -> { - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() - } - } - } + observeRefreshSignal() + } + private fun observeRefreshSignal() { // 이전 화면에서 새로고침 요청이 있을 경우에만 데이터를 새로고침합니다. val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle savedStateHandle?.getLiveData(KEY_RECORD_UPDATED)?.observe(viewLifecycleOwner) { shouldRefresh -> @@ -96,78 +99,38 @@ class ExerciseDetailFragment : BindingFragment(R. } } - private fun bindExerciseRecordData(record: ExerciseRecord) { - with(binding) { - // ViewPager2와 CircleIndicator3 설정 - val currentPictureUrls = record.pictures?.map { it.url } - if (currentPictureUrls.isNullOrEmpty()) { - recordImgContainerFl.visibility = View.GONE - } else { - recordImgContainerFl.visibility = View.VISIBLE - recordImgVp.adapter = ImageSliderAdapter(currentPictureUrls) - - val indicator = recordImgIndicator - indicator.setViewPager(recordImgVp) - } - - recordTitleTv.text = record.title - recordTypeTv.setTextOrHide(record.personalType, recordTypeTitleTv) - - // 시간 처리 - val dateTimeFormatter = CommonDateTimeFormatters.YY_MM_DD_HH_MM - val startTimeString = record.startedAt.format(dateTimeFormatter) - val endTimeString = record.endedAt.format(dateTimeFormatter) - - if (startTimeString.isNullOrBlank() && endTimeString.isNullOrBlank()) { - recordTimeCl.visibility = View.GONE - } else { - recordTimeCl.visibility = View.VISIBLE - startTimeTv.text = startTimeString - endTimeTv.text = endTimeString - } - - // 장소 처리 - recordLocationTv.setTextOrHide(record.location, recordLocationTitleTv) - - // 내용 처리 - recordDescTv.setTextOrHide(record.detail, recordDescTitleTv) - } - } - - private fun TextView.setTextOrHide( - newText: String?, - titleView: TextView? = null, - ) { - if (!newText.isNullOrBlank()) { - this.text = newText - this.visibility = View.VISIBLE - titleView?.visibility = View.VISIBLE + private fun handleShareClick() { + val record = (viewModel.exerciseRecord.value as? UiState.Success)?.data + if (record?.pictures.isNullOrEmpty()) { + Toast.makeText(requireContext(), R.string.share_image_required, Toast.LENGTH_SHORT).show() } else { - this.visibility = View.GONE - titleView?.visibility = View.GONE + findNavController().navigate( + ExerciseDetailFragmentDirections.actionExerciseDetailFragmentToExerciseShareEditFragment(args.recordId), + ) } } private fun showExerciseDetailMenu() { - ExerciseMenuBottomSheetDialog( - onEditClicked = { + // 종속 다이얼로그(ExerciseMenuBottomSheetDialog) → 공통 UndabangBottomSheet.showMenu로 대체 + UndabangBottomSheet.showMenu( + fragmentManager = parentFragmentManager, + onEditClick = { findNavController().navigate( ExerciseDetailFragmentDirections .actionExerciseDetailFragmentToExerciseFormFragment(args.recordId), ) }, - onDeleteClicked = { showDeleteConfirmationDialog() }, - ).show(parentFragmentManager, ExerciseMenuBottomSheetDialog::class.java.simpleName) + onDeleteClick = { showDeleteConfirmationDialog() }, + ) } private fun showDeleteConfirmationDialog() { - BaseAlertDialog( + UndabangAlertDialogFragment.show( + fragmentManager = parentFragmentManager, title = getString(R.string.exercise_record_delete_alert), - desc = null, - onConfirmClicked = { - viewModel.deleteExerciseRecord(args.recordId) - }, - ).show(parentFragmentManager, BaseAlertDialog::class.java.simpleName) + onCancel = {}, + onConfirm = { viewModel.deleteExerciseRecord(args.recordId) }, + ) } companion object { diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt new file mode 100644 index 00000000..dfc75c1f --- /dev/null +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt @@ -0,0 +1,289 @@ +package com.project200.feature.exercise.detail + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.project200.common.utils.CommonDateTimeFormatters +import com.project200.domain.model.ExerciseRecord +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite100 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.subtext14 +import com.project200.presentation.utils.UiState +import com.project200.undabang.feature.exercise.R + +@Composable +fun ExerciseDetailScreen( + state: UiState, + onBackClick: () -> Unit, + onShareClick: () -> Unit, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = stringResource(R.string.exercise_detail), + onNavigationClick = onBackClick, + actions = { + // 우측: 공유 + 메뉴 + IconButton(onClick = onShareClick) { + Icon( + painter = painterResource(R.drawable.ic_share), + contentDescription = null, + tint = Color.Unspecified, + ) + } + IconButton(onClick = onMenuClick) { + Icon( + painter = painterResource(R.drawable.ic_menu), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when (state) { + is UiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + is UiState.Success -> { + DetailContent(record = state.data) + } + is UiState.Error -> { + Text( + text = stringResource(R.string.exercise_load_fail), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DetailContent( + record: ExerciseRecord, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 40.dp), + ) { + // 이미지 슬라이더 (HorizontalPager + 인디케이터) + val pictures = record.pictures.orEmpty() + if (pictures.isNotEmpty()) { + val pagerState = rememberPagerState(pageCount = { pictures.size }) + Box( + modifier = + Modifier + .fillMaxWidth() + .background(ColorBlack), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth().height(240.dp), + ) { page -> + AsyncImage( + model = pictures[page].url, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } + // 페이지 인디케이터 (하단 가운데) + if (pictures.size > 1) { + Row( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) { + repeat(pictures.size) { index -> + val color = if (index == pagerState.currentPage) ColorMain else ColorWhite300 + Box( + modifier = + Modifier + .padding(horizontal = 4.dp) + .size(6.dp) + .clip(CircleShape) + .background(color), + ) + } + } + } + } + } + + Spacer(Modifier.height(20.dp)) + + // 제목 (항상 표시) + DetailField( + label = stringResource(R.string.exercise_record_title), + value = record.title, + ) + + // 운동 종류 (있을 때만) + if (!record.personalType.isNullOrBlank()) { + DetailField( + label = stringResource(R.string.exercise_record_type), + value = record.personalType.orEmpty(), + ) + } + + // 운동 시간 (시작/종료) + val formatter = CommonDateTimeFormatters.YY_MM_DD_HH_MM + val startStr = record.startedAt.format(formatter) + val endStr = record.endedAt.format(formatter) + if (!startStr.isNullOrBlank() || !endStr.isNullOrBlank()) { + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.exercise_record_time), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.padding(horizontal = 20.dp), + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = startStr.orEmpty(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(com.project200.undabang.presentation.R.drawable.ic_arrow_right), + contentDescription = null, + tint = ColorGray200, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = endStr.orEmpty(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + modifier = Modifier.weight(1f), + ) + } + } + + // 운동 장소 (있을 때만) + if (!record.location.isNullOrBlank()) { + DetailField( + label = stringResource(R.string.exercise_record_location), + value = record.location.orEmpty(), + ) + } + + // 운동 내용 (있을 때만) + if (!record.detail.isNullOrBlank()) { + DetailField( + label = stringResource(R.string.exercise_record_desc), + value = record.detail.orEmpty(), + minHeight = 120.dp, + ) + } + } +} + +@Composable +private fun DetailField( + label: String, + value: String, + modifier: Modifier = Modifier, + minHeight: androidx.compose.ui.unit.Dp = 44.dp, +) { + Spacer(Modifier.height(20.dp)) + Text( + text = label, + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = modifier.padding(start = 20.dp), + ) + Spacer(Modifier.height(8.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(8.dp)) + .background(ColorWhite100) + .padding(horizontal = 16.dp, vertical = 11.dp), + ) { + Text( + text = value, + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + modifier = Modifier.defaultMinSize(minHeight = minHeight), + ) + } +} + +@Preview(showBackground = true, heightDp = 800) +@Composable +private fun ExerciseDetailScreenPreview() { + AppTheme { + ExerciseDetailScreen( + state = UiState.Loading, + onBackClick = {}, + onShareClick = {}, + onMenuClick = {}, + ) + } +} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt index 19d3c8af..95ed7dbe 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt @@ -1,7 +1,5 @@ package com.project200.feature.exercise.detail -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -12,8 +10,11 @@ import com.project200.presentation.utils.UiState import com.project200.presentation.utils.mapCodeToFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +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.launch import javax.inject.Inject @@ -27,8 +28,9 @@ class ExerciseDetailViewModel private val _exerciseRecord = MutableStateFlow>(UiState.Loading) val exerciseRecord: StateFlow> = _exerciseRecord - private val _deleteResult = MutableLiveData>() - val deleteResult: LiveData> = _deleteResult + // 1회성 이벤트. emit 직후 popBackStack/토스트로 화면 사라짐 + private val _deleteResult = MutableSharedFlow>(replay = 1) + val deleteResult: SharedFlow> = _deleteResult.asSharedFlow() fun getExerciseRecord(recordId: Long) { viewModelScope.launch { @@ -47,7 +49,7 @@ class ExerciseDetailViewModel fun deleteExerciseRecord(recordId: Long) { viewModelScope.launch { - _deleteResult.value = deleteExerciseRecordUseCase(recordId) + _deleteResult.emit(deleteExerciseRecordUseCase(recordId)) } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseMenuBottomSheetDialog.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseMenuBottomSheetDialog.kt deleted file mode 100644 index c272c6d3..00000000 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseMenuBottomSheetDialog.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.project200.feature.exercise.detail - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.project200.undabang.feature.exercise.databinding.BottomSheetExerciseMenuBinding - -class ExerciseMenuBottomSheetDialog( - private val onEditClicked: () -> Unit, - private val onDeleteClicked: () -> Unit, -) : BottomSheetDialogFragment() { - private var _binding: BottomSheetExerciseMenuBinding? = null - val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, com.project200.undabang.presentation.R.style.CustomBottomSheetDialogTheme) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = BottomSheetExerciseMenuBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - binding.editBtn.setOnClickListener { - onEditClicked() - dismiss() - } - - binding.deleteBtn.setOnClickListener { - onDeleteClicked() - dismiss() - } - - binding.closeBtn.setOnClickListener { dismiss() } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ImageSliderAdapter.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ImageSliderAdapter.kt deleted file mode 100644 index 29479673..00000000 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ImageSliderAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.project200.feature.exercise.detail - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.project200.undabang.feature.exercise.R // 기능 모듈의 R 클래스 -import com.project200.undabang.feature.exercise.databinding.ItemImgSliderBinding - -class ImageSliderAdapter(private val imageUrls: List) : - RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ImageViewHolder { - val binding = ItemImgSliderBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ImageViewHolder(binding) - } - - override fun onBindViewHolder( - holder: ImageViewHolder, - position: Int, - ) { - holder.bind(imageUrls[position]) - } - - override fun getItemCount(): Int = imageUrls.size - - class ImageViewHolder(private val binding: ItemImgSliderBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(imageUrl: String) { - Glide.with(binding.root.context) - .load(imageUrl) - .placeholder(R.drawable.ic_menu) - .error(R.drawable.ic_menu) - .into(binding.sliderImg) - } - } -} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index 1f299fd1..c7d5568f 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -1,425 +1,357 @@ package com.project200.feature.exercise.form import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat.getDrawable -import androidx.core.view.isVisible +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.GridLayoutManager import com.project200.common.constants.RuleConstants.ALLOWED_EXTENSIONS import com.project200.common.constants.RuleConstants.MAX_IMAGE -import com.project200.common.utils.CommonDateTimeFormatters.HH_MM_KR -import com.project200.common.utils.CommonDateTimeFormatters.YYYY_MM_DD_KR import com.project200.domain.model.ExerciseEditResult -import com.project200.domain.model.ExerciseRecord import com.project200.domain.model.SubmissionResult import com.project200.feature.exercise.detail.ExerciseDetailFragment -import com.project200.feature.exercise.utils.ScoreGuidanceState -import com.project200.feature.exercise.utils.TimeSelectionState -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.ImageUtils.compressImage import com.project200.presentation.utils.ImageValidator import com.project200.presentation.utils.ImageValidator.FAIL_TO_READ import com.project200.presentation.utils.ImageValidator.INVALID_TYPE import com.project200.presentation.utils.ImageValidator.OVERSIZE -import com.project200.presentation.utils.KeyboardAdjustHelper.applyEdgeToEdgeInsets -import com.project200.presentation.utils.TimeEditTextLimiter.addRangeLimit -import com.project200.presentation.utils.UiUtils.dpToPx -import com.project200.presentation.utils.UiUtils.getScreenWidthPx -import com.project200.presentation.view.SelectionBottomSheetDialog import com.project200.undabang.feature.exercise.R -import com.project200.undabang.feature.exercise.databinding.FragmentExerciseFormBinding import dagger.hilt.android.AndroidEntryPoint -import java.time.ZoneId -import java.util.Calendar -import java.util.Date +import kotlinx.coroutines.launch +import java.time.LocalDate @AndroidEntryPoint -class ExerciseFormFragment : BindingFragment(R.layout.fragment_exercise_form) { +class ExerciseFormFragment : Fragment() { private val viewModel: ExerciseFormViewModel by viewModels() private val args: ExerciseFormFragmentArgs by navArgs() - private lateinit var imageAdapter: ExerciseImageAdapter private val pickMultipleMediaLauncher = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(MAX_IMAGE)) { uris -> if (uris.isNotEmpty()) { - val validatedUris = mutableListOf() - var errorReason: String? = null - - // 유효성 검사 및 유효한 URI 수집 - for (uri in uris) { - val (isValid, reason) = ImageValidator.validateImageFile(uri, requireContext()) - if (isValid) { - validatedUris.add(uri) - } else if (reason == OVERSIZE) { - compressImage(requireContext(), uri)?.let { validatedUris.add(it) } - } else { - errorReason = reason - } - } - - // 유효성 검사 에러가 있었다면 메시지 표시 - errorReason?.let { reason -> - val errorMessage = - when (reason) { - INVALID_TYPE -> getString(R.string.image_error_invalid_type, ALLOWED_EXTENSIONS.joinToString(", ")) - FAIL_TO_READ -> getString(R.string.image_error_file_read) - else -> getString(R.string.unknown_error) - } - Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() - } - - // 최대 이미지 개수를 넘은 경우 - if (validatedUris.size > viewModel.getCurrentPermittedImageCount()) { - Toast.makeText(requireContext(), getString(R.string.exercise_record_max_image), Toast.LENGTH_LONG).show() - } else { - if (validatedUris.isNotEmpty()) { - viewModel.addImage(validatedUris) - } - } + handlePickedImages(uris) } } - override fun getViewBinding(view: View): FragmentExerciseFormBinding { - return FragmentExerciseFormBinding.bind(view) - } - - private fun setupRVAdapter(calculatedItemSize: Int) { - imageAdapter = - ExerciseImageAdapter( - itemSize = calculatedItemSize, - onAddItemClick = { - val currentImageCount = viewModel.imageItems.value?.count { it !is ExerciseImageListItem.AddButtonItem } ?: 0 - if (currentImageCount >= MAX_IMAGE) { - Toast.makeText(requireContext(), getString(R.string.exercise_record_max_image), Toast.LENGTH_SHORT).show() - } else { - launchGallery() - } - }, - onDeleteItemClick = { item -> - viewModel.removeImage(item) - }, - ) - - binding.exerciseImageRv.apply { - addItemDecoration(GridItemDecoration(GRID_SPAN_COUNT, dpToPx(requireContext(), RV_MARGIN))) - layoutManager = GridLayoutManager(requireContext(), GRID_SPAN_COUNT) - adapter = imageAdapter - } - } - - override fun setupViews() { - binding.root.applyEdgeToEdgeInsets() - binding.baseToolbar.apply { - showBackButton(true) { findNavController().navigateUp() } - binding.baseToolbar.setTitle( - if (args.recordId == -1L) { - getString(R.string.record_exercise) - } else { - getString(R.string.edit_exercise) - }, - ) - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) viewModel.loadInitialRecord(args.recordId) viewModel.loadExerciseTypes() viewModel.loadExerciseLocation() - - setupRVAdapter((getScreenWidthPx(requireActivity()) - dpToPx(requireContext(), GRID_SPAN_MARGIN)) / GRID_SPAN_COUNT) - initListeners() } - private fun initListeners() { - // 운동 종류 선택 버튼 - binding.recordTypeSelectBtn.setOnClickListener { - val items = viewModel.exerciseTypeList.value - if (items.isNullOrEmpty()) { - viewModel.loadExerciseTypes() // 데이터가 없으면 다시 요청 - return@setOnClickListener - } - - SelectionBottomSheetDialog(items, binding.recordTypeSelectBtn.text.toString()) { selectedType -> - if (selectedType == ExerciseFormViewModel.DIRECT_INPUT) { - // 직접 입력 선택 - binding.recordTypeSelectBtn.setText(R.string.exercise_record_type_direct) - binding.recordTypeEt.apply { - setText("") - visibility = View.VISIBLE - requestFocus() - } - } else { - binding.recordTypeSelectBtn.setText(selectedType) - binding.recordTypeEt.visibility = View.GONE + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val startTime by viewModel.startTime.collectAsStateWithLifecycle() + val endTime by viewModel.endTime.collectAsStateWithLifecycle() + val timeSelection by viewModel.timeSelectionState.collectAsStateWithLifecycle() + val imageItems by viewModel.imageItems.collectAsStateWithLifecycle() + val initialDataLoaded by viewModel.initialDataLoaded.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val scoreGuidance by viewModel.scoreGuidanceState.collectAsStateWithLifecycle() + + // 입력 필드 상태는 화면 단에서 hoisting (회전 보존) + var title by rememberSaveable { mutableStateOf("") } + var selectedType by rememberSaveable { mutableStateOf("") } + var directTypeInput by rememberSaveable { mutableStateOf("") } + var showDirectTypeInput by rememberSaveable { mutableStateOf(false) } + var selectedLocation by rememberSaveable { mutableStateOf("") } + var detail by rememberSaveable { mutableStateOf("") } + + // 수정 모드 초기 데이터 로드 시 입력 필드에 반영 (한 번만) + LaunchInitialData(initialDataLoaded) { record -> + title = record.title + selectedType = record.personalType + selectedLocation = record.location + detail = record.detail } - }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name) - } - - // 시간/날짜 버튼 클릭 리스너 설정 - binding.startDateBtn.setOnClickListener { viewModel.onTimeSelectionClick(TimeSelectionState.START_DATE) } - binding.startTimeBtn.setOnClickListener { viewModel.onTimeSelectionClick(TimeSelectionState.START_TIME) } - binding.endDateBtn.setOnClickListener { viewModel.onTimeSelectionClick(TimeSelectionState.END_DATE) } - binding.endTimeBtn.setOnClickListener { viewModel.onTimeSelectionClick(TimeSelectionState.END_TIME) } - - binding.timeHourEt.addRangeLimit(23) - binding.timeMinuteEt.addRangeLimit(59) - - // 캘린더 날짜 선택 - binding.exerciseDateCalendar.setOnDateChangeListener { _, year, month, dayOfMonth -> - viewModel.updateDate(year, month, dayOfMonth) - } - - // 시간 입력 확인 - binding.timeConfirmBtn.setOnClickListener { - val hour = binding.timeHourEt.text.toString().toIntOrNull() - val minute = binding.timeMinuteEt.text.toString().toIntOrNull() - if (hour == null || minute == null || hour !in 0..23 || minute !in 0..59) { - Toast.makeText(requireContext(), "유효한 시간을 입력해주세요.", Toast.LENGTH_SHORT).show() - return@setOnClickListener + // PlaceSearch 결과 처리 (Compose 영역 밖이지만 같이 처리) + ObservePlaceSearchResult { name -> selectedLocation = name } + + ExerciseFormScreen( + isEditMode = args.recordId != -1L, + title = title, + selectedType = selectedType, + directTypeInput = directTypeInput, + showDirectTypeInput = showDirectTypeInput, + startTime = startTime, + endTime = endTime, + timeSelectionState = timeSelection, + selectedLocation = selectedLocation, + detail = detail, + scoreGuidanceState = scoreGuidance, + imageItems = imageItems, + isLoading = isLoading, + onTitleChange = { title = it }, + onTypeSelectClick = { + showTypeSelection(selectedType) { selected, isDirect -> + selectedType = selected + showDirectTypeInput = isDirect + if (isDirect) directTypeInput = "" + } + }, + onDirectTypeInputChange = { directTypeInput = it }, + onLocationSelectClick = { + showLocationSelection(selectedLocation) { selected -> + selectedLocation = selected + } + }, + onTimeButtonClick = viewModel::onTimeSelectionClick, + onDateSelected = ::handleDateSelected, + onTimeConfirmed = viewModel::updateTime, + onDetailChange = { detail = it }, + onAddImageClick = ::launchGallery, + onDeleteImageClick = viewModel::removeImage, + onCompleteClick = { + val typeToSubmit = + if (showDirectTypeInput) directTypeInput.trim() else selectedType.trim() + if (showDirectTypeInput && directTypeInput.isBlank()) { + Toast.makeText( + requireContext(), + R.string.exercise_record_type_direct_input_warning, + Toast.LENGTH_SHORT, + ).show() + return@ExerciseFormScreen + } + viewModel.submitRecord( + recordId = args.recordId, + title = title.trim(), + type = typeToSubmit, + location = selectedLocation.trim(), + detail = detail.trim(), + ) + }, + onBackClick = { findNavController().navigateUp() }, + ) } - viewModel.updateTime(hour, minute) } - // 운동 장소 선택 버튼 - binding.recordLocationSelectBtn.setOnClickListener { - val items = viewModel.exerciseLocation.value - if (items.isNullOrEmpty()) { - viewModel.loadExerciseLocation() - return@setOnClickListener - } - - SelectionBottomSheetDialog(items, binding.recordLocationSelectBtn.text.toString()) { selectedLocation -> - if (selectedLocation == ExerciseFormViewModel.DIRECT_INPUT) { - // 직접 입력 선택 - findNavController().navigate( - ExerciseFormFragmentDirections.actionExerciseFormFragmentToPlaceSearchFragment(), - ) - } else { - binding.recordLocationSelectBtn.setText(selectedLocation) + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.toastMessage.collect { messageId -> + Toast.makeText(requireContext(), getString(messageId), Toast.LENGTH_SHORT).show() + } } - }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name) + launch { + viewModel.createResult.collect { result -> + when (result) { + is SubmissionResult.Success -> handleSuccessfulCreate(result.earnedPoints) + is SubmissionResult.PartialSuccess -> { + // 부분 성공 (이미지 업로드 실패) + findNavController().navigate( + ExerciseFormFragmentDirections + .actionExerciseFormFragmentToExerciseDetailFragment(result.recordId), + ) + } + is SubmissionResult.Failure -> { /* 기록 생성 실패 */ } + } + } + } + launch { + viewModel.editResult.collect { result -> + handleEditResult(result) + } + } + } } + } - // 기록 완료 버튼 - binding.recordCompleteBtn.setOnClickListener { - val type = binding.recordTypeSelectBtn.text.toString().trim() - if (type == getString(R.string.exercise_record_type_direct) && - binding.recordTypeEt.text.toString().isBlank() - ) { - Toast.makeText(requireContext(), R.string.exercise_record_type_direct_input_warning, Toast.LENGTH_SHORT).show() - return@setOnClickListener + private fun handleEditResult(result: ExerciseEditResult) { + when (result) { + is ExerciseEditResult.Success -> { + // 기록 수정, 이미지 삭제/업로드 성공 + findNavController().previousBackStackEntry?.savedStateHandle?.set( + ExerciseDetailFragment.KEY_RECORD_UPDATED, + true, + ) + findNavController().popBackStack() + } + is ExerciseEditResult.ContentFailure -> { + // 내용 수정 실패 + Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set( + ExerciseDetailFragment.KEY_RECORD_UPDATED, + true, + ) + findNavController().popBackStack() + } + is ExerciseEditResult.ImageFailure -> { + // 이미지 삭제/업로드 실패 + Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set( + ExerciseDetailFragment.KEY_RECORD_UPDATED, + true, + ) + findNavController().popBackStack() + } + is ExerciseEditResult.Failure -> { + // 내용 수정, 이미지 삭제/업로드 실패 + Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() } - viewModel.submitRecord( - recordId = args.recordId, - title = binding.recordTitleEt.text.toString().trim(), - type = - if (type == - getString( - R.string.exercise_record_type_direct, - ) - ) { - binding.recordTypeEt.text.toString().trim() - } else { - binding.recordTypeSelectBtn.text.toString().trim() - }, - location = binding.recordLocationSelectBtn.text.toString().trim(), - detail = binding.recordDescEt.text.toString().trim(), - ) } } - private fun launchGallery() { - pickMultipleMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + private fun handleDateSelected(date: LocalDate) { + viewModel.updateDate(date.year, date.monthValue, date.dayOfMonth) } - override fun setupObservers() { - // 시작 시간 - viewModel.startTime.observe(viewLifecycleOwner) { dateTime -> - binding.startDateBtn.text = dateTime?.format(YYYY_MM_DD_KR) - binding.startTimeBtn.text = dateTime?.format(HH_MM_KR) - // CalendarView의 날짜도 동기화 - dateTime?.let { - val calendar = Calendar.getInstance() - calendar.time = Date.from(it.atZone(ZoneId.systemDefault()).toInstant()) - binding.exerciseDateCalendar.date = calendar.timeInMillis + private fun handlePickedImages(uris: List) { + val validatedUris = mutableListOf() + var errorReason: String? = null + + // 유효성 검사 및 유효한 URI 수집 + for (uri in uris) { + val (isValid, reason) = ImageValidator.validateImageFile(uri, requireContext()) + if (isValid) { + validatedUris.add(uri) + } else if (reason == OVERSIZE) { + compressImage(requireContext(), uri)?.let { validatedUris.add(it) } + } else { + errorReason = reason } } - // 종료 시간 - viewModel.endTime.observe(viewLifecycleOwner) { dateTime -> - binding.endDateBtn.text = dateTime?.format(YYYY_MM_DD_KR) - binding.endTimeBtn.text = dateTime?.format(HH_MM_KR) - } - - // 시간 선택 상태 (어떤 버튼이 선택되었는지) - viewModel.timeSelectionState.observe(viewLifecycleOwner) { state -> - updateTimeSelectionUi(state) - } - - findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData( - PlaceSearchFragment.KEY_SELECTED_PLACE, - )?.observe(viewLifecycleOwner) { name -> - binding.recordLocationSelectBtn.setText(name) - // 선택 후에는 결과 삭제 - findNavController().currentBackStackEntry?.savedStateHandle?.remove( - PlaceSearchFragment.KEY_SELECTED_PLACE, - ) - } - - viewModel.imageItems.observe(viewLifecycleOwner) { items -> - imageAdapter.submitList(items.toList()) - } - - viewModel.initialDataLoaded.observe(viewLifecycleOwner) { record -> - if (record != null) setupInitialData(record) - } - - viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> - binding.loadingGroup.isVisible = isLoading - binding.recordCompleteBtn.isEnabled = !isLoading - } - - viewModel.createResult.observe(viewLifecycleOwner) { result -> - when (result) { - is SubmissionResult.Success -> { - handleSuccessfulCreate(result.earnedPoints) + // 유효성 검사 에러가 있었다면 메시지 표시 + errorReason?.let { reason -> + val errorMessage = + when (reason) { + INVALID_TYPE -> getString(R.string.image_error_invalid_type, ALLOWED_EXTENSIONS.joinToString(", ")) + FAIL_TO_READ -> getString(R.string.image_error_file_read) + else -> getString(R.string.unknown_error) } - is SubmissionResult.PartialSuccess -> { - // 부분 성공 (이미지 업로드 실패) - findNavController().navigate( - ExerciseFormFragmentDirections - .actionExerciseFormFragmentToExerciseDetailFragment(result.recordId), - ) - } - is SubmissionResult.Failure -> { // 기록 생성 실패 - } - } + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() } - viewModel.editResult.observe(viewLifecycleOwner) { result -> - when (result) { - is ExerciseEditResult.Success -> { // 기록 수정, 이미지 삭제/업로드 성공 - findNavController().previousBackStackEntry?.savedStateHandle?.set( - ExerciseDetailFragment.KEY_RECORD_UPDATED, - true, - ) - findNavController().popBackStack() - } - is ExerciseEditResult.ContentFailure -> { // 내용 수정 실패 - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() - findNavController().previousBackStackEntry?.savedStateHandle?.set( - ExerciseDetailFragment.KEY_RECORD_UPDATED, - true, - ) - findNavController().popBackStack() - } - is ExerciseEditResult.ImageFailure -> { // 이미지 삭제/업로드 실패 - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() - findNavController().previousBackStackEntry?.savedStateHandle?.set( - ExerciseDetailFragment.KEY_RECORD_UPDATED, - true, - ) - findNavController().popBackStack() - } - is ExerciseEditResult.Failure -> { // 내용 수정, 이미지 삭제/업로드 실패 - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() - } - } + // 최대 이미지 개수를 넘은 경우 + if (validatedUris.size > viewModel.getCurrentPermittedImageCount()) { + Toast.makeText(requireContext(), getString(R.string.exercise_record_max_image), Toast.LENGTH_LONG).show() + } else if (validatedUris.isNotEmpty()) { + viewModel.addImage(validatedUris) } + } - viewModel.toastMessage.observe(viewLifecycleOwner) { messageId -> - messageId?.let { - Toast.makeText(requireContext(), getString(messageId), Toast.LENGTH_SHORT).show() - } + private fun launchGallery() { + val currentImageCount = viewModel.imageItems.value.count { it !is ExerciseImageListItem.AddButtonItem } + if (currentImageCount >= MAX_IMAGE) { + Toast.makeText(requireContext(), getString(R.string.exercise_record_max_image), Toast.LENGTH_SHORT).show() + return } + pickMultipleMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } - viewModel.scoreGuidanceState.observe(viewLifecycleOwner) { state -> - when (state) { - is ScoreGuidanceState.Hidden -> { - binding.scoreWarningTv.isVisible = false - } - is ScoreGuidanceState.Warning -> { - binding.scoreWarningTv.isVisible = true - binding.scoreWarningTv.text = getString(state.messageId) - } - is ScoreGuidanceState.PointsAvailable -> { - binding.scoreWarningTv.isVisible = false - } + private fun showTypeSelection( + current: String, + onSelected: (selected: String, isDirect: Boolean) -> Unit, + ) { + val items = viewModel.exerciseTypeList.value + if (items.isEmpty()) { + viewModel.loadExerciseTypes() // 데이터가 없으면 다시 요청 + return + } + UndabangBottomSheet.showSelection( + fragmentManager = parentFragmentManager, + items = items, + selectedItem = current, + ) { selectedType -> + if (selectedType == ExerciseFormViewModel.DIRECT_INPUT) { + // 직접 입력 선택 + onSelected(getString(R.string.exercise_record_type_direct), true) + } else { + onSelected(selectedType, false) } } } - private fun setupInitialData(record: ExerciseRecord) { - binding.recordTitleEt.setText(record.title) - binding.recordTypeSelectBtn.setText(record.personalType) - binding.recordLocationSelectBtn.setText(record.location) - binding.recordDescEt.setText(record.detail) - } - - // 선택된 시간/날짜 UI를 업데이트하는 함수 - private fun updateTimeSelectionUi(state: TimeSelectionState) { - // 모든 선택 UI 초기화 - binding.startDateBtn.background = null - binding.startTimeBtn.background = null - binding.endDateBtn.background = null - binding.endTimeBtn.background = null - - binding.exerciseDateCalendar.isVisible = false - binding.timeSelectorLl.isVisible = false - - // 현재 상태에 맞는 UI 활성화 - val indicator = getDrawable(requireContext(), R.drawable.bg_time_indicator) - when (state) { - TimeSelectionState.START_DATE -> { - binding.startDateBtn.background = indicator - binding.exerciseDateCalendar.isVisible = true - } - TimeSelectionState.START_TIME -> { - binding.startTimeBtn.background = indicator - binding.timeSelectorLl.isVisible = true - // 현재 시간으로 EditText 초기화 - val time = viewModel.startTime.value - binding.timeHourEt.setText(time?.hour?.toString()?.padStart(2, '0') ?: "") - binding.timeMinuteEt.setText(time?.minute?.toString()?.padStart(2, '0') ?: "") - } - TimeSelectionState.END_DATE -> { - binding.endDateBtn.background = indicator - binding.exerciseDateCalendar.isVisible = true - } - TimeSelectionState.END_TIME -> { - binding.endTimeBtn.background = indicator - binding.timeSelectorLl.isVisible = true - - val time = viewModel.endTime.value - binding.timeHourEt.setText(time?.hour?.toString()?.padStart(2, '0') ?: "") - binding.timeMinuteEt.setText(time?.minute?.toString()?.padStart(2, '0') ?: "") + private fun showLocationSelection( + current: String, + onSelected: (String) -> Unit, + ) { + val items = viewModel.exerciseLocation.value + if (items.isEmpty()) { + viewModel.loadExerciseLocation() + return + } + UndabangBottomSheet.showSelection( + fragmentManager = parentFragmentManager, + items = items, + selectedItem = current, + ) { selectedLocation -> + if (selectedLocation == ExerciseFormViewModel.DIRECT_INPUT) { + // 직접 입력 선택 + findNavController().navigate( + ExerciseFormFragmentDirections.actionExerciseFormFragmentToPlaceSearchFragment(), + ) + } else { + onSelected(selectedLocation) } - TimeSelectionState.NONE -> { } } } private fun handleSuccessfulCreate(earnedPoints: Int) { when { - (earnedPoints > 0) -> { + earnedPoints > 0 -> { ScoreCongratulationDialog(earnedPoints).apply { - confirmClickListener = { - findNavController().popBackStack() - } + confirmClickListener = { findNavController().popBackStack() } }.show(parentFragmentManager, ScoreCongratulationDialog::class.java.name) } - else -> { - findNavController().popBackStack() - } + else -> findNavController().popBackStack() + } + } + + @androidx.compose.runtime.Composable + private fun LaunchInitialData( + initialRecord: com.project200.domain.model.ExerciseRecord?, + onLoaded: (com.project200.domain.model.ExerciseRecord) -> Unit, + ) { + androidx.compose.runtime.LaunchedEffect(initialRecord) { + initialRecord?.let(onLoaded) } } - companion object { - private const val GRID_SPAN_COUNT = 3 - private const val GRID_SPAN_MARGIN = 80f - private const val RV_MARGIN = 20f + @androidx.compose.runtime.Composable + private fun ObservePlaceSearchResult(onResult: (String) -> Unit) { + androidx.compose.runtime.DisposableEffect(Unit) { + val owner = viewLifecycleOwner + val handle = findNavController().currentBackStackEntry?.savedStateHandle + val liveData = handle?.getLiveData(PlaceSearchFragment.KEY_SELECTED_PLACE) + val observer = + androidx.lifecycle.Observer { name -> + onResult(name) + // 선택 후에는 결과 삭제 + handle?.remove(PlaceSearchFragment.KEY_SELECTED_PLACE) + } + liveData?.observe(owner, observer) + onDispose { liveData?.removeObserver(observer) } + } } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormScreen.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormScreen.kt new file mode 100644 index 00000000..b7426e43 --- /dev/null +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormScreen.kt @@ -0,0 +1,711 @@ +package com.project200.feature.exercise.form + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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 coil.compose.AsyncImage +import com.project200.common.utils.CommonDateTimeFormatters.HH_MM_KR +import com.project200.common.utils.CommonDateTimeFormatters.YYYY_MM_DD_KR +import com.project200.feature.exercise.utils.ScoreGuidanceState +import com.project200.feature.exercise.utils.TimeSelectionState +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.input.TextFieldVariant +import com.project200.presentation.compose.components.input.UndabangTextField +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorErrorRed +import com.project200.presentation.compose.theme.ColorGray100 +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite100 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.subtext14 +import com.project200.undabang.feature.exercise.R +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun ExerciseFormScreen( + isEditMode: Boolean, + title: String, + selectedType: String, + directTypeInput: String, + showDirectTypeInput: Boolean, + startTime: LocalDateTime?, + endTime: LocalDateTime?, + timeSelectionState: TimeSelectionState, + selectedLocation: String, + detail: String, + scoreGuidanceState: ScoreGuidanceState, + imageItems: List, + isLoading: Boolean, + onTitleChange: (String) -> Unit, + onTypeSelectClick: () -> Unit, + onDirectTypeInputChange: (String) -> Unit, + onLocationSelectClick: () -> Unit, + onTimeButtonClick: (TimeSelectionState) -> Unit, + onDateSelected: (LocalDate) -> Unit, + onTimeConfirmed: (hour: Int, minute: Int) -> Unit, + onDetailChange: (String) -> Unit, + onAddImageClick: () -> Unit, + onDeleteImageClick: (ExerciseImageListItem) -> Unit, + onCompleteClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = + stringResource( + if (isEditMode) R.string.edit_exercise else R.string.record_exercise, + ), + onNavigationClick = onBackClick, + actions = { + PrimaryButton( + text = stringResource(PresentationR.string.complete), + onClick = onCompleteClick, + isCompact = true, + enabled = !isLoading, + modifier = Modifier.padding(end = 20.dp), + ) + }, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), + ) { + // 운동 제목 + SectionLabel(text = stringResource(R.string.exercise_record_title)) + UndabangTextField( + value = title, + onValueChange = onTitleChange, + hint = stringResource(R.string.exercise_record_title_hint), + variant = TextFieldVariant.FilledSmall, + maxLength = 255, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 8.dp), + ) + + // 운동 종류 (BottomSheet 호출) + SectionLabel( + text = stringResource(R.string.exercise_record_type), + topPadding = 24.dp, + ) + SelectorRow( + label = selectedType.ifEmpty { stringResource(R.string.exercise_record_type_select_hint) }, + hintColor = selectedType.isEmpty(), + onClick = onTypeSelectClick, + modifier = + Modifier + .padding(horizontal = 20.dp) + .padding(top = 8.dp), + ) + // 직접 입력 선택 시 EditText 노출 + if (showDirectTypeInput) { + UndabangTextField( + value = directTypeInput, + onValueChange = onDirectTypeInputChange, + hint = stringResource(R.string.exercise_record_type_hint), + variant = TextFieldVariant.FilledSmall, + maxLength = 50, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 8.dp), + ) + } + + // 시작/종료 시간 + 점수 안내 + SectionLabel( + text = stringResource(R.string.exercise_record_time), + topPadding = 24.dp, + ) + // 점수 안내는 시간 라벨 바로 아래에 배치 (원본 디자인 일치) + ScoreGuidanceText(state = scoreGuidanceState) + TimeSelectionRow( + startTime = startTime, + endTime = endTime, + selection = timeSelectionState, + onClick = onTimeButtonClick, + ) + + // 선택 상태에 따라 인라인 캘린더/시간 입력기 노출 + when (timeSelectionState) { + TimeSelectionState.START_DATE, + TimeSelectionState.END_DATE, + -> { + InlineDatePicker( + initialDate = + if (timeSelectionState == TimeSelectionState.START_DATE) { + startTime?.toLocalDate() ?: LocalDate.now() + } else { + endTime?.toLocalDate() ?: LocalDate.now() + }, + onDateSelected = onDateSelected, + ) + } + TimeSelectionState.START_TIME, + TimeSelectionState.END_TIME, + -> { + InlineTimePicker( + initialTime = + if (timeSelectionState == TimeSelectionState.START_TIME) startTime else endTime, + onConfirm = onTimeConfirmed, + ) + } + TimeSelectionState.NONE -> Unit + } + + // 운동 장소 + SectionLabel( + text = stringResource(R.string.exercise_record_location), + topPadding = 24.dp, + ) + SelectorRow( + label = selectedLocation.ifEmpty { stringResource(R.string.exercise_record_location_hint) }, + hintColor = selectedLocation.isEmpty(), + onClick = onLocationSelectClick, + modifier = + Modifier + .padding(horizontal = 20.dp) + .padding(top = 8.dp), + ) + + // 운동 내용 + SectionLabel( + text = stringResource(R.string.exercise_record_desc), + topPadding = 24.dp, + ) + UndabangTextField( + value = detail, + onValueChange = onDetailChange, + hint = "", + variant = TextFieldVariant.FilledLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 8.dp) + .height(160.dp), + ) + + // 이미지 그리드 + SectionLabel( + text = stringResource(R.string.exercise_record_add_image), + topPadding = 24.dp, + ) + ImageGrid( + items = imageItems, + onAddClick = onAddImageClick, + onDeleteClick = onDeleteImageClick, + modifier = Modifier.padding(horizontal = 20.dp).padding(top = 8.dp), + ) + } + + if (isLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun SectionLabel( + text: String, + modifier: Modifier = Modifier, + topPadding: androidx.compose.ui.unit.Dp = 20.dp, +) { + Text( + text = text, + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = modifier.padding(start = 20.dp, top = topPadding), + ) +} + +@Composable +private fun SelectorRow( + label: String, + hintColor: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // 원본은 focusable=false EditText + bg_solid_corner. 드롭다운 아이콘 없음 + Box( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ColorWhite100) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 11.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.subtext14, + color = if (hintColor) ColorGray200 else ColorBlack, + ) + } +} + +@Composable +private fun TimeSelectionRow( + startTime: LocalDateTime?, + endTime: LocalDateTime?, + selection: TimeSelectionState, + onClick: (TimeSelectionState) -> Unit, + modifier: Modifier = Modifier, +) { + // 원본: 좌측(시작 날짜 위, 시작 시간 아래) + 가운데 화살표 + 우측(종료 날짜 위, 종료 시간 아래) + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TimeButton( + text = startTime?.format(YYYY_MM_DD_KR) ?: "-", + selected = selection == TimeSelectionState.START_DATE, + onClick = { onClick(TimeSelectionState.START_DATE) }, + ) + TimeButton( + text = startTime?.format(HH_MM_KR) ?: "-", + selected = selection == TimeSelectionState.START_TIME, + onClick = { onClick(TimeSelectionState.START_TIME) }, + ) + } + Icon( + painter = painterResource(PresentationR.drawable.ic_arrow_right), + contentDescription = null, + tint = ColorGray200, + modifier = Modifier.padding(horizontal = 20.dp).size(30.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TimeButton( + text = endTime?.format(YYYY_MM_DD_KR) ?: "-", + selected = selection == TimeSelectionState.END_DATE, + onClick = { onClick(TimeSelectionState.END_DATE) }, + ) + TimeButton( + text = endTime?.format(HH_MM_KR) ?: "-", + selected = selection == TimeSelectionState.END_TIME, + onClick = { onClick(TimeSelectionState.END_TIME) }, + ) + } + } +} + +@Composable +private fun TimeButton( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // 원본 bg_time_indicator (선택 시) — 선택 안 됐을 땐 배경 없음, 선택되면 배경/테두리 강조 + val borderColor = if (selected) ColorMain else Color.Transparent + val bgColor = if (selected) ColorWhite100 else Color.Transparent + Box( + modifier = + modifier + .clip(RoundedCornerShape(8.dp)) + .background(bgColor) + .border(1.dp, borderColor, RoundedCornerShape(8.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InlineDatePicker( + initialDate: LocalDate, + onDateSelected: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val initialMillis = remember(initialDate) { initialDate.toUtcMillis() } + val nowMillis = remember { LocalDate.now().toUtcMillis() } + val state = + rememberDatePickerState( + initialSelectedDateMillis = initialMillis, + selectableDates = + object : SelectableDates { + // 미래 날짜는 선택 불가 (운동 기록은 과거~현재만 허용) + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis <= nowMillis + }, + ) + LaunchedEffect(state.selectedDateMillis) { + state.selectedDateMillis?.let { millis -> + val date = + Instant.ofEpochMilli(millis) + .atOffset(ZoneOffset.UTC) + .toLocalDate() + if (date != initialDate) { + onDateSelected(date) + } + } + } + DatePicker( + state = state, + title = null, + headline = null, + showModeToggle = false, + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) +} + +@Composable +private fun InlineTimePicker( + initialTime: LocalDateTime?, + onConfirm: (hour: Int, minute: Int) -> Unit, + modifier: Modifier = Modifier, +) { + // 원본 time_selector_ll: height 140dp, bg_time_selector, 시계 35dp + 큰 40sp 시/분 입력 + 확인 버튼 + var hour by remember(initialTime) { + mutableStateOf(initialTime?.hour?.toString()?.padStart(2, '0') ?: "") + } + var minute by remember(initialTime) { + mutableStateOf(initialTime?.minute?.toString()?.padStart(2, '0') ?: "") + } + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + .clip(RoundedCornerShape(8.dp)) + .background(ColorWhite100) + .height(140.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_clock), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(35.dp), + ) + Spacer(Modifier.width(20.dp)) + androidx.compose.foundation.text.BasicTextField( + value = hour, + onValueChange = { input -> + val digits = input.filter { it.isDigit() }.take(2) + val parsed = digits.toIntOrNull() + if (digits.isEmpty() || (parsed != null && parsed in 0..23)) { + hour = digits + } + }, + textStyle = + androidx.compose.ui.text.TextStyle( + fontSize = 40.sp, + color = ColorBlack, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ), + singleLine = true, + keyboardOptions = + androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, + ), + modifier = Modifier.weight(1f), + ) + Text( + text = ":", + fontSize = 40.sp, + color = ColorBlack, + modifier = Modifier.padding(horizontal = 4.dp), + ) + androidx.compose.foundation.text.BasicTextField( + value = minute, + onValueChange = { input -> + val digits = input.filter { it.isDigit() }.take(2) + val parsed = digits.toIntOrNull() + if (digits.isEmpty() || (parsed != null && parsed in 0..59)) { + minute = digits + } + }, + textStyle = + androidx.compose.ui.text.TextStyle( + fontSize = 40.sp, + color = ColorBlack, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ), + singleLine = true, + keyboardOptions = + androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, + ), + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(20.dp)) + PrimaryButton( + text = stringResource(PresentationR.string.confirm), + onClick = { + val h = hour.toIntOrNull() ?: return@PrimaryButton + val m = minute.toIntOrNull() ?: return@PrimaryButton + onConfirm(h, m) + }, + isCompact = true, + ) + } +} + +@Composable +private fun ScoreGuidanceText( + state: ScoreGuidanceState, + modifier: Modifier = Modifier, +) { + when (state) { + is ScoreGuidanceState.Hidden -> Unit + is ScoreGuidanceState.Warning -> { + Text( + text = stringResource(state.messageId), + style = MaterialTheme.typography.subtext14, + color = ColorErrorRed, + modifier = modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + } + is ScoreGuidanceState.PointsAvailable -> { + Text( + text = "${state.points}점을 획득할 수 있어요", + style = MaterialTheme.typography.subtext14, + color = ColorMain, + modifier = modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +private fun ImageGrid( + items: List, + onAddClick: () -> Unit, + onDeleteClick: (ExerciseImageListItem) -> Unit, + modifier: Modifier = Modifier, +) { + // verticalScroll Column 안에 LazyVerticalGrid를 못 두므로 chunked Row로 직접 그림 + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items.chunked(3).forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + rowItems.forEach { item -> + Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { + ImageCell( + item = item, + onAddClick = onAddClick, + onDeleteClick = { onDeleteClick(item) }, + ) + } + } + // 빈 칸 채우기 + repeat(3 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun ImageCell( + item: ExerciseImageListItem, + onAddClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (item) { + is ExerciseImageListItem.AddButtonItem -> { + Box( + modifier = + modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(ColorWhite100) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onAddClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_camera), + contentDescription = null, + tint = ColorGray200, + ) + } + } + is ExerciseImageListItem.NewImageItem, is ExerciseImageListItem.ExistingImageItem -> { + Box(modifier = modifier.fillMaxSize()) { + val model = + when (item) { + is ExerciseImageListItem.NewImageItem -> item.uri + is ExerciseImageListItem.ExistingImageItem -> item.url + else -> null + } + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(ColorGray100), + ) + Icon( + painter = painterResource(PresentationR.drawable.ic_close), + contentDescription = null, + tint = ColorWhite300, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(20.dp) + .background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(50)) + .padding(2.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDeleteClick, + ), + ) + } + } + } +} + +private fun LocalDate.toUtcMillis(): Long = atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + +@Preview(showBackground = true, heightDp = 1000) +@Composable +private fun ExerciseFormScreenPreview() { + AppTheme { + ExerciseFormScreen( + isEditMode = false, + title = "오늘 운동", + selectedType = "헬스", + directTypeInput = "", + showDirectTypeInput = false, + startTime = LocalDateTime.now().minusHours(1), + endTime = LocalDateTime.now(), + timeSelectionState = TimeSelectionState.NONE, + selectedLocation = "헬스장", + detail = "스쿼트 5세트", + scoreGuidanceState = ScoreGuidanceState.Hidden, + imageItems = listOf(ExerciseImageListItem.AddButtonItem), + isLoading = false, + onTitleChange = {}, + onTypeSelectClick = {}, + onDirectTypeInputChange = {}, + onLocationSelectClick = {}, + onTimeButtonClick = {}, + onDateSelected = {}, + onTimeConfirmed = { _, _ -> }, + onDetailChange = {}, + onAddImageClick = {}, + onDeleteImageClick = {}, + onCompleteClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt index 3b48e975..2161ec92 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt @@ -1,8 +1,6 @@ package com.project200.feature.exercise.form import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.common.constants.RuleConstants.MAX_IMAGE @@ -23,6 +21,12 @@ import com.project200.feature.exercise.utils.ScoreGuidanceState import com.project200.feature.exercise.utils.TimeSelectionState import com.project200.undabang.feature.exercise.R import dagger.hilt.android.lifecycle.HiltViewModel +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.launch import java.time.LocalDate import java.time.LocalDateTime @@ -43,17 +47,17 @@ class ExerciseFormViewModel private val getExercisePlaceUseCase: GetExercisePlaceUseCase, private val clockProvider: ClockProvider, ) : ViewModel() { - private val _startTime = MutableLiveData() - val startTime: LiveData = _startTime + private val _startTime = MutableStateFlow(null) + val startTime: StateFlow = _startTime.asStateFlow() - private val _endTime = MutableLiveData() - val endTime: LiveData = _endTime + private val _endTime = MutableStateFlow(null) + val endTime: StateFlow = _endTime.asStateFlow() private val _imageItems = - MutableLiveData>( - mutableListOf(ExerciseImageListItem.AddButtonItem), + MutableStateFlow>( + listOf(ExerciseImageListItem.AddButtonItem), ) - val imageItems: LiveData> = _imageItems + val imageItems: StateFlow> = _imageItems.asStateFlow() // 수정/생성 모드 관련 private var initialRecord: ExerciseRecord? = null // 수정 시 초기 데이터 저장 @@ -61,33 +65,34 @@ class ExerciseFormViewModel private val removedPictureIds = mutableListOf() // 삭제할 기존 이미지 ID 목록 // 운동 종류 - private val _exerciseTypeList = MutableLiveData>() - val exerciseTypeList: LiveData> = _exerciseTypeList + private val _exerciseTypeList = MutableStateFlow>(emptyList()) + val exerciseTypeList: StateFlow> = _exerciseTypeList.asStateFlow() // 운동 장소 목록 - private val _exerciseLocation = MutableLiveData>() - val exerciseLocation: LiveData> = _exerciseLocation + private val _exerciseLocation = MutableStateFlow>(emptyList()) + val exerciseLocation: StateFlow> = _exerciseLocation.asStateFlow() - private val _initialDataLoaded = MutableLiveData() - val initialDataLoaded: LiveData = _initialDataLoaded + private val _initialDataLoaded = MutableStateFlow(null) + val initialDataLoaded: StateFlow = _initialDataLoaded.asStateFlow() - private val _isLoading = MutableLiveData() - val isLoading: LiveData = _isLoading + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() - private val _createResult = MutableLiveData() - val createResult: LiveData = _createResult + // 1회성 이벤트지만 테스트에서 emit 후 collect 가능하도록 replay=1 적용 + private val _createResult = MutableSharedFlow(replay = 1) + val createResult: SharedFlow = _createResult.asSharedFlow() - private val _editResult = MutableLiveData() - val editResult: LiveData = _editResult + private val _editResult = MutableSharedFlow(replay = 1) + val editResult: SharedFlow = _editResult.asSharedFlow() - private val _toastMessage = MutableLiveData() - val toastMessage: LiveData = _toastMessage + private val _toastMessage = MutableSharedFlow(replay = 1) + val toastMessage: SharedFlow = _toastMessage.asSharedFlow() - private val _scoreGuidanceState = MutableLiveData(ScoreGuidanceState.Hidden) - val scoreGuidanceState: LiveData = _scoreGuidanceState + private val _scoreGuidanceState = MutableStateFlow(ScoreGuidanceState.Hidden) + val scoreGuidanceState: StateFlow = _scoreGuidanceState.asStateFlow() - private val _timeSelectionState = MutableLiveData(TimeSelectionState.NONE) - val timeSelectionState: LiveData = _timeSelectionState + private val _timeSelectionState = MutableStateFlow(TimeSelectionState.NONE) + val timeSelectionState: StateFlow = _timeSelectionState.asStateFlow() /** 초기 데이터 설정 */ fun loadInitialRecord(recordId: Long) { @@ -101,7 +106,7 @@ class ExerciseFormViewModel _startTime.value = now.minusHours(1) _endTime.value = now - _imageItems.value = mutableListOf(ExerciseImageListItem.AddButtonItem) + _imageItems.value = listOf(ExerciseImageListItem.AddButtonItem) _initialDataLoaded.value = null } else { // 수정 모드 @@ -111,7 +116,7 @@ class ExerciseFormViewModel setupEditMode(result.data) } is BaseResult.Error -> { - _toastMessage.value = R.string.exercise_load_fail + _toastMessage.emit(R.string.exercise_load_fail) } } } @@ -132,7 +137,6 @@ class ExerciseFormViewModel } _imageItems.value = imageListForEditMode - _initialDataLoaded.value = record _scoreGuidanceState.value = ScoreGuidanceState.Hidden } @@ -144,7 +148,7 @@ class ExerciseFormViewModel // 시작 시간이 현재 시간 이후로 설정되지 않도록 보정 if (newStartTime.isAfter(now)) { newStartTime = now - _toastMessage.value = R.string.exercise_error_start_time_future + viewModelScope.launch { _toastMessage.emit(R.string.exercise_error_start_time_future) } } _startTime.value = newStartTime @@ -162,7 +166,7 @@ class ExerciseFormViewModel // 종료 시간이 현재 시간 이후로 설정되지 않도록 보정 if (newEndTime.isAfter(now)) { newEndTime = now - _toastMessage.value = R.string.exercise_error_end_time_future + viewModelScope.launch { _toastMessage.emit(R.string.exercise_error_end_time_future) } } _endTime.value = newEndTime @@ -209,13 +213,13 @@ class ExerciseFormViewModel } } - // CalendarView에서 날짜가 선택되었을 때 호출 + // 캘린더에서 날짜가 선택되었을 때 호출 fun updateDate( year: Int, month: Int, dayOfMonth: Int, ) { - val selectedDate = LocalDate.of(year, month + 1, dayOfMonth) + val selectedDate = LocalDate.of(year, month, dayOfMonth) when (_timeSelectionState.value) { TimeSelectionState.START_DATE -> { val existingTime = _startTime.value?.toLocalTime() ?: clockProvider.nowTime() @@ -250,7 +254,7 @@ class ExerciseFormViewModel } fun addImage(uris: List) { - val currentList = _imageItems.value ?: mutableListOf() + val currentList = _imageItems.value.toMutableList() for (uri in uris) { currentList.add(ExerciseImageListItem.NewImageItem(uri)) } @@ -258,7 +262,7 @@ class ExerciseFormViewModel } fun removeImage(itemToRemove: ExerciseImageListItem) { - val currentList = _imageItems.value ?: return + val currentList = _imageItems.value.toMutableList() currentList.remove(itemToRemove) // 기존 이미지였다면, 삭제 목록에 ID 추가 @@ -269,14 +273,13 @@ class ExerciseFormViewModel } fun getCurrentPermittedImageCount(): Int { - val imageCount = - _imageItems.value?.count { it !is ExerciseImageListItem.AddButtonItem } ?: 0 + val imageCount = _imageItems.value.count { it !is ExerciseImageListItem.AddButtonItem } return MAX_IMAGE - imageCount } /** 운동 종류 리스트 로드 */ fun loadExerciseTypes() { - if (!_exerciseTypeList.value.isNullOrEmpty()) return + if (_exerciseTypeList.value.isNotEmpty()) return viewModelScope.launch { // 유저의 선호 운동 리스트 val preferredResult = getPreferredExerciseUseCase() @@ -310,7 +313,7 @@ class ExerciseFormViewModel /** 운동 장소 설정 */ fun loadExerciseLocation() { - if (!_exerciseLocation.value.isNullOrEmpty()) return + if (_exerciseLocation.value.isNotEmpty()) return viewModelScope.launch { val locationResult = getExercisePlaceUseCase() val locationList = @@ -347,7 +350,7 @@ class ExerciseFormViewModel /** 이미지 변경 사항이 있는지 확인 */ private fun hasImageChanges(): Boolean { return removedPictureIds.isNotEmpty() || - _imageItems.value?.any { it is ExerciseImageListItem.NewImageItem } == true + _imageItems.value.any { it is ExerciseImageListItem.NewImageItem } } /** 기록 생성 또는 수정 */ @@ -360,7 +363,7 @@ class ExerciseFormViewModel ) { // 유효성 검사 if (title.isBlank() || _startTime.value == null || _endTime.value == null) { - _toastMessage.value = R.string.exercise_invalid_input + viewModelScope.launch { _toastMessage.emit(R.string.exercise_invalid_input) } return } @@ -378,7 +381,7 @@ class ExerciseFormViewModel // 변경 사항 확인 (수정 모드일 때) if (isEditMode && !hasChanges(recordToSubmit)) { - _toastMessage.value = R.string.exercise_no_change + viewModelScope.launch { _toastMessage.emit(R.string.exercise_no_change) } return } @@ -388,8 +391,8 @@ class ExerciseFormViewModel // 새로 추가된 이미지 URI 목록 가져오기 val newImageUris = _imageItems.value - ?.filterIsInstance() - ?.map { it.uri.toString() } ?: emptyList() + .filterIsInstance() + .map { it.uri.toString() } if (isEditMode) { editExerciseRecord(recordId, recordToSubmit, newImageUris) @@ -411,8 +414,8 @@ class ExerciseFormViewModel } is BaseResult.Error -> { // 기록 생성 실패 - _createResult.value = SubmissionResult.Failure(createResult.message.toString(), createResult.cause) - _toastMessage.value = R.string.exercise_create_fail + _createResult.emit(SubmissionResult.Failure(createResult.message.toString(), createResult.cause)) + _toastMessage.emit(R.string.exercise_create_fail) _isLoading.value = false } } @@ -429,16 +432,16 @@ class ExerciseFormViewModel when (uploadExerciseRecordImagesUseCase(recordId, newImageUris)) { is BaseResult.Success -> { // 이미지 업로드 성공 -> 최종 성공 - _createResult.value = SubmissionResult.Success(recordId, earnedPoints) + _createResult.emit(SubmissionResult.Success(recordId, earnedPoints)) } is BaseResult.Error -> { // 이미지 업로드 실패 -> 부분 성공 - _createResult.value = SubmissionResult.PartialSuccess(recordId, R.string.exercise_upload_fail) + _createResult.emit(SubmissionResult.PartialSuccess(recordId, R.string.exercise_upload_fail)) } } } else { // 업로드할 이미지가 없음 -> 최종 성공 - _createResult.value = SubmissionResult.Success(recordId, earnedPoints) + _createResult.emit(SubmissionResult.Success(recordId, earnedPoints)) } _isLoading.value = false // 이미지 업로드까지 완료된 후 로딩 종료 } @@ -450,7 +453,7 @@ class ExerciseFormViewModel newImageUris: List, ) { viewModelScope.launch { - _editResult.value = + val result = editExerciseRecordUseCase( recordId = recordId, recordToUpdate = record, @@ -458,6 +461,7 @@ class ExerciseFormViewModel imagesToDelete = removedPictureIds, newImages = newImageUris, ) + _editResult.emit(result) // 로딩 종료 _isLoading.value = false } @@ -516,7 +520,7 @@ class ExerciseFormViewModel is BaseResult.Error -> { // API 호출 실패 시 에러 메시지 표시 및 안내 숨김 - _toastMessage.value = R.string.exercise_fetch_score_info_fail + _toastMessage.emit(R.string.exercise_fetch_score_info_fail) _scoreGuidanceState.value = ScoreGuidanceState.Hidden } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseImageAdapter.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseImageAdapter.kt deleted file mode 100644 index bfa3601f..00000000 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseImageAdapter.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.project200.feature.exercise.form - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.bitmap.CenterCrop -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.project200.presentation.utils.UiUtils.dpToPx -import com.project200.undabang.feature.exercise.databinding.ItemExerciseAddImageBinding -import com.project200.undabang.feature.exercise.databinding.ItemExerciseImageBinding - -class ExerciseImageAdapter( - private var itemSize: Int, - private val onAddItemClick: () -> Unit, - private val onDeleteItemClick: (ExerciseImageListItem) -> Unit, -) : ListAdapter(ExerciseImageDiffCallback()) { - class ExerciseImageDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ExerciseImageListItem, - newItem: ExerciseImageListItem, - ): Boolean { - return oldItem.key == newItem.key - } - - override fun areContentsTheSame( - oldItem: ExerciseImageListItem, - newItem: ExerciseImageListItem, - ): Boolean { - // 데이터 클래스의 동등성 비교 - return oldItem == newItem - } - } - - companion object { - private const val VIEW_TYPE_ADD = 1 - private const val VIEW_TYPE_IMAGE = 2 - private const val ROUND_CORNER = 8f - } - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is ExerciseImageListItem.AddButtonItem -> VIEW_TYPE_ADD - is ExerciseImageListItem.NewImageItem, - is ExerciseImageListItem.ExistingImageItem, - -> VIEW_TYPE_IMAGE - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val holder = - when (viewType) { - VIEW_TYPE_ADD -> { - val binding = ItemExerciseAddImageBinding.inflate(inflater, parent, false) - AddImageViewHolder(binding, onAddItemClick) - } - VIEW_TYPE_IMAGE -> { - val binding = ItemExerciseImageBinding.inflate(inflater, parent, false) - ExerciseImageViewHolder(binding, onDeleteItemClick) - } - else -> throw IllegalArgumentException("Invalid view type: $viewType") - } - - // 아이템 뷰 크기 설정 - val layoutParams = holder.itemView.layoutParams - layoutParams.width = itemSize - layoutParams.height = itemSize - holder.itemView.layoutParams = layoutParams - - return holder - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - val item = getItem(position) - when (holder) { - is AddImageViewHolder -> holder.bind() - is ExerciseImageViewHolder -> holder.bind(item) - } - } - - inner class AddImageViewHolder( - private val binding: ItemExerciseAddImageBinding, - private val onAddItemClick: () -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind() { - binding.root.setOnClickListener { - onAddItemClick() - } - } - } - - inner class ExerciseImageViewHolder( - private val binding: ItemExerciseImageBinding, - private val onDeleteItemClick: (ExerciseImageListItem) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: ExerciseImageListItem) { - val imageSource: Any? = - when (item) { - is ExerciseImageListItem.NewImageItem -> item.uri - is ExerciseImageListItem.ExistingImageItem -> item.url - else -> null - } - - imageSource?.let { - Glide.with(binding.imageIv.context) - .load(it) - .transform(CenterCrop(), RoundedCorners(dpToPx(binding.root.context, ROUND_CORNER))) - .into(binding.imageIv) - } - - binding.deleteBtn.setOnClickListener { - val currentPosition = bindingAdapterPosition - if (currentPosition != RecyclerView.NO_POSITION) { - onDeleteItemClick(getItem(currentPosition)) - } - } - } - } -} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/GridItemDecoration.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/GridItemDecoration.kt deleted file mode 100644 index 7b4291a5..00000000 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/GridItemDecoration.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.project200.feature.exercise.form - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -class GridItemDecoration( - private val spanCount: Int, - private val spacingDp: Int, -) : RecyclerView.ItemDecoration() { - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State, - ) { - val position = parent.getChildAdapterPosition(view) // 아이템 위치 - if (position == RecyclerView.NO_POSITION) { - return - } - val column = position % spanCount // 아이템 열 - - outRect.left = column * spacingDp / spanCount - outRect.right = spacingDp - (column + 1) * spacingDp / spanCount - if (position >= spanCount) { // 첫 번째 행 이후부터 상단 간격 - outRect.top = spacingDp - } - } -} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/PlaceSearchFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/PlaceSearchFragment.kt index 32b10844..97209335 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/PlaceSearchFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/PlaceSearchFragment.kt @@ -6,6 +6,8 @@ import android.content.pm.PackageManager import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels @@ -13,19 +15,19 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices +import com.kakao.vectormap.GestureType import com.kakao.vectormap.KakaoMap -import com.kakao.vectormap.KakaoMapReadyCallback import com.kakao.vectormap.LatLng -import com.kakao.vectormap.MapLifeCycleCallback import com.kakao.vectormap.camera.CameraUpdateFactory import com.project200.common.constants.RuleConstants import com.project200.domain.model.BaseResult import com.project200.domain.model.KakaoPlaceInfo import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.map.KakaoMapView import com.project200.undabang.feature.exercise.R import com.project200.undabang.feature.exercise.databinding.FragmentPlaceSearchBinding import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber @AndroidEntryPoint class PlaceSearchFragment : BindingFragment(R.layout.fragment_place_search) { @@ -98,30 +100,23 @@ class PlaceSearchFragment : BindingFragment(R.layout } } - // 지도 초기화 함수 + // 지도 초기화 함수: 공통 KakaoMapView 컴포저블을 ComposeView에 호스팅 (생명주기는 컴포저블이 처리) private fun initMapView() { - binding.mapView.start( - object : MapLifeCycleCallback() { - override fun onMapDestroy() {} - - override fun onMapError(error: Exception) { - Timber.d("$error") - } - }, - object : KakaoMapReadyCallback() { - override fun onMapReady(map: KakaoMap) { + binding.mapComposeView.applyAppTheme { + KakaoMapView( + modifier = Modifier.fillMaxSize(), + onMapReady = { map -> kakaoMap = map setupInitialMapPosition() - // 두 손가락으로 지도를 회전시키는 제스처 비활성화 - kakaoMap?.setGestureEnable(com.kakao.vectormap.GestureType.Rotate, false) - // 두 손가락으로 지도를 기울이는 제스처(틸트) 비활성화 - kakaoMap?.setGestureEnable(com.kakao.vectormap.GestureType.Tilt, false) + // 두 손가락으로 지도를 회전/기울이는 제스처 비활성화 + map.setGestureEnable(GestureType.Rotate, false) + map.setGestureEnable(GestureType.Tilt, false) setMapListeners() - } - }, - ) + }, + ) + } } /** @@ -153,8 +148,8 @@ class PlaceSearchFragment : BindingFragment(R.layout kakaoMap?.setOnCameraMoveEndListener { _, _, _ -> val map = kakaoMap ?: return@setOnCameraMoveEndListener - val targetScreenX = binding.mapView.width / 2 - val targetScreenY = binding.centerMarkerIv.bottom - binding.mapView.top + val targetScreenX = binding.mapComposeView.width / 2 + val targetScreenY = binding.centerMarkerIv.bottom - binding.mapComposeView.top val targetLatLng = map.fromScreenPoint(targetScreenX, targetScreenY) @@ -214,8 +209,8 @@ class PlaceSearchFragment : BindingFragment(R.layout // 검색 결과 아이템 클릭 시 동작 kakaoMap?.let { map -> // 현재 화면을 기준으로, 지도 중앙과 마커 끝의 위도 값을 구합니다. - val mapCenterLatLng = map.fromScreenPoint(binding.mapView.width / 2, binding.mapView.height / 2) - val markerTipLatLng = map.fromScreenPoint(binding.mapView.width / 2, binding.centerMarkerIv.bottom) + val mapCenterLatLng = map.fromScreenPoint(binding.mapComposeView.width / 2, binding.mapComposeView.height / 2) + val markerTipLatLng = map.fromScreenPoint(binding.mapComposeView.width / 2, binding.centerMarkerIv.bottom) // 두 위도 값의 차이를 계산하여 '보정값(offset)'을 구합니다. (null일 경우를 대비해 기본값 0.0 사용) val latOffset = (mapCenterLatLng?.latitude ?: 0.0) - (markerTipLatLng?.latitude ?: 0.0) @@ -273,16 +268,6 @@ class PlaceSearchFragment : BindingFragment(R.layout ) == PackageManager.PERMISSION_GRANTED } - override fun onResume() { - super.onResume() - binding.mapView.resume() - } - - override fun onPause() { - super.onPause() - binding.mapView.pause() - } - companion object { const val DEFAULT_ZOOM_LEVEL = 18 const val KEY_SELECTED_PLACE = "selected_place" diff --git a/feature/exercise/src/main/res/layout/bottom_sheet_exercise_menu.xml b/feature/exercise/src/main/res/layout/bottom_sheet_exercise_menu.xml deleted file mode 100644 index 223bfd18..00000000 --- a/feature/exercise/src/main/res/layout/bottom_sheet_exercise_menu.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml b/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml deleted file mode 100644 index f91be0ce..00000000 --- a/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/exercise/src/main/res/layout/fragment_exercise_form.xml b/feature/exercise/src/main/res/layout/fragment_exercise_form.xml deleted file mode 100644 index 03fe9297..00000000 --- a/feature/exercise/src/main/res/layout/fragment_exercise_form.xml +++ /dev/null @@ -1,369 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/exercise/src/main/res/layout/fragment_place_search.xml b/feature/exercise/src/main/res/layout/fragment_place_search.xml index 01e21e96..e6888aeb 100644 --- a/feature/exercise/src/main/res/layout/fragment_place_search.xml +++ b/feature/exercise/src/main/res/layout/fragment_place_search.xml @@ -53,8 +53,8 @@ app:layout_constraintEnd_toEndOf="@+id/base_toolbar" app:layout_constraintTop_toTopOf="@+id/base_toolbar" /> - diff --git a/feature/exercise/src/main/res/layout/item_exercise_add_image.xml b/feature/exercise/src/main/res/layout/item_exercise_add_image.xml deleted file mode 100644 index 94163f71..00000000 --- a/feature/exercise/src/main/res/layout/item_exercise_add_image.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/exercise/src/main/res/layout/item_exercise_image.xml b/feature/exercise/src/main/res/layout/item_exercise_image.xml deleted file mode 100644 index ce2942c6..00000000 --- a/feature/exercise/src/main/res/layout/item_exercise_image.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml b/feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml deleted file mode 100644 index e30d5ac6..00000000 --- a/feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml b/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml index 8148aac8..f5b168be 100644 --- a/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml +++ b/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml @@ -31,7 +31,7 @@ android:id="@+id/exerciseDetailFragment" android:name="com.project200.feature.exercise.detail.ExerciseDetailFragment" android:label="exerciseDetail" - tools:layout="@layout/fragment_exercise_detail"> + > @@ -55,7 +55,7 @@ android:id="@+id/exerciseFormFragment" android:name="com.project200.feature.exercise.form.ExerciseFormFragment" android:label="exerciseForm" - tools:layout="@layout/fragment_exercise_form"> + > Unit, - private val onReplyClick: (CommentItem) -> Unit, - private val onMoreClick: (CommentItem) -> Unit, -) : ListAdapter(CommentDiffCallback()) { - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is CommentItem.CommentData -> VIEW_TYPE_COMMENT - is CommentItem.ReplyData -> VIEW_TYPE_REPLY - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - return when (viewType) { - VIEW_TYPE_COMMENT -> { - val binding = - ItemCommentBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - CommentViewHolder(binding) - } - VIEW_TYPE_REPLY -> { - val binding = - ItemReplyBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - ReplyViewHolder(binding) - } - else -> throw IllegalArgumentException("Unknown view type: $viewType") - } - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - when (val item = getItem(position)) { - is CommentItem.CommentData -> (holder as CommentViewHolder).bind(item) - is CommentItem.ReplyData -> (holder as ReplyViewHolder).bind(item) - } - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: MutableList, - ) { - if (payloads.isEmpty()) { - super.onBindViewHolder(holder, position, payloads) - return - } - - val payload = - payloads.firstOrNull() as? Set<*> ?: run { - super.onBindViewHolder(holder, position, payloads) - return - } - - if (PAYLOAD_LIKE in payload) { - val item = getItem(position) - when (holder) { - is CommentViewHolder -> holder.bindLike(item) - is ReplyViewHolder -> holder.bindLike(item) - } - } - } - - inner class CommentViewHolder( - private val binding: ItemCommentBinding, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: CommentItem.CommentData) { - with(binding) { - nicknameTv.text = item.memberNickname - timeTv.text = RelativeTimeUtil.getRelativeTime(item.createdAt) - timeTv.visibility = View.VISIBLE - contentTv.text = item.content - likeCountTv.text = item.likesCount.toString() - - val profileUrl = item.memberThumbnailUrl ?: item.memberProfileImageUrl - Glide.with(root.context) - .load(profileUrl) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .circleCrop() - .into(profileIv) - - val likeIcon = - if (item.isLiked) { - R.drawable.ic_like_fill - } else { - R.drawable.ic_like - } - likeIv.setImageResource(likeIcon) - - val isMyComment = currentMemberId != null && item.memberId == currentMemberId - moreIv.visibility = if (isMyComment) View.VISIBLE else View.GONE - - likeIv.setOnClickListener { onLikeClick(item) } - replyBtn.setOnClickListener { onReplyClick(item) } - moreIv.setOnClickListener { onMoreClick(item) } - } - } - - fun bindLike(item: CommentItem) { - with(binding) { - likeCountTv.text = item.likesCount.toString() - val likeIcon = - if (item.isLiked) { - R.drawable.ic_like_fill - } else { - R.drawable.ic_like - } - likeIv.setImageResource(likeIcon) - } - } - } - - inner class ReplyViewHolder( - private val binding: ItemReplyBinding, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: CommentItem.ReplyData) { - with(binding) { - nicknameTv.text = item.memberNickname - timeTv.text = RelativeTimeUtil.getRelativeTime(item.createdAt) - timeTv.visibility = View.VISIBLE - likeCountTv.text = item.likesCount.toString() - - if (item.taggedMember != null) { - val tag = "@${item.taggedMember.memberNickname} " - val spannable = SpannableString("$tag${item.content}") - val tagColor = ContextCompat.getColor(root.context, R.color.tag) - spannable.setSpan( - ForegroundColorSpan(tagColor), - 0, - tag.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - contentTv.text = spannable - } else { - contentTv.text = item.content - } - - val profileUrl = item.memberThumbnailUrl ?: item.memberProfileImageUrl - Glide.with(root.context) - .load(profileUrl) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .circleCrop() - .into(profileIv) - - likeIv.setImageResource( - if (item.isLiked) { - R.drawable.ic_like_fill - } else { - R.drawable.ic_like - }, - ) - - val isMyComment = currentMemberId != null && item.memberId == currentMemberId - moreIv.visibility = if (isMyComment) View.VISIBLE else View.GONE - - likeIv.setOnClickListener { onLikeClick(item) } - replyBtn.setOnClickListener { onReplyClick(item) } - moreIv.setOnClickListener { onMoreClick(item) } - } - } - - fun bindLike(item: CommentItem) { - with(binding) { - likeCountTv.text = item.likesCount.toString() - val likeIcon = - if (item.isLiked) { - R.drawable.ic_like_fill - } else { - R.drawable.ic_like - } - likeIv.setImageResource(likeIcon) - } - } - } - - class CommentDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: CommentItem, - newItem: CommentItem, - ): Boolean { - return oldItem.commentId == newItem.commentId && - oldItem::class == newItem::class - } - - override fun areContentsTheSame( - oldItem: CommentItem, - newItem: CommentItem, - ): Boolean { - return oldItem == newItem - } - - override fun getChangePayload( - oldItem: CommentItem, - newItem: CommentItem, - ): Any? { - val payloads = mutableSetOf() - if (oldItem.isLiked != newItem.isLiked || oldItem.likesCount != newItem.likesCount) { - payloads.add(PAYLOAD_LIKE) - } - return payloads.ifEmpty { null } - } - } - - companion object { - private const val VIEW_TYPE_COMMENT = 0 - private const val VIEW_TYPE_REPLY = 1 - const val PAYLOAD_LIKE = "payload_like" - } -} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt index 16d50b9a..922e3a37 100644 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailFragment.kt @@ -1,248 +1,125 @@ package com.project200.undabang.feature.feed.detail -import android.text.Editable -import android.text.TextWatcher +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import com.bumptech.glide.Glide -import com.project200.domain.model.Feed -import com.project200.presentation.base.BindingFragment -import com.project200.presentation.utils.RelativeTimeUtil +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.collectFlow import com.project200.presentation.utils.collectToast -import com.project200.presentation.view.MenuBottomSheetDialog -import com.project200.undabang.feature.feed.R -import com.project200.undabang.feature.feed.databinding.FragmentFeedDetailBinding import com.project200.undabang.feature.feed.form.FeedFormFragment import com.project200.undabang.feature.feed.list.FeedListFragment -import com.project200.undabang.feature.feed.list.ImageRVAdapter import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class FeedDetailFragment : BindingFragment(R.layout.fragment_feed_detail) { +class FeedDetailFragment : Fragment() { private val viewModel: FeedDetailViewModel by viewModels() private val args: FeedDetailFragmentArgs by navArgs() - private var commentRVAdapter: CommentRVAdapter? = null - override fun getViewBinding(view: View): FragmentFeedDetailBinding { - return FragmentFeedDetailBinding.bind(view) - } - - override fun setupViews() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) viewModel.setFeedId(args.feedId) - initToolbar() - initCommentInput() - initObserver() - observeFeedUpdated() } - private fun observeFeedUpdated() { - findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData(FeedFormFragment.FEED_UPDATED_KEY) - ?.observe(viewLifecycleOwner) { updated -> - if (updated) { - viewModel.refreshFeed() - findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) - findNavController().currentBackStackEntry?.savedStateHandle?.remove(FeedFormFragment.FEED_UPDATED_KEY) - } - } - } - - private fun initToolbar() { - binding.baseToolbar.apply { - setTitle("") - showBackButton(true) { findNavController().navigateUp() } - } - } - - private fun initCommentInput() { - with(binding.commentInputLayout) { - commentInputEt.addTextChangedListener( - object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) {} - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) {} - - override fun afterTextChanged(s: Editable?) { - val hasText = !s.isNullOrBlank() - val sendIcon = - if (hasText) { - R.drawable.ic_send - } else { - R.drawable.ic_send_unable - } - sendBtn.setImageResource(sendIcon) - } - }, - ) - - sendBtn.setOnClickListener { - val content = commentInputEt.text.toString() - if (content.isNotBlank()) { - viewModel.createComment(content) - commentInputEt.text.clear() - } - } - - cancelReplyIv.setOnClickListener { - viewModel.setReplyTarget(null) - } - } - } - - private fun initObserver() { - viewModel.feed.observe(viewLifecycleOwner) { feed -> - bindFeedData(feed) - } - - viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> - if (isLoading) { - binding.shimmerLayout.visibility = View.VISIBLE - binding.shimmerLayout.startShimmer() - binding.scrollView.visibility = View.GONE - } else { - binding.shimmerLayout.stopShimmer() - binding.shimmerLayout.visibility = View.GONE - binding.scrollView.visibility = View.VISIBLE + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val feed by viewModel.feed.collectAsStateWithLifecycle() + val comments by viewModel.comments.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val isMyFeed by viewModel.isMyFeed.collectAsStateWithLifecycle() + val currentMemberId by viewModel.currentMemberId.collectAsStateWithLifecycle() + val replyTarget by viewModel.replyTarget.collectAsStateWithLifecycle() + + // 댓글 입력 텍스트는 화면 회전 시에도 유지되도록 rememberSaveable로 저장 + var commentInput by rememberSaveable { mutableStateOf("") } + // 첫 로드 실패를 한 번 감지하면 토스트 + 에러 UI 표시 + val isLoadError = remember { mutableStateOf(false) } + LaunchedFeedLoadErrorCollector(viewModel) { isLoadError.value = true } + + FeedDetailScreen( + feed = feed, + commentItems = comments.toCommentItems(), + isLoading = isLoading, + isLoadError = isLoadError.value, + isMyFeed = isMyFeed, + currentMemberId = currentMemberId, + replyTarget = replyTarget, + commentInput = commentInput, + onCommentInputChange = { commentInput = it }, + onSendComment = { + if (commentInput.isNotBlank()) { + viewModel.createComment(commentInput) + commentInput = "" + } + }, + onCancelReply = { viewModel.setReplyTarget(null) }, + onReplyClick = viewModel::setReplyTarget, + onCommentLikeClick = viewModel::toggleCommentLike, + onCommentMoreClick = ::showCommentMenuBottomSheet, + onFeedLikeClick = viewModel::toggleFeedLike, + onFeedMoreClick = ::showFeedMenuBottomSheet, + onBackClick = { findNavController().navigateUp() }, + ) } } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + observeFeedUpdated() collectToast(viewModel.toastEvent) - collectFlow(viewModel.feedDeleted) { findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) findNavController().navigateUp() } + } - collectFlow(viewModel.feedLoadError) { - binding.errorTv.visibility = View.VISIBLE - binding.scrollView.visibility = View.GONE - } - - viewModel.isMyFeed.observe(viewLifecycleOwner) { isMyFeed -> - binding.moreIv.visibility = if (isMyFeed) View.VISIBLE else View.GONE - } - - viewModel.comments.observe(viewLifecycleOwner) { comments -> - setupCommentAdapter() - val items = comments.toCommentItems() - commentRVAdapter?.submitList(items) - - binding.noCommentsTv.visibility = if (comments.isEmpty()) View.VISIBLE else View.GONE - binding.commentsRv.visibility = if (comments.isEmpty()) View.GONE else View.VISIBLE - } - - viewModel.replyTarget.observe(viewLifecycleOwner) { target -> - with(binding.commentInputLayout) { - if (target != null) { - replyTargetTv.text = getString(R.string.feed_reply_writing, target.memberNickname) - replyTargetTv.visibility = View.VISIBLE - cancelReplyIv.visibility = View.VISIBLE - } else { - replyTargetTv.visibility = View.GONE - cancelReplyIv.visibility = View.GONE + private fun observeFeedUpdated() { + // 작성/수정 화면에서 돌아왔을 때 갱신 시그널 처리 + findNavController().currentBackStackEntry?.savedStateHandle + ?.getLiveData(FeedFormFragment.FEED_UPDATED_KEY) + ?.observe(viewLifecycleOwner) { updated -> + if (updated) { + viewModel.refreshFeed() + findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) + findNavController().currentBackStackEntry?.savedStateHandle?.remove(FeedFormFragment.FEED_UPDATED_KEY) } } - } - } - - private fun setupCommentAdapter() { - if (commentRVAdapter != null) return - - val currentMemberId = viewModel.currentMemberId.value - commentRVAdapter = - CommentRVAdapter( - currentMemberId = currentMemberId, - onLikeClick = { item -> viewModel.toggleCommentLike(item) }, - onReplyClick = { item -> viewModel.setReplyTarget(item) }, - onMoreClick = { item -> showCommentMenuBottomSheet(item) }, - ) - binding.commentsRv.adapter = commentRVAdapter } private fun showCommentMenuBottomSheet(item: CommentItem) { - MenuBottomSheetDialog( - onDeleteClicked = { - viewModel.deleteComment(item.commentId) - }, - showEditButton = false, - ).show(parentFragmentManager, "CommentMenu") - } - - private fun bindFeedData(feed: Feed) { - with(binding) { - nicknameTv.text = feed.nickname - timeTv.text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt) - contentTv.text = feed.feedContent - likeCountTv.text = feed.feedLikesCount.toString() - commentCountTv.text = feed.feedCommentsCount.toString() - - val likeIcon = - if (feed.feedIsLiked) { - R.drawable.ic_like_fill - } else { - R.drawable.ic_like - } - likeIv.setImageResource(likeIcon) - - likeIv.setOnClickListener { - viewModel.toggleFeedLike() - } - - val hasType = !feed.feedTypeName.isNullOrBlank() - arrowIv.visibility = if (hasType) View.VISIBLE else View.GONE - feedTypeTv.visibility = if (hasType) View.VISIBLE else View.GONE - if (hasType) { - feedTypeTv.text = feed.feedTypeName - } - - Glide.with(profileIv.context) - .load(feed.profileUrl) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .circleCrop() - .into(profileIv) - - if (feed.feedPictures.isNotEmpty()) { - imagesRv.visibility = View.VISIBLE - imagesRv.layoutManager = - LinearLayoutManager( - context, - LinearLayoutManager.HORIZONTAL, - false, - ) - imagesRv.adapter = ImageRVAdapter(feed.feedPictures) - } else { - imagesRv.visibility = View.GONE - } - - moreIv.setOnClickListener { - showMenuBottomSheet() - } - } + UndabangBottomSheet.showMenu( + fragmentManager = parentFragmentManager, + showEdit = false, + onDeleteClick = { viewModel.deleteComment(item.commentId) }, + ) } - private fun showMenuBottomSheet() { - MenuBottomSheetDialog( - onEditClicked = { - navigateToEditFeed() - }, - onDeleteClicked = { - viewModel.deleteFeed() - }, - ).show(parentFragmentManager, MenuBottomSheetDialog::class.java.simpleName) + private fun showFeedMenuBottomSheet() { + UndabangBottomSheet.showMenu( + fragmentManager = parentFragmentManager, + onEditClick = { navigateToEditFeed() }, + onDeleteClick = { viewModel.deleteFeed() }, + ) } private fun navigateToEditFeed() { @@ -253,9 +130,14 @@ class FeedDetailFragment : BindingFragment(R.layout.f ) findNavController().navigate(action) } +} - override fun onDestroyView() { - commentRVAdapter = null - super.onDestroyView() +@androidx.compose.runtime.Composable +private fun LaunchedFeedLoadErrorCollector( + viewModel: FeedDetailViewModel, + onError: () -> Unit, +) { + androidx.compose.runtime.LaunchedEffect(viewModel) { + viewModel.feedLoadError.collect { onError() } } } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailScreen.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailScreen.kt new file mode 100644 index 00000000..8f600114 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailScreen.kt @@ -0,0 +1,594 @@ +package com.project200.undabang.feature.feed.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.project200.domain.model.Feed +import com.project200.domain.model.FeedPicture +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.subtext12 +import com.project200.presentation.compose.theme.subtext14 +import com.project200.presentation.utils.RelativeTimeUtil +import com.project200.undabang.feature.feed.R +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun FeedDetailScreen( + feed: Feed?, + commentItems: List, + isLoading: Boolean, + isLoadError: Boolean, + isMyFeed: Boolean, + currentMemberId: String?, + replyTarget: CommentItem?, + commentInput: String, + onCommentInputChange: (String) -> Unit, + onSendComment: () -> Unit, + onCancelReply: () -> Unit, + onReplyClick: (CommentItem) -> Unit, + onCommentLikeClick: (CommentItem) -> Unit, + onCommentMoreClick: (CommentItem) -> Unit, + onFeedLikeClick: () -> Unit, + onFeedMoreClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = "", + onNavigationClick = onBackClick, + actions = { + // 내 피드일 때만 우측에 더보기 메뉴 노출 + if (isMyFeed) { + IconButton(onClick = onFeedMoreClick) { + Icon( + painter = painterResource(R.drawable.ic_more), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + }, + ) + }, + bottomBar = { + CommentInputBar( + input = commentInput, + replyTarget = replyTarget, + onChange = onCommentInputChange, + onSend = onSendComment, + onCancelReply = onCancelReply, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + isLoadError -> { + Text( + text = stringResource(R.string.feed_load_error), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + modifier = Modifier.align(Alignment.Center), + ) + } + feed != null -> { + FeedDetailContent( + feed = feed, + commentItems = commentItems, + currentMemberId = currentMemberId, + onFeedLikeClick = onFeedLikeClick, + onCommentLikeClick = onCommentLikeClick, + onReplyClick = onReplyClick, + onCommentMoreClick = onCommentMoreClick, + ) + } + } + } + } +} + +@Composable +private fun FeedDetailContent( + feed: Feed, + commentItems: List, + currentMemberId: String?, + onFeedLikeClick: () -> Unit, + onCommentLikeClick: (CommentItem) -> Unit, + onReplyClick: (CommentItem) -> Unit, + onCommentMoreClick: (CommentItem) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + item(key = "header") { FeedHeader(feed = feed) } + item(key = "content") { + Text( + text = feed.feedContent.orEmpty(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) + } + if (feed.feedPictures.isNotEmpty()) { + item(key = "images") { + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(feed.feedPictures, key = { it.feedPictureId }) { picture -> + DetailImage(picture = picture) + } + } + Spacer(Modifier.height(16.dp)) + } + } + item(key = "actions") { FeedActionBar(feed = feed, onLikeClick = onFeedLikeClick) } + item(key = "divider") { + Spacer( + Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .height(1.dp) + .background(ColorGray300), + ) + } + if (commentItems.isEmpty()) { + item(key = "no-comments") { + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.comment_empty_message), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + ) + } + } + } else { + items(commentItems, key = { "${if (it is CommentItem.ReplyData) "r" else "c"}-${it.commentId}" }) { item -> + CommentRow( + item = item, + currentMemberId = currentMemberId, + onLikeClick = { onCommentLikeClick(item) }, + onReplyClick = { onReplyClick(item) }, + onMoreClick = { onCommentMoreClick(item) }, + ) + } + } + } +} + +@Composable +private fun FeedHeader( + feed: Feed, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = feed.profileUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(PresentationR.drawable.ic_profile_default), + error = painterResource(PresentationR.drawable.ic_profile_default), + fallback = painterResource(PresentationR.drawable.ic_profile_default), + modifier = + Modifier + .size(40.dp) + .clip(CircleShape), + ) + Spacer(Modifier.width(12.dp)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = feed.nickname, + style = MaterialTheme.typography.contentBold, + fontSize = 14.sp, + color = ColorBlack, + ) + if (!feed.feedTypeName.isNullOrBlank()) { + Spacer(Modifier.width(6.dp)) + Icon( + painter = painterResource(PresentationR.drawable.ic_arrow_right), + contentDescription = null, + tint = ColorGray200, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = feed.feedTypeName.orEmpty(), + style = MaterialTheme.typography.contentBold, + fontSize = 14.sp, + color = ColorBlack, + ) + } + } + Text( + text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt), + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + ) + } + } +} + +@Composable +private fun DetailImage( + picture: FeedPicture, + modifier: Modifier = Modifier, +) { + AsyncImage( + model = picture.feedPictureUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.ic_feed_image_placeholder), + error = painterResource(R.drawable.ic_feed_image_placeholder), + modifier = + modifier + .padding(end = 8.dp) + .height(160.dp) + .clip(RoundedCornerShape(12.dp)) + .background(ColorWhite300), + ) +} + +@Composable +private fun FeedActionBar( + feed: Feed, + onLikeClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_comment), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.padding(4.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = feed.feedCommentsCount.toString(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + ) + Spacer(Modifier.width(12.dp)) + Icon( + painter = + painterResource( + if (feed.feedIsLiked) R.drawable.ic_like_fill else R.drawable.ic_like, + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .padding(4.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onLikeClick, + ), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = feed.feedLikesCount.toString(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + ) + } +} + +@Composable +private fun CommentRow( + item: CommentItem, + currentMemberId: String?, + onLikeClick: () -> Unit, + onReplyClick: () -> Unit, + onMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isReply = item is CommentItem.ReplyData + Row( + modifier = + modifier + .fillMaxWidth() + .padding( + // 답글은 더 들여서 표시 + start = if (isReply) 56.dp else 16.dp, + end = 16.dp, + top = 12.dp, + bottom = 12.dp, + ), + ) { + AsyncImage( + model = item.memberProfileImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(PresentationR.drawable.ic_profile_default), + error = painterResource(PresentationR.drawable.ic_profile_default), + fallback = painterResource(PresentationR.drawable.ic_profile_default), + modifier = + Modifier + .size(if (isReply) 28.dp else 32.dp) + .clip(CircleShape), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.memberNickname, + style = MaterialTheme.typography.contentBold, + fontSize = 13.sp, + color = ColorBlack, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = RelativeTimeUtil.getRelativeTime(item.createdAt), + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + ) + } + Spacer(Modifier.height(4.dp)) + val displayContent = + when (item) { + is CommentItem.ReplyData -> { + val tag = item.taggedMember?.memberNickname + if (tag.isNullOrBlank()) item.content else "@$tag ${item.content}" + } + else -> item.content + } + Text( + text = displayContent, + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + ) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "답글", + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + modifier = + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onReplyClick, + ), + ) + Spacer(Modifier.width(12.dp)) + Icon( + painter = + painterResource( + if (item.isLiked) R.drawable.ic_like_fill else R.drawable.ic_like, + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .size(16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onLikeClick, + ), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = item.likesCount.toString(), + style = MaterialTheme.typography.subtext12, + color = ColorBlack, + ) + } + } + // 내 댓글에만 더보기 노출 + if (currentMemberId != null && item.memberId == currentMemberId) { + Icon( + painter = painterResource(R.drawable.ic_more), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .padding(start = 8.dp) + .size(20.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onMoreClick, + ), + ) + } + } +} + +@Composable +private fun CommentInputBar( + input: String, + replyTarget: CommentItem?, + onChange: (String) -> Unit, + onSend: () -> Unit, + onCancelReply: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .background(ColorWhite300) + .padding(vertical = 8.dp), + ) { + Spacer(Modifier.fillMaxWidth().height(1.dp).background(ColorGray300)) + + // 답글 대상 표시 (있으면 상단에 띠 형태) + if (replyTarget != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 10.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + stringResource( + R.string.feed_reply_writing, + replyTarget.memberNickname, + ), + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + Icon( + painter = painterResource(PresentationR.drawable.ic_close), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .size(20.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onCancelReply, + ), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp), + verticalAlignment = Alignment.Bottom, + ) { + BasicTextField( + value = input, + onValueChange = onChange, + textStyle = TextStyle(fontSize = 14.sp, color = ColorBlack), + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(20.dp)) + .background(ColorWhite300) + .padding(horizontal = 12.dp, vertical = 10.dp) + .heightIn(min = 24.dp, max = 130.dp) + .defaultMinSize(minHeight = 24.dp), + decorationBox = { inner -> + if (input.isEmpty()) { + Text( + text = stringResource(R.string.comment_input_hint), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + ) + } + inner() + }, + ) + Spacer(Modifier.width(6.dp)) + Icon( + painter = + painterResource( + if (input.isNotBlank()) R.drawable.ic_send else R.drawable.ic_send_unable, + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .padding(6.dp) + .size(24.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = input.isNotBlank(), + onClick = onSend, + ), + ) + } + } +} + +@Preview(showBackground = true, heightDp = 800) +@Composable +private fun FeedDetailScreenPreview() { + AppTheme { + FeedDetailScreen( + feed = null, + commentItems = emptyList(), + isLoading = true, + isLoadError = false, + isMyFeed = false, + currentMemberId = null, + replyTarget = null, + commentInput = "", + onCommentInputChange = {}, + onSendComment = {}, + onCancelReply = {}, + onReplyClick = {}, + onCommentLikeClick = {}, + onCommentMoreClick = {}, + onFeedLikeClick = {}, + onFeedMoreClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt index f0fedb3a..04ea13fc 100644 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/detail/FeedDetailViewModel.kt @@ -1,7 +1,5 @@ package com.project200.undabang.feature.feed.detail -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -21,8 +19,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay 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.launch import javax.inject.Inject @@ -41,11 +42,11 @@ class FeedDetailViewModel ) : ViewModel() { private var feedId: Long = -1L - private val _feed = MutableLiveData() - val feed: LiveData get() = _feed + private val _feed = MutableStateFlow(null) + val feed: StateFlow = _feed.asStateFlow() - private val _isLoading = MutableLiveData(false) - val isLoading: LiveData get() = _isLoading + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() private val _toastEvent = MutableSharedFlow() val toastEvent: SharedFlow = _toastEvent.asSharedFlow() @@ -56,21 +57,19 @@ class FeedDetailViewModel private val _feedLoadError = MutableSharedFlow() val feedLoadError: SharedFlow = _feedLoadError.asSharedFlow() - private val _currentMemberId = MutableLiveData() - val currentMemberId: LiveData get() = _currentMemberId + private val _currentMemberId = MutableStateFlow(null) + val currentMemberId: StateFlow = _currentMemberId.asStateFlow() - private val _isMyFeed = MutableLiveData(false) - val isMyFeed: LiveData get() = _isMyFeed + private val _isMyFeed = MutableStateFlow(false) + val isMyFeed: StateFlow = _isMyFeed.asStateFlow() - private val _comments = MutableLiveData>() - val comments: LiveData> get() = _comments + private val _comments = MutableStateFlow>(emptyList()) + val comments: StateFlow> = _comments.asStateFlow() - private val _commentsLoading = MutableLiveData(false) - val commentsLoading: LiveData get() = _commentsLoading - - private val _replyTarget = MutableLiveData() - val replyTarget: LiveData get() = _replyTarget + private val _replyTarget = MutableStateFlow(null) + val replyTarget: StateFlow = _replyTarget.asStateFlow() + // 댓글별 좋아요 토글 디바운스 처리용 (서버 중복 호출 방지) private val commentLikeJobs = mutableMapOf() private val pendingCommentLikes = mutableMapOf() @@ -143,7 +142,6 @@ class FeedDetailViewModel fun loadComments() { if (feedId == -1L) return - _commentsLoading.value = true viewModelScope.launch { when (val result = getCommentsUseCase(feedId)) { is BaseResult.Success -> { @@ -153,7 +151,6 @@ class FeedDetailViewModel _toastEvent.emit(UiText.StringResource(R.string.comment_load_error)) } } - _commentsLoading.value = false } } @@ -230,7 +227,7 @@ class FeedDetailViewModel commentId: Long, isLiked: Boolean, ) { - val currentComments = _comments.value ?: return + val currentComments = _comments.value val updatedComments = currentComments.map { comment -> if (comment.commentId == commentId) { diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt index 833ec17f..e86aea88 100644 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormFragment.kt @@ -1,31 +1,32 @@ package com.project200.undabang.feature.feed.form +import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.Toast +import android.view.ViewGroup import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import com.bumptech.glide.Glide -import com.project200.domain.model.PreferredExercise -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.collectToast -import com.project200.presentation.view.SelectionBottomSheetDialog -import com.project200.undabang.feature.feed.R -import com.project200.undabang.feature.feed.databinding.FragmentFeedFormBinding import com.project200.undabang.feature.feed.list.FeedListFragment import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint -class FeedFormFragment : BindingFragment(R.layout.fragment_feed_form) { +class FeedFormFragment : Fragment() { private val viewModel: FeedFormViewModel by viewModels() private val args: FeedFormFragmentArgs by navArgs() - private val imageAdapter = - FeedFormImageAdapter( - onDeleteExistingClick = { imageId -> viewModel.removeExistingImage(imageId) }, - onDeleteNewClick = { uri -> viewModel.removeImage(uri) }, - ) private val pickImagesLauncher = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> @@ -34,125 +35,95 @@ class FeedFormFragment : BindingFragment(R.layout.fragm } } - override fun getViewBinding(view: View): FragmentFeedFormBinding { - return FragmentFeedFormBinding.bind(view) - } - - override fun setupViews() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) viewModel.initData(feedId = args.feedId) - initToolbar() - initView() - initObserver() - } - - private fun initToolbar() { - viewModel.isEditMode.observe(viewLifecycleOwner) { isEditMode -> - val title = - if (isEditMode) { - getString(R.string.feed_form_edit_title) - } else { - getString(R.string.feed_form_title) - } - binding.baseToolbar.setTitle(title) - } - binding.baseToolbar.showBackButton(true) { findNavController().navigateUp() } - binding.completeBtn.setOnClickListener { - viewModel.submitFeed(binding.contentEt.text.toString()) - } - } - - private fun initView() { - binding.imageListRv.adapter = imageAdapter - - binding.addImageBtn.setOnClickListener { - pickImagesLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - - binding.dabangSelectionTv.setOnClickListener { - showDabangSelection() - } - - binding.arrowIv.setOnClickListener { - showDabangSelection() - } - } - - private fun showDabangSelection() { - viewModel.requestShowDabangSelection() - } - - private fun updateImageList() { - val existingImages = viewModel.registeredImages.value ?: emptyList() - val newImages = viewModel.selectedImages.value ?: emptyList() - imageAdapter.submitList(existingImages, newImages) - } - - private fun displayDabangSelection(types: List) { - val names = types.map { it.name } - - SelectionBottomSheetDialog(names) { selectedName -> - val selected = types.find { it.name == selectedName } - viewModel.selectType(selected) - binding.dabangSelectionTv.text = selectedName - binding.dabangSelectionTv.setTextColor(resources.getColor(com.project200.undabang.presentation.R.color.black, null)) - }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name) - viewModel.onDabangSelectionShown() } - private fun initObserver() { - viewModel.userProfile.observe(viewLifecycleOwner) { profile -> - binding.nicknameTv.text = profile.nickname - Glide.with(this) - .load(profile.profileImageUrl) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .circleCrop() - .into(binding.profileIv) - } - - viewModel.selectedImages.observe(viewLifecycleOwner) { _ -> - updateImageList() - } - - viewModel.registeredImages.observe(viewLifecycleOwner) { _ -> - updateImageList() - } - - viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> - binding.loadingPb.visibility = if (isLoading) View.VISIBLE else View.GONE - } - - viewModel.createSuccess.observe(viewLifecycleOwner) { - Toast.makeText(context, R.string.feed_form_create_success, Toast.LENGTH_SHORT).show() - findNavController().previousBackStackEntry?.savedStateHandle?.set(FeedListFragment.REFRESH_KEY, true) - findNavController().popBackStack() - } - - viewModel.updateSuccess.observe(viewLifecycleOwner) { success -> - if (success) { - Toast.makeText(context, R.string.feed_form_update_success, Toast.LENGTH_SHORT).show() - findNavController().previousBackStackEntry?.savedStateHandle?.set(FEED_UPDATED_KEY, true) - findNavController().popBackStack() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle() + val userProfile by viewModel.userProfile.collectAsStateWithLifecycle() + val content by viewModel.content.collectAsStateWithLifecycle() + val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() + val registeredImages by viewModel.registeredImages.collectAsStateWithLifecycle() + val selectedImages by viewModel.selectedImages.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + FeedFormScreen( + isEditMode = isEditMode, + userProfile = userProfile, + content = content, + selectedTypeName = selectedType?.name, + registeredImages = registeredImages, + newImages = selectedImages, + isLoading = isLoading, + onContentChange = viewModel::updateContent, + onDabangSelectClick = viewModel::requestShowDabangSelection, + onAddImageClick = { + pickImagesLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + onRemoveRegisteredImage = viewModel::removeExistingImage, + onRemoveNewImage = viewModel::removeImage, + onCompleteClick = viewModel::submitFeed, + onBackClick = { findNavController().navigateUp() }, + ) } } - collectToast(viewModel.toastEvent) + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) - viewModel.showDabangSelection.observe(viewLifecycleOwner) { types -> - if (!types.isNullOrEmpty()) { - displayDabangSelection(types) - } - } - - viewModel.initialContentForEdit.observe(viewLifecycleOwner) { content -> - if (!content.isNullOrEmpty()) { - binding.contentEt.setText(content) - } - } + collectToast(viewModel.toastEvent) - viewModel.selectedType.observe(viewLifecycleOwner) { type -> - if (type != null) { - binding.dabangSelectionTv.text = type.name - binding.dabangSelectionTv.setTextColor(resources.getColor(com.project200.undabang.presentation.R.color.black, null)) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.showDabangSelection.collect { types -> + val names = types.map { it.name } + UndabangBottomSheet.showSelection( + fragmentManager = parentFragmentManager, + items = names, + ) { selectedName -> + val selected = types.find { it.name == selectedName } + viewModel.selectType(selected) + } + } + } + launch { + viewModel.createSuccess.collect { + android.widget.Toast.makeText( + requireContext(), + com.project200.undabang.feature.feed.R.string.feed_form_create_success, + android.widget.Toast.LENGTH_SHORT, + ).show() + findNavController().previousBackStackEntry?.savedStateHandle + ?.set(FeedListFragment.REFRESH_KEY, true) + findNavController().popBackStack() + } + } + launch { + viewModel.updateSuccess.collect { + android.widget.Toast.makeText( + requireContext(), + com.project200.undabang.feature.feed.R.string.feed_form_update_success, + android.widget.Toast.LENGTH_SHORT, + ).show() + findNavController().previousBackStackEntry?.savedStateHandle + ?.set(FEED_UPDATED_KEY, true) + findNavController().popBackStack() + } + } } } } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt deleted file mode 100644 index 3a23efff..00000000 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormImageAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.project200.undabang.feature.feed.form - -import android.net.Uri -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.project200.undabang.feature.feed.R -import com.project200.undabang.feature.feed.databinding.ItemFeedFormImageBinding - -class FeedFormImageAdapter( - private val onDeleteExistingClick: (Long) -> Unit, - private val onDeleteNewClick: (Uri) -> Unit, -) : RecyclerView.Adapter() { - private val items = mutableListOf() - - fun submitList( - registeredImages: List, - newImages: List, - ) { - items.clear() - items.addAll(registeredImages.map { FeedFormImageItem.Existing(it.imageId, it.imageUrl) }) - items.addAll(newImages.map { FeedFormImageItem.New(it) }) - notifyDataSetChanged() - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - val binding = - ItemFeedFormImageBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return ViewHolder(binding) - } - - override fun onBindViewHolder( - holder: ViewHolder, - position: Int, - ) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int = items.size - - inner class ViewHolder(private val binding: ItemFeedFormImageBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(item: FeedFormImageItem) { - val imageSource: Any = - when (item) { - is FeedFormImageItem.Existing -> item.imageUrl - is FeedFormImageItem.New -> item.uri - } - - Glide.with(binding.formImageIv.context) - .load(imageSource) - .placeholder(R.drawable.ic_feed_image_placeholder) - .fitCenter() - .into(binding.formImageIv) - - binding.deleteIv.setOnClickListener { - when (item) { - is FeedFormImageItem.Existing -> onDeleteExistingClick(item.imageId) - is FeedFormImageItem.New -> onDeleteNewClick(item.uri) - } - } - } - } -} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormScreen.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormScreen.kt new file mode 100644 index 00000000..498feae1 --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormScreen.kt @@ -0,0 +1,343 @@ +package com.project200.undabang.feature.feed.form + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.defaultMinSize +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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.project200.domain.model.UserProfile +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.subtext14 +import com.project200.undabang.feature.feed.R +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun FeedFormScreen( + isEditMode: Boolean, + userProfile: UserProfile?, + content: String, + selectedTypeName: String?, + registeredImages: List, + newImages: List, + isLoading: Boolean, + onContentChange: (String) -> Unit, + onDabangSelectClick: () -> Unit, + onAddImageClick: () -> Unit, + onRemoveRegisteredImage: (Long) -> Unit, + onRemoveNewImage: (Uri) -> Unit, + onCompleteClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = + stringResource( + if (isEditMode) R.string.feed_form_edit_title else R.string.feed_form_title, + ), + onNavigationClick = onBackClick, + actions = { + PrimaryButton( + text = stringResource(PresentationR.string.complete), + onClick = onCompleteClick, + isCompact = true, + modifier = Modifier.padding(end = 20.dp), + ) + }, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + ProfileRow( + userProfile = userProfile, + selectedTypeName = selectedTypeName, + onDabangSelectClick = onDabangSelectClick, + ) + + Spacer(Modifier.height(16.dp)) + + BasicTextField( + value = content, + onValueChange = onContentChange, + textStyle = + TextStyle( + fontSize = 14.sp, + color = ColorBlack, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 60.dp) + .defaultMinSize(minHeight = 60.dp), + decorationBox = { inner -> + if (content.isEmpty()) { + Text( + text = stringResource(R.string.feed_form_content_hint), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + ) + } + inner() + }, + ) + + Spacer(Modifier.height(12.dp)) + + AddImageButton( + onClick = onAddImageClick, + modifier = Modifier.padding(start = 60.dp), + ) + + Spacer(Modifier.height(16.dp)) + + if (registeredImages.isNotEmpty() || newImages.isNotEmpty()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = 60.dp), + ) { + items(registeredImages, key = { "existing-${it.imageId}" }) { image -> + ImageCard( + model = image.imageUrl, + onDelete = { onRemoveRegisteredImage(image.imageId) }, + ) + } + items(newImages, key = { "new-$it" }) { uri -> + ImageCard( + model = uri, + onDelete = { onRemoveNewImage(uri) }, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +@Composable +private fun ProfileRow( + userProfile: UserProfile?, + selectedTypeName: String?, + onDabangSelectClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = userProfile?.profileImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(PresentationR.drawable.ic_profile_default), + error = painterResource(PresentationR.drawable.ic_profile_default), + fallback = painterResource(PresentationR.drawable.ic_profile_default), + modifier = + Modifier + .size(48.dp) + .clip(CircleShape), + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = userProfile?.nickname.orEmpty(), + style = MaterialTheme.typography.contentBold, + fontSize = 16.sp, + color = ColorBlack, + ) + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDabangSelectClick, + ), + ) { + Icon( + painter = painterResource(PresentationR.drawable.ic_arrow_right), + contentDescription = null, + tint = ColorGray200, + ) + Spacer(Modifier.width(8.dp)) + val typeLabel = selectedTypeName ?: stringResource(R.string.feed_select_dabang) + val typeColor = if (selectedTypeName != null) ColorBlack else ColorGray200 + Text( + text = typeLabel, + style = MaterialTheme.typography.subtext14, + color = typeColor, + ) + } + } + } +} + +@Composable +private fun AddImageButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_add_image), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.feed_form_add_image), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + ) + } +} + +@Composable +private fun ImageCard( + model: Any?, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding(end = 12.dp), + ) { + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Fit, + placeholder = painterResource(R.drawable.ic_feed_image_placeholder), + error = painterResource(R.drawable.ic_feed_image_placeholder), + modifier = + Modifier + .height(120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(ColorWhite300), + ) + Icon( + painter = painterResource(R.drawable.ic_delete_circle), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 4.dp, end = 4.dp) + .size(24.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDelete, + ), + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun FeedFormScreenPreview() { + AppTheme { + FeedFormScreen( + isEditMode = false, + userProfile = + UserProfile( + profileThumbnailUrl = null, + profileImageUrl = null, + nickname = "운다방 테스터", + gender = "M", + birthDate = "1990-01-01", + bio = null, + yearlyExerciseDays = 0, + exerciseCountInLast30Days = 0, + exerciseScore = 0, + preferredExercises = emptyList(), + ), + content = "오늘 운동 다녀왔어요!", + selectedTypeName = "헬스", + registeredImages = emptyList(), + newImages = emptyList(), + isLoading = false, + onContentChange = {}, + onDabangSelectClick = {}, + onAddImageClick = {}, + onRemoveRegisteredImage = {}, + onRemoveNewImage = {}, + onCompleteClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt index e45adb94..24500a85 100644 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/form/FeedFormViewModel.kt @@ -1,8 +1,6 @@ package com.project200.undabang.feature.feed.form import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -22,8 +20,11 @@ import com.project200.presentation.utils.UiText import com.project200.undabang.feature.feed.R import dagger.hilt.android.lifecycle.HiltViewModel 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.launch import javax.inject.Inject @@ -42,43 +43,42 @@ class FeedFormViewModel ) : ViewModel() { private var feedId: Long = -1L - private val _isEditMode = MutableLiveData(false) - val isEditMode: LiveData get() = _isEditMode + private val _isEditMode = MutableStateFlow(false) + val isEditMode: StateFlow = _isEditMode.asStateFlow() - private val _userProfile = MutableLiveData() - val userProfile: LiveData get() = _userProfile + private val _userProfile = MutableStateFlow(null) + val userProfile: StateFlow = _userProfile.asStateFlow() - private val _exerciseTypes = MutableLiveData>() - val exerciseTypes: LiveData> get() = _exerciseTypes + private val exerciseTypesFlow = MutableStateFlow>(emptyList()) - private val _selectedType = MutableLiveData() - val selectedType: LiveData get() = _selectedType + private val _selectedType = MutableStateFlow(null) + val selectedType: StateFlow = _selectedType.asStateFlow() - private val _selectedImages = MutableLiveData>(emptyList()) - val selectedImages: LiveData> get() = _selectedImages + private val _selectedImages = MutableStateFlow>(emptyList()) + val selectedImages: StateFlow> = _selectedImages.asStateFlow() - private val _registeredImages = MutableLiveData>(emptyList()) - val registeredImages: LiveData> get() = _registeredImages + private val _registeredImages = MutableStateFlow>(emptyList()) + val registeredImages: StateFlow> = _registeredImages.asStateFlow() + + private val _content = MutableStateFlow("") + val content: StateFlow = _content.asStateFlow() private val deletedImageIds = mutableListOf() - private val _isLoading = MutableLiveData() - val isLoading: LiveData get() = _isLoading + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() - private val _createSuccess = MutableLiveData() - val createSuccess: LiveData get() = _createSuccess + private val _createSuccess = MutableSharedFlow() + val createSuccess: SharedFlow = _createSuccess.asSharedFlow() - private val _updateSuccess = MutableLiveData() - val updateSuccess: LiveData get() = _updateSuccess + private val _updateSuccess = MutableSharedFlow() + val updateSuccess: SharedFlow = _updateSuccess.asSharedFlow() private val _toastEvent = MutableSharedFlow() val toastEvent: SharedFlow = _toastEvent.asSharedFlow() - private val _showDabangSelection = MutableLiveData?>() - val showDabangSelection: LiveData?> get() = _showDabangSelection - - private val _initialContentForEdit = MutableLiveData() - val initialContentForEdit: LiveData get() = _initialContentForEdit + private val _showDabangSelection = MutableSharedFlow>() + val showDabangSelection: SharedFlow> = _showDabangSelection.asSharedFlow() fun initData(feedId: Long = -1L) { this.feedId = feedId @@ -100,9 +100,9 @@ class FeedFormViewModel val allList = if (allTypesResult is BaseResult.Success) allTypesResult.data else emptyList() val combined = (preferredList + allList).distinctBy { it.exerciseTypeId } - _exerciseTypes.value = combined + exerciseTypesFlow.value = combined - if (_isEditMode.value == true) { + if (_isEditMode.value) { loadFeedForEdit(combined) } } @@ -112,7 +112,7 @@ class FeedFormViewModel when (val result = getFeedDetailUseCase(feedId)) { is BaseResult.Success -> { val feed = result.data - _initialContentForEdit.value = feed.feedContent + _content.value = feed.feedContent.orEmpty() val existingList = feed.feedPictures.map { picture -> @@ -142,39 +142,39 @@ class FeedFormViewModel } } + fun updateContent(value: String) { + _content.value = value + } + fun selectType(exercise: PreferredExercise?) { _selectedType.value = exercise } fun requestShowDabangSelection() { - val types = _exerciseTypes.value - if (!types.isNullOrEmpty()) { - _showDabangSelection.value = types + val types = exerciseTypesFlow.value + if (types.isNotEmpty()) { + viewModelScope.launch { + _showDabangSelection.emit(types) + } } } - fun onDabangSelectionShown() { - _showDabangSelection.value = null - } - fun addImages(uris: List) { - val current = _selectedImages.value ?: emptyList() - _selectedImages.value = current + uris + _selectedImages.value = _selectedImages.value + uris } fun removeImage(uri: Uri) { - val current = _selectedImages.value ?: emptyList() - _selectedImages.value = current.filter { it != uri } + _selectedImages.value = _selectedImages.value.filter { it != uri } } fun removeExistingImage(imageId: Long) { deletedImageIds.add(imageId) - val current = _registeredImages.value ?: emptyList() - _registeredImages.value = current.filter { it.imageId != imageId } + _registeredImages.value = _registeredImages.value.filter { it.imageId != imageId } } - fun submitFeed(content: String) { - if (content.isBlank()) { + fun submitFeed() { + val text = _content.value + if (text.isBlank()) { viewModelScope.launch { _toastEvent.emit(UiText.StringResource(R.string.feed_form_empty_content_warning)) } @@ -183,73 +183,79 @@ class FeedFormViewModel _isLoading.value = true viewModelScope.launch { - if (_isEditMode.value == true) { - val model = - UpdateFeedModel( - feedId = feedId, - feedContent = content, - feedTypeId = _selectedType.value?.exerciseTypeId, - ) - when (updateFeedUseCase(model)) { - is BaseResult.Success -> { - var hasImageError = false - - deletedImageIds.forEach { imageId -> - when (deleteFeedImageUseCase(feedId, imageId)) { - is BaseResult.Error -> hasImageError = true - is BaseResult.Success -> {} - } - } + if (_isEditMode.value) { + submitEdit(text) + } else { + submitCreate(text) + } + _isLoading.value = false + } + } - val newImages = _selectedImages.value ?: emptyList() - if (newImages.isNotEmpty()) { - val imageUriStrings = newImages.map { it.toString() } - when (uploadFeedImagesUseCase(feedId, imageUriStrings)) { - is BaseResult.Error -> hasImageError = true - is BaseResult.Success -> {} - } - } + private suspend fun submitEdit(text: String) { + val model = + UpdateFeedModel( + feedId = feedId, + feedContent = text, + feedTypeId = _selectedType.value?.exerciseTypeId, + ) + when (updateFeedUseCase(model)) { + is BaseResult.Success -> { + var hasImageError = false - if (hasImageError) { - _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error)) - } - _updateSuccess.value = true + deletedImageIds.forEach { imageId -> + when (deleteFeedImageUseCase(feedId, imageId)) { + is BaseResult.Error -> hasImageError = true + is BaseResult.Success -> {} } - is BaseResult.Error -> { - _toastEvent.emit(UiText.StringResource(R.string.feed_form_update_error)) + } + + val newImages = _selectedImages.value + if (newImages.isNotEmpty()) { + val imageUriStrings = newImages.map { it.toString() } + when (uploadFeedImagesUseCase(feedId, imageUriStrings)) { + is BaseResult.Error -> hasImageError = true + is BaseResult.Success -> {} } } - } else { - val model = - CreateFeedModel( - feedContent = content, - feedTypeId = _selectedType.value?.exerciseTypeId, - ) - when (val result = createFeedUseCase(model)) { - is BaseResult.Success -> { - val createdFeedId = result.data.feedId - val images = _selectedImages.value ?: emptyList() - if (images.isNotEmpty()) { - val imageUriStrings = images.map { it.toString() } - when (uploadFeedImagesUseCase(createdFeedId, imageUriStrings)) { - is BaseResult.Success -> { - _createSuccess.value = createdFeedId - } - is BaseResult.Error -> { - _createSuccess.value = createdFeedId - _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error)) - } - } - } else { - _createSuccess.value = createdFeedId + + if (hasImageError) { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error)) + } + _updateSuccess.emit(Unit) + } + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_update_error)) + } + } + } + + private suspend fun submitCreate(text: String) { + val model = + CreateFeedModel( + feedContent = text, + feedTypeId = _selectedType.value?.exerciseTypeId, + ) + when (val result = createFeedUseCase(model)) { + is BaseResult.Success -> { + val createdFeedId = result.data.feedId + val images = _selectedImages.value + if (images.isNotEmpty()) { + val imageUriStrings = images.map { it.toString() } + when (uploadFeedImagesUseCase(createdFeedId, imageUriStrings)) { + is BaseResult.Success -> _createSuccess.emit(createdFeedId) + is BaseResult.Error -> { + _createSuccess.emit(createdFeedId) + _toastEvent.emit(UiText.StringResource(R.string.feed_form_image_upload_error)) } } - is BaseResult.Error -> { - _toastEvent.emit(UiText.StringResource(R.string.feed_form_create_error)) - } + } else { + _createSuccess.emit(createdFeedId) } } - _isLoading.value = false + is BaseResult.Error -> { + _toastEvent.emit(UiText.StringResource(R.string.feed_form_create_error)) + } } } } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt deleted file mode 100644 index 07d6c347..00000000 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListAdapter.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.project200.undabang.feature.feed.list - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.project200.domain.model.Feed -import com.project200.presentation.utils.RelativeTimeUtil -import com.project200.undabang.feature.feed.R -import com.project200.undabang.feature.feed.databinding.ItemFeedBinding - -class FeedListAdapter( - private val onItemClick: (Feed) -> Unit, -) : RecyclerView.Adapter() { - private val items = mutableListOf() - private var currentMemberId: String? = null - - fun submitList(newItems: List) { - items.clear() - items.addAll(newItems) - notifyDataSetChanged() - } - - fun setCurrentMemberId(memberId: String) { - currentMemberId = memberId - notifyDataSetChanged() - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): FeedViewHolder { - val binding = - ItemFeedBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return FeedViewHolder(binding) - } - - override fun onBindViewHolder( - holder: FeedViewHolder, - position: Int, - ) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int = items.size - - inner class FeedViewHolder(private val binding: ItemFeedBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(feed: Feed) { - with(binding) { - nicknameTv.text = feed.nickname - timeTv.text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt) - - val hasType = !feed.feedTypeName.isNullOrBlank() - arrowIv.visibility = if (hasType) View.VISIBLE else View.GONE - feedTypeTv.visibility = if (hasType) View.VISIBLE else View.GONE - if (hasType) { - feedTypeTv.text = feed.feedTypeName - } - - Glide.with(root.context) - .load(feed.profileUrl) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .circleCrop() - .into(profileIv) - - contentTv.text = feed.feedContent - - if (feed.feedPictures.isNotEmpty()) { - imagesRv.visibility = View.VISIBLE - - val imageAdapter = ImageRVAdapter(feed.feedPictures) - imagesRv.adapter = imageAdapter - } else { - imagesRv.visibility = View.GONE - } - - likeIv.setImageResource( - if (feed.feedIsLiked) { - R.drawable.ic_like_fill - } else { - R.drawable.ic_like - }, - ) - - likeCountTv.text = feed.feedLikesCount.toString() - commentCountTv.text = feed.feedCommentsCount.toString() - - root.setOnClickListener { onItemClick(feed) } - } - } - } -} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt index 08c09851..9679669f 100644 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListFragment.kt @@ -1,38 +1,87 @@ package com.project200.undabang.feature.feed.list import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.project200.domain.model.ExerciseType -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.collectToast -import com.project200.presentation.view.SelectionBottomSheetDialog -import com.project200.undabang.feature.feed.R -import com.project200.undabang.feature.feed.databinding.FragmentFeedListBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint -class FeedListFragment : BindingFragment(R.layout.fragment_feed_list) { +class FeedListFragment : Fragment() { private val viewModel: FeedListViewModel by viewModels() - private lateinit var feedAdapter: FeedListAdapter - override fun getViewBinding(view: View): FragmentFeedListBinding { - return FragmentFeedListBinding.bind(view) - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val feeds by viewModel.feedList.collectAsStateWithLifecycle() + val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val isEmpty by viewModel.isEmpty.collectAsStateWithLifecycle() + + FeedListScreen( + feeds = feeds, + selectedTypeName = selectedType?.name, + isLoading = isLoading, + isEmpty = isEmpty, + onFeedClick = { feedId -> + findNavController().navigate( + FeedListFragmentDirections.actionFeedListFragmentToFeedDetailFragment(feedId), + ) + }, + onAddClick = { + findNavController().navigate( + FeedListFragmentDirections.actionFeedListFragmentToFeedFormFragment(), + ) + }, + onCategoryClick = viewModel::requestShowCategoryBottomSheet, + onClearCategory = viewModel::clearType, + onLoadMore = { + if (viewModel.canLoadMore()) { + viewModel.loadFeeds() + } + }, + ) + } + } override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - initAdapter() - initToolbar() - initView() - initObserver() observeRefreshSignal() + collectToast(viewModel.toastEvent) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.showCategoryBottomSheet.collect { types -> + UndabangBottomSheet.showSelection( + fragmentManager = parentFragmentManager, + items = types.map { it.name }, + ) { selectedName -> + val selected = types.find { it.name == selectedName } + viewModel.selectType(selected) + } + } + } + } } private fun observeRefreshSignal() { @@ -45,128 +94,6 @@ class FeedListFragment : BindingFragment(R.layout.fragm } } - private fun initAdapter() { - feedAdapter = - FeedListAdapter( - onItemClick = { feed -> - findNavController().navigate( - FeedListFragmentDirections.actionFeedListFragmentToFeedDetailFragment(feed.feedId), - ) - }, - ) - } - - private fun initToolbar() { - binding.baseToolbar.apply { - setTitle(getString(R.string.feed_title)) - showBackButton(false) - setSubButton(R.drawable.ic_feed_add) { - findNavController().navigate( - FeedListFragmentDirections.actionFeedListFragmentToFeedFormFragment(), - ) - } - setSecondarySubButton(R.drawable.ic_category) { - showCategoryBottomSheet() - } - } - } - - private fun showCategoryBottomSheet() { - viewModel.requestShowCategoryBottomSheet() - } - - private fun displayCategoryBottomSheet(exerciseTypes: List) { - val names = exerciseTypes.map { it.name } - SelectionBottomSheetDialog(names) { selectedName -> - val selectedType = exerciseTypes.find { it.name == selectedName } - viewModel.selectType(selectedType) - }.show(parentFragmentManager, SelectionBottomSheetDialog::class.java.name) - viewModel.onCategoryBottomSheetShown() - } - - private fun initView() { - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.loadFeeds(isRefresh = true) - } - - binding.feedListRv.apply { - adapter = feedAdapter - layoutManager = LinearLayoutManager(context) - addOnScrollListener( - object : RecyclerView.OnScrollListener() { - override fun onScrolled( - recyclerView: RecyclerView, - dx: Int, - dy: Int, - ) { - super.onScrolled(recyclerView, dx, dy) - - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - val totalItemCount = layoutManager.itemCount - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - - if (viewModel.canLoadMore() && totalItemCount <= (lastVisibleItem + 5)) { - viewModel.loadFeeds() - } - } - }, - ) - } - } - - private fun initObserver() { - viewModel.feedList.observe(viewLifecycleOwner) { feeds -> - feedAdapter.submitList(feeds) - } - - viewModel.showEmptyView.observe(viewLifecycleOwner) { showEmpty -> - binding.emptyTv.visibility = if (showEmpty) View.VISIBLE else View.GONE - } - - viewModel.showCategoryBottomSheet.observe(viewLifecycleOwner) { exerciseTypes -> - if (!exerciseTypes.isNullOrEmpty()) { - displayCategoryBottomSheet(exerciseTypes) - } - } - - viewModel.selectedType.observe(viewLifecycleOwner) { type -> - binding.baseToolbar.apply { - if (type != null) { - setTitle(type.name) - setSecondarySubButton(null) - showBackButton(true) { viewModel.clearType() } - } else { - setTitle(getString(R.string.feed_title)) - setSecondarySubButton(R.drawable.ic_category) { - showCategoryBottomSheet() - } - showBackButton(false) - } - } - } - - viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> - val isInitialLoading = isLoading && feedAdapter.itemCount == 0 - if (isInitialLoading) { - binding.shimmerLayout.visibility = View.VISIBLE - binding.shimmerLayout.startShimmer() - binding.swipeRefreshLayout.visibility = View.GONE - binding.emptyTv.visibility = View.GONE - } else { - binding.shimmerLayout.stopShimmer() - binding.shimmerLayout.visibility = View.GONE - binding.swipeRefreshLayout.visibility = View.VISIBLE - binding.swipeRefreshLayout.isRefreshing = false - } - } - - collectToast(viewModel.toastEvent) - - viewModel.currentMemberId.observe(viewLifecycleOwner) { memberId -> - feedAdapter.setCurrentMemberId(memberId) - } - } - companion object { const val REFRESH_KEY = "feed_list_refresh" } diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListScreen.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListScreen.kt new file mode 100644 index 00000000..33a6075b --- /dev/null +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListScreen.kt @@ -0,0 +1,320 @@ +package com.project200.undabang.feature.feed.list + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.project200.domain.model.Feed +import com.project200.domain.model.FeedPicture +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.subtext12 +import com.project200.presentation.compose.theme.subtext14 +import com.project200.presentation.utils.RelativeTimeUtil +import com.project200.undabang.feature.feed.R +import kotlinx.coroutines.flow.distinctUntilChanged +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun FeedListScreen( + feeds: List, + selectedTypeName: String?, + isLoading: Boolean, + isEmpty: Boolean, + onFeedClick: (Long) -> Unit, + onAddClick: () -> Unit, + onCategoryClick: () -> Unit, + onClearCategory: () -> Unit, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + val lazyListState = rememberLazyListState() + + // 스크롤 위치를 감지해서 마지막 보이는 아이템이 끝에서 5개 이내에 들어오면 추가 페이지 요청 + // 기존 addOnScrollListener 패턴을 LazyListState + snapshotFlow로 대체 + LaunchedEffect(lazyListState, feeds.size) { + snapshotFlow { + val layoutInfo = lazyListState.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + total to lastVisible + } + .distinctUntilChanged() + .collect { (total, lastVisible) -> + if (total > 0 && total <= lastVisible + 5) { + onLoadMore() + } + } + } + + UndabangScaffold( + modifier = modifier, + topBar = { + // 카테고리 선택 모드일 때는 뒤로가기 + 카테고리명을 제목으로, 일반 모드일 때는 카테고리/추가 액션을 노출 + UndabangTopBar( + title = + selectedTypeName + ?: stringResource(R.string.feed_title), + navigationIconVisible = selectedTypeName != null, + onNavigationClick = onClearCategory, + actions = { + if (selectedTypeName == null) { + IconButton(onClick = onCategoryClick) { + Icon( + painter = painterResource(R.drawable.ic_category), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + IconButton(onClick = onAddClick) { + Icon( + painter = painterResource(R.drawable.ic_feed_add), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // 첫 페이지 로딩 중에는 인디케이터만, 페이지 더 불러올 때는 기존 리스트 유지 + val showInitialLoading = isLoading && feeds.isEmpty() + + when { + showInitialLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + isEmpty -> { + Text( + text = stringResource(R.string.feed_empty_message), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + modifier = Modifier.align(Alignment.Center), + ) + } + else -> { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + items(feeds, key = { it.feedId }) { feed -> + FeedCard( + feed = feed, + onClick = { onFeedClick(feed.feedId) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun FeedCard( + feed: Feed, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(bottom = 24.dp), + ) { + Row( + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = feed.profileUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(PresentationR.drawable.ic_profile_default), + error = painterResource(PresentationR.drawable.ic_profile_default), + fallback = painterResource(PresentationR.drawable.ic_profile_default), + modifier = + Modifier + .size(40.dp) + .clip(CircleShape), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = feed.nickname, + style = MaterialTheme.typography.contentBold, + fontSize = 14.sp, + color = ColorBlack, + ) + if (!feed.feedTypeName.isNullOrBlank()) { + Spacer(Modifier.width(6.dp)) + Icon( + painter = painterResource(PresentationR.drawable.ic_arrow_right), + contentDescription = null, + tint = ColorGray200, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = feed.feedTypeName.orEmpty(), + style = MaterialTheme.typography.contentBold, + fontSize = 14.sp, + color = ColorBlack, + ) + } + Spacer(Modifier.width(6.dp)) + Text( + text = RelativeTimeUtil.getRelativeTime(feed.feedCreatedAt), + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + ) + } + + Spacer(Modifier.height(12.dp)) + + Text( + text = feed.feedContent.orEmpty(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + maxLines = 6, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp).padding(start = 52.dp - 16.dp), + ) + + if (feed.feedPictures.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = 52.dp, end = 16.dp), + ) { + items(feed.feedPictures, key = { it.feedPictureId }) { picture -> + FeedImage(picture = picture) + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.padding(start = 52.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_comment), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.padding(4.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = feed.feedCommentsCount.toString(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + ) + Spacer(Modifier.width(12.dp)) + Icon( + painter = + painterResource( + if (feed.feedIsLiked) R.drawable.ic_like_fill else R.drawable.ic_like, + ), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.padding(4.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = feed.feedLikesCount.toString(), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + ) + } + } +} + +@Composable +private fun FeedImage( + picture: FeedPicture, + modifier: Modifier = Modifier, +) { + AsyncImage( + model = picture.feedPictureUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.ic_feed_image_placeholder), + error = painterResource(R.drawable.ic_feed_image_placeholder), + modifier = + modifier + .padding(end = 8.dp) + .height(120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(ColorWhite300), + ) +} + +@Preview(showBackground = true, heightDp = 800) +@Composable +private fun FeedListScreenPreview() { + AppTheme { + FeedListScreen( + feeds = emptyList(), + selectedTypeName = null, + isLoading = false, + isEmpty = true, + onFeedClick = {}, + onAddClick = {}, + onCategoryClick = {}, + onClearCategory = {}, + onLoadMore = {}, + ) + } +} diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt index ab7be171..c23b70b3 100644 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt +++ b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/FeedListViewModel.kt @@ -1,7 +1,5 @@ package com.project200.undabang.feature.feed.list -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -16,8 +14,11 @@ import com.project200.presentation.utils.UiText import com.project200.undabang.feature.feed.R import dagger.hilt.android.lifecycle.HiltViewModel 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.launch import javax.inject.Inject @@ -31,33 +32,30 @@ class FeedListViewModel private val getMemberIdUseCase: GetMemberIdUseCase, private val deleteFeedUseCase: DeleteFeedUseCase, ) : ViewModel() { - private val _feedList = MutableLiveData>() - val feedList: LiveData> get() = _feedList + private val _feedList = MutableStateFlow>(emptyList()) + val feedList: StateFlow> = _feedList.asStateFlow() - private val _selectedType = MutableLiveData(null) - val selectedType: LiveData = _selectedType + private val _selectedType = MutableStateFlow(null) + val selectedType: StateFlow = _selectedType.asStateFlow() - private val _isLoading = MutableLiveData(false) - val isLoading: LiveData get() = _isLoading + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() private val _toastEvent = MutableSharedFlow() val toastEvent: SharedFlow = _toastEvent.asSharedFlow() - private val _isEmpty = MutableLiveData(false) - val isEmpty: LiveData get() = _isEmpty + private val _isEmpty = MutableStateFlow(false) + val isEmpty: StateFlow = _isEmpty.asStateFlow() - private val _exerciseTypeList = MutableLiveData>() - val exerciseTypeList: LiveData> = _exerciseTypeList + private val exerciseTypeListFlow = MutableStateFlow>(emptyList()) - private val _currentMemberId = MutableLiveData() - val currentMemberId: LiveData get() = _currentMemberId + private val _currentMemberId = MutableStateFlow(null) + val currentMemberId: StateFlow = _currentMemberId.asStateFlow() - private val _showEmptyView = MutableLiveData(false) - val showEmptyView: LiveData get() = _showEmptyView - - private val _showCategoryBottomSheet = MutableLiveData?>() - val showCategoryBottomSheet: LiveData?> get() = _showCategoryBottomSheet + private val _showCategoryBottomSheet = MutableSharedFlow>() + val showCategoryBottomSheet: SharedFlow> = _showCategoryBottomSheet.asSharedFlow() + // 페이징 상태. allFeeds는 원본, _feedList는 카테고리 필터링 결과 private var hasNext: Boolean = true private var lastFeedId: Long? = null private val allFeeds = mutableListOf() @@ -88,27 +86,22 @@ class FeedListViewModel updateFilteredList() } + // 카테고리 필터링 중에는 페이징을 멈춤 (현재 정책: 필터링은 로컬 데이터만 대상) fun canLoadMore(): Boolean { - return _selectedType.value == null && _isLoading.value != true && hasNext + return _selectedType.value == null && !_isLoading.value && hasNext } fun requestShowCategoryBottomSheet() { - val items = _exerciseTypeList.value - if (items.isNullOrEmpty()) { + val items = exerciseTypeListFlow.value + if (items.isEmpty()) { loadExerciseTypes() } else { - _showCategoryBottomSheet.value = items + viewModelScope.launch { + _showCategoryBottomSheet.emit(items) + } } } - fun onCategoryBottomSheetShown() { - _showCategoryBottomSheet.value = null - } - - private fun updateShowEmptyView() { - _showEmptyView.value = _isEmpty.value == true && _isLoading.value != true - } - private fun updateFilteredList() { val selectedTypeId = _selectedType.value?.id if (selectedTypeId == null) { @@ -119,7 +112,7 @@ class FeedListViewModel } fun loadExerciseTypes() { - if (!_exerciseTypeList.value.isNullOrEmpty()) return + if (exerciseTypeListFlow.value.isNotEmpty()) return viewModelScope.launch { val preferredResult = getPreferredExerciseUseCase() val preferredTypes = @@ -137,6 +130,7 @@ class FeedListViewModel emptyList() } + // 선호 운동을 앞에 두고, 그 외 전체 운동을 뒤에 붙임 (중복 제거) val preferredIds = preferredTypes.map { it.id }.toSet() val combinedList = mutableListOf().apply { @@ -144,7 +138,7 @@ class FeedListViewModel addAll(allTypes.filterNot { preferredIds.contains(it.id) }) } - _exerciseTypeList.value = combinedList + exerciseTypeListFlow.value = combinedList } } @@ -155,7 +149,7 @@ class FeedListViewModel allFeeds.clear() } - if (!hasNext || (_isLoading.value == true && !isRefresh)) return + if (!hasNext || (_isLoading.value && !isRefresh)) return _isLoading.value = true @@ -171,19 +165,16 @@ class FeedListViewModel } updateFilteredList() _isEmpty.value = allFeeds.isEmpty() - updateShowEmptyView() } is BaseResult.Error -> { _toastEvent.emit(UiText.StringResource(R.string.unknown_error)) if (allFeeds.isEmpty()) { _isEmpty.value = true _feedList.value = emptyList() - updateShowEmptyView() } } } _isLoading.value = false - updateShowEmptyView() } } @@ -194,7 +185,6 @@ class FeedListViewModel allFeeds.removeAll { it.feedId == feedId } updateFilteredList() _isEmpty.value = allFeeds.isEmpty() - updateShowEmptyView() _toastEvent.emit(UiText.StringResource(R.string.feed_deleted)) } is BaseResult.Error -> { diff --git a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt b/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt deleted file mode 100644 index 99507e97..00000000 --- a/feature/feed/src/main/java/com/project200/undabang/feature/feed/list/ImageRVAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.project200.undabang.feature.feed.list - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.project200.domain.model.FeedPicture -import com.project200.presentation.utils.UiUtils.dpToPx -import com.project200.undabang.feature.feed.R -import com.project200.undabang.feature.feed.databinding.ItemFeedImageBinding - -class ImageRVAdapter(private val pictures: List) : - RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ImageViewHolder { - val binding = - ItemFeedImageBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return ImageViewHolder(binding) - } - - override fun onBindViewHolder( - holder: ImageViewHolder, - position: Int, - ) { - holder.bind(pictures[position]) - } - - override fun getItemCount(): Int = pictures.size - - inner class ImageViewHolder(private val binding: ItemFeedImageBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(picture: FeedPicture) { - Glide.with(binding.feedImageIv.context) - .load(picture.feedPictureUrl) - .placeholder(R.drawable.ic_feed_image_placeholder) - .transform(RoundedCorners(dpToPx(binding.root.context, 12f))) - .error(R.drawable.ic_feed_image_placeholder) - .into(binding.feedImageIv) - } - } -} diff --git a/feature/feed/src/main/res/layout/fragment_feed_detail.xml b/feature/feed/src/main/res/layout/fragment_feed_detail.xml deleted file mode 100644 index 700ad015..00000000 --- a/feature/feed/src/main/res/layout/fragment_feed_detail.xml +++ /dev/null @@ -1,255 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/fragment_feed_form.xml b/feature/feed/src/main/res/layout/fragment_feed_form.xml deleted file mode 100644 index 4fd4e8ef..00000000 --- a/feature/feed/src/main/res/layout/fragment_feed_form.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/fragment_feed_list.xml b/feature/feed/src/main/res/layout/fragment_feed_list.xml deleted file mode 100644 index 1d9652c1..00000000 --- a/feature/feed/src/main/res/layout/fragment_feed_list.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/item_comment.xml b/feature/feed/src/main/res/layout/item_comment.xml deleted file mode 100644 index 7ec01c7a..00000000 --- a/feature/feed/src/main/res/layout/item_comment.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/item_feed.xml b/feature/feed/src/main/res/layout/item_feed.xml deleted file mode 100644 index d9c08e24..00000000 --- a/feature/feed/src/main/res/layout/item_feed.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/item_feed_form_image.xml b/feature/feed/src/main/res/layout/item_feed_form_image.xml deleted file mode 100644 index 045c7b53..00000000 --- a/feature/feed/src/main/res/layout/item_feed_form_image.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/feature/feed/src/main/res/layout/item_feed_image.xml b/feature/feed/src/main/res/layout/item_feed_image.xml deleted file mode 100644 index 42f74c73..00000000 --- a/feature/feed/src/main/res/layout/item_feed_image.xml +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/feature/feed/src/main/res/layout/item_feed_skeleton.xml b/feature/feed/src/main/res/layout/item_feed_skeleton.xml deleted file mode 100644 index f2099edf..00000000 --- a/feature/feed/src/main/res/layout/item_feed_skeleton.xml +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/item_reply.xml b/feature/feed/src/main/res/layout/item_reply.xml deleted file mode 100644 index 89a31aca..00000000 --- a/feature/feed/src/main/res/layout/item_reply.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/layout_comment_input.xml b/feature/feed/src/main/res/layout/layout_comment_input.xml deleted file mode 100644 index bfd15a29..00000000 --- a/feature/feed/src/main/res/layout/layout_comment_input.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml b/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml deleted file mode 100644 index faf41bc4..00000000 --- a/feature/feed/src/main/res/layout/layout_feed_list_skeleton.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - diff --git a/feature/feed/src/main/res/navigation/feed_nav_graph.xml b/feature/feed/src/main/res/navigation/feed_nav_graph.xml index 921464ee..d7142552 100644 --- a/feature/feed/src/main/res/navigation/feed_nav_graph.xml +++ b/feature/feed/src/main/res/navigation/feed_nav_graph.xml @@ -9,7 +9,7 @@ android:id="@+id/feedListFragment" android:name="com.project200.undabang.feature.feed.list.FeedListFragment" android:label="FeedListFragment" - tools:layout="@layout/fragment_feed_list" > + > @@ -21,7 +21,7 @@ android:id="@+id/feedFormFragment" android:name="com.project200.undabang.feature.feed.form.FeedFormFragment" android:label="FeedFormFragment" - tools:layout="@layout/fragment_feed_form" > + > + > diff --git a/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedFormViewModelTest.kt b/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedFormViewModelTest.kt index 43bca74e..a6513899 100644 --- a/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedFormViewModelTest.kt +++ b/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedFormViewModelTest.kt @@ -1,7 +1,6 @@ package com.project200.undabang.feature.feed import android.net.Uri -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult @@ -42,9 +41,6 @@ class FeedFormViewModelTest { @get:Rule val mockkRule = MockKRule(this) - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @MockK private lateinit var getUserProfileUseCase: GetUserProfileUseCase @@ -184,7 +180,7 @@ class FeedFormViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.initialContentForEdit.value).isEqualTo("기존 내용") + assertThat(viewModel.content.value).isEqualTo("기존 내용") } @Test @@ -254,7 +250,7 @@ class FeedFormViewModelTest { runTest { // When & Then viewModel.toastEvent.test { - viewModel.submitFeed("") + viewModel.submitFeed() testDispatcher.scheduler.advanceUntilIdle() assertThat(awaitItem()).isNotNull() } @@ -270,13 +266,15 @@ class FeedFormViewModelTest { coEvery { createFeedUseCase(any()) } returns BaseResult.Success(FeedCreateResult(feedId = 1L)) viewModel.initData() testDispatcher.scheduler.advanceUntilIdle() + viewModel.updateContent("테스트 내용") - // When - viewModel.submitFeed("테스트 내용") - testDispatcher.scheduler.advanceUntilIdle() - - // Then - assertThat(viewModel.createSuccess.value).isEqualTo(1L) + // When & Then + viewModel.createSuccess.test { + viewModel.submitFeed() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isEqualTo(1L) + cancelAndIgnoreRemainingEvents() + } coVerify { createFeedUseCase(any()) } } @@ -295,9 +293,10 @@ class FeedFormViewModelTest { viewModel.initData() testDispatcher.scheduler.advanceUntilIdle() viewModel.addImages(listOf(mockUri)) + viewModel.updateContent("테스트 내용") // When - viewModel.submitFeed("테스트 내용") + viewModel.submitFeed() testDispatcher.scheduler.advanceUntilIdle() // Then @@ -315,13 +314,15 @@ class FeedFormViewModelTest { coEvery { updateFeedUseCase(any()) } returns BaseResult.Success(Unit) viewModel.initData(feedId = 1L) testDispatcher.scheduler.advanceUntilIdle() + viewModel.updateContent("수정된 내용") - // When - viewModel.submitFeed("수정된 내용") - testDispatcher.scheduler.advanceUntilIdle() - - // Then - assertThat(viewModel.updateSuccess.value).isTrue() + // When & Then + viewModel.updateSuccess.test { + viewModel.submitFeed() + testDispatcher.scheduler.advanceUntilIdle() + awaitItem() + cancelAndIgnoreRemainingEvents() + } coVerify { updateFeedUseCase(any()) } } @@ -335,10 +336,11 @@ class FeedFormViewModelTest { coEvery { createFeedUseCase(any()) } returns BaseResult.Error("ERROR", "Failed") viewModel.initData() testDispatcher.scheduler.advanceUntilIdle() + viewModel.updateContent("테스트 내용") // When & Then viewModel.toastEvent.test { - viewModel.submitFeed("테스트 내용") + viewModel.submitFeed() testDispatcher.scheduler.advanceUntilIdle() assertThat(awaitItem()).isNotNull() } @@ -354,10 +356,12 @@ class FeedFormViewModelTest { viewModel.initData() testDispatcher.scheduler.advanceUntilIdle() - // When - viewModel.requestShowDabangSelection() - - // Then - assertThat(viewModel.showDabangSelection.value).isNotNull() + // When & Then + viewModel.showDabangSelection.test { + viewModel.requestShowDabangSelection() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isNotEmpty() + cancelAndIgnoreRemainingEvents() + } } } diff --git a/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedListViewModelTest.kt b/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedListViewModelTest.kt index 9308856e..e145bc92 100644 --- a/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedListViewModelTest.kt +++ b/feature/feed/src/test/java/com/project200/undabang/feature/feed/FeedListViewModelTest.kt @@ -145,7 +145,6 @@ class FeedListViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - assertThat(viewModel.exerciseTypeList.value).isNotNull() coVerify { getPreferredExerciseUseCase() } coVerify { getPreferredExerciseTypesUseCase() } } @@ -341,25 +340,12 @@ class FeedListViewModelTest { viewModel = createViewModel() testDispatcher.scheduler.advanceUntilIdle() - // When - viewModel.requestShowCategoryBottomSheet() - - // Then - assertThat(viewModel.showCategoryBottomSheet.value).isNotNull() - } - - @Test - fun `onCategoryBottomSheetShown - 바텀시트 표시 후 상태를 초기화한다`() = - runTest { - // Given - viewModel = createViewModel() - testDispatcher.scheduler.advanceUntilIdle() - viewModel.requestShowCategoryBottomSheet() - - // When - viewModel.onCategoryBottomSheetShown() - - // Then - assertThat(viewModel.showCategoryBottomSheet.value).isNull() + // When & Then + viewModel.showCategoryBottomSheet.test { + viewModel.requestShowCategoryBottomSheet() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isNotEmpty() + cancelAndIgnoreRemainingEvents() + } } } diff --git a/feature/matching/build.gradle.kts b/feature/matching/build.gradle.kts index 5d4cdf44..b9650e8f 100644 --- a/feature/matching/build.gradle.kts +++ b/feature/matching/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("convention.android.library") id("convention.android.hilt") + id("convention.android.compose") alias(libs.plugins.navigation.safeargs) id("convention.android.jacoco") } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideFragment.kt index ca5c5796..a6824c58 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideFragment.kt @@ -1,39 +1,47 @@ package com.project200.feature.matching.map import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView import androidx.core.content.edit +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.project200.common.utils.DefaultPrefs -import com.project200.presentation.base.BindingFragment -import com.project200.undabang.feature.matching.R -import com.project200.undabang.feature.matching.databinding.FragmentMatchingGuideBinding +import com.project200.presentation.compose.applyAppTheme import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint -class MatchingGuideFragment : BindingFragment (R.layout.fragment_matching_guide) { +class MatchingGuideFragment : Fragment() { @Inject @DefaultPrefs lateinit var sharedPreferences: SharedPreferences - override fun getViewBinding(view: android.view.View): FragmentMatchingGuideBinding { - return FragmentMatchingGuideBinding.bind(view) - } - - override fun setupViews() { - binding.registerBtn.setOnClickListener { - sharedPreferences.edit { putBoolean(KEY_FIRST_MATCHING_VISIT, false) } - findNavController().navigate( - MatchingGuideFragmentDirections.actionMatchingGuideFragmentToExercisePlaceSearchFragment(), - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + MatchingGuideScreen( + onSkipClick = { + sharedPreferences.edit { putBoolean(KEY_FIRST_MATCHING_VISIT, false) } + findNavController().navigateUp() + }, + onStartClick = { + sharedPreferences.edit { putBoolean(KEY_FIRST_MATCHING_VISIT, false) } + findNavController().navigate( + MatchingGuideFragmentDirections.actionMatchingGuideFragmentToExercisePlaceSearchFragment(), + ) + }, + ) + } } - binding.skipBtn.setOnClickListener { - sharedPreferences.edit { putBoolean(KEY_FIRST_MATCHING_VISIT, false) } - findNavController().navigateUp() - } - } - companion object { const val KEY_FIRST_MATCHING_VISIT = "key_first_matching_visit" } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideScreen.kt new file mode 100644 index 00000000..95479fcb --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingGuideScreen.kt @@ -0,0 +1,225 @@ +package com.project200.feature.matching.map + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBackground +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray100 +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.contentRegular +import com.project200.presentation.compose.theme.header +import com.project200.presentation.compose.theme.mediumBold +import com.project200.undabang.feature.matching.R + +@Composable +fun MatchingGuideScreen( + onSkipClick: () -> Unit, + onStartClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { Toolbar(onSkipClick = onSkipClick) }, + bottomBar = { + PrimaryButton( + text = stringResource(R.string.guide_start), + onClick = onStartClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 40.dp), + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(10.dp)) + + Box( + modifier = + Modifier + .size(150.dp) + .align(Alignment.CenterHorizontally) + .clip(RoundedCornerShape(20.dp)) + .background(ColorBackground) + .padding(10.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_guide_character), + contentDescription = null, + ) + } + + Spacer(Modifier.height(20.dp)) + + Text( + text = stringResource(R.string.guide_title), + style = MaterialTheme.typography.header, + color = ColorBlack, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Spacer(Modifier.height(10.dp)) + + Text( + text = stringResource(R.string.guide_content), + style = MaterialTheme.typography.mediumBold, + color = ColorGray100, + textAlign = TextAlign.Center, + fontSize = 15.sp, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 20.dp), + ) + + Spacer(Modifier.height(30.dp)) + + GuideBeforeBox() + } + } +} + +@Composable +private fun Toolbar( + onSkipClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .height(60.dp), + ) { + Text( + text = stringResource(R.string.guide_toolbar), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.align(Alignment.Center), + ) + Text( + text = stringResource(R.string.guide_skip), + style = MaterialTheme.typography.contentRegular, + color = ColorGray200, + modifier = + Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onSkipClick, + ) + .padding(4.dp), + ) + } +} + +@Composable +private fun GuideBeforeBox(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(20.dp)) + .background(ColorBackground), + ) { + Spacer(Modifier.height(25.dp)) + Text( + text = stringResource(R.string.guide_before_title), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.padding(start = 20.dp), + ) + Spacer(Modifier.height(25.dp)) + GuideItem( + iconRes = R.drawable.ic_guide_1, + text = stringResource(R.string.guide_before_content_1), + ) + Spacer(Modifier.height(25.dp)) + GuideItem( + iconRes = R.drawable.ic_guide_2, + text = stringResource(R.string.guide_before_content_2), + ) + Spacer(Modifier.height(25.dp)) + GuideItem( + iconRes = R.drawable.ic_guide_3, + text = stringResource(R.string.guide_before_content_3), + ) + Spacer(Modifier.height(25.dp)) + } +} + +@Composable +private fun GuideItem( + @DrawableRes iconRes: Int, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(start = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + ) + Spacer(Modifier.width(14.dp)) + Text( + text = text, + style = MaterialTheme.typography.mediumBold, + color = ColorBlack, + fontSize = 14.sp, + ) + } +} + +@Preview(showBackground = true, heightDp = 900) +@Composable +private fun MatchingGuideScreenPreview() { + AppTheme { + MatchingGuideScreen( + onSkipClick = {}, + onStartClick = {}, + ) + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt index 643a3c00..f7c8eff9 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt @@ -7,7 +7,6 @@ import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat -import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -15,23 +14,17 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices -import com.kakao.vectormap.KakaoMap -import com.kakao.vectormap.KakaoMapReadyCallback import com.kakao.vectormap.LatLng -import com.kakao.vectormap.MapLifeCycleCallback -import com.kakao.vectormap.camera.CameraPosition -import com.kakao.vectormap.label.Label import com.project200.common.constants.RuleConstants.SEOUL_CITY_HALL_LATITUDE import com.project200.common.constants.RuleConstants.SEOUL_CITY_HALL_LONGITUDE import com.project200.common.constants.RuleConstants.ZOOM_LEVEL -import com.project200.domain.model.MapPosition -import com.project200.domain.model.MatchingMember -import com.project200.feature.matching.map.cluster.ClusterCalculator import com.project200.feature.matching.map.cluster.MapClusterItem +import com.project200.feature.matching.map.compose.MatchingMapController +import com.project200.feature.matching.map.compose.MatchingMapScreen import com.project200.feature.matching.map.filter.FilterBottomSheetDialog -import com.project200.feature.matching.map.filter.MatchingFilterRVAdapter import com.project200.feature.matching.utils.MatchingFilterType import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.matching.R import com.project200.undabang.feature.matching.databinding.FragmentMatchingMapBinding import dagger.hilt.android.AndroidEntryPoint @@ -41,35 +34,16 @@ import timber.log.Timber @AndroidEntryPoint class MatchingMapFragment : BindingFragment(R.layout.fragment_matching_map) { - // MapViewManager로 지도 관련 로직 위임 - private var mapViewManager: MapViewManager? = null - - private lateinit var fusedLocationClient: FusedLocationProviderClient private val viewModel: MatchingMapViewModel by viewModels() - private val filterAdapter by lazy { - MatchingFilterRVAdapter( - onFilterClick = { type -> - viewModel.onFilterTypeClicked(type) - }, - onClearClick = { - viewModel.clearFilters() - }, - ) - } - - private var isMapInitialized: Boolean = false - - // 클러스터링 계산을 위한 헬퍼 클래스 - private lateinit var clusterCalculator: ClusterCalculator + // 지도 본체(MatchingMapScreen) 내부 카메라를 제어하기 위한 통로 + private val mapController = MatchingMapController() + private var isMapInitialized = false + private lateinit var fusedLocationClient: FusedLocationProviderClient - // 위치 권한 요청을 위한 ActivityResultLauncher 정의 private val locationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) { - // 권한이 허용되면 현재 위치로 이동 - moveToCurrentLocation() - } + if (isGranted) moveToCurrentLocation() } override fun getViewBinding(view: View): FragmentMatchingMapBinding { @@ -77,95 +51,103 @@ class MatchingMapFragment : } override fun setupViews() { - // 클러스터 계산기 초기화 - clusterCalculator = ClusterCalculator() isMapInitialized = false fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireActivity()) - initMapView() - initListeners() - binding.matchingFilterRv.adapter = filterAdapter - filterAdapter.submitFilterList(MatchingFilterType.entries) + // 지도·툴바(필터)·현재위치·로딩까지 전체를 Compose(MatchingMapScreen)로 호스팅. + binding.mapComposeView.applyAppTheme { + MatchingMapScreen( + viewModel = viewModel, + controller = mapController, + onMapReady = { restoreInitialCamera() }, + onClusterClick = { items -> showMembersBottomSheet(items) }, + onPlaceMarkerClick = { navigateToExercisePlace() }, + onCurrentLocationClick = { checkPermissionAndMove() }, + onFilterClick = { type -> viewModel.onFilterTypeClicked(type) }, + onClearClick = { viewModel.clearFilters() }, + onExercisePlaceListClick = { navigateToExercisePlace() }, + ) + } } - private fun initMapView() { - binding.mapView.start( - object : MapLifeCycleCallback() { - override fun onMapDestroy() {} - - override fun onMapError(error: Exception) { - Timber.d("$error") - } - }, - object : KakaoMapReadyCallback() { - override fun onMapReady(map: KakaoMap) { - // MapViewManager를 생성하여 지도 로직을 위임 - mapViewManager = - MapViewManager( - context = requireContext(), - kakaoMap = map, - onCameraIdle = { cameraPosition -> handleCamera(cameraPosition) }, - onLabelClick = { label -> handleLabelClick(label) }, - ) - - setupMapRelatedObservers() - } - }, + private fun navigateToExercisePlace() { + findNavController().navigate( + MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment(), ) } - private fun initListeners() { - binding.currentLocationBtn.setOnClickListener { - checkPermissionAndMove() - } + /** + * 지도가 준비되면 1회 호출되어 초기 카메라 위치를 복원한다. + * 저장된 위치가 있으면 그곳으로, 없으면 현재 위치(권한 시) 또는 기본 위치(서울시청)로 이동한다. + */ + private fun restoreInitialCamera() { + if (isMapInitialized) return - binding.exercisePlaceListBtn.setOnClickListener { - findNavController().navigate( - MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment(), + val savedPosition = viewModel.initialMapPosition.value + if (isLocationPermissionGranted()) { + if (savedPosition != null) { + mapController.moveCamera( + LatLng.from(savedPosition.latitude, savedPosition.longitude), + savedPosition.zoomLevel, + ) + } else { + moveToCurrentLocation() + } + } else { + mapController.moveCamera( + LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE), + ZOOM_LEVEL, ) + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } + + isMapInitialized = true } - /** - * MapViewManager로부터 카메라 이동 완료 이벤트를 받아 처리합니다. - */ - private fun handleCamera(cameraPosition: CameraPosition) { - // 마지막 위치 저장 - viewModel.saveLastLocation( - cameraPosition.position.latitude, - cameraPosition.position.longitude, - cameraPosition.zoomLevel, - ) + private fun checkPermissionAndMove() { + if (isLocationPermissionGranted()) { + moveToCurrentLocation() + } else { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + } - // 현재 화면 영역(Bounds) 조회 - val currentBounds = mapViewManager?.getCurrentBounds() ?: return + @SuppressLint("MissingPermission") // 권한은 isLocationPermissionGranted()로 이미 확인됨 + private fun moveToCurrentLocation() { + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> + if (location != null) { + mapController.moveCamera( + LatLng.from(location.latitude, location.longitude), + ZOOM_LEVEL, + ) + } else { + fallbackToDefaultLocation(cause = null) + } + } + .addOnFailureListener { e -> + fallbackToDefaultLocation(cause = e) + } + } - // 화면 이동에 따라 매칭 멤버 데이터 갱신 - viewModel.fetchMatchingMembersIfMoved( - currentBounds = currentBounds, - currentCenter = cameraPosition.position, - currentZoom = cameraPosition.zoomLevel, + private fun fallbackToDefaultLocation(cause: Throwable?) { + cause?.let { Timber.e(it, "getLastLocation failed") } + Toast.makeText( + requireContext(), + R.string.error_cannot_find_current_location, + Toast.LENGTH_SHORT, + ).show() + mapController.moveCamera( + LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE), + ZOOM_LEVEL, ) } - /** - * MapViewManager로부터 라벨 클릭 이벤트를 받아 처리합니다. - */ - private fun handleLabelClick(label: Label) { - val cameraPosition = mapViewManager?.getCurrentCameraPosition() ?: return - - // 클릭한 라벨이 클러스터인지 확인 - clusterCalculator.getClusters(cameraPosition).find { cluster -> - cluster.position.latitude == label.position.latitude && - cluster.position.longitude == label.position.longitude - }?.let { foundCluster -> - // 클러스터에 있는 운동 장소 리스트 표시 - showMembersBottomSheet(foundCluster.items.toList()) - return - } - - // 클러스터를 찾지 못한 경우 == 내 장소 마커를 클릭한 경우 - findNavController().navigate(MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment()) + private fun isLocationPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED } override fun setupObservers() { @@ -188,21 +170,11 @@ class MatchingMapFragment : Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } } - launch { - viewModel.filterState.collect { state -> - filterAdapter.submitFilterState(state) - } - } launch { viewModel.currentFilterType.collect { type -> showFilterBottomSheet(type) } } - launch { - viewModel.isFilterLoading.collect { isLoading -> - binding.filterLoadingGroup.isVisible = isLoading - } - } launch { viewModel.zoomLevelWarning.collect { Toast.makeText( @@ -216,74 +188,6 @@ class MatchingMapFragment : } } - /** - * 지도와 직접적으로 관련된 옵저버들을 설정합니다. - */ - private fun setupMapRelatedObservers() { - // 초기 지도 위치 관찰 - viewModel.initialMapPosition.observe(viewLifecycleOwner) { savedPosition -> - // 지도의 초기 위치 설정이 아직 완료되지 않았을 때만 카메라를 이동시킵니다. - if (isMapInitialized) return@observe - - if (isLocationPermissionGranted()) { // 권한이 있는 경우 - if (savedPosition != null) { - mapViewManager?.moveCamera( - LatLng.from(savedPosition.latitude, savedPosition.longitude), - savedPosition.zoomLevel, - ) - } else { - // 저장된 위치가 없으면, 현재 위치로 이동 - moveToCurrentLocation() - } - } else { - // 위치 권한이 없는 경우 - // 기본위치(서울시청)로 이동 - val defaultPosition = - MapPosition( - latitude = SEOUL_CITY_HALL_LATITUDE, - longitude = SEOUL_CITY_HALL_LONGITUDE, - zoomLevel = ZOOM_LEVEL, - ) - - mapViewManager?.moveCamera( - LatLng.from(defaultPosition.latitude, defaultPosition.longitude), - defaultPosition.zoomLevel, - ) - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - - isMapInitialized = true - } - - // 회원 및 장소 데이터 통합 관찰 - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.combinedMapData.collect { (members, places) -> - // 클러스터 계산기에 최신 회원 데이터 업데이트 - updateClusterData(members) - - // 데이터가 변경되었으므로 마커 redraw - mapViewManager?.redrawMarkers(places, clusterCalculator) - } - } - } - } - - /** - * ViewModel의 최신 데이터를 ClusterCalculator에 업데이트합니다. - */ - private fun updateClusterData(members: List) { - val clusterItems = mutableListOf() - members.forEach { member -> - member.locations.forEach { location -> - clusterItems.add(MapClusterItem(member, location)) - } - } - - clusterCalculator.clearItems() - clusterCalculator.addItems(clusterItems) - } - private fun showPlaceGuideDialog() { val dialog = MatchingPlaceGuideDialog( @@ -293,7 +197,7 @@ class MatchingMapFragment : ) }, ) - dialog.isCancelable = false // 바깥 영역 터치 시 다이얼로그가 닫히지 않도록 설정 + dialog.isCancelable = false dialog.show(parentFragmentManager, this::class.java.simpleName) } @@ -321,50 +225,8 @@ class MatchingMapFragment : bottomSheet.show(childFragmentManager, FilterBottomSheetDialog::class.java.simpleName) } - private fun checkPermissionAndMove() { - if (isLocationPermissionGranted()) { - moveToCurrentLocation() - } else { - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - - /** - * 현재 위치로 카메라를 이동시키는 함수 - */ - @SuppressLint("MissingPermission") // 권한 체크는 isLocationPermissionGranted()로 이미 수행됨 - private fun moveToCurrentLocation() { - fusedLocationClient.lastLocation.addOnSuccessListener { location -> - val latLng: LatLng - if (location != null) { - latLng = LatLng.from(location.latitude, location.longitude) - } else { - latLng = LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE) - Toast.makeText( - requireContext(), - R.string.error_cannot_find_current_location, - Toast.LENGTH_SHORT, - ).show() - } - mapViewManager?.moveCamera(latLng, ZOOM_LEVEL) - } - } - - private fun isLocationPermissionGranted(): Boolean { - return ContextCompat.checkSelfPermission( - requireContext(), - Manifest.permission.ACCESS_FINE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - } - override fun onResume() { super.onResume() - binding.mapView.resume() viewModel.refreshExercisePlaces() } - - override fun onPause() { - super.onPause() - binding.mapView.pause() - } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt index 8b6c5fd0..0b525586 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt @@ -1,8 +1,6 @@ package com.project200.feature.matching.map import android.content.SharedPreferences -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kakao.vectormap.LatLng @@ -126,8 +124,8 @@ class MatchingMapViewModel ) // 지도 초기 위치 - private val _initialMapPosition = MutableLiveData() - val initialMapPosition: LiveData = _initialMapPosition + private val _initialMapPosition = MutableStateFlow(null) + val initialMapPosition: StateFlow = _initialMapPosition.asStateFlow() // 운동 장소 다이얼로그 표시 알림 private val _shouldShowPlaceGuideDialog = MutableSharedFlow() diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingFilterRow.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingFilterRow.kt new file mode 100644 index 00000000..ede2eff2 --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingFilterRow.kt @@ -0,0 +1,181 @@ +package com.project200.feature.matching.map.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.project200.feature.matching.utils.FilterState +import com.project200.feature.matching.utils.MatchingFilterType +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite100 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.subtext14 +import com.project200.presentation.utils.labelResId +import com.project200.undabang.feature.matching.R + +private val ChipShape = RoundedCornerShape(40.dp) + +/** + * 매칭 지도 상단 필터 칩 줄. + * + * 맨 앞에 초기화 칩, 그 뒤로 [MatchingFilterType] 별 필터 칩을 [LazyRow] 로 나열한다. + * 칩 클릭/초기화는 콜백으로 위임하고, 선택 여부 표시는 [filterState] 로 그린다. + * (기존 RecyclerView 필터 어댑터를 Compose 로 옮긴 것) + */ +@Composable +fun MatchingFilterRow( + filterState: FilterState, + onFilterClick: (MatchingFilterType) -> Unit, + onClearClick: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier, + contentPadding = + PaddingValues( + start = dimensionResource(com.project200.undabang.presentation.R.dimen.base_horizontal_margin), + end = 10.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { ClearChip(onClick = onClearClick) } + items(MatchingFilterType.entries) { type -> + FilterChip( + text = filterChipLabel(type, filterState), + selected = isFilterSelected(type, filterState), + onClick = { onFilterClick(type) }, + ) + } + } +} + +/** 초기화 칩: 항상 흰 배경 + 회색 테두리. */ +@Composable +private fun ClearChip( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .clip(ChipShape) + .background(ColorWhite100, ChipShape) + .border(1.dp, ColorGray200, ChipShape) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.filter_clear), + style = MaterialTheme.typography.subtext14, + color = ColorBlack, + modifier = Modifier.padding(end = 4.dp), + ) + Image( + painter = painterResource(R.drawable.ic_filter_clear), + contentDescription = null, + ) + } +} + +/** 필터 칩: 선택 시 main 배경 + 흰 텍스트, 미선택 시 흰 배경 + 회색 테두리 + 검정 텍스트. */ +@Composable +private fun FilterChip( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val contentColor = if (selected) ColorWhite300 else ColorBlack + Row( + modifier = + modifier + .clip(ChipShape) + .background(if (selected) ColorMain else ColorWhite100, ChipShape) + .then(if (selected) Modifier else Modifier.border(1.dp, ColorGray200, ChipShape)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.subtext14, + color = contentColor, + modifier = Modifier.padding(end = 4.dp), + ) + Icon( + painter = painterResource(com.project200.undabang.presentation.R.drawable.ic_arrow_down), + contentDescription = null, + tint = contentColor, + ) + } +} + +/** + * 칩에 표시할 라벨. + * - 종목: 선택된 종목명(문자열) + * - 다중선택(요일): 항상 기본 라벨 + * - 그 외: 선택된 옵션 라벨(있으면), 없으면 기본 라벨 + */ +@Composable +private fun filterChipLabel( + type: MatchingFilterType, + state: FilterState, +): String { + return when { + type == MatchingFilterType.EXERCISE_TYPE && state.selectedExerciseType != null -> { + state.selectedExerciseType.name + } + type.isMultiSelect -> stringResource(type.labelResId) + else -> stringResource(selectedOptionLabelResId(type, state) ?: type.labelResId) + } +} + +/** 단일선택 필터에서 현재 선택된 옵션의 라벨 resId (없으면 null). */ +private fun selectedOptionLabelResId( + type: MatchingFilterType, + state: FilterState, +): Int? { + return when (type) { + MatchingFilterType.GENDER -> state.gender?.labelResId + MatchingFilterType.AGE -> state.ageGroup?.labelResId + MatchingFilterType.SKILL -> state.skillLevel?.labelResId + MatchingFilterType.SCORE -> state.exerciseScore?.labelResId + else -> null + } +} + +/** 필터가 선택된 상태인지. */ +private fun isFilterSelected( + type: MatchingFilterType, + state: FilterState, +): Boolean { + return when (type) { + MatchingFilterType.GENDER -> state.gender != null + MatchingFilterType.AGE -> state.ageGroup != null + MatchingFilterType.EXERCISE_TYPE -> state.selectedExerciseType != null + MatchingFilterType.DAY -> state.days.isNotEmpty() + MatchingFilterType.SKILL -> state.skillLevel != null + MatchingFilterType.SCORE -> state.exerciseScore != null + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt new file mode 100644 index 00000000..26ea5a33 --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt @@ -0,0 +1,22 @@ +package com.project200.feature.matching.map.compose + +import com.kakao.vectormap.LatLng +import com.project200.feature.matching.map.MapViewManager + +/** + * 지도 카메라를 화면 밖(Fragment)에서 제어하기 위한 통로. + * + * MapViewManager 는 MatchingMapScreen 내부에서 비동기로 생성되므로, + * 권한/현재위치/초기 복원처럼 Fragment 가 담당하는 로직이 카메라를 만지려면 이 컨트롤러로 다리를 놓는다. + * 준비 전(manager == null)에는 카메라 명령이 무시된다. + */ +class MatchingMapController { + internal var manager: MapViewManager? = null + + fun moveCamera( + latLng: LatLng, + zoomLevel: Int, + ) { + manager?.moveCamera(latLng, zoomLevel) + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt new file mode 100644 index 00000000..c91d03ec --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt @@ -0,0 +1,325 @@ +package com.project200.feature.matching.map.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +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.lifecycle.compose.collectAsStateWithLifecycle +import com.kakao.vectormap.label.Label +import com.project200.domain.model.MatchingMember +import com.project200.feature.matching.map.MapViewManager +import com.project200.feature.matching.map.MatchingMapViewModel +import com.project200.feature.matching.map.cluster.ClusterCalculator +import com.project200.feature.matching.map.cluster.MapClusterItem +import com.project200.feature.matching.utils.FilterState +import com.project200.feature.matching.utils.MatchingFilterType +import com.project200.presentation.compose.map.KakaoMapView +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.undabang.feature.matching.R + +private val ToolbarShape = RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp) + +/** + * 매칭 지도 화면 (stateful). + * + * ViewModel 의 상태를 구독하고 지도 호스팅(manager 생성·카메라 idle·라벨 클릭)을 담당한 뒤, + * 화면 레이아웃/오버레이는 stateless 인 [MatchingMapContent] 에 위임한다. 지도는 슬롯으로 넘긴다. + * + * @param onClusterClick 클러스터 라벨 클릭 시, 묶인 멤버 목록 전달 + * @param onPlaceMarkerClick 내 장소 마커 클릭 시 + * @param onCurrentLocationClick 현재위치 버튼 클릭 시 (권한/위치는 Fragment 처리) + * @param onFilterClick 필터 칩 클릭 시 (바텀시트는 Fragment 처리) + * @param onClearClick 필터 초기화 칩 클릭 시 + * @param onExercisePlaceListClick 운동 장소 목록 버튼 클릭 시 + */ +@Composable +fun MatchingMapScreen( + viewModel: MatchingMapViewModel, + modifier: Modifier = Modifier, + controller: MatchingMapController? = null, + onMapReady: () -> Unit = {}, + onClusterClick: (List) -> Unit = {}, + onPlaceMarkerClick: () -> Unit = {}, + onCurrentLocationClick: () -> Unit = {}, + onFilterClick: (MatchingFilterType) -> Unit = {}, + onClearClick: () -> Unit = {}, + onExercisePlaceListClick: () -> Unit = {}, +) { + val context = LocalContext.current + // 멤버 데이터 / 필터 상태 / 로딩 구독 + val mapData by viewModel.combinedMapData.collectAsStateWithLifecycle() + val filterState by viewModel.filterState.collectAsStateWithLifecycle() + val isFilterLoading by viewModel.isFilterLoading.collectAsStateWithLifecycle() + + // 클러스터 계산기 / 지도 매니저(지도 준비 후 비동기 생성이라 상태로 둠) + val clusterCalculator = remember { ClusterCalculator() } + var manager by remember { mutableStateOf(null) } + + MatchingMapContent( + filterState = filterState, + isFilterLoading = isFilterLoading, + onCurrentLocationClick = onCurrentLocationClick, + onFilterClick = onFilterClick, + onClearClick = onClearClick, + onExercisePlaceListClick = onExercisePlaceListClick, + modifier = modifier, + ) { + // 지도 슬롯: 호스팅 + manager 생성 + 카메라 idle/라벨 클릭 처리 + KakaoMapView( + modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + manager = + MapViewManager( + context = context, + kakaoMap = map, + onCameraIdle = { cameraPosition -> + // 카메라 이동 종료 → 위치 저장 + 영역 이동 시 멤버 재조회 + viewModel.saveLastLocation( + cameraPosition.position.latitude, + cameraPosition.position.longitude, + cameraPosition.zoomLevel, + ) + manager?.getCurrentBounds()?.let { bounds -> + viewModel.fetchMatchingMembersIfMoved( + currentBounds = bounds, + currentCenter = cameraPosition.position, + currentZoom = cameraPosition.zoomLevel, + ) + } + }, + onLabelClick = { label -> + handleLabelClick(label, manager, clusterCalculator, onClusterClick, onPlaceMarkerClick) + }, + ).also { mgr -> + controller?.manager = mgr // 카메라 제어 통로 연결 + onMapReady() // Fragment가 초기 복원 등을 시작하도록 통지 + } + }, + ) + } + + // 데이터(또는 지도 준비) 변경 시 마커/클러스터 다시 그리기 + LaunchedEffect(mapData, manager) { + val mgr = manager ?: return@LaunchedEffect + val (members, places) = mapData + updateClusterData(members, clusterCalculator) + mgr.redrawMarkers(places, clusterCalculator) + } +} + +/** + * 매칭 지도 화면의 stateless 레이아웃. + * + * 지도 본체는 [mapContent] 슬롯으로 받고, 오버레이(현재위치 버튼·상단 툴바·필터 로딩)는 + * 순수 상태([filterState], [isFilterLoading])와 콜백으로 그린다. ViewModel 의존이 없어 Preview 가능. + */ +@Composable +fun MatchingMapContent( + filterState: FilterState, + isFilterLoading: Boolean, + onCurrentLocationClick: () -> Unit, + onFilterClick: (MatchingFilterType) -> Unit, + onClearClick: () -> Unit, + onExercisePlaceListClick: () -> Unit, + modifier: Modifier = Modifier, + mapContent: @Composable BoxScope.() -> Unit = {}, +) { + Box(modifier = modifier.fillMaxSize()) { + mapContent() + + // 현재위치 버튼 오버레이 (권한/위치 처리는 콜백으로 Fragment에 위임) + Image( + painter = painterResource(R.drawable.ic_current_location), + contentDescription = "현재 위치", + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(dimensionResource(com.project200.undabang.presentation.R.dimen.base_horizontal_margin)) + .size(44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(ColorWhite300) + .clickable { onCurrentLocationClick() } + .padding(10.dp), + ) + + // 상단 툴바: 운동 장소 목록 버튼 + 필터 칩 줄 + MatchingMapToolbar( + filterState = filterState, + onExercisePlaceListClick = onExercisePlaceListClick, + onFilterClick = onFilterClick, + onClearClick = onClearClick, + modifier = Modifier.align(Alignment.TopCenter), + ) + + // 필터 로딩 오버레이 (터치 차단 + 진행 표시) + if (isFilterLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color(0x80000000)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { }, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ColorMain) + } + } + } +} + +/** 지도 위 상단에 뜨는 툴바(흰 배경 + 하단 라운드). */ +@Composable +private fun MatchingMapToolbar( + filterState: FilterState, + onExercisePlaceListClick: () -> Unit, + onFilterClick: (MatchingFilterType) -> Unit, + onClearClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val horizontalMargin = dimensionResource(com.project200.undabang.presentation.R.dimen.base_horizontal_margin) + Column( + modifier = + modifier + .fillMaxWidth() + .shadow(2.dp, ToolbarShape) + .background(ColorWhite300, ToolbarShape) + .padding(top = 16.dp, bottom = horizontalMargin), + ) { + Row( + modifier = + Modifier + .padding(start = horizontalMargin) + .clickable { onExercisePlaceListClick() } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.exercise_place_list), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Icon( + painter = painterResource(com.project200.undabang.presentation.R.drawable.ic_arrow_right), + contentDescription = null, + tint = ColorBlack, + modifier = Modifier.padding(start = 2.dp), + ) + } + Spacer(Modifier.height(12.dp)) + MatchingFilterRow( + filterState = filterState, + onFilterClick = onFilterClick, + onClearClick = onClearClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** ViewModel 의 최신 멤버를 ClusterCalculator 에 반영. */ +private fun updateClusterData( + members: List, + clusterCalculator: ClusterCalculator, +) { + val clusterItems = + members.flatMap { member -> + member.locations.map { location -> MapClusterItem(member, location) } + } + clusterCalculator.clearItems() + clusterCalculator.addItems(clusterItems) +} + +/** 클릭한 라벨이 클러스터면 멤버 목록 콜백, 아니면 내 장소 마커 콜백. */ +private fun handleLabelClick( + label: Label, + manager: MapViewManager?, + clusterCalculator: ClusterCalculator, + onClusterClick: (List) -> Unit, + onPlaceMarkerClick: () -> Unit, +) { + val cameraPosition = manager?.getCurrentCameraPosition() ?: return + val cluster = + clusterCalculator.getClusters(cameraPosition).find { cluster -> + cluster.position.latitude == label.position.latitude && + cluster.position.longitude == label.position.longitude + } + if (cluster != null) { + onClusterClick(cluster.items.toList()) + } else { + onPlaceMarkerClick() + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun MatchingMapContentPreview() { + AppTheme { + MatchingMapContent( + filterState = FilterState(), + isFilterLoading = false, + onCurrentLocationClick = {}, + onFilterClick = {}, + onClearClick = {}, + onExercisePlaceListClick = {}, + ) { + // 지도 자리 placeholder (실제 지도는 네이티브라 Preview 불가) + Box(Modifier.fillMaxSize().background(ColorGray200)) + } + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun MatchingMapContentLoadingPreview() { + AppTheme { + MatchingMapContent( + filterState = FilterState(), + isFilterLoading = true, + onCurrentLocationClick = {}, + onFilterClick = {}, + onClearClick = {}, + onExercisePlaceListClick = {}, + ) { + Box(Modifier.fillMaxSize().background(ColorGray200)) + } + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterRVAdapter.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterRVAdapter.kt deleted file mode 100644 index 5d4b6cd2..00000000 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterRVAdapter.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.project200.feature.matching.map.filter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.project200.feature.matching.utils.FilterListItem -import com.project200.feature.matching.utils.FilterState -import com.project200.feature.matching.utils.MatchingFilterType -import com.project200.undabang.feature.matching.databinding.ItemFilterBinding -import com.project200.undabang.feature.matching.databinding.ItemFilterClearBinding - -class MatchingFilterRVAdapter( - private val onFilterClick: (MatchingFilterType) -> Unit, - private val onClearClick: () -> Unit, -) : ListAdapter(DiffCallback) { - // 현재 필터 상태를 저장 (어떤 필터가 활성화되었는지 확인용) - private var currentFilterState: FilterState = FilterState() - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is FilterListItem.ClearButton -> TYPE_CLEAR - is FilterListItem.FilterItem -> TYPE_FILTER - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - return if (viewType == TYPE_CLEAR) { - val binding = ItemFilterClearBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ClearViewHolder(binding, onClearClick) - } else { - val binding = ItemFilterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - FilterViewHolder(binding, onFilterClick) - } - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - when (val item = getItem(position)) { - is FilterListItem.ClearButton -> (holder as ClearViewHolder).bind() - is FilterListItem.FilterItem -> (holder as FilterViewHolder).bind(item.type, currentFilterState) - } - } - - /** - * 필터 상태가 변경될 때 호출하여 UI 갱신 (선택 여부 표시) - */ - fun submitFilterState(newState: FilterState) { - this.currentFilterState = newState - notifyDataSetChanged() - } - - fun submitFilterList(filters: List) { - val list = mutableListOf() - list.add(FilterListItem.ClearButton) // 맨 앞에 초기화 버튼 추가 - list.addAll(filters.map { FilterListItem.FilterItem(it) }) - submitList(list) - } - - companion object { - private const val TYPE_CLEAR = 0 - private const val TYPE_FILTER = 1 - - private val DiffCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: FilterListItem, - newItem: FilterListItem, - ): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame( - oldItem: FilterListItem, - newItem: FilterListItem, - ): Boolean { - return oldItem == newItem - } - } - } -} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt deleted file mode 100644 index c910af16..00000000 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.project200.feature.matching.map.filter - -import android.content.res.ColorStateList -import androidx.recyclerview.widget.RecyclerView -import com.project200.feature.matching.utils.FilterState -import com.project200.feature.matching.utils.MatchingFilterType -import com.project200.presentation.utils.labelResId -import com.project200.undabang.feature.matching.databinding.ItemFilterBinding -import com.project200.undabang.feature.matching.databinding.ItemFilterClearBinding -import com.project200.undabang.presentation.R - -/** - * 초기화 뷰홀더 - */ -class ClearViewHolder( - private val binding: ItemFilterClearBinding, - private val onClearClick: () -> Unit, -) : RecyclerView.ViewHolder(binding.root) { - fun bind() { - binding.root.setOnClickListener { onClearClick() } - } -} - -/** - * 필터 항목 뷰홀더 - */ -class FilterViewHolder( - private val binding: ItemFilterBinding, - private val onFilterClick: (MatchingFilterType) -> Unit, -) : RecyclerView.ViewHolder(binding.root) { - fun bind( - item: MatchingFilterType, - currentState: FilterState, - ) { - val context = binding.root.context - - val labelText = - when { - item == MatchingFilterType.EXERCISE_TYPE && currentState.selectedExerciseType != null -> { - currentState.selectedExerciseType.name - } - item.isMultiSelect -> { - context.getString(item.labelResId) - } - else -> { - val selectedLabelResId = getSelectedOptionLabelResId(item, currentState) - context.getString(selectedLabelResId ?: item.labelResId) - } - } - binding.filterTitleTv.text = labelText - - val isSelected = isFilterSelected(item, currentState) - binding.root.isSelected = isSelected - - binding.filterTitleTv.setTextColor( - context.getColor(if (isSelected) R.color.white300 else R.color.black), - ) - binding.filterIv.imageTintList = - ColorStateList.valueOf( - context.getColor(if (isSelected) R.color.white300 else R.color.black), - ) - - binding.root.setOnClickListener { onFilterClick(item) } - } - - private fun isFilterSelected( - type: MatchingFilterType, - state: FilterState, - ): Boolean { - return when (type) { - MatchingFilterType.GENDER -> state.gender != null - MatchingFilterType.AGE -> state.ageGroup != null - MatchingFilterType.EXERCISE_TYPE -> state.selectedExerciseType != null - MatchingFilterType.DAY -> state.days.isNotEmpty() - MatchingFilterType.SKILL -> state.skillLevel != null - MatchingFilterType.SCORE -> state.exerciseScore != null - } - } - - private fun getSelectedOptionLabelResId( - type: MatchingFilterType, - state: FilterState, - ): Int? { - return when (type) { - MatchingFilterType.GENDER -> state.gender?.labelResId - MatchingFilterType.AGE -> state.ageGroup?.labelResId - MatchingFilterType.SKILL -> state.skillLevel?.labelResId - MatchingFilterType.SCORE -> state.exerciseScore?.labelResId - else -> null - } - } -} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceFragment.kt index 7d8b2afd..2e885777 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceFragment.kt @@ -1,112 +1,50 @@ package com.project200.feature.matching.place -import android.view.ContextThemeWrapper +import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.PopupMenu +import android.view.ViewGroup import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.project200.domain.model.ExercisePlace import com.project200.feature.matching.utils.ExercisePlaceErrorType -import com.project200.presentation.base.BindingFragment -import com.project200.presentation.utils.MenuStyler +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.matching.R -import com.project200.undabang.feature.matching.databinding.FragmentExercisePlaceBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint -class ExercisePlaceFragment : BindingFragment (R.layout.fragment_exercise_place) { +class ExercisePlaceFragment : Fragment() { private val viewModel: ExercisePlaceViewModel by viewModels() - private lateinit var exercisePlaceAdapter: ExercisePlaceRVAdapter - override fun getViewBinding(view: View): FragmentExercisePlaceBinding { - return FragmentExercisePlaceBinding.bind(view) - } - - override fun onResume() { - super.onResume() - viewModel.getExercisePlaces() - } - - override fun setupViews() { - binding.baseToolbar.apply { - setTitle(getString(R.string.exercise_place)) - showBackButton(true) { requireActivity().onBackPressedDispatcher.onBackPressed() } - } - binding.exercisePlaceSearchBtn.setOnClickListener { - viewModel.places.value?.size?.let { - if (it >= 10) { - Toast.makeText(requireContext(), R.string.max_place_count, Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - } - findNavController().navigate( - ExercisePlaceFragmentDirections.actionExercisePlaceFragmentToExercisePlaceSearchFragment(), - ) - } - - setupRecyclerView() - } - - private fun setupRecyclerView() { - exercisePlaceAdapter = - ExercisePlaceRVAdapter(onMenuClicked = { place, view -> - showPopupMenu(place, view) - }) - - binding.placeRv.apply { - adapter = exercisePlaceAdapter - layoutManager = LinearLayoutManager(requireContext()) - } - } - - override fun setupObservers() { - viewModel.places.observe(viewLifecycleOwner) { places -> - if (places.isEmpty()) { - binding.emptyPlaceTv.visibility = View.VISIBLE - binding.placeRv.visibility = View.GONE - } else { - binding.emptyPlaceTv.visibility = View.GONE - binding.placeRv.visibility = View.VISIBLE - exercisePlaceAdapter.submitList(places) - } - } - - viewModel.errorToast.observe(viewLifecycleOwner) { - val message = - when (it) { - ExercisePlaceErrorType.LOAD_FAILED -> R.string.error_failed_to_load_exercise_place - ExercisePlaceErrorType.DELETE_FAILED -> { - binding.emptyPlaceTv.visibility = View.VISIBLE - binding.placeRv.visibility = View.GONE - R.string.error_failed_to_delete_exercise_place - } - null -> return@observe - } - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() - } - } - - private fun showPopupMenu( - place: ExercisePlace, - view: View, - ) { - val contextWrapper = ContextThemeWrapper(requireContext(), com.project200.undabang.presentation.R.style.PopupItemStyle) - - PopupMenu(contextWrapper, view).apply { - menuInflater.inflate(R.menu.exercise_place_item_menu, this.menu) - - menu.findItem(R.id.action_edit)?.let { - MenuStyler.applyTextColor(requireContext(), it, android.R.color.black) - } - menu.findItem(R.id.action_delete)?.let { - MenuStyler.applyTextColor(requireContext(), it, com.project200.undabang.presentation.R.color.error_red) - } - - setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_edit -> { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val places by viewModel.places.collectAsStateWithLifecycle() + ExercisePlaceScreen( + places = places, + onBackClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onAddClick = { + if (places.size >= MAX_PLACE_COUNT) { + Toast.makeText(requireContext(), R.string.max_place_count, Toast.LENGTH_SHORT).show() + } else { + findNavController().navigate( + ExercisePlaceFragmentDirections.actionExercisePlaceFragmentToExercisePlaceSearchFragment(), + ) + } + }, + onEditClick = { place -> findNavController().navigate( ExercisePlaceFragmentDirections.actionExercisePlaceFragmentToExercisePlaceRegisterFragment( placeId = place.id, @@ -116,15 +54,38 @@ class ExercisePlaceFragment : BindingFragment (R.l longitude = place.longitude.toString(), ), ) - } - R.id.action_delete -> { - viewModel.deleteExercisePlace(place.id) - } + }, + onDeleteClick = { place -> viewModel.deleteExercisePlace(place.id) }, + ) + } + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.errorToast.collect { type -> + val message = + when (type) { + ExercisePlaceErrorType.LOAD_FAILED -> R.string.error_failed_to_load_exercise_place + ExercisePlaceErrorType.DELETE_FAILED -> R.string.error_failed_to_delete_exercise_place + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } - true } + } + } + + override fun onResume() { + super.onResume() + viewModel.getExercisePlaces() + } - MenuStyler.showIcons(this) - }.show() + companion object { + private const val MAX_PLACE_COUNT = 10 } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRVAdapter.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRVAdapter.kt deleted file mode 100644 index b4552459..00000000 --- a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRVAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.project200.feature.matching.place - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.project200.domain.model.ExercisePlace -import com.project200.undabang.feature.matching.databinding.ItemExercisePlaceBinding - -class ExercisePlaceRVAdapter( - val onMenuClicked: (ExercisePlace, View) -> Unit, -) : RecyclerView.Adapter() { - private var places: List = emptyList() - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ExercisePlaceViewHolder { - val binding = - ItemExercisePlaceBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return ExercisePlaceViewHolder(binding) - } - - override fun onBindViewHolder( - holder: ExercisePlaceViewHolder, - position: Int, - ) { - holder.bind(places[position]) - } - - override fun getItemCount(): Int = places.size - - fun submitList(newPlaces: List) { - places = newPlaces - notifyDataSetChanged() - } - - inner class ExercisePlaceViewHolder( - private val binding: ItemExercisePlaceBinding, - ) : - RecyclerView.ViewHolder(binding.root) { - fun bind(place: ExercisePlace) { - binding.placeNameTv.text = place.name - binding.placeAddressTv.text = place.address - binding.menuIv.setOnClickListener { - onMenuClicked(place, binding.menuIv) - } - } - } -} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterFragment.kt index a5b9c2c3..e9b93b56 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterFragment.kt @@ -1,33 +1,33 @@ package com.project200.feature.matching.place +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast -import androidx.core.widget.doAfterTextChanged +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.project200.domain.model.BaseResult -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.matching.R -import com.project200.undabang.feature.matching.databinding.FragmentExercisePlaceRegisterBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch -@AndroidEntryPoint // Hilt 어노테이션 추가 -class ExercisePlaceRegisterFragment : BindingFragment (R.layout.fragment_exercise_place_register) { +@AndroidEntryPoint +class ExercisePlaceRegisterFragment : Fragment() { private val viewModel: ExercisePlaceRegisterViewModel by viewModels() private val args: ExercisePlaceRegisterFragmentArgs by navArgs() - override fun getViewBinding(view: View): FragmentExercisePlaceRegisterBinding { - return FragmentExercisePlaceRegisterBinding.bind(view) - } - - override fun setupViews() { - binding.baseToolbar.apply { - setTitle(getString(R.string.exercise_place_register_title)) - showBackButton(true) { findNavController().navigateUp() } - } - binding.placeAddressTv.text = args.address - + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) viewModel.initializePlaceInfo( id = args.placeId, placeName = args.name, @@ -35,48 +35,66 @@ class ExercisePlaceRegisterFragment : BindingFragment - viewModel.onPlaceNameChanged(text.toString()) - } - - binding.registerBtn.setOnClickListener { - if (binding.placeNameEt.text.toString().isEmpty()) { - Toast.makeText(requireContext(), R.string.empty_place_name, Toast.LENGTH_SHORT).show() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val placeNameInput by viewModel.customPlaceName.collectAsStateWithLifecycle() + val placeAddress by viewModel.placeAddress.collectAsStateWithLifecycle() + ExercisePlaceRegisterScreen( + placeName = if (placeNameInput.isBlank()) getString(R.string.place_name_hint) else placeNameInput, + placeAddress = placeAddress, + placeNameInput = placeNameInput, + onPlaceNameChange = viewModel::onPlaceNameChanged, + onRegisterClick = { + if (placeNameInput.isBlank()) { + Toast.makeText(requireContext(), R.string.empty_place_name, Toast.LENGTH_SHORT).show() + } + viewModel.confirmExercisePlace() + }, + onBackClick = { findNavController().navigateUp() }, + ) } - viewModel.confirmExercisePlace() } - } - override fun setupObservers() { - viewModel.customPlaceName.observe(viewLifecycleOwner) { name -> - binding.placeNameTv.text = if (name.isBlank()) getString(R.string.place_name_hint) else name - } - - viewModel.registrationResult.observe(viewLifecycleOwner) { result -> - when (result) { - is BaseResult.Success -> { - Toast.makeText(requireContext(), getString(R.string.success_register_place), Toast.LENGTH_SHORT).show() - findNavController().navigate(R.id.action_complete_registration_and_go_to_place_list) - } - is BaseResult.Error -> { - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() - } - } - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) - viewModel.editResult.observe(viewLifecycleOwner) { result -> - when (result) { - is BaseResult.Success -> { - Toast.makeText(requireContext(), getString(R.string.success_edit_place), Toast.LENGTH_SHORT).show() - findNavController().navigate(R.id.action_complete_registration_and_go_to_place_list) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.registrationResult.collect { result -> + when (result) { + is BaseResult.Success -> { + Toast.makeText(requireContext(), getString(R.string.success_register_place), Toast.LENGTH_SHORT).show() + findNavController().navigate(R.id.action_complete_registration_and_go_to_place_list) + } + is BaseResult.Error -> { + Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + } + } + } } - is BaseResult.Error -> { - Toast.makeText(requireContext(), R.string.error_fail_to_register_place, Toast.LENGTH_SHORT).show() + launch { + viewModel.editResult.collect { result -> + when (result) { + is BaseResult.Success -> { + Toast.makeText(requireContext(), getString(R.string.success_edit_place), Toast.LENGTH_SHORT).show() + findNavController().navigate(R.id.action_complete_registration_and_go_to_place_list) + } + is BaseResult.Error -> { + Toast.makeText(requireContext(), R.string.error_fail_to_register_place, Toast.LENGTH_SHORT).show() + } + } + } } } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterScreen.kt new file mode 100644 index 00000000..9324dd35 --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterScreen.kt @@ -0,0 +1,187 @@ +package com.project200.feature.matching.place + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.input.TextFieldVariant +import com.project200.presentation.compose.components.input.UndabangTextField +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.matching.R + +@Composable +fun ExercisePlaceRegisterScreen( + placeName: String, + placeAddress: String, + placeNameInput: String, + onPlaceNameChange: (String) -> Unit, + onRegisterClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = stringResource(R.string.exercise_place_register_title), + onNavigationClick = onBackClick, + ) + }, + bottomBar = { + PrimaryButton( + text = stringResource(R.string.exercise_place_register_complete), + onClick = onRegisterClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 40.dp), + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()), + ) { + PlaceInfoSection( + placeName = placeName, + placeAddress = placeAddress, + ) + + Spacer(Modifier.height(30.dp)) + + Text( + text = stringResource(R.string.place_name_title), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(Modifier.height(4.dp)) + + UndabangTextField( + value = placeNameInput, + onValueChange = onPlaceNameChange, + hint = stringResource(R.string.place_name_hint), + variant = TextFieldVariant.Outlined, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .height(50.dp), + ) + + Spacer(Modifier.height(30.dp)) + + NoticeSection() + } + } +} + +@Composable +private fun PlaceInfoSection( + placeName: String, + placeAddress: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(100.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_member_marker), + contentDescription = null, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(ColorBlack), + ) + Spacer(Modifier.width(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = placeName, + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Text( + text = placeAddress, + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + } + } +} + +@Composable +private fun NoticeSection(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = 20.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.ic_warning), + contentDescription = null, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.place_name_notice_title), + color = ColorGray200, + fontSize = 12.sp, + style = MaterialTheme.typography.contentBold, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.place_name_notice_content), + color = ColorGray200, + fontSize = 12.sp, + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun ExercisePlaceRegisterScreenPreview() { + AppTheme { + var name by remember { mutableStateOf("운다방 체육관") } + ExercisePlaceRegisterScreen( + placeName = "체육관", + placeAddress = "서울특별시 강남구 테헤란로 123", + placeNameInput = name, + onPlaceNameChange = { name = it }, + onRegisterClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModel.kt index 75c46f28..4b0a1161 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModel.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModel.kt @@ -1,7 +1,5 @@ package com.project200.feature.matching.place -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -9,6 +7,12 @@ import com.project200.domain.model.ExercisePlace import com.project200.domain.usecase.EditExercisePlaceUseCase import com.project200.domain.usecase.RegisterExercisePlaceUseCase import dagger.hilt.android.lifecycle.HiltViewModel +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.launch import javax.inject.Inject @@ -19,17 +23,20 @@ class ExercisePlaceRegisterViewModel private val registerExercisePlaceUseCase: RegisterExercisePlaceUseCase, private val editExercisePlace: EditExercisePlaceUseCase, ) : ViewModel() { - // 원본 장소 정보를 저장 (Fragment에서 arguments로 전달받음) - private val originalPlaceInfo = MutableLiveData() + // 원본 장소 정보 (Fragment에서 arguments로 전달받음) + private var originalPlaceInfo: ExercisePlace? = null - private val _customPlaceName = MutableLiveData() - val customPlaceName: LiveData = _customPlaceName + private val _customPlaceName = MutableStateFlow("") + val customPlaceName: StateFlow = _customPlaceName.asStateFlow() - private val _registrationResult = MutableLiveData>() - val registrationResult: LiveData> = _registrationResult + private val _placeAddress = MutableStateFlow("") + val placeAddress: StateFlow = _placeAddress.asStateFlow() - private val _editResult = MutableLiveData>() - val editResult: LiveData> = _editResult + private val _registrationResult = MutableSharedFlow>() + val registrationResult: SharedFlow> = _registrationResult.asSharedFlow() + + private val _editResult = MutableSharedFlow>() + val editResult: SharedFlow> = _editResult.asSharedFlow() /** * Fragment에서 받은 arguments로 ViewModel 초기화 @@ -41,7 +48,7 @@ class ExercisePlaceRegisterViewModel latitude: Double, longitude: Double, ) { - val placeInfo = + originalPlaceInfo = ExercisePlace( id = id, name = placeName, @@ -49,12 +56,12 @@ class ExercisePlaceRegisterViewModel latitude = latitude, longitude = longitude, ) - originalPlaceInfo.value = placeInfo - _customPlaceName.value = placeInfo.name + _customPlaceName.value = placeName + _placeAddress.value = placeAddress } /** - * EditText의 텍스트가 변경될 때마다 호출 + * 입력 필드의 텍스트가 변경될 때마다 호출 */ fun onPlaceNameChanged(newName: String) { _customPlaceName.value = newName @@ -66,16 +73,16 @@ class ExercisePlaceRegisterViewModel fun confirmExercisePlace() { viewModelScope.launch { val customName = _customPlaceName.value - if (customName.isNullOrBlank()) return@launch + if (customName.isBlank()) return@launch - val originalInfo = originalPlaceInfo.value ?: return@launch + val originalInfo = originalPlaceInfo ?: return@launch if (originalInfo.id != DEFAULT_PLACE_ID) { // 기존 장소 수정 val result = editExercisePlace(originalInfo.copy(name = customName)) - _editResult.value = result + _editResult.emit(result) } else { // 신규 장소 등록 val result = registerExercisePlaceUseCase(originalInfo.copy(name = customName)) - _registrationResult.value = result + _registrationResult.emit(result) } } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceScreen.kt new file mode 100644 index 00000000..e40f7458 --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceScreen.kt @@ -0,0 +1,280 @@ +package com.project200.feature.matching.place + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.domain.model.ExercisePlace +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorErrorRed +import com.project200.presentation.compose.theme.ColorGray100 +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.contentRegular +import com.project200.presentation.compose.theme.subtext12 +import com.project200.undabang.feature.matching.R +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun ExercisePlaceScreen( + places: List, + onBackClick: () -> Unit, + onAddClick: () -> Unit, + onEditClick: (ExercisePlace) -> Unit, + onDeleteClick: (ExercisePlace) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite300), + ) { + UndabangTopBar( + title = stringResource(R.string.exercise_place), + onNavigationClick = onBackClick, + actions = { + AddPlaceButton( + onClick = onAddClick, + modifier = Modifier.padding(end = 20.dp), + ) + }, + ) + + if (places.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.empty_exercise_place), + style = MaterialTheme.typography.contentRegular, + color = ColorGray200, + textAlign = TextAlign.Center, + ) + } + } else { + Spacer(Modifier.height(32.dp)) + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = places, key = { it.id }) { place -> + ExercisePlaceItem( + place = place, + onEditClick = { onEditClick(place) }, + onDeleteClick = { onDeleteClick(place) }, + ) + } + } + } + } +} + +@Composable +private fun AddPlaceButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .size(width = 62.dp, height = 33.dp) + .clip(RoundedCornerShape(5.dp)) + .background(ColorMain) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.exercise_place_add), + style = MaterialTheme.typography.contentBold, + color = ColorWhite300, + ) + } +} + +@Composable +private fun ExercisePlaceItem( + place: ExercisePlace, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(100.dp) + .drawBehind { + val strokePx = 1.dp.toPx() + drawLine( + color = ColorGray300, + start = Offset(0f, size.height - strokePx / 2), + end = Offset(size.width, size.height - strokePx / 2), + strokeWidth = strokePx, + ) + } + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_member_marker), + contentDescription = null, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(ColorBlack), + ) + Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = place.name, + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Text( + text = place.address, + style = MaterialTheme.typography.subtext12, + color = ColorGray100, + ) + } + MenuButton( + onEditClick = onEditClick, + onDeleteClick = onDeleteClick, + ) + } +} + +@Composable +private fun MenuButton( + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + IconButton(onClick = { expanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_menu), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { + Text( + text = stringResource(PresentationR.string.edit), + color = ColorBlack, + ) + }, + leadingIcon = { + Image( + painter = painterResource(R.drawable.ic_edit), + contentDescription = null, + ) + }, + onClick = { + expanded = false + onEditClick() + }, + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(PresentationR.string.delete), + color = ColorErrorRed, + ) + }, + leadingIcon = { + Image( + painter = painterResource(R.drawable.ic_trash), + contentDescription = null, + ) + }, + onClick = { + expanded = false + onDeleteClick() + }, + ) + } + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun ExercisePlaceScreenPreview() { + AppTheme { + ExercisePlaceScreen( + places = + listOf( + ExercisePlace(1L, "운다방 헬스장", "서울특별시 강남구 테헤란로 123", 37.5, 127.0), + ExercisePlace(2L, "한강공원 수영장", "서울특별시 영등포구 여의도동", 37.5, 127.0), + ), + onBackClick = {}, + onAddClick = {}, + onEditClick = {}, + onDeleteClick = {}, + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun ExercisePlaceScreenEmptyPreview() { + AppTheme { + ExercisePlaceScreen( + places = emptyList(), + onBackClick = {}, + onAddClick = {}, + onEditClick = {}, + onDeleteClick = {}, + ) + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceSearchFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceSearchFragment.kt index b35ab8db..559b10b9 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceSearchFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceSearchFragment.kt @@ -6,6 +6,8 @@ import android.content.pm.PackageManager import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels @@ -13,19 +15,19 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices +import com.kakao.vectormap.GestureType import com.kakao.vectormap.KakaoMap -import com.kakao.vectormap.KakaoMapReadyCallback import com.kakao.vectormap.LatLng -import com.kakao.vectormap.MapLifeCycleCallback import com.kakao.vectormap.camera.CameraUpdateFactory import com.project200.common.constants.RuleConstants import com.project200.domain.model.BaseResult import com.project200.domain.model.KakaoPlaceInfo import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.map.KakaoMapView import com.project200.undabang.feature.matching.R import com.project200.undabang.feature.matching.databinding.FragmentExercisePlaceSearchBinding import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber @AndroidEntryPoint class ExercisePlaceSearchFragment : BindingFragment(R.layout.fragment_exercise_place_search) { @@ -104,30 +106,23 @@ class ExercisePlaceSearchFragment : BindingFragment kakaoMap = map setupInitialMapPosition() - // 두 손가락으로 지도를 회전시키는 제스처 비활성화 - kakaoMap?.setGestureEnable(com.kakao.vectormap.GestureType.Rotate, false) - // 두 손가락으로 지도를 기울이는 제스처(틸트) 비활성화 - kakaoMap?.setGestureEnable(com.kakao.vectormap.GestureType.Tilt, false) + // 두 손가락으로 지도를 회전/기울이는 제스처 비활성화 + map.setGestureEnable(GestureType.Rotate, false) + map.setGestureEnable(GestureType.Tilt, false) setMapListeners() - } - }, - ) + }, + ) + } } /** @@ -159,8 +154,8 @@ class ExercisePlaceSearchFragment : BindingFragment val map = kakaoMap ?: return@setOnCameraMoveEndListener - val targetScreenX = binding.mapView.width / 2 - val targetScreenY = binding.centerMarkerIv.bottom - binding.mapView.top + val targetScreenX = binding.mapComposeView.width / 2 + val targetScreenY = binding.centerMarkerIv.bottom - binding.mapComposeView.top val targetLatLng = map.fromScreenPoint(targetScreenX, targetScreenY) @@ -220,8 +215,8 @@ class ExercisePlaceSearchFragment : BindingFragment // 현재 화면을 기준으로, 지도 중앙과 마커 끝의 위도 값을 구합니다. - val mapCenterLatLng = map.fromScreenPoint(binding.mapView.width / 2, binding.mapView.height / 2) - val markerTipLatLng = map.fromScreenPoint(binding.mapView.width / 2, binding.centerMarkerIv.bottom) + val mapCenterLatLng = map.fromScreenPoint(binding.mapComposeView.width / 2, binding.mapComposeView.height / 2) + val markerTipLatLng = map.fromScreenPoint(binding.mapComposeView.width / 2, binding.centerMarkerIv.bottom) // 두 위도 값의 차이를 계산하여 '보정값(offset)'을 구합니다. (null일 경우를 대비해 기본값 0.0 사용) val latOffset = (mapCenterLatLng?.latitude ?: 0.0) - (markerTipLatLng?.latitude ?: 0.0) @@ -279,16 +274,6 @@ class ExercisePlaceSearchFragment : BindingFragment>() - val places: LiveData> = _places + private val _places = MutableStateFlow>(emptyList()) + val places: StateFlow> = _places.asStateFlow() - private val _errorToast = MutableLiveData() - val errorToast: LiveData = _errorToast + private val _errorToast = MutableSharedFlow() + val errorToast: SharedFlow = _errorToast.asSharedFlow() fun getExercisePlaces() { viewModelScope.launch { @@ -33,7 +37,7 @@ class ExercisePlaceViewModel _places.value = result.data } is BaseResult.Error -> { - _errorToast.value = ExercisePlaceErrorType.LOAD_FAILED + _errorToast.emit(ExercisePlaceErrorType.LOAD_FAILED) } } } @@ -43,10 +47,10 @@ class ExercisePlaceViewModel viewModelScope.launch { when (deleteExercisePlaceUseCase(placeId)) { is BaseResult.Success -> { - _places.value = _places.value?.filterNot { it.id == placeId } + _places.value = _places.value.filterNot { it.id == placeId } } is BaseResult.Error -> { - _errorToast.value = ExercisePlaceErrorType.DELETE_FAILED + _errorToast.emit(ExercisePlaceErrorType.DELETE_FAILED) } } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt index 069a3ef2..d19e9acc 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt @@ -7,12 +7,6 @@ import com.project200.domain.model.Gender import com.project200.domain.model.SkillLevel import com.project200.undabang.feature.matching.R -sealed class FilterListItem { - data object ClearButton : FilterListItem() // 초기화 버튼 - - data class FilterItem(val type: MatchingFilterType) : FilterListItem() // 필터 버튼 -} - enum class MatchingFilterType( val labelResId: Int, val isMultiSelect: Boolean = false, diff --git a/feature/matching/src/main/res/drawable/bg_current_location_btn.xml b/feature/matching/src/main/res/drawable/bg_current_location_btn.xml deleted file mode 100644 index b9e993ef..00000000 --- a/feature/matching/src/main/res/drawable/bg_current_location_btn.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/drawable/bg_filter_btn.xml b/feature/matching/src/main/res/drawable/bg_filter_btn.xml deleted file mode 100644 index 96acc1e6..00000000 --- a/feature/matching/src/main/res/drawable/bg_filter_btn.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/drawable/bg_filter_btn_selected.xml b/feature/matching/src/main/res/drawable/bg_filter_btn_selected.xml deleted file mode 100644 index f8b7a304..00000000 --- a/feature/matching/src/main/res/drawable/bg_filter_btn_selected.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/drawable/bg_matching_map_toolbar.xml b/feature/matching/src/main/res/drawable/bg_matching_map_toolbar.xml deleted file mode 100644 index 7b610c1b..00000000 --- a/feature/matching/src/main/res/drawable/bg_matching_map_toolbar.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/drawable/selector_filter_btn.xml b/feature/matching/src/main/res/drawable/selector_filter_btn.xml deleted file mode 100644 index f76528f7..00000000 --- a/feature/matching/src/main/res/drawable/selector_filter_btn.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/layout/fragment_exercise_place.xml b/feature/matching/src/main/res/layout/fragment_exercise_place.xml deleted file mode 100644 index 668a40dc..00000000 --- a/feature/matching/src/main/res/layout/fragment_exercise_place.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/layout/fragment_exercise_place_register.xml b/feature/matching/src/main/res/layout/fragment_exercise_place_register.xml deleted file mode 100644 index 19a48032..00000000 --- a/feature/matching/src/main/res/layout/fragment_exercise_place_register.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/layout/fragment_exercise_place_search.xml b/feature/matching/src/main/res/layout/fragment_exercise_place_search.xml index 3e5ede30..b1685821 100644 --- a/feature/matching/src/main/res/layout/fragment_exercise_place_search.xml +++ b/feature/matching/src/main/res/layout/fragment_exercise_place_search.xml @@ -53,8 +53,8 @@ app:layout_constraintEnd_toEndOf="@+id/base_toolbar" app:layout_constraintTop_toTopOf="@+id/base_toolbar" /> - diff --git a/feature/matching/src/main/res/layout/fragment_matching_guide.xml b/feature/matching/src/main/res/layout/fragment_matching_guide.xml deleted file mode 100644 index bebe35c7..00000000 --- a/feature/matching/src/main/res/layout/fragment_matching_guide.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/layout/fragment_matching_map.xml b/feature/matching/src/main/res/layout/fragment_matching_map.xml index ebf7568e..97703c8d 100644 --- a/feature/matching/src/main/res/layout/fragment_matching_map.xml +++ b/feature/matching/src/main/res/layout/fragment_matching_map.xml @@ -1,86 +1,5 @@ - - - - - - - - - - - - - - \ No newline at end of file + android:layout_height="match_parent" /> diff --git a/feature/matching/src/main/res/layout/item_exercise_place.xml b/feature/matching/src/main/res/layout/item_exercise_place.xml deleted file mode 100644 index c57e0ce2..00000000 --- a/feature/matching/src/main/res/layout/item_exercise_place.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/layout/item_filter.xml b/feature/matching/src/main/res/layout/item_filter.xml deleted file mode 100644 index 5882fbcb..00000000 --- a/feature/matching/src/main/res/layout/item_filter.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/layout/item_filter_clear.xml b/feature/matching/src/main/res/layout/item_filter_clear.xml deleted file mode 100644 index 02c4b2c9..00000000 --- a/feature/matching/src/main/res/layout/item_filter_clear.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/matching/src/main/res/navigation/matching_nav_graph.xml b/feature/matching/src/main/res/navigation/matching_nav_graph.xml index b404ea68..eb9532af 100644 --- a/feature/matching/src/main/res/navigation/matching_nav_graph.xml +++ b/feature/matching/src/main/res/navigation/matching_nav_graph.xml @@ -49,8 +49,7 @@ + android:label="exercisePlace"> @@ -101,8 +100,7 @@ + android:label="exercisePlaceRegister"> + android:label="matchingGuide"> diff --git a/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModelTest.kt b/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModelTest.kt index 855ba853..a5f11db8 100644 --- a/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModelTest.kt +++ b/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceRegisterViewModelTest.kt @@ -1,6 +1,7 @@ package com.project200.feature.matching.place import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult import com.project200.domain.usecase.EditExercisePlaceUseCase @@ -133,10 +134,12 @@ class ExercisePlaceRegisterViewModelTest { viewModel = createViewModel() viewModel.initializePlaceInfo(-1L, "새 장소", "서울시 강남구", 37.5, 127.0) - viewModel.confirmExercisePlace() - testDispatcher.scheduler.advanceUntilIdle() - - assertThat(viewModel.registrationResult.value).isInstanceOf(BaseResult.Success::class.java) + viewModel.registrationResult.test { + viewModel.confirmExercisePlace() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isInstanceOf(BaseResult.Success::class.java) + cancelAndIgnoreRemainingEvents() + } } @Test @@ -147,10 +150,12 @@ class ExercisePlaceRegisterViewModelTest { viewModel = createViewModel() viewModel.initializePlaceInfo(100L, "기존 장소", "서울시 강남구", 37.5, 127.0) - viewModel.confirmExercisePlace() - testDispatcher.scheduler.advanceUntilIdle() - - assertThat(viewModel.editResult.value).isInstanceOf(BaseResult.Success::class.java) + viewModel.editResult.test { + viewModel.confirmExercisePlace() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isInstanceOf(BaseResult.Success::class.java) + cancelAndIgnoreRemainingEvents() + } } @Test @@ -161,10 +166,12 @@ class ExercisePlaceRegisterViewModelTest { viewModel = createViewModel() viewModel.initializePlaceInfo(-1L, "새 장소", "서울시 강남구", 37.5, 127.0) - viewModel.confirmExercisePlace() - testDispatcher.scheduler.advanceUntilIdle() - - assertThat(viewModel.registrationResult.value).isInstanceOf(BaseResult.Error::class.java) + viewModel.registrationResult.test { + viewModel.confirmExercisePlace() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isInstanceOf(BaseResult.Error::class.java) + cancelAndIgnoreRemainingEvents() + } } @Test diff --git a/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt b/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt index ec70106f..85976447 100644 --- a/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt +++ b/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt @@ -1,6 +1,7 @@ package com.project200.feature.matching.place import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult import com.project200.domain.model.ExercisePlace @@ -81,10 +82,13 @@ class ExercisePlaceViewModelTest { coEvery { mockGetExercisePlaceUseCase() } returns BaseResult.Error("ERROR", "로드 실패") viewModel = createViewModel() - viewModel.getExercisePlaces() - testDispatcher.scheduler.advanceUntilIdle() - assertThat(viewModel.errorToast.value).isEqualTo(ExercisePlaceErrorType.LOAD_FAILED) + viewModel.errorToast.test { + viewModel.getExercisePlaces() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isEqualTo(ExercisePlaceErrorType.LOAD_FAILED) + cancelAndIgnoreRemainingEvents() + } } @Test @@ -101,7 +105,7 @@ class ExercisePlaceViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertThat(viewModel.places.value).hasSize(1) - assertThat(viewModel.places.value?.none { it.id == 1L }).isTrue() + assertThat(viewModel.places.value.none { it.id == 1L }).isTrue() } @Test @@ -114,10 +118,12 @@ class ExercisePlaceViewModelTest { viewModel.getExercisePlaces() testDispatcher.scheduler.advanceUntilIdle() - viewModel.deleteExercisePlace(1L) - testDispatcher.scheduler.advanceUntilIdle() - - assertThat(viewModel.errorToast.value).isEqualTo(ExercisePlaceErrorType.DELETE_FAILED) + viewModel.errorToast.test { + viewModel.deleteExercisePlace(1L) + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isEqualTo(ExercisePlaceErrorType.DELETE_FAILED) + cancelAndIgnoreRemainingEvents() + } } @Test diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index 4cc8c496..8baf95a6 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("convention.android.library") id("convention.android.hilt") + id("convention.android.compose") alias(libs.plugins.navigation.safeargs) } @@ -47,6 +48,9 @@ dependencies { // Glide implementation(libs.glide) + // Coil (Compose 이미지 로딩) + implementation(libs.coil.compose) + // 캘린더 implementation(libs.kizitonwose.calendar.view) diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt index ea28826c..3bdff5e1 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt @@ -1,60 +1,54 @@ package com.project200.undabang.profile.mypage import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat.getColor -import androidx.core.widget.doAfterTextChanged +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import com.bumptech.glide.Glide import com.project200.common.constants.RuleConstants -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.presentation.utils.ImageUtils.compressImage import com.project200.presentation.utils.ImageValidator -import com.project200.presentation.utils.KeyboardAdjustHelper.applyEdgeToEdgeInsets import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentProfileEditBinding -import com.project200.undabang.profile.utils.NicknameValidationState import com.project200.undabang.profile.utils.ProfileEditErrorType import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class ProfileEditFragment : - BindingFragment(R.layout.fragment_profile_edit) { +class ProfileEditFragment : Fragment() { private val viewModel: ProfileEditViewModel by viewModels() private val pickImageLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { selectedUri -> - // 이미지 유효성 검사 시작 val (isValid, reason) = ImageValidator.validateImageFile(selectedUri, requireContext()) if (isValid) { - // 유효성 검사 통과 시 ViewModel에 URI 전달 viewModel.updateProfileImageUri(selectedUri) } else { when (reason) { - // 용량이 클 경우 압축 시도 ImageValidator.OVERSIZE -> { val compressedUri = compressImage(requireContext(), selectedUri) if (compressedUri != null) { viewModel.updateProfileImageUri(compressedUri) } else { - // 압축 실패 시 에러 메시지 표시 viewModel.postImageError(ProfileEditErrorType.IMAGE_READ_FAILED) } } - // 잘못된 파일 타입일 경우 ImageValidator.INVALID_TYPE -> { viewModel.postImageError(ProfileEditErrorType.IMAGE_INVALID_TYPE) } - // 파일을 읽을 수 없을 경우 ImageValidator.FAIL_TO_READ -> { viewModel.postImageError(ProfileEditErrorType.IMAGE_READ_FAILED) } @@ -63,122 +57,51 @@ class ProfileEditFragment : } } - override fun getViewBinding(view: View): FragmentProfileEditBinding { - return FragmentProfileEditBinding.bind(view) - } - - override fun setupViews() { - binding.root.applyEdgeToEdgeInsets() - binding.baseToolbar.apply { - setTitle(getString(R.string.profile_edit)) - showBackButton(true) { findNavController().navigateUp() } - } - initListeners() - } - - private fun initListeners() { - binding.nicknameEt.doAfterTextChanged { - viewModel.updateNickname(it.toString()) - } - - binding.duplicateCheckBtn.setOnClickListener { - viewModel.checkIsNicknameDuplicated() - } - - binding.introductionEt.doAfterTextChanged { - viewModel.updateIntroduction(it.toString()) - } - - binding.genderMaleLl.setOnClickListener { - viewModel.selectGender(MALE) - } - - binding.genderFemaleLl.setOnClickListener { - viewModel.selectGender(FEMALE) - } - - binding.genderHiddenLl.setOnClickListener { - viewModel.selectGender(HIDDEN) - } - - binding.profileEditCompleteBtn.setOnClickListener { - viewModel.completeEditProfile() - } - - binding.profileImgIv.setOnClickListener { - pickImageLauncher.launch("image/*") - } - } - - override fun setupObservers() { - viewModel.initProfile.observe(viewLifecycleOwner) { profile -> - binding.nicknameEt.setText(profile.nickname) - viewModel.selectGender(profile.gender) - binding.introductionEt.setText(profile.bio) - setupProfileImage(profile.profileImageUrl) - } - - viewModel.isNicknameChecked.observe(viewLifecycleOwner) { isChecked -> - binding.duplicateCheckBtn.isEnabled = !isChecked - } - - viewModel.gender.observe(viewLifecycleOwner) { gender -> - binding.maleBtnImv.isSelected = gender == MALE - binding.femaleBtnImv.isSelected = gender == FEMALE - binding.hiddenBtnImv.isSelected = gender == HIDDEN - } - - viewModel.newProfileImageUri.observe(viewLifecycleOwner) { uri -> - uri?.let { - setupProfileImage(it.toString()) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val gender by viewModel.gender.collectAsStateWithLifecycle() + val introduction by viewModel.introduction.collectAsStateWithLifecycle() + val initProfile by viewModel.initProfile.collectAsStateWithLifecycle() + val newProfileImageUri by viewModel.newProfileImageUri.collectAsStateWithLifecycle() + val nicknameValidationState by viewModel.nicknameValidationState.collectAsStateWithLifecycle() + val isNicknameChecked by viewModel.isNicknameChecked.collectAsStateWithLifecycle() + + ProfileEditScreen( + nickname = nickname, + gender = gender, + introduction = introduction, + initProfileImageUrl = initProfile?.profileImageUrl, + newProfileImageUri = newProfileImageUri, + nicknameValidationState = nicknameValidationState, + isNicknameChecked = isNicknameChecked, + onNicknameChange = viewModel::updateNickname, + onIntroductionChange = viewModel::updateIntroduction, + onGenderSelect = viewModel::selectGender, + onProfileImageClick = { pickImageLauncher.launch("image/*") }, + onDuplicateCheckClick = viewModel::checkIsNicknameDuplicated, + onCompleteClick = viewModel::completeEditProfile, + onBackClick = { findNavController().navigateUp() }, + ) } } - viewModel.nicknameValidationState.observe(viewLifecycleOwner) { state -> - binding.duplicateMessageTv.visibility = - if (state == NicknameValidationState.INVISIBLE) View.GONE else View.VISIBLE - - when (state) { - NicknameValidationState.INVISIBLE -> {} - NicknameValidationState.INVALID -> { - binding.duplicateMessageTv.text = getString(R.string.error_nickname_invalid) - binding.duplicateMessageTv.setTextColor( - getColor( - requireContext(), - com.project200.undabang.presentation.R.color.error_red, - ), - ) - } - - NicknameValidationState.DUPLICATED -> { - binding.duplicateMessageTv.text = getString(R.string.error_nickname_duplicated) - binding.duplicateMessageTv.setTextColor( - getColor( - requireContext(), - com.project200.undabang.presentation.R.color.error_red, - ), - ) - } - - NicknameValidationState.AVAILABLE -> { - binding.duplicateMessageTv.text = getString(R.string.usable_nickname) - binding.duplicateMessageTv.setTextColor( - getColor( - requireContext(), - com.project200.undabang.presentation.R.color.main, - ), - ) - } - null -> return@observe - } - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { - // UI가 STARTED 상태일 때만 수집하고, STOPPED 상태가 되면 중단 repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.errorType.collect { type -> - val str = + val msg = when (type) { ProfileEditErrorType.LOAD_FAILED -> getString(R.string.error_failed_to_load) ProfileEditErrorType.SAME_AS_ORIGINAL -> getString(R.string.same_nickname) @@ -192,8 +115,7 @@ class ProfileEditFragment : ) ProfileEditErrorType.IMAGE_READ_FAILED -> getString(R.string.image_error_file_read) } - Toast.makeText(requireContext(), str, Toast.LENGTH_SHORT) - .show() + Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show() } } @@ -212,14 +134,6 @@ class ProfileEditFragment : } } - private fun setupProfileImage(imageUrl: String?) { - Glide.with(binding.profileImgIv) - .load(imageUrl) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .into(binding.profileImgIv) - } - companion object { const val MALE = "MALE" const val FEMALE = "FEMALE" diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt new file mode 100644 index 00000000..8772c6f3 --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt @@ -0,0 +1,396 @@ +package com.project200.undabang.profile.mypage + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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 coil.compose.AsyncImage +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.input.TextFieldVariant +import com.project200.presentation.compose.components.input.UndabangTextField +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorErrorRed +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.contentRegular +import com.project200.presentation.compose.theme.subtext14 +import com.project200.undabang.feature.profile.R +import com.project200.undabang.profile.mypage.ProfileEditFragment.Companion.FEMALE +import com.project200.undabang.profile.mypage.ProfileEditFragment.Companion.HIDDEN +import com.project200.undabang.profile.mypage.ProfileEditFragment.Companion.MALE +import com.project200.undabang.profile.utils.NicknameValidationState +import com.project200.undabang.presentation.R as PresentationR + +@Composable +fun ProfileEditScreen( + nickname: String, + gender: String?, + introduction: String, + initProfileImageUrl: String?, + newProfileImageUri: Uri?, + nicknameValidationState: NicknameValidationState, + isNicknameChecked: Boolean, + onNicknameChange: (String) -> Unit, + onIntroductionChange: (String) -> Unit, + onGenderSelect: (String) -> Unit, + onProfileImageClick: () -> Unit, + onDuplicateCheckClick: () -> Unit, + onCompleteClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite300), + ) { + UndabangTopBar( + title = stringResource(R.string.profile_edit), + onNavigationClick = onBackClick, + actions = { + PrimaryButton( + text = stringResource(PresentationR.string.complete), + onClick = onCompleteClick, + isCompact = true, + modifier = Modifier.padding(end = 20.dp), + ) + }, + ) + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Spacer(Modifier.height(20.dp)) + + ProfileImageSection( + initImageUrl = initProfileImageUrl, + newImageUri = newProfileImageUri, + onClick = onProfileImageClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Spacer(Modifier.height(90.dp)) + + NicknameSection( + nickname = nickname, + validationState = nicknameValidationState, + isChecked = isNicknameChecked, + onNicknameChange = onNicknameChange, + onDuplicateCheckClick = onDuplicateCheckClick, + ) + + Spacer(Modifier.height(32.dp)) + + GenderSection( + gender = gender, + onSelect = onGenderSelect, + ) + + Spacer(Modifier.height(32.dp)) + + IntroductionSection( + introduction = introduction, + onChange = onIntroductionChange, + ) + + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun ProfileImageSection( + initImageUrl: String?, + newImageUri: Uri?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .size(120.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + ) { + AsyncImage( + model = newImageUri ?: initImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = painterResource(PresentationR.drawable.ic_profile_default), + error = painterResource(PresentationR.drawable.ic_profile_default), + fallback = painterResource(PresentationR.drawable.ic_profile_default), + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), + ) + Icon( + painter = painterResource(R.drawable.ic_add_profile_img_btn), + contentDescription = null, + tint = Color.Unspecified, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp) + .size(40.dp), + ) + } +} + +@Composable +private fun NicknameSection( + nickname: String, + validationState: NicknameValidationState, + isChecked: Boolean, + onNicknameChange: (String) -> Unit, + onDuplicateCheckClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val labelText = stringResource(R.string.nickname) + val labelStyle = MaterialTheme.typography.contentBold + + Column(modifier = modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = labelText, style = labelStyle, color = ColorBlack) + Spacer(Modifier.width(20.dp)) + UndabangTextField( + value = nickname, + onValueChange = onNicknameChange, + modifier = Modifier.weight(1f), + variant = TextFieldVariant.Underline, + maxLength = 30, + ) + Spacer(Modifier.width(8.dp)) + DuplicateCheckButton( + onClick = onDuplicateCheckClick, + enabled = !isChecked && nickname.isNotBlank(), + ) + } + Spacer(Modifier.height(4.dp)) + Row { + // 라벨 영역 reserve (invisible) — 안내문이 TextField 좌측 정렬과 맞춰지게 + Text(text = labelText, style = labelStyle, color = Color.Transparent) + Spacer(Modifier.width(20.dp)) + Column(modifier = Modifier.weight(1f)) { + if (validationState != NicknameValidationState.INVISIBLE) { + val (message, color) = + when (validationState) { + NicknameValidationState.INVALID -> + stringResource(R.string.error_nickname_invalid) to ColorErrorRed + NicknameValidationState.DUPLICATED -> + stringResource(R.string.error_nickname_duplicated) to ColorErrorRed + NicknameValidationState.AVAILABLE -> + stringResource(R.string.usable_nickname) to ColorMain + NicknameValidationState.INVISIBLE -> "" to ColorBlack + } + Text( + text = message, + style = MaterialTheme.typography.subtext14, + color = color, + ) + } + Text( + text = stringResource(R.string.profile_edit_nickname_rule), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + ) + } + } + } +} + +@Composable +private fun GenderSection( + gender: String?, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(PresentationR.string.gender), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Spacer(Modifier.width(20.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GenderRow( + label = stringResource(R.string.profile_edit_male), + selected = gender == MALE, + onClick = { onSelect(MALE) }, + ) + GenderRow( + label = stringResource(R.string.profile_edit_female), + selected = gender == FEMALE, + onClick = { onSelect(FEMALE) }, + ) + GenderRow( + label = stringResource(R.string.profile_edit_hidden), + selected = gender == HIDDEN, + onClick = { onSelect(HIDDEN) }, + ) + } + } +} + +@Composable +private fun GenderRow( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + Spacer(Modifier.width(4.dp)) + Image( + painter = + painterResource( + if (selected) R.drawable.ic_check else R.drawable.ic_uncheck, + ), + contentDescription = null, + ) + } +} + +@Composable +private fun DuplicateCheckButton( + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val color = if (enabled) ColorMain else ColorGray200 + Box( + modifier = + modifier + .clip(RoundedCornerShape(5.dp)) + .background(ColorWhite300) + .border(1.dp, color, RoundedCornerShape(5.dp)) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.profile_edit_duplicate_check), + style = MaterialTheme.typography.contentRegular, + color = color, + ) + } +} + +@Composable +private fun IntroductionSection( + introduction: String, + onChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.profile_edit_introduction), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Spacer(Modifier.height(8.dp)) + UndabangTextField( + value = introduction, + onValueChange = onChange, + hint = stringResource(R.string.empty_introduction), + variant = TextFieldVariant.FilledLarge, + singleLine = false, + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 120.dp), + ) + } +} + +@Preview(showBackground = true, heightDp = 900) +@Composable +private fun ProfileEditScreenPreview() { + AppTheme { + var nickname by remember { mutableStateOf("운다방테스터") } + var gender by remember { mutableStateOf(MALE) } + var introduction by remember { mutableStateOf("안녕하세요") } + ProfileEditScreen( + nickname = nickname, + gender = gender, + introduction = introduction, + initProfileImageUrl = null, + newProfileImageUri = null, + nicknameValidationState = NicknameValidationState.AVAILABLE, + isNicknameChecked = true, + onNicknameChange = { nickname = it }, + onIntroductionChange = { introduction = it }, + onGenderSelect = { gender = it }, + onProfileImageClick = {}, + onDuplicateCheckClick = {}, + onCompleteClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt index 53a9218c..460d5b9f 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt @@ -1,8 +1,6 @@ package com.project200.undabang.profile.mypage import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -16,7 +14,10 @@ import com.project200.undabang.profile.utils.NicknameValidationState import com.project200.undabang.profile.utils.ProfileEditErrorType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -30,27 +31,29 @@ class ProfileEditViewModel private val editProfileUseCase: EditProfileUseCase, private val addProfileImageUseCase: AddProfileImageUseCase, ) : ViewModel() { - private val _initProfile = MutableLiveData() - val initProfile: LiveData = _initProfile + private val _initProfile = MutableStateFlow(null) + val initProfile: StateFlow = _initProfile.asStateFlow() - private val nickname = MutableLiveData("") + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() - private val _gender = MutableLiveData() - val gender: LiveData = _gender + private val _gender = MutableStateFlow(null) + val gender: StateFlow = _gender.asStateFlow() - private val introduction = MutableLiveData("") + private val _introduction = MutableStateFlow("") + val introduction: StateFlow = _introduction.asStateFlow() - private val _newProfileImageUri = MutableLiveData() - val newProfileImageUri: LiveData get() = _newProfileImageUri + private val _newProfileImageUri = MutableStateFlow(null) + val newProfileImageUri: StateFlow = _newProfileImageUri.asStateFlow() - // 닉네임 유효성 검사 결과를 UI에 전달하기 위한 LiveData + // 닉네임 유효성 검사 결과를 UI에 전달 private val _nicknameValidationState = - MutableLiveData(NicknameValidationState.INVISIBLE) - val nicknameValidationState: LiveData = _nicknameValidationState + MutableStateFlow(NicknameValidationState.INVISIBLE) + val nicknameValidationState: StateFlow = _nicknameValidationState.asStateFlow() // 중복 확인 버튼 활성화 여부 및 중복 체크 완료 상태를 관리 - private val _isNicknameChecked = MutableLiveData(false) - val isNicknameChecked: LiveData = _isNicknameChecked + private val _isNicknameChecked = MutableStateFlow(false) + val isNicknameChecked: StateFlow = _isNicknameChecked.asStateFlow() private val _errorType = MutableSharedFlow() val errorType: SharedFlow = _errorType @@ -66,7 +69,11 @@ class ProfileEditViewModel viewModelScope.launch { when (val result = getUserProfileUseCase()) { is BaseResult.Success -> { - _initProfile.value = result.data + val profile = result.data + _initProfile.value = profile + _nickname.value = profile.nickname + _gender.value = profile.gender + _introduction.value = profile.bio.orEmpty() } is BaseResult.Error -> { @@ -77,15 +84,15 @@ class ProfileEditViewModel } fun updateNickname(value: String) { - if (nickname.value != value) { - nickname.value = value + if (_nickname.value != value) { + _nickname.value = value _isNicknameChecked.value = false _nicknameValidationState.value = NicknameValidationState.INVISIBLE } } fun updateIntroduction(value: String) { - introduction.value = value + _introduction.value = value } fun updateProfileImageUri(uri: Uri?) { @@ -98,9 +105,9 @@ class ProfileEditViewModel fun completeEditProfile() { viewModelScope.launch { - val currentNickname = nickname.value ?: return@launch + val currentNickname = _nickname.value val currentGender = _gender.value ?: return@launch - val currentIntroduction = introduction.value.orEmpty() + val currentIntroduction = _introduction.value val isProfileChanged = checkProfileChanged(currentNickname, currentGender, currentIntroduction) @@ -112,7 +119,7 @@ class ProfileEditViewModel } // 중복 확인이 됐는지 체크 - if (_initProfile.value?.nickname != currentNickname && _isNicknameChecked.value == false) { + if (_initProfile.value?.nickname != currentNickname && !_isNicknameChecked.value) { _errorType.emit(ProfileEditErrorType.NO_DUPLICATE_CHECKED) return@launch } @@ -123,9 +130,7 @@ class ProfileEditViewModel if (_newProfileImageUri.value != null) { addProfileImageUseCase(_newProfileImageUri.value.toString()) } else { - BaseResult.Success( - Unit, - ) + BaseResult.Success(Unit) } val editProfileResult = if (isProfileChanged) { @@ -143,7 +148,7 @@ class ProfileEditViewModel } fun checkIsNicknameDuplicated() { - val currentNickname = nickname.value.orEmpty() + val currentNickname = _nickname.value // 기존 닉네임과 동일한지 체크 if (currentNickname == _initProfile.value?.nickname) { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt deleted file mode 100644 index 906000b9..00000000 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageAdapter.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.project200.undabang.profile.mypage - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.project200.domain.model.ProfileImage -import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.ItemProfileImageBinding - -class ProfileImageAdapter : ListAdapter(ImageDiffCallback) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ImageViewHolder { - val binding = ItemProfileImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ImageViewHolder(binding) - } - - override fun onBindViewHolder( - holder: ImageViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - class ImageViewHolder(private val binding: ItemProfileImageBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(profileImage: ProfileImage) { - Glide.with(binding.root.context) - .load(profileImage.url) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .into(binding.profileImageIv) - } - } - - companion object { - private val ImageDiffCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ProfileImage, - newItem: ProfileImage, - ): Boolean { - // 각 아이템의 고유 ID로 비교 - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: ProfileImage, - newItem: ProfileImage, - ): Boolean { - // 아이템의 내용(데이터)이 같은지 비교 - return oldItem == newItem - } - } - } -} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailFragment.kt index faf59f4d..98cb0ebc 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailFragment.kt @@ -1,80 +1,68 @@ package com.project200.undabang.profile.mypage +import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.PopupMenu +import android.view.ViewGroup import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import androidx.viewpager2.widget.ViewPager2 import com.project200.domain.model.BaseResult -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentProfileImageDetailBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import kotlin.getValue @AndroidEntryPoint -class ProfileImageDetailFragment : BindingFragment (R.layout.fragment_profile_image_detail) { +class ProfileImageDetailFragment : Fragment() { private val viewModel: ProfileImageDetailViewModel by viewModels() - private lateinit var profileImageAdapter: ProfileImageAdapter - override fun getViewBinding(view: View): FragmentProfileImageDetailBinding { - return FragmentProfileImageDetailBinding.bind(view) - } - - override fun setupViews() { - profileImageAdapter = ProfileImageAdapter() - binding.profileImageVp.adapter = profileImageAdapter - - initListeners() - } - - private fun initListeners() { - binding.profileImageVp.registerOnPageChangeCallback( - object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - val currentPage = position + 1 - binding.currentPageTv.text = currentPage.toString() - } - }, - ) - binding.backBtn.setOnClickListener { - findNavController().previousBackStackEntry?.savedStateHandle?.set(MypageFragment.REFRESH_KEY, true) - findNavController().popBackStack() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val images by viewModel.profileImages.collectAsStateWithLifecycle() + ProfileImageDetailScreen( + images = images, + onBackClick = { popBackWithRefresh() }, + onChangeThumbnail = { id -> viewModel.changeThumbnail(id) }, + onSaveImage = { image -> + if (image.id != ProfileImageDetailViewModel.EMPTY_ID) { + viewModel.saveImageToGallery(image.url) + } else { + Toast.makeText(requireContext(), R.string.cannot_save_default_image, Toast.LENGTH_SHORT).show() + } + }, + onDeleteImage = { id -> viewModel.deleteImage(id) }, + ) + } } - binding.menuBtn.setOnClickListener { - showPopupMenu() - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - findNavController().previousBackStackEntry?.savedStateHandle?.set(MypageFragment.REFRESH_KEY, true) - findNavController().popBackStack() + popBackWithRefresh() } }, ) - } - - override fun setupObservers() { - viewModel.profileImages.observe(viewLifecycleOwner) { images -> - profileImageAdapter.submitList(images) - binding.imageMaxTv.text = images.size.toString() - - // ViewPager2의 현재 아이템이 0이 아니면서 리스트가 갱신될 경우를 대비 (예: 이미지 삭제 후) - val currentItem = binding.profileImageVp.currentItem - binding.currentPageTv.text = getString(R.string.number_format, currentItem + 1) - - binding.menuBtn.visibility = if (images[0].id == EMPTY_ID) View.GONE else View.VISIBLE - } viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -120,45 +108,8 @@ class ProfileImageDetailFragment : BindingFragment - if (profileImageAdapter.currentList.isEmpty()) { - return@setOnMenuItemClickListener false - } - // 현재 보고 있는 이미지 정보를 가져옵니다. - val currentImage = profileImageAdapter.currentList[currentPosition] - - when (item.itemId) { - R.id.item_change_thumbnail -> { - viewModel.changeThumbnail(currentImage.id) - true - } - R.id.item_save_image -> { - // 빈 상태 이미지는 저장하지 않도록 URL을 확인합니다. - if (currentImage.id != EMPTY_ID) { - viewModel.saveImageToGallery(currentImage.url) - } else { - Toast.makeText(requireContext(), R.string.cannot_save_default_image, Toast.LENGTH_SHORT).show() - } - true - } - R.id.item_delete_record -> { - viewModel.deleteImage(currentImage.id) - true - } - else -> false - } - } - popup.show() - } - - companion object { - const val EMPTY_ID = -1L + private fun popBackWithRefresh() { + findNavController().previousBackStackEntry?.savedStateHandle?.set(MypageFragment.REFRESH_KEY, true) + findNavController().popBackStack() } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailScreen.kt new file mode 100644 index 00000000..0a2a30db --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailScreen.kt @@ -0,0 +1,233 @@ +package com.project200.undabang.profile.mypage + +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.project200.domain.model.ProfileImage +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.profile.R +import com.project200.undabang.presentation.R as PresentationR + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ProfileImageDetailScreen( + images: List, + onBackClick: () -> Unit, + onChangeThumbnail: (Long) -> Unit, + onSaveImage: (ProfileImage) -> Unit, + onDeleteImage: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + if (images.isEmpty()) return + + val pagerState = rememberPagerState(pageCount = { images.size }) + val currentImage = images.getOrNull(pagerState.currentPage) ?: return + val showMenu = currentImage.id != ProfileImageDetailViewModel.EMPTY_ID + + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorBlack), + ) { + ProfileImageDetailToolbar( + currentPage = pagerState.currentPage + 1, + totalCount = images.size, + showMenu = showMenu, + isThumbnailVisible = pagerState.currentPage != 0, + onBackClick = onBackClick, + onChangeThumbnailClick = { onChangeThumbnail(currentImage.id) }, + onSaveClick = { onSaveImage(currentImage) }, + onDeleteClick = { onDeleteImage(currentImage.id) }, + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val image = images[page] + AsyncImage( + model = image.url, + contentDescription = null, + contentScale = ContentScale.Fit, + error = painterResource(PresentationR.drawable.ic_profile_default), + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun ProfileImageDetailToolbar( + currentPage: Int, + totalCount: Int, + showMenu: Boolean, + isThumbnailVisible: Boolean, + onBackClick: () -> Unit, + onChangeThumbnailClick: () -> Unit, + onSaveClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(PresentationR.drawable.ic_arrow_back), + contentDescription = null, + tint = ColorWhite300, + ) + } + + Row( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = currentPage.toString(), + style = MaterialTheme.typography.contentRegular, + color = ColorWhite300, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.profile_image_detail_divider), + color = ColorGray200, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Text( + text = totalCount.toString(), + style = MaterialTheme.typography.contentRegular, + color = ColorGray200, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f), + ) + } + + Box { + var menuExpanded by remember { mutableStateOf(false) } + if (showMenu) { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(PresentationR.drawable.ic_menu), + contentDescription = null, + tint = ColorWhite300, + ) + } + } else { + IconButton(onClick = {}, enabled = false) { + Icon( + painter = painterResource(PresentationR.drawable.ic_menu), + contentDescription = null, + tint = Color.Transparent, + ) + } + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + if (isThumbnailVisible) { + DropdownMenuItem( + text = { Text(stringResource(R.string.change_thumbnail)) }, + onClick = { + menuExpanded = false + onChangeThumbnailClick() + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.save_profile_image)) }, + onClick = { + menuExpanded = false + onSaveClick() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(PresentationR.string.delete)) }, + onClick = { + menuExpanded = false + onDeleteClick() + }, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ProfileImageDetailScreenPreview() { + AppTheme { + ProfileImageDetailScreen( + images = + listOf( + ProfileImage(id = 1L, url = ""), + ProfileImage(id = 2L, url = ""), + ProfileImage(id = 3L, url = ""), + ), + onBackClick = {}, + onChangeThumbnail = {}, + onSaveImage = {}, + onDeleteImage = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ProfileImageDetailScreenEmptyPreview() { + AppTheme { + ProfileImageDetailScreen( + images = listOf(ProfileImage(id = ProfileImageDetailViewModel.EMPTY_ID, url = "")), + onBackClick = {}, + onChangeThumbnail = {}, + onSaveImage = {}, + onDeleteImage = {}, + ) + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailViewModel.kt index 692dd8b6..f5d805aa 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileImageDetailViewModel.kt @@ -4,8 +4,6 @@ import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.provider.MediaStore -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bumptech.glide.Glide @@ -19,7 +17,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject @@ -33,8 +34,8 @@ class ProfileImageDetailViewModel @ApplicationContext private val context: Context, private val changeThumbnailUseCase: ChangeThumbnailUseCase, ) : ViewModel() { - private val _profileImages = MutableLiveData>() - val profileImages: LiveData> = _profileImages + private val _profileImages = MutableStateFlow>(emptyList()) + val profileImages: StateFlow> = _profileImages.asStateFlow() private val _getProfileImageErrorToast = MutableSharedFlow() val getProfileImageErrorToast: SharedFlow = _getProfileImageErrorToast @@ -84,7 +85,6 @@ class ProfileImageDetailViewModel /** * URL로부터 이미지를 다운로드하여 갤러리에 저장합니다. - * @param context ContentResolver를 사용하기 위해 필요합니다. * @param imageUrl 저장할 이미지의 URL */ fun saveImageToGallery(imageUrl: String) { @@ -142,7 +142,7 @@ class ProfileImageDetailViewModel val result = deleteProfileImageUseCase(pictureId) if (result is BaseResult.Success) { - val currentList = _profileImages.value ?: return@launch + val currentList = _profileImages.value var updatedList = currentList.filter { it.id != pictureId } @@ -158,7 +158,7 @@ class ProfileImageDetailViewModel fun changeThumbnail(pictureId: Long) { viewModelScope.launch { - val currentList = _profileImages.value ?: return@launch + val currentList = _profileImages.value val result = changeThumbnailUseCase(pictureId) if (result is BaseResult.Success) { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseDetailFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseDetailFragment.kt deleted file mode 100644 index 6b7ecf4d..00000000 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseDetailFragment.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.project200.undabang.profile.mypage.preferredExercise - -import android.view.View -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.SimpleItemAnimator -import com.project200.common.utils.PreferredExerciseDayFormatter -import com.project200.presentation.base.BindingFragment -import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentPreferredExerciseDetailBinding -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class PreferredExerciseDetailFragment : BindingFragment( - R.layout.fragment_preferred_exercise_detail, -) { - private val viewModel: PreferredExerciseViewModel by viewModels({ requireParentFragment() }) - private lateinit var detailAdapter: PreferredExerciseDetailRVAdapter - - @Inject - lateinit var formatter: PreferredExerciseDayFormatter - - override fun getViewBinding(view: View): FragmentPreferredExerciseDetailBinding { - return FragmentPreferredExerciseDetailBinding.bind(view) - } - - override fun setupViews() { - detailAdapter = PreferredExerciseDetailRVAdapter(viewModel, formatter) - binding.exerciseRv.apply { - adapter = detailAdapter - layoutManager = LinearLayoutManager(requireContext()) - (itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false - } - } - - override fun setupObservers() { - viewModel.selectedExerciseUiModels.observe(viewLifecycleOwner) { uiModels -> - detailAdapter.submitList(uiModels) - } - } -} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseDetailRVAdapter.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseDetailRVAdapter.kt deleted file mode 100644 index 25dbb2df..00000000 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseDetailRVAdapter.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.project200.undabang.profile.mypage.preferredExercise - -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.project200.common.utils.PreferredExerciseDayFormatter -import com.project200.presentation.utils.SkillLevel -import com.project200.undabang.feature.profile.databinding.ItemPreferredExerciseDetailBinding -import com.project200.undabang.profile.utils.PreferredExerciseUiModel - -class PreferredExerciseDetailRVAdapter( - private val viewModel: PreferredExerciseViewModel, - private val formatter: PreferredExerciseDayFormatter, -) : ListAdapter(ExerciseDiffCallback()) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): DetailViewHolder { - val binding = ItemPreferredExerciseDetailBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return DetailViewHolder(binding) - } - - override fun onBindViewHolder( - holder: DetailViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - inner class DetailViewHolder(private val binding: ItemPreferredExerciseDetailBinding) : RecyclerView.ViewHolder(binding.root) { - private val dayButtons: List = - with(binding) { listOf(btnMon, btnTue, btnWed, btnThu, btnFri, btnSat, btnSun) } - private val skillButtons: Map = - mapOf( - SkillLevel.BEGINNER to binding.btnBeginner, - SkillLevel.ROOKIE to binding.btnRookie, - SkillLevel.INTERMEDIATE to binding.btnIntermediate, - SkillLevel.ADVANCED to binding.btnAdvanced, - SkillLevel.SKILLED to binding.btnSkilled, - SkillLevel.PRO to binding.btnProfessional, - ) - - fun bind(uiModel: PreferredExerciseUiModel) { - binding.exerciseTypeTv.text = uiModel.exercise.name - Glide.with(itemView.context) - .load(uiModel.exercise.imageUrl) - .into(binding.exerciseTypeIv) - - updateUi(uiModel) - - dayButtons.forEachIndexed { index, button -> - button.setOnClickListener { - viewModel.updateDaySelection(uiModel.exercise.exerciseTypeId, index) - } - } - - skillButtons.forEach { (skill, button) -> - button.setOnClickListener { - viewModel.updateSkillLevel(uiModel.exercise.exerciseTypeId, skill) - } - } - } - - private fun updateUi(uiModel: PreferredExerciseUiModel) { - binding.exerciseInfoTv.text = uiModel.getExerciseInfo(binding.root.context, formatter) - - // 요일 버튼 상태 업데이트 - dayButtons.forEachIndexed { index, button -> - button.isSelected = uiModel.selectedDays[index] - } - - // 숙련도 버튼 상태 업데이트 - skillButtons.forEach { (skill, button) -> - button.isSelected = (uiModel.skillLevel == skill) - } - } - } - - class ExerciseDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: PreferredExerciseUiModel, - newItem: PreferredExerciseUiModel, - ): Boolean { - return oldItem.exercise.exerciseTypeId == newItem.exercise.exerciseTypeId - } - - override fun areContentsTheSame( - oldItem: PreferredExerciseUiModel, - newItem: PreferredExerciseUiModel, - ): Boolean { - return oldItem == newItem - } - } -} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt index 752f0038..22a69cf1 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt @@ -1,130 +1,130 @@ package com.project200.undabang.profile.mypage.preferredExercise +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast -import androidx.core.view.isVisible +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import com.project200.presentation.base.BindingFragment +import com.project200.common.utils.PreferredExerciseDayFormatter +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentPreferredExerciseBinding import com.project200.undabang.profile.mypage.MypageFragment import com.project200.undabang.profile.utils.CompletionState import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint -class PreferredExerciseFragment : - BindingFragment(R.layout.fragment_preferred_exercise) { +class PreferredExerciseFragment : Fragment() { private val viewModel: PreferredExerciseViewModel by viewModels() - private val args: PreferredExerciseFragmentArgs by navArgs() - override fun getViewBinding(view: View): FragmentPreferredExerciseBinding { - return FragmentPreferredExerciseBinding.bind(view) - } + @Inject + lateinit var formatter: PreferredExerciseDayFormatter - override fun setupViews() { - binding.baseToolbar.setTitle(getString(R.string.preferred_exercise)) - initClickListener() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { viewModel.initNickname(args.nickname) - if (childFragmentManager.findFragmentById(R.id.preferred_exercise_container) == null) { - replaceFragment(PreferredExerciseTypeFragment()) - } + return ComposeView(requireContext()).apply { + applyAppTheme { + var step by rememberSaveable { mutableStateOf(PreferredExerciseStep.TYPE) } - childFragmentManager.addOnBackStackChangedListener { - updateCompleteButtonState() - } - } + val uiModels by viewModel.exerciseUiModels.collectAsStateWithLifecycle() + val selectedUiModels by viewModel.selectedExerciseUiModels.collectAsStateWithLifecycle() + val completionState by viewModel.completionState.collectAsStateWithLifecycle() - override fun setupObservers() { - viewModel.completionState.observe(viewLifecycleOwner) { state -> - binding.loadingLayout.isVisible = (state is CompletionState.Loading) - when (state) { - is CompletionState.Loading -> { - binding.completeBtn.isEnabled = false - } - is CompletionState.Success -> { - Toast.makeText(requireContext(), R.string.preferred_exercise_success, Toast.LENGTH_SHORT).show() - findNavController().previousBackStackEntry?.savedStateHandle?.set(MypageFragment.REFRESH_KEY, true) - findNavController().popBackStack() - viewModel.consumeCompletionState() + BackHandler(enabled = step == PreferredExerciseStep.DETAIL) { + step = PreferredExerciseStep.TYPE } - is CompletionState.NoChanges -> { - Toast.makeText(requireContext(), R.string.preferred_exercise_no_changed, Toast.LENGTH_SHORT).show() - binding.completeBtn.isEnabled = true - viewModel.consumeCompletionState() - } - is CompletionState.NoneSelected -> { - Toast.makeText(requireContext(), R.string.preferred_exercise_none_selected, Toast.LENGTH_SHORT).show() - binding.completeBtn.isEnabled = true - viewModel.consumeCompletionState() - } - is CompletionState.IncompleteSelection -> { - Toast.makeText(requireContext(), R.string.preferred_exercise_incomplete_seleted, Toast.LENGTH_SHORT).show() - binding.completeBtn.isEnabled = true - viewModel.consumeCompletionState() - } - is CompletionState.Error -> { - Toast.makeText(requireContext(), state.message, Toast.LENGTH_SHORT).show() - binding.completeBtn.isEnabled = true - viewModel.consumeCompletionState() - } - is CompletionState.Idle -> { - binding.completeBtn.isEnabled = true - } - } - } - } - - private fun initClickListener() { - binding.baseToolbar.showBackButton(true, onClick = { - if (childFragmentManager.backStackEntryCount > 0) { - childFragmentManager.popBackStack() - } else { - findNavController().popBackStack() - } - }) - binding.completeBtn.setOnClickListener { - when (childFragmentManager.findFragmentById(R.id.preferred_exercise_container)) { - is PreferredExerciseTypeFragment -> { - replaceFragment(PreferredExerciseDetailFragment(), addToBackStack = true) - } - is PreferredExerciseDetailFragment -> { - viewModel.completePreferredExerciseChanges() - } + PreferredExerciseScreen( + step = step, + nickname = viewModel.nickname, + uiModels = uiModels, + selectedUiModels = selectedUiModels, + isLoading = completionState is CompletionState.Loading, + formatter = formatter, + onBackClick = { + if (step == PreferredExerciseStep.DETAIL) { + step = PreferredExerciseStep.TYPE + } else { + findNavController().popBackStack() + } + }, + onNextOrCompleteClick = { + when (step) { + PreferredExerciseStep.TYPE -> step = PreferredExerciseStep.DETAIL + PreferredExerciseStep.DETAIL -> viewModel.completePreferredExerciseChanges() + } + }, + onTypeClick = viewModel::updateSelectedExercise, + onTypeLimitReached = { + Toast.makeText( + requireContext(), + getString(R.string.preferred_exercise_type_max_error), + Toast.LENGTH_SHORT, + ).show() + }, + onDayClick = viewModel::updateDaySelection, + onSkillClick = viewModel::updateSkillLevel, + ) } } } - private fun updateCompleteButtonState() { - binding.completeBtn.text = - when (childFragmentManager.findFragmentById(R.id.preferred_exercise_container)) { - is PreferredExerciseTypeFragment -> getString(com.project200.undabang.presentation.R.string.next) - is PreferredExerciseDetailFragment -> getString(com.project200.undabang.presentation.R.string.complete) - else -> getString(com.project200.undabang.presentation.R.string.complete) - } - } - - /** - * FrameLayout의 프래그먼트를 교체하는 함수 - * @param fragment 교체할 프래그먼트 - * @param addToBackStack 뒤로가기 스택에 추가할지 여부 - */ - private fun replaceFragment( - fragment: Fragment, - addToBackStack: Boolean = false, + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, ) { - val transaction = - childFragmentManager.beginTransaction() - .replace(R.id.preferred_exercise_container, fragment) + super.onViewCreated(view, savedInstanceState) - if (addToBackStack) { - transaction.addToBackStack(null) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.completionState.collect { state -> + when (state) { + is CompletionState.Success -> { + Toast.makeText(requireContext(), R.string.preferred_exercise_success, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set(MypageFragment.REFRESH_KEY, true) + findNavController().popBackStack() + viewModel.consumeCompletionState() + } + is CompletionState.NoChanges -> { + Toast.makeText(requireContext(), R.string.preferred_exercise_no_changed, Toast.LENGTH_SHORT).show() + viewModel.consumeCompletionState() + } + is CompletionState.NoneSelected -> { + Toast.makeText(requireContext(), R.string.preferred_exercise_none_selected, Toast.LENGTH_SHORT).show() + viewModel.consumeCompletionState() + } + is CompletionState.IncompleteSelection -> { + Toast.makeText(requireContext(), R.string.preferred_exercise_incomplete_seleted, Toast.LENGTH_SHORT).show() + viewModel.consumeCompletionState() + } + is CompletionState.Error -> { + Toast.makeText(requireContext(), state.message, Toast.LENGTH_SHORT).show() + viewModel.consumeCompletionState() + } + is CompletionState.Loading, is CompletionState.Idle -> Unit + } + } + } } - - transaction.commit() } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt new file mode 100644 index 00000000..73a81701 --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt @@ -0,0 +1,472 @@ +package com.project200.undabang.profile.mypage.preferredExercise + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.project200.common.utils.PreferredExerciseDayFormatter +import com.project200.domain.model.PreferredExercise +import com.project200.presentation.compose.components.button.PrimaryButton +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorGray300 +import com.project200.presentation.compose.theme.ColorGrayTrans +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite100 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.header +import com.project200.presentation.compose.theme.subtext12 +import com.project200.presentation.compose.theme.subtext14 +import com.project200.presentation.utils.SkillLevel +import com.project200.undabang.feature.profile.R +import com.project200.undabang.profile.utils.PreferredExerciseUiModel +import com.project200.undabang.presentation.R as PresentationR + +enum class PreferredExerciseStep { + TYPE, + DETAIL, +} + +@Composable +fun PreferredExerciseScreen( + step: PreferredExerciseStep, + nickname: String, + uiModels: List, + selectedUiModels: List, + isLoading: Boolean, + formatter: PreferredExerciseDayFormatter, + onBackClick: () -> Unit, + onNextOrCompleteClick: () -> Unit, + onTypeClick: (PreferredExercise) -> Unit, + onTypeLimitReached: () -> Unit, + onDayClick: (exerciseTypeId: Long, dayIndex: Int) -> Unit, + onSkillClick: (exerciseTypeId: Long, skill: SkillLevel) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(if (step == PreferredExerciseStep.DETAIL) ColorWhite100 else ColorWhite300), + ) { + UndabangTopBar( + title = stringResource(R.string.preferred_exercise), + onNavigationClick = onBackClick, + actions = { + PrimaryButton( + text = + stringResource( + if (step == PreferredExerciseStep.TYPE) { + PresentationR.string.next + } else { + PresentationR.string.complete + }, + ), + onClick = onNextOrCompleteClick, + enabled = !isLoading, + isCompact = true, + modifier = Modifier.padding(end = 20.dp), + ) + }, + ) + + when (step) { + PreferredExerciseStep.TYPE -> + PreferredExerciseTypeContent( + nickname = nickname, + uiModels = uiModels, + onTypeClick = onTypeClick, + onLimitReached = onTypeLimitReached, + ) + PreferredExerciseStep.DETAIL -> + PreferredExerciseDetailContent( + items = selectedUiModels, + formatter = formatter, + onDayClick = onDayClick, + onSkillClick = onSkillClick, + ) + } + } + + if (isLoading) { + LoadingOverlay() + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PreferredExerciseTypeContent( + nickname: String, + uiModels: List, + onTypeClick: (PreferredExercise) -> Unit, + onLimitReached: () -> Unit, + modifier: Modifier = Modifier, +) { + val selectedCount = uiModels.count { it.isSelected } + + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + Spacer(Modifier.height(44.dp)) + Text( + text = stringResource(R.string.preferred_exercise_type_title, nickname), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + ) + Text( + text = stringResource(R.string.preferred_exercise_type_max_cnt), + style = MaterialTheme.typography.subtext14, + color = ColorGray200, + ) + Spacer(Modifier.height(20.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + uiModels.forEach { uiModel -> + PreferredExerciseTypeChip( + uiModel = uiModel, + onClick = { + if (!uiModel.isSelected && selectedCount >= MAX_SELECTION) { + onLimitReached() + } else { + onTypeClick(uiModel.exercise) + } + }, + ) + } + } + } +} + +@Composable +private fun PreferredExerciseTypeChip( + uiModel: PreferredExerciseUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (uiModel.isSelected) ColorMain else ColorGray300 + val textColor = if (uiModel.isSelected) ColorWhite300 else ColorBlack + + Row( + modifier = + modifier + .clip(RoundedCornerShape(50.dp)) + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (uiModel.isSelected) { + androidx.compose.foundation.Image( + painter = painterResource(R.drawable.ic_preferred_exercise_type_check), + contentDescription = null, + modifier = Modifier.size(width = 11.dp, height = 9.dp), + ) + Spacer(Modifier.width(8.dp)) + } + Text( + text = uiModel.exercise.name, + color = textColor, + fontSize = 14.sp, + style = MaterialTheme.typography.contentBold, + ) + } +} + +@Composable +private fun PreferredExerciseDetailContent( + items: List, + formatter: PreferredExerciseDayFormatter, + onDayClick: (exerciseTypeId: Long, dayIndex: Int) -> Unit, + onSkillClick: (exerciseTypeId: Long, skill: SkillLevel) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite100), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = items, key = { it.exercise.exerciseTypeId }) { uiModel -> + PreferredExerciseDetailCard( + uiModel = uiModel, + formatter = formatter, + onDayClick = { dayIndex -> onDayClick(uiModel.exercise.exerciseTypeId, dayIndex) }, + onSkillClick = { skill -> onSkillClick(uiModel.exercise.exerciseTypeId, skill) }, + ) + } + } +} + +@Composable +private fun PreferredExerciseDetailCard( + uiModel: PreferredExerciseUiModel, + formatter: PreferredExerciseDayFormatter, + onDayClick: (Int) -> Unit, + onSkillClick: (SkillLevel) -> Unit, + modifier: Modifier = Modifier, +) { + val context = androidx.compose.ui.platform.LocalContext.current + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .shadow(elevation = 2.dp, shape = RoundedCornerShape(15.dp), clip = false) + .clip(RoundedCornerShape(15.dp)) + .background(ColorWhite300) + .border(width = 1.dp, color = ColorGray300, shape = RoundedCornerShape(15.dp)) + .padding(vertical = 15.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(12.dp)) + Box( + modifier = + Modifier + .size(50.dp) + .clip(CircleShape), + ) { + AsyncImage( + model = uiModel.exercise.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Column { + Text( + text = uiModel.exercise.name, + style = MaterialTheme.typography.header, + color = ColorBlack, + ) + Text( + text = uiModel.getExerciseInfo(context, formatter), + style = MaterialTheme.typography.subtext12, + color = ColorGray200, + ) + } + } + + Spacer(Modifier.height(13.dp)) + HorizontalDivider(color = ColorGray300, thickness = 1.dp) + + Text( + text = stringResource(R.string.preferred_exercise_detail_days_title), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.padding(12.dp), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 7.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + DAY_LABEL_RES_IDS.forEachIndexed { index, resId -> + DayOrSkillButton( + text = stringResource(resId), + selected = uiModel.selectedDays[index], + onClick = { onDayClick(index) }, + modifier = Modifier.weight(1f).padding(5.dp).height(40.dp), + ) + } + } + + Text( + text = stringResource(R.string.preferred_exercise_detail_days_skill_level), + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.padding(12.dp), + ) + + Column(modifier = Modifier.padding(horizontal = 7.dp)) { + SkillLevel.entries.chunked(3).forEach { rowSkills -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + rowSkills.forEach { skill -> + DayOrSkillButton( + text = stringResource(skill.resId), + selected = uiModel.skillLevel == skill, + onClick = { onSkillClick(skill) }, + modifier = Modifier.weight(1f).padding(5.dp).height(36.dp), + ) + } + // 마지막 row가 3개 미만이면 빈 칸 채우기 + repeat(3 - rowSkills.size) { + Spacer(modifier = Modifier.weight(1f).padding(5.dp)) + } + } + } + } + } +} + +@Composable +private fun DayOrSkillButton( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (selected) ColorMain else ColorGray300 + val textColor = if (selected) ColorWhite300 else ColorBlack + + Box( + modifier = + modifier + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = textColor, + fontSize = 14.sp, + style = MaterialTheme.typography.contentBold, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun LoadingOverlay(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .fillMaxSize() + .background(ColorGrayTrans) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ColorMain) + } +} + +private const val MAX_SELECTION = 5 + +private val DAY_LABEL_RES_IDS = + intArrayOf( + PresentationR.string.mon, + PresentationR.string.tue, + PresentationR.string.wed, + PresentationR.string.thu, + PresentationR.string.fri, + PresentationR.string.sat, + PresentationR.string.sun, + ) + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun PreferredExerciseScreenTypePreview() { + AppTheme { + PreferredExerciseScreen( + step = PreferredExerciseStep.TYPE, + nickname = "운다방", + uiModels = + listOf( + sampleUiModel(1L, "축구", true), + sampleUiModel(2L, "농구", false), + sampleUiModel(3L, "테니스", true), + sampleUiModel(4L, "수영", false), + sampleUiModel(5L, "러닝", false), + ), + selectedUiModels = emptyList(), + isLoading = false, + formatter = PreferredExerciseDayFormatter(androidx.compose.ui.platform.LocalContext.current), + onBackClick = {}, + onNextOrCompleteClick = {}, + onTypeClick = {}, + onTypeLimitReached = {}, + onDayClick = { _, _ -> }, + onSkillClick = { _, _ -> }, + ) + } +} + +private fun sampleUiModel( + id: Long, + name: String, + isSelected: Boolean, +): PreferredExerciseUiModel = + PreferredExerciseUiModel( + exercise = + PreferredExercise( + preferredExerciseId = id, + exerciseTypeId = id, + name = name, + skillLevel = "", + daysOfWeek = MutableList(7) { false }, + imageUrl = null, + ), + isSelected = isSelected, + ) diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseTypeFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseTypeFragment.kt deleted file mode 100644 index 038819f0..00000000 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseTypeFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.project200.undabang.profile.mypage.preferredExercise - -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.fragment.app.viewModels -import com.google.android.flexbox.FlexDirection -import com.google.android.flexbox.FlexWrap -import com.google.android.flexbox.FlexboxLayoutManager -import com.google.android.flexbox.JustifyContent -import com.project200.presentation.base.BindingFragment -import com.project200.presentation.utils.UiUtils.dpToPx -import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentPreferredExerciseTypeBinding -import com.project200.undabang.profile.utils.FlexboxItemDecoration -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class PreferredExerciseTypeFragment : - BindingFragment(R.layout.fragment_preferred_exercise_type) { - private val viewModel: PreferredExerciseViewModel by viewModels({ requireParentFragment() }) - - private val kindsRvAdapter = - PreferredExerciseTypeRVAdapter( - onItemClicked = { exercise -> - viewModel.updateSelectedExercise(exercise) - }, - onLimitReached = { - Toast.makeText( - requireContext(), - getString(R.string.preferred_exercise_type_max_error), - Toast.LENGTH_SHORT, - ).show() - }, - ) - - override fun getViewBinding(view: View): FragmentPreferredExerciseTypeBinding { - return FragmentPreferredExerciseTypeBinding.bind(view) - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupViews() - setupObservers() - } - - override fun setupViews() { - binding.typeTitleTv.text = getString(R.string.preferred_exercise_type_title, viewModel.nickname) - setupRecyclerView() - } - - override fun setupObservers() { - viewModel.exerciseUiModels.observe(viewLifecycleOwner) { uiModels -> - kindsRvAdapter.submitList(uiModels) - } - } - - private fun setupRecyclerView() { - val flexboxLayoutManager = - FlexboxLayoutManager(requireContext()).apply { - flexDirection = FlexDirection.ROW - flexWrap = FlexWrap.WRAP - justifyContent = JustifyContent.FLEX_START - } - - binding.exerciseTypeRv.apply { - layoutManager = flexboxLayoutManager - adapter = kindsRvAdapter - if (itemDecorationCount == 0) { - addItemDecoration(FlexboxItemDecoration(dpToPx(requireContext(), 10f))) - } - } - } -} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseTypeRVAdapter.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseTypeRVAdapter.kt deleted file mode 100644 index b0870050..00000000 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseTypeRVAdapter.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.project200.undabang.profile.mypage.preferredExercise - -import android.content.res.ColorStateList -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.project200.domain.model.PreferredExercise -import com.project200.undabang.feature.profile.databinding.ItemExerciseTypeBinding -import com.project200.undabang.presentation.R -import com.project200.undabang.profile.utils.PreferredExerciseUiModel - -class PreferredExerciseTypeRVAdapter( - private val onItemClicked: (PreferredExercise) -> Unit, - private val onLimitReached: () -> Unit, -) : ListAdapter(DiffCallback) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ViewHolder { - val binding = - ItemExerciseTypeBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return ViewHolder(binding) - } - - override fun onBindViewHolder( - holder: ViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - inner class ViewHolder(private val binding: ItemExerciseTypeBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(uiModel: PreferredExerciseUiModel) { - binding.exerciseTypeLl.setOnClickListener { - // 이미 5개가 선택되어 있다면 개수 제한 콜백 호출 - if (!uiModel.isSelected && currentList.count { it.isSelected } >= MAX_SELECTION) { - onLimitReached() - } else { - onItemClicked(uiModel.exercise) - } - } - - binding.exerciseNameTv.text = uiModel.exercise.name - binding.checkIv.isVisible = uiModel.isSelected - binding.exerciseTypeLl.isSelected = uiModel.isSelected - - if (uiModel.isSelected) { - binding.exerciseNameTv.setTextColor(ContextCompat.getColor(binding.root.context, R.color.white300)) - binding.root.backgroundTintList = - ColorStateList.valueOf( - ContextCompat.getColor(binding.root.context, R.color.main), - ) - } else { - binding.exerciseNameTv.setTextColor(ContextCompat.getColor(binding.root.context, R.color.black)) - binding.root.backgroundTintList = - ColorStateList.valueOf( - ContextCompat.getColor(binding.root.context, R.color.gray300), - ) - } - } - } - - companion object { - private val DiffCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: PreferredExerciseUiModel, - newItem: PreferredExerciseUiModel, - ): Boolean { - return oldItem.exercise.exerciseTypeId == newItem.exercise.exerciseTypeId - } - - override fun areContentsTheSame( - oldItem: PreferredExerciseUiModel, - newItem: PreferredExerciseUiModel, - ): Boolean { - return oldItem == newItem - } - } - private const val MAX_SELECTION = 5 - } -} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt index 4a36aa63..3e4584e2 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt @@ -1,10 +1,6 @@ package com.project200.undabang.profile.mypage.preferredExercise -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult import com.project200.domain.model.PreferredExercise @@ -20,6 +16,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -36,26 +38,25 @@ class PreferredExerciseViewModel var nickname: String = "" // 전체 운동 종류 목록 - private val exerciseTypes = MutableLiveData>() + private val exerciseTypes = MutableStateFlow>(emptyList()) // 선택한 운동 종류 - private val preferredExercise = MutableLiveData>() + private val preferredExercise = MutableStateFlow>(emptyList()) private var initialPreferredExercises: List = emptyList() - private val _completionState = MutableLiveData(CompletionState.Idle) - val completionState: LiveData = _completionState + private val _completionState = MutableStateFlow(CompletionState.Idle) + val completionState: StateFlow = _completionState.asStateFlow() - val exerciseUiModels = MediatorLiveData>() + private val _exerciseUiModels = MutableStateFlow>(emptyList()) + val exerciseUiModels: StateFlow> = _exerciseUiModels.asStateFlow() - val selectedExerciseUiModels: LiveData> = - exerciseUiModels.map { list -> - list.filter { it.isSelected } - } + val selectedExerciseUiModels: StateFlow> = + _exerciseUiModels + .map { list -> list.filter { it.isSelected } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) init { - exerciseUiModels.addSource(exerciseTypes) { updateUiModels() } - exerciseUiModels.addSource(preferredExercise) { updateUiModels() } fetchInitialData() } @@ -78,21 +79,23 @@ class PreferredExerciseViewModel initialPreferredExercises = exerciseResult.data exerciseTypes.value = allTypesResult.data preferredExercise.value = exerciseResult.data + updateUiModels() } } } /** - * _exerciseTypes 또는 _preferredExercise가 변경될 때마다 호출되어 UiModel을 설정합니다. + * exerciseTypes 또는 preferredExercise가 변경될 때 호출되어 UiModel을 갱신합니다. */ private fun updateUiModels() { - val allTypes = exerciseTypes.value ?: return - val selected = preferredExercise.value ?: emptyList() + val allTypes = exerciseTypes.value + val selected = preferredExercise.value val selectedTypeIds = selected.map { it.exerciseTypeId }.toSet() + val currentUiModels = _exerciseUiModels.value val uiList = allTypes.map { exercise -> - val existingUiModel = exerciseUiModels.value?.find { it.exercise.exerciseTypeId == exercise.exerciseTypeId } + val existingUiModel = currentUiModels.find { it.exercise.exerciseTypeId == exercise.exerciseTypeId } val serverData = initialPreferredExercises.find { it.exerciseTypeId == exercise.exerciseTypeId } PreferredExerciseUiModel( @@ -118,7 +121,7 @@ class PreferredExerciseViewModel } } } - exerciseUiModels.value = uiList + _exerciseUiModels.value = uiList } /** @@ -128,9 +131,8 @@ class PreferredExerciseViewModel exerciseTypeId: Long, dayIndex: Int, ) { - val currentModels = exerciseUiModels.value ?: return val newModels = - currentModels.map { uiModel -> + _exerciseUiModels.value.map { uiModel -> if (uiModel.exercise.exerciseTypeId == exerciseTypeId) { uiModel.copy( selectedDays = @@ -142,7 +144,7 @@ class PreferredExerciseViewModel uiModel } } - exerciseUiModels.value = newModels + _exerciseUiModels.value = newModels } /** @@ -152,28 +154,27 @@ class PreferredExerciseViewModel exerciseTypeId: Long, skill: SkillLevel, ) { - val currentModels = exerciseUiModels.value ?: return val newModels = - currentModels.map { uiModel -> + _exerciseUiModels.value.map { uiModel -> if (uiModel.exercise.exerciseTypeId == exerciseTypeId) { uiModel.copy(skillLevel = if (uiModel.skillLevel == skill) null else skill) } else { uiModel } } - exerciseUiModels.value = newModels + _exerciseUiModels.value = newModels } /** * 운동 종류 선택/해제 */ fun updateSelectedExercise(exercise: PreferredExercise) { - val list = preferredExercise.value?.toMutableList() ?: mutableListOf() + val list = preferredExercise.value.toMutableList() if (!list.removeAll { it.exerciseTypeId == exercise.exerciseTypeId }) { list.add(exercise) } - preferredExercise.value = list + updateUiModels() } /** @@ -182,7 +183,7 @@ class PreferredExerciseViewModel fun completePreferredExerciseChanges() { _completionState.value = CompletionState.Loading - val selectedUiModels = exerciseUiModels.value?.filter { it.isSelected } ?: emptyList() + val selectedUiModels = _exerciseUiModels.value.filter { it.isSelected } val currentPreferredExercises = selectedUiModels.map { it.toModel() } if (validateComplete(selectedUiModels)) return diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMemberRVAdapter.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMemberRVAdapter.kt deleted file mode 100644 index 32ca88a4..00000000 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMemberRVAdapter.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.project200.undabang.profile.setting - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.project200.domain.model.BlockedMember -import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.ItemBlockMemberBinding - -class BlockMemberRVAdapter( - private val onUnblockClicked: (BlockedMember) -> Unit, -) : ListAdapter(BlockMemberDiffCallback()) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): BlockMemberViewHolder { - val binding = ItemBlockMemberBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return BlockMemberViewHolder(binding, onUnblockClicked) - } - - override fun onBindViewHolder( - holder: BlockMemberViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - class BlockMemberViewHolder( - private val binding: ItemBlockMemberBinding, - private val onUnblockClicked: (BlockedMember) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: BlockedMember) { - binding.nicknameTv.text = item.nickname - - val imgRes = - if (!item.thumbnailImageUrl.isNullOrEmpty()) { - item.thumbnailImageUrl - } else { - item.profileImageUrl - } - - Glide.with(binding.profileImgIv) - .load(imgRes) - .placeholder(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .error(com.project200.undabang.presentation.R.drawable.ic_profile_default) - .circleCrop() - .into(binding.profileImgIv) - - binding.unblockBtn.setOnClickListener { - onUnblockClicked(item) - } - } - } - - companion object { - class BlockMemberDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: BlockedMember, - newItem: BlockedMember, - ): Boolean { - return oldItem.memberBlockId == newItem.memberBlockId - } - - override fun areContentsTheSame( - oldItem: BlockedMember, - newItem: BlockedMember, - ): Boolean { - return oldItem == newItem - } - } - } -} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersFragment.kt index 2d438ab8..61d7384f 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersFragment.kt @@ -1,60 +1,57 @@ package com.project200.undabang.profile.setting +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast -import androidx.core.view.isVisible +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentBlockMembersBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class BlockMembersFragment : BindingFragment(R.layout.fragment_block_members) { +class BlockMembersFragment : Fragment() { private val viewModel: BlockMembersViewModel by viewModels() - private lateinit var blockMemberAdapter: BlockMemberRVAdapter - override fun getViewBinding(view: View): FragmentBlockMembersBinding { - return FragmentBlockMembersBinding.bind(view) - } - - override fun setupViews() { - binding.baseToolbar.apply { - setTitle(getString(R.string.block_members)) - showBackButton(true) { requireActivity().onBackPressedDispatcher.onBackPressed() } - } - setupRecyclerView() - } - - private fun setupRecyclerView() { - blockMemberAdapter = - BlockMemberRVAdapter { member -> - viewModel.unblockMember(member) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val members by viewModel.blockedMembers.collectAsStateWithLifecycle() + BlockMembersScreen( + members = members, + onBackClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onUnblockClick = viewModel::unblockMember, + ) } - - binding.blockMembersRv.apply { - adapter = blockMemberAdapter - layoutManager = LinearLayoutManager(requireContext()) } - } - override fun setupObservers() { - viewModel.blockedMembers.observe(viewLifecycleOwner) { members -> - blockMemberAdapter.submitList(members) - binding.blockMembersRv.isVisible = !members.isNullOrEmpty() - // 차단된 사용자가 없을 때 표시할 뷰 - binding.emptyBlockMembersTv.isVisible = members.isNullOrEmpty() - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.errorEvent.collect { message -> - Toast.makeText(requireContext(), message ?: getString(R.string.error_unknown), Toast.LENGTH_SHORT).show() + Toast.makeText( + requireContext(), + message ?: getString(R.string.error_unknown), + Toast.LENGTH_SHORT, + ).show() } } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersScreen.kt new file mode 100644 index 00000000..2d092db6 --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersScreen.kt @@ -0,0 +1,177 @@ +package com.project200.undabang.profile.setting + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.project200.domain.model.BlockedMember +import com.project200.presentation.compose.components.display.UndabangAvatar +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentBold +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.profile.R + +@Composable +fun BlockMembersScreen( + members: List, + onBackClick: () -> Unit, + onUnblockClick: (BlockedMember) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(ColorWhite300), + ) { + UndabangTopBar( + title = stringResource(R.string.block_members), + onNavigationClick = onBackClick, + ) + + if (members.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.block_members_empty), + style = MaterialTheme.typography.contentRegular, + color = ColorGray200, + textAlign = TextAlign.Center, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = members, key = { it.memberBlockId }) { member -> + BlockMemberItem( + member = member, + onUnblockClick = { onUnblockClick(member) }, + ) + } + } + } + } +} + +@Composable +private fun BlockMemberItem( + member: BlockedMember, + onUnblockClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .drawBehind { + val strokePx = 0.3.dp.toPx() + drawLine( + color = ColorBlack, + start = Offset(0f, size.height - strokePx / 2), + end = Offset(size.width, size.height - strokePx / 2), + strokeWidth = strokePx, + ) + } + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UndabangAvatar( + imageUrl = member.thumbnailImageUrl ?: member.profileImageUrl, + size = 50.dp, + borderWidth = 0.dp, + ) + Spacer(Modifier.width(15.dp)) + Text( + text = member.nickname, + style = MaterialTheme.typography.contentBold, + color = ColorBlack, + modifier = Modifier.weight(1f), + ) + UnblockButton(onClick = onUnblockClick) + } +} + +@Composable +private fun UnblockButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(8.dp)) + .background(ColorGray200) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 13.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.unblock), + color = ColorWhite300, + fontSize = 14.sp, + style = MaterialTheme.typography.contentBold, + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun BlockMembersScreenPreview() { + AppTheme { + BlockMembersScreen( + members = + listOf( + BlockedMember(1L, "id1", "운다방테스터1", null, null), + BlockedMember(2L, "id2", "운다방테스터2", null, null), + BlockedMember(3L, "id3", "긴닉네임을가진사용자입니다", null, null), + ), + onBackClick = {}, + onUnblockClick = {}, + ) + } +} + +@Preview(showBackground = true, heightDp = 720) +@Composable +private fun BlockMembersScreenEmptyPreview() { + AppTheme { + BlockMembersScreen( + members = emptyList(), + onBackClick = {}, + onUnblockClick = {}, + ) + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersViewModel.kt index 6d9c7b70..c3462fba 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/BlockMembersViewModel.kt @@ -1,7 +1,5 @@ package com.project200.undabang.profile.setting -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult @@ -10,7 +8,10 @@ import com.project200.domain.usecase.GetBlockedMembersUseCase import com.project200.domain.usecase.UnblockMemberUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -21,8 +22,8 @@ class BlockMembersViewModel private val getBlockedMembersUseCase: GetBlockedMembersUseCase, private val unblockMemberUseCase: UnblockMemberUseCase, ) : ViewModel() { - private val _blockedMembers = MutableLiveData>() - val blockedMembers: LiveData> = _blockedMembers + private val _blockedMembers = MutableStateFlow>(emptyList()) + val blockedMembers: StateFlow> = _blockedMembers.asStateFlow() private val _errorEvent = MutableSharedFlow() val errorEvent: SharedFlow = _errorEvent diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index e16f3d9c..843fe301 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -4,78 +4,71 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.os.Bundle import android.provider.Settings +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController import com.project200.domain.model.NotificationType -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentNotificationBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint -class NotificationFragment : BindingFragment(R.layout.fragment_notification) { +class NotificationFragment : Fragment() { private val viewModel: NotificationViewModel by viewModels() - // 알림 권한 요청을 위한 ActivityResultLauncher private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> viewModel.updateNotiStateByPermission(isGranted) if (!isGranted) { Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() - binding.exerciseNotiSwitch.isChecked = false - binding.chattingNotiSwitch.isChecked = false } } - override fun getViewBinding(view: View): FragmentNotificationBinding { - return FragmentNotificationBinding.bind(view) - } - - override fun onResume() { - super.onResume() - // 화면에 다시 돌아왔을 때 현재 알림 권한 상태를 체크하여 UI를 업데이트 - checkNotificationPermission() - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + applyAppTheme { + val states by viewModel.notificationStates.collectAsStateWithLifecycle() + val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false + val isChattingOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false - override fun setupViews() { - binding.backBtnIv.setOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } - binding.exerciseNotiSwitch.setOnClickListener { - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, binding.exerciseNotiSwitch.isChecked) + NotificationScreen( + isExerciseOn = isExerciseOn, + isChattingOn = isChattingOn, + onNavigateBack = { findNavController().popBackStack() }, + onExerciseToggle = { viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, it) }, + onChattingToggle = { viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, it) }, + ) + } } - binding.chattingNotiSwitch.setOnClickListener { - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, binding.chattingNotiSwitch.isChecked) - } - } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) - override fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.notificationStates.collect { states -> - // 운동 알림 스위치 상태 설정 - val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false - if (binding.exerciseNotiSwitch.isChecked != isExerciseOn) { - binding.exerciseNotiSwitch.isChecked = isExerciseOn - } - - // 채팅 알림 스위치 상태 설정 - val isChatOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false - if (binding.chattingNotiSwitch.isChecked != isChatOn) { - binding.chattingNotiSwitch.isChecked = isChatOn - } - } - } - launch { viewModel.permissionRequestTrigger.collect { needsRequest -> if (needsRequest) { @@ -96,7 +89,11 @@ class NotificationFragment : BindingFragment(R.layo } } - // 현재 알림 권한 상태를 확인 + override fun onResume() { + super.onResume() + checkNotificationPermission() + } + private fun checkNotificationPermission() { val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -107,41 +104,31 @@ class NotificationFragment : BindingFragment(R.layo } else { true } - // 확인된 권한 상태를 ViewModel에 전달하여 초기 로직을 수행하도록 합니다. viewModel.updateNotiStateByPermission(isGranted) if (!isGranted) { Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() - binding.exerciseNotiSwitch.isChecked = false - binding.chattingNotiSwitch.isChecked = false } } - // 알림 권한 요청 private fun requestNotificationPermission() { - // Android 13 (Tiramisu) 이상에서만 런타임 권한 요청 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { when { ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED -> { - // 이미 권한이 있는 경우 Timber.tag("NotificationFragment").d("Notification permission already granted.") } shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { openAppSettings() } else -> { - // 처음 권한을 요청하는 경우 requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } - } else { - // Android 13 미만 버전에서는 별도의 런타임 권한이 필요 없음 } } - // 앱의 알림 설정 화면으로 이동 private fun openAppSettings() { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt new file mode 100644 index 00000000..b1a24bb4 --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt @@ -0,0 +1,122 @@ +package com.project200.undabang.profile.setting + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.ColorMain +import com.project200.presentation.compose.theme.ColorWhite300 +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.profile.R + +@Composable +fun NotificationScreen( + isExerciseOn: Boolean, + isChattingOn: Boolean, + onNavigateBack: () -> Unit, + onExerciseToggle: (Boolean) -> Unit, + onChattingToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = stringResource(R.string.notification), + onNavigationClick = onNavigateBack, + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()), + ) { + NotificationSwitchRow( + labelRes = R.string.exercise_notification, + checked = isExerciseOn, + onCheckedChange = onExerciseToggle, + ) + NotificationSwitchRow( + labelRes = R.string.chatting_notification, + checked = isChattingOn, + onCheckedChange = onChattingToggle, + ) + } + } +} + +@Composable +private fun NotificationSwitchRow( + @StringRes labelRes: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = labelRes), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = ColorWhite300, + checkedTrackColor = ColorMain, + checkedBorderColor = ColorMain, + uncheckedThumbColor = ColorWhite300, + uncheckedTrackColor = ColorGray200, + uncheckedBorderColor = ColorGray200, + ), + ) + } + HorizontalDivider(thickness = 0.3.dp, color = ColorBlack) + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationScreenPreview() { + AppTheme { + NotificationScreen( + isExerciseOn = true, + isChattingOn = false, + onNavigateBack = {}, + onExerciseToggle = {}, + onChattingToggle = {}, + ) + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt index 46ffe3b2..87881136 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingFragment.kt @@ -2,21 +2,24 @@ package com.project200.undabang.profile.setting import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import com.project200.presentation.base.BaseAlertDialog -import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme +import com.project200.presentation.compose.components.feedback.UndabangAlertDialogFragment import com.project200.presentation.navigator.ActivityNavigator import com.project200.presentation.terms.TermsDialogFragment import com.project200.presentation.terms.TermsDialogFragment.Companion.PRIVACY import com.project200.presentation.terms.TermsDialogFragment.Companion.TERMS import com.project200.undabang.feature.profile.R -import com.project200.undabang.feature.profile.databinding.FragmentSettingBinding import com.project200.undabang.oauth.AuthManager import com.project200.undabang.oauth.LogoutResultCallback import dagger.hilt.android.AndroidEntryPoint @@ -26,18 +29,13 @@ import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class SettingFragment : BindingFragment(R.layout.fragment_setting) { - override fun getViewBinding(view: View): FragmentSettingBinding { - return FragmentSettingBinding.bind(view) - } - +class SettingFragment : Fragment() { @Inject lateinit var appNavigator: ActivityNavigator @Inject lateinit var authManager: AuthManager private lateinit var authService: AuthorizationService private val viewModel: SettingViewModel by viewModels() - // Cognito 로그아웃 페이지를 열기 위한 ActivityResultLauncher private val logoutPageLauncher: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> Timber.d("로그아웃: ${result.resultCode}") @@ -49,44 +47,68 @@ class SettingFragment : BindingFragment(R.layout.fragmen authService = AuthorizationService(requireContext()) } - override fun setupViews() = - with(binding) { - versionInfoTv.text = requireActivity().packageManager.getPackageInfo(requireContext().packageName, 0).versionName + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val versionName = + requireActivity().packageManager + .getPackageInfo(requireContext().packageName, 0) + .versionName + .orEmpty() - backBtnIv.setOnClickListener { findNavController().popBackStack() } - logoutLl.setOnClickListener { - BaseAlertDialog(getString(R.string.alert_logout), null) { - performLogout() - }.show(parentFragmentManager, BaseAlertDialog::class.java.simpleName) - } - blockMembersLl.setOnClickListener { - findNavController().navigate(R.id.action_settingFragment_to_blockMembersFragment) - } - withdrawLl.setOnClickListener { // 회원탈퇴 웹 페이지로 이동 - appNavigator.navigateToWeb(requireContext(), getString(R.string.withdraw_url)) - } - customerServiceLl.setOnClickListener { // 고객센터 웹 페이지로 이동 - appNavigator.navigateToWeb(requireContext(), getString(R.string.customer_service_url)) - } - termsLl.setOnClickListener { showTermsDialog(TERMS) } - privacyLl.setOnClickListener { showTermsDialog(PRIVACY) } - notificationLl.setOnClickListener { - findNavController().navigate(R.id.action_settingFragment_to_notificationFragment) + return ComposeView(requireContext()).apply { + applyAppTheme { + SettingScreen( + versionName = versionName, + onNavigateBack = { findNavController().popBackStack() }, + onCustomerServiceClick = { + appNavigator.navigateToWeb( + requireContext(), + getString(R.string.customer_service_url), + ) + }, + onLogoutClick = { + UndabangAlertDialogFragment.show( + fragmentManager = parentFragmentManager, + title = getString(R.string.alert_logout), + onCancel = {}, + onConfirm = { performLogout() }, + ) + }, + onWithdrawClick = { + appNavigator.navigateToWeb( + requireContext(), + getString(R.string.withdraw_url), + ) + }, + onBlockMembersClick = { + findNavController().navigate(R.id.action_settingFragment_to_blockMembersFragment) + }, + onTermsClick = { showTermsDialog(TERMS) }, + onPrivacyClick = { showTermsDialog(PRIVACY) }, + onNotificationClick = { + findNavController().navigate(R.id.action_settingFragment_to_notificationFragment) + }, + ) } } + } private fun performLogout() { - viewLifecycleOwner.lifecycleScope.launch { // Fragment의 viewLifecycleOwner 사용 + viewLifecycleOwner.lifecycleScope.launch { try { viewModel.logout() } catch (e: Exception) { Timber.e(e, "로그아웃 실패") } - + var logoutPageLaunched = false authManager.logout( authService, object : LogoutResultCallback { override fun onLogoutPageIntentReady(logoutIntent: Intent) { + logoutPageLaunched = true logoutPageLauncher.launch(logoutIntent) } @@ -97,16 +119,17 @@ class SettingFragment : BindingFragment(R.layout.fragmen override fun onLogoutProcessError(exception: Exception) { Timber.e(exception, "로그아웃 에러: ${exception.message}") Toast.makeText(requireContext(), getString(R.string.logout_error), Toast.LENGTH_LONG).show() - // 오류 발생 시에도 로컬 상태는 AuthManager에서 정리 시도됨. 로그인 화면으로 이동. - appNavigator.navigateToLogin(requireContext()) } }, ) + + runCatching { viewModel.clearLocalSession() } + if (!logoutPageLaunched) appNavigator.navigateToLogin(requireContext()) } } private fun showTermsDialog(termsType: String) { - TermsDialogFragment(termsType).show(parentFragmentManager, TermsDialogFragment::class.java.simpleName) + TermsDialogFragment.newInstance(termsType).show(parentFragmentManager, TermsDialogFragment::class.java.simpleName) } override fun onDestroy() { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt new file mode 100644 index 00000000..44e26398 --- /dev/null +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingScreen.kt @@ -0,0 +1,183 @@ +package com.project200.undabang.profile.setting + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.project200.presentation.compose.components.layout.UndabangScaffold +import com.project200.presentation.compose.components.layout.UndabangTopBar +import com.project200.presentation.compose.theme.AppTheme +import com.project200.presentation.compose.theme.ColorBlack +import com.project200.presentation.compose.theme.ColorGray200 +import com.project200.presentation.compose.theme.contentRegular +import com.project200.undabang.feature.profile.R +import com.project200.undabang.presentation.R as PresentationR + +private data class SettingMenuItemData( + @DrawableRes val iconRes: Int, + @StringRes val labelRes: Int, + val onClick: (() -> Unit)? = null, + val trailingText: String? = null, + val showDivider: Boolean = true, +) + +@Composable +fun SettingScreen( + versionName: String, + onNavigateBack: () -> Unit, + onCustomerServiceClick: () -> Unit, + onLogoutClick: () -> Unit, + onWithdrawClick: () -> Unit, + onBlockMembersClick: () -> Unit, + onTermsClick: () -> Unit, + onPrivacyClick: () -> Unit, + onNotificationClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // 메뉴 항목을 데이터로 묶어 반복 코드 정리 + val items = + remember(versionName) { + listOf( + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_customer_service, + labelRes = R.string.customer_service, + onClick = onCustomerServiceClick, + ), + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_logout, + labelRes = R.string.logout, + onClick = onLogoutClick, + ), + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_withdraw, + labelRes = R.string.withdraw, + onClick = onWithdrawClick, + ), + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_block, + labelRes = R.string.block_members, + onClick = onBlockMembersClick, + ), + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_document, + labelRes = R.string.terms, + onClick = onTermsClick, + ), + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_document, + labelRes = R.string.privacy, + onClick = onPrivacyClick, + ), + SettingMenuItemData( + iconRes = R.drawable.ic_notification, + labelRes = R.string.notification, + onClick = onNotificationClick, + ), + SettingMenuItemData( + iconRes = PresentationR.drawable.ic_version_info, + labelRes = R.string.version_info, + trailingText = versionName, + showDivider = false, + ), + ) + } + + UndabangScaffold( + modifier = modifier, + topBar = { + UndabangTopBar( + title = stringResource(R.string.setting), + onNavigationClick = onNavigateBack, + ) + }, + ) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(items, key = { it.labelRes }) { item -> + SettingMenuItem(item = item) + } + } + } +} + +@Composable +private fun SettingMenuItem( + item: SettingMenuItemData, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (item.onClick != null) it.clickable(onClick = item.onClick) else it } + .padding(horizontal = 20.dp, vertical = 19.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = item.iconRes), + contentDescription = null, + tint = ColorBlack, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(id = item.labelRes), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.contentRegular, + color = ColorBlack, + ) + } + if (item.trailingText != null) { + Text( + text = item.trailingText, + style = MaterialTheme.typography.contentRegular, + color = ColorGray200, + ) + } + } + if (item.showDivider) { + HorizontalDivider(thickness = 0.3.dp, color = ColorBlack) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + AppTheme { + SettingScreen( + versionName = "0.8.3", + onNavigateBack = {}, + onCustomerServiceClick = {}, + onLogoutClick = {}, + onWithdrawClick = {}, + onBlockMembersClick = {}, + onTermsClick = {}, + onPrivacyClick = {}, + onNotificationClick = {}, + ) + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt index 284f4272..f632cff9 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt @@ -1,6 +1,7 @@ package com.project200.undabang.profile.setting import androidx.lifecycle.ViewModel +import com.project200.domain.usecase.ClearSessionUseCase import com.project200.domain.usecase.LogoutUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -10,8 +11,9 @@ class SettingViewModel @Inject constructor( private val logoutUseCase: LogoutUseCase, + private val clearSessionUseCase: ClearSessionUseCase, ) : ViewModel() { - suspend fun logout() { - logoutUseCase() - } + suspend fun logout() = logoutUseCase() + + suspend fun clearLocalSession() = clearSessionUseCase() } diff --git a/feature/profile/src/main/res/drawable/style_toggle_thumb.xml b/feature/profile/src/main/res/drawable/style_toggle_thumb.xml deleted file mode 100644 index 5f06e683..00000000 --- a/feature/profile/src/main/res/drawable/style_toggle_thumb.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/drawable/style_toggle_track.xml b/feature/profile/src/main/res/drawable/style_toggle_track.xml deleted file mode 100644 index b22b4543..00000000 --- a/feature/profile/src/main/res/drawable/style_toggle_track.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_block_members.xml b/feature/profile/src/main/res/layout/fragment_block_members.xml deleted file mode 100644 index 25c9e7b2..00000000 --- a/feature/profile/src/main/res/layout/fragment_block_members.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_notification.xml b/feature/profile/src/main/res/layout/fragment_notification.xml deleted file mode 100644 index a1e6520b..00000000 --- a/feature/profile/src/main/res/layout/fragment_notification.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_preferred_exercise.xml b/feature/profile/src/main/res/layout/fragment_preferred_exercise.xml deleted file mode 100644 index 5af10d8a..00000000 --- a/feature/profile/src/main/res/layout/fragment_preferred_exercise.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_preferred_exercise_detail.xml b/feature/profile/src/main/res/layout/fragment_preferred_exercise_detail.xml deleted file mode 100644 index c101a653..00000000 --- a/feature/profile/src/main/res/layout/fragment_preferred_exercise_detail.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_preferred_exercise_type.xml b/feature/profile/src/main/res/layout/fragment_preferred_exercise_type.xml deleted file mode 100644 index f8d169c8..00000000 --- a/feature/profile/src/main/res/layout/fragment_preferred_exercise_type.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_profile_edit.xml b/feature/profile/src/main/res/layout/fragment_profile_edit.xml deleted file mode 100644 index d97ab10b..00000000 --- a/feature/profile/src/main/res/layout/fragment_profile_edit.xml +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_profile_image_detail.xml b/feature/profile/src/main/res/layout/fragment_profile_image_detail.xml deleted file mode 100644 index baf0e97e..00000000 --- a/feature/profile/src/main/res/layout/fragment_profile_image_detail.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/fragment_setting.xml b/feature/profile/src/main/res/layout/fragment_setting.xml deleted file mode 100644 index e728502c..00000000 --- a/feature/profile/src/main/res/layout/fragment_setting.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/item_block_member.xml b/feature/profile/src/main/res/layout/item_block_member.xml deleted file mode 100644 index a26e4136..00000000 --- a/feature/profile/src/main/res/layout/item_block_member.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/item_exercise_type.xml b/feature/profile/src/main/res/layout/item_exercise_type.xml deleted file mode 100644 index 95d4f8a0..00000000 --- a/feature/profile/src/main/res/layout/item_exercise_type.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/item_preferred_exercise_detail.xml b/feature/profile/src/main/res/layout/item_preferred_exercise_detail.xml deleted file mode 100644 index 540e04f7..00000000 --- a/feature/profile/src/main/res/layout/item_preferred_exercise_detail.xml +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/layout/item_profile_image.xml b/feature/profile/src/main/res/layout/item_profile_image.xml deleted file mode 100644 index 04cda128..00000000 --- a/feature/profile/src/main/res/layout/item_profile_image.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/menu/menu_profile_image.xml b/feature/profile/src/main/res/menu/menu_profile_image.xml deleted file mode 100644 index d16f6488..00000000 --- a/feature/profile/src/main/res/menu/menu_profile_image.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/feature/profile/src/main/res/navigation/profile_nav_graph.xml b/feature/profile/src/main/res/navigation/profile_nav_graph.xml index 5441ac65..4f9cee90 100644 --- a/feature/profile/src/main/res/navigation/profile_nav_graph.xml +++ b/feature/profile/src/main/res/navigation/profile_nav_graph.xml @@ -56,24 +56,17 @@ + android:label="notification" /> - - + android:label="profileEdit" /> - - + android:label="profileImageDetail" /> + /> + android:label="preferredExercise" > diff --git a/feature/profile/src/main/res/values/styles.xml b/feature/profile/src/main/res/values/styles.xml index a1f758d7..89f3decb 100644 --- a/feature/profile/src/main/res/values/styles.xml +++ b/feature/profile/src/main/res/values/styles.xml @@ -13,13 +13,6 @@ 1.2 - -