From a691e84fd8daa3a6c20234ed99c064eff7dc280b Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Mon, 2 Feb 2026 07:00:56 +0530 Subject: [PATCH] ai removed boilerplate and orgnized code even more --- .../core/viewmodel/ViewModelRequest.kt | 25 ++ .../ui/guest/GuestSignatureViewModel.kt | 41 ++- .../ui/home/HomeJoinPropertyViewModel.kt | 49 ++-- .../ui/navigation/MainRoutesStayFlow.kt | 130 +++++---- .../ui/payment/BookingPaymentsViewModel.kt | 204 ++++++-------- .../ui/property/AddPropertyViewModel.kt | 51 ++-- .../ui/property/PropertyListViewModel.kt | 37 ++- .../ui/room/RoomFormViewModel.kt | 143 +++++----- .../ui/room/RoomListViewModel.kt | 57 ++-- .../ui/roomimage/AddImageTagScreen.kt | 69 +---- .../ui/roomimage/EditImageTagScreen.kt | 67 +---- .../ui/roomimage/ImageTagFormScreen.kt | 73 ++++++ .../ui/roomimage/ImageTagFormViewModel.kt | 62 +++-- .../ui/roomimage/ImageTagViewModel.kt | 71 +++-- .../ui/roomstay/ActiveRoomStaysViewModel.kt | 41 ++- .../ui/roomstay/BookingDetailsViewModel.kt | 37 +-- .../ui/roomstay/BookingRoomStaysViewModel.kt | 39 ++- .../roomstay/ManageRoomStayRatesViewModel.kt | 57 ++-- .../roomstay/ManageRoomStaySelectViewModel.kt | 37 ++- .../ui/roomtype/AddAmenityScreen.kt | 7 +- .../ui/roomtype/AmenityEditorScreen.kt | 27 ++ .../ui/roomtype/AmenityFormViewModel.kt | 95 ++++--- .../ui/roomtype/AmenityListViewModel.kt | 71 +++-- .../ui/roomtype/EditAmenityScreen.kt | 8 +- .../ui/roomtype/RoomTypeFormViewModel.kt | 151 ++++++----- .../ui/roomtype/RoomTypeListViewModel.kt | 96 +++---- .../ui/users/PropertyUsersViewModel.kt | 248 ++++++++---------- .../ui/users/UserDirectoryViewModel.kt | 122 ++++----- 28 files changed, 1077 insertions(+), 1038 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/core/viewmodel/ViewModelRequest.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityEditorScreen.kt diff --git a/app/src/main/java/com/android/trisolarispms/core/viewmodel/ViewModelRequest.kt b/app/src/main/java/com/android/trisolarispms/core/viewmodel/ViewModelRequest.kt new file mode 100644 index 0000000..f8fb0e4 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/core/viewmodel/ViewModelRequest.kt @@ -0,0 +1,25 @@ +package com.android.trisolarispms.core.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal fun ViewModel.launchRequest( + state: MutableStateFlow, + setLoading: (S) -> S, + setError: (S, String) -> S, + defaultError: String, + block: suspend () -> Unit +) { + viewModelScope.launch { + state.update(setLoading) + try { + block() + } catch (e: Exception) { + val message = e.localizedMessage ?: defaultError + state.update { current -> setError(current, message) } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt index 9152b9a..79e0283 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt @@ -1,12 +1,11 @@ package com.android.trisolarispms.ui.guest import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody @@ -21,25 +20,25 @@ class GuestSignatureViewModel : ViewModel() { fun uploadSignature(propertyId: String, guestId: String, svg: String, onDone: () -> Unit) { if (propertyId.isBlank() || guestId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val requestBody = svg.toRequestBody("image/svg+xml".toMediaType()) - val part = MultipartBody.Part.createFormData( - name = "file", - filename = "signature.svg", - body = requestBody - ) - val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, error = null) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Upload failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Upload failed") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Upload failed" + ) { + val api = ApiClient.create() + val requestBody = svg.toRequestBody("image/svg+xml".toMediaType()) + val part = MultipartBody.Part.createFormData( + name = "file", + filename = "signature.svg", + body = requestBody + ) + val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part) + if (response.isSuccessful) { + _state.update { it.copy(isLoading = false, error = null) } + onDone() + } else { + _state.update { it.copy(isLoading = false, error = "Upload failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt index eab11ea..04d83d2 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt @@ -1,15 +1,14 @@ package com.android.trisolarispms.ui.home import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.core.auth.Role import com.android.trisolarispms.core.auth.toRoles import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch data class HomeJoinPropertyState( val isLoading: Boolean = false, @@ -34,31 +33,31 @@ class HomeJoinPropertyViewModel : ViewModel() { _state.update { it.copy(error = "Code must be 6 digits", message = null) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().joinAccessCode( - PropertyAccessCodeJoinRequest( - propertyId = trimmedPropertyId, - code = digits - ) + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null, message = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message, message = null) }, + defaultError = "Join failed" + ) { + val response = ApiClient.create().joinAccessCode( + PropertyAccessCodeJoinRequest( + propertyId = trimmedPropertyId, + code = digits ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { - it.copy( - isLoading = false, - message = "Joined property", - error = null, - joinedPropertyId = body.propertyId ?: trimmedPropertyId, - joinedRoles = body.roles.toRoles() - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + message = "Joined property", + error = null, + joinedPropertyId = body.propertyId ?: trimmedPropertyId, + joinedRoles = body.roles.toRoles() + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt index a64be35..afdbc41 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt @@ -8,6 +8,7 @@ import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.razorpay.RazorpayQrScreen import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen +import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen @@ -18,23 +19,56 @@ internal fun renderStayFlowRoutes( authViewModel: AuthViewModel, authz: AuthzPolicy ): Boolean { + @Composable + fun renderManageRoomStaySelectRoute( + propertyId: String, + fromAt: String, + toAt: String?, + onNext: (List) -> Unit + ) { + ManageRoomStaySelectScreen( + propertyId = propertyId, + bookingFromAt = fromAt, + bookingToAt = toAt, + onBack = { refs.openActiveRoomStays(propertyId) }, + onNext = { rooms -> + refs.selectedManageRooms.value = rooms + onNext(rooms) + } + ) + } + + @Composable + fun renderManageRoomStayRatesRoute( + propertyId: String, + bookingId: String, + fromAt: String, + toAt: String?, + onBack: () -> Unit, + onDone: () -> Unit + ) { + ManageRoomStayRatesScreen( + propertyId = propertyId, + bookingId = bookingId, + checkInAt = fromAt, + checkOutAt = toAt, + selectedRooms = refs.selectedManageRooms.value, + onBack = onBack, + onDone = onDone + ) + } + when (val currentRoute = refs.currentRoute) { is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen( propertyId = currentRoute.propertyId, propertyName = currentRoute.propertyName, onBack = { - val blockBack = authz.shouldBlockBackToHome( - propertyId = currentRoute.propertyId, - propertyCount = state.propertyRoles.size - ) + val blockBack = shouldBlockHomeBack(authz, state, currentRoute.propertyId) if (!blockBack) { refs.route.value = AppRoute.Home } }, - showBack = !authz.shouldBlockBackToHome( - propertyId = currentRoute.propertyId, - propertyCount = state.propertyRoles.size - ), + showBack = !shouldBlockHomeBack(authz, state, currentRoute.propertyId), onViewRooms = { refs.route.value = AppRoute.Rooms(currentRoute.propertyId) }, onCreateBooking = { refs.route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId), @@ -91,62 +125,54 @@ internal fun renderStayFlowRoutes( } ) - is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( + is AppRoute.ManageRoomStaySelect -> renderManageRoomStaySelectRoute( propertyId = currentRoute.propertyId, - bookingFromAt = currentRoute.fromAt, - bookingToAt = currentRoute.toAt, - onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }, - onNext = { rooms -> - refs.selectedManageRooms.value = rooms - refs.route.value = AppRoute.ManageRoomStayRates( - propertyId = currentRoute.propertyId, - bookingId = currentRoute.bookingId, - fromAt = currentRoute.fromAt, - toAt = currentRoute.toAt - ) - } - ) + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt + ) { + refs.route.value = AppRoute.ManageRoomStayRates( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt + ) + } - is AppRoute.ManageRoomStaySelectFromBooking -> ManageRoomStaySelectScreen( + is AppRoute.ManageRoomStaySelectFromBooking -> renderManageRoomStaySelectRoute( propertyId = currentRoute.propertyId, - bookingFromAt = currentRoute.fromAt, - bookingToAt = currentRoute.toAt, - onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }, - onNext = { rooms -> - refs.selectedManageRooms.value = rooms - refs.route.value = AppRoute.ManageRoomStayRatesFromBooking( - propertyId = currentRoute.propertyId, - bookingId = currentRoute.bookingId, - guestId = currentRoute.guestId, - fromAt = currentRoute.fromAt, - toAt = currentRoute.toAt - ) - } - ) + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt + ) { + refs.route.value = AppRoute.ManageRoomStayRatesFromBooking( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + guestId = currentRoute.guestId, + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt + ) + } - is AppRoute.ManageRoomStayRates -> ManageRoomStayRatesScreen( + is AppRoute.ManageRoomStayRates -> renderManageRoomStayRatesRoute( propertyId = currentRoute.propertyId, bookingId = currentRoute.bookingId, - checkInAt = currentRoute.fromAt, - checkOutAt = currentRoute.toAt, - selectedRooms = refs.selectedManageRooms.value, + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt, onBack = { refs.route.value = AppRoute.ManageRoomStaySelect( - currentRoute.propertyId, - currentRoute.bookingId, - currentRoute.fromAt, - currentRoute.toAt + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt ) }, onDone = { refs.openActiveRoomStays(currentRoute.propertyId) } ) - is AppRoute.ManageRoomStayRatesFromBooking -> ManageRoomStayRatesScreen( + is AppRoute.ManageRoomStayRatesFromBooking -> renderManageRoomStayRatesRoute( propertyId = currentRoute.propertyId, bookingId = currentRoute.bookingId, - checkInAt = currentRoute.fromAt, - checkOutAt = currentRoute.toAt, - selectedRooms = refs.selectedManageRooms.value, + fromAt = currentRoute.fromAt, + toAt = currentRoute.toAt, onBack = { refs.route.value = AppRoute.ManageRoomStaySelectFromBooking( currentRoute.propertyId, @@ -169,3 +195,9 @@ internal fun renderStayFlowRoutes( } return true } + +private fun shouldBlockHomeBack(authz: AuthzPolicy, state: AuthUiState, propertyId: String): Boolean = + authz.shouldBlockBackToHome( + propertyId = propertyId, + propertyCount = state.propertyRoles.size + ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt index a9dbbcd..13eb2c9 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt @@ -3,49 +3,33 @@ package com.android.trisolarispms.ui.payment import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.RazorpayRefundRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import retrofit2.Response class BookingPaymentsViewModel : ViewModel() { private val _state = MutableStateFlow(BookingPaymentsState()) val state: StateFlow = _state fun load(propertyId: String, bookingId: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val api = ApiClient.create() - val response = api.listPayments(propertyId, bookingId) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { - it.copy( - isLoading = false, - payments = body, - error = null, - message = null - ) - } - } else { - _state.update { - it.copy( - isLoading = false, - error = "Load failed: ${response.code()}", - message = null - ) - } - } - } catch (e: Exception) { + runPaymentAction(defaultError = "Load failed") { api -> + val response = api.listPayments(propertyId, bookingId) + val body = response.body() + if (response.isSuccessful && body != null) { _state.update { it.copy( isLoading = false, - error = e.localizedMessage ?: "Load failed", + payments = body, + error = null, message = null ) } + } else { + setActionFailure("Load", response) } } } @@ -55,82 +39,46 @@ class BookingPaymentsViewModel : ViewModel() { _state.update { it.copy(error = "Amount must be greater than 0", message = null) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val api = ApiClient.create() - val response = api.createPayment( - propertyId = propertyId, - bookingId = bookingId, - body = com.android.trisolarispms.data.api.model.PaymentCreateRequest(amount = amount) - ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { current -> - current.copy( - isLoading = false, - payments = listOf(body) + current.payments, - error = null, - message = "Cash payment added" - ) - } - } else { - _state.update { - it.copy( - isLoading = false, - error = "Create failed: ${response.code()}", - message = null - ) - } - } - } catch (e: Exception) { - _state.update { - it.copy( + runPaymentAction(defaultError = "Create failed") { api -> + val response = api.createPayment( + propertyId = propertyId, + bookingId = bookingId, + body = com.android.trisolarispms.data.api.model.PaymentCreateRequest(amount = amount) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { current -> + current.copy( isLoading = false, - error = e.localizedMessage ?: "Create failed", - message = null + payments = listOf(body) + current.payments, + error = null, + message = "Cash payment added" ) } + } else { + setActionFailure("Create", response) } } } fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val api = ApiClient.create() - val response = api.deletePayment( - propertyId = propertyId, - bookingId = bookingId, - paymentId = paymentId - ) - if (response.isSuccessful) { - _state.update { current -> - current.copy( - isLoading = false, - payments = current.payments.filterNot { it.id == paymentId }, - error = null, - message = "Cash payment deleted" - ) - } - } else { - _state.update { - it.copy( - isLoading = false, - error = "Delete failed: ${response.code()}", - message = null - ) - } - } - } catch (e: Exception) { - _state.update { - it.copy( + runPaymentAction(defaultError = "Delete failed") { api -> + val response = api.deletePayment( + propertyId = propertyId, + bookingId = bookingId, + paymentId = paymentId + ) + if (response.isSuccessful) { + _state.update { current -> + current.copy( isLoading = false, - error = e.localizedMessage ?: "Delete failed", - message = null + payments = current.payments.filterNot { it.id == paymentId }, + error = null, + message = "Cash payment deleted" ) } + } else { + setActionFailure("Delete", response) } } } @@ -151,48 +99,60 @@ class BookingPaymentsViewModel : ViewModel() { _state.update { it.copy(error = "Missing payment ID", message = null) } return } + runPaymentAction(defaultError = "Refund failed") { api -> + val response = api.refundRazorpayPayment( + propertyId = propertyId, + bookingId = bookingId, + body = RazorpayRefundRequest( + paymentId = paymentId, + razorpayPaymentId = razorpayPaymentId, + amount = amount, + notes = notes?.takeIf { it.isNotBlank() } + ) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + error = null, + message = "Refund processed" + ) + } + load(propertyId, bookingId) + } else { + setActionFailure("Refund", response) + } + } + } + + private fun runPaymentAction( + defaultError: String, + block: suspend (ApiService) -> Unit + ) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null, message = null) } try { - val api = ApiClient.create() - val response = api.refundRazorpayPayment( - propertyId = propertyId, - bookingId = bookingId, - body = RazorpayRefundRequest( - paymentId = paymentId, - razorpayPaymentId = razorpayPaymentId, - amount = amount, - notes = notes?.takeIf { it.isNotBlank() } - ) - ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { - it.copy( - isLoading = false, - error = null, - message = "Refund processed" - ) - } - load(propertyId, bookingId) - } else { - _state.update { - it.copy( - isLoading = false, - error = "Refund failed: ${response.code()}", - message = null - ) - } - } + block(ApiClient.create()) } catch (e: Exception) { _state.update { it.copy( isLoading = false, - error = e.localizedMessage ?: "Refund failed", + error = e.localizedMessage ?: defaultError, message = null ) } } } } + + private fun setActionFailure(action: String, response: Response<*>) { + _state.update { + it.copy( + isLoading = false, + error = "$action failed: ${response.code()}", + message = null + ) + } + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt index ecbac39..59681f8 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt @@ -1,13 +1,12 @@ package com.android.trisolarispms.ui.property import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.model.PropertyCreateRequest +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class AddPropertyViewModel : ViewModel() { private val _state = MutableStateFlow(AddPropertyState()) @@ -56,31 +55,31 @@ class AddPropertyViewModel : ViewModel() { return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val body = PropertyCreateRequest( - code = null, - name = name, - addressText = state.value.addressText.takeIf { it.isNotBlank() }, - timezone = state.value.timezone.takeIf { it.isNotBlank() }, - currency = state.value.currency.takeIf { it.isNotBlank() } - ) - val response = api.createProperty(body) - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - createdPropertyId = response.body()?.id, - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Create failed" + ) { + val api = ApiClient.create() + val body = PropertyCreateRequest( + code = null, + name = name, + addressText = state.value.addressText.takeIf { it.isNotBlank() }, + timezone = state.value.timezone.takeIf { it.isNotBlank() }, + currency = state.value.currency.takeIf { it.isNotBlank() } + ) + val response = api.createProperty(body) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + createdPropertyId = response.body()?.id, + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt index 9ea528c..928e6a9 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt @@ -1,36 +1,35 @@ package com.android.trisolarispms.ui.property import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class PropertyListViewModel : ViewModel() { private val _state = MutableStateFlow(PropertyListState()) val state: StateFlow = _state fun refresh() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listProperties() - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - properties = response.body().orEmpty(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.listProperties() + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + properties = response.body().orEmpty(), + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt index 978a263..3522652 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt @@ -3,12 +3,14 @@ package com.android.trisolarispms.ui.room import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.RoomCreateRequest import com.android.trisolarispms.data.api.model.RoomUpdateRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import retrofit2.Response class RoomFormViewModel : ViewModel() { private val _state = MutableStateFlow(RoomFormState()) @@ -58,70 +60,22 @@ class RoomFormViewModel : ViewModel() { fun onNotesChange(value: String) = _state.update { it.copy(notes = value, error = null) } fun submitCreate(propertyId: String, onDone: () -> Unit) { - val roomNumberText = state.value.roomNumber.trim() - val roomTypeCode = state.value.roomTypeCode.trim() - val roomNumber = roomNumberText.toIntOrNull() - if (roomNumber == null || roomTypeCode.isBlank()) { - _state.update { it.copy(error = "Room number must be a number and room type is required") } - return - } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val body = RoomCreateRequest( - roomNumber = roomNumber, - floor = state.value.floor.trim().toIntOrNull(), - roomTypeCode = roomTypeCode, - hasNfc = state.value.hasNfc, - active = state.value.active, - maintenance = state.value.maintenance, - notes = state.value.notes.takeIf { it.isNotBlank() } - ) - val response = api.createRoom(propertyId, body) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, success = true) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } - } + val input = readValidatedInput() ?: return + submitMutation( + action = "Create", + onDone = onDone + ) { api -> + api.createRoom(propertyId, input.toCreateRequest()) } } fun submitUpdate(propertyId: String, roomId: String, onDone: () -> Unit) { - val roomNumberText = state.value.roomNumber.trim() - val roomTypeCode = state.value.roomTypeCode.trim() - val roomNumber = roomNumberText.toIntOrNull() - if (roomNumber == null || roomTypeCode.isBlank()) { - _state.update { it.copy(error = "Room number must be a number and room type is required") } - return - } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val body = RoomUpdateRequest( - roomNumber = roomNumber, - floor = state.value.floor.trim().toIntOrNull(), - roomTypeCode = roomTypeCode, - hasNfc = state.value.hasNfc, - active = state.value.active, - maintenance = state.value.maintenance, - notes = state.value.notes.takeIf { it.isNotBlank() } - ) - val response = api.updateRoom(propertyId, roomId, body) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, success = true) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } - } + val input = readValidatedInput() ?: return + submitMutation( + action = "Update", + onDone = onDone + ) { api -> + api.updateRoom(propertyId, roomId, input.toUpdateRequest()) } } @@ -130,19 +84,80 @@ class RoomFormViewModel : ViewModel() { _state.update { it.copy(error = "Room ID is missing") } return } + submitMutation( + action = "Delete", + onDone = onDone + ) { api -> + api.deleteRoom(propertyId, roomId) + } + } + + private data class ValidRoomInput( + val roomNumber: Int, + val floor: Int?, + val roomTypeCode: String, + val hasNfc: Boolean, + val active: Boolean, + val maintenance: Boolean, + val notes: String? + ) { + fun toCreateRequest(): RoomCreateRequest = RoomCreateRequest( + roomNumber = roomNumber, + floor = floor, + roomTypeCode = roomTypeCode, + hasNfc = hasNfc, + active = active, + maintenance = maintenance, + notes = notes + ) + + fun toUpdateRequest(): RoomUpdateRequest = RoomUpdateRequest( + roomNumber = roomNumber, + floor = floor, + roomTypeCode = roomTypeCode, + hasNfc = hasNfc, + active = active, + maintenance = maintenance, + notes = notes + ) + } + + private fun readValidatedInput(): ValidRoomInput? { + val current = state.value + val roomNumber = current.roomNumber.trim().toIntOrNull() + val roomTypeCode = current.roomTypeCode.trim() + if (roomNumber == null || roomTypeCode.isBlank()) { + _state.update { it.copy(error = "Room number must be a number and room type is required") } + return null + } + return ValidRoomInput( + roomNumber = roomNumber, + floor = current.floor.trim().toIntOrNull(), + roomTypeCode = roomTypeCode, + hasNfc = current.hasNfc, + active = current.active, + maintenance = current.maintenance, + notes = current.notes.takeIf { it.isNotBlank() } + ) + } + + private fun submitMutation( + action: String, + onDone: () -> Unit, + call: suspend (ApiService) -> Response<*> + ) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } try { - val api = ApiClient.create() - val response = api.deleteRoom(propertyId, roomId) + val response = call(ApiClient.create()) if (response.isSuccessful) { _state.update { it.copy(isLoading = false, success = true) } onDone() } else { - _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } + _state.update { it.copy(isLoading = false, error = "$action failed: ${response.code()}") } } } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") } + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "$action failed") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt index b379f96..40be9cf 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt @@ -1,12 +1,11 @@ package com.android.trisolarispms.ui.room import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class RoomListViewModel : ViewModel() { private val _state = MutableStateFlow(RoomListState()) @@ -14,35 +13,35 @@ class RoomListViewModel : ViewModel() { fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) { if (propertyId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) } - try { - val api = ApiClient.create() - val trimmedCode = roomTypeCode?.trim().orEmpty() - val response = if (trimmedCode.isNotBlank()) { - api.listRoomsByType( - propertyId = propertyId, - roomTypeCode = trimmedCode, - availableOnly = if (showAll) false else true + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val trimmedCode = roomTypeCode?.trim().orEmpty() + val response = if (trimmedCode.isNotBlank()) { + api.listRoomsByType( + propertyId = propertyId, + roomTypeCode = trimmedCode, + availableOnly = !showAll + ) + } else if (showAll) { + api.listRooms(propertyId) + } else { + api.listAvailableRooms(propertyId) + } + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + rooms = response.body().orEmpty(), + error = null ) - } else if (showAll) { - api.listRooms(propertyId) - } else { - api.listAvailableRooms(propertyId) } - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - rooms = response.body().orEmpty(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt index 2f38073..7289c03 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt @@ -1,34 +1,12 @@ package com.android.trisolarispms.ui.roomimage -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Done -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @Composable -@OptIn(ExperimentalMaterial3Api::class) fun AddImageTagScreen( onBack: () -> Unit, onSave: () -> Unit, @@ -40,41 +18,12 @@ fun AddImageTagScreen( viewModel.reset() } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Add Tag") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { viewModel.submitCreate(onSave) }) { - Icon(Icons.Default.Done, contentDescription = "Save") - } - }, - colors = TopAppBarDefaults.topAppBarColors() - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp), - verticalArrangement = Arrangement.Top - ) { - OutlinedTextField( - value = state.name, - onValueChange = viewModel::onNameChange, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth() - ) - state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(text = it, color = MaterialTheme.colorScheme.error) - } - } - } + ImageTagFormScreen( + title = "Add Tag", + name = state.name, + error = state.error, + onNameChange = viewModel::onNameChange, + onBack = onBack, + onSave = { viewModel.submitCreate(onSave) } + ) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt index 3170e80..fa6311c 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt @@ -1,35 +1,13 @@ package com.android.trisolarispms.ui.roomimage -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Done -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.android.trisolarispms.data.api.model.RoomImageTagDto @Composable -@OptIn(ExperimentalMaterial3Api::class) fun EditImageTagScreen( tag: RoomImageTagDto, onBack: () -> Unit, @@ -42,41 +20,12 @@ fun EditImageTagScreen( viewModel.setTag(tag) } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Edit Tag") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { viewModel.submitUpdate(tag.id.orEmpty(), onSave) }) { - Icon(Icons.Default.Done, contentDescription = "Save") - } - }, - colors = TopAppBarDefaults.topAppBarColors() - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp), - verticalArrangement = Arrangement.Top - ) { - OutlinedTextField( - value = state.name, - onValueChange = viewModel::onNameChange, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth() - ) - state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(text = it, color = MaterialTheme.colorScheme.error) - } - } - } + ImageTagFormScreen( + title = "Edit Tag", + name = state.name, + error = state.error, + onNameChange = viewModel::onNameChange, + onBack = onBack, + onSave = { viewModel.submitUpdate(tag.id.orEmpty(), onSave) } + ) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormScreen.kt new file mode 100644 index 0000000..e1cbfa9 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormScreen.kt @@ -0,0 +1,73 @@ +package com.android.trisolarispms.ui.roomimage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun ImageTagFormScreen( + title: String, + name: String, + error: String?, + onNameChange: (String) -> Unit, + onBack: () -> Unit, + onSave: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onSave) { + Icon(Icons.Default.Done, contentDescription = "Save") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth() + ) + error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt index d86a1ea..0c56d0e 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt @@ -3,11 +3,13 @@ package com.android.trisolarispms.ui.roomimage import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.RoomImageTagDto import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import retrofit2.Response class ImageTagFormViewModel : ViewModel() { private val _state = MutableStateFlow(ImageTagFormState()) @@ -26,47 +28,57 @@ class ImageTagFormViewModel : ViewModel() { } fun submitCreate(onDone: () -> Unit) { - val name = state.value.name.trim() - if (name.isBlank()) { - _state.update { it.copy(error = "Name is required") } - return - } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.createImageTag(RoomImageTagDto(name = name)) - if (response.isSuccessful) { - _state.update { ImageTagFormState(success = true) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } - } + val payload = readValidatedPayload() ?: return + submitMutation( + action = "Create", + onDone = onDone, + resetOnSuccess = true + ) { api -> + api.createImageTag(payload) } } fun submitUpdate(tagId: String, onDone: () -> Unit) { + val payload = readValidatedPayload() ?: return + submitMutation( + action = "Update", + onDone = onDone, + resetOnSuccess = false + ) { api -> + api.updateImageTag(tagId, payload) + } + } + + private fun readValidatedPayload(): RoomImageTagDto? { val name = state.value.name.trim() if (name.isBlank()) { _state.update { it.copy(error = "Name is required") } - return + return null } + return RoomImageTagDto(name = name) + } + + private fun submitMutation( + action: String, + onDone: () -> Unit, + resetOnSuccess: Boolean, + call: suspend (ApiService) -> Response<*> + ) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } try { - val api = ApiClient.create() - val response = api.updateImageTag(tagId, RoomImageTagDto(name = name)) + val response = call(ApiClient.create()) if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, success = true) } + _state.update { + if (resetOnSuccess) ImageTagFormState(success = true) + else it.copy(isLoading = false, success = true) + } onDone() } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + _state.update { it.copy(isLoading = false, error = "$action failed: ${response.code()}") } } } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "$action failed") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt index b05a061..e24f88a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt @@ -1,60 +1,59 @@ package com.android.trisolarispms.ui.roomimage import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class ImageTagViewModel : ViewModel() { private val _state = MutableStateFlow(ImageTagState()) val state: StateFlow = _state fun load() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listImageTags() - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - tags = response.body().orEmpty(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.listImageTags() + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + tags = response.body().orEmpty(), + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } fun delete(tagId: String) { if (tagId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.deleteImageTag(tagId) - if (response.isSuccessful) { - _state.update { current -> - current.copy( - isLoading = false, - tags = current.tags.filterNot { it.id == tagId }, - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Delete failed" + ) { + val api = ApiClient.create() + val response = api.deleteImageTag(tagId) + if (response.isSuccessful) { + _state.update { current -> + current.copy( + isLoading = false, + tags = current.tags.filterNot { it.id == tagId }, + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt index d18f047..9978e6e 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt @@ -1,12 +1,11 @@ package com.android.trisolarispms.ui.roomstay import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class ActiveRoomStaysViewModel : ViewModel() { private val _state = MutableStateFlow(ActiveRoomStaysState()) @@ -14,26 +13,26 @@ class ActiveRoomStaysViewModel : ViewModel() { fun load(propertyId: String) { if (propertyId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val activeResponse = api.listActiveRoomStays(propertyId) - val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN") - if (activeResponse.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - items = activeResponse.body().orEmpty(), - checkedInBookings = bookingsResponse.body().orEmpty(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${activeResponse.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val activeResponse = api.listActiveRoomStays(propertyId) + val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN") + if (activeResponse.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + items = activeResponse.body().orEmpty(), + checkedInBookings = bookingsResponse.body().orEmpty(), + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${activeResponse.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt index b8ff7c0..dd2fb11 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt @@ -5,13 +5,14 @@ import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiConstants import com.android.trisolarispms.data.api.model.BookingDetailsResponse +import com.android.trisolarispms.core.viewmodel.launchRequest import com.google.gson.Gson import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.delay import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import okhttp3.Request import okhttp3.sse.EventSource import okhttp3.sse.EventSourceListener @@ -30,24 +31,24 @@ class BookingDetailsViewModel : ViewModel() { fun load(propertyId: String, bookingId: String) { if (propertyId.isBlank() || bookingId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.getBookingDetails(propertyId, bookingId) - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - details = response.body(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.getBookingDetails(propertyId, bookingId) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + details = response.body(), + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt index 56913da..1e1ae15 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt @@ -1,12 +1,11 @@ package com.android.trisolarispms.ui.roomstay import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class BookingRoomStaysViewModel : ViewModel() { private val _state = MutableStateFlow(BookingRoomStaysState()) @@ -18,25 +17,25 @@ class BookingRoomStaysViewModel : ViewModel() { fun load(propertyId: String, bookingId: String) { if (propertyId.isBlank() || bookingId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listActiveRoomStays(propertyId) - if (response.isSuccessful) { - val filtered = response.body().orEmpty().filter { it.bookingId == bookingId } - _state.update { - it.copy( - isLoading = false, - stays = filtered, - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.listActiveRoomStays(propertyId) + if (response.isSuccessful) { + val filtered = response.body().orEmpty().filter { it.bookingId == bookingId } + _state.update { + it.copy( + isLoading = false, + stays = filtered, + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt index 48795c1..ba08947 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt @@ -1,14 +1,13 @@ package com.android.trisolarispms.ui.roomstay import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest import com.android.trisolarispms.data.api.model.BookingBulkCheckInStayRequest +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class ManageRoomStayRatesViewModel : ViewModel() { private val _state = MutableStateFlow(ManageRoomStayRatesState()) @@ -79,34 +78,34 @@ class ManageRoomStayRatesViewModel : ViewModel() { if (propertyId.isBlank() || bookingId.isBlank() || checkInAt.isBlank()) return val items = _state.value.items if (items.isEmpty()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val stays = items.map { item -> - BookingBulkCheckInStayRequest( - roomId = item.roomId, - checkInAt = checkInAt, - checkOutAt = checkOutAt, - nightlyRate = item.nightlyRate, - rateSource = "MANUAL", - ratePlanCode = item.ratePlanCode, - currency = item.currency - ) - } - val response = api.bulkCheckIn( - propertyId = propertyId, - bookingId = bookingId, - body = BookingBulkCheckInRequest(stays = stays) + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Update failed" + ) { + val api = ApiClient.create() + val stays = items.map { item -> + BookingBulkCheckInStayRequest( + roomId = item.roomId, + checkInAt = checkInAt, + checkOutAt = checkOutAt, + nightlyRate = item.nightlyRate, + rateSource = "MANUAL", + ratePlanCode = item.ratePlanCode, + currency = item.currency ) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, error = null) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + } + val response = api.bulkCheckIn( + propertyId = propertyId, + bookingId = bookingId, + body = BookingBulkCheckInRequest(stays = stays) + ) + if (response.isSuccessful) { + _state.update { it.copy(isLoading = false, error = null) } + onDone() + } else { + _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt index 805183c..eb91041 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt @@ -1,12 +1,11 @@ package com.android.trisolarispms.ui.roomstay import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class ManageRoomStaySelectViewModel : ViewModel() { private val _state = MutableStateFlow(ManageRoomStaySelectState()) @@ -14,24 +13,24 @@ class ManageRoomStaySelectViewModel : ViewModel() { fun load(propertyId: String, from: String, to: String) { if (propertyId.isBlank() || from.isBlank() || to.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to) - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - rooms = response.body().orEmpty(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + rooms = response.body().orEmpty(), + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt index edea759..24c32ab 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt @@ -10,13 +10,12 @@ fun AddAmenityScreen( viewModel: AmenityFormViewModel = viewModel(), amenityListViewModel: AmenityListViewModel = viewModel() ) { - androidx.compose.runtime.LaunchedEffect(Unit) { - viewModel.resetForm(preserveOptions = true) - } - AmenityFormScreen( + AmenityEditorScreen( title = "Add Amenity", + setupKey = Unit, onBack = onBack, onSaveClick = { viewModel.submitCreate(onSave) }, + setupForm = { viewModel.resetForm(preserveOptions = true) }, viewModel = viewModel, amenityListViewModel = amenityListViewModel ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityEditorScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityEditorScreen.kt new file mode 100644 index 0000000..883191e --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityEditorScreen.kt @@ -0,0 +1,27 @@ +package com.android.trisolarispms.ui.roomtype + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +internal fun AmenityEditorScreen( + title: String, + setupKey: Any?, + onBack: () -> Unit, + onSaveClick: () -> Unit, + setupForm: () -> Unit, + viewModel: AmenityFormViewModel = viewModel(), + amenityListViewModel: AmenityListViewModel = viewModel() +) { + LaunchedEffect(setupKey) { + setupForm() + } + AmenityFormScreen( + title = title, + onBack = onBack, + onSaveClick = onSaveClick, + viewModel = viewModel, + amenityListViewModel = amenityListViewModel + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt index 2f3cbc0..af4ec91 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt @@ -3,12 +3,14 @@ package com.android.trisolarispms.ui.roomtype import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.AmenityCreateRequest import com.android.trisolarispms.data.api.model.AmenityUpdateRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import retrofit2.Response class AmenityFormViewModel : ViewModel() { private val _state = MutableStateFlow(AmenityFormState()) @@ -88,62 +90,75 @@ class AmenityFormViewModel : ViewModel() { } fun submitCreate(onDone: () -> Unit) { - val name = state.value.name.trim() - if (name.isBlank()) { - _state.update { it.copy(error = "Name is required") } - return - } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val iconKey = state.value.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG") - val api = ApiClient.create() - val response = api.createAmenity( - AmenityCreateRequest( - name = name, - category = state.value.category.trim().ifBlank { null }, - iconKey = iconKey.ifBlank { null } - ) - ) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, success = true) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } - } + val payload = readValidatedPayload() ?: return + submitMutation( + action = "Create", + onDone = onDone + ) { api -> + api.createAmenity(payload.toCreateRequest()) } } fun submitUpdate(amenityId: String, onDone: () -> Unit) { - val name = state.value.name.trim() + val payload = readValidatedPayload() ?: return + submitMutation( + action = "Update", + onDone = onDone + ) { api -> + api.updateAmenity(amenityId, payload.toUpdateRequest()) + } + } + + private data class AmenityPayload( + val name: String, + val category: String?, + val iconKey: String? + ) { + fun toCreateRequest(): AmenityCreateRequest = AmenityCreateRequest( + name = name, + category = category, + iconKey = iconKey + ) + + fun toUpdateRequest(): AmenityUpdateRequest = AmenityUpdateRequest( + name = name, + category = category, + iconKey = iconKey + ) + } + + private fun readValidatedPayload(): AmenityPayload? { + val current = state.value + val name = current.name.trim() if (name.isBlank()) { _state.update { it.copy(error = "Name is required") } - return + return null } + val iconKey = current.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG") + return AmenityPayload( + name = name, + category = current.category.trim().ifBlank { null }, + iconKey = iconKey.ifBlank { null } + ) + } + + private fun submitMutation( + action: String, + onDone: () -> Unit, + call: suspend (ApiService) -> Response<*> + ) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } try { - val iconKey = state.value.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG") - val api = ApiClient.create() - val response = api.updateAmenity( - amenityId, - AmenityUpdateRequest( - name = name, - category = state.value.category.trim().ifBlank { null }, - iconKey = iconKey.ifBlank { null } - ) - ) + val response = call(ApiClient.create()) if (response.isSuccessful) { _state.update { it.copy(isLoading = false, success = true) } onDone() } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + _state.update { it.copy(isLoading = false, error = "$action failed: ${response.code()}") } } } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "$action failed") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt index bed0231..0670476 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt @@ -1,60 +1,59 @@ package com.android.trisolarispms.ui.roomtype import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class AmenityListViewModel : ViewModel() { private val _state = MutableStateFlow(AmenityListState()) val state: StateFlow = _state fun load() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listAmenities() - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - items = response.body().orEmpty(), - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.listAmenities() + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + items = response.body().orEmpty(), + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } fun deleteAmenity(amenityId: String) { if (amenityId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.deleteAmenity(amenityId) - if (response.isSuccessful) { - _state.update { current -> - current.copy( - isLoading = false, - items = current.items.filterNot { it.id == amenityId }, - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Delete failed" + ) { + val api = ApiClient.create() + val response = api.deleteAmenity(amenityId) + if (response.isSuccessful) { + _state.update { current -> + current.copy( + isLoading = false, + items = current.items.filterNot { it.id == amenityId }, + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt index 86d7dd1..476b8e9 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt @@ -1,7 +1,6 @@ package com.android.trisolarispms.ui.roomtype import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.viewmodel.compose.viewModel import com.android.trisolarispms.data.api.model.AmenityDto @@ -13,16 +12,15 @@ fun EditAmenityScreen( viewModel: AmenityFormViewModel = viewModel(), amenityListViewModel: AmenityListViewModel = viewModel() ) { - LaunchedEffect(amenity.id) { - viewModel.setAmenity(amenity) - } - AmenityFormScreen( + AmenityEditorScreen( title = "Edit Amenity", + setupKey = amenity.id, onBack = onBack, onSaveClick = { val id = amenity.id.orEmpty() viewModel.submitUpdate(id, onSave) }, + setupForm = { viewModel.setAmenity(amenity) }, viewModel = viewModel, amenityListViewModel = amenityListViewModel ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt index 6b771b2..6213573 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt @@ -3,12 +3,14 @@ package com.android.trisolarispms.ui.roomtype import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.RoomTypeCreateRequest import com.android.trisolarispms.data.api.model.RoomTypeUpdateRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import retrofit2.Response class RoomTypeFormViewModel : ViewModel() { private val _state = MutableStateFlow(RoomTypeFormState()) @@ -48,70 +50,22 @@ class RoomTypeFormViewModel : ViewModel() { fun onAliasesChange(value: String) = _state.update { it.copy(otaAliases = value, error = null) } fun submit(propertyId: String, onDone: () -> Unit) { - val code = state.value.code.trim() - val name = state.value.name.trim() - if (code.isBlank() || name.isBlank()) { - _state.update { it.copy(error = "Code and name are required") } - return - } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val body = RoomTypeCreateRequest( - code = code, - name = name, - baseOccupancy = state.value.baseOccupancy.toIntOrNull(), - maxOccupancy = state.value.maxOccupancy.toIntOrNull(), - sqFeet = state.value.sqFeet.toIntOrNull(), - bathroomSqFeet = state.value.bathroomSqFeet.toIntOrNull(), - amenityIds = state.value.amenityIds.toList().ifEmpty { null }, - otaAliases = state.value.otaAliases.split(',').map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null } - ) - val response = api.createRoomType(propertyId, body) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, success = true) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } - } + val payload = readValidatedPayload() ?: return + submitMutation( + action = "Create", + onDone = onDone + ) { api -> + api.createRoomType(propertyId, payload.toCreateRequest()) } } fun submitUpdate(propertyId: String, roomTypeId: String, onDone: () -> Unit) { - val code = state.value.code.trim() - val name = state.value.name.trim() - if (code.isBlank() || name.isBlank()) { - _state.update { it.copy(error = "Code and name are required") } - return - } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val body = RoomTypeUpdateRequest( - code = code, - name = name, - baseOccupancy = state.value.baseOccupancy.toIntOrNull(), - maxOccupancy = state.value.maxOccupancy.toIntOrNull(), - sqFeet = state.value.sqFeet.toIntOrNull(), - bathroomSqFeet = state.value.bathroomSqFeet.toIntOrNull(), - amenityIds = state.value.amenityIds.toList().ifEmpty { null }, - otaAliases = state.value.otaAliases.split(',').map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null } - ) - val response = api.updateRoomType(propertyId, roomTypeId, body) - if (response.isSuccessful) { - _state.update { it.copy(isLoading = false, success = true) } - onDone() - } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } - } + val payload = readValidatedPayload() ?: return + submitMutation( + action = "Update", + onDone = onDone + ) { api -> + api.updateRoomType(propertyId, roomTypeId, payload.toUpdateRequest()) } } @@ -120,19 +74,88 @@ class RoomTypeFormViewModel : ViewModel() { _state.update { it.copy(error = "Room type ID is missing") } return } + submitMutation( + action = "Delete", + onDone = onDone + ) { api -> + api.deleteRoomType(propertyId, roomTypeId) + } + } + + private data class RoomTypePayload( + val code: String, + val name: String, + val baseOccupancy: Int?, + val maxOccupancy: Int?, + val sqFeet: Int?, + val bathroomSqFeet: Int?, + val amenityIds: List?, + val otaAliases: List? + ) { + fun toCreateRequest(): RoomTypeCreateRequest = RoomTypeCreateRequest( + code = code, + name = name, + baseOccupancy = baseOccupancy, + maxOccupancy = maxOccupancy, + sqFeet = sqFeet, + bathroomSqFeet = bathroomSqFeet, + amenityIds = amenityIds, + otaAliases = otaAliases + ) + + fun toUpdateRequest(): RoomTypeUpdateRequest = RoomTypeUpdateRequest( + code = code, + name = name, + baseOccupancy = baseOccupancy, + maxOccupancy = maxOccupancy, + sqFeet = sqFeet, + bathroomSqFeet = bathroomSqFeet, + amenityIds = amenityIds, + otaAliases = otaAliases + ) + } + + private fun readValidatedPayload(): RoomTypePayload? { + val current = state.value + val code = current.code.trim() + val name = current.name.trim() + if (code.isBlank() || name.isBlank()) { + _state.update { it.copy(error = "Code and name are required") } + return null + } + return RoomTypePayload( + code = code, + name = name, + baseOccupancy = current.baseOccupancy.toIntOrNull(), + maxOccupancy = current.maxOccupancy.toIntOrNull(), + sqFeet = current.sqFeet.toIntOrNull(), + bathroomSqFeet = current.bathroomSqFeet.toIntOrNull(), + amenityIds = current.amenityIds.toList().ifEmpty { null }, + otaAliases = current.otaAliases + .split(',') + .map { it.trim() } + .filter { it.isNotBlank() } + .ifEmpty { null } + ) + } + + private fun submitMutation( + action: String, + onDone: () -> Unit, + call: suspend (ApiService) -> Response<*> + ) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } try { - val api = ApiClient.create() - val response = api.deleteRoomType(propertyId, roomTypeId) + val response = call(ApiClient.create()) if (response.isSuccessful) { _state.update { it.copy(isLoading = false, success = true) } onDone() } else { - _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } + _state.update { it.copy(isLoading = false, error = "$action failed: ${response.code()}") } } } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") } + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "$action failed") } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt index 8c339ac..d190224 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt @@ -3,6 +3,9 @@ package com.android.trisolarispms.ui.roomtype import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService +import com.android.trisolarispms.data.api.model.RoomTypeDto +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -14,76 +17,81 @@ class RoomTypeListViewModel : ViewModel() { fun load(propertyId: String) { if (propertyId.isBlank()) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listRoomTypes(propertyId) - if (response.isSuccessful) { - val items = response.body().orEmpty() - _state.update { - it.copy( - isLoading = false, - items = items, - error = null - ) - } - loadRoomTypeImages(propertyId, items) - loadRoomTypeAvailableCounts(propertyId, items) - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "Load failed" + ) { + val api = ApiClient.create() + val response = api.listRoomTypes(propertyId) + if (response.isSuccessful) { + val items = response.body().orEmpty() + _state.update { + it.copy( + isLoading = false, + items = items, + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + loadRoomTypeImages(propertyId, items) + loadRoomTypeAvailableCounts(propertyId, items) + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } - private fun loadRoomTypeImages(propertyId: String, items: List) { + private fun loadRoomTypeImages(propertyId: String, items: List) { viewModelScope.launch { - val api = ApiClient.create() - val updates = mutableMapOf() - for (item in items) { - val code = item.code?.trim().orEmpty() - if (code.isBlank()) continue - try { + val updates = buildByTypeCodeMap( + items = items, + fetch = { api, code -> val response = api.listRoomTypeImages(propertyId, code) - if (response.isSuccessful) { - updates[code] = response.body().orEmpty().firstOrNull() - } - } catch (_: Exception) { - // Ignore per-item failures to avoid blocking the list. + if (response.isSuccessful) response.body().orEmpty().firstOrNull() else null } - } + ) if (updates.isNotEmpty()) { _state.update { it.copy(imageByTypeCode = it.imageByTypeCode + updates) } } } } - private fun loadRoomTypeAvailableCounts(propertyId: String, items: List) { + private fun loadRoomTypeAvailableCounts(propertyId: String, items: List) { viewModelScope.launch { - val api = ApiClient.create() - val updates = mutableMapOf() - for (item in items) { - val code = item.code?.trim().orEmpty() - if (code.isBlank()) continue - try { + val updates = buildByTypeCodeMap( + items = items, + fetch = { api, code -> val response = api.listRoomsByType( propertyId = propertyId, roomTypeCode = code, availableOnly = true ) if (response.isSuccessful) { - updates[code] = response.body().orEmpty().size + response.body().orEmpty().size + } else { + null } - } catch (_: Exception) { - // Ignore per-item failures. } - } + ) if (updates.isNotEmpty()) { _state.update { it.copy(availableCountByTypeCode = it.availableCountByTypeCode + updates) } } } } + + private suspend fun buildByTypeCodeMap( + items: List, + fetch: suspend (ApiService, String) -> T? + ): Map { + val api = ApiClient.create() + val updates = mutableMapOf() + for (item in items) { + val code = item.code?.trim().orEmpty() + if (code.isBlank()) continue + val value = runCatching { fetch(api, code) }.getOrNull() ?: continue + updates[code] = value + } + return updates + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt index 04d3d4b..9f82a18 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt @@ -3,9 +3,10 @@ package com.android.trisolarispms.ui.users import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.PropertyAccessCodeCreateRequest import com.android.trisolarispms.data.api.model.PropertyAccessCodeResponse -import com.android.trisolarispms.data.api.model.PropertyUserResponse +import com.android.trisolarispms.data.api.model.PropertyUserDetailsResponse import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest import com.android.trisolarispms.data.api.model.PropertyUserDisabledRequest @@ -28,33 +29,7 @@ class PropertyUsersViewModel : ViewModel() { val state: StateFlow = _state fun loadAll(propertyId: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().searchPropertyUsers( - propertyId = propertyId, - phone = null - ) - val body = response.body() - if (response.isSuccessful && body != null) { - val mapped = body.map { - PropertyUserUi( - userId = it.userId, - roles = it.roles, - name = it.name, - phoneE164 = it.phoneE164, - disabled = it.disabled, - superAdmin = it.superAdmin - ) - } - _state.update { it.copy(isLoading = false, users = mapped, error = null) } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } - } - } + fetchUsers(propertyId = propertyId, phone = null, action = "Load") } fun searchUsers(propertyId: String, phoneInput: String) { @@ -63,33 +38,7 @@ class PropertyUsersViewModel : ViewModel() { _state.update { it.copy(users = emptyList(), error = null, isLoading = false, message = null) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().searchPropertyUsers( - propertyId = propertyId, - phone = digits - ) - val body = response.body() - if (response.isSuccessful && body != null) { - val mapped = body.map { - PropertyUserUi( - userId = it.userId, - roles = it.roles, - name = it.name, - phoneE164 = it.phoneE164, - disabled = it.disabled, - superAdmin = it.superAdmin - ) - } - _state.update { it.copy(isLoading = false, users = mapped, error = null) } - } else { - _state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Search failed") } - } - } + fetchUsers(propertyId = propertyId, phone = digits, action = "Search") } fun createAccessCode(propertyId: String, roles: List) { @@ -97,27 +46,22 @@ class PropertyUsersViewModel : ViewModel() { _state.update { it.copy(error = "Select at least one role", message = null) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().createAccessCode( - propertyId = propertyId, - body = PropertyAccessCodeCreateRequest(roles = roles) - ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { - it.copy( - isLoading = false, - accessCode = body, - message = "Access code created" - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } + runAction(defaultError = "Create failed") { api -> + val response = api.createAccessCode( + propertyId = propertyId, + body = PropertyAccessCodeCreateRequest(roles = roles) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + accessCode = body, + message = "Access code created" + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } } } } @@ -128,26 +72,21 @@ class PropertyUsersViewModel : ViewModel() { _state.update { it.copy(error = "Code must be 6 digits", message = null) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().joinAccessCode( - PropertyAccessCodeJoinRequest(propertyId = propertyId, code = digits) - ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { - it.copy( - isLoading = false, - message = "Joined property", - error = null - ) - } - } else { - _state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } + runAction(defaultError = "Join failed") { api -> + val response = api.joinAccessCode( + PropertyAccessCodeJoinRequest(propertyId = propertyId, code = digits) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + message = "Joined property", + error = null + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } } } } @@ -157,64 +96,50 @@ class PropertyUsersViewModel : ViewModel() { _state.update { it.copy(error = "Select at least one role", message = null) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().updateUserRoles( - propertyId = propertyId, - userId = userId, - body = UserRolesUpdateRequest(roles = roles) - ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { current -> - val updated = current.users.map { user -> - if (user.userId == userId) { - user.copy(roles = body.roles) - } else user - } - current.copy( - isLoading = false, - users = updated, - message = "Roles updated" - ) + runAction(defaultError = "Update failed") { api -> + val response = api.updateUserRoles( + propertyId = propertyId, + userId = userId, + body = UserRolesUpdateRequest(roles = roles) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { current -> + val updated = current.users.map { user -> + if (user.userId == userId) user.copy(roles = body.roles) else user } - } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + current.copy( + isLoading = false, + users = updated, + message = "Roles updated" + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } } } } fun updateDisabled(propertyId: String, userId: String, disabled: Boolean) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null, message = null) } - try { - val response = ApiClient.create().updateUserDisabled( - propertyId = propertyId, - userId = userId, - body = PropertyUserDisabledRequest(disabled = disabled) - ) - if (response.isSuccessful) { - _state.update { current -> - val updated = current.users.map { user -> - if (user.userId == userId) { - user.copy(disabled = disabled) - } else user - } - current.copy( - isLoading = false, - users = updated, - message = if (disabled) "User disabled" else "User enabled" - ) + runAction(defaultError = "Update failed") { api -> + val response = api.updateUserDisabled( + propertyId = propertyId, + userId = userId, + body = PropertyUserDisabledRequest(disabled = disabled) + ) + if (response.isSuccessful) { + _state.update { current -> + val updated = current.users.map { user -> + if (user.userId == userId) user.copy(disabled = disabled) else user } - } else { - _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + current.copy( + isLoading = false, + users = updated, + message = if (disabled) "User disabled" else "User enabled" + ) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + } else { + _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } } } } @@ -232,4 +157,41 @@ class PropertyUsersViewModel : ViewModel() { } } } + + private fun fetchUsers(propertyId: String, phone: String?, action: String) { + runAction(defaultError = "$action failed") { api -> + val response = api.searchPropertyUsers(propertyId = propertyId, phone = phone) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { it.copy(isLoading = false, users = mapPropertyUsers(body), error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "$action failed: ${response.code()}") } + } + } + } + + private fun mapPropertyUsers(body: List): List = body.map { + PropertyUserUi( + userId = it.userId, + roles = it.roles, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + + private fun runAction( + defaultError: String, + block: suspend (ApiService) -> Unit + ) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + block(ApiClient.create()) + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: defaultError) } + } + } + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt index 8cef723..55de246 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt @@ -3,6 +3,8 @@ package com.android.trisolarispms.ui.users import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.core.ApiService +import com.android.trisolarispms.core.viewmodel.launchRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -25,37 +27,11 @@ class UserDirectoryViewModel : ViewModel() { val state: StateFlow = _state fun loadAll(mode: UserDirectoryMode) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - when (mode) { - UserDirectoryMode.SuperAdmin -> { - val response = api.listUsers(null) - val users = response.body()?.toSuperAdminUsers() - if (response.isSuccessful && users != null) { - _state.update { it.copy(isLoading = false, users = users, error = null) } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } - } - } - is UserDirectoryMode.Property -> { - val response = api.searchPropertyUsers( - propertyId = mode.propertyId, - phone = null - ) - val users = response.body()?.toPropertyUsers() - if (response.isSuccessful && users != null) { - _state.update { it.copy(isLoading = false, users = users, error = null) } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } - } - } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } - } - } + runDirectoryQuery( + mode = mode, + phone = null, + action = "Load" + ) } fun search(mode: UserDirectoryMode, phoneInput: String) { @@ -64,39 +40,65 @@ class UserDirectoryViewModel : ViewModel() { _state.update { it.copy(users = emptyList(), error = null, isLoading = false) } return } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - when (mode) { - UserDirectoryMode.SuperAdmin -> { - val response = api.listUsers(digits) - val users = response.body()?.toSuperAdminUsers() - if (response.isSuccessful && users != null) { - _state.update { it.copy(isLoading = false, users = users, error = null) } - } else { - _state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") } - } - } - is UserDirectoryMode.Property -> { - val response = api.searchPropertyUsers( - propertyId = mode.propertyId, - phone = digits - ) - val users = response.body()?.toPropertyUsers() - if (response.isSuccessful && users != null) { - _state.update { it.copy(isLoading = false, users = users, error = null) } - } else { - _state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") } - } - } - } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Search failed") } + runDirectoryQuery( + mode = mode, + phone = digits, + action = "Search" + ) + } + + private data class UserQueryResult( + val users: List?, + val code: Int, + val isSuccessful: Boolean + ) + + private fun runDirectoryQuery( + mode: UserDirectoryMode, + phone: String?, + action: String + ) { + launchRequest( + state = _state, + setLoading = { it.copy(isLoading = true, error = null) }, + setError = { current, message -> current.copy(isLoading = false, error = message) }, + defaultError = "$action failed" + ) { + val result = queryUsers(ApiClient.create(), mode, phone) + if (result.isSuccessful && result.users != null) { + _state.update { it.copy(isLoading = false, users = result.users, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "$action failed: ${result.code}") } } } } + private suspend fun queryUsers( + api: ApiService, + mode: UserDirectoryMode, + phone: String? + ): UserQueryResult = when (mode) { + UserDirectoryMode.SuperAdmin -> { + val response = api.listUsers(phone) + UserQueryResult( + users = response.body()?.toSuperAdminUsers(), + code = response.code(), + isSuccessful = response.isSuccessful + ) + } + is UserDirectoryMode.Property -> { + val response = api.searchPropertyUsers( + propertyId = mode.propertyId, + phone = phone + ) + UserQueryResult( + users = response.body()?.toPropertyUsers(), + code = response.code(), + isSuccessful = response.isSuccessful + ) + } + } + private fun List.toSuperAdminUsers(): List = map {