diff --git a/.editorconfig b/.editorconfig index 35fccf7..8bf3def 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,11 @@ [*.{kt,kts}] ktlint_standard_annotation = disabled -ktlint_function_naming_ignore_when_annotated_with = Composable \ No newline at end of file +ktlint_function_naming_ignore_when_annotated_with = Composable + +[app/src/test/java/**.kt] +ktlint_function_naming = disabled +ktlint_standard_function-naming = disabled + +[app/src/androidTest/java/**.kt] +ktlint_function_naming = disabled +ktlint_standard_function-naming = disabled \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b950ef..8448aa7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,7 +50,13 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - testImplementation(libs.junit) + + // 네비게이션 바를 위한 의존성 추가 + implementation("androidx.navigation:navigation-compose:2.8.3") + + // 테스트 케이스 작성을 위한 의존성 추가 + testImplementation("junit:junit:4.13.2") + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordDetailScreenCalendarTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordDetailScreenCalendarTest.kt new file mode 100644 index 0000000..69ed9f7 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordDetailScreenCalendarTest.kt @@ -0,0 +1,68 @@ +package com.sprinter.studyspot + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.ui.RecordDetailScreen +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * RecordDetailScreen 내부의 달력 UI 동작 테스트 + * 요일 표시, 5주 그리드 생성, 날짜 정렬 여부 확인 + * 공부한 날짜 강조 및 날짜 선택 기능 검증 + **/ +class RecordDetailScreenCalendarTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun calendar_dayClick_selectsStudyDay() { + composeRule.setContent { + RecordDetailScreen( + navController = rememberNavController(), + recordId = "1", + timerViewModel = TimerViewModel(), + ) + } + + // 4일은 studyDaySet = setOf(4, 6, 12, 13, 19)에 포함됨 + composeRule.onNodeWithContentDescription("day-4").assertExists() + + // 클릭 + composeRule.onNodeWithContentDescription("day-4").performClick() + + // 선택되면 색상이 FF8686 (선택 색) + composeRule + .onNodeWithContentDescription("day-4") + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.ContentDescription, + listOf("day-4"), + ), + ) + } + + @Test + fun monthNavigation_buttonsWork() { + composeRule.setContent { + RecordDetailScreen( + navController = rememberNavController(), + recordId = "1", + timerViewModel = TimerViewModel(), + ) + } + + // → 버튼 클릭 (다음 달) + composeRule.onNodeWithContentDescription("Next Month").performClick() + + // ← 버튼 클릭 (이전 달) + composeRule.onNodeWithContentDescription("Prev Month").performClick() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordDetailScreenUITest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordDetailScreenUITest.kt new file mode 100644 index 0000000..e6d650c --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordDetailScreenUITest.kt @@ -0,0 +1,68 @@ +package com.sprinter.studyspot + +import androidx.activity.ComponentActivity +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.compose.rememberNavController +import com.sprinter.studyspot.timer.ui.RecordDetailScreen +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * RecordDetailScreen의 전체 UI 구성 요소가 정상 렌더링되는지 확인 + * 제목, 색상 배지, 달력 UI, 월 이동 버튼, 일별 기록 리스트 등의 UI 체크 + */ +class RecordDetailScreenUITest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun recordDetailScreen_displaysTitleAndHeaders() { + // 🔸 테스트용 ViewModel (records 더미 데이터 포함) + val timerViewModel = TimerViewModel() + + composeRule.setContent { + val navController = rememberNavController() + + RecordDetailScreen( + navController = navController, + recordId = "1", // TimerViewModel의 records에서 id = "1" → "자바스크립트" + title = null, // title은 ViewModel 데이터 사용 + color = null, // color도 ViewModel 데이터 사용 + timerViewModel = timerViewModel, + ) + } + + // 🔹 제목이 잘 보이는지 + composeRule.onNodeWithText("자바스크립트").assertExists() + + // 🔹 헤더 텍스트가 잘 렌더링되는지 + composeRule.onNodeWithText("시작").assertExists() + composeRule.onNodeWithText("종료").assertExists() + composeRule.onNodeWithText("집중 시간").assertExists() + } + + @Test + fun backButton_isClickable() { + composeRule.setContent { + val navController = rememberNavController() + val timerViewModel = TimerViewModel() + + RecordDetailScreen( + navController = navController, + recordId = "1", + title = null, + color = null, + timerViewModel = timerViewModel, + ) + } + + composeRule + .onNodeWithContentDescription("Back") + .assertExists() + .performClick() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordFlowNavigationTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordFlowNavigationTest.kt new file mode 100644 index 0000000..8d0cee4 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordFlowNavigationTest.kt @@ -0,0 +1,82 @@ +package com.sprinter.studyspot + +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.ui.RecordDetailScreen +import com.sprinter.studyspot.timer.ui.component.RecordItemCard +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerScreen -> 기록 탭 -> 기록 상세 화면까지의 이동 흐름을 검증 + * 뒤로 가기 버튼 클릭 시 '기록' 탭 상태가 유지되는지 확인 + */ +class RecordFlowNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun clickingRecordItem_navigatesToDetailScreen() { + composeRule.setContent { + val navController = rememberNavController() + val timerViewModel = TimerViewModel() + + NavHost(navController = navController, startDestination = "list") { + composable("list") { + RecordItemCard( + color = Color(0xFFFF8686), + title = "자바스크립트", + totalTime = "01:20", + dayCounts = mapOf(1 to 2, 2 to 3), + onClick = { + navController.navigate( + "recordDetail/1?title=자바스크립트&color=FFFF8686", + ) + }, + ) + } + + composable("recordDetail/{id}?title={title}&color={color}") { backStackEntry -> + + val id = backStackEntry.arguments?.getString("id") ?: "" + val title = backStackEntry.arguments?.getString("title") + val colorHex = backStackEntry.arguments?.getString("color") ?: "FFFF8686" + + // HEX string → Color 변환 + val color = + try { + Color(colorHex.toLong(16)) + } catch (_: Exception) { + Color.Gray + } + + RecordDetailScreen( + navController = navController, + recordId = id, + title = title, + color = color, + timerViewModel = timerViewModel, + ) + } + } + } + + // 1) 목록에서 "자바스크립트" 카드 클릭 + composeRule.onNodeWithText("자바스크립트").performClick() + + // 2) DetailScreen 제목 표시 확인 + composeRule.onNodeWithText("자바스크립트").assertExists() + + // 3) 헤더 텍스트 확인 (정상 렌더링 확인) + composeRule.onNodeWithText("시작").assertExists() + composeRule.onNodeWithText("종료").assertExists() + composeRule.onNodeWithText("집중 시간").assertExists() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordItemCardTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordItemCardTest.kt new file mode 100644 index 0000000..ca8828b --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordItemCardTest.kt @@ -0,0 +1,54 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.sprinter.studyspot.timer.ui.component.RecordItemCard +import org.junit.Rule +import org.junit.Test + +/** + * RecordItemCard 내부 데이터 로직 테스트 + * 색상 표시, 타이틀, 총 공부 시간, 요일별 dayCounts 로직 검증 + */ +class RecordItemCardTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun recordItemCard_rendersCorrectly() { + composeRule.setContent { + RecordItemCard( + color = Color(0xFFFF8686), + title = "파이썬", + totalTime = "00:45", + dayCounts = mapOf(1 to 1, 3 to 2), + onClick = {}, + ) + } + + composeRule.onNodeWithText("파이썬").assertExists() + composeRule.onNodeWithText("00:45").assertExists() + } + + @Test + fun recordItemCard_click_works() { + var clicked = false + + composeRule.setContent { + RecordItemCard( + color = Color(0xFFFF8686), + title = "파이썬", + totalTime = "00:45", + dayCounts = emptyMap(), + onClick = { clicked = true }, + ) + } + + composeRule.onNodeWithText("파이썬").performClick() + + assert(clicked) + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordItemCardUITest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordItemCardUITest.kt new file mode 100644 index 0000000..def21d2 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordItemCardUITest.kt @@ -0,0 +1,57 @@ +package com.sprinter.studyspot + +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.sprinter.studyspot.timer.ui.component.RecordItemCard +import org.junit.Rule +import org.junit.Test + +/** + * RecordItemCard UI 컴포저블의 실제 화면 렌더링 테스트 + * 클릭 동작, 레이아웃 배치, 색상 표시 등이 정상적으로 보이는지 검증 + */ +class RecordItemCardUITest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun recordItemCard_rendersCorrectly() { + composeRule.setContent { + RecordItemCard( + color = Color(0xFFFF8686), + title = "자바스크립트", + totalTime = "01:20", + dayCounts = mapOf(1 to 2, 3 to 1), + onClick = {}, + ) + } + + // 타이틀 + composeRule.onNodeWithText("자바스크립트").assertExists() + + // 총 시간 표시 + composeRule.onNodeWithText("01:20").assertExists() + } + + @Test + fun recordItemCard_onClick_isTriggered() { + var clicked = false + + composeRule.setContent { + RecordItemCard( + color = Color(0xFFFF8686), + title = "테스트 카드", + totalTime = "00:30", + dayCounts = mapOf(), + onClick = { clicked = true }, + ) + } + + composeRule.onNodeWithText("테스트 카드").performClick() + + assert(clicked) + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordNavigationTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordNavigationTest.kt new file mode 100644 index 0000000..dece1c2 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordNavigationTest.kt @@ -0,0 +1,82 @@ +package com.sprinter.studyspot + +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.ui.RecordDetailScreen +import com.sprinter.studyspot.timer.ui.component.RecordItemCard +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerScreen에서 기록 탭 전환 및 기록 리스트 렌더링 검증 + * RecordItemCard가 정상적으로 표시되는지 확인 + */ +class RecordNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun clickingRecordItem_navigatesToDetailScreen() { + composeRule.setContent { + val navController = rememberNavController() + + // 🔥 테스트 환경에서 ViewModel 정상 생성 + val timerViewModel = + ViewModelProvider( + composeRule.activity, + )[TimerViewModel::class.java] + + NavHost(navController = navController, startDestination = "list") { + composable("list") { + RecordItemCard( + color = Color(0xFFFF8686), + title = "자바스크립트", + totalTime = "01:20", + dayCounts = mapOf(1 to 2, 2 to 3), + onClick = { + navController.navigate( + "recordDetail/1?title=자바스크립트&color=FFFF8686", + ) + }, + ) + } + + composable("recordDetail/{id}?title={title}&color={color}") { backStackEntry -> + + val id = backStackEntry.arguments?.getString("id") ?: "" + val title = backStackEntry.arguments?.getString("title") ?: "" + val colorHex = backStackEntry.arguments?.getString("color") ?: "FF8686" + + val color = + try { + Color(colorHex.toLong(16)) + } catch (_: Exception) { + Color.Gray + } + + RecordDetailScreen( + navController = navController, + recordId = id, + title = title, + color = color, + timerViewModel = timerViewModel, // 🔥 수정된 ViewModel 전달 + ) + } + } + } + + // 목록 화면에서 → 클릭 + composeRule.onNodeWithText("자바스크립트").performClick() + + // 상세 화면 표시 확인 + composeRule.onNodeWithText("자바스크립트").assertExists() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/RecordTabNavigationTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/RecordTabNavigationTest.kt new file mode 100644 index 0000000..36f25ad --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/RecordTabNavigationTest.kt @@ -0,0 +1,74 @@ +package com.sprinter.studyspot.record.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.ui.TimerScreen +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +class RecordTabNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun 기록탭_클릭시_기록화면으로_전환된다() { + composeRule.setContent { + val navController = rememberNavController() + val viewModel = TimerViewModel() + + TimerScreen( + navController = navController, + timerViewModel = viewModel, + ) + } + + // 기록 탭 클릭 + composeRule + .onNodeWithText("기록") + .assertExists() + .performClick() + + // "총 집중 시간"이 기록 화면에 존재하는지 확인 + composeRule + .onNodeWithText("총 집중 시간") + .assertExists() + } + + @Test + fun 기록탭_클릭하면_기록카드들이_표시된다() { + composeRule.setContent { + val navController = rememberNavController() + val viewModel = TimerViewModel() + + // 더미 기록 추가 + viewModel.addRecordForTest( + title = "테스트 기록", + color = androidx.compose.ui.graphics.Color.Red, + totalTime = "00:20:00", + dayCounts = mapOf(1 to 1), + ) + + TimerScreen( + navController = navController, + timerViewModel = viewModel, + ) + } + + // 기록 탭 클릭 + composeRule + .onNodeWithText("기록") + .performClick() + + val nodes = + composeRule + .onAllNodes(hasTestTag("record_card")) + .fetchSemanticsNodes() + + assert(nodes.isNotEmpty()) + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerProgressComposableTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerProgressComposableTest.kt new file mode 100644 index 0000000..b1706f5 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerProgressComposableTest.kt @@ -0,0 +1,42 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.junit.Rule +import org.junit.Test + +/** + * TimerScreen의 "타이머" ↔ "기록" 탭 전환을 테스트 + * ViewModel의 selectedTab 상태 유지 여부 검증 + */ +class TimerProgressComposableTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun timerProgress_rendersCanvas() { + composeRule.setContent { + TimerProgress( + totalSeconds = 3600L, // 1시간 + elapsedSeconds = 0L, // 아직 경과 X + ) + } + + // Canvas가 정상적으로 렌더링되는지 체크 + composeRule.onNodeWithTag("TimerProgressCanvas").assertExists() + } + + @Test + fun timerProgress_updatesOnElapsedTimeChange() { + composeRule.setContent { + TimerProgress( + totalSeconds = 3600L, + elapsedSeconds = 1800L, // 절반 경과 + ) + } + + // 절반 진행이라도, 렌더링 존재 여부 확인 + composeRule.onNodeWithTag("TimerProgressCanvas").assertExists() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerRecordDetailBackTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerRecordDetailBackTest.kt new file mode 100644 index 0000000..a435c42 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerRecordDetailBackTest.kt @@ -0,0 +1,70 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerProgress 컴포저블의 원형 진행바 그리기 로직 테스트 + * totalSeconds / elapsedSeconds 변화에 따른 UI 업데이트 검증 + */ +class TimerRecordDetailBackTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun recordDetail_back_returnsToRecordTab() { + val vm = TimerViewModel() + + composeTestRule.setContent { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = "timer", + ) { + composable("timer") { + TimerScreen( + navController = navController, + timerViewModel = vm, + modifier = Modifier.fillMaxSize(), + ) + } + composable("recordDetail/{id}") { backStackEntry -> + + val recordId = backStackEntry.arguments?.getString("id") ?: "" + RecordDetailScreen( + navController = navController, + recordId = recordId, + timerViewModel = vm, + ) + } + } + } + + // 1) 기록 탭으로 이동 + composeTestRule.onNodeWithText("기록").performClick() + + // 2) 첫 번째 기록 카드 클릭 → 상세화면으로 이동 + val record = vm.records.first() + composeTestRule.onNodeWithText(record.title).performClick() + + // 상세화면에서 "뒤로가기" 버튼 클릭 + composeTestRule.onNodeWithContentDescription("뒤로가기").performClick() + + // 돌아왔을 때 "기록" 탭이 선택되어 있는지 확인 + composeTestRule.onNodeWithText("기록").assertExists() + + // 기록 탭 내 기록 리스트가 다시 보이는지 체크 + composeTestRule.onNodeWithText(record.title).assertExists() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerRecordDetailNavigationTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerRecordDetailNavigationTest.kt new file mode 100644 index 0000000..effc33e --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerRecordDetailNavigationTest.kt @@ -0,0 +1,64 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * 기록 상세 화면으로 이동 + 뒤로가기 시 ‘기록’ 탭이 유지되는지 검증하는 테스트 + */ +class TimerRecordDetailNavigationTest { + @get:Rule + val composeTestRule = createComposeRule() + + // navController를 외부에서 접근 가능하게 저장 + private lateinit var navController: NavHostController + + @Test + fun clickRecordCard_navigatesToRecordDetailScreen() { + // GIVEN - Fake ViewModel + val vm = TimerViewModel() + + composeTestRule.setContent { + navController = rememberNavController() + + TimerScreen( + navController = navController, + timerViewModel = vm, + modifier = Modifier.fillMaxSize(), + ) + } + + // WHEN - 기록 탭 선택 + composeTestRule + .onNodeWithText("기록") + .assertExists() + .performClick() + + // WHEN - 첫 번째 기록 카드 선택 + val firstRecord = vm.records.first() + + composeTestRule + .onNodeWithText(firstRecord.title) + .assertExists() + .performClick() + + // THEN - NavController 의 현재 route 확인 + var currentRoute: String? = null + composeTestRule.runOnIdle { + currentRoute = navController.currentBackStackEntry?.destination?.route + } + + println("📌 current route = $currentRoute") + + // 상세화면 경로 검증 + assert(currentRoute?.startsWith("recordDetail") == true) + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerRunningScreenTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerRunningScreenTest.kt new file mode 100644 index 0000000..7361c39 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerRunningScreenTest.kt @@ -0,0 +1,50 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerRunningScreen의 기본 UI 요소 테스트 + * 남은 시간 표시, 일시정지/재생 버튼, 종료 버튼 UI 확인 + */ +class TimerRunningScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun 타이머실행화면_UI가_정상_렌더링된다() { + composeRule.setContent { + val navController = rememberNavController() + val viewModel = TimerViewModel() + + TimerRunningScreen( + navController = navController, + color = Color(0xFFFF8686), + title = "공부", + totalSeconds = 600, // 10분 + ) + } + + // 남은 시간 텍스트 확인 + composeRule.onNodeWithText("남은시간").assertExists() + + // 재생/일시정지 버튼 존재 확인 + composeRule + .onNode( + hasContentDescription("pause_button") + .or(hasContentDescription("play_button")), + ).assertExists() + + // 종료 버튼 존재 확인 + composeRule + .onNode(hasContentDescription("stop_button")) + .assertExists() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenNavigationTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenNavigationTest.kt new file mode 100644 index 0000000..ac4eae5 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenNavigationTest.kt @@ -0,0 +1,52 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.activity.ComponentActivity +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.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerScreen에서 TimerSettingScreen / TimerRunningScreen으로 이동 테스트 + * title, color, hour/minute/second 값 전달 여부 검증 + */ +class TimerScreenNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun navigate_from_timer_to_setting() { + composeRule.setContent { + val navController = rememberNavController() + val vm = TimerViewModel() // <- 필수 + + NavHost(navController = navController, startDestination = "timer") { + composable("timer") { + TimerScreen( + navController = navController, + timerViewModel = vm, // <- 필수 + ) + } + + composable("setting") { + TimerSettingScreen( + navController = navController, + timerViewModel = vm, // <- 필수 + ) + } + } + } + + // "+" 버튼 클릭 (contentDescription은 "추가") + composeRule.onNodeWithContentDescription("추가").performClick() + + // TimerSettingScreen 진입 확인 + composeRule.onNodeWithText("타이머 이름").assertExists() + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenRecordTabTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenRecordTabTest.kt new file mode 100644 index 0000000..5e3ace0 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenRecordTabTest.kt @@ -0,0 +1,39 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerScreen에서 기록 탭 UI 렌더링을 테스트 + * RecordItemCard 목록이 정상적으로 표시되는지 확인 + */ +class TimerScreenRecordTabTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun 기록탭_선택시_기록리스트가_표시된다() { + composeRule.setContent { + val viewModel = TimerViewModel() + TimerScreen( + navController = rememberNavController(), + timerViewModel = viewModel, + ) + } + + // when: 탭 바에서 "기록" 탭 클릭 + composeRule.onNodeWithText("기록").performClick() + + // then: Dummy 기록 데이터 중 하나의 제목이 화면에 표시되어야 한다. + // 🔹 TimerViewModelTest 에서 getRecordById("1") 의 title 이 "자바스크립트" 였으니까 그걸 기준으로 검증 + composeRule + .onNodeWithText("자바스크립트") + .assertExists("기록 탭 선택 후 '자바스크립트' 기록 카드가 표시되지 않았습니다.") + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenTest.kt new file mode 100644 index 0000000..6a5cebe --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerScreenTest.kt @@ -0,0 +1,54 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerScreen 기본 UI 렌더링 테스트 + * 전체 시간, 원형 그래프, 탭 UI, 타이머 리스트 표시 확인 + */ +class TimerScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun 기록탭_선택시_RecordItemCard_표시된다() { + // 더미 ViewModel 준비 + val viewModel = + TimerViewModel().apply { + // 기록이 존재하도록 더미 데이터 추가 + addRecordForTest( + title = "자바스크립트", + color = Color(0xFFFF8686), + totalTime = "01:20", + dayCounts = mapOf(1 to 2, 3 to 4), + ) + } + + composeTestRule.setContent { + TimerScreen( + navController = rememberNavController(), + timerViewModel = viewModel, + ) + } + + // 탭 바에서 "기록" 클릭 + composeTestRule + .onNode( + hasText("기록") and hasClickAction(), + ).performClick() + + // RecordItemCard 표시 여부 확인 + composeTestRule + .onNodeWithTag("record_card") + .assertExists("RecordItemCard가 표시되지 않았습니다.") + } +} diff --git a/app/src/androidTest/java/com/sprinter/studyspot/TimerSettingScreenInputTest.kt b/app/src/androidTest/java/com/sprinter/studyspot/TimerSettingScreenInputTest.kt new file mode 100644 index 0000000..3caa169 --- /dev/null +++ b/app/src/androidTest/java/com/sprinter/studyspot/TimerSettingScreenInputTest.kt @@ -0,0 +1,36 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import org.junit.Rule +import org.junit.Test + +/** + * TimerSettingScreen의 입력 동작을 테스트 + * 타이틀 입력, 시간 선택, 색상 선택, 저장/수정 기능 검증 + */ +class TimerSettingScreenInputTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun timerSetting_hoursInputWorks() { + composeRule.setContent { + val vm = TimerViewModel() + + TimerSettingScreen( + navController = null, + timerViewModel = vm, // ✅ 필수 파라미터 추가 + initialTitle = "테스트", + initialHour = 0, + initialMinute = 0, + initialSecond = 0, + ) + } + + composeRule.onNodeWithText("시간").performClick() + } +} diff --git a/app/src/main/java/com/sprinter/studyspot/MainActivity.kt b/app/src/main/java/com/sprinter/studyspot/MainActivity.kt index c76b347..63b5389 100644 --- a/app/src/main/java/com/sprinter/studyspot/MainActivity.kt +++ b/app/src/main/java/com/sprinter/studyspot/MainActivity.kt @@ -1,50 +1,20 @@ +// MainActivity.kt package com.sprinter.studyspot import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.sprinter.studyspot.ui.theme.StudySpot_AndroidTheme +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.navigation.AppNavGraph class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() setContent { - StudySpot_AndroidTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding), - ) - } - } - } - } -} + window.statusBarColor = android.graphics.Color.WHITE -@Composable -fun Greeting( - name: String, - modifier: Modifier = Modifier, -) { - Text( - text = "Hello $name!", - modifier = modifier, - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - StudySpot_AndroidTheme { - Greeting("Android") + val navController = rememberNavController() + AppNavGraph(navController = navController) + } } } diff --git a/app/src/main/java/com/sprinter/studyspot/navigation/AppNavGraph.kt b/app/src/main/java/com/sprinter/studyspot/navigation/AppNavGraph.kt new file mode 100644 index 0000000..c8b30d3 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/navigation/AppNavGraph.kt @@ -0,0 +1,242 @@ +package com.sprinter.studyspot.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.navArgument +import com.sprinter.studyspot.timer.ui.RecordDetailScreen // ✅ 추가 +import com.sprinter.studyspot.timer.ui.TimerRunningScreen +import com.sprinter.studyspot.timer.ui.TimerScreen +import com.sprinter.studyspot.timer.ui.TimerSettingScreen +import com.sprinter.studyspot.timer.ui.component.BottomNavBar +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +@Composable +fun AppNavGraph(navController: NavHostController) { + val timerViewModel: TimerViewModel = viewModel() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route ?: "" + + Scaffold( + bottomBar = { + if (!currentRoute.startsWith("timerSetting") && + !currentRoute.startsWith("timerRunning") && + !currentRoute.startsWith("recordDetail") + ) { + BottomNavBar(navController = navController, currentRoute = currentRoute) + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = "timer", + modifier = + androidx.compose.ui.Modifier + .padding(innerPadding), + ) { + // ✅ 타이머 메인 화면 + composable( + route = "timer?title={title}&color={color}&hour={hour}&minute={minute}&second={second}", + arguments = + listOf( + navArgument("title") { + type = NavType.StringType + defaultValue = "기본 타이머" + }, + navArgument("color") { + type = NavType.StringType + defaultValue = "0" + }, + navArgument("hour") { + type = NavType.IntType + defaultValue = 0 + }, + navArgument("minute") { + type = NavType.IntType + defaultValue = 0 + }, + navArgument("second") { + type = NavType.IntType + defaultValue = 0 + }, + ), + ) { backStackEntry -> + val title = backStackEntry.arguments?.getString("title") ?: "기본 타이머" + val colorValue = backStackEntry.arguments?.getString("color") ?: "0" + val hour = backStackEntry.arguments?.getInt("hour") ?: 0 + val minute = backStackEntry.arguments?.getInt("minute") ?: 0 + val second = backStackEntry.arguments?.getInt("second") ?: 0 + + TimerScreen( + navController = navController, + timerViewModel = timerViewModel, + colorValue = colorValue, + title = title, + hour = hour, + minute = minute, + second = second, + ) + } + + // ✅ 타이머 설정 화면 + composable( + route = "timerSetting?id={id}&title={title}&color={color}&hour={hour}&minute={minute}&second={second}", + arguments = + listOf( + navArgument("id") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("title") { + type = NavType.StringType + defaultValue = "자바스크립트" + }, + navArgument("color") { + type = NavType.StringType + defaultValue = "0" + }, + navArgument("hour") { + type = NavType.IntType + defaultValue = 0 + }, + navArgument("minute") { + type = NavType.IntType + defaultValue = 0 + }, + navArgument("second") { + type = NavType.IntType + defaultValue = 0 + }, + ), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("id") ?: "" + val title = backStackEntry.arguments?.getString("title") ?: "자바스크립트" + val colorValue = backStackEntry.arguments?.getString("color") ?: "0" + val hour = backStackEntry.arguments?.getInt("hour") ?: 0 + val minute = backStackEntry.arguments?.getInt("minute") ?: 0 + val second = backStackEntry.arguments?.getInt("second") ?: 0 + + val initialColor = + if (colorValue == "0" || colorValue.isBlank()) { + null + } else { + try { + Color(colorValue.toULong()) + } catch (e: Exception) { + null + } + } + + TimerSettingScreen( + navController = navController, + timerViewModel = timerViewModel, + initialTitle = title, + initialHour = hour, + initialMinute = minute, + initialSecond = second, + initialColor = initialColor, + timerId = if (id.isNotEmpty()) id else null, + ) + } + + // ✅ 타이머 실행 화면 + composable( + route = "timerRunning?title={title}&color={color}&total={total}", + arguments = + listOf( + navArgument("title") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("color") { + type = NavType.StringType + defaultValue = "0" + }, + navArgument("total") { + type = NavType.LongType + defaultValue = 0L + }, + ), + ) { backStackEntry -> + val rawTitle = backStackEntry.arguments?.getString("title") ?: "기본 타이머" + val title = URLDecoder.decode(rawTitle, StandardCharsets.UTF_8.toString()) + val colorValue = backStackEntry.arguments?.getString("color") ?: "0" + val total = backStackEntry.arguments?.getLong("total") ?: 0L + + val color = + try { + if (colorValue == "0" || colorValue.isBlank()) { + Color(0xFFD9D9D9) + } else { + Color(colorValue.toULong()) + } + } catch (e: Exception) { + Color(0xFFD9D9D9) + } + + TimerRunningScreen( + navController = navController, + title = title, + color = color, + totalSeconds = total, + ) + } + +// ✅ 기록 상세 화면 + composable( + route = "recordDetail/{recordId}?title={title}&color={color}", + arguments = + listOf( + navArgument("recordId") { type = NavType.StringType }, + navArgument("title") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("color") { + type = NavType.StringType + defaultValue = "0" + }, // ← 수정됨 + ), + ) { backStackEntry -> + + val recordId = backStackEntry.arguments?.getString("recordId") ?: "" + + // title 디코딩 + val rawTitle = backStackEntry.arguments?.getString("title") ?: "" + val title = URLDecoder.decode(rawTitle, StandardCharsets.UTF_8.toString()) + + // color 변환 (문자열 → Color) + val rawColor = backStackEntry.arguments?.getString("color") ?: "0" + val color = + try { + if (rawColor == "0" || rawColor.isBlank()) { + Color(0xFFD9D9D9) + } else { + Color(rawColor.toULong()) + } + } catch (e: Exception) { + Color(0xFFD9D9D9) + } + + RecordDetailScreen( + navController = navController, + recordId = recordId, + title = title, + color = color, + timerViewModel = timerViewModel, + ) + } + } + } +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/model/RecordDetailItem.kt b/app/src/main/java/com/sprinter/studyspot/timer/model/RecordDetailItem.kt new file mode 100644 index 0000000..df04ed0 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/model/RecordDetailItem.kt @@ -0,0 +1,10 @@ +package com.sprinter.studyspot.timer.model + +import androidx.compose.ui.graphics.Color + +data class RecordDetailItem( + val title: String, // 타이머명 + val color: Color, // 컬러 + val totalTimeFormatted: String, // "00:20:00" + val records: List, // 시작/종료/집중 세부 기록 +) diff --git a/app/src/main/java/com/sprinter/studyspot/timer/model/RecordDetailRowData.kt b/app/src/main/java/com/sprinter/studyspot/timer/model/RecordDetailRowData.kt new file mode 100644 index 0000000..b6f813f --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/model/RecordDetailRowData.kt @@ -0,0 +1,7 @@ +package com.sprinter.studyspot.timer.model + +data class RecordDetailRowData( + val start: String, // ex: "13:00:00" + val end: String, // ex: "13:10:00" + val duration: String, // ex: "00:10:00" +) diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/RecordDetailScreen.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/RecordDetailScreen.kt new file mode 100644 index 0000000..2c415b3 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/RecordDetailScreen.kt @@ -0,0 +1,470 @@ +package com.sprinter.studyspot.timer.ui + +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +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 androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.R +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import java.time.YearMonth + +@Composable +fun RecordDetailScreen( + navController: NavController, + recordId: String, + title: String? = null, + color: Color? = null, + timerViewModel: TimerViewModel, +) { + val record = timerViewModel.getRecordById(recordId) + val recordTitle = record?.title ?: title ?: "기록 상세" + + val recordColor: Color = + when (val c = record?.color) { + is Color -> c + is Int -> Color(c) + else -> color ?: Color(0xFFD9D9D9) + } + + val monthState = remember { mutableStateOf(YearMonth.now()) } + val monthTitle = "${monthState.value.monthValue}월" + val ym = monthState.value + val daysInMonth = ym.lengthOfMonth() + val firstDay = (ym.atDay(1).dayOfWeek.value + 6) % 7 + + val combinedDayCounts: Map = + if (recordId == "0") { + timerViewModel.records + .filter { it.id != "0" } + .flatMap { it.dayCounts.entries } + .groupBy { it.key } + .mapValues { it.value.sumOf { pair -> pair.value } } + } else { + record?.dayCounts ?: emptyMap() + } + + val studyDaySet = combinedDayCounts.keys + + val gridCells = + remember(ym) { + buildList { + repeat(firstDay) { add(null) } + for (d in 1..daysInMonth) add(d) + while (size < 35) add(null) + } + } + + var selectedDay by remember { mutableStateOf(null) } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 20.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + painter = painterResource(id = R.drawable.ic_left_arrow), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + + Spacer(modifier = Modifier.height(21.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + // 🔙 이전 기록으로 이동 + IconButton( + onClick = { + val prevId = timerViewModel.getPrevRecordId(recordId) + if (prevId != null) { + // title / color는 생략해도 ViewModel에서 다시 가져오니까 OK + navController.navigate("recordDetail/$prevId") + } + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_left_triangle), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified, + ) + } + Spacer(modifier = Modifier.width(38.dp)) + + Box( + modifier = + Modifier + .size(26.dp) + .clip(CircleShape) + .background(recordColor) + .then( + if (recordId == "0" || recordColor == Color.White) { + Modifier.border( + width = 2.dp, + color = Color.LightGray, + shape = CircleShape, + ) + } else { + Modifier + }, + ), + ) + + Spacer(modifier = Modifier.width(12.dp)) + Text(recordTitle, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.width(12.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_trashcan), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified, + ) + + Spacer(modifier = Modifier.width(38.dp)) + // ▶ 다음 기록으로 이동 + IconButton( + onClick = { + val nextId = timerViewModel.getNextRecordId(recordId) + if (nextId != null) { + navController.navigate("recordDetail/$nextId") + } + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_right_triangle), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified, + ) + } + } + + Spacer(modifier = Modifier.height(21.dp)) + + // 달력 UI + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFFF3F3F3)), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(20.dp)) + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { monthState.value = monthState.value.minusMonths(1) }) { + Icon( + painter = painterResource(id = R.drawable.ic_left_triangle), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified, + ) + } + + Spacer(modifier = Modifier.width(38.dp)) + Text(monthTitle, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.width(38.dp)) + + IconButton(onClick = { monthState.value = monthState.value.plusMonths(1) }) { + Icon( + painter = painterResource(id = R.drawable.ic_right_triangle), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 49.dp), + horizontalArrangement = Arrangement.spacedBy(30.dp), + ) { + listOf("월", "화", "수", "목", "금", "토", "일").forEach { + Text( + it, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Calendar grid + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 6.dp), + ) { + gridCells.chunked(7).forEach { week -> + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + week.forEach { dayVal -> + val isStudy = dayVal != null && studyDaySet.contains(dayVal) + val isSelected = selectedDay == dayVal + + val color = + when { + dayVal == null -> Color(0xFFE0E0E0) + isSelected -> Color(0xFFFF8686) + isStudy -> Color(0xFFB3E5C2) + else -> Color(0xFFE0E0E0) + } + + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color) + .clickable(enabled = isStudy) { + selectedDay = dayVal + }, + ) + } + } + } + } + } + } + // ------------------------- + // 상세 데이터 영역 + // ------------------------- + selectedDay?.let { day -> + + val details = + if (recordId == "0") { + // 전체 타이머 → 해당 날짜 기록 전체 + timerViewModel.getRecordDetailByDay(day) + } else { + val currentTitle = recordTitle // 예: "자바스크립트" + timerViewModel + .getRecordDetailByDay(day) + .filter { it.title == currentTitle } + } + + val totalSec = + details.sumOf { item -> + item.records.sumOf { r -> + val t = r.duration.split(":") + val h = t[0].toInt() + val m = t[1].toInt() + val s = t[2].toInt() + h * 3600 + m * 60 + s + } + } + + val totalFormatted = + String.format( + "%02d:%02d:%02d", + totalSec / 3600, + (totalSec % 3600) / 60, + totalSec % 60, + ) + + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .weight(1f), + ) { + // 🔥 전체(recordId == "0")일 때만 전체시간 카드 노출 + if (recordId == "0") { + item { + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(end = 49.dp), + horizontalArrangement = Arrangement.End, + ) { + Text("총 집중 시간", fontSize = 15.sp, color = Color.Gray) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(33.dp) + .clip(CircleShape) + .background(Color.White) + .border(2.dp, Color.LightGray, CircleShape), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text("전체 시간", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + + Text( + totalFormatted, + fontSize = 32.sp, + modifier = Modifier.padding(end = 18.dp), + ) + } + + Spacer(modifier = Modifier.height(13.dp)) + Divider(thickness = 1.dp, color = Color(0xFFDDDDDD)) + } + } + + // 🔥🔥🔥 (중요) 개별 타이머 리스트 — 전체/특정 모두 공통 + items( + items = details, + key = { it.title }, // 고유 key + ) { item -> + + Spacer(modifier = Modifier.height(26.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(33.dp) + .clip(CircleShape) + .background(item.color), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(item.title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + + Text( + item.totalTimeFormatted, + fontSize = 32.sp, + modifier = Modifier.padding(end = 18.dp), + ) + } + + Spacer(modifier = Modifier.height(13.dp)) + + Divider(thickness = 1.dp, color = Color(0xFFDDDDDD)) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.padding(start = 46.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "시작", + modifier = Modifier.width(90.dp), + color = Color.Gray, + fontSize = 15.sp, + ) + Text( + "종료", + modifier = Modifier.width(90.dp), + color = Color.Gray, + fontSize = 15.sp, + ) + Text("집중 시간", color = Color.Gray, fontSize = 15.sp) + } + + Spacer(modifier = Modifier.height(6.dp)) + + item.records.forEach { row -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 46.dp), + ) { + Text(row.start, modifier = Modifier.width(90.dp)) + Text(row.end, modifier = Modifier.width(90.dp)) + Text(row.duration) + } + } + + Spacer(modifier = Modifier.height(18.dp)) + Divider(thickness = 1.dp, color = Color(0xFFDDDDDD)) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewRecordDetailScreen() { + val navController = rememberNavController() + RecordDetailScreen( + navController = navController, + recordId = "1", + timerViewModel = TimerViewModel(), + ) +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerProgress.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerProgress.kt new file mode 100644 index 0000000..2daa0d4 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerProgress.kt @@ -0,0 +1,116 @@ +// TimerProgress.kt +package com.sprinter.studyspot.timer.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +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.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +fun TimerProgress( + totalSeconds: Long, + elapsedSeconds: Long, + maxDisplaySeconds: Long = 60 * 60L, + modifier: Modifier = Modifier, + progressColor: Color = Color(0xFFB8E9BB), +) { + val density = LocalDensity.current + val remainingSeconds = (totalSeconds - elapsedSeconds).coerceAtLeast(0L) + val remainingRatio = (remainingSeconds.toFloat() / maxDisplaySeconds.toFloat()).coerceIn(0f, 1f) + val animatedProgress by animateFloatAsState( + targetValue = remainingRatio, + animationSpec = tween(300, easing = LinearEasing), + ) + + Canvas(modifier = modifier.aspectRatio(1f)) { + val center = Offset(size.width / 2f, size.height / 2f) + + // 배경 원 + drawCircle(color = Color(0xFFD9D9D9), radius = size.minDimension / 2f, center = center) + + // 진행 원 + drawArc( + color = progressColor, + startAngle = -90f, + sweepAngle = 360f * animatedProgress, + useCenter = true, + style = Fill, + ) + + // 물방울 + val dropRadiusPx = (35f / 2f).dp.toPx() + drawCircle(color = Color.White, radius = dropRadiusPx, center = center) + + // 뿔 + val hornWidthPx = 23.dp.toPx() + val hornHeightPx = 25.dp.toPx() + val path = + Path().apply { + val tipY = center.y - dropRadiusPx - hornHeightPx / 2 + moveTo(center.x, tipY) + lineTo(center.x - hornWidthPx / 2, center.y - dropRadiusPx + hornHeightPx / 2) + lineTo(center.x + hornWidthPx / 2, center.y - dropRadiusPx + hornHeightPx / 2) + close() + } + drawPath(path, color = Color.White) + } +} + +// 데모 +@Composable +fun TimerProgressDemo() { + var elapsed by remember { mutableStateOf(0L) } + val total = 45 * 60L + + LaunchedEffect(Unit) { + while (true) { + delay(1000) + if (elapsed < total) elapsed++ + } + } + + TimerProgress(totalSeconds = total, elapsedSeconds = elapsed) +} + +@Preview( + showBackground = true, + name = "45분 시작 - 실시간 감소", + widthDp = 500, // 그대로 + heightDp = 500, // 그대로 +) +@Composable +fun Preview45MinInteractive() { + var elapsed by remember { mutableStateOf(0L) } + val total = 45 * 60L + + // Interactive Mode에서만 동작 + LaunchedEffect(Unit) { + while (elapsed < total) { + delay(1000) + elapsed++ + } + } + + TimerProgress( + totalSeconds = total, + elapsedSeconds = elapsed, + modifier = Modifier.size(190.dp), + ) +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerRunningScreen.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerRunningScreen.kt new file mode 100644 index 0000000..434ed3f --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerRunningScreen.kt @@ -0,0 +1,284 @@ +package com.sprinter.studyspot.timer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +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 androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.R +import kotlinx.coroutines.delay + +@Composable +fun TimerRunningScreen( + navController: NavController, + color: Color, + title: String, + totalSeconds: Long, +) { + // 🔹 경과 시간 + var elapsed by remember { mutableStateOf(0L) } + + // 🔹 타이머 동작 여부 + var isRunning by remember { mutableStateOf(true) } + + // 초기화 버튼 클릭 여부 + var isReset by remember { mutableStateOf(false) } + + // 🔹 1시간 이벤트 발동 여부 + var hourReached by remember { mutableStateOf(false) } + + // 🔹 1시간 이벤트 유지 시간 (10분 유지) + var hourEventElapsed by remember { mutableStateOf(0L) } + + // 🔹 1시간 이벤트 체크 + LaunchedEffect(isRunning) { + while (isRunning && elapsed < totalSeconds) { + delay(1000) + elapsed++ + + // ⭐ 정확히 1시간(3600초) 도달 시 + if (elapsed == 3600L && !hourReached) { + hourReached = true + hourEventElapsed = 0L + } + } + } + + // 🔹 1시간 이벤트가 발동하면 10분 유지 후 게이지 리셋 + LaunchedEffect(hourReached) { + if (hourReached) { + // 10분 = 600초 동안 유지 + while (hourEventElapsed < 600L) { + delay(1000) + hourEventElapsed++ + } + + // 10분 지나면 이벤트 종료 + 게이지 리셋 + hourReached = false + elapsed = 0L // ⭐ 게이지 다시 맨 처음부터 돌게 하기 + } + } + + // 🔹 남은시간 / 집중시간 계산 + val remainingSeconds = + if (hourReached) { + (3600 - hourEventElapsed).coerceAtLeast(0L) + } else { + (totalSeconds - elapsed).coerceAtLeast(0L) + } + + val focusSeconds = + if (hourReached) { + 3600 + } else { + totalSeconds - remainingSeconds + } + + // ⏰ 시/분/초 포맷 + val timeText = + String.format( + "%02d:%02d:%02d", + remainingSeconds / 3600, + (remainingSeconds % 3600) / 60, + remainingSeconds % 60, + ) + val focusTimeText = + String.format( + "%02d:%02d:%02d", + focusSeconds / 3600, + (focusSeconds % 3600) / 60, + focusSeconds % 60, + ) + + // 🔹 타이머가 끝난 상태 감지 + val isFinished = elapsed >= totalSeconds + + Surface( + modifier = + Modifier + .fillMaxSize() + .background(Color.White), + color = Color.White, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 🔹 상단 바 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_left_arrow), + contentDescription = "뒤로가기", + modifier = + Modifier + .size(28.dp) + .clickable { navController.popBackStack() }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 🔹 타이머명 + 색상 표시 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = + Modifier + .size(16.dp) + .background(color = color, shape = RoundedCornerShape(50)), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title, fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 🔹 원형 타이머 색상 + val progressColor = + when { + isFinished -> Color(0xFF6D6D6D) // 완료 → 회색 + isRunning -> color // 실행 → 본래 색 + isReset -> color // 초기화 → 본래 색 유지 + else -> Color(0xFF6D6D6D) // 일시정지 → 회색 + } + + Box( + modifier = + Modifier + .size(220.dp) + .aspectRatio(1f) + .background(Color(0xFFEAEAEA), RoundedCornerShape(999.dp)), + contentAlignment = Alignment.Center, + ) { + TimerProgress( + totalSeconds = if (hourReached) 3600 else totalSeconds, + elapsedSeconds = if (hourReached) hourEventElapsed else elapsed, + modifier = Modifier.fillMaxSize(), + progressColor = progressColor, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 🔹 상태별 멘트 변경 + val motivationText = + when { + hourReached -> "🎉🎊축하합니다!🎊🎉\n1시간을 완수했어요!" + isFinished -> "🎉🎊축하합니다!🎊🎉\n목표를 완수했어요~!" + isRunning -> "👀 원하는 목표 만큼 공부해 보세요!\n화이팅~!" + else -> "👀 조금만 쉬었다 갈까요?\n준비가 다 되면 알려주세요!" + } + + Text( + motivationText, + textAlign = TextAlign.Center, + color = if (hourReached || isFinished) Color(0xFF4CAF50) else Color.Gray, + fontWeight = if (hourReached || isFinished) FontWeight.Bold else FontWeight.Normal, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 🔹 남은 시간 / 집중 시간 + Text("남은 시간", fontSize = 14.sp, color = Color.Gray) + Text(timeText, fontSize = 40.sp, fontWeight = FontWeight.Bold) + + Spacer(modifier = Modifier.height(16.dp)) + Text("집중 시간", fontSize = 14.sp, color = Color.Gray) + Text(focusTimeText, fontSize = 24.sp, fontWeight = FontWeight.Medium) + + Spacer(modifier = Modifier.height(32.dp)) + + // 🔹 버튼 2개 + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + onClick = { + if (isFinished) { + // ✅ 완료된 상태일 때 -> 타이머 목록 화면으로 이동 + navController.navigate("timer") { + popUpTo("timer") { inclusive = true } + } + } else { + // ⏸ 일시정지 / ▶ 재시작 토글 + isRunning = !isRunning + isReset = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF0F0F0)), + shape = RoundedCornerShape(8.dp), + ) { + Text( + when { + isFinished -> "✅ 완료됨" + isRunning -> "⏸ 일시 정지" + else -> "▶ 실행" + }, + color = Color.Black, + ) + } + + Button( + onClick = { + elapsed = 0L + isRunning = false + isReset = true // ⭐ 초기화 상태 활성화 + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF0F0F0)), + shape = RoundedCornerShape(8.dp), + ) { + Text("🔄 초기화", color = Color.Black) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TimerRunningPreview() { + TimerRunningScreen( + navController = rememberNavController(), + color = Color(0xFFFF8686), + title = "자바스크립트", + totalSeconds = 60 * 60L, + ) +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerScreen.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerScreen.kt new file mode 100644 index 0000000..98d933c --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerScreen.kt @@ -0,0 +1,339 @@ +package com.sprinter.studyspot.timer.ui + +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.R +import com.sprinter.studyspot.timer.ui.component.RecordItemCard +import com.sprinter.studyspot.timer.ui.component.SwipeableStudyItem +import com.sprinter.studyspot.timer.ui.component.TimerItemData +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import java.net.URLEncoder + +@Composable +fun TimerScreen( + navController: NavController, + timerViewModel: TimerViewModel, + colorValue: String = "0", + title: String = "기본 타이머", + hour: Int = 0, + minute: Int = 0, + second: Int = 0, + modifier: Modifier = Modifier, +) { + val timerList by remember { derivedStateOf { timerViewModel.timers } } + + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + + val circleSize = + when { + screenWidth < 360.dp -> 150.dp + screenWidth < 600.dp -> 190.dp + else -> 250.dp + } + + var elapsed by remember { mutableStateOf(0L) } + val selectedTab = timerViewModel.selectedTab + + val selectedColor = + remember(colorValue) { + try { + if (colorValue == "0" || colorValue.isBlank()) { + Color(0xFFD9D9D9) + } else { + Color(colorValue.toULong()) + } + } catch (e: Exception) { + Color(0xFFD9D9D9) + } + } + + val total = timerViewModel.getTotalTimerSeconds() + val remainingSeconds = (total - elapsed).coerceAtLeast(0L) + val h = remainingSeconds / 3600 + val m = (remainingSeconds % 3600) / 60 + val s = remainingSeconds % 60 + val timeText = String.format("%02d:%02d:%02d", h, m, s) + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Transparent, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.White), + ) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // ✅ 상단 타이머 영역 + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(0.45f) + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .size(circleSize) + .aspectRatio(1f) + .background(Color(0xFFD9D9D9), RoundedCornerShape(999.dp)), + contentAlignment = Alignment.Center, + ) { + TimerProgress( + totalSeconds = total, + elapsedSeconds = elapsed, + modifier = Modifier.fillMaxSize(), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text("남은시간", fontSize = 14.sp, color = Color.Gray) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = timeText, + fontSize = if (screenWidth < 360.dp) 28.sp else 36.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + ) + } + } + + // ✅ 하단 영역 (탭 전환) + Surface( + modifier = + Modifier + .fillMaxWidth() + .weight(0.55f), + color = Color.White, + ) { + Column { + // 🔹 탭 바 + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 41.dp), + ) { + TabRow( + selectedTabIndex = selectedTab, + containerColor = Color.Transparent, + divider = { }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + indicator = { tabPositions -> + Box( + modifier = + Modifier + .tabIndicatorOffset(tabPositions[selectedTab]) + .height(2.dp) + .background(Color.Black), + ) + }, + ) { + Tab( + selected = selectedTab == 0, + onClick = { timerViewModel.updateSelectedTab(0) }, + ) { + Text( + "타이머", + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = if (selectedTab == 0) Color.Black else Color.Gray, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + Tab( + selected = selectedTab == 1, + onClick = { timerViewModel.updateSelectedTab(1) }, + ) { + Text( + "기록", + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = if (selectedTab == 1) Color.Black else Color.Gray, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 🔹 탭 내용 분기 + Box(modifier = Modifier.fillMaxSize()) { + if (selectedTab == 0) { + // ✅ 타이머 탭 + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(bottom = 80.dp), + ) { + items(timerList) { timer -> + SwipeableStudyItem( + color = timer.color, + title = timer.title, + time = + String.format( + "%02d:%02d:%02d", + timer.hour, + timer.minute, + timer.second, + ), + onEdit = { + val encodedTitle = + + URLEncoder.encode(timer.title, "UTF-8") + val colorValueStr = timer.color.value.toString() + navController.navigate( + "timerSetting?title=$encodedTitle&color=$colorValueStr&hour=${timer.hour}&minute=${timer.minute}&second=${timer.second}&id=${timer.id}", + ) + }, + onDelete = { timerViewModel.deleteTimer(timer.id) }, + onClick = { + val encodedTitle = + URLEncoder.encode(timer.title, "UTF-8") + val colorValueStr = timer.color.value.toString() + val total = + timer.hour * 3600 + timer.minute * 60 + timer.second + navController.navigate( + "timerRunning?title=$encodedTitle&color=$colorValueStr&total=$total", + ) + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // 추가 버튼 + IconButton( + onClick = { + timerViewModel.addTimer( + TimerItemData( + id = + java.util.UUID + .randomUUID() + .toString(), + title = "새 타이머", + color = Color(0xFFD9D9D9), + hour = 0, + minute = 0, + second = 0, + ), + ) + }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(24.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_button), + contentDescription = "추가", + tint = Color.Unspecified, + modifier = Modifier.size(32.dp), + ) + } + } else if (selectedTab == 1) { + // ✅ 기록 탭 + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + items(timerViewModel.records) { record -> + // 🔹 안전한 Color 변환 (핵심 수정) + val recordColor: Color = + when (val c = record.color) { + is Color -> c + is Int -> Color(c) + else -> Color(0xFFD9D9D9) + } + + val encodedTitle = URLEncoder.encode(record.title, "UTF-8") + val colorValueStr = recordColor.value.toString() + + RecordItemCard( + color = recordColor, + title = record.title, + totalTime = record.totalTime, + dayCounts = record.dayCounts, + onClick = { + navController.navigate( + "recordDetail/${record.id}?title=$encodedTitle&color=$colorValueStr", + ) + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } + } + } + } + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_4) +@Composable +fun TimerScreenPreview() { + val dummyViewModel = remember { TimerViewModel() } + + MaterialTheme { + TimerScreen( + navController = rememberNavController(), + timerViewModel = dummyViewModel, + ) + } +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerSettingScreen.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerSettingScreen.kt new file mode 100644 index 0000000..5d824db --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/TimerSettingScreen.kt @@ -0,0 +1,341 @@ +package com.sprinter.studyspot.timer.ui + +import android.annotation.SuppressLint +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.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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +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 androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.R +import com.sprinter.studyspot.timer.ui.component.TimerItemData +import com.sprinter.studyspot.timer.viewmodel.TimerViewModel +import java.net.URLEncoder +import java.util.UUID + +@Composable +private fun TimeUnitField( + value: String, + onValueChange: (String) -> Unit, + maxLength: Int, + maxValue: Int, +) { + var internalValue by remember(value) { mutableStateOf(value) } + + LaunchedEffect(value) { + if (value != internalValue) internalValue = value + } + + val isError = + internalValue.isNotEmpty() && internalValue.toIntOrNull()?.let { it > maxValue } == true + + OutlinedTextField( + value = internalValue, + onValueChange = { raw -> + val digits = raw.filter { it.isDigit() }.take(maxLength) + if (digits != internalValue) { + internalValue = digits + onValueChange(digits) + } + }, + modifier = + Modifier + .width(64.dp) + .height(56.dp) + .background(Color(0xFFF0F0F0), RoundedCornerShape(30.dp)) + .border( + width = if (isError) 2.dp else 0.dp, + color = if (isError) Color.Red else Color.Transparent, + shape = RoundedCornerShape(30.dp), + ), + textStyle = + TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = if (isError) Color.Red else Color.Black, + textAlign = TextAlign.Center, + ), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = { text -> + val padded = text.text.padStart(2, '0').take(2) + TransformedText( + text = + androidx.compose.ui.text + .AnnotatedString(padded), + offsetMapping = + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset.coerceAtMost(2) + + override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(2) + }, + ) + }, + colors = + TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ), + placeholder = { + Text( + "00", + color = Color.Gray, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimerSettingScreen( + navController: NavController? = null, + timerViewModel: TimerViewModel, + initialTitle: String = "새 타이머", + initialHour: Int = 0, + initialMinute: Int = 0, + initialSecond: Int = 0, + initialColor: Color? = null, + timerId: String? = null, +) { + val decodedTitle = + try { + java.net.URLDecoder.decode(initialTitle, "UTF-8") + } catch (e: Exception) { + initialTitle + } + + var selectedColor by remember { mutableStateOf(initialColor) } + var timerTitle by remember { mutableStateOf(decodedTitle) } + + var hourStr by remember { mutableStateOf(initialHour.toString().padStart(2, '0')) } + var minuteStr by remember { mutableStateOf(initialMinute.toString().padStart(2, '0')) } + var secondStr by remember { mutableStateOf(initialSecond.toString().padStart(2, '0')) } + + val hour = hourStr.toIntOrNull() ?: 0 + val minute = minuteStr.toIntOrNull() ?: 0 + val second = secondStr.toIntOrNull() ?: 0 + val isValid = hour in 0..99 && minute in 0..59 && second in 0..59 + + Surface(modifier = Modifier.fillMaxSize(), color = Color.White) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 20.dp, start = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_left_arrow), + contentDescription = "뒤로가기", + modifier = + Modifier + .size(28.dp) + .clickable { navController?.popBackStack() }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "\"$timerTitle\" 타이머를 설정하시는군요!\n오늘 공부 목표 시간을 설정해보세요!", + fontSize = 14.sp, + color = Color.DarkGray, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + // ✅ 색상 선택 + val colors = + listOf( + Color(0xFFB8E9BB), + Color(0xFFFFDB86), + Color(0xFFFF8686), + Color(0xFFF9C0EB), + Color(0xFFD2ACAC), + Color(0xFFD9D9D9), + Color(0xFFAECDE5), + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + colors.forEach { color -> + Box( + modifier = + Modifier + .size(27.dp, 26.dp) + .background(color, RoundedCornerShape(8.dp)) + .border( + width = if (selectedColor == color) 3.dp else 0.dp, + color = if (selectedColor == color) Color.Black else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ).clickable { selectedColor = color }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + Text("타이머 이름", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.height(14.dp)) + OutlinedTextField( + value = timerTitle, + onValueChange = { timerTitle = it }, + shape = RoundedCornerShape(30.dp), + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp) + .background(Color(0xFFF0F0F0), RoundedCornerShape(30.dp)), + textStyle = TextStyle(fontSize = 16.sp, color = Color.Black), + placeholder = { Text("타이머 이름", color = Color.Gray, fontSize = 16.sp) }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, // 한글/영문 둘 다 가능 + ), + colors = + TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ), + ) + + Spacer(modifier = Modifier.height(32.dp)) + Text("시간 설정", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + TimeUnitField( + value = hourStr, + onValueChange = { hourStr = it }, + maxLength = 2, + maxValue = 99, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(":", fontSize = 28.sp) + Spacer(modifier = Modifier.width(8.dp)) + TimeUnitField( + value = minuteStr, + onValueChange = { minuteStr = it }, + maxLength = 2, + maxValue = 59, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(":", fontSize = 28.sp) + Spacer(modifier = Modifier.width(8.dp)) + TimeUnitField( + value = secondStr, + onValueChange = { secondStr = it }, + maxLength = 2, + maxValue = 59, + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(end = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Surface( + modifier = + Modifier + .size(77.dp, 41.dp) + .clickable(enabled = isValid) { + val safeTitle = timerTitle.ifBlank { "기본 타이머" } + val encodedTitle = URLEncoder.encode(safeTitle, "UTF-8") + val colorValue = selectedColor?.value?.toString() ?: "0" + + val updatedTimer = + TimerItemData( + id = timerId ?: UUID.randomUUID().toString(), + title = safeTitle, + color = selectedColor ?: Color(0xFFD9D9D9), + hour = hour, + minute = minute, + second = second, + ) + timerViewModel.updateTimer(updatedTimer) // ✅ ViewModel 업데이트 + + navController?.navigate( + "timer?title=$encodedTitle&color=$colorValue&hour=$hour&minute=$minute&second=$second", + ) { + popUpTo("timerSetting") { inclusive = true } + } + }, + shape = RoundedCornerShape(8.dp), + color = if (isValid) Color(0xFFF0F0F0) else Color(0xFFE0E0E0), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.ic_confirm_check), + contentDescription = "완료", + tint = if (isValid) Color.Unspecified else Color.Gray, + modifier = Modifier.size(48.dp), + ) + } + } + } + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview(showBackground = true) +@Composable +fun TimerSettingPreview() { + val vm = TimerViewModel() + TimerSettingScreen(navController = rememberNavController(), timerViewModel = vm) +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/BottomNavBar.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/BottomNavBar.kt new file mode 100644 index 0000000..f518145 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/BottomNavBar.kt @@ -0,0 +1,103 @@ +package com.sprinter.studyspot.timer.ui.component + +import androidx.compose.foundation.background +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.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.compose.rememberNavController +import com.sprinter.studyspot.R + +@Composable +fun BottomNavBar( + navController: androidx.navigation.NavController, + currentRoute: String, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(76.dp) + .background(Color.White) + .padding(horizontal = 36.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + BottomNavItem( + icon = R.drawable.ic_clock, + label = "타이머", + isSelected = currentRoute == "timer", + onClick = { navController.navigate("timer") }, + ) + + BottomNavItem( + icon = R.drawable.ic_home, + label = "홈", + isSelected = currentRoute == "home", + onClick = { navController.navigate("home") }, + ) + + BottomNavItem( + icon = R.drawable.ic_search, + label = "검색", + isSelected = currentRoute == "search", + onClick = { navController.navigate("search") }, + ) + } +} + +@Composable +private fun BottomNavItem( + icon: Int, + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .width(60.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = Color.Black, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + fontSize = 12.sp, + color = Color.Black, + fontWeight = FontWeight.Medium, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun BottomNavBarPreview() { + BottomNavBar( + navController = rememberNavController(), + currentRoute = "home", + ) +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/DailyFocus.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/DailyFocus.kt new file mode 100644 index 0000000..9aebb12 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/DailyFocus.kt @@ -0,0 +1,9 @@ +package com.sprinter.studyspot.timer.ui.component + +data class DailyFocus( + val timerTitle: String, + val color: androidx.compose.ui.graphics.Color, + val start: String, + val end: String, + val duration: String, +) diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/RecordData.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/RecordData.kt new file mode 100644 index 0000000..e1ddc70 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/RecordData.kt @@ -0,0 +1,11 @@ +package com.sprinter.studyspot.timer.ui.component + +import androidx.compose.ui.graphics.Color + +data class RecordData( + val id: String, + val title: String, + val color: Color, + val totalTime: String, + val dayCounts: Map, +) diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/RecordItemCard.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/RecordItemCard.kt new file mode 100644 index 0000000..f1f0e4c --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/RecordItemCard.kt @@ -0,0 +1,184 @@ +package com.sprinter.studyspot.timer.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun RecordItemCard( + color: Color = Color(0xFF8BC34A), // 타이머 색상 (왼쪽 원) + title: String, + totalTime: String, + dayCounts: Map, // 요일별 학습 횟수 + onClick: (() -> Unit)? = null, +) { + // 최대 횟수 대비 비율 계산 + val maxCount = (dayCounts.values.maxOrNull() ?: 1).coerceAtLeast(1) + + // ✅ 잔디 색상 팔레트 (GitHub 느낌) + val grassPalette = + listOf( + Color(0xFFE8F5E9), // 연한 녹색 + Color(0xFFC8E6C9), + Color(0xFF81C784), + Color(0xFF388E3C), // 진한 녹색 + ) + + // ✅ 반응형 기기 크기 계산 + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val screenHeight = configuration.screenHeightDp.dp + + // 화면 비율 기반으로 크기 조정 + val cardPadding = (screenWidth.value * 0.04f).dp.coerceIn(12.dp, 24.dp) + val boxHeight = (screenHeight.value * 0.085f).dp.coerceAtLeast(60.dp) + val boxPadding = (screenWidth.value * 0.04f).dp.coerceIn(12.dp, 24.dp) + + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .testTag("record_card") + .clickable(enabled = onClick != null) { onClick?.invoke() }, + shape = RoundedCornerShape(12.dp), + color = Color(0xFFF5F5F5), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(cardPadding), + ) { + // ✅ 왼쪽: 타이틀 + 요일 + Column( + modifier = Modifier.align(Alignment.CenterStart), + ) { + // 상단 타이틀 + Row(verticalAlignment = Alignment.CenterVertically) { + // 🔹 타이머별 고유 색상 원 + Box( + modifier = + Modifier + .size(26.dp) + .background(color = color, shape = RoundedCornerShape(50)), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 하단: 요일별 "잔디" 표시 (녹색 팔레트 기반) + val days = listOf("월", "화", "수", "목", "금", "토", "일") + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + days.forEachIndexed { index, day -> + val count = dayCounts[index + 1] ?: 0 + val ratio = count.toFloat() / maxCount.toFloat() + val colorIndex = + when { + ratio == 0f -> -1 + ratio < 0.25f -> 0 + ratio < 0.5f -> 1 + ratio < 0.75f -> 2 + else -> 3 + } + + val grassColor = + if (colorIndex == -1) Color(0xFFE0E0E0) else grassPalette[colorIndex] + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .size(26.dp) + .background( + color = grassColor, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) {} + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = day, + fontSize = 12.sp, + color = if (count > 0) Color.Black else Color.Gray, + ) + } + } + } + } + + // ✅ 오른쪽: 중앙 정렬된 타이머 박스 (반응형) + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .height(boxHeight) + .background(Color(0xFFE0E0E0), RoundedCornerShape(8.dp)) + .padding(horizontal = boxPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = totalTime, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.Black, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordItemCardPreview() { + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + RecordItemCard( + color = Color(0xFFFFFFFF), // 타이머 색상 + title = "전체 시간", + totalTime = "00:45", + dayCounts = mapOf(2 to 1, 3 to 3, 4 to 5), + ) + Spacer(modifier = Modifier.height(9.dp)) + RecordItemCard( + color = Color(0xFFFF8686), + title = "자바스크립트", + totalTime = "00:45", + dayCounts = mapOf(1 to 2, 2 to 5, 3 to 6), + ) + Spacer(modifier = Modifier.height(9.dp)) + RecordItemCard( + color = Color(0xFFFFDB86), + title = "파이썬", + totalTime = "00:45", + dayCounts = mapOf(1 to 1, 3 to 1, 4 to 1), + ) + } + } +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/StudyItem.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/StudyItem.kt new file mode 100644 index 0000000..95e61ea --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/StudyItem.kt @@ -0,0 +1,64 @@ +package com.sprinter.studyspot.timer.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// StudyItem (그대로 유지) +@Composable +fun StudyItem( + color: Color, + title: String, + time: String, + onClick: () -> Unit, +) { + val isCompact = LocalConfiguration.current.screenWidthDp < 360 + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 28.dp) + .padding(vertical = if (isCompact) 6.dp else 8.dp) + .clickable { onClick() }, + ) { + Box( + modifier = + Modifier + .size(if (isCompact) 31.dp else 33.dp) + .background(color, CircleShape), + ) + Spacer(modifier = Modifier.width(if (isCompact) 8.dp else 12.dp)) + Text( + title, + fontWeight = FontWeight.Medium, + fontSize = if (isCompact) 14.sp else 16.sp, + color = Color.Black, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + time, + fontWeight = FontWeight.Medium, + fontSize = if (isCompact) 30.sp else 32.sp, + color = Color.Gray, + ) + Spacer(modifier = Modifier.width(34.dp)) + } +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/SwipeableStudyItem.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/SwipeableStudyItem.kt new file mode 100644 index 0000000..f77a6b9 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/SwipeableStudyItem.kt @@ -0,0 +1,190 @@ +// timer/ui/component/SwipeableStudyItem.kt +package com.sprinter.studyspot.timer.ui.component + +import androidx.compose.animation.core.tween +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sprinter.studyspot.R +import kotlin.math.roundToInt + +enum class SwipeState { Closed, Swiped } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SwipeableStudyItem( + color: Color, + title: String, + time: String, + onEdit: () -> Unit, + onDelete: () -> Unit, + onClick: () -> Unit, +) { + val density = LocalDensity.current + var widthPx by remember { mutableStateOf(0f) } + + val decay = rememberSplineBasedDecay() + + val state = + remember { + AnchoredDraggableState( + initialValue = SwipeState.Closed, + positionalThreshold = { distance -> distance * 0.5f }, + velocityThreshold = { with(density) { 100.dp.toPx() } }, + snapAnimationSpec = tween(), + decayAnimationSpec = decay, + confirmValueChange = { true }, + ) + } + + // 앵커 업데이트: Swiped = 오른쪽으로 40% 이동 + LaunchedEffect(widthPx) { + if (widthPx > 0f) { + state.updateAnchors( + DraggableAnchors { + SwipeState.Closed at 0f + SwipeState.Swiped at +widthPx * 0.4f // 오른쪽으로 이동 + }, + ) + } + } + + // 안전한 offset 계산 + val safeOffsetX by remember { + derivedStateOf { + val current = state.offset + if (current.isNaN()) return@derivedStateOf 0f + + val swipedPos = state.anchors.positionOf(SwipeState.Swiped) ?: 0f + current.coerceIn(0f, swipedPos) // min: 0f, max: swipedPos (양수) + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(64.dp) + .onGloballyPositioned { coordinates -> + widthPx = coordinates.size.width.toFloat() + }, + ) { + // 배경 버튼 (왼쪽에 고정) + Row( + modifier = + Modifier + .matchParentSize() + .background(Color(0xFFF0F0F0)) + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.Start, // 왼쪽 정렬 + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = "Edit", + tint = Color.Black, + modifier = + Modifier + .size(24.dp) + .clickable { onEdit() }, + ) + Spacer(modifier = Modifier.width(24.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = "Delete", + tint = Color.Black, + modifier = + Modifier + .size(24.dp) + .clickable { onDelete() }, + ) + } + + // 앞쪽 카드: 오른쪽으로 스와이프 + Surface( + color = Color.White, + modifier = + Modifier + .offset { + if (widthPx <= 0f) { + IntOffset(0, 0) + } else { + IntOffset(safeOffsetX.roundToInt(), 0) + } + }.anchoredDraggable( + state = state, + orientation = Orientation.Horizontal, + ).background(Color.White) + .clickable( + enabled = state.currentValue == SwipeState.Closed, + onClick = onClick, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 16.dp) + .height(64.dp), + ) { + Box( + modifier = + Modifier + .size(33.dp) + .background(color, CircleShape), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = title, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = Color.Black, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = time, + fontWeight = FontWeight.Medium, + fontSize = 28.sp, + color = Color.Gray, + ) + } + } + } +} diff --git a/app/src/main/java/com/sprinter/studyspot/timer/ui/component/TimerItemData.kt b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/TimerItemData.kt new file mode 100644 index 0000000..e92a093 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/ui/component/TimerItemData.kt @@ -0,0 +1,13 @@ +package com.sprinter.studyspot.timer.ui.component + +import androidx.compose.ui.graphics.Color +import java.util.UUID + +data class TimerItemData( + val id: String = UUID.randomUUID().toString(), // ✅ 고유 식별자 추가 + val title: String, + val color: Color, + val hour: Int, + val minute: Int, + val second: Int, +) diff --git a/app/src/main/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModel.kt b/app/src/main/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModel.kt new file mode 100644 index 0000000..f56b233 --- /dev/null +++ b/app/src/main/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModel.kt @@ -0,0 +1,161 @@ +package com.sprinter.studyspot.timer.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import com.sprinter.studyspot.timer.model.RecordDetailItem +import com.sprinter.studyspot.timer.model.RecordDetailRowData +import com.sprinter.studyspot.timer.ui.component.DailyFocus +import com.sprinter.studyspot.timer.ui.component.RecordData +import com.sprinter.studyspot.timer.ui.component.TimerItemData + +class TimerViewModel : ViewModel() { + private val _timers = mutableStateListOf() + val timers: List get() = _timers + + // 타이머의 총 소요시간 + fun getTotalTimerSeconds(): Long = + timers.sumOf { timer -> + (timer.hour * 3600L) + (timer.minute * 60L) + timer.second + } + + // ✅ 기록 데이터 (내부 mutable, 외부는 읽기 전용으로 노출) + private val _records = + mutableStateListOf( + RecordData( + id = "0", + title = "전체 시간", + color = Color(0xFFFFFFFF), + totalTime = "02:10", + dayCounts = mapOf(2 to 1, 3 to 3, 4 to 5), + ), + RecordData( + id = "1", + title = "자바스크립트", + color = Color(0xFFFF8686), + totalTime = "01:20", + dayCounts = mapOf(1 to 2, 2 to 5, 3 to 6), + ), + RecordData( + id = "2", + title = "파이썬", + color = Color(0xFFFFE382), + totalTime = "00:45", + dayCounts = mapOf(1 to 1, 3 to 1, 4 to 1), + ), + ) + val records: List get() = _records + + fun addTimer(timer: TimerItemData) { + _timers.add(timer) + } + + fun deleteTimer(id: String) { + _timers.removeAll { it.id == id } + } + + fun updateTimer(updated: TimerItemData) { + val idx = _timers.indexOfFirst { it.id == updated.id } + if (idx != -1) _timers[idx] = updated else _timers.add(updated) + } + + // ✅ recordId로 RecordData 가져오기 + fun getRecordById(id: String): RecordData? = records.find { it.id == id } + + // 테스트에서 기록을 부여하기 위한 함수 + fun addRecordForTest( + title: String, + color: Color, + totalTime: String, + dayCounts: Map, + ) { + _records.add( + RecordData( + id = System.currentTimeMillis().toString(), + title = title, + color = color, + totalTime = totalTime, + dayCounts = dayCounts, + ), + ) + } + + var selectedTab by mutableStateOf(0) + private set + + fun updateSelectedTab(tabIndex: Int) { + selectedTab = tabIndex + } + + // 다음 타이머 세부 내용 파악을 위한 함수 + fun getNextRecordId(currentId: String): String? { + val index = _records.indexOfFirst { it.id == currentId } + if (index != -1 && index < _records.lastIndex) { + return _records[index + 1].id + } + return null + } + + // 이전 타이머 세부 내용 파악을 위한 함수 + fun getPrevRecordId(currentId: String): String? { + val index = _records.indexOfFirst { it.id == currentId } + if (index > 0) { + return _records[index - 1].id + } + return null + } + + fun getDailyGroupedRecords(day: Int): Map> { + val result = mutableMapOf>() + + records.filter { it.id != "0" }.forEach { record -> + val count = record.dayCounts[day] ?: return@forEach + + // 예시: count 만큼 더미 데이터 생성 (지금은 더미 → 나중에 실제 데이터 연결) + repeat(count) { + val item = + DailyFocus( + timerTitle = record.title, + color = record.color, + start = "13:00:00", + end = "13:10:00", + duration = "00:10:00", + ) + result.getOrPut(record.title) { mutableListOf() }.add(item) + } + } + + return result + } + + fun getRecordDetailByDay(day: Int): List { + // TODO: 실제 기록 DB 연동 시 교체 가능 + val example = + listOf( + RecordDetailItem( + title = "자바스크립트", + color = Color(0xFFFF8686), + totalTimeFormatted = "00:20:00", + records = + listOf( + RecordDetailRowData("13:00:00", "13:10:00", "00:10:00"), + RecordDetailRowData("14:00:00", "14:10:00", "00:10:00"), + ), + ), + RecordDetailItem( + title = "파이썬", + color = Color(0xFFFFE382), + totalTimeFormatted = "00:10:00", + records = + listOf( + RecordDetailRowData("13:00:00", "13:10:00", "00:10:00"), + ), + ), + ) + + return example + } +} diff --git a/app/src/main/res/drawable/ic_add_button.png b/app/src/main/res/drawable/ic_add_button.png new file mode 100644 index 0000000..704efd9 Binary files /dev/null and b/app/src/main/res/drawable/ic_add_button.png differ diff --git a/app/src/main/res/drawable/ic_clock.png b/app/src/main/res/drawable/ic_clock.png new file mode 100644 index 0000000..70bf7cb Binary files /dev/null and b/app/src/main/res/drawable/ic_clock.png differ diff --git a/app/src/main/res/drawable/ic_confirm_check.png b/app/src/main/res/drawable/ic_confirm_check.png new file mode 100644 index 0000000..315fadf Binary files /dev/null and b/app/src/main/res/drawable/ic_confirm_check.png differ diff --git a/app/src/main/res/drawable/ic_delete.png b/app/src/main/res/drawable/ic_delete.png new file mode 100644 index 0000000..624652a Binary files /dev/null and b/app/src/main/res/drawable/ic_delete.png differ diff --git a/app/src/main/res/drawable/ic_edit.png b/app/src/main/res/drawable/ic_edit.png new file mode 100644 index 0000000..4b4df36 Binary files /dev/null and b/app/src/main/res/drawable/ic_edit.png differ diff --git a/app/src/main/res/drawable/ic_home.png b/app/src/main/res/drawable/ic_home.png new file mode 100644 index 0000000..aec21a4 Binary files /dev/null and b/app/src/main/res/drawable/ic_home.png differ diff --git a/app/src/main/res/drawable/ic_left_arrow.png b/app/src/main/res/drawable/ic_left_arrow.png new file mode 100644 index 0000000..34713e2 Binary files /dev/null and b/app/src/main/res/drawable/ic_left_arrow.png differ diff --git a/app/src/main/res/drawable/ic_left_triangle.png b/app/src/main/res/drawable/ic_left_triangle.png new file mode 100644 index 0000000..4cdbc5d Binary files /dev/null and b/app/src/main/res/drawable/ic_left_triangle.png differ diff --git a/app/src/main/res/drawable/ic_right_triangle.png b/app/src/main/res/drawable/ic_right_triangle.png new file mode 100644 index 0000000..bc4c9f2 Binary files /dev/null and b/app/src/main/res/drawable/ic_right_triangle.png differ diff --git a/app/src/main/res/drawable/ic_search.png b/app/src/main/res/drawable/ic_search.png new file mode 100644 index 0000000..dc72cf6 Binary files /dev/null and b/app/src/main/res/drawable/ic_search.png differ diff --git a/app/src/main/res/drawable/ic_trashcan.png b/app/src/main/res/drawable/ic_trashcan.png new file mode 100644 index 0000000..e04fd4d Binary files /dev/null and b/app/src/main/res/drawable/ic_trashcan.png differ diff --git a/app/src/test/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModelRecordTest.kt b/app/src/test/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModelRecordTest.kt new file mode 100644 index 0000000..0a363ae --- /dev/null +++ b/app/src/test/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModelRecordTest.kt @@ -0,0 +1,44 @@ +package com.sprinter.studyspot.timer.viewmodel + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import org.junit.Before +import org.junit.Test + +/** + * ViewModel의 기록 데이터 로직 테스트 + * addRecordForTest(), getRecordById(), records 상태 변화 확인 + */ +class TimerViewModelRecordTest { + private lateinit var viewModel: TimerViewModel + + @Before + fun setup() { + viewModel = TimerViewModel() + } + + @Test + fun `기록 ID로 올바른 레코드를 조회한다`() { + val record = viewModel.getRecordById("1") + + assertNotNull(record) + assertEquals("자바스크립트", record?.title) + } + + @Test + fun `존재하지 않는 기록 ID 조회 시 null 반환`() { + val record = viewModel.getRecordById("999") + + assertNull(record) + } + + @Test + fun `요일별 집중시간 Map이 올바르게 매핑되어 있다`() { + val record = viewModel.getRecordById("2") + + assertEquals(1, record?.dayCounts?.get(1)) + assertEquals(1, record?.dayCounts?.get(3)) + assertEquals(1, record?.dayCounts?.get(4)) + } +} diff --git a/app/src/test/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModelTest.kt b/app/src/test/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModelTest.kt new file mode 100644 index 0000000..78f3154 --- /dev/null +++ b/app/src/test/java/com/sprinter/studyspot/timer/viewmodel/TimerViewModelTest.kt @@ -0,0 +1,144 @@ +package com.sprinter.studyspot.timer.viewmodel + +import androidx.compose.ui.graphics.Color +import com.sprinter.studyspot.timer.ui.component.TimerItemData +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * 타이머 추가/삭제/수정 로직 검증 + * 타이머 전체 시간 계산 기능 테스트 + * selectedTab 상태 유지 여부 확인 + */ +class TimerViewModelTest { + private lateinit var viewModel: TimerViewModel + + @Before + fun setup() { + viewModel = TimerViewModel() + } + + @Test + fun `타이머 추가 시 리스트에 정상적으로 포함된다`() { + val timer = + TimerItemData( + id = "test", + title = "테스트 타이머", + color = Color(0xFFFF0000), + hour = 1, + minute = 10, + second = 5, + ) + + viewModel.addTimer(timer) + + assertTrue(viewModel.timers.contains(timer)) + } + + @Test + fun `타이머 삭제 시 리스트에서 제거된다`() { + val timer = + TimerItemData( + id = "delete-test", + title = "삭제용 타이머", + color = Color(0xFFFF0000), + hour = 0, + minute = 30, + second = 0, + ) + + viewModel.addTimer(timer) + viewModel.deleteTimer("delete-test") + + assertFalse(viewModel.timers.contains(timer)) + } + + @Test + fun `기록 ID로 레코드를 정상적으로 조회한다`() { + val record = viewModel.getRecordById("1") + assertEquals("자바스크립트", record?.title) + } + + @Test + fun `타이머 수정 시 기존 값이 업데이트된다`() { + val timer = + TimerItemData( + id = "update-test", + title = "Old", + color = Color(0xFFFF0000), + hour = 0, + minute = 30, + second = 0, + ) + + viewModel.addTimer(timer) + + val updated = + TimerItemData( + id = "update-test", + title = "New", + color = Color(0xFF00FF00), + hour = 1, + minute = 0, + second = 0, + ) + + viewModel.updateTimer(updated) + + assertEquals("New", viewModel.timers.first { it.id == "update-test" }.title) + } + + @Test + fun `존재하지 않는 ID 삭제 시 오류 없이 유지된다`() { + val initialSize = viewModel.timers.size + viewModel.deleteTimer("not-found") + assertEquals(initialSize, viewModel.timers.size) + } + + @Test + fun `타이머 수정 시 리스트 크기는 유지된다`() { + val timer = + TimerItemData( + id = "size-test", + title = "원본", + color = Color(0xFFFF0000), + hour = 0, + minute = 10, + second = 0, + ) + + viewModel.addTimer(timer) + val sizeBefore = viewModel.timers.size + + val updated = + timer.copy( + title = "수정된 타이머", + minute = 20, + ) + viewModel.updateTimer(updated) + + val sizeAfter = viewModel.timers.size + assertEquals(sizeBefore, sizeAfter) + } + + @Test + fun `여러 타이머 추가 후 일부 삭제 시 남은 타이머 수가 예상과 같다`() { + val t1 = TimerItemData("t1", "타이머1", Color(0xFFFF0000), 0, 10, 0) + val t2 = TimerItemData("t2", "타이머2", Color(0xFFFF0000), 0, 20, 0) + val t3 = TimerItemData("t3", "타이머3", Color(0xFFFF0000), 0, 30, 0) + + viewModel.addTimer(t1) + viewModel.addTimer(t2) + viewModel.addTimer(t3) + + viewModel.deleteTimer("t2") + + assertEquals(2, viewModel.timers.size) + assertTrue(viewModel.timers.any { it.id == "t1" }) + assertTrue(viewModel.timers.any { it.id == "t3" }) + assertFalse(viewModel.timers.any { it.id == "t2" }) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 236801c..4815f00 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,18 @@ [versions] agp = "8.12.3" +junitJupiterApi = "6.0.1" +junitJupiterEngine = "6.0.1" +junitJupiterParams = "6.0.1" kotlin = "2.0.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +kotlinTest = "1.9.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.8.0" composeBom = "2024.09.00" +mockk = "1.14.6" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +29,11 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiterApi" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiterEngine" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junitJupiterParams" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlinTest" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6a2fbcc..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Thu Nov 06 20:50:04 KST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal