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
166 changes: 166 additions & 0 deletions app/src/androidTest/java/com/yapp/plus/navigation/MyPageUiTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package com.yapp.plus.navigation

import android.graphics.Bitmap
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
import com.yapp.plus.core.designsystem.theme.YappTheme
import com.yapp.plus.feature.main.my.MyAttendanceHistoryScreen
import com.yapp.plus.feature.main.my.MyPreviewData
import com.yapp.plus.feature.main.my.MyScreen
import com.yapp.plus.feature.main.my.model.MyAttendanceHistoryState
import com.yapp.plus.feature.main.my.model.MyProfileUiState
import com.yapp.plus.feature.main.my.model.MySessionSummary
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.io.File

class MyPageUiTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun historyNavigationAndRulesWorkWithoutInlineExpansion() {
var history by mutableStateOf(false)
var myState by mutableStateOf(MyPreviewData.state)
composeRule.setContent {
YappTheme(darkTheme = false, dynamicColor = false) {
Box(
Modifier
.requiredSize(
393.dp,
756.dp,
).background(Color.White)
.testTag("my-screen"),
) {
if (history) {
MyAttendanceHistoryScreen(
state = MyPreviewData.history,
onBack = { history = false },
onRetry = {},
onLoadMore = {},
)
} else {
MyScreen(
state = myState,
onAttendanceHistoryClick = { history = true },
onContactClick = {},
)
}
}
}
}
saveScreenshot("my-summary")
composeRule.onNodeWithContentDescription("출석 규칙").performClick()
composeRule.onNodeWithText("1시간 지각/조퇴").assertIsDisplayed()
composeRule.onNodeWithText("-20점").assertIsDisplayed()
saveWindowScreenshot("my-rules")
composeRule.onNodeWithText("확인").performClick()
composeRule.onNodeWithText("출석 내역").performClick()
composeRule.onNodeWithText("세션 출석 내역").assertIsDisplayed()
composeRule.onNodeWithText("개발-디자인 세션").assertIsDisplayed()
composeRule.onNodeWithText("마이페이지").assertDoesNotExist()
saveScreenshot("my-history")
composeRule.onNodeWithContentDescription("마이페이지로 돌아가기").performClick()
composeRule.onNodeWithText("내 출석 현황").assertIsDisplayed()
composeRule.runOnIdle {
myState =
MyPreviewData.state.copy(
profile = MyProfileUiState.Unavailable,
score = MyPreviewData.score.copy(lateExemptionCount = null),
)
}
composeRule.onNodeWithText("김야뿌").assertDoesNotExist()
composeRule.onNodeWithText("지각 면제권").assertIsDisplayed()
composeRule.onNodeWithText("이용문의").assertIsDisplayed()
saveScreenshot("my-unconnected")
}

@Test
fun emptyHistoryShowsFigmaGraphicAndServerCounts() {
composeRule.setContent {
YappTheme(darkTheme = false, dynamicColor = false) {
Box(
Modifier
.requiredSize(
393.dp,
756.dp,
).background(Color.White)
.testTag("my-screen"),
) {
MyAttendanceHistoryScreen(
state =
MyAttendanceHistoryState.Content(
MySessionSummary(12, 12, 0),
emptyList(),
),
onBack = {},
onRetry = {},
onLoadMore = {},
)
}
}
}
composeRule.onNodeWithText("아직 출석 내역이 없어요").assertIsDisplayed()
composeRule.onNodeWithText("진행률").assertIsDisplayed()
saveScreenshot("my-history-empty")
}

@Test
fun loadingFailureRetriesWithoutInventingAttendance() {
var retries = 0
composeRule.setContent {
YappTheme {
MyAttendanceHistoryScreen(
state = MyAttendanceHistoryState.Error,
onBack = {},
onRetry = { retries++ },
onLoadMore = {},
)
}
}
composeRule.onNodeWithText("아직 출석 내역이 없어요").assertDoesNotExist()
composeRule.onNodeWithText("다시 시도").performClick()
composeRule.runOnIdle { assertEquals(1, retries) }
}

private fun saveScreenshot(name: String) {
val bitmap = composeRule.onNodeWithTag("my-screen").captureToImage().asAndroidBitmap()
saveBitmap(bitmap, name)
}

