diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0c20e8e..5d56c433 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -123,6 +123,7 @@ dependencies { implementation(libs.navigation3.runtime) implementation(libs.navigation3.ui) + implementation(libs.androidx.navigationevent) implementation(libs.orbit.core) implementation(libs.orbit.viewmodel) diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index e59dab7c..a665b2ad 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt @@ -1,5 +1,8 @@ package com.gamss.android.app.main +import android.os.SystemClock +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection @@ -19,14 +22,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.NavigationEvent +import com.gamss.android.app.R import com.gamss.android.app.navigation.Navigator import com.gamss.android.app.navigation.bottomBarItems import com.gamss.android.app.navigation.keys @@ -72,6 +81,7 @@ fun MainScreen( val snackbarHostState = remember { SnackbarHostState() } ModelDownloadConfirmationEffect(modelDownloadPromptViewModel, snackbarHostState) + DoubleBackToExitHandler(enabled = { !navigationState.canGoBack }) // 배경/탭바 에셋이 라이트 전용이라 다크 시안이 나올 때까지 셸은 라이트로 고정한다. GamssTheme(darkTheme = false) { @@ -106,6 +116,7 @@ fun MainScreen( // metadata(detailTransition)가 이 기본값을 덮어쓴다. transitionSpec = tabFadeThroughSpec, popTransitionSpec = tabFadeThroughSpec, + predictivePopTransitionSpec = { tabFadeThroughSpec(this) }, onBack = { if (navigationState.canGoBack) { navigator.goBack() @@ -194,10 +205,7 @@ private val tabFadeThroughSpec: AnimatedContentTransitionScope>.() * 탭 내부의 상세 화면(Setting/AccountInfo/NicknameChange/ChatRoom/WebView) push·pop 전용 * 트랜지션. 계층 이동이라는 방향감을 주기 위해 좌우 슬라이드(shared axis X)를 쓴다. * - * popTransitionSpec과 predictivePopTransitionSpec을 반드시 같이 지정해야 한다 — 인앱 뒤로가기 - * 아이콘(Navigator.goBack() 직접 호출)은 popTransitionSpec을, 폰의 시스템 뒤로가기(제스처·버튼 - * 모두 OnBackInvokedCallback 경유)는 predictivePopTransitionSpec을 따로 참조하기 때문에, 하나만 - * 지정하면 트리거 경로에 따라 모션이 달라져 버린다. + * 트리거 경로마다 참조하는 spec이 달라서, 셋 다 지정하지 않으면 모션이 갈립니다. */ private val detailSlideTransition: Map = NavDisplay.transitionSpec { (slideIntoContainer(SlideDirection.Start, tween(300)) + fadeIn(tween(300))) togetherWith @@ -205,9 +213,42 @@ private val detailSlideTransition: Map = NavDisplay.transitionSpec } + NavDisplay.popTransitionSpec { (slideIntoContainer(SlideDirection.End, tween(300)) + fadeIn(tween(300))) togetherWith (slideOutOfContainer(SlideDirection.End, tween(300)) + fadeOut(tween(150))) -} + NavDisplay.predictivePopTransitionSpec { _ -> - (slideIntoContainer(SlideDirection.End, tween(300)) + fadeIn(tween(300))) togetherWith - (slideOutOfContainer(SlideDirection.End, tween(300)) + fadeOut(tween(150))) +} + NavDisplay.predictivePopTransitionSpec { swipeEdge -> + val towards = if (swipeEdge == NavigationEvent.EDGE_RIGHT) SlideDirection.Start else SlideDirection.End + (slideIntoContainer(towards, tween(300)) + fadeIn(tween(300))) togetherWith + (slideOutOfContainer(towards, tween(300)) + fadeOut(tween(150))) +} + +/** + * 홈 루트에서는 NavDisplay의 previousEntries가 비어 자체 back handler가 꺼지므로 이 handler가 받습니다. + */ +@Composable +private fun DoubleBackToExitHandler(enabled: () -> Boolean) { + val activity = LocalActivity.current + val context = LocalContext.current + val toast = remember(context) { + Toast.makeText(context, R.string.back_press_exit_confirm, Toast.LENGTH_SHORT) + } + var lastBackPressedAt by remember { mutableLongStateOf(NO_BACK_PRESS) } + val isEnabled = enabled() + + // 다른 화면을 거쳐 돌아오면 직전 경고는 무효입니다. 남겨두면 경고 없이 종료됩니다. + LaunchedEffect(isEnabled) { + if (!isEnabled) lastBackPressedAt = NO_BACK_PRESS + } + + BackHandler(enabled = isEnabled) { + val now = SystemClock.elapsedRealtime() + val withinWindow = lastBackPressedAt != NO_BACK_PRESS && now - lastBackPressedAt <= EXIT_CONFIRM_WINDOW_MS + if (withinWindow) { + // 취소하지 않으면 앱이 사라진 뒤에도 런처 위에 남습니다. + toast.cancel() + activity?.finish() + } else { + lastBackPressedAt = now + toast.show() + } + } } /** @@ -222,13 +263,15 @@ private fun ModelDownloadConfirmationEffect( ) { val activity = LocalActivity.current val needsConfirmation by viewModel.needsUserConfirmation.collectAsState() + val message = stringResource(R.string.model_download_message) + val actionLabel = stringResource(R.string.model_download_action) LaunchedEffect(needsConfirmation, activity) { if (!needsConfirmation || activity == null) return@LaunchedEffect val result = snackbarHostState.showSnackbar( - message = MODEL_DOWNLOAD_MESSAGE, - actionLabel = MODEL_DOWNLOAD_ACTION, + message = message, + actionLabel = actionLabel, withDismissAction = true, ) if (result == SnackbarResult.ActionPerformed) { @@ -237,5 +280,5 @@ private fun ModelDownloadConfirmationEffect( } } -private const val MODEL_DOWNLOAD_MESSAGE = "추가 다운로드가 필요해요" -private const val MODEL_DOWNLOAD_ACTION = "모바일 데이터로 받기" +private const val NO_BACK_PRESS = 0L +private const val EXIT_CONFIRM_WINDOW_MS = 2000L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ba34df8..21b1b45d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,11 @@ 대화 + 뒤로가기를 한 번 더 누르면 종료돼요 + + 추가 다운로드가 필요해요 + 모바일 데이터로 받기 + push_default 알림 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cea976b4..153288a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-lifecycle = "2.9.4" lifecycle-viewmodel-navigation3 = "2.10.0" compose-bom = "2026.04.01" navigation3 = "1.1.0-rc01" +navigationevent = "1.0.2" junit = "4.13.2" detekt = "1.23.8" kotlinx-serialization = "1.11.0" @@ -61,6 +62,7 @@ compose-material-icons-core = { module = "androidx.compose.material:material-ico navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-navigationevent = { module = "androidx.navigationevent:navigationevent", version.ref = "navigationevent" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }