From cae137269cce6d49eea838cb3379711e84c5c8ce Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Tue, 28 Jul 2026 00:06:56 +0900 Subject: [PATCH] feat: timetable impl --- .../local/GuestTimetableLocalDataSource.kt | 125 ++ .../data/network/api/TimetableApi.kt | 38 + .../network/model/CreateTimetableRequest.kt | 12 + .../network/model/TimetableListResponse.kt | 8 + .../data/network/model/TimetableResponse.kt | 14 + .../network/model/UpdateTimetableRequest.kt | 8 + .../data/repository/TimetableRepository.kt | 93 ++ .../hangsha_android/di/NetworkModule.kt | 9 + .../ui/navigation/HangshaNavHost.kt | 3 +- .../timetable/TimetableAddCourseValidator.kt | 100 ++ .../timetable/TimetableLayoutCalculator.kt | 128 ++ .../ui/view/timetable/TimetableScreen.kt | 8 + .../timetable/TimetableScreenContentHost.kt | 1122 +++++++++++++++++ .../ui/view/timetable/TimetableUiModels.kt | 42 + .../ui/view/timetable/TimetableViewModel.kt | 335 +++++ .../TimetableAddCourseValidatorTest.kt | 124 ++ .../TimetableLayoutCalculatorTest.kt | 88 ++ 17 files changed, 2256 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/example/hangsha_android/data/local/GuestTimetableLocalDataSource.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/api/TimetableApi.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/CreateTimetableRequest.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/TimetableListResponse.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/TimetableResponse.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/UpdateTimetableRequest.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/repository/TimetableRepository.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidator.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculator.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreen.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreenContentHost.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableUiModels.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableViewModel.kt create mode 100644 app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidatorTest.kt create mode 100644 app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculatorTest.kt diff --git a/app/src/main/java/com/example/hangsha_android/data/local/GuestTimetableLocalDataSource.kt b/app/src/main/java/com/example/hangsha_android/data/local/GuestTimetableLocalDataSource.kt new file mode 100644 index 0000000..13cecdb --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/local/GuestTimetableLocalDataSource.kt @@ -0,0 +1,125 @@ +package com.example.hangsha_android.data.local + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.hangsha_android.data.network.model.TimetableListResponse +import com.example.hangsha_android.data.network.model.TimetableResponse +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.guestTimetablesDataStore by preferencesDataStore(name = "guest_timetables") + +@Singleton +class GuestTimetableLocalDataSource @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val gson = Gson() + + val timetables: Flow> = + context.guestTimetablesDataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> parseTimetables(preferences[GUEST_TIMETABLES_JSON]) } + + suspend fun getTimetables( + year: Int, + semester: String + ): TimetableListResponse { + return TimetableListResponse( + items = timetables.first() + .filter { timetable -> timetable.year == year && timetable.semester == semester } + .map { timetable -> timetable.toTimetableResponse() } + ) + } + + suspend fun createTimetable( + name: String, + year: Int, + semester: String + ): TimetableResponse { + val currentTimetables = timetables.first() + val nextId = (currentTimetables.minOfOrNull { timetable -> timetable.id } ?: 0L) - 1L + val timetable = StoredGuestTimetable( + id = nextId, + name = name.trim(), + year = year, + semester = semester + ) + replaceAll(currentTimetables + timetable) + return timetable.toTimetableResponse() + } + + suspend fun updateTimetableName( + timetableId: Long, + name: String + ): TimetableResponse { + val currentTimetables = timetables.first() + val target = currentTimetables.firstOrNull { timetable -> timetable.id == timetableId } + ?: throw NoSuchElementException("Timetable was not found.") + val updatedTimetable = target.copy(name = name.trim()) + replaceAll(currentTimetables.map { timetable -> + if (timetable.id == timetableId) updatedTimetable else timetable + }) + return updatedTimetable.toTimetableResponse() + } + suspend fun deleteTimetable(timetableId: Long) { + replaceAll(timetables.first().filterNot { timetable -> timetable.id == timetableId }) + } + private suspend fun replaceAll(items: List) { + context.guestTimetablesDataStore.edit { preferences -> + preferences[GUEST_TIMETABLES_JSON] = gson.toJson( + items + .filter { timetable -> timetable.name.isNotBlank() } + .distinctBy { timetable -> timetable.id } + .sortedWith(compareBy({ timetable -> timetable.year }, { timetable -> timetable.semester }, { timetable -> timetable.id })) + ) + } + } + + private fun parseTimetables(json: String?): List { + if (json.isNullOrBlank()) { + return emptyList() + } + + return runCatching { + gson.fromJson>(json, storedTimetableListType) + }.getOrDefault(emptyList()) + } + + companion object { + private val GUEST_TIMETABLES_JSON = stringPreferencesKey("guest_timetables_json") + private val storedTimetableListType = object : TypeToken>() {}.type + } +} + +data class StoredGuestTimetable( + val id: Long, + val name: String, + val year: Int, + val semester: String +) + +private fun StoredGuestTimetable.toTimetableResponse(): TimetableResponse { + return TimetableResponse( + id = id, + name = name, + year = year, + semester = semester + ) +} diff --git a/app/src/main/java/com/example/hangsha_android/data/network/api/TimetableApi.kt b/app/src/main/java/com/example/hangsha_android/data/network/api/TimetableApi.kt new file mode 100644 index 0000000..5453c2d --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/api/TimetableApi.kt @@ -0,0 +1,38 @@ +package com.example.hangsha_android.data.network.api + +import com.example.hangsha_android.data.network.model.CreateTimetableRequest +import com.example.hangsha_android.data.network.model.TimetableListResponse +import com.example.hangsha_android.data.network.model.TimetableResponse +import com.example.hangsha_android.data.network.model.UpdateTimetableRequest +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface TimetableApi { + @GET("api/v1/timetables") + suspend fun getTimetables( + @Query("year") year: Int, + @Query("semester") semester: String + ): Response + + @PATCH("api/v1/timetables/{timetableId}") + suspend fun updateTimetable( + @Path("timetableId") timetableId: Long, + @Body request: UpdateTimetableRequest + ): Response + + @DELETE("api/v1/timetables/{timetableId}") + suspend fun deleteTimetable( + @Path("timetableId") timetableId: Long + ): Response + + @POST("api/v1/timetables") + suspend fun createTimetable( + @Body request: CreateTimetableRequest + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/CreateTimetableRequest.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/CreateTimetableRequest.kt new file mode 100644 index 0000000..f86cd2d --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/CreateTimetableRequest.kt @@ -0,0 +1,12 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class CreateTimetableRequest( + @SerializedName("name") + val name: String, + @SerializedName("year") + val year: Int, + @SerializedName("semester") + val semester: String +) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableListResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableListResponse.kt new file mode 100644 index 0000000..e80fe3d --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableListResponse.kt @@ -0,0 +1,8 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class TimetableListResponse( + @SerializedName("items") + val items: List +) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableResponse.kt new file mode 100644 index 0000000..25883f8 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableResponse.kt @@ -0,0 +1,14 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class TimetableResponse( + @SerializedName("id") + val id: Long, + @SerializedName("name") + val name: String, + @SerializedName("year") + val year: Int, + @SerializedName("semester") + val semester: String +) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateTimetableRequest.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateTimetableRequest.kt new file mode 100644 index 0000000..38dacae --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateTimetableRequest.kt @@ -0,0 +1,8 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class UpdateTimetableRequest( + @SerializedName("name") + val name: String +) diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/TimetableRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/TimetableRepository.kt new file mode 100644 index 0000000..a408429 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/repository/TimetableRepository.kt @@ -0,0 +1,93 @@ +package com.example.hangsha_android.data.repository + +import com.example.hangsha_android.data.local.AuthTokenStorage +import com.example.hangsha_android.data.local.GuestTimetableLocalDataSource +import com.example.hangsha_android.data.network.api.TimetableApi +import com.example.hangsha_android.data.network.model.CreateTimetableRequest +import com.example.hangsha_android.data.network.model.TimetableListResponse +import com.example.hangsha_android.data.network.model.TimetableResponse +import com.example.hangsha_android.data.network.model.UpdateTimetableRequest +import javax.inject.Inject +import javax.inject.Singleton +import retrofit2.Response + +@Singleton +class TimetableRepository @Inject constructor( + private val timetableApi: TimetableApi, + private val localDataSource: GuestTimetableLocalDataSource, + private val authTokenStorage: AuthTokenStorage +) { + suspend fun getTimetables( + year: Int, + semester: String + ): Response { + if (!isLoggedIn()) { + return Response.success( + localDataSource.getTimetables( + year = year, + semester = semester + ) + ) + } + + return timetableApi.getTimetables( + year = year, + semester = semester + ) + } + + suspend fun createTimetable( + name: String, + year: Int, + semester: String + ): Response { + if (!isLoggedIn()) { + return Response.success( + localDataSource.createTimetable( + name = name, + year = year, + semester = semester + ) + ) + } + + return timetableApi.createTimetable( + request = CreateTimetableRequest( + name = name, + year = year, + semester = semester + ) + ) + } + + suspend fun updateTimetableName( + timetableId: Long, + name: String + ): Response { + val normalizedName = name.trim() + if (!isLoggedIn()) { + return Response.success( + localDataSource.updateTimetableName( + timetableId = timetableId, + name = normalizedName + ) + ) + } + + return timetableApi.updateTimetable( + timetableId = timetableId, + request = UpdateTimetableRequest(name = normalizedName) + ) + } + suspend fun deleteTimetable(timetableId: Long): Response { + if (!isLoggedIn()) { + localDataSource.deleteTimetable(timetableId) + return Response.success(Unit) + } + + return timetableApi.deleteTimetable(timetableId = timetableId) + } + private fun isLoggedIn(): Boolean { + return authTokenStorage.hasAccessToken() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt b/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt index c0be38b..cb95048 100644 --- a/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt +++ b/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt @@ -11,6 +11,7 @@ import com.example.hangsha_android.data.network.api.EventApi import com.example.hangsha_android.data.network.api.ExcludedKeywordsApi import com.example.hangsha_android.data.network.api.MemoApi import com.example.hangsha_android.data.network.api.ServerHealthApi +import com.example.hangsha_android.data.network.api.TimetableApi import com.example.hangsha_android.data.network.api.UserApi import dagger.Module import dagger.Provides @@ -176,6 +177,14 @@ object NetworkModule { return retrofit.create(CategoryApi::class.java) } + @Provides + @Singleton + fun provideTimetableApi( + @Named("app") retrofit: Retrofit + ): TimetableApi { + return retrofit.create(TimetableApi::class.java) + } + private val AUTH_PATH_PREFIXES = listOf( "/api/v1/mobile/auth/", "/api/v1/auth/" diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index a09080d..486e28a 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -60,6 +60,7 @@ import com.example.hangsha_android.ui.view.onboarding.OnboardingViewModel import com.example.hangsha_android.ui.view.onboarding.OnboardingWelcomeScreen import com.example.hangsha_android.ui.view.signup.SignUpScreen import com.example.hangsha_android.ui.view.signup.SignUpViewModel +import com.example.hangsha_android.ui.view.timetable.TimetableScreen import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.common.api.ApiException @@ -528,7 +529,7 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { ) } composable(BottomTab.Timetable.route) { - SimplePageText("timetable") + TimetableScreen() } composable(BottomTab.Bookmarks.route) { val authStateViewModel: AuthStateViewModel = hiltViewModel() diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidator.kt b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidator.kt new file mode 100644 index 0000000..50170b0 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidator.kt @@ -0,0 +1,100 @@ +package com.example.hangsha_android.ui.view.timetable + +internal enum class TimetableDayOfWeek(val weekday: Int, val label: String, val apiValue: String) { + MON(0, "월", "MON"), + TUE(1, "화", "TUE"), + WED(2, "수", "WED"), + THU(3, "목", "THU"), + FRI(4, "금", "FRI"), + SAT(5, "토", "SAT"), + SUN(6, "일", "SUN") +} + +internal data class EditableTimeSlot( + val localId: Long, + val dayOfWeek: TimetableDayOfWeek, + val startAt: Int, + val endAt: Int +) + +internal data class TimeSlotError( + val localId: Long, + val message: String +) + +internal data class AddCourseValidationResult( + val canSubmit: Boolean, + val titleError: String?, + val creditError: String?, + val timeSlotErrors: List +) + +internal object TimetableAddCourseValidator { + fun validate( + timetableId: String?, + title: String, + creditText: String, + timeSlots: List, + existingTimeSlots: List, + isSubmitting: Boolean + ): AddCourseValidationResult { + val timeSlotErrors = mutableListOf() + val titleError = if (title.trim().isEmpty()) "과목명을 입력하세요" else null + val creditError = validateCredit(creditText) + + if (timeSlots.isEmpty()) { + timeSlotErrors += TimeSlotError(-1L, "수업 시간을 1개 이상 추가하세요") + } + + timeSlots.forEach { slot -> + when { + slot.endAt <= slot.startAt -> { + timeSlotErrors += TimeSlotError(slot.localId, "종료 시각은 시작 시각보다 늦어야 합니다") + } + !slot.startAt.isFiveMinuteUnit() || !slot.endAt.isFiveMinuteUnit() -> { + timeSlotErrors += TimeSlotError(slot.localId, "시간은 5분 단위여야 합니다") + } + } + } + + timeSlots.forEach { slot -> + if (existingTimeSlots.any { existing -> slot.overlaps(existing) }) { + timeSlotErrors += TimeSlotError(slot.localId, "기존 수업과 시간이 겹칩니다") + } + } + + timeSlots.forEachIndexed { index, slot -> + val hasInternalOverlap = timeSlots.drop(index + 1).any { other -> slot.overlaps(other) } + if (hasInternalOverlap) { + timeSlotErrors += TimeSlotError(slot.localId, "추가한 시간끼리 겹칩니다") + } + } + + return AddCourseValidationResult( + canSubmit = timetableId != null && + titleError == null && + creditError == null && + timeSlots.isNotEmpty() && + timeSlotErrors.isEmpty() && + !isSubmitting, + titleError = titleError, + creditError = creditError, + timeSlotErrors = timeSlotErrors.distinctBy { it.localId to it.message } + ) + } + + private fun validateCredit(creditText: String): String? { + val normalized = creditText.trim() + if (normalized.isEmpty()) { + return null + } + val credit = normalized.toIntOrNull() ?: return "학점은 0 이상의 정수여야 합니다" + return if (credit < 0) "학점은 0 이상의 정수여야 합니다" else null + } + + private fun Int.isFiveMinuteUnit(): Boolean = this % 5 == 0 + + private fun EditableTimeSlot.overlaps(other: EditableTimeSlot): Boolean { + return dayOfWeek == other.dayOfWeek && startAt < other.endAt && other.startAt < endAt + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculator.kt b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculator.kt new file mode 100644 index 0000000..a3267cb --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculator.kt @@ -0,0 +1,128 @@ +package com.example.hangsha_android.ui.view.timetable + +internal data class TimetableBlock( + val id: String, + val weekday: Int, + val startMinute: Int, + val endMinute: Int +) + +internal data class PositionedTimetableBlock( + val id: String, + val weekday: Int, + val clippedStartMinute: Int, + val clippedEndMinute: Int, + val topFraction: Float, + val heightFraction: Float, + val laneIndex: Int, + val laneCount: Int +) + +internal object TimetableLayoutCalculator { + fun positionBlocks( + blocks: List, + gridStartMinute: Int, + gridEndMinute: Int, + dayCount: Int = 5, + splitOverlaps: Boolean = false + ): List { + if (gridEndMinute <= gridStartMinute) { + return emptyList() + } + + val clippedBlocks = blocks.mapNotNull { block -> + clipBlock( + block = block, + gridStartMinute = gridStartMinute, + gridEndMinute = gridEndMinute, + dayCount = dayCount + ) + } + + return if (splitOverlaps) { + clippedBlocks + .groupBy { it.weekday } + .values + .flatMap { blocksForDay -> assignOverlapLanes(blocksForDay) } + .sortedWith(compareBy({ it.weekday }, { it.clippedStartMinute }, { it.laneIndex })) + } else { + clippedBlocks + } + } + + private fun clipBlock( + block: TimetableBlock, + gridStartMinute: Int, + gridEndMinute: Int, + dayCount: Int + ): PositionedTimetableBlock? { + if (block.weekday !in 0 until dayCount || block.endMinute <= block.startMinute) { + return null + } + + val clippedStart = maxOf(block.startMinute, gridStartMinute) + val clippedEnd = minOf(block.endMinute, gridEndMinute) + if (clippedEnd <= clippedStart) { + return null + } + + val gridDuration = gridEndMinute - gridStartMinute + return PositionedTimetableBlock( + id = block.id, + weekday = block.weekday, + clippedStartMinute = clippedStart, + clippedEndMinute = clippedEnd, + topFraction = (clippedStart - gridStartMinute).toFloat() / gridDuration, + heightFraction = (clippedEnd - clippedStart).toFloat() / gridDuration, + laneIndex = 0, + laneCount = 1 + ) + } + + private fun assignOverlapLanes( + blocks: List + ): List { + val sortedBlocks = blocks.sortedWith(compareBy({ it.clippedStartMinute }, { it.clippedEndMinute })) + val result = mutableListOf() + val activeGroup = mutableListOf() + var activeGroupEnd = Int.MIN_VALUE + + fun flushGroup() { + if (activeGroup.isEmpty()) { + return + } + result += assignLanesWithinGroup(activeGroup) + activeGroup.clear() + activeGroupEnd = Int.MIN_VALUE + } + + sortedBlocks.forEach { block -> + if (activeGroup.isNotEmpty() && block.clippedStartMinute >= activeGroupEnd) { + flushGroup() + } + activeGroup += block + activeGroupEnd = maxOf(activeGroupEnd, block.clippedEndMinute) + } + flushGroup() + + return result + } + + private fun assignLanesWithinGroup( + blocks: List + ): List { + val laneEnds = mutableListOf() + val assigned = blocks.map { block -> + val reusableLane = laneEnds.indexOfFirst { laneEnd -> laneEnd <= block.clippedStartMinute } + val laneIndex = if (reusableLane >= 0) reusableLane else laneEnds.size + if (reusableLane >= 0) { + laneEnds[reusableLane] = block.clippedEndMinute + } else { + laneEnds += block.clippedEndMinute + } + block.copy(laneIndex = laneIndex) + } + val laneCount = maxOf(1, laneEnds.size) + return assigned.map { block -> block.copy(laneCount = laneCount) } + } +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreen.kt new file mode 100644 index 0000000..c562401 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreen.kt @@ -0,0 +1,8 @@ +package com.example.hangsha_android.ui.view.timetable + +import androidx.compose.runtime.Composable + +@Composable +fun TimetableScreen() { + TimetableScreenContentHost() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreenContentHost.kt b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreenContentHost.kt new file mode 100644 index 0000000..d56a968 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableScreenContentHost.kt @@ -0,0 +1,1122 @@ +package com.example.hangsha_android.ui.view.timetable + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Canvas +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.heightIn +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.hangsha_android.ui.theme.Ink60 +import com.example.hangsha_android.ui.theme.Ink100 +import com.example.hangsha_android.ui.theme.PureWhite + +private const val GridStartMinute = 9 * 60 +private const val GridEndMinute = 18 * 60 +private const val DayCount = 5 +private const val DefaultYear = 2026 +private const val DefaultSemester = "FALL" +private val TimeLabelWidth = 26.dp +private val HeaderHeight = 26.dp +private val GridLineColor = Color(0xFFE8E8E8) +private val HalfHourLineColor = Color(0xFFF1F1F1) +private val CourseMaskColor = Color(0xFFCFCFCF) +private val SnuttDisabledButtonColor = Color(0xFFCFCFCF) +private val ChangeButtonColor = Color(0xFF72D3EC) +private val AddButtonColor = Color(0xFFF08AA0) +private val EditButtonColor = Color(0xFF72D3EC) +private val DeleteButtonColor = Color(0xFFF08AA0) +private val PanelHintColor = Color(0xFFB4B4B4) +private val WeekdayLabels = listOf("월", "화", "수", "목", "금") +private val CourseColors = listOf( + Color(0xFF55B9DC), + Color(0xFFEAC94D), + Color(0xFF2D82EA), + Color(0xFFE94061), + Color(0xFF54C987), + Color(0xFF8F56EC) +) +private val EmptyTimetable = TimetableUiModel( + id = "empty", + name = "시간표 이름", + year = DefaultYear, + semester = DefaultSemester, + courses = emptyList() +) + +// 시간표 화면 상태 호스트: 로컬 시간표 생성, 선택, 수업 추가 상태를 관리한다. +@Composable +internal fun TimetableScreenContentHost() { + val timetableViewModel: TimetableViewModel = hiltViewModel() + val apiUiState by timetableViewModel.uiState.collectAsState() + val events = remember { emptyList() } + var timetables by remember { mutableStateOf(emptyList()) } + var selectedTimetableId by rememberSaveable { mutableStateOf(null) } + var isEventOverlayEnabled by rememberSaveable { mutableStateOf(false) } + var isTimetablePanelOpen by rememberSaveable { mutableStateOf(false) } + var isCreateTimetablePanelOpen by rememberSaveable { mutableStateOf(false) } + var isEditTimetablePanelOpen by rememberSaveable { mutableStateOf(false) } + var isAddCoursePanelOpen by rememberSaveable { mutableStateOf(false) } + var nextTimetableId by rememberSaveable { mutableStateOf(1L) } + var nextCourseId by rememberSaveable { mutableStateOf(1L) } + var nextSlotId by rememberSaveable { mutableStateOf(2L) } + var createTimetableName by rememberSaveable { mutableStateOf("") } + var editTimetableId by rememberSaveable { mutableStateOf(null) } + var editTimetableName by rememberSaveable { mutableStateOf("") } + var courseTitle by rememberSaveable { mutableStateOf("") } + var instructor by rememberSaveable { mutableStateOf("") } + var creditText by rememberSaveable { mutableStateOf("") } + var timeSlots by remember { + mutableStateOf(listOf(defaultEditableTimeSlot(1L))) + } + var isSubmitting by rememberSaveable { mutableStateOf(false) } + var submitError by rememberSaveable { mutableStateOf(null) } + var submitMessage by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(Unit) { + timetableViewModel.loadTimetables( + year = DefaultYear, + semester = DefaultSemester + ) + } + + LaunchedEffect(apiUiState.timetables) { + val localCoursesByTimetableId = timetables.associate { timetable -> + timetable.id to timetable.courses + } + val loadedTimetables = apiUiState.timetables.map { response -> + TimetableUiModel( + id = response.id.toString(), + name = response.name, + year = response.year, + semester = response.semester, + courses = localCoursesByTimetableId[response.id.toString()].orEmpty() + ) + } + if (loadedTimetables.isNotEmpty() || timetables.isNotEmpty()) { + timetables = loadedTimetables + if (selectedTimetableId == null || loadedTimetables.none { timetable -> timetable.id == selectedTimetableId }) { + selectedTimetableId = loadedTimetables.firstOrNull()?.id + } + } + } + + LaunchedEffect(apiUiState.updatedTimetable) { + val updatedTimetable = apiUiState.updatedTimetable ?: return@LaunchedEffect + timetables = timetables.map { timetable -> + if (timetable.id == updatedTimetable.id.toString()) { + timetable.copy(name = updatedTimetable.name, year = updatedTimetable.year, semester = updatedTimetable.semester) + } else { + timetable + } + } + editTimetableId = null + editTimetableName = "" + isTimetablePanelOpen = false + isEditTimetablePanelOpen = false + timetableViewModel.onUpdatedTimetableConsumed() + } + LaunchedEffect(apiUiState.deletedTimetableId) { + val deletedTimetableId = apiUiState.deletedTimetableId?.toString() ?: return@LaunchedEffect + val updatedTimetables = timetables.filterNot { timetable -> timetable.id == deletedTimetableId } + timetables = updatedTimetables + if (selectedTimetableId == deletedTimetableId) { + selectedTimetableId = updatedTimetables.firstOrNull()?.id + } + timetableViewModel.onDeletedTimetableConsumed() + } + LaunchedEffect(apiUiState.createdTimetable) { + val createdTimetable = apiUiState.createdTimetable ?: return@LaunchedEffect + val newTimetable = TimetableUiModel( + id = createdTimetable.id.toString(), + name = createdTimetable.name, + year = createdTimetable.year, + semester = createdTimetable.semester, + courses = emptyList() + ) + timetables = timetables.filterNot { timetable -> timetable.id == newTimetable.id } + newTimetable + selectedTimetableId = newTimetable.id + createTimetableName = "" + isTimetablePanelOpen = false + isCreateTimetablePanelOpen = false + isAddCoursePanelOpen = false + timetableViewModel.onCreatedTimetableConsumed() + } + + val selectedTimetable = timetables.firstOrNull { it.id == selectedTimetableId } + ?: timetables.firstOrNull() + ?: EmptyTimetable + val hasSelectedTimetable = timetables.any { it.id == selectedTimetableId } + val validation = TimetableAddCourseValidator.validate( + timetableId = selectedTimetableId.takeIf { hasSelectedTimetable }, + title = courseTitle, + creditText = creditText, + timeSlots = timeSlots, + existingTimeSlots = selectedTimetable.courses.toExistingEditableSlots(), + isSubmitting = isSubmitting + ) + + fun closePanels() { + isTimetablePanelOpen = false + isCreateTimetablePanelOpen = false + isAddCoursePanelOpen = false + } + + fun resetAddCourseForm(message: String? = null) { + courseTitle = "" + instructor = "" + creditText = "" + timeSlots = listOf(defaultEditableTimeSlot(nextSlotId++)) + submitError = null + submitMessage = message + } + + TimetableScreenContent( + selectedTimetable = selectedTimetable, + timetables = timetables, + events = events, + isLoadingTimetables = apiUiState.isLoadingTimetables, + loadTimetablesError = apiUiState.loadTimetablesError, + deletingTimetableId = apiUiState.deletingTimetableId?.toString(), + deleteTimetableError = apiUiState.deleteTimetableError, + hasSelectedTimetable = hasSelectedTimetable, + isEventOverlayEnabled = isEventOverlayEnabled, + isTimetablePanelOpen = isTimetablePanelOpen, + isCreateTimetablePanelOpen = isCreateTimetablePanelOpen, + isEditTimetablePanelOpen = isEditTimetablePanelOpen, + isAddCoursePanelOpen = isAddCoursePanelOpen, + createTimetableName = createTimetableName, + createTimetableError = apiUiState.createTimetableError, + isCreatingTimetable = apiUiState.isCreatingTimetable, + editTimetableName = editTimetableName, + editTimetableError = apiUiState.updateTimetableError, + isUpdatingTimetable = apiUiState.updatingTimetableId != null, + courseTitle = courseTitle, + instructor = instructor, + creditText = creditText, + timeSlots = timeSlots, + validation = validation, + submitError = submitError, + submitMessage = submitMessage, + isSubmitting = isSubmitting, + onEventOverlayChanged = { isEventOverlayEnabled = it }, + onOpenTimetablePanel = { + isAddCoursePanelOpen = false + isCreateTimetablePanelOpen = false + isTimetablePanelOpen = true + }, + onClosePanels = { closePanels() }, + onSelectTimetable = { timetableId -> + selectedTimetableId = timetableId + closePanels() + }, + onOpenEditTimetable = { timetableId -> + val target = timetables.firstOrNull { timetable -> timetable.id == timetableId } + if (target != null) { + timetableViewModel.clearUpdateError() + editTimetableId = target.id + editTimetableName = target.name + isTimetablePanelOpen = false + isCreateTimetablePanelOpen = false + isAddCoursePanelOpen = false + isEditTimetablePanelOpen = true + } + }, + onDeleteTimetable = { timetableId -> + timetableViewModel.clearDeleteError() + timetableId.toLongOrNull()?.let { id -> timetableViewModel.deleteTimetable(id) } + }, + onOpenCreateTimetable = { + isTimetablePanelOpen = false + isAddCoursePanelOpen = false + isCreateTimetablePanelOpen = true + }, + onCreateTimetableNameChange = { createTimetableName = it }, + onEditTimetableNameChange = { editTimetableName = it }, + onSubmitCreateTimetable = { + val normalizedName = createTimetableName.trim() + if (normalizedName.isNotEmpty()) { + timetableViewModel.createTimetable( + name = normalizedName, + year = DefaultYear, + semester = DefaultSemester + ) + } + }, + onSubmitEditTimetable = { + val timetableId = editTimetableId?.toLongOrNull() + val normalizedName = editTimetableName.trim() + if (timetableId != null && normalizedName.isNotEmpty()) { + timetableViewModel.updateTimetableName(timetableId = timetableId, name = normalizedName) + } + }, + onOpenAddCourse = { + if (hasSelectedTimetable) { + isTimetablePanelOpen = false + isCreateTimetablePanelOpen = false + isAddCoursePanelOpen = true + submitError = null + submitMessage = null + } + }, + onTitleChange = { + courseTitle = it + submitError = null + submitMessage = null + }, + onInstructorChange = { instructor = it }, + onCreditChange = { + creditText = it.filter { char -> char.isDigit() } + submitError = null + }, + onAddTimeSlot = { + timeSlots = timeSlots + defaultEditableTimeSlot(nextSlotId++) + }, + onRemoveTimeSlot = { localId -> + if (timeSlots.size > 1) { + timeSlots = timeSlots.filterNot { it.localId == localId } + } + }, + onChangeDay = { localId, day -> + timeSlots = timeSlots.map { slot -> + if (slot.localId == localId) slot.copy(dayOfWeek = day) else slot + } + }, + onChangeStart = { localId, minute -> + timeSlots = timeSlots.map { slot -> + if (slot.localId == localId) slot.copy(startAt = minute.coerceIn(0, 24 * 60 - 5)) else slot + } + }, + onChangeEnd = { localId, minute -> + timeSlots = timeSlots.map { slot -> + if (slot.localId == localId) slot.copy(endAt = minute.coerceIn(5, 24 * 60)) else slot + } + }, + onSubmitAddCourse = { + if (!validation.canSubmit || isSubmitting || selectedTimetableId == null) { + return@TimetableScreenContent + } + isSubmitting = true + val newCourse = CourseUiModel( + id = "local-course-${nextCourseId++}", + title = courseTitle.trim(), + instructor = instructor.trim().takeIf { it.isNotEmpty() }, + credit = creditText.trim().takeIf { it.isNotEmpty() }?.toIntOrNull(), + color = CourseColors[(nextCourseId.toInt() - 2).floorMod(CourseColors.size)], + timeSlots = timeSlots.map { slot -> + CourseTimeSlot( + weekday = slot.dayOfWeek.weekday, + startMinute = slot.startAt, + endMinute = slot.endAt + ) + } + ) + timetables = timetables.map { timetable -> + if (timetable.id == selectedTimetableId) { + timetable.copy(courses = timetable.courses + newCourse) + } else { + timetable + } + } + isSubmitting = false + resetAddCourseForm(message = "수업을 추가했습니다") + } + ) +} + +// 화면 전체 레이아웃: 그리드, 플로팅 버튼, 하단 패널들을 한 화면 안에서 겹쳐 배치한다. +@Composable +private fun TimetableScreenContent( + selectedTimetable: TimetableUiModel, + timetables: List, + events: List, + isLoadingTimetables: Boolean, + loadTimetablesError: String?, + deletingTimetableId: String?, + deleteTimetableError: String?, + hasSelectedTimetable: Boolean, + isEventOverlayEnabled: Boolean, + isTimetablePanelOpen: Boolean, + isCreateTimetablePanelOpen: Boolean, + isEditTimetablePanelOpen: Boolean, + isAddCoursePanelOpen: Boolean, + createTimetableName: String, + createTimetableError: String?, + isCreatingTimetable: Boolean, + editTimetableName: String, + editTimetableError: String?, + isUpdatingTimetable: Boolean, + courseTitle: String, + instructor: String, + creditText: String, + timeSlots: List, + validation: AddCourseValidationResult, + submitError: String?, + submitMessage: String?, + isSubmitting: Boolean, + onEventOverlayChanged: (Boolean) -> Unit, + onOpenTimetablePanel: () -> Unit, + onClosePanels: () -> Unit, + onSelectTimetable: (String) -> Unit, + onOpenEditTimetable: (String) -> Unit, + onDeleteTimetable: (String) -> Unit, + onOpenCreateTimetable: () -> Unit, + onCreateTimetableNameChange: (String) -> Unit, + onEditTimetableNameChange: (String) -> Unit, + onSubmitCreateTimetable: () -> Unit, + onSubmitEditTimetable: () -> Unit, + onOpenAddCourse: () -> Unit, + onTitleChange: (String) -> Unit, + onInstructorChange: (String) -> Unit, + onCreditChange: (String) -> Unit, + onAddTimeSlot: () -> Unit, + onRemoveTimeSlot: (Long) -> Unit, + onChangeDay: (Long, TimetableDayOfWeek) -> Unit, + onChangeStart: (Long, Int) -> Unit, + onChangeEnd: (Long, Int) -> Unit, + onSubmitAddCourse: () -> Unit +) { + val hasPanelOpen = isTimetablePanelOpen || isCreateTimetablePanelOpen || isEditTimetablePanelOpen || isAddCoursePanelOpen + BackHandler(enabled = hasPanelOpen, onBack = onClosePanels) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(24.dp)) + TimetableHeader( + name = selectedTimetable.name, + credits = selectedTimetable.totalCredits, + isEventOverlayEnabled = isEventOverlayEnabled, + onEventOverlayChanged = onEventOverlayChanged + ) + Spacer(modifier = Modifier.height(4.dp)) + WeekdayHeader() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + WeeklyTimetableGrid( + courses = selectedTimetable.courses, + events = events, + showEvents = isEventOverlayEnabled, + modifier = Modifier.fillMaxSize() + ) + TimetableFloatingActions( + hasSelectedTimetable = hasSelectedTimetable, + onChangeTimetableClick = onOpenTimetablePanel, + onAddCourseClick = onOpenAddCourse, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (isTimetablePanelOpen) { + TimetableSelectionPanel( + timetables = timetables, + selectedTimetableId = selectedTimetable.id, + isLoading = isLoadingTimetables, + errorMessage = loadTimetablesError, + deletingTimetableId = deletingTimetableId, + deleteErrorMessage = deleteTimetableError, + onClose = onClosePanels, + onAddTimetableClick = onOpenCreateTimetable, + onSelectTimetable = onSelectTimetable, + onEditTimetableClick = onOpenEditTimetable, + onDeleteTimetableClick = onDeleteTimetable, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + + if (isCreateTimetablePanelOpen) { + TransparentDismissLayer(onDismiss = onClosePanels) + CreateTimetablePanel( + name = createTimetableName, + errorMessage = createTimetableError, + isCreating = isCreatingTimetable, + onNameChange = onCreateTimetableNameChange, + onClose = onClosePanels, + onSubmit = onSubmitCreateTimetable, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + + if (isEditTimetablePanelOpen) { + TransparentDismissLayer(onDismiss = onClosePanels) + CreateTimetablePanel( + title = "시간표 이름 수정", + submitText = "저장", + name = editTimetableName, + errorMessage = editTimetableError, + isCreating = isUpdatingTimetable, + onNameChange = onEditTimetableNameChange, + onClose = onClosePanels, + onSubmit = onSubmitEditTimetable, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + if (isAddCoursePanelOpen) { + TransparentDismissLayer(onDismiss = onClosePanels) + AddCoursePanel( + courseTitle = courseTitle, + instructor = instructor, + creditText = creditText, + timeSlots = timeSlots, + validation = validation, + submitError = submitError, + submitMessage = submitMessage, + isSubmitting = isSubmitting, + onTitleChange = onTitleChange, + onInstructorChange = onInstructorChange, + onCreditChange = onCreditChange, + onAddTimeSlot = onAddTimeSlot, + onRemoveTimeSlot = onRemoveTimeSlot, + onChangeDay = onChangeDay, + onChangeStart = onChangeStart, + onChangeEnd = onChangeEnd, + onClose = onClosePanels, + onSubmit = onSubmitAddCourse, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} + +// 상단 헤더: 시간표 이름, 학점, 행사 보기 스위치를 표시한다. +@Composable +private fun TimetableHeader( + name: String, + credits: Int, + isEventOverlayEnabled: Boolean, + onEventOverlayChanged: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(38.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "(${credits}학점)", + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = isEventOverlayEnabled, + onCheckedChange = onEventOverlayChanged, + modifier = Modifier + .size(width = 42.dp, height = 24.dp) + .semantics { + contentDescription = "행사 보기" + stateDescription = if (isEventOverlayEnabled) "켜짐" else "꺼짐" + }, + colors = SwitchDefaults.colors( + checkedThumbColor = PureWhite, + checkedTrackColor = ChangeButtonColor, + uncheckedThumbColor = PureWhite, + uncheckedTrackColor = Color(0xFFD0D0D0), + uncheckedBorderColor = Color.Transparent, + checkedBorderColor = Color.Transparent + ) + ) + } +} + +// 요일 헤더: 시간 라벨 영역을 제외한 월~금 열 제목을 그린다. +@Composable +private fun WeekdayHeader() { + Row( + modifier = Modifier + .fillMaxWidth() + .height(HeaderHeight), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(TimeLabelWidth)) + WeekdayLabels.forEach { label -> + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +// 주간 그리드: 시간 좌표 계산 결과에 따라 수업/행사 레이어를 순서대로 올린다. +@Composable +private fun WeeklyTimetableGrid( + courses: List, + events: List, + showEvents: Boolean, + modifier: Modifier = Modifier +) { + val courseBlocks = remember(courses) { courses.toCourseBlocks() } + val coursePositions = remember(courseBlocks) { + TimetableLayoutCalculator.positionBlocks( + blocks = courseBlocks.map { block -> + TimetableBlock( + id = block.id, + weekday = block.weekday, + startMinute = block.startMinute, + endMinute = block.endMinute + ) + }, + gridStartMinute = GridStartMinute, + gridEndMinute = GridEndMinute + ).associateBy { it.id } + } + val eventPositions = remember(events) { + TimetableLayoutCalculator.positionBlocks( + blocks = events.map { event -> + TimetableBlock( + id = event.id, + weekday = event.weekday, + startMinute = event.startMinute, + endMinute = event.endMinute + ) + }, + gridStartMinute = GridStartMinute, + gridEndMinute = GridEndMinute, + splitOverlaps = true + ).associateBy { it.id } + } + + BoxWithConstraints(modifier = modifier) { + val gridWidth = maxWidth - TimeLabelWidth + val dayWidth = gridWidth / DayCount + val gridHeight = maxHeight + + GridLines() + HourLabels(gridHeight = gridHeight) + + if (showEvents) { + events.forEach { event -> + val position = eventPositions[event.id] ?: return@forEach + TimetableBlockBackground(position, dayWidth, gridHeight, event.categoryColor, 1f) + } + courseBlocks.forEach { block -> + val position = coursePositions[block.id] ?: return@forEach + TimetableBlockBackground(position, dayWidth, gridHeight, CourseMaskColor, 0.72f) + } + events.forEach { event -> + val position = eventPositions[event.id] ?: return@forEach + TimetableBlockLabel(position, dayWidth, gridHeight, event.title, PureWhite, onClick = {}) + } + } else { + courseBlocks.forEach { block -> + val position = coursePositions[block.id] ?: return@forEach + CourseBlock(position, dayWidth, gridHeight, block.color, block.title, block.subtitle, onClick = {}) + } + } + } +} + +// 그리드 배경: 30분 단위 가로선을 그리고 시간 라벨 영역은 비워둔다. +@Composable +private fun GridLines() { + Canvas(modifier = Modifier.fillMaxSize()) { + val labelWidthPx = TimeLabelWidth.toPx() + val minuteHeight = size.height / (GridEndMinute - GridStartMinute) + for (minute in GridStartMinute..GridEndMinute step 30) { + val y = (minute - GridStartMinute) * minuteHeight + val color = if (minute % 60 == 0) GridLineColor else HalfHourLineColor + drawLine(color = color, start = Offset(labelWidthPx, y), end = Offset(size.width, y), strokeWidth = 1.dp.toPx()) + } + } +} + +// 시간 축: 그리드 높이에 맞춰 9시부터 6시까지의 라벨 위치를 계산한다. +@Composable +private fun HourLabels(gridHeight: Dp) { + val gridDuration = GridEndMinute - GridStartMinute + for (minute in GridStartMinute until GridEndMinute step 60) { + val top = gridHeight * ((minute - GridStartMinute).toFloat() / gridDuration) + Text( + text = hourLabel(minute), + modifier = Modifier.offset(x = 0.dp, y = top - 8.dp).width(TimeLabelWidth), + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF8D8D8D), + fontSize = 11.sp, + textAlign = TextAlign.Start + ) + } +} + +// 수업 블록: 토글 OFF 상태에서 과목 색상과 텍스트를 중앙 정렬로 표시한다. +@Composable +private fun CourseBlock(position: PositionedTimetableBlock, dayWidth: Dp, gridHeight: Dp, color: Color, title: String, subtitle: String?, onClick: () -> Unit) { + Box( + modifier = blockModifier(position, dayWidth, gridHeight) + .clip(RoundedCornerShape(0.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = listOfNotNull(title, subtitle).joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, + color = PureWhite, + fontSize = 9.sp, + lineHeight = 11.sp, + fontWeight = FontWeight.Bold, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun TimetableBlockBackground(position: PositionedTimetableBlock, dayWidth: Dp, gridHeight: Dp, color: Color, alpha: Float) { + Box(modifier = blockModifier(position, dayWidth, gridHeight).background(color.copy(alpha = alpha))) +} + +@Composable +private fun TimetableBlockLabel(position: PositionedTimetableBlock, dayWidth: Dp, gridHeight: Dp, text: String, textColor: Color, onClick: () -> Unit) { + Box( + modifier = blockModifier(position, dayWidth, gridHeight).clickable(onClick = onClick).padding(horizontal = 4.dp, vertical = 5.dp), + contentAlignment = Alignment.Center + ) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = textColor, fontSize = 9.sp, lineHeight = 11.sp, fontWeight = FontWeight.Bold, maxLines = 5, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } +} + +@Composable +private fun TimetableFloatingActions(hasSelectedTimetable: Boolean, onChangeTimetableClick: () -> Unit, onAddCourseClick: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(7.dp)) { + TimetablePillButton("SNUTT 연동", SnuttDisabledButtonColor, false, {}, "SNUTT 연동, 비활성") + TimetablePillButton("시간표 바꾸기", ChangeButtonColor, true, onChangeTimetableClick, "시간표 바꾸기") + TimetablePillButton("수업 추가", AddButtonColor, hasSelectedTimetable, onAddCourseClick, "수업 추가") { + Icon(imageVector = Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } +} + +@Composable +private fun TimetableSelectionPanel( + timetables: List, + selectedTimetableId: String, + isLoading: Boolean, + errorMessage: String?, + deletingTimetableId: String?, + deleteErrorMessage: String?, + onClose: () -> Unit, + onAddTimetableClick: () -> Unit, + onSelectTimetable: (String) -> Unit, + onEditTimetableClick: (String) -> Unit, + onDeleteTimetableClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Surface(modifier = modifier.fillMaxWidth().heightIn(min = 250.dp, max = 292.dp), shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), color = PureWhite, shadowElevation = 8.dp) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 10.dp)) { + IconButton(onClick = onClose, modifier = Modifier.size(30.dp).semantics { contentDescription = "시간표 변경 패널 닫기" }) { + Icon(imageVector = Icons.Rounded.KeyboardArrowDown, contentDescription = null, tint = PanelHintColor) + } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("나의 시간표", style = MaterialTheme.typography.bodyMedium, color = Ink100, fontSize = 16.sp, fontWeight = FontWeight.Bold) + IconButton(onClick = onAddTimetableClick, modifier = Modifier.size(32.dp).semantics { contentDescription = "시간표 추가" }) { + Icon(imageVector = Icons.Rounded.Add, contentDescription = null, tint = Ink60, modifier = Modifier.size(18.dp)) + } + } + Text( + text = "스누티티 연동하기", + style = MaterialTheme.typography.bodyMedium, + color = PanelHintColor, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { + contentDescription = "스누티티 연동하기, 비활성" + stateDescription = "비활성" + } + ) + Spacer(modifier = Modifier.height(14.dp)) + if (deleteErrorMessage != null) { + FieldErrorText(deleteErrorMessage) + Spacer(modifier = Modifier.height(8.dp)) + } + if (isLoading) { + LoadingTimetablePanelState(modifier = Modifier.fillMaxWidth()) + } else if (errorMessage != null) { + TimetablePanelErrorState(message = errorMessage, modifier = Modifier.fillMaxWidth()) + } else if (timetables.isEmpty()) { + EmptyTimetablePanelState(modifier = Modifier.fillMaxWidth()) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + items(items = timetables, key = { it.id }) { timetable -> + TimetableSelectionRow( + timetable = timetable, + isSelected = timetable.id == selectedTimetableId, + isDeleting = timetable.id == deletingTimetableId, + onSelect = { onSelectTimetable(timetable.id) }, + onEdit = { onEditTimetableClick(timetable.id) }, + onDelete = { onDeleteTimetableClick(timetable.id) } + ) + } + } + } + } + } +} + +@Composable +private fun CreateTimetablePanel(title: String = "시간표 만들기", submitText: String = "만들기", name: String, errorMessage: String?, isCreating: Boolean, onNameChange: (String) -> Unit, onClose: () -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier) { + Surface(modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), color = PureWhite, shadowElevation = 8.dp) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 16.dp)) { + PanelHeader(title = title, onClose = onClose) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("시간표 이름") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(14.dp)) + Button(onClick = onSubmit, enabled = name.trim().isNotEmpty() && !isCreating, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = ChangeButtonColor, contentColor = PureWhite)) { + Text(if (isCreating) "저장 중" else submitText) + } + } + } +} + +@Composable +private fun AddCoursePanel( + courseTitle: String, + instructor: String, + creditText: String, + timeSlots: List, + validation: AddCourseValidationResult, + submitError: String?, + submitMessage: String?, + isSubmitting: Boolean, + onTitleChange: (String) -> Unit, + onInstructorChange: (String) -> Unit, + onCreditChange: (String) -> Unit, + onAddTimeSlot: () -> Unit, + onRemoveTimeSlot: (Long) -> Unit, + onChangeDay: (Long, TimetableDayOfWeek) -> Unit, + onChangeStart: (Long, Int) -> Unit, + onChangeEnd: (Long, Int) -> Unit, + onClose: () -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier +) { + Surface(modifier = modifier.fillMaxWidth().heightIn(max = 560.dp), shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), color = PureWhite, shadowElevation = 8.dp) { + LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp), contentPadding = PaddingValues(top = 14.dp, bottom = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { PanelHeader(title = "수업 추가", onClose = onClose) } + item { + OutlinedTextField(value = courseTitle, onValueChange = onTitleChange, label = { Text("과목명") }, isError = validation.titleError != null, supportingText = validation.titleError?.let { message -> { Text(message) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedTextField(value = instructor, onValueChange = onInstructorChange, label = { Text("교수명") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedTextField(value = creditText, onValueChange = onCreditChange, label = { Text("학점") }, isError = validation.creditError != null, supportingText = validation.creditError?.let { message -> { Text(message) } }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.width(104.dp)) + } + } + item { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("수업 시간", style = MaterialTheme.typography.bodyMedium, color = Ink100, fontSize = 14.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onAddTimeSlot) { Text("+ 시간 추가") } + } + } + items(items = timeSlots, key = { it.localId }) { slot -> + TimeSlotEditorRow( + slot = slot, + canRemove = timeSlots.size > 1, + errors = validation.timeSlotErrors.filter { it.localId == slot.localId }.map { it.message }, + onRemove = { onRemoveTimeSlot(slot.localId) }, + onChangeDay = { day -> onChangeDay(slot.localId, day) }, + onChangeStart = { minute -> onChangeStart(slot.localId, minute) }, + onChangeEnd = { minute -> onChangeEnd(slot.localId, minute) } + ) + } + val globalErrors = validation.timeSlotErrors.filter { it.localId == -1L } + if (globalErrors.isNotEmpty()) { + item { FieldErrorText(globalErrors.joinToString("\n") { it.message }) } + } + if (submitError != null) { + item { FieldErrorText(submitError) } + } + if (submitMessage != null) { + item { Text(submitMessage, color = ChangeButtonColor, fontSize = 12.sp, fontWeight = FontWeight.Bold) } + } + item { + Button(onClick = onSubmit, enabled = validation.canSubmit, modifier = Modifier.fillMaxWidth().height(42.dp), colors = ButtonDefaults.buttonColors(containerColor = AddButtonColor, contentColor = PureWhite)) { + Text(if (isSubmitting) "저장 중" else "저장") + } + } + } + } +} + +@Composable +private fun TimeSlotEditorRow( + slot: EditableTimeSlot, + canRemove: Boolean, + errors: List, + onRemove: () -> Unit, + onChangeDay: (TimetableDayOfWeek) -> Unit, + onChangeStart: (Int) -> Unit, + onChangeEnd: (Int) -> Unit +) { + Surface(shape = RoundedCornerShape(8.dp), color = Color(0xFFF8F8F8)) { + Column(modifier = Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + TimetableDayOfWeek.values().take(5).forEach { day -> + DayChip(day = day, selected = slot.dayOfWeek == day, onClick = { onChangeDay(day) }) + Spacer(modifier = Modifier.width(4.dp)) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onRemove, enabled = canRemove, modifier = Modifier.size(28.dp)) { + Icon(imageVector = Icons.Rounded.Close, contentDescription = "시간 삭제", tint = if (canRemove) Ink60 else PanelHintColor) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + TimeStepper(label = "시작", minute = slot.startAt, onChange = onChangeStart, modifier = Modifier.weight(1f)) + TimeStepper(label = "종료", minute = slot.endAt, onChange = onChangeEnd, modifier = Modifier.weight(1f)) + } + errors.forEach { FieldErrorText(it) } + } + } +} + +@Composable +private fun DayChip(day: TimetableDayOfWeek, selected: Boolean, onClick: () -> Unit) { + Button(onClick = onClick, shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = if (selected) ChangeButtonColor else Color(0xFFE9E9E9), contentColor = if (selected) PureWhite else Ink60), contentPadding = PaddingValues(horizontal = 8.dp), modifier = Modifier.height(28.dp)) { + Text(day.label, fontSize = 11.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun TimeStepper(label: String, minute: Int, onChange: (Int) -> Unit, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text(label, color = Ink60, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { onChange(minute - 5) }, modifier = Modifier.size(28.dp)) { + Icon(imageVector = Icons.Rounded.Remove, contentDescription = "$label 5분 감소") + } + Text(formatMinute(minute), modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 13.sp, fontWeight = FontWeight.Bold) + IconButton(onClick = { onChange(minute + 5) }, modifier = Modifier.size(28.dp)) { + Icon(imageVector = Icons.Rounded.Add, contentDescription = "$label 5분 증가") + } + } + } +} + +@Composable +private fun PanelHeader(title: String, onClose: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.bodyMedium, color = Ink100, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) { + Icon(imageVector = Icons.Rounded.KeyboardArrowDown, contentDescription = "닫기", tint = PanelHintColor) + } + } +} + +@Composable +private fun TransparentDismissLayer(onDismiss: () -> Unit) { + Box(modifier = Modifier.fillMaxSize().clickable(onClick = onDismiss)) +} + +@Composable +private fun TimetableSelectionRow(timetable: TimetableUiModel, isSelected: Boolean, isDeleting: Boolean, onSelect: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth().clickable(onClick = onSelect).semantics { contentDescription = "${timetable.name} 선택" }, verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(timetable.name, style = MaterialTheme.typography.bodyMedium, color = Ink100, fontSize = 14.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${timetable.totalCredits}학점", style = MaterialTheme.typography.bodyMedium, color = Ink60, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + SmallPanelButton(text = "수정", color = EditButtonColor, onClick = onEdit) + Spacer(modifier = Modifier.width(8.dp)) + SmallPanelButton(text = if (isDeleting) "삭제 중" else "삭제", color = DeleteButtonColor, enabled = !isDeleting, onClick = onDelete) + } +} + +@Composable +private fun EmptyTimetablePanelState(modifier: Modifier = Modifier) { + Box(modifier = modifier.height(88.dp), contentAlignment = Alignment.CenterStart) { + Text("등록된 시간표가 없습니다.", style = MaterialTheme.typography.bodyMedium, color = Ink60, fontSize = 13.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun SmallPanelButton(text: String, color: Color, enabled: Boolean = true, onClick: () -> Unit) { + Button(onClick = onClick, enabled = enabled, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = color, contentColor = PureWhite), contentPadding = PaddingValues(horizontal = 16.dp), modifier = Modifier.height(28.dp)) { + Text(text, style = MaterialTheme.typography.bodyMedium, fontSize = 11.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun TimetablePillButton(text: String, color: Color, enabled: Boolean, onClick: () -> Unit, contentDescription: String, icon: (@Composable () -> Unit)? = null) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors(containerColor = color, contentColor = PureWhite, disabledContainerColor = color, disabledContentColor = Color(0xFFFAFAFA).copy(alpha = 0.72f)), + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.height(32.dp).semantics { this.contentDescription = contentDescription } + ) { + if (icon != null) { + icon() + Spacer(modifier = Modifier.width(2.dp)) + } + Text(text, style = MaterialTheme.typography.bodyMedium, fontSize = 11.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun FieldErrorText(message: String) { + Text(message, color = DeleteButtonColor, fontSize = 11.sp, fontWeight = FontWeight.Bold) +} + +private fun blockModifier(position: PositionedTimetableBlock, dayWidth: Dp, gridHeight: Dp): Modifier { + val laneWidth = dayWidth / position.laneCount + val x = TimeLabelWidth + (dayWidth * position.weekday) + (laneWidth * position.laneIndex) + val y = gridHeight * position.topFraction + val height = gridHeight * position.heightFraction + return Modifier.offset(x = x + 1.dp, y = y).width(laneWidth - 2.dp).height(height) +} + +private data class CourseBlockUiModel( + val id: String, + val title: String, + val subtitle: String?, + val color: Color, + val weekday: Int, + val startMinute: Int, + val endMinute: Int +) + +private fun List.toCourseBlocks(): List { + return flatMap { course -> + course.timeSlots.mapIndexed { index, slot -> + CourseBlockUiModel("${course.id}-$index", course.title, course.subtitle, course.color, slot.weekday, slot.startMinute, slot.endMinute) + } + } +} + +private fun List.toExistingEditableSlots(): List { + var localId = 1L + return flatMap { course -> + course.timeSlots.mapNotNull { slot -> + val day = TimetableDayOfWeek.values().firstOrNull { it.weekday == slot.weekday } ?: return@mapNotNull null + EditableTimeSlot(localId++, day, slot.startMinute, slot.endMinute) + } + } +} + +private fun defaultEditableTimeSlot(localId: Long): EditableTimeSlot { + return EditableTimeSlot(localId, TimetableDayOfWeek.MON, 8 * 60, 11 * 60) +} + +private fun Int.floorMod(divisor: Int): Int = ((this % divisor) + divisor) % divisor + +private fun hourLabel(minute: Int): String { + val hour = minute / 60 + return when { + hour == 12 -> "12" + hour > 12 -> (hour - 12).toString() + else -> hour.toString() + } +} + +private fun formatMinute(minute: Int): String { + val safeMinute = minute.coerceIn(0, 24 * 60) + val hour = safeMinute / 60 + val minuteOfHour = safeMinute % 60 + return "%02d:%02d".format(hour, minuteOfHour) +} +@Composable +private fun LoadingTimetablePanelState(modifier: Modifier = Modifier) { + Box(modifier = modifier.height(88.dp), contentAlignment = Alignment.CenterStart) { + Text("시간표 목록을 불러오는 중입니다.", style = MaterialTheme.typography.bodyMedium, color = Ink60, fontSize = 13.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun TimetablePanelErrorState(message: String, modifier: Modifier = Modifier) { + Box(modifier = modifier.height(88.dp), contentAlignment = Alignment.CenterStart) { + FieldErrorText(message) + } +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableUiModels.kt b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableUiModels.kt new file mode 100644 index 0000000..1155e1f --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableUiModels.kt @@ -0,0 +1,42 @@ +package com.example.hangsha_android.ui.view.timetable + +import androidx.compose.ui.graphics.Color + +internal data class TimetableUiModel( + val id: String, + val name: String, + val year: Int, + val semester: String, + val courses: List +) { + val totalCredits: Int + get() = courses.sumOf { course -> course.credit ?: 0 } +} + +internal data class CourseUiModel( + val id: String, + val title: String, + val instructor: String?, + val credit: Int?, + val color: Color, + val timeSlots: List +) { + val subtitle: String? + get() = instructor?.takeIf { it.isNotBlank() } +} + +internal data class CourseTimeSlot( + val weekday: Int, + val startMinute: Int, + val endMinute: Int +) + +internal data class TimetableEventItem( + val id: String, + val title: String, + val weekday: Int, + val startMinute: Int, + val endMinute: Int, + val categoryColor: Color +) + diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableViewModel.kt new file mode 100644 index 0000000..0d6d6c7 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/timetable/TimetableViewModel.kt @@ -0,0 +1,335 @@ +package com.example.hangsha_android.ui.view.timetable + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.hangsha_android.data.network.model.TimetableResponse +import com.example.hangsha_android.data.repository.TimetableRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.HttpException + +@HiltViewModel +class TimetableViewModel @Inject constructor( + private val timetableRepository: TimetableRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(TimetableApiUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var isLoadInFlight = false + + fun loadTimetables( + year: Int, + semester: String + ) { + if (isLoadInFlight) { + return + } + + isLoadInFlight = true + viewModelScope.launch { + _uiState.update { + it.copy( + isLoadingTimetables = true, + loadTimetablesError = null + ) + } + + runCatching { + val response = timetableRepository.getTimetables( + year = year, + semester = semester + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body()?.items ?: throw IllegalStateException("Timetable list response was empty.") + }.fold( + onSuccess = { timetables -> + _uiState.update { + it.copy( + isLoadingTimetables = false, + loadTimetablesError = null, + timetables = timetables + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoadingTimetables = false, + loadTimetablesError = mapLoadErrorMessage(error) + ) + } + } + ) + isLoadInFlight = false + } + } + + fun createTimetable( + name: String, + year: Int, + semester: String + ) { + val normalizedName = name.trim() + if (normalizedName.isBlank() || _uiState.value.isCreatingTimetable) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isCreatingTimetable = true, + createTimetableError = null, + createdTimetable = null + ) + } + + runCatching { + val response = timetableRepository.createTimetable( + name = normalizedName, + year = year, + semester = semester + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body() ?: throw IllegalStateException("Timetable response was empty.") + }.fold( + onSuccess = { body -> + _uiState.update { + it.copy( + isCreatingTimetable = false, + createTimetableError = null, + createdTimetable = body, + timetables = (it.timetables.filterNot { timetable -> timetable.id == body.id } + body) + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isCreatingTimetable = false, + createTimetableError = mapCreateErrorMessage(error), + createdTimetable = null + ) + } + } + ) + } + } + + fun updateTimetableName( + timetableId: Long, + name: String + ) { + val normalizedName = name.trim() + if (normalizedName.isBlank() || _uiState.value.updatingTimetableId != null) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + updatingTimetableId = timetableId, + updateTimetableError = null, + updatedTimetable = null + ) + } + + runCatching { + val response = timetableRepository.updateTimetableName( + timetableId = timetableId, + name = normalizedName + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body() ?: throw IllegalStateException("Timetable response was empty.") + }.fold( + onSuccess = { body -> + _uiState.update { + it.copy( + updatingTimetableId = null, + updateTimetableError = null, + updatedTimetable = body, + timetables = it.timetables.map { timetable -> + if (timetable.id == body.id) body else timetable + } + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + updatingTimetableId = null, + updateTimetableError = mapUpdateErrorMessage(error), + updatedTimetable = null + ) + } + } + ) + } + } + fun deleteTimetable(timetableId: Long) { + if (_uiState.value.deletingTimetableId != null) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + deletingTimetableId = timetableId, + deleteTimetableError = null + ) + } + + runCatching { + val response = timetableRepository.deleteTimetable(timetableId) + if (!response.isSuccessful) { + throw HttpException(response) + } + }.fold( + onSuccess = { + _uiState.update { + it.copy( + deletingTimetableId = null, + deleteTimetableError = null, + deletedTimetableId = timetableId, + timetables = it.timetables.filterNot { timetable -> timetable.id == timetableId } + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + deletingTimetableId = null, + deleteTimetableError = mapDeleteErrorMessage(error) + ) + } + } + ) + } + } + + fun onUpdatedTimetableConsumed() { + _uiState.update { + it.copy(updatedTimetable = null) + } + } + + fun clearUpdateError() { + _uiState.update { + it.copy(updateTimetableError = null) + } + } + fun onDeletedTimetableConsumed() { + _uiState.update { + it.copy(deletedTimetableId = null) + } + } + + fun clearDeleteError() { + _uiState.update { + it.copy(deleteTimetableError = null) + } + } + fun onCreatedTimetableConsumed() { + _uiState.update { + it.copy(createdTimetable = null) + } + } + + fun clearCreateError() { + _uiState.update { + it.copy(createTimetableError = null) + } + } + + private fun mapLoadErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 400 -> "조회할 학기 정보를 확인해 주세요." + 401 -> "로그인이 필요합니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "시간표 목록을 불러오지 못했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + is IllegalStateException -> error.message ?: "시간표 목록을 불러오지 못했습니다." + else -> error.message ?: "시간표 목록을 불러오지 못했습니다." + } + } + + private fun mapUpdateErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 400 -> "시간표 이름을 확인해 주세요." + 401 -> "로그인이 필요합니다." + 403 -> "시간표 수정 권한이 없습니다." + 404 -> "이미 삭제되었거나 찾을 수 없는 시간표입니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "시간표 수정에 실패했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + is IllegalStateException -> error.message ?: "시간표 수정에 실패했습니다." + else -> error.message ?: "시간표 수정에 실패했습니다." + } + } + private fun mapDeleteErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 401 -> "로그인이 필요합니다." + 403 -> "시간표 삭제 권한이 없습니다." + 404 -> "이미 삭제되었거나 찾을 수 없는 시간표입니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "시간표 삭제에 실패했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + else -> error.message ?: "시간표 삭제에 실패했습니다." + } + } + private fun mapCreateErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 400 -> "시간표 정보를 확인해 주세요." + 401 -> "로그인이 필요합니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "시간표 생성에 실패했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + is IllegalStateException -> error.message ?: "시간표 생성에 실패했습니다." + else -> error.message ?: "시간표 생성에 실패했습니다." + } + } +} + +data class TimetableApiUiState( + val isLoadingTimetables: Boolean = false, + val loadTimetablesError: String? = null, + val timetables: List = emptyList(), + val isCreatingTimetable: Boolean = false, + val createTimetableError: String? = null, + val createdTimetable: TimetableResponse? = null, + val updatingTimetableId: Long? = null, + val updatedTimetable: TimetableResponse? = null, + val updateTimetableError: String? = null, + val deletingTimetableId: Long? = null, + val deletedTimetableId: Long? = null, + val deleteTimetableError: String? = null +) \ No newline at end of file diff --git a/app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidatorTest.kt b/app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidatorTest.kt new file mode 100644 index 0000000..6e5a808 --- /dev/null +++ b/app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableAddCourseValidatorTest.kt @@ -0,0 +1,124 @@ +package com.example.hangsha_android.ui.view.timetable + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimetableAddCourseValidatorTest { + @Test + fun validate_rejectsBlankTitle() { + val result = validate( + title = " ", + timeSlots = listOf(slot(1, TimetableDayOfWeek.MON, 600, 660)) + ) + + assertFalse(result.canSubmit) + } + + @Test + fun validate_rejectsEmptyTimeSlots() { + val result = validate( + title = "자료구조", + timeSlots = emptyList() + ) + + assertFalse(result.canSubmit) + } + + @Test + fun validate_rejectsEndAtBeforeOrEqualStartAt() { + val result = validate( + title = "자료구조", + timeSlots = listOf(slot(1, TimetableDayOfWeek.MON, 660, 660)) + ) + + assertFalse(result.canSubmit) + } + + @Test + fun validate_rejectsExistingOverlapOnSameDay() { + val result = validate( + title = "자료구조", + timeSlots = listOf(slot(1, TimetableDayOfWeek.MON, 600, 660)), + existingTimeSlots = listOf(slot(2, TimetableDayOfWeek.MON, 630, 700)) + ) + + assertFalse(result.canSubmit) + } + + @Test + fun validate_allowsTouchingTimeSlots() { + val result = validate( + title = "자료구조", + timeSlots = listOf(slot(1, TimetableDayOfWeek.MON, 600, 660)), + existingTimeSlots = listOf(slot(2, TimetableDayOfWeek.MON, 660, 720)) + ) + + assertTrue(result.canSubmit) + } + + @Test + fun validate_allowsSameTimeOnDifferentDay() { + val result = validate( + title = "자료구조", + timeSlots = listOf(slot(1, TimetableDayOfWeek.MON, 600, 660)), + existingTimeSlots = listOf(slot(2, TimetableDayOfWeek.TUE, 600, 660)) + ) + + assertTrue(result.canSubmit) + } + + @Test + fun validate_rejectsInternalOverlap() { + val result = validate( + title = "자료구조", + timeSlots = listOf( + slot(1, TimetableDayOfWeek.MON, 600, 660), + slot(2, TimetableDayOfWeek.MON, 630, 700) + ) + ) + + assertFalse(result.canSubmit) + } + + @Test + fun validate_rejectsSubmittingState() { + val result = validate( + title = "자료구조", + timeSlots = listOf(slot(1, TimetableDayOfWeek.MON, 600, 660)), + isSubmitting = true + ) + + assertFalse(result.canSubmit) + } + + private fun validate( + title: String, + timeSlots: List, + existingTimeSlots: List = emptyList(), + isSubmitting: Boolean = false + ): AddCourseValidationResult { + return TimetableAddCourseValidator.validate( + timetableId = "timetable-1", + title = title, + creditText = "3", + timeSlots = timeSlots, + existingTimeSlots = existingTimeSlots, + isSubmitting = isSubmitting + ) + } + + private fun slot( + id: Long, + day: TimetableDayOfWeek, + start: Int, + end: Int + ): EditableTimeSlot { + return EditableTimeSlot( + localId = id, + dayOfWeek = day, + startAt = start, + endAt = end + ) + } +} diff --git a/app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculatorTest.kt b/app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculatorTest.kt new file mode 100644 index 0000000..566af98 --- /dev/null +++ b/app/src/test/java/com/example/hangsha_android/ui/view/timetable/TimetableLayoutCalculatorTest.kt @@ -0,0 +1,88 @@ +package com.example.hangsha_android.ui.view.timetable + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimetableLayoutCalculatorTest { + @Test + fun positionBlocks_clipsBlockToVisibleGrid() { + val result = TimetableLayoutCalculator.positionBlocks( + blocks = listOf( + TimetableBlock( + id = "early", + weekday = 0, + startMinute = 8 * 60 + 30, + endMinute = 9 * 60 + 30 + ) + ), + gridStartMinute = 9 * 60, + gridEndMinute = 18 * 60 + ) + + assertEquals(1, result.size) + assertEquals(9 * 60, result.first().clippedStartMinute) + assertEquals(9 * 60 + 30, result.first().clippedEndMinute) + assertEquals(0f, result.first().topFraction, 0.0001f) + assertEquals(30f / 540f, result.first().heightFraction, 0.0001f) + } + + @Test + fun positionBlocks_ignoresBlocksOutsideGridOrWeek() { + val result = TimetableLayoutCalculator.positionBlocks( + blocks = listOf( + TimetableBlock( + id = "before", + weekday = 0, + startMinute = 7 * 60, + endMinute = 8 * 60 + ), + TimetableBlock( + id = "after", + weekday = 1, + startMinute = 19 * 60, + endMinute = 20 * 60 + ), + TimetableBlock( + id = "weekend", + weekday = 5, + startMinute = 10 * 60, + endMinute = 11 * 60 + ) + ), + gridStartMinute = 9 * 60, + gridEndMinute = 18 * 60 + ) + + assertTrue(result.isEmpty()) + } + + @Test + fun positionBlocks_splitsOverlappingBlocksIntoLanes() { + val result = TimetableLayoutCalculator.positionBlocks( + blocks = listOf( + TimetableBlock( + id = "a", + weekday = 2, + startMinute = 10 * 60, + endMinute = 12 * 60 + ), + TimetableBlock( + id = "b", + weekday = 2, + startMinute = 11 * 60, + endMinute = 13 * 60 + ) + ), + gridStartMinute = 9 * 60, + gridEndMinute = 18 * 60, + splitOverlaps = true + ).sortedBy { it.id } + + assertEquals(2, result.size) + assertEquals(2, result[0].laneCount) + assertEquals(2, result[1].laneCount) + assertEquals(0, result[0].laneIndex) + assertEquals(1, result[1].laneIndex) + } +}