private fun saveWindowScreenshot(name: String) {
val bitmap =
checkNotNull(InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot())
saveBitmap(bitmap, name)
}

private fun saveBitmap(
bitmap: Bitmap,
name: String,
) {
val directory =
File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "my-figma")
directory.mkdirs()
File(directory, "$name.png").outputStream().use {
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.yapp.plus.core.designsystem.theme.YappTheme
import com.yapp.plus.testing.NavigationTestActivity
Expand All @@ -41,6 +43,25 @@ class SessionOperationNavigationUiTest {
assertSessionOperationReturnsToLogin(isLogout = false)
}

@Test
fun historyRestoresWithoutTabsAndSystemBackReturnsToMy() {
val restoration = StateRestorationTester(composeRule)
restoration.setContent {
YappTheme {
MainTabScaffold(initialTab = MainTab.My)
}
}
composeRule.onNodeWithText("MY").assertIsDisplayed()
composeRule.onNodeWithText("출석 내역").performClick()
composeRule.onNodeWithText("MY").assertDoesNotExist()
restoration.emulateSavedInstanceStateRestore()
composeRule.onNodeWithContentDescription("마이페이지로 돌아가기").assertIsDisplayed()
composeRule.onNodeWithText("MY").assertDoesNotExist()
Espresso.pressBack()
composeRule.onNodeWithText("마이페이지").assertIsDisplayed()
composeRule.onNodeWithText("MY").assertIsDisplayed()
}

private fun assertSessionOperationReturnsToLogin(isLogout: Boolean) {
var hasSession by mutableStateOf(true)
var logoutRequests = 0
Expand Down
14 changes: 12 additions & 2 deletions app/src/main/java/com/yapp/plus/navigation/MainTabScaffold.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,29 @@ internal fun MainTabScaffold(
) {
var selectedTab by rememberSaveable(initialTab) { mutableStateOf(initialTab) }
var isSettingsVisible by rememberSaveable { mutableStateOf(false) }
var isMyHistoryVisible by rememberSaveable { mutableStateOf(false) }
var selectedNoticeId by rememberSaveable { mutableStateOf<Long?>(null) }
BackHandler(enabled = selectedTab == MainTab.Board && selectedNoticeId != null) {
selectedNoticeId = null
}
BackHandler(enabled = selectedTab == MainTab.My && isSettingsVisible) {
isSettingsVisible = false
}
BackHandler(enabled = selectedTab == MainTab.My && isMyHistoryVisible) {
isMyHistoryVisible = false
}
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = YappMainColor.surface,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
if (!isSettingsVisible && selectedNoticeId == null) {
if (!isSettingsVisible && !isMyHistoryVisible && selectedNoticeId == null) {
MainTabNavigation(
selectedTab = selectedTab,
onTabSelected = {
selectedTab = it
isSettingsVisible = false
isMyHistoryVisible = false
selectedNoticeId = null
},
)
Expand Down Expand Up @@ -98,7 +103,12 @@ internal fun MainTabScaffold(
onDeleteAccountErrorDismissed = onDeleteAccountErrorDismissed,
)
} else {
MyRoute(onSettingsClick = { isSettingsVisible = true })
MyRoute(
onSettingsClick = { isSettingsVisible = true },
onAttendanceHistoryClick = { isMyHistoryVisible = true },
onBack = { isMyHistoryVisible = false },
isHistoryVisible = isMyHistoryVisible,
)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/product-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ Notion 표에는 출석·지각·결석의 시간 구간이 제시되어 있지
- 사용자가 [Figma](https://www.figma.com/design/LUFGoDnVmwJT43QhecBI8x/YAPP-?node-id=222-40302)를 My와 설정의 UI/UX 기준으로 지정했습니다. 설정의 앱 버전, 탈퇴 문의 이메일·메뉴와 확장 안내를 제거합니다.
- 탈퇴 확인은 “정말 탈퇴하시겠어요?”, “탈퇴하시면 모든 정보가 삭제돼요.”, “취소 / 탈퇴하기”를 사용합니다. 서버의 비식별화·이력 보존과 다른 문구임을 설명한 뒤 사용자가 Figma를 따르도록 재확인했습니다. 서버 계약을 바꾸는 결정은 아닙니다.
- 미지원 API는 사용자 요청대로 UI를 유지하고 연결만 비웁니다. 설정의 알림 토글은 Figma 초기 상태에서 화면 내 변경·복원만 제공하며 실제 푸시 설정 저장 성공으로 취급하지 않습니다.
- 프로필 일러스트·회원 배지·기수·직군 컴포넌트와 debug Preview를 준비했습니다. 미확인 값은 `—`로 표시합니다. 메인은 프로필·출석 요약·규칙·메뉴를 조합하고 별도 출석 내역 화면은 상태·재시도·추가 조회 콜백을 받습니다. ViewModel과 App 이동 연결은 후속입니다.
- 프로필 일러스트·회원 배지·기수·직군 컴포넌트와 debug Preview를 준비했습니다. 미확인 값은 `—`로 표시합니다. 메인은 프로필·출석 요약·규칙·메뉴를 조합하고 별도 출석 내역 화면은 상태·재시도·추가 조회 콜백을 받습니다. ViewModel이 인증 응답에 저장한 이름과 서버 출석 점수·커서 이력을 조회하고 App이 출석 내역 진입·뒤로가기·복원을 관리합니다.
- 출석 요약 카드·서버 상태별 배지·규칙 팝업과 표시 모델·개별 Preview를 준비했습니다. 값이 없는 요약은 `—`로 표시하고 면제권을 조퇴 횟수로 대체하지 않습니다. 규칙 문구는 Figma 원문을 표시하며 클라이언트 판정 로직으로 추가하지 않습니다.
- 적용 상태: 설정과 My 메인·별도 출석 내역 UI를 반영했습니다. 실제 조회·화면 이동 연결은 후속 PR입니다. 근거는 [이슈 #160](https://github.com/YAPP-admin/yapp-plus-android/issues/160)과 DEC-0043입니다.
- 적용 상태: 설정과 My 메인·별도 출석 내역 UI, 이름·점수·이력 조회와 App 이동을 연결했습니다. 미확인 회원 정보·면제권·알림 설정·이용 문의 URL은 연결만 비웁니다. 근거는 [이슈 #160](https://github.com/YAPP-admin/yapp-plus-android/issues/160)과 DEC-0043입니다.

## 계정·기수 통합 탈퇴

Expand Down
11 changes: 10 additions & 1 deletion feature/main/my/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
plugins {
id("yapp.android.compose-library")
id("yapp.hilt")
}

android {
namespace = "com.yapp.plus.feature.main.my"
}

dependencies {
implementation(project(":core:designsystem"))
implementation(project(":domain"))
implementation(project(":core:designsystem"))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.hilt.android)
implementation(libs.kotlinx.coroutines.core)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.androidx.compose.ui.tooling)
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
package com.yapp.plus.feature.main.my

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yapp.plus.feature.main.my.model.MyAttendanceScoreState

@Composable
fun MyRoute(
onSettingsClick: () -> Unit,
onAttendanceHistoryClick: () -> Unit,
onBack: () -> Unit,
isHistoryVisible: Boolean,
modifier: Modifier = Modifier,
onContactClick: () -> Unit = {},
) {
MyScreen(
onSettingsClick = onSettingsClick,
modifier = modifier,
)
val viewModel: MyViewModel = hiltViewModel()
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(isHistoryVisible) {
viewModel.fetchProfile()
viewModel.fetchScore()
if (isHistoryVisible) viewModel.fetchHistory()
}
if (isHistoryVisible) {
MyAttendanceHistoryScreen(
state = state.attendanceHistory,
absentCount = (state.score as? MyAttendanceScoreState.Content)?.score?.absentCount,
onBack = onBack,
onRetry = viewModel::fetchHistory,
onLoadMore = viewModel::fetchNextPage,
modifier = modifier,
)
} else {
MyScreen(
state = state,
onRetryScore = viewModel::fetchScore,
onAttendanceHistoryClick = onAttendanceHistoryClick,
onSettingsClick = onSettingsClick,
onContactClick = onContactClick,
modifier = modifier,
)
}
}
Loading
Loading