Skip to content
Merged
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
65 changes: 54 additions & 11 deletions app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -72,6 +81,7 @@ fun MainScreen(
val snackbarHostState = remember { SnackbarHostState() }

ModelDownloadConfirmationEffect(modelDownloadPromptViewModel, snackbarHostState)
DoubleBackToExitHandler(enabled = { !navigationState.canGoBack })

// 배경/탭바 에셋이 라이트 전용이라 다크 시안이 나올 때까지 셸은 라이트로 고정한다.
GamssTheme(darkTheme = false) {
Expand Down Expand Up @@ -106,6 +116,7 @@ fun MainScreen(
// metadata(detailTransition)가 이 기본값을 덮어쓴다.
transitionSpec = tabFadeThroughSpec,
popTransitionSpec = tabFadeThroughSpec,
predictivePopTransitionSpec = { tabFadeThroughSpec(this) },
onBack = {
if (navigationState.canGoBack) {
navigator.goBack()
Expand Down Expand Up @@ -194,20 +205,50 @@ private val tabFadeThroughSpec: AnimatedContentTransitionScope<Scene<NavKey>>.()
* 탭 내부의 상세 화면(Setting/AccountInfo/NicknameChange/ChatRoom/WebView) push·pop 전용
* 트랜지션. 계층 이동이라는 방향감을 주기 위해 좌우 슬라이드(shared axis X)를 쓴다.
*
* popTransitionSpec과 predictivePopTransitionSpec을 반드시 같이 지정해야 한다 — 인앱 뒤로가기
* 아이콘(Navigator.goBack() 직접 호출)은 popTransitionSpec을, 폰의 시스템 뒤로가기(제스처·버튼
* 모두 OnBackInvokedCallback 경유)는 predictivePopTransitionSpec을 따로 참조하기 때문에, 하나만
* 지정하면 트리거 경로에 따라 모션이 달라져 버린다.
* 트리거 경로마다 참조하는 spec이 달라서, 셋 다 지정하지 않으면 모션이 갈립니다.
*/
private val detailSlideTransition: Map<String, Any> = NavDisplay.transitionSpec {
(slideIntoContainer(SlideDirection.Start, tween(300)) + fadeIn(tween(300))) togetherWith
(slideOutOfContainer(SlideDirection.Start, tween(300)) + fadeOut(tween(150)))
} + 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()
}
}
}

/**
Expand All @@ -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) {
Expand All @@ -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
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
<string name="tab_home">홈</string>
<string name="tab_chat">대화</string>

<string name="back_press_exit_confirm">뒤로가기를 한 번 더 누르면 종료돼요</string>

<string name="model_download_message">추가 다운로드가 필요해요</string>
<string name="model_download_action">모바일 데이터로 받기</string>

<string name="default_notification_channel_id" translatable="false">push_default</string>
<string name="default_notification_channel_name">알림</string>
</resources>
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
Loading