diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/data/repository/ReportCardRepository.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/data/repository/ReportCardRepository.kt index a17e918..126db1b 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/data/repository/ReportCardRepository.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/data/repository/ReportCardRepository.kt @@ -8,6 +8,7 @@ import com.yourssu.soomsil.usaint.data.source.local.dao.SemesterDao import com.yourssu.soomsil.usaint.data.source.local.datastore.ReportCardSummaryDataSource import com.yourssu.soomsil.usaint.data.source.local.datastore.StudentCredentialDataSource import com.yourssu.soomsil.usaint.data.source.local.entity.LectureEntity +import com.yourssu.soomsil.usaint.data.source.local.entity.SemesterEntity import com.yourssu.soomsil.usaint.data.source.local.entity.asEntity import com.yourssu.soomsil.usaint.data.source.local.entity.asExternalModel import com.yourssu.soomsil.usaint.data.source.remote.USaintRemoteSource @@ -35,6 +36,13 @@ class ReportCardRepository @Inject constructor( .mapValues { (_, lectureEntity) -> lectureEntity.map(LectureEntity::asExternalModel) } } + // 전체 학기 목록(학기별 GPA 포함). Semester 테이블은 한 번에 채워지므로 + // 강의 적재를 기다리는 semesterWithLectures와 달리 즉시 전체가 로드된다. + val semesters: Flow> = + semesterDao.getSemesterEntities().map { entities -> + entities.map(SemesterEntity::asExternalModel) + } + suspend fun fetchReportCardSummary(): Result = runCatching { val credential = studentCredential.getStudentCredential() val reportCardSummaryData = uSaintRemoteSource.remoteReportCardSummaryData(credential) diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelScreen.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelScreen.kt index f5466b4..6fe423d 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelScreen.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelScreen.kt @@ -21,6 +21,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.SnackbarHostState 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -49,10 +54,43 @@ fun ChapelScreen( // onRefresh = { viewModel.fetchData(refresh = true) }, // onPasswordChange = viewModel::changePassword // ) - ChapelSeatScreen( - modifier = modifier - ) - + val isOnLeave by viewModel.isOnLeave.collectAsStateWithLifecycle() + val chapelUiState by viewModel.chapelUiState.collectAsStateWithLifecycle() + var showSeatLocation by remember { mutableStateOf(false) } + + val chapelCard = (chapelUiState as? ChapelUiState.Chapel)?.chapelCard + val simple = chapelCard?.chapelSimpleData + val attendances = chapelCard?.chapelAttendances.orEmpty() + // 실제 내 출석/지각 일수 집계 + val attended = attendances.count { it.attendance == "출석" } + val late = attendances.count { it.attendance == "지각" } + + val seatNumber = simple?.seatNumber?.takeIf { it.isNotBlank() } ?: "-" + val seatFloor = simple?.let { "${it.floorLevel}층" } ?: "" + val seatZone = simple?.chapelRoom ?: "" + + if (showSeatLocation) { + MySeatLocationScreen( + seatCode = seatNumber, + seatFloor = seatFloor, + seatBuilding = seatZone, + onBackClick = { showSeatLocation = false }, + modifier = modifier, + ) + } else { + ChapelSeatScreen( + seatNumber = seatNumber, + seatFloor = seatFloor, + seatZone = seatZone, + attended = attended, + total = MAX_REQUIRED_CHAPEL, + late = late, + progress = (attended.toFloat() / MAX_REQUIRED_CHAPEL).coerceIn(0f, 1f), + isOnLeave = isOnLeave, + onViewSeatClick = { showSeatLocation = true }, + modifier = modifier, + ) + } } /* @OptIn(ExperimentalMaterial3Api::class) @@ -298,36 +336,14 @@ fun ChapelHeader( modifier = modifier .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_caret_left), - contentDescription = "뒤로가기", - modifier = Modifier - .size(24.dp) - .clickable { onBackClick() }, - tint = Color(0xFF0A0A0A) - ) - Text( - text = "채플", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF0A0A0A), - letterSpacing = (-0.3).sp - ) - } - Icon( - painter = painterResource(R.drawable.ic_info), - contentDescription = "정보", - modifier = Modifier - .size(22.dp) - .clickable { onInfoClick() }, - tint = Color(0xFF9CA3AF) + Text( + text = "채플", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0A0A0A), + letterSpacing = (-0.5).sp ) } } @@ -391,6 +407,9 @@ fun SeatHeroCard( // ─── Attendance Gauge ─── +// 한 학기 채플 이수 횟수는 최대 7회 +private const val MAX_REQUIRED_CHAPEL = 7 + @Composable fun AttendanceGauge( attended: Int, @@ -399,6 +418,8 @@ fun AttendanceGauge( progress: Float, modifier: Modifier = Modifier ) { + val cappedTotal = total.coerceAtMost(MAX_REQUIRED_CHAPEL) + val remaining = (cappedTotal - attended).coerceAtLeast(0) Column( modifier = modifier .fillMaxWidth() @@ -425,14 +446,14 @@ fun AttendanceGauge( horizontalArrangement = Arrangement.spacedBy(3.dp) ) { Text( - text = "${total - attended}", + text = "$remaining", fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFF0062FF), letterSpacing = (-0.4).sp ) Text( - text = "/ ${total}회", + text = "/ ${cappedTotal}회", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color(0xFF9CA3AF) @@ -538,13 +559,14 @@ fun ChapelSeatScreen( seatFloor: String = "1층 앞자리", seatZone: String = "A구역", attended: Int = 5, - total: Int = 8, + total: Int = 7, late: Int = 1, - progress: Float = 0.6f, + progress: Float = 0.71f, onBackClick: () -> Unit = {}, onInfoClick: () -> Unit = {}, onViewSeatClick: () -> Unit = {}, onAttendClick: () -> Unit = {}, + isOnLeave: Boolean = false, modifier: Modifier = Modifier ) { Column( @@ -557,26 +579,63 @@ fun ChapelSeatScreen( onInfoClick = onInfoClick ) - Column( - modifier = Modifier - .weight(1f) - .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - SeatHeroCard( - seatNumber = seatNumber, - floor = seatFloor, - zone = seatZone, - onViewSeatClick = onViewSeatClick - ) - AttendanceGauge( - attended = attended, - total = total, - late = late, - progress = progress + if (isOnLeave) { + ChapelOnLeaveMessage( + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) + } else { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SeatHeroCard( + seatNumber = seatNumber, + floor = seatFloor, + zone = seatZone, + onViewSeatClick = onViewSeatClick + ) + AttendanceGauge( + attended = attended, + total = total, + late = late, + progress = progress + ) + } + + // TODO: 출석 인증(QR) 기능 구현 전까지 임시 비활성화 + // AttendanceCta(onClick = onAttendClick) } + } +} - AttendanceCta(onClick = onAttendClick) +// ─── 휴학 안내 ─── + +@Composable +private fun ChapelOnLeaveMessage( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + Text( + text = "휴학 중이에요", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0A0A0A), + letterSpacing = (-0.3).sp + ) + Text( + text = "휴학 중이라 수강할 채플이 없어요", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF9CA3AF), + letterSpacing = (-0.2).sp + ) } } diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelSeatLocation.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelSeatLocation.kt index 51632bd..929f38d 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelSeatLocation.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelSeatLocation.kt @@ -291,7 +291,7 @@ private fun HelperText( // ─── Screen ─── @Composable -private fun MySeatLocationScreen( +fun MySeatLocationScreen( seatCode: String = "B-12", seatFloor: String = "1층 앞자리", seatBuilding: String = "한경직기념관", diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelViewModel.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelViewModel.kt index 9ec2d1a..fa2773e 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelViewModel.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/chapel/ChapelViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.yourssu.soomsil.usaint.core.model.ChapelData import com.yourssu.soomsil.usaint.data.repository.ChapelRepository +import com.yourssu.soomsil.usaint.data.repository.StudentDataRepository import com.yourssu.soomsil.usaint.data.source.local.datastore.StudentCredentialDataSource import com.yourssu.soomsil.usaint.domain.usecase.GetCurrentSemesterUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber @@ -36,9 +38,19 @@ class ChapelViewModel @Inject constructor( private val studentCredential: StudentCredentialDataSource, private val chapelRepository: ChapelRepository, private val getCurrentSemesterUseCase: GetCurrentSemesterUseCase, + studentDataRepository: StudentDataRepository, ) : ViewModel() { private var showPasswordIncorrectSnackbar = mutableStateOf(false) + // 휴학 중인 학생은 수강할 채플이 없음 (학적 상태로 판별) + val isOnLeave: StateFlow = studentDataRepository.studentData + .map { it.status.contains("휴학") } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) + val chapelUiState: StateFlow = combine( chapelRepository.chapelCard, chapelRepository.chapels, diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeDetailScreen.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeDetailScreen.kt index 0a90123..e0ea1fe 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeDetailScreen.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeDetailScreen.kt @@ -4,14 +4,22 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.yourssu.soomsil.usaint.screen.grade.components.CourseDetailCard import com.yourssu.soomsil.usaint.screen.grade.components.GpaDetailCard import com.yourssu.soomsil.usaint.screen.grade.components.GpaTrendChart @@ -23,40 +31,76 @@ import com.yourssu.soomsil.usaint.screen.grade.model.SemesterTab @Composable @Preview -fun GradeDetailScreen(){ - GradeDetailScreen(modifier = Modifier) +private fun GradeDetailScreenPreview() { + GradeDetailScreen( + onBackClick = {}, + semesters = listOf( + SemesterTab("2025년 1학기", isActive = true), + SemesterTab("2024년 2학기"), + SemesterTab("2024년 1학기"), + SemesterTab("2023년 2학기") + ), + gpaPoints = listOf( + GpaPoint("24-1", 3.2f), + GpaPoint("24-2", 3.5f), + GpaPoint("25-1", 3.7f), + GpaPoint("25-2", 3.87f, isCurrent = true) + ), + courses = listOf( + CourseItem("비전채플", "박영수", "0.5학점", "P", Color(0xFF0062FF), Color(0xFF0062FF), Color(0xFFE6F0FF)), + CourseItem("CTE for IT, Engineering", "최민지", "3.0학점", "A+", Color(0xFF059669), Color(0xFF059669), Color(0xFFECFDF5)), + CourseItem("인간관계론", "이준호", "2.0학점", "A-", Color(0xFF0062FF), Color(0xFF0062FF), Color(0xFFE6F0FF)), + ), + gpa = "3.87", + maxGpa = "4.5", + credits = "11.5", + courseCount = "5", + rank = "12위", + onTabClick = {}, + ) } -// ─── Screen ─── +// ─── Screen (ViewModel 연결) ─── @Composable fun GradeDetailScreen( onBackClick: () -> Unit = {}, - semesters: List = listOf( - SemesterTab("2025년 1학기", isActive = true), - SemesterTab("2024년 2학기"), - SemesterTab("2024년 1학기"), - SemesterTab("2023년 2학기") - ), - gpaPoints: List = listOf( - GpaPoint("24-1", 3.2f), - GpaPoint("24-2", 3.5f), - GpaPoint("25-1", 3.7f), - GpaPoint("25-2", 3.87f, isCurrent = true) - ), - courses: List = listOf( - CourseItem("비전채플", "박영수", "0.5학점", "P", Color(0xFF0062FF), Color(0xFF0062FF), Color(0xFFE6F0FF)), - CourseItem("CTE for IT, Engineering", "최민지", "3.0학점", "A+", Color(0xFF059669), Color(0xFF059669), Color(0xFFECFDF5)), - CourseItem("인간관계론", "이준호", "2.0학점", "A-", Color(0xFF0062FF), Color(0xFF0062FF), Color(0xFFE6F0FF)), - CourseItem("디지털미디어원리", "김서연", "3.0학점", "A-", Color(0xFF0062FF), Color(0xFF0062FF), Color(0xFFE6F0FF)), - CourseItem("데이터베이스", "전지훈", "3.0학점", "B+", Color(0xFFD97706), Color(0xFFD97706), Color(0xFFFFF7ED)) - ), - gpa: String = "3.87", - maxGpa: String = "4.5", - credits: String = "11.5", - courseCount: String = "5", - rank: String = "12위", - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: GradeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + GradeDetailScreen( + onBackClick = onBackClick, + semesters = uiState.semesters, + gpaPoints = uiState.gpaPoints, + courses = uiState.courses, + gpa = uiState.gpa, + maxGpa = uiState.maxGpa, + credits = uiState.credits, + courseCount = uiState.courseCount, + rank = uiState.rank, + registered = uiState.registered, + onTabClick = viewModel::onTabClick, + modifier = modifier, + ) +} + +// ─── Screen (stateless) ─── + +@Composable +fun GradeDetailScreen( + onBackClick: () -> Unit, + semesters: List, + gpaPoints: List, + courses: List, + gpa: String, + maxGpa: String, + credits: String, + courseCount: String, + rank: String, + onTabClick: (Int) -> Unit, + modifier: Modifier = Modifier, + registered: Boolean = true, ) { Column( modifier = modifier @@ -74,21 +118,36 @@ fun GradeDetailScreen( ) { SemesterTabs( tabs = semesters, - onTabClick = { /* handle tab change */ } + onTabClick = onTabClick ) - GpaDetailCard( - gpa = gpa, - maxGpa = maxGpa, - credits = credits, - courseCount = courseCount, - rank = rank - ) + if (registered) { + GpaDetailCard( + gpa = gpa, + maxGpa = maxGpa, + credits = credits, + courseCount = courseCount, + rank = rank + ) + } GpaTrendChart(points = gpaPoints) - courses.forEach { course -> - CourseDetailCard(course = course) + if (registered) { + courses.forEach { course -> + CourseDetailCard(course = course) + } + } else { + Text( + text = "아직 등록된 성적이 없어요", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF8B95A1), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + ) } } } diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeViewModel.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeViewModel.kt index 68a3dcc..99efbda 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeViewModel.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/GradeViewModel.kt @@ -1,8 +1,175 @@ package com.yourssu.soomsil.usaint.screen.grade import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yourssu.soomsil.usaint.core.model.LectureData +import com.yourssu.soomsil.usaint.core.model.SemesterData +import com.yourssu.soomsil.usaint.core.types.SemesterType +import com.yourssu.soomsil.usaint.data.repository.ReportCardRepository +import com.yourssu.soomsil.usaint.screen.grade.model.CourseItem +import com.yourssu.soomsil.usaint.screen.grade.model.GpaPoint +import com.yourssu.soomsil.usaint.screen.grade.model.SemesterTab +import com.yourssu.soomsil.usaint.screen.grade.model.gradeStyle import dagger.hilt.android.lifecycle.HiltViewModel +import dev.eatsteak.rusaint.ffi.RusaintException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.Locale import javax.inject.Inject +data class GradeUiState( + val isLoading: Boolean = true, + val semesters: List = emptyList(), + val gpaPoints: List = emptyList(), + val courses: List = emptyList(), + val gpa: String = "-", + val maxGpa: String = MAX_GPA, + val credits: String = "-", + val courseCount: String = "0", + val rank: String = "-", + // 선택한 학기의 성적이 공개(적재)되었는지 여부 + val registered: Boolean = false, +) { + companion object { + const val MAX_GPA = "4.5" + } +} + @HiltViewModel -class GradeViewModel @Inject constructor() : ViewModel() +class GradeViewModel @Inject constructor( + private val reportCardRepository: ReportCardRepository, +) : ViewModel() { + // 사용자가 선택한 학기 탭 인덱스(최신 학기 우선 정렬 기준). 기본값은 가장 최근 학기. + private val selectedIndex = MutableStateFlow(0) + + val uiState: StateFlow = combine( + reportCardRepository.semesters, + reportCardRepository.semesterWithLectures, + selectedIndex, + ) { semesters, semesterWithLectures, selected -> + if (semesters.isEmpty()) { + GradeUiState(isLoading = true) + } else { + buildUiState(semesters, semesterWithLectures, selected) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = GradeUiState(isLoading = true), + ) + + init { + viewModelScope.launch { + if (reportCardRepository.semesters.first().isEmpty()) { + fetchData() + } + } + } + + fun onTabClick(index: Int) { + selectedIndex.value = index + } + + fun fetchData() { + viewModelScope.launch { + reportCardRepository.fetchSemesterWithLectures() + .onFailure { e -> + Timber.e(e) + if (e is RusaintException) throw e + } + } + } + + private fun buildUiState( + semesters: List, + semesterWithLectures: Map>, + selected: Int, + ): GradeUiState { + // 오래된 학기 → 최신 학기 순 (GPA 추이 차트용) + val ascending = semesters.sortedWith( + compareBy({ it.year }, { it.semester.ordinal }) + ) + // 최신 학기 → 오래된 학기 순 (탭 표시용) + val descending = ascending.reversed() + val safeIndex = selected.coerceIn(0, (descending.size - 1).coerceAtLeast(0)) + val selectedSemester = descending.getOrNull(safeIndex) + + val semesterTabs = descending.mapIndexed { index, semester -> + SemesterTab(label = semester.tabLabel(), isActive = index == safeIndex) + } + + // 성적 추이: 전 학기를 종합한 추이이므로 휴학(미수강) 학기는 제외하고, + // P/F 전용 학기(평점 0, 이수학점>0)는 직전 성적을 유지한다. + var carriedGpa = 0f + val gpaPoints = ascending.filter { it.isEnrolled() }.map { semester -> + val gpaValue = if (semester.gradePointsAverage > 0f) { + carriedGpa = semester.gradePointsAverage + semester.gradePointsAverage + } else { + carriedGpa + } + GpaPoint( + semester = semester.chartLabel(), + gpa = gpaValue, + isCurrent = semester == selectedSemester, + ) + } + + // 강의 목록은 적재가 끝난 학기만 semesterWithLectures에 존재하므로 year/semester로 매칭 + val lectures = selectedSemester?.let { sel -> + semesterWithLectures.entries + .firstOrNull { it.key.year == sel.year && it.key.semester == sel.semester } + ?.value + }.orEmpty() + val courses = lectures.map { lecture -> + val grade = lecture.lectureGrade.toString() + val (dot, gradeColor, badgeBg) = gradeStyle(grade) + CourseItem( + name = lecture.title, + professor = lecture.professor, + credit = "${formatCredit(lecture.credit)}학점", + grade = grade, + dotColor = dot, + gradeColor = gradeColor, + badgeBgColor = badgeBg, + ) + } + + return GradeUiState( + isLoading = false, + semesters = semesterTabs, + gpaPoints = gpaPoints, + courses = courses, + gpa = selectedSemester?.let { "%.2f".format(Locale.US, it.gradePointsAverage) } ?: "-", + maxGpa = GradeUiState.MAX_GPA, + credits = selectedSemester?.let { formatCredit(it.earnedCredit) } ?: "-", + courseCount = courses.size.toString(), + rank = selectedSemester?.let { "${it.semesterRank.first}위" } ?: "-", + registered = lectures.isNotEmpty(), + ) + } + + private fun formatCredit(credit: Float): String = + if (credit % 1f == 0f) credit.toInt().toString() else "%.1f".format(Locale.US, credit) + + // 휴학/미수강 학기: 시도·취득 학점이 모두 0 + private fun SemesterData.isEnrolled(): Boolean = + attemptedCredit > 0f || earnedCredit > 0f + + private fun SemesterData.tabLabel(): String = "${year}년 ${semester.semesterLabel()}" + + private fun SemesterData.chartLabel(): String = "${year % 100}-${semester.kor}" + + private fun SemesterType.semesterLabel(): String = when (this) { + SemesterType.One -> "1학기" + SemesterType.Two -> "2학기" + SemesterType.Summer -> "여름학기" + SemesterType.Winter -> "겨울학기" + } +} diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/components/CourseDetailCard.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/components/CourseDetailCard.kt index 7801f5d..7a815d6 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/components/CourseDetailCard.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/grade/components/CourseDetailCard.kt @@ -5,9 +5,11 @@ import androidx.compose.foundation.border 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -16,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.yourssu.soomsil.usaint.screen.grade.model.CourseItem @@ -35,6 +38,7 @@ fun CourseDetailCard( verticalAlignment = Alignment.CenterVertically ) { Row( + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -48,21 +52,28 @@ fun CourseDetailCard( fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF191F28), - letterSpacing = (-0.3).sp + letterSpacing = (-0.3).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) Text( text = course.professor, fontSize = 12.sp, fontWeight = FontWeight.Normal, - color = Color(0xFF8B95A1) + color = Color(0xFF8B95A1), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( text = course.credit, fontSize = 12.sp, fontWeight = FontWeight.Normal, - color = Color(0xFFB0B8C1) + color = Color(0xFFB0B8C1), + maxLines = 1 ) } + Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier .background(course.badgeBgColor, RoundedCornerShape(8.dp)) diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainScreen.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainScreen.kt index 515ad69..d501243 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainScreen.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainScreen.kt @@ -7,28 +7,78 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.yourssu.soomsil.usaint.screen.main.components.ChapelCard import com.yourssu.soomsil.usaint.screen.main.components.GpaChartCard import com.yourssu.soomsil.usaint.screen.main.components.GpaHeroCard import com.yourssu.soomsil.usaint.screen.main.components.MainHeader import com.yourssu.soomsil.usaint.screen.main.components.ProfileCard import com.yourssu.soomsil.usaint.screen.main.model.GpaBarData +import com.yourssu.soomsil.usaint.ui.components.GradeBottomSheet +@OptIn(ExperimentalMaterial3Api::class) @Composable -@Preview fun MainScreen( onGradeDetailClick: () -> Unit = {}, - tabBar: @Composable () -> Unit = {} + onChartDetailClick: () -> Unit = {}, + onChapelClick: () -> Unit = {}, + tabBar: @Composable () -> Unit = {}, + viewModel: MainViewModel = hiltViewModel(), ){ + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showGradeSheet by remember { mutableStateOf(false) } + MainPageScreen( - onGradeDetailClick = onGradeDetailClick, - tabBar = tabBar + greetingName = uiState.greetingName, + notificationCount = uiState.notificationCount, + profileName = uiState.profileName, + department = uiState.department, + year = uiState.year, + status = uiState.status, + studentId = uiState.studentId, + gpa = uiState.gpa, + maxGpa = uiState.maxGpa, + barData = uiState.barData, + chapelAttended = uiState.chapelAttended, + chapelTotal = uiState.chapelTotal, + chapelProgress = uiState.chapelProgress, + // 이번 학기 성적보기 → 이번 학기 성적 바텀시트 + onGradeDetailClick = { showGradeSheet = true }, + // 전체 학기 추이 자세히 → 성적 상세 페이지 + onChartDetailClick = onChartDetailClick, + onChapelClick = onChapelClick, + tabBar = tabBar, ) + + if (showGradeSheet) { + GradeBottomSheet( + term = uiState.currentSemesterTerm, + registered = uiState.currentSemesterRegistered, + averageGrade = uiState.currentSemesterGpa, + earnedCredits = uiState.currentSemesterCredits, + courseCount = uiState.currentSemesterCourseCount, + courses = uiState.currentSemesterCourses, + onLeave = uiState.currentSemesterOnLeave, + onDismissRequest = { showGradeSheet = false }, + ) + } +} + +@Composable +@Preview +private fun MainScreenPreview() { + MainPageScreen(tabBar = {}) } // ─── Screen ─── @@ -75,7 +125,7 @@ private fun MainPageScreen( modifier = Modifier .weight(1f) .verticalScroll(rememberScrollState()) - .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 16.dp), + .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 110.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { ProfileCard( diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainViewModel.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainViewModel.kt index 678c3d7..8db965e 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/MainViewModel.kt @@ -1,8 +1,232 @@ package com.yourssu.soomsil.usaint.screen.main +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yourssu.soomsil.usaint.core.model.ChapelData +import com.yourssu.soomsil.usaint.core.model.LectureData +import com.yourssu.soomsil.usaint.core.model.ReportCardSummaryData +import com.yourssu.soomsil.usaint.core.model.SemesterData +import com.yourssu.soomsil.usaint.core.model.StudentData +import com.yourssu.soomsil.usaint.core.types.SemesterType +import com.yourssu.soomsil.usaint.data.repository.ChapelRepository +import com.yourssu.soomsil.usaint.data.repository.ReportCardRepository +import com.yourssu.soomsil.usaint.data.repository.StudentDataRepository +import com.yourssu.soomsil.usaint.domain.usecase.GetCurrentSemesterUseCase +import com.yourssu.soomsil.usaint.screen.main.model.GpaBarData +import com.yourssu.soomsil.usaint.screen.main.model.SemesterCourseItem import dagger.hilt.android.lifecycle.HiltViewModel +import dev.eatsteak.rusaint.ffi.RusaintException +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.Locale import javax.inject.Inject +data class MainUiState( + val isLoading: Boolean = true, + val greetingName: String = "", + val notificationCount: Int = 0, + val profileName: String = "", + val department: String = "", + val year: String = "", + val status: String = "", + val studentId: String = "", + val gpa: String = "-", + val maxGpa: String = MAX_GPA, + val barData: List = emptyList(), + val chapelAttended: Int = 0, + val chapelTotal: Int = 0, + val chapelProgress: Float = 0f, + // 이번 학기 성적 바텀시트용 + val currentSemesterTerm: String = "", + val currentSemesterRegistered: Boolean = false, + val currentSemesterGpa: String = UNKNOWN, + val currentSemesterCredits: String = UNKNOWN, + val currentSemesterCourseCount: String = UNKNOWN, + val currentSemesterCourses: List = emptyList(), + val currentSemesterOnLeave: Boolean = false, +) { + companion object { + const val MAX_GPA = "4.5" + const val UNKNOWN = "?" + } +} + @HiltViewModel -class MainViewModel @Inject constructor() : ViewModel() +class MainViewModel @Inject constructor( + private val studentDataRepository: StudentDataRepository, + private val reportCardRepository: ReportCardRepository, + private val chapelRepository: ChapelRepository, + private val getCurrentSemesterUseCase: GetCurrentSemesterUseCase, +) : ViewModel() { + + val uiState: StateFlow = combine( + studentDataRepository.studentData, + reportCardRepository.reportCardSummaryData, + reportCardRepository.semesters, + reportCardRepository.semesterWithLectures, + chapelRepository.chapelCard, + ) { student, summary, semesters, semesterWithLectures, chapelCard -> + // 현재 학기는 DB의 최신 행이 아니라 날짜 기준 유스케이스로 판별 + // (현재 학기가 아직 성적 미등록이라 DB에 없을 수 있음) + val currentKey = getCurrentSemesterUseCase() + buildUiState(student, summary, semesters, semesterWithLectures, chapelCard, currentKey) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = MainUiState(isLoading = true), + ) + + init { + viewModelScope.launch { + if (reportCardRepository.semesters.first().isEmpty()) { + fetchData() + } + } + } + + fun fetchData() { + viewModelScope.launch { + try { + studentDataRepository.fetchStudentData().onFailure { e -> + Timber.e(e) + if (e is RusaintException) throw e + } + reportCardRepository.fetchSemesterWithLectures().onFailure { e -> + Timber.e(e) + if (e is RusaintException) throw e + } + getCurrentSemesterUseCase()?.let { semester -> + chapelRepository.fetchChapelCardData(semester).onFailure { e -> Timber.e(e) } + } + } catch (e: RusaintException) { + Timber.e(e) + } + } + } + + private fun buildUiState( + student: StudentData, + summary: ReportCardSummaryData, + semesters: List, + semesterWithLectures: Map>, + chapelCard: ChapelData?, + currentKey: Pair?, + ): MainUiState { + val ascending = semesters.sortedWith(compareBy({ it.year }, { it.semester.ordinal })) + + // 성적 추이는 전 학기를 종합한 추이이므로 휴학(미수강) 학기는 제외하고, + // P/F 전용 학기(평점 0이지만 이수 학점 존재)는 직전 성적을 유지한다. + val enrolled = ascending.filter { it.isEnrolled() } + val barLastIndex = enrolled.lastIndex + var carriedGpa = 0f + val barData = enrolled.mapIndexed { index, semester -> + val gpaValue = if (semester.gradePointsAverage > 0f) { + carriedGpa = semester.gradePointsAverage + semester.gradePointsAverage + } else { + carriedGpa + } + val isCurrent = index == barLastIndex + GpaBarData( + label = "${semester.year % 100}-${semester.semester.kor}", + height = barHeight(gpaValue), + isCurrent = isCurrent, + gpaText = if (isCurrent) formatGpa(gpaValue) else null, + ) + } + + val attendances = chapelCard?.chapelAttendances.orEmpty() + val chapelTotal = attendances.size + val chapelAttended = attendances.count { it.attendance == "출석" } + val chapelProgress = if (chapelTotal > 0) chapelAttended.toFloat() / chapelTotal else 0f + + // 이번 학기는 날짜 기준 현재 학기(currentKey). DB에 해당 학기가 없거나 currentKey가 + // null이면(방학 등) 최신 학기로 폴백한다. 성적이 아직 없으면 성적 부분만 ?로 표시한다. + val fallback = ascending.lastOrNull() + val currentYear = currentKey?.first ?: fallback?.year + val currentType = currentKey?.second ?: fallback?.semester + val currentSemesterData = semesters.firstOrNull { + it.year == currentYear && it.semester == currentType + } + val currentLectures = if (currentYear != null && currentType != null) { + semesterWithLectures.entries + .firstOrNull { it.key.year == currentYear && it.key.semester == currentType } + ?.value.orEmpty() + } else emptyList() + val registered = currentSemesterData != null && currentLectures.isNotEmpty() + val isOnLeave = student.status.contains("휴학") + val currentTerm = if (currentYear != null && currentType != null) { + termLabel(currentYear, currentType) + } else "" + + return MainUiState( + isLoading = student.name.isBlank(), + greetingName = student.name, + notificationCount = 0, + profileName = student.name, + department = student.majors.firstOrNull() ?: student.department, + year = "${student.grade}학년", + status = student.status, + studentId = student.id, + gpa = formatGpa(summary.gradePointsAverage), + maxGpa = MainUiState.MAX_GPA, + barData = barData, + chapelAttended = chapelAttended, + chapelTotal = chapelTotal, + chapelProgress = chapelProgress, + currentSemesterTerm = currentTerm, + currentSemesterRegistered = registered, + currentSemesterGpa = if (registered) formatGpa(currentSemesterData!!.gradePointsAverage) else MainUiState.UNKNOWN, + currentSemesterCredits = if (registered) formatCredit(currentSemesterData!!.earnedCredit) else MainUiState.UNKNOWN, + currentSemesterCourseCount = if (registered) currentLectures.size.toString() else MainUiState.UNKNOWN, + currentSemesterCourses = currentLectures.map { lecture -> + SemesterCourseItem( + name = lecture.title, + professor = lecture.professor, + credit = formatCredit(lecture.credit), + grade = lecture.lectureGrade.toString(), + ) + }, + currentSemesterOnLeave = isOnLeave && !registered, + ) + } + + // 휴학/미수강 학기: 시도·취득 학점이 모두 0 + private fun SemesterData.isEnrolled(): Boolean = + attemptedCredit > 0f || earnedCredit > 0f + + private fun termLabel(year: Int, type: SemesterType): String { + val suffix = when (type) { + SemesterType.One -> "1학기" + SemesterType.Two -> "2학기" + SemesterType.Summer -> "여름학기" + SemesterType.Winter -> "겨울학기" + } + return "${year}년 $suffix" + } + + private fun formatCredit(credit: Float): String = + if (credit % 1f == 0f) credit.toInt().toString() else "%.1f".format(Locale.US, credit) + + // GPA에 0 기준으로 비례하는 높이(막대 바닥을 공유하므로 높이만으로 비교 가능). + // GPA가 0이어도 최소한의 막대는 보이도록 floor를 둔다. + private fun barHeight(gpa: Float): Dp { + val ratio = (gpa / MAX_GPA_VALUE).coerceIn(0f, 1f) + return (MAX_BAR_HEIGHT * ratio).coerceAtLeast(MIN_BAR_HEIGHT) + } + + private fun formatGpa(gpa: Float): String = "%.2f".format(Locale.US, gpa) + + companion object { + private const val MAX_GPA_VALUE = 4.5f + private val MIN_BAR_HEIGHT = 6.dp + private val MAX_BAR_HEIGHT = 90.dp + } +} diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/components/GpaChartCard.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/components/GpaChartCard.kt index c08b5d7..dff4b8b 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/components/GpaChartCard.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/components/GpaChartCard.kt @@ -8,6 +8,7 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -61,38 +62,53 @@ fun GpaChartCard( Row( modifier = Modifier .fillMaxWidth() - .height(118.dp) + .height(140.dp) .padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Bottom ) { bars.forEach { bar -> Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(5.dp) + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.weight(1f)) - - if (bar.gpaText != null) { - Text( - text = bar.gpaText, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF0A0A0A), - letterSpacing = (-0.3).sp - ) + // 값 라벨 영역: 모든 막대에 동일한 높이를 예약해 막대 바닥선이 어긋나지 않도록 함 + Box( + modifier = Modifier.height(16.dp), + contentAlignment = Alignment.Center + ) { + if (bar.gpaText != null) { + Text( + text = bar.gpaText, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0A0A0A), + letterSpacing = (-0.3).sp + ) + } } + // 플롯 영역: 막대를 하단에 고정해 모든 막대가 같은 바닥선을 공유 Box( modifier = Modifier - .fillMaxWidth() - .height(bar.height) - .background( - if (bar.isCurrent) Color(0xFF0A0A0A) else Color(0xFFDCE9FF), - RoundedCornerShape(6.dp) - ) - ) + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(bar.height) + .background( + if (bar.isCurrent) Color(0xFF0A0A0A) else Color(0xFFDCE9FF), + RoundedCornerShape(6.dp) + ) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) Text( text = bar.label, diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/model/MainModels.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/model/MainModels.kt index 511cb74..b504b7d 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/model/MainModels.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/model/MainModels.kt @@ -14,3 +14,11 @@ data class TabItem( val label: String, @DrawableRes val iconRes: Int ) + +// 이번 학기 성적 바텀시트의 강의 항목 +data class SemesterCourseItem( + val name: String, + val professor: String, + val credit: String, + val grade: String, +) diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/navigation/MainNavigation.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/navigation/MainNavigation.kt index d272b00..89e01bc 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/navigation/MainNavigation.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/main/navigation/MainNavigation.kt @@ -17,7 +17,7 @@ fun NavGraphBuilder.mainScreen( ) { composable
{ MainScreen( - onGradeDetailClick = navigateToGradeDetail, + onChartDetailClick = navigateToGradeDetail, ) } } diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageScreen.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageScreen.kt index f149b34..ae06584 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageScreen.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageScreen.kt @@ -28,15 +28,42 @@ 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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.yourssu.soomsil.usaint.BuildConfig @Composable -@Preview() fun MyPageScreen( onBackClick: () -> Unit = {}, - tabBar: @Composable () -> Unit = {} + onLogoutClick: () -> Unit = {}, + tabBar: @Composable () -> Unit = {}, + viewModel: MyPageViewModel = hiltViewModel(), ) { - MyPageScreenContent(tabBar = tabBar) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + MyPageScreenContent( + gradeNotification = uiState.gradeNotificationEnabled, + onGradeNotificationChange = viewModel::setGradeNotificationEnabled, + campusNotification = uiState.campusNotificationEnabled, + onCampusNotificationChange = viewModel::setCampusNotificationEnabled, + onLogoutClick = { + viewModel.logout() + onLogoutClick() + }, + tabBar = tabBar, + ) +} + +@Composable +@Preview +private fun MyPageScreenPreview() { + MyPageScreenContent( + gradeNotification = true, + onGradeNotificationChange = {}, + campusNotification = true, + onCampusNotificationChange = {}, + onLogoutClick = {}, + tabBar = {}, + ) } @@ -51,8 +78,8 @@ private fun SettingsHeader(modifier: Modifier = Modifier) { contentAlignment = Alignment.CenterStart ) { Text( text = "설정", - fontSize =24.sp, - lineHeight =30.sp, + fontSize = 22.sp, + lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = Color(0xFF191F28) ) @@ -147,12 +174,14 @@ private fun SettingSection( @Composable private fun MyPageScreenContent( + gradeNotification: Boolean, + onGradeNotificationChange: (Boolean) -> Unit, + campusNotification: Boolean, + onCampusNotificationChange: (Boolean) -> Unit, + onLogoutClick: () -> Unit, tabBar: @Composable () -> Unit, modifier: Modifier = Modifier ) { - var gradeNotification by remember { mutableStateOf(true) } - var campusNotification by remember { mutableStateOf(true) } - Box( modifier = modifier .fillMaxSize() @@ -173,7 +202,7 @@ private fun MyPageScreenContent( ) { // 계정관리 SettingSection("계정관리") { - SettingRow("로그아웃") + SettingRow("로그아웃", onClick = onLogoutClick) } // 알림 @@ -181,12 +210,12 @@ private fun MyPageScreenContent( ToggleRow( label = "성적 알림 받기", checked = gradeNotification, - onCheckedChange = { gradeNotification = it } + onCheckedChange = onGradeNotificationChange ) ToggleRow( label = "캠퍼스 알림 받기", checked = campusNotification, - onCheckedChange = { campusNotification = it } + onCheckedChange = onCampusNotificationChange ) } diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageViewModel.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageViewModel.kt index 322e2e2..ff4b3fa 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageViewModel.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/mypage/MyPageViewModel.kt @@ -1,8 +1,69 @@ package com.yourssu.soomsil.usaint.screen.mypage import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yourssu.soomsil.usaint.data.repository.ChapelRepository +import com.yourssu.soomsil.usaint.data.repository.ReportCardRepository +import com.yourssu.soomsil.usaint.data.repository.StudentCredentialRepository +import com.yourssu.soomsil.usaint.data.repository.StudentDataRepository +import com.yourssu.soomsil.usaint.data.repository.UserDataRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject +data class MyPageUiState( + val gradeNotificationEnabled: Boolean = true, + val campusNotificationEnabled: Boolean = true, +) + @HiltViewModel -class MyPageViewModel @Inject constructor() : ViewModel() +class MyPageViewModel @Inject constructor( + private val userDataRepository: UserDataRepository, + private val studentCredentialRepository: StudentCredentialRepository, + private val studentDataRepository: StudentDataRepository, + private val reportCardRepository: ReportCardRepository, + private val chapelRepository: ChapelRepository, +) : ViewModel() { + // UserData에는 단일 notificationEnabled 필드만 있어 성적 알림에 매핑(영속화)하고, + // 캠퍼스 알림은 대응 필드가 없어 VM 내부 상태로 관리한다. + private val campusNotificationEnabled = MutableStateFlow(true) + + val uiState: StateFlow = combine( + userDataRepository.userData, + campusNotificationEnabled, + ) { userData, campus -> + MyPageUiState( + gradeNotificationEnabled = userData.notificationEnabled, + campusNotificationEnabled = campus, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = MyPageUiState(), + ) + + fun setGradeNotificationEnabled(enabled: Boolean) { + viewModelScope.launch { + userDataRepository.setNotificationEnabled(enabled) + } + } + + fun setCampusNotificationEnabled(enabled: Boolean) { + campusNotificationEnabled.value = enabled + } + + fun logout() { + viewModelScope.launch { + reportCardRepository.deleteAll() + studentCredentialRepository.clear() + studentDataRepository.clear() + userDataRepository.clear() + chapelRepository.deleteAll() + } + } +} diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingScreen.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingScreen.kt index a9a29b1..0e82d6d 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingScreen.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingScreen.kt @@ -34,6 +34,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.yourssu.soomsil.usaint.screen.mypage.NotificationSettingViewModel import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -51,6 +54,35 @@ private val DividerColor = Color(0xFFF1F5F9) private val ToggleOnColor = Color(0xFF0062FF) private val ToggleOffColor = Color(0xFFE5E7EB) +@Composable +fun NotificationSettingScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + viewModel: NotificationSettingViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + NotificationSettingScreen( + pushNotificationEnabled = uiState.pushNotificationEnabled, + onPushNotificationToggle = viewModel::setPushNotificationEnabled, + soundEnabled = uiState.soundEnabled, + onSoundToggle = viewModel::setSoundEnabled, + vibrationEnabled = uiState.vibrationEnabled, + onVibrationToggle = viewModel::setVibrationEnabled, + courseRegistrationEnabled = uiState.courseRegistrationEnabled, + onCourseRegistrationToggle = viewModel::setCourseRegistrationEnabled, + assignmentDeadlineEnabled = uiState.assignmentDeadlineEnabled, + onAssignmentDeadlineToggle = viewModel::setAssignmentDeadlineEnabled, + gradeReleaseEnabled = uiState.gradeReleaseEnabled, + onGradeReleaseToggle = viewModel::setGradeReleaseEnabled, + chapelEnabled = uiState.chapelEnabled, + onChapelToggle = viewModel::setChapelEnabled, + marketingEnabled = uiState.marketingEnabled, + onMarketingToggle = viewModel::setMarketingEnabled, + modifier = modifier, + onBackClick = onBackClick, + ) +} + @Composable fun NotificationSettingScreen( pushNotificationEnabled: Boolean, diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingViewModel.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingViewModel.kt index 2408e4e..a4d3234 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingViewModel.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/notificationsetting/NotificationSettingViewModel.kt @@ -2,7 +2,49 @@ package com.yourssu.soomsil.usaint.screen.mypage import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject +data class NotificationSettingUiState( + val pushNotificationEnabled: Boolean = true, + val soundEnabled: Boolean = true, + val vibrationEnabled: Boolean = false, + val courseRegistrationEnabled: Boolean = true, + val assignmentDeadlineEnabled: Boolean = true, + val gradeReleaseEnabled: Boolean = true, + val chapelEnabled: Boolean = true, + val marketingEnabled: Boolean = false, +) + @HiltViewModel -class NotificationSettingViewModel @Inject constructor() : ViewModel() \ No newline at end of file +class NotificationSettingViewModel @Inject constructor() : ViewModel() { + private val _uiState = MutableStateFlow(NotificationSettingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun setPushNotificationEnabled(enabled: Boolean) = + _uiState.update { it.copy(pushNotificationEnabled = enabled) } + + fun setSoundEnabled(enabled: Boolean) = + _uiState.update { it.copy(soundEnabled = enabled) } + + fun setVibrationEnabled(enabled: Boolean) = + _uiState.update { it.copy(vibrationEnabled = enabled) } + + fun setCourseRegistrationEnabled(enabled: Boolean) = + _uiState.update { it.copy(courseRegistrationEnabled = enabled) } + + fun setAssignmentDeadlineEnabled(enabled: Boolean) = + _uiState.update { it.copy(assignmentDeadlineEnabled = enabled) } + + fun setGradeReleaseEnabled(enabled: Boolean) = + _uiState.update { it.copy(gradeReleaseEnabled = enabled) } + + fun setChapelEnabled(enabled: Boolean) = + _uiState.update { it.copy(chapelEnabled = enabled) } + + fun setMarketingEnabled(enabled: Boolean) = + _uiState.update { it.copy(marketingEnabled = enabled) } +} diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsScreen.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsScreen.kt index 2942f7d..8c2df52 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsScreen.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsScreen.kt @@ -15,8 +15,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,14 +45,36 @@ private val HomeIndicator = Color(0x331A1A1A) @Composable @Preview fun PushNotificationsScreen() { - PushNotificationsScreen(modifier = Modifier) + PushNotificationsScreen( + unreadCount = 0, + selectedTab = NotificationTab.ALL, + onTabSelected = {}, + modifier = Modifier, + ) } // ─── Screen ─── @Composable fun PushNotificationsScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: PushNotificationsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + PushNotificationsScreen( + unreadCount = uiState.unreadCount, + selectedTab = uiState.selectedTab, + onTabSelected = viewModel::selectTab, + modifier = modifier, + ) +} + +@Composable +fun PushNotificationsScreen( + unreadCount: Int, + selectedTab: NotificationTab, + onTabSelected: (NotificationTab) -> Unit, + modifier: Modifier = Modifier, ) { Column( modifier = modifier @@ -60,8 +88,8 @@ fun PushNotificationsScreen( .weight(1f) .verticalScroll(rememberScrollState()) ) { - UnreadHeroSection() - NotificationTabs() + UnreadHeroSection(unreadCount = unreadCount) + NotificationTabs(selectedTab = selectedTab, onTabSelected = onTabSelected) NotificationBanner() EmptyNotificationState() } @@ -102,6 +130,7 @@ private fun NotificationHeader( @Composable private fun UnreadHeroSection( + unreadCount: Int, modifier: Modifier = Modifier ) { Column( @@ -123,7 +152,7 @@ private fun UnreadHeroSection( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = "0", + text = unreadCount.toString(), fontSize = 36.sp, fontWeight = FontWeight.ExtraBold, color = EmphasisText, @@ -163,6 +192,8 @@ private fun UnreadHeroSection( @Composable private fun NotificationTabs( + selectedTab: NotificationTab, + onTabSelected: (NotificationTab) -> Unit, modifier: Modifier = Modifier ) { Row( @@ -172,10 +203,14 @@ private fun NotificationTabs( horizontalArrangement = Arrangement.spacedBy(28.dp), verticalAlignment = Alignment.CenterVertically ) { - NotificationTabItem(text = "전체", selected = true, underlineWidth = 32.dp) - NotificationTabItem(text = "학사", selected = false, underlineWidth = 24.dp) - NotificationTabItem(text = "수업", selected = false, underlineWidth = 24.dp) - NotificationTabItem(text = "시험", selected = false, underlineWidth = 24.dp) + NotificationTab.entries.forEach { tab -> + NotificationTabItem( + text = tab.label, + selected = tab == selectedTab, + underlineWidth = if (tab == NotificationTab.ALL) 32.dp else 24.dp, + onClick = { onTabSelected(tab) }, + ) + } } } @@ -184,10 +219,16 @@ private fun NotificationTabItem( text: String, selected: Boolean, underlineWidth: Dp, + onClick: () -> Unit, modifier: Modifier = Modifier ) { Column( - modifier = modifier.padding(vertical = 12.dp), + modifier = modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onClick() } + .padding(vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsViewModel.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsViewModel.kt index 0448b77..467f987 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsViewModel.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/screen/pushnotifications/PushNotificationsViewModel.kt @@ -2,7 +2,64 @@ package com.yourssu.soomsil.usaint.screen.pushnotifications import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject +enum class NotificationTab(val label: String) { + ALL("전체"), + ACADEMIC("학사"), + CLASS("수업"), + EXAM("시험"), +} + +data class PushNotificationItem( + val id: String, + val tab: NotificationTab, + val title: String, + val body: String, + val read: Boolean = false, +) + +data class PushNotificationsUiState( + val notifications: List = emptyList(), + val selectedTab: NotificationTab = NotificationTab.ALL, +) { + val visibleNotifications: List + get() = if (selectedTab == NotificationTab.ALL) { + notifications + } else { + notifications.filter { it.tab == selectedTab } + } + + val unreadCount: Int + get() = visibleNotifications.count { !it.read } +} + @HiltViewModel -class PushNotificationsViewModel @Inject constructor() : ViewModel() +class PushNotificationsViewModel @Inject constructor() : ViewModel() { + private val _uiState = MutableStateFlow(PushNotificationsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun selectTab(tab: NotificationTab) { + _uiState.update { it.copy(selectedTab = tab) } + } + + fun markAsRead(id: String) { + _uiState.update { state -> + state.copy( + notifications = state.notifications.map { + if (it.id == id) it.copy(read = true) else it + } + ) + } + } + + fun markAllAsRead() { + _uiState.update { state -> + state.copy(notifications = state.notifications.map { it.copy(read = true) }) + } + } +} diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/GradeBottomSheet.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/GradeBottomSheet.kt index 928685c..d537f18 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/GradeBottomSheet.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/GradeBottomSheet.kt @@ -4,11 +4,15 @@ 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,31 +21,28 @@ 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 GradeBottomSheet(){ - GradeBottomSheet( - "2026년 1학기", - 3.87, - 11.5, - 5, - ) -} +import com.yourssu.soomsil.usaint.screen.main.model.SemesterCourseItem @OptIn(ExperimentalMaterial3Api::class) @Composable -@Preview -private fun GradeBottomSheet( - term: String = "2026년 1학기", - averageGrade: Double = 3.87, - earnedCredits: Double = 11.5, - courseCount: Int = 5 +fun GradeBottomSheet( + term: String, + registered: Boolean, + averageGrade: String, + earnedCredits: String, + courseCount: String, + courses: List, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + onLeave: Boolean = false, + sheetState: SheetState = rememberModalBottomSheetState(), ) { ModalBottomSheet( - modifier = Modifier, - onDismissRequest = { /*TODO*/ } + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, ) { - Box( //SheetHeader + Box( // SheetHeader modifier = Modifier .fillMaxWidth() .padding(start = 24.dp, end = 24.dp, top = 20.dp) @@ -65,7 +66,7 @@ private fun GradeBottomSheet( } } - Box( //GPA Summary + Box( // GPA Summary modifier = Modifier .fillMaxWidth() .padding(vertical = 20.dp, horizontal = 24.dp) @@ -85,7 +86,7 @@ private fun GradeBottomSheet( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = averageGrade.toString(), + text = averageGrade, fontSize = 32.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFF191F28) @@ -104,50 +105,87 @@ private fun GradeBottomSheet( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // 취득 - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = "취득", - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF8B95A1) - ) - Text( - text = earnedCredits.toString(), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF191F28) - ) - } + GradeSummaryStat(label = "취득", value = earnedCredits) + GradeSummaryStat(label = "과목", value = courseCount) + } + } - // 과목 - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = "과목", - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF8B95A1) - ) - Text( - text = courseCount.toString(), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF191F28) + if (registered) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + courses.forEach { course -> + CourseCard( + courseName = course.name, + professor = course.professor, + credit = course.credit, + grade = course.grade, ) } } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (onLeave) "이번 학기는 휴학이에요" else "아직 등록된 성적이 없어요", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF8B95A1) + ) + } } - CourseCard( - courseName = "객체지향 프로그래밍", - professor = "최지웅", - credit = "3.0", - grade = "A+" + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun GradeSummaryStat( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = label, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF8B95A1) + ) + Text( + text = value, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF191F28) ) } -} \ No newline at end of file +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Preview +private fun GradeBottomSheetPreview() { + GradeBottomSheet( + term = "2026년 1학기", + registered = true, + averageGrade = "3.87", + earnedCredits = "11.5", + courseCount = "5", + courses = listOf( + SemesterCourseItem("객체지향 프로그래밍", "최지웅", "3", "A+"), + SemesterCourseItem("자료구조", "김교수", "3", "A0"), + ), + onDismissRequest = {}, + ) +} diff --git a/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/TabBar.kt b/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/TabBar.kt index eeef4f6..cf309ea 100644 --- a/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/TabBar.kt +++ b/app/src/main/kotlin/com/yourssu/soomsil/usaint/ui/components/TabBar.kt @@ -143,7 +143,7 @@ private fun RowScope.TabItem( verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), modifier = Modifier .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 6.dp) + .padding(horizontal = 12.dp, vertical = 4.dp) ) { Image( painter = painterResource(id = iconId),