Коммит bf6326f5 создал по автору Radch-enko's avatar Radch-enko
Просмотр файлов

Enhance error handling and improve booking editor state management

- Added error state tracking (`isLoadUpdate`, `isErrorUpdate`, `isLoadCreate`, `isErrorCreate`) to the booking editor.
- Refactored update and create event logic to handle errors and loading states properly.
- Updated UI components to display loader or error messages for create and update operations.
- Improved organizer selection logic with fallback handling for non-matching input.
- Adjusted date and duration validation to ensure proper time range for events.
владелец 111af54d
......@@ -5,4 +5,5 @@
<string name="booking_time_button">Занять c %1$s до %2$s</string>
<string name="update_button">Изменить</string>
<string name="delete_button">Удалить бронь</string>
<string name="error">Произошла ошибка</string>
</resources>
\ Нет новой строки в конце файла
......@@ -37,6 +37,7 @@ import band.effective.office.tablet.feature.bookingEditor.booking_time_button
import band.effective.office.tablet.feature.bookingEditor.booking_view_title
import band.effective.office.tablet.feature.bookingEditor.create_view_title
import band.effective.office.tablet.feature.bookingEditor.delete_button
import band.effective.office.tablet.feature.bookingEditor.error
import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerModalView
import band.effective.office.tablet.feature.bookingEditor.update_button
import com.arkivanov.decompose.extensions.compose.stack.Children
......@@ -67,7 +68,7 @@ fun BookingEditor(
onDismissRequest = { component.sendIntent(Intent.OnClose) })
BookingEditorComponent.ModalConfig.SuccessModal -> SuccessSelectRoomView(
roomName = component.room,
roomName = component.roomName,
organizerName = state.selectOrganizer.fullName,
startTime = state.event.startTime,
finishTime = state.event.finishTime,
......@@ -88,7 +89,7 @@ fun BookingEditor(
selectOrganizer = state.selectOrganizer,
organizers = state.selectOrganizers,
expended = state.expanded,
onUpdateEvent = { component.sendIntent(Intent.OnUpdateEvent(component.room)) },
onUpdateEvent = { component.sendIntent(Intent.OnUpdateEvent(component.roomName)) },
onDeleteEvent = { component.sendIntent(Intent.OnDeleteEvent) },
inputText = state.inputText,
onInput = { component.sendIntent(Intent.OnInput(it)) },
......@@ -96,12 +97,16 @@ fun BookingEditor(
onDoneInput = { component.sendIntent(Intent.OnDoneInput) },
isDeleteError = state.isErrorDelete,
isDeleteLoad = state.isLoadDelete,
isUpdateError = state.isErrorUpdate,
isUpdateLoad = state.isLoadUpdate,
isCreateError = state.isErrorCreate,
isCreateLoad = state.isLoadCreate,
enableUpdateButton = state.enableUpdateButton,
isNewEvent = !state.isCreatedEvent(),
onCreateEvent = { component.sendIntent(Intent.OnBooking) },
start = state.event.startTime.format(timeFormatter),
finish = state.event.finishTime.format(timeFormatter),
room = component.room,
room = component.roomName,
)
}
}
......@@ -134,6 +139,10 @@ private fun BookingEditor(
onDoneInput: (String) -> Unit,
isDeleteError: Boolean,
isDeleteLoad: Boolean,
isUpdateError: Boolean = false,
isUpdateLoad: Boolean = false,
isCreateError: Boolean = false,
isCreateLoad: Boolean = false,
enableUpdateButton: Boolean,
isNewEvent: Boolean,
start: String,
......@@ -176,7 +185,15 @@ private fun BookingEditor(
selectOrganizers = organizers.map { it.fullName },
expanded = expended,
onExpandedChange = onExpandedChange,
onSelectItem = { org -> onSelectOrganizer(organizers.find { it.fullName == org }!!) },
onSelectItem = { org ->
organizers.find { it.fullName == org }?.let { organizer ->
onSelectOrganizer(organizer)
} ?: run {
// If organizer not found, use the first one or default
val fallbackOrganizer = organizers.firstOrNull() ?: Organizer.default
onSelectOrganizer(fallbackOrganizer)
}
},
onInput = onInput,
isInputError = isInputError,
onDoneInput = onDoneInput,
......@@ -187,23 +204,37 @@ private fun BookingEditor(
SuccessButton(
modifier = Modifier.fillMaxWidth().height(60.dp),
onClick = onCreateEvent,
enable = enableUpdateButton
enable = enableUpdateButton && !isCreateLoad
) {
Text(
text = stringResource(Res.string.booking_time_button, start, finish),
style = MaterialTheme.typography.h6
)
when {
isCreateLoad -> Loader()
isCreateError -> Text(
text = "Error creating event", // Ideally, this should be a string resource
style = MaterialTheme.typography.h6
)
else -> Text(
text = stringResource(Res.string.booking_time_button, start, finish),
style = MaterialTheme.typography.h6
)
}
}
} else {
SuccessButton(
modifier = Modifier.fillMaxWidth().height(60.dp),
onClick = onUpdateEvent,
enable = enableUpdateButton
enable = enableUpdateButton && !isUpdateLoad
) {
Text(
text = stringResource(Res.string.update_button),
style = MaterialTheme.typography.h6
)
when {
isUpdateLoad -> Loader()
isUpdateError -> Text(
text = stringResource(Res.string.error),
style = MaterialTheme.typography.h6
)
else -> Text(
text = stringResource(Res.string.update_button),
style = MaterialTheme.typography.h6
)
}
}
Spacer(modifier = Modifier.height(10.dp))
AlertButton(
......@@ -213,7 +244,7 @@ private fun BookingEditor(
when {
isDeleteLoad -> Loader()
isDeleteError -> Text(
text = stringResource(Res.string.update_button),
text = "Error deleting event", // Ideally, this should be a string resource
style = MaterialTheme.typography.h6
)
......
......@@ -21,54 +21,63 @@ import band.effective.office.tablet.feature.bookingEditor.presentation.mapper.Up
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import io.github.aakira.napier.Napier
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.atStartOfDayIn
import kotlinx.serialization.Serializable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Component responsible for editing booking events.
* Handles creating new bookings and updating existing ones.
*/
class BookingEditorComponent(
componentContext: ComponentContext,
event: EventInfo,
val room: String,
private val onDelete: (Slot) -> Unit,
initialEvent: EventInfo,
val roomName: String,
private val onDeleteEvent: (Slot) -> Unit,
private val onCloseRequest: () -> Unit,
) : ComponentContext by componentContext, KoinComponent, ModalWindow {
val dateTimePickerComponent: DateTimePickerComponent by lazy {
DateTimePickerComponent(
componentContext = componentContext,
onSelectDate = { newDate -> setDay(newDate) },
onSelectDate = { newDate -> updateEventDate(newDate) },
onCloseRequest = { mutableState.update { it.copy(showSelectDate = false) } },
event = event,
room = room,
event = initialEvent,
room = roomName,
duration = state.value.duration,
initDate = { state.value.date }
)
}
private val scope = componentCoroutineScope()
private val coroutineScope = componentCoroutineScope()
val organizersInfoUseCase: OrganizersInfoUseCase by inject()
val checkBookingUseCase: CheckBookingUseCase by inject()
val updateBookingUseCase: UpdateBookingUseCase by inject()
val createBookingUseCase: CreateBookingUseCase by inject()
// Use cases
private val organizersInfoUseCase: OrganizersInfoUseCase by inject()
private val checkBookingUseCase: CheckBookingUseCase by inject()
private val updateBookingUseCase: UpdateBookingUseCase by inject()
private val createBookingUseCase: CreateBookingUseCase by inject()
val eventInfoMapper: EventInfoMapper by inject()
val stateToEventInfoMapper: UpdateEventComponentStateToEventInfoMapper by inject()
// Mappers
private val eventInfoMapper: EventInfoMapper by inject()
private val stateToEventInfoMapper: UpdateEventComponentStateToEventInfoMapper by inject()
private val mutableState = MutableStateFlow(eventInfoMapper.mapToUpdateBookingState(event))
// State management
private val mutableState = MutableStateFlow(eventInfoMapper.mapToUpdateBookingState(initialEvent))
val state = mutableState.asStateFlow()
// Navigation
private val navigation = StackNavigation<ModalConfig>()
val childStack = childStack(
......@@ -82,44 +91,58 @@ class BookingEditorComponent(
loadOrganizers()
}
/**
* Handles intents from the UI
*/
fun sendIntent(intent: Intent) {
when (intent) {
Intent.OnBooking -> createEvent()
Intent.OnBooking -> createNewEvent()
Intent.OnClose -> onCloseRequest()
Intent.OnCloseSelectDateDialog -> mutableState.update { it.copy(showSelectDate = false) }
Intent.OnDeleteEvent -> cancel()
Intent.OnDoneInput -> onDoneInput()
Intent.OnExpandedChange -> mutableState.update { it.copy(expanded = !it.expanded) }
is Intent.OnInput -> onInput(intent.input)
Intent.OnOpenSelectDateDialog -> mutableState.update { it.copy(showSelectDate = true) }
is Intent.OnSelectOrganizer -> {
mutableState.update {
it.copy(
selectOrganizer = intent.newOrganizer,
inputText = intent.newOrganizer.fullName,
)
}
checkEnableButton(
inputError = false,
busyEvent = state.value.isBusyEvent,
)
}
is Intent.OnSetDate -> setDay(intent.calendar)
is Intent.OnUpdateDate -> updateInfo(changeData = intent.updateInDays)
is Intent.OnUpdateEvent -> updateEvent()
is Intent.OnUpdateLength -> updateInfo(changeDuration = intent.update)
Intent.OnCloseSelectDateDialog -> closeSelectDateDialog()
Intent.OnDeleteEvent -> deleteEvent()
Intent.OnDoneInput -> finalizeOrganizerSelection()
Intent.OnExpandedChange -> toggleExpandedState()
is Intent.OnInput -> handleOrganizerInput(intent.input)
Intent.OnOpenSelectDateDialog -> openSelectDateDialog()
is Intent.OnSelectOrganizer -> selectOrganizer(intent.newOrganizer)
is Intent.OnSetDate -> updateEventDate(intent.calendar)
is Intent.OnUpdateDate -> updateEventDetails(daysToAdd = intent.updateInDays)
is Intent.OnUpdateEvent -> updateExistingEvent()
is Intent.OnUpdateLength -> updateEventDetails(durationChange = intent.update)
}
}
private fun updateEvent() = scope.launch {
CoroutineScope(Dispatchers.IO).launch {
updateBookingUseCase(roomName = room, eventInfo = stateToEventInfoMapper.map(state.value))
/**
* Updates an existing event in the database
*/
private fun updateExistingEvent() = coroutineScope.launch {
mutableState.update { it.copy(isLoadUpdate = true) }
withContext(Dispatchers.IO) {
updateBookingUseCase(
roomName = roomName,
eventInfo = stateToEventInfoMapper.map(state.value)
).unbox(
errorHandler = {
Napier.d { "Update booking failed: ${it.description}" }
mutableState.update {
it.copy(
isLoadUpdate = false,
isErrorUpdate = true
)
}
},
successHandler = {
mutableState.update { it.copy(isLoadUpdate = false) }
onCloseRequest()
}
)
}
onCloseRequest()
}
private fun loadOrganizers() = scope.launch {
/**
* Loads the list of organizers from the database
*/
private fun loadOrganizers() = coroutineScope.launch {
val organizers = organizersInfoUseCase().unbox(errorHandler = { emptyList() })
mutableState.update {
it.copy(
......@@ -129,14 +152,22 @@ class BookingEditorComponent(
}
}
private fun cancel() {
onDelete(eventInfoMapper.mapToSlot(state.value.event))
/**
* Deletes the current event
*/
private fun deleteEvent() = coroutineScope.launch {
mutableState.update { it.copy(isLoadDelete = true) }
onDeleteEvent(eventInfoMapper.mapToSlot(state.value.event))
mutableState.update { it.copy(isLoadDelete = false) }
onCloseRequest()
}
private fun onDoneInput() = with(state.value) {
/**
* Finalizes the organizer selection based on the input text
*/
private fun finalizeOrganizerSelection() = with(state.value) {
val input = inputText.lowercase()
val organizer = selectOrganizers.firstOrNull { it.fullName.lowercase().contains(input) } ?: event.organizer
val organizer = findOrganizerByName(input) ?: event.organizer
val isOrganizerIncorrect = !organizers.contains(organizer)
mutableState.update {
......@@ -146,48 +177,67 @@ class BookingEditorComponent(
isInputError = isOrganizerIncorrect,
)
}
checkEnableButton(
updateButtonState(
inputError = isOrganizerIncorrect,
busyEvent = isBusyEvent
)
}
private fun onInput(input: String) {
val newList = state.value.organizers
/**
* Finds an organizer by name (partial match)
*/
private fun findOrganizerByName(name: String): Organizer? {
return state.value.selectOrganizers.firstOrNull {
it.fullName.lowercase().contains(name.lowercase())
}
}
/**
* Handles input in the organizer field
*/
private fun handleOrganizerInput(input: String) {
val filteredOrganizers = state.value.organizers
.filter { it.fullName.lowercase().contains(input.lowercase()) }
.sortedBy { it.fullName.lowercase().indexOf(input.lowercase()) }
mutableState.update { it.copy(inputText = input, selectOrganizers = newList) }
mutableState.update {
it.copy(
inputText = input,
selectOrganizers = filteredOrganizers
)
}
}
private fun checkEnableButton(
/**
* Updates the button state based on validation
*/
private fun updateButtonState(
inputError: Boolean,
busyEvent: Boolean
) = mutableState.update { it.copy(enableUpdateButton = !inputError && !busyEvent) }
) = mutableState.update {
it.copy(enableUpdateButton = !inputError && !busyEvent)
}
private fun setDay(newDate: LocalDateTime) = scope.launch {
/**
* Updates the event date
*/
private fun updateEventDate(newDate: LocalDateTime) = coroutineScope.launch {
with(state.value) {
val busyEvents: List<EventInfo> = checkBookingUseCase.busyEvents(
event = copy(date = newDate).let(stateToEventInfoMapper::map),
room = room
).filter { it.startTime != date }
mutableState.update {
it.copy(
date = newDate,
duration = duration,
selectOrganizer = selectOrganizer,
event = event(
id = event.id,
newDate = newDate,
newDuration = duration,
organizer = selectOrganizer,
),
isBusyEvent = busyEvents.isNotEmpty()
)
}
val busyEvents = checkForBusyEvents(
date = newDate,
duration = duration,
organizer = selectOrganizer
)
updateStateWithNewEventDetails(
newDate = newDate,
newDuration = duration,
newOrganizer = selectOrganizer,
busyEvents = busyEvents
)
if (selectOrganizer != Organizer.default) {
checkEnableButton(
updateButtonState(
inputError = isInputError,
busyEvent = busyEvents.isNotEmpty()
)
......@@ -195,79 +245,198 @@ class BookingEditorComponent(
}
}
private fun updateInfo(
changeData: Int = 0,
changeDuration: Int = 0,
newOrg: Organizer = state.value.selectOrganizer
) = scope.launch {
/**
* Updates event details (date, duration, organizer)
*/
private fun updateEventDetails(
daysToAdd: Int = 0,
durationChange: Int = 0,
newOrganizer: Organizer = state.value.selectOrganizer
) = coroutineScope.launch {
with(state.value) {
val newDate = date.asInstant.plus(changeData.days).asLocalDateTime
val newDuration = duration + changeDuration
val newOrganizer = organizers.firstOrNull { it.fullName == newOrg.fullName } ?: event.organizer
val busyEvent: List<EventInfo> = checkBookingUseCase.busyEvents(
event = copy(
date = newDate,
duration = newDuration,
selectOrganizer = newOrganizer
).let(stateToEventInfoMapper::map),
room = room
).filter { it.startTime != date }
fun today(): LocalDateTime {
val now = currentLocalDateTime
return now.date.atStartOfDayIn(defaultTimeZone).asLocalDateTime
}
val newDate = date.asInstant.plus(daysToAdd.days).asLocalDateTime
val newDuration = duration + durationChange
val resolvedOrganizer = organizers.firstOrNull {
it.fullName == newOrganizer.fullName
} ?: event.organizer
val officeEndTime = OfficeTime.finishWorkTime(newDate.date)
val newEventFinish = newDate.asInstant.plus(newDuration.minutes).asLocalDateTime
if (newDuration > 0 && newDate > today() && newEventFinish < officeEndTime) {
mutableState.update {
it.copy(
date = newDate,
duration = newDuration,
selectOrganizer = newOrganizer,
event = event(
id = event.id,
newDate = newDate,
newDuration = newDuration,
organizer = newOrganizer,
),
isBusyEvent = busyEvent.isNotEmpty()
)
}
checkEnableButton(
inputError = !organizers.contains(newOrganizer),
busyEvent = busyEvent.isNotEmpty()
val busyEvents = checkForBusyEvents(
date = newDate,
duration = newDuration,
organizer = resolvedOrganizer
)
if (isValidEventTime(newDate, newDuration)) {
updateStateWithNewEventDetails(
newDate = newDate,
newDuration = newDuration,
newOrganizer = resolvedOrganizer,
busyEvents = busyEvents
)
updateButtonState(
inputError = !organizers.contains(resolvedOrganizer),
busyEvent = busyEvents.isNotEmpty()
)
}
}
}
private fun createEvent() = scope.launch {
val event = stateToEventInfoMapper.map(state.value)
CoroutineScope(Dispatchers.IO).launch {
createBookingUseCase(roomName = room, eventInfo = event)
/**
* Checks if the event time is valid
*/
private fun isValidEventTime(date: LocalDateTime, duration: Int): Boolean {
val today = getTodayStartTime()
val officeEndTime = OfficeTime.finishWorkTime(date.date)
val eventEndTime = date.asInstant.plus(duration.minutes).asLocalDateTime
return duration > 0 && date > today && eventEndTime < officeEndTime
}
/**
* Gets the start time of today
*/
private fun getTodayStartTime(): LocalDateTime =
currentLocalDateTime.date.atStartOfDayIn(defaultTimeZone).asLocalDateTime
/**
* Checks for busy events that conflict with the given parameters
*/
private suspend fun checkForBusyEvents(
date: LocalDateTime,
duration: Int,
organizer: Organizer
): List<EventInfo> {
val eventToCheck = createEventInfo(
id = state.value.event.id,
startTime = date,
duration = duration,
organizer = organizer
)
val currentEventId = state.value.event.id
return checkBookingUseCase.busyEvents(
event = eventToCheck,
room = roomName
).filter { busyEvent ->
// Exclude the current event being edited (if it's an update)
if (busyEvent.id == currentEventId && !currentEventId.isBlank()) {
return@filter false
}
// Check for any overlap between events
val newEventStart = eventToCheck.startTime.asInstant
val newEventEnd = eventToCheck.finishTime.asInstant
val busyEventStart = busyEvent.startTime.asInstant
val busyEventEnd = busyEvent.finishTime.asInstant
// Events overlap if one starts before the other ends
(newEventStart < busyEventEnd && newEventEnd > busyEventStart)
}
onCloseRequest()
}
// TODO refactor
private fun event(
id: String,
/**
* Updates the state with new event details
*/
private fun updateStateWithNewEventDetails(
newDate: LocalDateTime,
newDuration: Int,
newOrganizer: Organizer,
busyEvents: List<EventInfo>
) {
val updatedEvent = createEventInfo(
id = state.value.event.id,
startTime = newDate,
duration = newDuration,
organizer = newOrganizer
)
mutableState.update {
it.copy(
date = newDate,
duration = newDuration,
selectOrganizer = newOrganizer,
event = updatedEvent,
isBusyEvent = busyEvents.isNotEmpty()
)
}
}
/**
* Creates a new event in the database
*/
private fun createNewEvent() = coroutineScope.launch {
mutableState.update { it.copy(isLoadCreate = true) }
val eventToCreate = stateToEventInfoMapper.map(state.value)
withContext(Dispatchers.IO) {
createBookingUseCase(roomName = roomName, eventInfo = eventToCreate).unbox(
errorHandler = {
mutableState.update {
it.copy(
isLoadCreate = false,
isErrorCreate = true,
)
}
},
successHandler = {
mutableState.update { it.copy(isLoadCreate = false) }
onCloseRequest()
}
)
}
}
/**
* Creates an EventInfo object with the given parameters
*/
private fun createEventInfo(
id: String,
startTime: LocalDateTime,
duration: Int,
organizer: Organizer,
): EventInfo {
return EventInfo(
startTime = newDate,
finishTime = newDate.asInstant.plus(newDuration.minutes).asLocalDateTime,
startTime = startTime,
finishTime = startTime.asInstant.plus(duration.minutes).asLocalDateTime,
organizer = organizer,
id = id,
isLoading = false,
)
}
/**
* Selects an organizer
*/
private fun selectOrganizer(organizer: Organizer) {
mutableState.update {
it.copy(
selectOrganizer = organizer,
inputText = organizer.fullName,
)
}
updateButtonState(
inputError = false,
busyEvent = state.value.isBusyEvent,
)
}
/**
* Toggles the expanded state
*/
private fun toggleExpandedState() = mutableState.update { it.copy(expanded = !it.expanded) }
/**
* Opens the select date dialog
*/
private fun openSelectDateDialog() = mutableState.update { it.copy(showSelectDate = true) }
/**
* Closes the select date dialog
*/
private fun closeSelectDateDialog() = mutableState.update { it.copy(showSelectDate = false) }
@Serializable
sealed interface ModalConfig {
@Serializable
......
......@@ -17,6 +17,10 @@ data class State(
val isInputError: Boolean,
val isLoadDelete: Boolean,
val isErrorDelete: Boolean,
val isLoadUpdate: Boolean,
val isErrorUpdate: Boolean,
val isLoadCreate: Boolean,
val isErrorCreate: Boolean,
val showSelectDate: Boolean,
val enableUpdateButton: Boolean,
val isBusyEvent: Boolean
......@@ -34,6 +38,10 @@ data class State(
isInputError = false,
isLoadDelete = false,
isErrorDelete = false,
isLoadUpdate = false,
isErrorUpdate = false,
isLoadCreate = false,
isErrorCreate = false,
showSelectDate = false,
enableUpdateButton = false,
isBusyEvent = false
......@@ -41,4 +49,4 @@ data class State(
}
fun isCreatedEvent() = !event.isNotCreated()
}
\ Нет новой строки в конце файла
}
......@@ -137,20 +137,20 @@ class MainComponent(
}
}
// reset selected room
currentRoomTimer.start(delay = 1.minutes) {
/*currentRoomTimer.start(delay = 1.minutes) {
withContext(Dispatchers.Main) {
loadRooms()
}
}
}*/
// update cache when get error
errorTimer.init(15.minutes) {
/*errorTimer.init(15.minutes) {
roomInfoUseCase.updateCache()
withContext(Dispatchers.Main) {
loadRooms()
}
}
}*/
// reset select date
currentTimeTimer.start(1.minutes) {
/*currentTimeTimer.start(1.minutes) {
withContext(Dispatchers.Main) {
mutableState.update {
it.copy(
......@@ -160,7 +160,7 @@ class MainComponent(
}
slotComponent.sendIntent(SlotIntent.UpdateDate(currentLocalDateTime))
}
}
}*/
}
fun sendIntent(intent: Intent) {
......@@ -207,10 +207,9 @@ class MainComponent(
is ModalWindowsConfig.UpdateEvent -> BookingEditorComponent(
componentContext = componentContext,
event = modalWindows.event,
room = state.value.run { roomList[indexSelectRoom].name },
onDelete = { slot ->
initialEvent = modalWindows.event,
roomName = state.value.run { roomList[indexSelectRoom].name },
onDeleteEvent = { slot ->
slotComponent.sendIntent(
SlotIntent.Delete(
slot = slot,
......
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать