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 index 13cecdb..c2a4b8e 100644 --- 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 @@ -5,6 +5,13 @@ 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.CreateCustomTimetableEnrollRequest +import com.example.hangsha_android.data.network.model.TimetableCourseResponse +import com.example.hangsha_android.data.network.model.TimetableCourseTimeSlotResponse +import com.example.hangsha_android.data.network.model.TimetableEnrollListResponse +import com.example.hangsha_android.data.network.model.TimetableEnrollResponse +import com.example.hangsha_android.data.network.model.UpdateCustomTimetableEnrollRequest +import com.example.hangsha_android.data.network.model.TimetableEnrollPatchValue import com.example.hangsha_android.data.network.model.TimetableListResponse import com.example.hangsha_android.data.network.model.TimetableResponse import com.google.gson.Gson @@ -59,7 +66,8 @@ class GuestTimetableLocalDataSource @Inject constructor( id = nextId, name = name.trim(), year = year, - semester = semester + semester = semester, + courses = emptyList() ) replaceAll(currentTimetables + timetable) return timetable.toTimetableResponse() @@ -78,9 +86,123 @@ class GuestTimetableLocalDataSource @Inject constructor( }) return updatedTimetable.toTimetableResponse() } + suspend fun deleteTimetable(timetableId: Long) { replaceAll(timetables.first().filterNot { timetable -> timetable.id == timetableId }) } + + suspend fun getEnrolls(timetableId: Long): TimetableEnrollListResponse { + val target = timetables.first().firstOrNull { timetable -> timetable.id == timetableId } + ?: throw NoSuchElementException("Timetable was not found.") + return TimetableEnrollListResponse( + items = target.courses.orEmpty().map { enroll -> enroll.toTimetableEnrollResponse() } + ) + } + + suspend fun getEnroll( + timetableId: Long, + enrollId: Long + ): TimetableEnrollResponse { + val target = timetables.first().firstOrNull { timetable -> timetable.id == timetableId } + ?: throw NoSuchElementException("Timetable was not found.") + return target.courses.orEmpty() + .firstOrNull { enroll -> enroll.enrollId == enrollId } + ?.toTimetableEnrollResponse() + ?: throw NoSuchElementException("Enroll was not found.") + } + + suspend fun createCustomEnroll( + timetableId: Long, + request: CreateCustomTimetableEnrollRequest + ): TimetableEnrollResponse { + val currentTimetables = timetables.first() + val target = currentTimetables.firstOrNull { timetable -> timetable.id == timetableId } + ?: throw NoSuchElementException("Timetable was not found.") + val nextEnrollId = (currentTimetables.flatMap { timetable -> timetable.courses.orEmpty() } + .minOfOrNull { enroll -> enroll.enrollId } ?: 0L) - 1L + val storedEnroll = StoredGuestEnroll( + enrollId = nextEnrollId, + course = StoredGuestCourse( + id = nextEnrollId, + year = request.year, + semester = request.semester, + courseTitle = request.courseTitle, + source = "CUSTOM", + timeSlots = request.timeSlots.map { slot -> + StoredGuestCourseTimeSlot( + dayOfWeek = slot.dayOfWeek, + startAt = slot.startAt, + endAt = slot.endAt + ) + }, + courseNumber = request.courseNumber, + lectureNumber = request.lectureNumber, + credit = request.credit, + instructor = request.instructor + ) + ) + ensureNoTimeConflict( + candidate = storedEnroll, + existingEnrolls = target.courses.orEmpty() + ) + replaceAll(currentTimetables.map { timetable -> + if (timetable.id == timetableId) { + target.copy(courses = target.courses.orEmpty() + storedEnroll) + } else { + timetable + } + }) + return storedEnroll.toTimetableEnrollResponse() + } + + + suspend fun updateCustomEnroll( + timetableId: Long, + enrollId: Long, + request: UpdateCustomTimetableEnrollRequest + ): TimetableEnrollResponse { + request.toJsonObject() + val currentTimetables = timetables.first() + val targetTimetable = currentTimetables.firstOrNull { timetable -> timetable.id == timetableId } + ?: throw NoSuchElementException("Timetable was not found.") + val targetEnroll = targetTimetable.courses.orEmpty().firstOrNull { enroll -> enroll.enrollId == enrollId } + ?: throw NoSuchElementException("Enroll was not found.") + require(targetEnroll.course.source == "CUSTOM") { + "Only custom enrolls can be updated." + } + + val updatedEnroll = targetEnroll.copy( + course = targetEnroll.course.patchWith(request) + ) + ensureNoTimeConflict( + candidate = updatedEnroll, + existingEnrolls = targetTimetable.courses.orEmpty().filterNot { enroll -> enroll.enrollId == enrollId } + ) + replaceAll(currentTimetables.map { timetable -> + if (timetable.id == timetableId) { + timetable.copy(courses = targetTimetable.courses.orEmpty().map { enroll -> + if (enroll.enrollId == enrollId) updatedEnroll else enroll + }) + } else { + timetable + } + }) + return updatedEnroll.toTimetableEnrollResponse() + } + + suspend fun deleteEnroll( + timetableId: Long, + enrollId: Long + ) { + replaceAll(timetables.first().map { timetable -> + if (timetable.id == timetableId) { + timetable.copy(courses = timetable.courses.orEmpty().filterNot { enroll -> enroll.enrollId == enrollId }) + } else { + timetable + } + }) + } + private suspend fun replaceAll(items: List) { context.guestTimetablesDataStore.edit { preferences -> preferences[GUEST_TIMETABLES_JSON] = gson.toJson( @@ -99,6 +221,7 @@ class GuestTimetableLocalDataSource @Inject constructor( return runCatching { gson.fromJson>(json, storedTimetableListType) + .map { timetable -> timetable.copy(courses = timetable.courses.orEmpty()) } }.getOrDefault(emptyList()) } @@ -112,7 +235,32 @@ data class StoredGuestTimetable( val id: Long, val name: String, val year: Int, - val semester: String + val semester: String, + val courses: List? = emptyList() +) + +data class StoredGuestEnroll( + val enrollId: Long, + val course: StoredGuestCourse +) + +data class StoredGuestCourse( + val id: Long, + val year: Int, + val semester: String, + val courseTitle: String, + val source: String, + val timeSlots: List, + val courseNumber: String?, + val lectureNumber: String?, + val credit: Int?, + val instructor: String? +) + +data class StoredGuestCourseTimeSlot( + val dayOfWeek: String, + val startAt: Int, + val endAt: Int ) private fun StoredGuestTimetable.toTimetableResponse(): TimetableResponse { @@ -123,3 +271,105 @@ private fun StoredGuestTimetable.toTimetableResponse(): TimetableResponse { semester = semester ) } + +private fun StoredGuestEnroll.toTimetableEnrollResponse(): TimetableEnrollResponse { + return TimetableEnrollResponse( + enrollId = enrollId, + course = TimetableCourseResponse( + id = course.id, + year = course.year, + semester = course.semester, + courseTitle = course.courseTitle, + source = course.source, + timeSlots = course.timeSlots.map { slot -> + TimetableCourseTimeSlotResponse( + dayOfWeek = slot.dayOfWeek, + startAt = slot.startAt, + endAt = slot.endAt + ) + }, + courseNumber = course.courseNumber, + lectureNumber = course.lectureNumber, + credit = course.credit, + instructor = course.instructor + ) + ) +} + + +private fun ensureNoTimeConflict( + candidate: StoredGuestEnroll, + existingEnrolls: List +) { + val hasConflict = candidate.course.timeSlots.any { candidateSlot -> + existingEnrolls.any { existingEnroll -> + existingEnroll.course.timeSlots.any { existingSlot -> + candidateSlot.overlaps(existingSlot) + } + } + } + require(!hasConflict) { + "Course time conflicts with another course." + } +} + +private fun StoredGuestCourseTimeSlot.overlaps(other: StoredGuestCourseTimeSlot): Boolean { + return dayOfWeek == other.dayOfWeek && startAt < other.endAt && other.startAt < endAt +} + +private fun StoredGuestCourse.patchWith( + request: UpdateCustomTimetableEnrollRequest +): StoredGuestCourse { + return copy( + courseTitle = when (val value = request.courseTitle) { + is TimetableEnrollPatchValue.Set -> value.value.trim() + TimetableEnrollPatchValue.Clear -> error("Course title cannot be null.") + TimetableEnrollPatchValue.Unchanged -> courseTitle + }, + timeSlots = when (val value = request.timeSlots) { + is TimetableEnrollPatchValue.Set -> value.value.map { slot -> + StoredGuestCourseTimeSlot( + dayOfWeek = slot.dayOfWeek, + startAt = slot.startAt.toStoredMinute(), + endAt = slot.endAt.toStoredMinute() + ) + } + TimetableEnrollPatchValue.Clear -> error("Time slots cannot be null.") + TimetableEnrollPatchValue.Unchanged -> timeSlots + }, + courseNumber = patchOptionalString(courseNumber, request.courseNumber), + lectureNumber = patchOptionalString(lectureNumber, request.lectureNumber), + credit = patchOptionalInt(credit, request.credit), + instructor = patchOptionalString(instructor, request.instructor) + ) +} + +private fun patchOptionalString( + current: String?, + patchValue: TimetableEnrollPatchValue +): String? { + return when (patchValue) { + is TimetableEnrollPatchValue.Set -> patchValue.value + TimetableEnrollPatchValue.Clear -> null + TimetableEnrollPatchValue.Unchanged -> current + } +} + +private fun patchOptionalInt( + current: Int?, + patchValue: TimetableEnrollPatchValue +): Int? { + return when (patchValue) { + is TimetableEnrollPatchValue.Set -> patchValue.value + TimetableEnrollPatchValue.Clear -> null + TimetableEnrollPatchValue.Unchanged -> current + } +} + +private fun String.toStoredMinute(): Int { + val parts = split(":") + if (parts.size == 2) { + return parts[0].toInt() * 60 + parts[1].toInt() + } + return toInt() +} 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 index 5453c2d..7d9d5f8 100644 --- 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 @@ -1,9 +1,13 @@ 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.CreateCustomTimetableEnrollRequest +import com.example.hangsha_android.data.network.model.TimetableEnrollListResponse +import com.example.hangsha_android.data.network.model.TimetableEnrollResponse 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 okhttp3.RequestBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -35,4 +39,35 @@ interface TimetableApi { suspend fun createTimetable( @Body request: CreateTimetableRequest ): Response + + @GET("api/v1/timetables/{timetableId}/enrolls") + suspend fun getEnrolls( + @Path("timetableId") timetableId: Long + ): Response + + @GET("api/v1/timetables/{timetableId}/enrolls/{enrollId}") + suspend fun getEnroll( + @Path("timetableId") timetableId: Long, + @Path("enrollId") enrollId: Long + ): Response + + @POST("api/v1/timetables/{timetableId}/enrolls/custom") + suspend fun createCustomEnroll( + @Path("timetableId") timetableId: Long, + @Body request: CreateCustomTimetableEnrollRequest + ): Response + + + @PATCH("api/v1/timetables/{timetableId}/enrolls/{enrollId}") + suspend fun updateCustomEnroll( + @Path("timetableId") timetableId: Long, + @Path("enrollId") enrollId: Long, + @Body request: RequestBody + ): Response + + @DELETE("api/v1/timetables/{timetableId}/enrolls/{enrollId}") + suspend fun deleteEnroll( + @Path("timetableId") timetableId: Long, + @Path("enrollId") enrollId: Long + ): Response } \ No newline at end of file diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableEnrollResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableEnrollResponse.kt new file mode 100644 index 0000000..c114062 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/TimetableEnrollResponse.kt @@ -0,0 +1,191 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName + +data class TimetableEnrollListResponse( + @SerializedName("items") + val items: List +) + +data class TimetableEnrollResponse( + @SerializedName("enrollId") + val enrollId: Long, + @SerializedName("course") + val course: TimetableCourseResponse +) + +data class TimetableCourseResponse( + @SerializedName("id") + val id: Long, + @SerializedName("year") + val year: Int, + @SerializedName("semester") + val semester: String, + @SerializedName("courseTitle") + val courseTitle: String, + @SerializedName("source") + val source: String, + @SerializedName("timeSlots") + val timeSlots: List, + @SerializedName("courseNumber") + val courseNumber: String?, + @SerializedName("lectureNumber") + val lectureNumber: String?, + @SerializedName("credit") + val credit: Int?, + @SerializedName("instructor") + val instructor: String? +) + +data class TimetableCourseTimeSlotResponse( + @SerializedName("dayOfWeek") + val dayOfWeek: String, + @SerializedName("startAt") + val startAt: Int, + @SerializedName("endAt") + val endAt: Int +) + +data class CreateCustomTimetableEnrollRequest( + @SerializedName("year") + val year: Int, + @SerializedName("semester") + val semester: String, + @SerializedName("courseTitle") + val courseTitle: String, + @SerializedName("timeSlots") + val timeSlots: List, + @SerializedName("courseNumber") + val courseNumber: String?, + @SerializedName("lectureNumber") + val lectureNumber: String?, + @SerializedName("credit") + val credit: Int?, + @SerializedName("instructor") + val instructor: String? +) + +data class CreateCustomTimetableEnrollTimeSlotRequest( + @SerializedName("dayOfWeek") + val dayOfWeek: String, + @SerializedName("startAt") + val startAt: Int, + @SerializedName("endAt") + val endAt: Int +) + +class UpdateCustomTimetableEnrollRequest private constructor( + val courseTitle: TimetableEnrollPatchValue, + val timeSlots: TimetableEnrollPatchValue>, + val courseNumber: TimetableEnrollPatchValue, + val lectureNumber: TimetableEnrollPatchValue, + val credit: TimetableEnrollPatchValue, + val instructor: TimetableEnrollPatchValue +) { + fun toJsonObject(): JsonObject { + require( + courseTitle !is TimetableEnrollPatchValue.Unchanged || + timeSlots !is TimetableEnrollPatchValue.Unchanged || + courseNumber !is TimetableEnrollPatchValue.Unchanged || + lectureNumber !is TimetableEnrollPatchValue.Unchanged || + credit !is TimetableEnrollPatchValue.Unchanged || + instructor !is TimetableEnrollPatchValue.Unchanged + ) { + "At least one enroll field must be included." + } + + return JsonObject().apply { + when (courseTitle) { + is TimetableEnrollPatchValue.Set -> { + val normalizedTitle = courseTitle.value.trim() + require(normalizedTitle.isNotBlank()) { "Course title cannot be blank." } + addProperty("courseTitle", normalizedTitle) + } + TimetableEnrollPatchValue.Clear -> error("Course title cannot be null.") + TimetableEnrollPatchValue.Unchanged -> Unit + } + + when (timeSlots) { + is TimetableEnrollPatchValue.Set -> { + require(timeSlots.value.isNotEmpty()) { "Time slots cannot be empty." } + add("timeSlots", JsonArray().apply { + timeSlots.value.forEach { slot -> + add(JsonObject().apply { + addProperty("dayOfWeek", slot.dayOfWeek) + addProperty("startAt", slot.startAt) + addProperty("endAt", slot.endAt) + }) + } + }) + } + TimetableEnrollPatchValue.Clear -> error("Time slots cannot be null.") + TimetableEnrollPatchValue.Unchanged -> Unit + } + + addOptionalString("courseNumber", courseNumber) + addOptionalString("lectureNumber", lectureNumber) + addOptionalInt("credit", credit) + addOptionalString("instructor", instructor) + } + } + + companion object { + fun partial( + courseTitle: TimetableEnrollPatchValue = TimetableEnrollPatchValue.Unchanged, + timeSlots: TimetableEnrollPatchValue> = TimetableEnrollPatchValue.Unchanged, + courseNumber: TimetableEnrollPatchValue = TimetableEnrollPatchValue.Unchanged, + lectureNumber: TimetableEnrollPatchValue = TimetableEnrollPatchValue.Unchanged, + credit: TimetableEnrollPatchValue = TimetableEnrollPatchValue.Unchanged, + instructor: TimetableEnrollPatchValue = TimetableEnrollPatchValue.Unchanged + ): UpdateCustomTimetableEnrollRequest { + return UpdateCustomTimetableEnrollRequest( + courseTitle = courseTitle, + timeSlots = timeSlots, + courseNumber = courseNumber, + lectureNumber = lectureNumber, + credit = credit, + instructor = instructor + ) + } + } +} + +data class UpdateCustomTimetableEnrollTimeSlotRequest( + val dayOfWeek: String, + val startAt: String, + val endAt: String +) + +sealed interface TimetableEnrollPatchValue { + data class Set(val value: T) : TimetableEnrollPatchValue + + data object Clear : TimetableEnrollPatchValue + + data object Unchanged : TimetableEnrollPatchValue +} + +private fun JsonObject.addOptionalString( + name: String, + value: TimetableEnrollPatchValue +) { + when (value) { + is TimetableEnrollPatchValue.Set -> addProperty(name, value.value) + TimetableEnrollPatchValue.Clear -> add(name, JsonNull.INSTANCE) + TimetableEnrollPatchValue.Unchanged -> Unit + } +} + +private fun JsonObject.addOptionalInt( + name: String, + value: TimetableEnrollPatchValue +) { + when (value) { + is TimetableEnrollPatchValue.Set -> addProperty(name, value.value) + TimetableEnrollPatchValue.Clear -> add(name, JsonNull.INSTANCE) + TimetableEnrollPatchValue.Unchanged -> Unit + } +} + 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 index a408429..438023c 100644 --- 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 @@ -3,12 +3,19 @@ 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.CreateCustomTimetableEnrollRequest +import com.example.hangsha_android.data.network.model.CreateCustomTimetableEnrollTimeSlotRequest import com.example.hangsha_android.data.network.model.CreateTimetableRequest +import com.example.hangsha_android.data.network.model.TimetableEnrollListResponse +import com.example.hangsha_android.data.network.model.TimetableEnrollResponse 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 com.example.hangsha_android.data.network.model.UpdateCustomTimetableEnrollRequest import javax.inject.Inject import javax.inject.Singleton +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.MediaType.Companion.toMediaTypeOrNull import retrofit2.Response @Singleton @@ -79,6 +86,7 @@ class TimetableRepository @Inject constructor( request = UpdateTimetableRequest(name = normalizedName) ) } + suspend fun deleteTimetable(timetableId: Long): Response { if (!isLoggedIn()) { localDataSource.deleteTimetable(timetableId) @@ -87,7 +95,118 @@ class TimetableRepository @Inject constructor( return timetableApi.deleteTimetable(timetableId = timetableId) } + + suspend fun getEnrolls(timetableId: Long): Response { + if (!isLoggedIn()) { + return Response.success(localDataSource.getEnrolls(timetableId = timetableId)) + } + + return timetableApi.getEnrolls(timetableId = timetableId) + } + + suspend fun getEnroll( + timetableId: Long, + enrollId: Long + ): Response { + if (!isLoggedIn()) { + return Response.success( + localDataSource.getEnroll( + timetableId = timetableId, + enrollId = enrollId + ) + ) + } + + return timetableApi.getEnroll( + timetableId = timetableId, + enrollId = enrollId + ) + } + + suspend fun createCustomEnroll( + timetableId: Long, + year: Int, + semester: String, + courseTitle: String, + timeSlots: List, + courseNumber: String?, + lectureNumber: String?, + credit: Int?, + instructor: String? + ): Response { + val request = CreateCustomTimetableEnrollRequest( + year = year, + semester = semester, + courseTitle = courseTitle.trim(), + timeSlots = timeSlots, + courseNumber = courseNumber?.trim()?.takeIf { it.isNotEmpty() }, + lectureNumber = lectureNumber?.trim()?.takeIf { it.isNotEmpty() }, + credit = credit, + instructor = instructor?.trim()?.takeIf { it.isNotEmpty() } + ) + + if (!isLoggedIn()) { + return Response.success( + localDataSource.createCustomEnroll( + timetableId = timetableId, + request = request + ) + ) + } + + return timetableApi.createCustomEnroll( + timetableId = timetableId, + request = request + ) + } + + + suspend fun updateCustomEnroll( + timetableId: Long, + enrollId: Long, + request: UpdateCustomTimetableEnrollRequest + ): Response { + if (!isLoggedIn()) { + return Response.success( + localDataSource.updateCustomEnroll( + timetableId = timetableId, + enrollId = enrollId, + request = request + ) + ) + } + + val requestBody = request.toJsonObject() + .toString() + .toRequestBody(JSON_MEDIA_TYPE.toMediaTypeOrNull()) + return timetableApi.updateCustomEnroll( + timetableId = timetableId, + enrollId = enrollId, + request = requestBody + ) + } + + suspend fun deleteEnroll( + timetableId: Long, + enrollId: Long + ): Response { + if (!isLoggedIn()) { + localDataSource.deleteEnroll( + timetableId = timetableId, + enrollId = enrollId + ) + return Response.success(Unit) + } + + return timetableApi.deleteEnroll( + timetableId = timetableId, + enrollId = enrollId + ) + } + private fun isLoggedIn(): Boolean { return authTokenStorage.hasAccessToken() } -} \ No newline at end of file +} + +private const val JSON_MEDIA_TYPE = "application/json; charset=utf-8" 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 index d56a968..445fe95 100644 --- 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 @@ -1,5 +1,6 @@ package com.example.hangsha_android.ui.view.timetable +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -19,6 +20,7 @@ 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.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -30,6 +32,8 @@ 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -52,6 +56,7 @@ 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.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -63,15 +68,16 @@ 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.data.network.model.CreateCustomTimetableEnrollTimeSlotRequest +import com.example.hangsha_android.data.network.model.TimetableEnrollResponse import com.example.hangsha_android.ui.theme.Ink60 import com.example.hangsha_android.ui.theme.Ink100 import com.example.hangsha_android.ui.theme.PureWhite +import java.time.LocalDate 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) @@ -83,6 +89,15 @@ private val AddButtonColor = Color(0xFFF08AA0) private val EditButtonColor = Color(0xFF72D3EC) private val DeleteButtonColor = Color(0xFFF08AA0) private val PanelHintColor = Color(0xFFB4B4B4) +private val YearOptions = buildYearOptions() +private val SemesterOptions = listOf( + TimetableSemesterOption("SPRING", "1\uD559\uAE30"), + TimetableSemesterOption("SUMMER", "\uC5EC\uB984\uD559\uAE30"), + TimetableSemesterOption("FALL", "2\uD559\uAE30"), + TimetableSemesterOption("WINTER", "\uACA8\uC6B8\uD559\uAE30") +) +private val DefaultYear = LocalDate.now().year +private val DefaultSemester = semesterForMonth(LocalDate.now().monthValue).apiValue private val WeekdayLabels = listOf("월", "화", "수", "목", "금") private val CourseColors = listOf( Color(0xFF55B9DC), @@ -92,6 +107,25 @@ private val CourseColors = listOf( Color(0xFF54C987), Color(0xFF8F56EC) ) +private data class TimetableSemesterOption( + val apiValue: String, + val label: String +) + +private fun buildYearOptions(): List { + val currentYear = LocalDate.now().year + return (currentYear - 2..currentYear + 2).toList() +} + +private fun semesterForMonth(month: Int): TimetableSemesterOption { + return when (month) { + in 3..6 -> SemesterOptions[0] + in 7..8 -> SemesterOptions[1] + in 9..12 -> SemesterOptions[2] + else -> SemesterOptions[3] + } +} + private val EmptyTimetable = TimetableUiModel( id = "empty", name = "시간표 이름", @@ -105,16 +139,17 @@ private val EmptyTimetable = TimetableUiModel( internal fun TimetableScreenContentHost() { val timetableViewModel: TimetableViewModel = hiltViewModel() val apiUiState by timetableViewModel.uiState.collectAsState() + val context = LocalContext.current val events = remember { emptyList() } var timetables by remember { mutableStateOf(emptyList()) } + var selectedYear by rememberSaveable { mutableStateOf(DefaultYear) } + var selectedSemester by rememberSaveable { mutableStateOf(DefaultSemester) } 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) } @@ -125,13 +160,21 @@ internal fun TimetableScreenContentHost() { 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) { + LaunchedEffect(selectedYear, selectedSemester) { + selectedTimetableId = null + timetables = emptyList() + isTimetablePanelOpen = false + isCreateTimetablePanelOpen = false + isEditTimetablePanelOpen = false + isAddCoursePanelOpen = false + submitError = null + submitMessage = null + timetableViewModel.clearLoadedEnrolls() timetableViewModel.loadTimetables( - year = DefaultYear, - semester = DefaultSemester + year = selectedYear, + semester = selectedSemester ) } @@ -202,13 +245,41 @@ internal fun TimetableScreenContentHost() { ?: timetables.firstOrNull() ?: EmptyTimetable val hasSelectedTimetable = timetables.any { it.id == selectedTimetableId } + + LaunchedEffect(selectedTimetableId, hasSelectedTimetable) { + val timetableId = selectedTimetableId?.toLongOrNull() ?: return@LaunchedEffect + if (hasSelectedTimetable && + apiUiState.loadedEnrollsTimetableId != timetableId && + apiUiState.loadingEnrollsTimetableId != timetableId + ) { + timetableViewModel.loadEnrolls(timetableId = timetableId) + } + } + + LaunchedEffect(apiUiState.loadedEnrollsTimetableId, apiUiState.enrolls) { + val timetableId = apiUiState.loadedEnrollsTimetableId?.toString() ?: return@LaunchedEffect + timetables = timetables.map { timetable -> + if (timetable.id == timetableId) { + timetable.copy(courses = apiUiState.enrolls.toCourseUiModels()) + } else { + timetable + } + } + } + + LaunchedEffect(apiUiState.loadEnrollsError) { + val message = apiUiState.loadEnrollsError ?: return@LaunchedEffect + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + timetableViewModel.clearLoadEnrollsError() + } + val validation = TimetableAddCourseValidator.validate( timetableId = selectedTimetableId.takeIf { hasSelectedTimetable }, title = courseTitle, creditText = creditText, timeSlots = timeSlots, existingTimeSlots = selectedTimetable.courses.toExistingEditableSlots(), - isSubmitting = isSubmitting + isSubmitting = apiUiState.isCreatingCustomEnroll ) fun closePanels() { @@ -226,7 +297,17 @@ internal fun TimetableScreenContentHost() { submitMessage = message } + LaunchedEffect(apiUiState.createdCustomEnroll) { + apiUiState.createdCustomEnroll ?: return@LaunchedEffect + resetAddCourseForm(message = "Course added.") + timetableViewModel.onCreatedCustomEnrollConsumed() + } + TimetableScreenContent( + selectedYear = selectedYear, + selectedSemester = selectedSemester, + yearOptions = YearOptions, + semesterOptions = SemesterOptions, selectedTimetable = selectedTimetable, timetables = timetables, events = events, @@ -251,9 +332,11 @@ internal fun TimetableScreenContentHost() { creditText = creditText, timeSlots = timeSlots, validation = validation, - submitError = submitError, + submitError = apiUiState.createCustomEnrollError ?: submitError, submitMessage = submitMessage, - isSubmitting = isSubmitting, + isSubmitting = apiUiState.isCreatingCustomEnroll, + onYearSelected = { year -> selectedYear = year }, + onSemesterSelected = { semester -> selectedSemester = semester.apiValue }, onEventOverlayChanged = { isEventOverlayEnabled = it }, onOpenTimetablePanel = { isAddCoursePanelOpen = false @@ -293,8 +376,8 @@ internal fun TimetableScreenContentHost() { if (normalizedName.isNotEmpty()) { timetableViewModel.createTimetable( name = normalizedName, - year = DefaultYear, - semester = DefaultSemester + year = selectedYear, + semester = selectedSemester ) } }, @@ -318,11 +401,13 @@ internal fun TimetableScreenContentHost() { courseTitle = it submitError = null submitMessage = null + timetableViewModel.clearCreateCustomEnrollError() }, onInstructorChange = { instructor = it }, onCreditChange = { creditText = it.filter { char -> char.isDigit() } submitError = null + timetableViewModel.clearCreateCustomEnrollError() }, onAddTimeSlot = { timeSlots = timeSlots + defaultEditableTimeSlot(nextSlotId++) @@ -348,33 +433,25 @@ internal fun TimetableScreenContentHost() { } }, onSubmitAddCourse = { - if (!validation.canSubmit || isSubmitting || selectedTimetableId == null) { + val timetableId = selectedTimetableId?.toLongOrNull() + if (!validation.canSubmit || apiUiState.isCreatingCustomEnroll || timetableId == 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)], + timetableViewModel.createCustomEnroll( + timetableId = timetableId, + year = selectedTimetable.year, + semester = selectedTimetable.semester, + courseTitle = courseTitle, timeSlots = timeSlots.map { slot -> - CourseTimeSlot( - weekday = slot.dayOfWeek.weekday, - startMinute = slot.startAt, - endMinute = slot.endAt + CreateCustomTimetableEnrollTimeSlotRequest( + dayOfWeek = slot.dayOfWeek.apiValue, + startAt = slot.startAt, + endAt = slot.endAt ) - } + }, + credit = creditText.trim().takeIf { it.isNotEmpty() }?.toIntOrNull(), + instructor = instructor ) - timetables = timetables.map { timetable -> - if (timetable.id == selectedTimetableId) { - timetable.copy(courses = timetable.courses + newCourse) - } else { - timetable - } - } - isSubmitting = false - resetAddCourseForm(message = "수업을 추가했습니다") } ) } @@ -382,6 +459,10 @@ internal fun TimetableScreenContentHost() { // 화면 전체 레이아웃: 그리드, 플로팅 버튼, 하단 패널들을 한 화면 안에서 겹쳐 배치한다. @Composable private fun TimetableScreenContent( + selectedYear: Int, + selectedSemester: String, + yearOptions: List, + semesterOptions: List, selectedTimetable: TimetableUiModel, timetables: List, events: List, @@ -409,6 +490,8 @@ private fun TimetableScreenContent( submitError: String?, submitMessage: String?, isSubmitting: Boolean, + onYearSelected: (Int) -> Unit, + onSemesterSelected: (TimetableSemesterOption) -> Unit, onEventOverlayChanged: (Boolean) -> Unit, onOpenTimetablePanel: () -> Unit, onClosePanels: () -> Unit, @@ -445,6 +528,15 @@ private fun TimetableScreenContent( .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(24.dp)) + TimetableTermSelector( + selectedYear = selectedYear, + selectedSemester = selectedSemester, + yearOptions = yearOptions, + semesterOptions = semesterOptions, + onYearSelected = onYearSelected, + onSemesterSelected = onSemesterSelected + ) + Spacer(modifier = Modifier.height(12.dp)) TimetableHeader( name = selectedTimetable.name, credits = selectedTimetable.totalCredits, @@ -548,6 +640,101 @@ private fun TimetableScreenContent( } // 상단 헤더: 시간표 이름, 학점, 행사 보기 스위치를 표시한다. +@Composable +private fun TimetableTermSelector( + selectedYear: Int, + selectedSemester: String, + yearOptions: List, + semesterOptions: List, + onYearSelected: (Int) -> Unit, + onSemesterSelected: (TimetableSemesterOption) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "\uB098\uC758 \uC2DC\uAC04\uD45C", + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(18.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TimetableDropdown( + text = "${selectedYear}\uD559\uB144\uB3C4", + contentDescription = "\uD559\uB144\uB3C4 \uC120\uD0DD", + options = yearOptions, + optionText = { year -> "${year}\uD559\uB144\uB3C4" }, + onOptionSelected = onYearSelected + ) + TimetableDropdown( + text = semesterOptions.firstOrNull { option -> option.apiValue == selectedSemester }?.label.orEmpty(), + contentDescription = "\uD559\uAE30 \uC120\uD0DD", + options = semesterOptions, + optionText = { semester -> semester.label }, + onOptionSelected = onSemesterSelected + ) + } + } +} + +@Composable +private fun TimetableDropdown( + text: String, + contentDescription: String, + options: List, + optionText: (T) -> String, + onOptionSelected: (T) -> Unit +) { + var expanded by rememberSaveable { mutableStateOf(false) } + Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { + Row( + modifier = Modifier + .clickable { expanded = true } + .semantics { this.contentDescription = contentDescription }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text( + text = optionText(option), + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp + ) + }, + onClick = { + expanded = false + onOptionSelected(option) + } + ) + } + } + } +} + @Composable private fun TimetableHeader( name: String, @@ -1076,6 +1263,30 @@ private fun List.toCourseBlocks(): List { } } +private fun List.toCourseUiModels(): List { + return mapIndexed { index, enroll -> + CourseUiModel( + id = enroll.enrollId.toString(), + title = enroll.course.courseTitle, + instructor = enroll.course.instructor, + credit = enroll.course.credit, + color = CourseColors[index.floorMod(CourseColors.size)], + timeSlots = enroll.course.timeSlots.mapNotNull { slot -> + val weekday = slot.dayOfWeek.toTimetableWeekday() ?: return@mapNotNull null + CourseTimeSlot( + weekday = weekday, + startMinute = slot.startAt, + endMinute = slot.endAt + ) + } + ) + } +} + +private fun String.toTimetableWeekday(): Int? { + return TimetableDayOfWeek.values().firstOrNull { day -> day.apiValue == this }?.weekday +} + private fun List.toExistingEditableSlots(): List { var localId = 1L return flatMap { course -> 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 index 0d6d6c7..277bb81 100644 --- 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 @@ -2,7 +2,10 @@ package com.example.hangsha_android.ui.view.timetable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.hangsha_android.data.network.model.CreateCustomTimetableEnrollTimeSlotRequest +import com.example.hangsha_android.data.network.model.TimetableEnrollResponse import com.example.hangsha_android.data.network.model.TimetableResponse +import com.example.hangsha_android.data.network.model.UpdateCustomTimetableEnrollRequest import com.example.hangsha_android.data.repository.TimetableRepository import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException @@ -23,17 +26,19 @@ class TimetableViewModel @Inject constructor( private val _uiState = MutableStateFlow(TimetableApiUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var isLoadInFlight = false + private var loadingTimetableKey: Pair? = null + private var isEnrollLoadInFlight = false fun loadTimetables( year: Int, semester: String ) { - if (isLoadInFlight) { + val loadKey = year to semester + if (loadingTimetableKey == loadKey) { return } - isLoadInFlight = true + loadingTimetableKey = loadKey viewModelScope.launch { _uiState.update { it.copy( @@ -53,24 +58,30 @@ class TimetableViewModel @Inject constructor( response.body()?.items ?: throw IllegalStateException("Timetable list response was empty.") }.fold( onSuccess = { timetables -> - _uiState.update { - it.copy( - isLoadingTimetables = false, - loadTimetablesError = null, - timetables = timetables - ) + if (loadingTimetableKey == loadKey) { + _uiState.update { + it.copy( + isLoadingTimetables = false, + loadTimetablesError = null, + timetables = timetables + ) + } } }, onFailure = { error -> - _uiState.update { - it.copy( - isLoadingTimetables = false, - loadTimetablesError = mapLoadErrorMessage(error) - ) + if (loadingTimetableKey == loadKey) { + _uiState.update { + it.copy( + isLoadingTimetables = false, + loadTimetablesError = mapLoadErrorMessage(error) + ) + } } } ) - isLoadInFlight = false + if (loadingTimetableKey == loadKey) { + loadingTimetableKey = null + } } } @@ -204,7 +215,9 @@ class TimetableViewModel @Inject constructor( deletingTimetableId = null, deleteTimetableError = null, deletedTimetableId = timetableId, - timetables = it.timetables.filterNot { timetable -> timetable.id == timetableId } + timetables = it.timetables.filterNot { timetable -> timetable.id == timetableId }, + loadedEnrollsTimetableId = if (it.loadedEnrollsTimetableId == timetableId) null else it.loadedEnrollsTimetableId, + enrolls = if (it.loadedEnrollsTimetableId == timetableId) emptyList() else it.enrolls ) } }, @@ -220,6 +233,344 @@ class TimetableViewModel @Inject constructor( } } + fun loadEnrolls(timetableId: Long) { + if (isEnrollLoadInFlight && _uiState.value.loadingEnrollsTimetableId == timetableId) { + return + } + + isEnrollLoadInFlight = true + viewModelScope.launch { + _uiState.update { + it.copy( + loadingEnrollsTimetableId = timetableId, + loadEnrollsError = null + ) + } + + runCatching { + val response = timetableRepository.getEnrolls(timetableId = timetableId) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body()?.items ?: throw IllegalStateException("Enroll list response was empty.") + }.fold( + onSuccess = { enrolls -> + _uiState.update { + it.copy( + loadingEnrollsTimetableId = null, + loadedEnrollsTimetableId = timetableId, + loadEnrollsError = null, + enrolls = enrolls + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + loadingEnrollsTimetableId = null, + loadEnrollsError = mapEnrollErrorMessage("load", error) + ) + } + } + ) + isEnrollLoadInFlight = false + } + } + + fun loadEnroll( + timetableId: Long, + enrollId: Long + ) { + if (_uiState.value.loadingEnrollId != null) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + loadingEnrollId = enrollId, + loadEnrollError = null, + loadedEnroll = null + ) + } + + runCatching { + val response = timetableRepository.getEnroll( + timetableId = timetableId, + enrollId = enrollId + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body() ?: throw IllegalStateException("Enroll response was empty.") + }.fold( + onSuccess = { enroll -> + _uiState.update { + it.copy( + loadingEnrollId = null, + loadEnrollError = null, + loadedEnroll = enroll + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + loadingEnrollId = null, + loadEnrollError = mapEnrollErrorMessage("load", error), + loadedEnroll = null + ) + } + } + ) + } + } + + fun createCustomEnroll( + timetableId: Long, + year: Int, + semester: String, + courseTitle: String, + timeSlots: List, + courseNumber: String? = null, + lectureNumber: String? = null, + credit: Int? = null, + instructor: String? = null + ) { + val normalizedTitle = courseTitle.trim() + if (normalizedTitle.isBlank() || timeSlots.isEmpty() || _uiState.value.isCreatingCustomEnroll) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isCreatingCustomEnroll = true, + createCustomEnrollError = null, + createdCustomEnroll = null + ) + } + + runCatching { + val response = timetableRepository.createCustomEnroll( + timetableId = timetableId, + year = year, + semester = semester, + courseTitle = normalizedTitle, + timeSlots = timeSlots, + courseNumber = courseNumber, + lectureNumber = lectureNumber, + credit = credit, + instructor = instructor + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body() ?: throw IllegalStateException("Enroll response was empty.") + }.fold( + onSuccess = { enroll -> + _uiState.update { + val updatedEnrolls = if (it.loadedEnrollsTimetableId == timetableId) { + it.enrolls.filterNot { current -> current.enrollId == enroll.enrollId } + enroll + } else { + listOf(enroll) + } + it.copy( + isCreatingCustomEnroll = false, + createCustomEnrollError = null, + createdCustomEnroll = enroll, + loadedEnrollsTimetableId = timetableId, + enrolls = updatedEnrolls + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isCreatingCustomEnroll = false, + createCustomEnrollError = mapEnrollErrorMessage("create", error), + createdCustomEnroll = null + ) + } + } + ) + } + } + + + fun updateCustomEnroll( + timetableId: Long, + enrollId: Long, + request: UpdateCustomTimetableEnrollRequest + ) { + if (_uiState.value.updatingEnrollId != null) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + updatingEnrollId = enrollId, + updateEnrollError = null, + updatedEnroll = null + ) + } + + runCatching { + val response = timetableRepository.updateCustomEnroll( + timetableId = timetableId, + enrollId = enrollId, + request = request + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + response.body() ?: throw IllegalStateException("Enroll response was empty.") + }.fold( + onSuccess = { enroll -> + _uiState.update { + it.copy( + updatingEnrollId = null, + updateEnrollError = null, + updatedEnroll = enroll, + loadedEnroll = if (it.loadedEnroll?.enrollId == enroll.enrollId) enroll else it.loadedEnroll, + enrolls = if (it.loadedEnrollsTimetableId == timetableId) { + it.enrolls.map { current -> + if (current.enrollId == enroll.enrollId) enroll else current + } + } else { + it.enrolls + } + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + updatingEnrollId = null, + updateEnrollError = mapEnrollErrorMessage("update", error), + updatedEnroll = null + ) + } + } + ) + } + } + + fun onUpdatedEnrollConsumed() { + _uiState.update { + it.copy(updatedEnroll = null) + } + } + + fun clearUpdateEnrollError() { + _uiState.update { + it.copy(updateEnrollError = null) + } + } + + fun deleteEnroll( + timetableId: Long, + enrollId: Long + ) { + if (_uiState.value.deletingEnrollId != null) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + deletingEnrollId = enrollId, + deleteEnrollError = null + ) + } + + runCatching { + val response = timetableRepository.deleteEnroll( + timetableId = timetableId, + enrollId = enrollId + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + }.fold( + onSuccess = { + _uiState.update { + it.copy( + deletingEnrollId = null, + deletedEnrollId = enrollId, + deleteEnrollError = null, + enrolls = if (it.loadedEnrollsTimetableId == timetableId) { + it.enrolls.filterNot { enroll -> enroll.enrollId == enrollId } + } else { + it.enrolls + } + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + deletingEnrollId = null, + deleteEnrollError = mapEnrollErrorMessage("delete", error) + ) + } + } + ) + } + } + + fun onCreatedCustomEnrollConsumed() { + _uiState.update { + it.copy(createdCustomEnroll = null) + } + } + + fun onDeletedEnrollConsumed() { + _uiState.update { + it.copy(deletedEnrollId = null) + } + } + + fun clearCreateCustomEnrollError() { + _uiState.update { + it.copy(createCustomEnrollError = null) + } + } + + fun clearDeleteEnrollError() { + _uiState.update { + it.copy(deleteEnrollError = null) + } + } + + fun clearLoadEnrollError() { + _uiState.update { + it.copy(loadEnrollError = null) + } + } + + fun clearLoadEnrollsError() { + _uiState.update { + it.copy(loadEnrollsError = null) + } + } + + fun clearLoadedEnrolls() { + _uiState.update { + it.copy( + loadingEnrollsTimetableId = null, + loadedEnrollsTimetableId = null, + loadEnrollsError = null, + enrolls = emptyList(), + loadingEnrollId = null, + loadedEnroll = null, + loadEnrollError = null + ) + } + } + + fun onUpdatedTimetableConsumed() { _uiState.update { it.copy(updatedTimetable = null) @@ -302,6 +653,35 @@ class TimetableViewModel @Inject constructor( else -> error.message ?: "시간표 삭제에 실패했습니다." } } + private fun mapEnrollErrorMessage(action: String, error: Throwable): String { + val actionLabel = enrollActionLabel(action) + return when (error) { + is HttpException -> when (error.code()) { + 400 -> "\uC218\uC5C5 \uC815\uBCF4\uB97C \uD655\uC778\uD574 \uC8FC\uC138\uC694." + 401 -> "\uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4." + 403 -> "\uC774 \uC2DC\uAC04\uD45C\uC5D0 \uB300\uD55C \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." + 404 -> "\uC2DC\uAC04\uD45C\uB098 \uC218\uC5C5\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4." + in 500..599 -> "\uC11C\uBC84 \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4. \uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574 \uC8FC\uC138\uC694." + else -> "$actionLabel\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. (${error.code()})" + } + is UnknownHostException -> "\uC778\uD130\uB137 \uC5F0\uACB0\uC744 \uD655\uC778\uD574 \uC8FC\uC138\uC694." + is SocketTimeoutException -> "\uC694\uCCAD \uC2DC\uAC04\uC774 \uCD08\uACFC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD574 \uC8FC\uC138\uC694." + is IOException -> "\uB124\uD2B8\uC6CC\uD06C \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD574 \uC8FC\uC138\uC694." + is IllegalStateException -> error.message ?: "$actionLabel\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4." + else -> error.message ?: "$actionLabel\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4." + } + } + + private fun enrollActionLabel(action: String): String { + return when (action) { + "load" -> "\uC218\uC5C5 \uBAA9\uB85D \uBD88\uB7EC\uC624\uAE30" + "create" -> "\uC218\uC5C5 \uCD94\uAC00" + "update" -> "\uC218\uC5C5 \uC218\uC815" + "delete" -> "\uC218\uC5C5 \uC0AD\uC81C" + else -> "\uC218\uC5C5 \uC694\uCCAD" + } + } + private fun mapCreateErrorMessage(error: Throwable): String { return when (error) { is HttpException -> when (error.code()) { @@ -323,6 +703,22 @@ data class TimetableApiUiState( val isLoadingTimetables: Boolean = false, val loadTimetablesError: String? = null, val timetables: List = emptyList(), + val loadingEnrollsTimetableId: Long? = null, + val loadedEnrollsTimetableId: Long? = null, + val loadEnrollsError: String? = null, + val enrolls: List = emptyList(), + val loadingEnrollId: Long? = null, + val loadedEnroll: TimetableEnrollResponse? = null, + val loadEnrollError: String? = null, + val isCreatingCustomEnroll: Boolean = false, + val createCustomEnrollError: String? = null, + val createdCustomEnroll: TimetableEnrollResponse? = null, + val updatingEnrollId: Long? = null, + val updatedEnroll: TimetableEnrollResponse? = null, + val updateEnrollError: String? = null, + val deletingEnrollId: Long? = null, + val deletedEnrollId: Long? = null, + val deleteEnrollError: String? = null, val isCreatingTimetable: Boolean = false, val createTimetableError: String? = null, val createdTimetable: TimetableResponse? = null,