Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ import com.afternote.afternote_fe.notification.NotificationPermissionEffect
import com.afternote.core.ui.Route
import com.afternote.core.ui.bottombar.BottomBar
import com.afternote.core.ui.navigation.FeatureNavigationCallbacks
import com.afternote.core.ui.navigation.NavDestinationBackground
import com.afternote.core.ui.navigation.PredictiveBackPopEnter
import com.afternote.core.ui.navigation.PredictiveBackPopExit
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.feature.afternote.presentation.navigation.AfternoteNavHost
import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteNavHost
import com.afternote.feature.home.presentation.HomeTabScreen
Expand Down Expand Up @@ -86,7 +86,7 @@ fun AppNavigation(

Scaffold(
modifier = modifier,
containerColor = AfternoteDesign.colors.gray1,
containerColor = NavDestinationBackground,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
contentWindowInsets =
WindowInsets.systemBars.only(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import androidx.navigation3.ui.NavDisplay
/**
* 피처 하나가 소유하는 로컬 Navigation 3 스택의 표준 표시부.
*
* 이 저장소의 로컬 스택은 전부 이 함수를 거친다 — 데코레이터 목록과 바닥 back 처리를 한 곳에
* 모아 두면, 아래 두 함정을 피처마다 다시 밟지 않는다.
* 이 저장소의 로컬 스택은 전부 이 함수를 거친다. 데코레이터 목록과 바닥 back 처리를 한 곳에
* 모아 두면, 아래 두 함정을 피처마다 다시 밟지 않는다. entry 바탕을 칠하는 것(#2145)도 여기서
* 한 번에 받는다([rememberStandardNavEntryDecorators]).
*
* 1. `NavDisplay.onBack` 은 `() -> Unit` 이다. 문서·블로그에 도는 `{ count -> … }` (Int 인자)
* 형태는 이 버전에서 컴파일되지 않는다 (#959 실측 함정 1).
Expand Down Expand Up @@ -78,10 +79,14 @@ public fun FeatureNavDisplay(
*
* [FeatureNavDisplay] 를 쓸 수 없어 `NavDisplay` 를 직접 부르는 경우(다른 scene 전략이 필요한
* 경우 등)에도 같은 목록을 쓰도록 공개해 둔다.
*
* 세 번째 [NavDestinationSurfaceDecorator] 는 entry 마다 불투명한 바탕을 깐다. 빠지면 predictive back
* 진행 중 줄어든 앞 화면 사이로 뒤 화면 글자가 비친다(#2145). 색은 루트 셸과 같은 [NavDestinationBackground] 를 쓴다.
*/
@Composable
public fun rememberStandardNavEntryDecorators(): List<NavEntryDecorator<NavKey>> =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
NavDestinationSurfaceDecorator,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.afternote.core.ui.navigation

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation3.runtime.NavEntryDecorator
import androidx.navigation3.runtime.NavKey
import com.afternote.core.ui.theme.AfternoteDesign

/**
* 네비게이션 목적지 하나를 불투명하게 칠하는 바탕 (#2145).
*
* predictive back 진행 중 앞 화면은 [PredictiveBackPopExit] 로 줄어들어 뒤 화면 위에 뜬다(#1869).
* 그런데 화면 대부분이 `Scaffold(containerColor = Color.Transparent)` 로 그려 루트 셸의 바탕을 비쳐
* 보이게 한다. 멈춰 있을 때는 그 아래가 루트 바탕 하나라 티가 안 나지만, 전환 중에는 그 아래에 뒤
* 화면이 있어서 **축소된 앞 화면 사이로 뒤 화면 글자가 그대로 비친다.**
*
* 화면마다 `containerColor` 를 고치지 않고 목적지 자리에서 한 번 칠한다. 색은 루트 셸과 같은
* [NavDestinationBackground] 라 멈춰 있을 때의 화면은 달라지지 않는다.
*
* [rememberStandardNavEntryDecorators] 가 Nav3 로컬 스택의 모든 entry 에 씌운다.
*/
@Composable
private fun NavDestinationSurface(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxSize().background(NavDestinationBackground)) {
content()
}
}

/**
* 루트 셸의 바탕과 [NavDestinationSurface] 가 함께 쓰는 색.
*
* 둘이 갈리면 투명하게 그리는 화면의 멈춘 모습이 바뀌므로 한 곳에서 받는다.
*/
public val NavDestinationBackground: Color
@Composable
@ReadOnlyComposable
get() = AfternoteDesign.colors.gray1

/** 로컬 스택의 모든 entry 를 [NavDestinationSurface] 로 감싼다. 상태가 없어 하나를 나눠 쓴다. */
internal val NavDestinationSurfaceDecorator: NavEntryDecorator<NavKey> =
NavEntryDecorator(decorate = { entry -> NavDestinationSurface { entry.Content() } })
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package com.afternote.core.ui.navigation

import android.app.Application
import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.View
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand All @@ -12,6 +18,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createComposeRule
Expand Down Expand Up @@ -44,7 +53,7 @@ import org.robolectric.annotation.GraphicsMode
* 로컬 Navigation 3 스택 표시부의 **기전** 회귀 기준 (#1698).
*
* 피처별 백스택 «모양» 은 각 `*LocalNavActionsTest` 가 본다. 여기서 잠그는 것은 그 모양을
* 실제 화면 수명으로 옮기는 [FeatureNavDisplay] 자신의 계약이다 — 이관 전 Nav2 의 백스택
* 실제 화면 수명으로 옮기는 [FeatureNavDisplay] 자신의 계약이다. 이관 전 Nav2 의 백스택
* 엔트리가 지켜 주던 것들이라, 깨져도 컴파일은 통과하고 조용히 상태만 사라진다.
*/
@RunWith(RobolectricTestRunner::class)
Expand Down Expand Up @@ -284,6 +293,37 @@ class FeatureNavDisplayTest {
composeRule.onNodeWithText("detail1#1").assertIsDisplayed()
}

/**
* **투명하게 그리는 entry 라도 그 아래는 비치지 않는다 (#2145).**
*
* predictive back 진행 중에는 줄어든 앞 entry 아래에 뒤 entry 가 그려진다. 화면 대부분이
* `Scaffold(containerColor = Color.Transparent)` 라 entry 자리가 스스로 칠하지 않으면 뒤 화면 글자가
* 앞 화면 사이로 비친다. 뒤 화면 대신 빨간 바탕을 깔고, 글자 하나뿐인 entry 의 빈 구석이 루트 셸과
* 같은 바탕색으로 덮이는지 본다. 표준 데코레이터 목록에서 바탕을 빼면 이 구석이 빨갛게 나온다.
*/
@Test
fun `투명한 entry 도 아래를 비치지 않도록 루트와 같은 바탕을 깐다`() {
var expected = Color.Unspecified
lateinit var hostView: View
composeRule.setContent {
expected = NavDestinationBackground
hostView = LocalView.current
Box(modifier = Modifier.fillMaxSize().background(Color.Red)) { TestHost() }
}
composeRule.waitForIdle()

// captureToImage 는 Robolectric 에서 다음 프레임을 기다리다 시간 초과로 죽는다. 뷰를 소프트웨어
// 캔버스에 직접 그려 같은 픽셀을 읽는다.
val emptyCorner =
composeRule.runOnIdle {
val bitmap = Bitmap.createBitmap(hostView.width, hostView.height, Bitmap.Config.ARGB_8888)
hostView.draw(Canvas(bitmap))
bitmap.getPixel(bitmap.width - 1, bitmap.height - 1)
}

assertEquals("entry 의 빈 자리에 아래 바탕(빨강)이 비친다", expected.toArgb(), emptyCorner)
}

@Test
fun `entry 범위 ViewModel 은 그 화면이 스택에서 빠질 때 정리된다`() {
start()
Expand Down
52 changes: 35 additions & 17 deletions docs/qa/predictive-back.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# predictive back 수동 확인 절차

Nav2 백스택 회귀 기준(#1601)의 다섯 축 중 **predictive back** 만 자동화가 반쪽이다.
Nav2 백스택 회귀 기준(#1601)의 다섯 축 중 predictive back 만 자동화가 반쪽이다.
백스택이 어떻게 바뀌는지는 JVM 테스트가 전부 고정하지만,
**손가락을 떼기 전에 화면이 어떻게 보이는지**는 원격 조작으로 재현할 수 없다.
손가락을 떼기 전에 화면이 어떻게 보이는지는 기기에서 터치 홀드로 확인한다.
이 문서는 그 남은 반쪽의 절차다.

## 자동으로 이미 고정한 것 — 다시 손으로 보지 않는다
## 자동으로 이미 고정한 것

`app/src/test/java/com/afternote/afternote_fe/navigation/PredictiveBackNavigationTest.kt`
(`:app:testDebugUnitTest`, 5건). `OnBackPressedDispatcher.dispatchOnBackStarted` /
`dispatchOnBackProgressed` / `dispatchOnBackCancelled` 를 그대로 흘려 넣어 검사한다.

- 진행 중(손가락을 떼기 전)에는 백스택이 아직 그대로다
- 취소하면 떠나려던 화면에 남는다
- 끝까지 진행하면 **한 칸만** pop 된다
- 끝까지 진행하면 한 칸만 pop 된다
- 취소한 직후 다시 시작해도 정상적으로 pop 된다
- 탭 그래프의 시작 화면에서 진행하면 그래프 엔트리까지 함께 빠지고 홈 탭이 남는다(앱을 벗어나지 않는다)

Expand All @@ -22,7 +22,7 @@ Nav2 백스택 회귀 기준(#1601)의 다섯 축 중 **predictive back** 만
| 항목 | 왜 자동이 안 되나 |
| --- | --- |
| 진행 중 앞 화면의 축소 비율·가장자리 여백 | 픽셀 판정이고, Robolectric 은 프레임을 돌리지 않는다 |
| 진행률에 따라 미리보기가 따라오는지(스크럽 감각) | 실제 터치 홀드가 필요하다 — `adb`·계측 모두 «진행 중» 상태를 유지하지 못한다 |
| 진행률에 따라 미리보기가 따라오는지(스크럽 감각) | 실제 터치 홀드가 필요하다. `adb shell input motionevent`의 DOWN·MOVE 후 UP 전에 캡처할 수 있다 |
| 취소 시 앞 화면이 제자리로 되돌아가는 애니메이션 | 위와 같다. JVM 테스트에서는 미리보기가 끝내 사라지지 않는데, 이는 프레임이 없기 때문이지 기기 동작이 아니다 |
| 시스템 back-to-home 전환(앱 밖으로) | 앱 밖 영역이라 앱 테스트의 관측 범위 밖이다 |

Expand All @@ -37,48 +37,66 @@ Nav3 로컬 스택이 쓰는 라이브러리 기본값(`defaultPredictivePopTran

## 절차

기기 조건 — **API 35 이상**. 앱이 `enableOnBackInvokedCallback` 을 명시하지 않지만
`targetSdk 36` 이라 시스템 기본값으로 predictive back 이 켜진다.
기기 설정에서 «뒤로 탐색 애니메이션» 이 켜져 있어야 한다(개발자 옵션이 아니라 기본 켜짐).
제스처 내비게이션 모드여야 한다 — 3버튼 모드에서는 predictive back 이 동작하지 않는다.
기기 조건: Android 16(API 36) 이상. 앱이 `enableOnBackInvokedCallback` 을 명시하지 않지만
`targetSdk 36` 이라 Android 16 에서는 시스템 기본값으로 predictive back 이 켜진다.
API 35 에서는 켜지지 않는다. 2026-09-23 API 35 에뮬레이터(제스처 내비게이션)에서 가장자리를 끌면
시스템 뒤로 화살표만 뜨고 앱 화면은 줄지 않았다(#2145). API 35 에뮬레이터로 봐야 하면 로컬 확인용
빌드에만 매니페스트 `<application>` 에 `android:enableOnBackInvokedCallback="true"` 를 넣는다. 커밋하지 않는다.
기기 설정에서 "뒤로 탐색 애니메이션" 이 켜져 있어야 한다(개발자 옵션이 아니라 기본 켜짐).
제스처 내비게이션 모드여야 한다. 3버튼 모드에서는 predictive back 이 동작하지 않는다.

각 항목은 **왼쪽 가장자리에서 오른쪽으로 천천히 끌되 손가락을 떼지 않은 채** 관찰한다.
각 항목은 왼쪽 가장자리에서 오른쪽으로 천천히 끌되 손가락을 떼지 않은 채 관찰한다.

### 1. 탭 안 깊은 화면 — 진행·취소·완료
### 1. 탭 안 깊은 화면: 진행·취소·완료

1. 로그인 → 타임레터 탭 → 임시저장 목록으로 들어간다.
2. 화면 폭의 30% 쯤까지 끌고 **멈춘다**.
2. 화면 폭의 30% 쯤까지 끌고 멈춘다.
- [ ] 임시저장 화면이 줄어들어 뒤로 물러나고, 그 뒤로 타임레터 홈이 비친다.
두 화면이 같은 크기로 겹쳐 글자가 서로 비치면 실패다(#1869).
3. 손가락을 **왼쪽으로 되돌려 놓고 뗀다**(취소).
3. 손가락을 왼쪽으로 되돌려 놓고 뗀다(취소).
- [ ] 임시저장 화면이 원래 크기로 되돌아가고 그 화면에 남는다.
4. 다시 끌어서 화면 밖까지 완료한다.
- [ ] 타임레터 홈으로 간다. 홈 탭으로 튀지 않는다.

### 2. 탭 시작 화면 — 앱을 벗어나지 않는다
### 2. 탭 시작 화면: 앱을 벗어나지 않는다

1. 타임레터 홈(탭 시작 화면)에서 같은 제스처를 완료한다.
- [ ] 홈 탭으로 간다. 앱이 종료되지 않는다.
2. 홈 탭에서 한 번 더 완료한다.
- [ ] 이때는 앱을 벗어난다(런처로 나간다).

### 3. 인증 관문 — 뒤로 가서 관문을 건너뛰지 못한다
### 3. 인증 관문: 뒤로 가서 관문을 건너뛰지 못한다

1. 애프터노트 탭 진입 → 지문 인증 통과 → 애프터노트 홈.
2. 제스처를 완료한다.
- [ ] 지문 관문이 아니라 홈 탭으로 간다.
3. 다시 애프터노트 탭을 누른다.
- [ ] 지문 관문이 다시 선다(저장된 스택으로 복원되지 않는다).

### 4. 되돌리기 가드가 걸린 화면 — 제스처가 다이얼로그에 막힌다
### 4. 되돌리기 가드가 걸린 화면: 제스처가 다이얼로그에 막힌다

애프터노트 에디터는 미저장 변경이 있으면 `BackHandler` 로 뒤로가기를 가로챈다.

1. 애프터노트 에디터에 들어가 아무 입력이나 한 글자 넣는다.
2. 제스처를 시작한다.
- [ ] **앞 화면 미리보기가 뜨지 않는다**(가로챈 콜백은 진행 이벤트를 소비하지 않는다).
- [ ] 앞 화면 미리보기가 뜨지 않는다(가로챈 콜백은 진행 이벤트를 소비하지 않는다).
- [ ] 완료하면 이탈 확인 다이얼로그가 뜬다. 화면은 그대로다.

### 5. 앞 화면 바탕: 뒤 화면 글자가 비치지 않는다 (#2145)

화면 대부분이 투명한 `Scaffold` 라, 목적지가 바탕을 깔지 않으면 줄어든 앞 화면 사이로 뒤 화면이 보인다.
설정과 애프터노트의 Nav3 로컬 스택, 로컬 스택 바닥에서 루트로 나가는 전환을 따로 본다.

1. 설정 > 알림 설정으로 들어가 30% 쯤 끌고 멈춘다(Nav3 로컬 스택 안).
- [ ] 줄어든 알림 설정 화면 안에 설정 목록의 글자·프로필이 겹쳐 보이지 않는다.
2. 노트 탭 > 애프터노트 한 건의 상세로 들어가 같은 제스처로 멈춘다(Nav3 로컬 스택 안).
- [ ] 줄어든 상세 화면 안에 애프터노트 목록의 카드·탭 글자가 겹쳐 보이지 않는다.
3. 애프터노트 홈(로컬 스택 바닥)에서 같은 제스처로 멈춘다(루트로 빠지는 Nav3 host).
- [ ] 줄어든 애프터노트 홈 안에 홈 탭의 로고·문구가 겹쳐 보이지 않는다.

타임레터·마음의 기록은 Nav2 그래프를 사용하므로 이 공통 Nav3 바탕의 적용 대상이 아니다.
두 화면의 잔여 결함은 #2149(타임레터)·#2150(마음의 기록)에서 추적하며, 해당 화면이 Nav3로 전환되면 같은 절차로 확인한다.

## 결과 기록

실행 결과는 커밋별 증거 대장(`docs/qa/evidence/`) 형식에 맞춰 남긴다.
Expand Down
Loading