From f9e95d79cd5f76b8bc8d7338b38eec72edac3e2b Mon Sep 17 00:00:00 2001 From: denwritescode Date: Wed, 15 Jul 2026 22:58:21 +0300 Subject: [PATCH 1/8] add compose-navigation + serialization deps --- ...ctive.office.client.kmp.feature.gradle.kts | 2 + clients/tablet/composeApp/build.gradle.kts | 4 ++ .../office/tablet/navigation/Routes.kt | 45 +++++++++++++++++++ .../tablet/navigation/SerializableNavType.kt | 36 +++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/Routes.kt create mode 100644 clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/SerializableNavType.kt diff --git a/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.feature.gradle.kts b/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.feature.gradle.kts index a4ae29bd4..b699b06d1 100644 --- a/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.feature.gradle.kts +++ b/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.feature.gradle.kts @@ -18,6 +18,8 @@ kotlin { implementation(libs.findLibrary("essenty.lifecycle").get()) implementation(libs.findLibrary("essenty.state.keeper").get()) + implementation(libs.findLibrary("jetbrains-lifecycle-viewmodel").get()) + implementation(libs.findLibrary("kotlin.coroutines.core").get()) implementation(libs.findBundle("koin").get()) diff --git a/clients/tablet/composeApp/build.gradle.kts b/clients/tablet/composeApp/build.gradle.kts index 6b41e8279..3da2cda71 100644 --- a/clients/tablet/composeApp/build.gradle.kts +++ b/clients/tablet/composeApp/build.gradle.kts @@ -50,6 +50,10 @@ kotlin { api(libs.calf.ui) api(libs.kotlinx.datetime) + implementation(libs.jetbrains.navigation.compose) + implementation(libs.jetbrains.lifecycle.viewmodel) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlin.coroutines.core) implementation(libs.bundles.koin) diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/Routes.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/Routes.kt new file mode 100644 index 000000000..79deb1382 --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/Routes.kt @@ -0,0 +1,45 @@ +package band.effective.office.tablet.navigation + +import band.effective.office.tablet.core.domain.model.EventInfo +import band.effective.office.tablet.core.domain.model.RoomInfo +import kotlinx.serialization.Serializable + +/** + * Type-safe compose-navigation routes for the tablet app. + * + * Full-screen destinations are hosted with `composable`; modal windows (and the date/time + * picker) with `dialog` — they are full-fledged navigation destinations, each its own dialog + * window, rather than state-driven overlays. + */ + +/** Settings screen — start destination when no room is configured yet. */ +@Serializable +object SettingsRoute + +/** Main room list / dashboard — start destination once settings exist. */ +@Serializable +object MainRoute + +/** Modal: release ("free up") the current room's event. */ +@Serializable +data class FreeRoomRoute(val event: EventInfo, val roomName: String) + +/** Modal: create/edit a booking. */ +@Serializable +data class BookingEditorRoute(val event: EventInfo, val room: String) + +/** Modal: quick booking of the nearest available room. */ +@Serializable +data class FastBookingRoute( + val minEventDuration: Int, + val selectedRoom: RoomInfo, + val rooms: List, +) + +/** + * Date/time picker for the booking editor. Carries no payload: it is pushed on top of + * [BookingEditorRoute] and shares that entry's `BookingEditorViewModel` (and its + * `dateTimePickerComponent`), so the picked date flows back through the shared state. + */ +@Serializable +object DateTimePickerRoute diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/SerializableNavType.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/SerializableNavType.kt new file mode 100644 index 000000000..95af2cf0e --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/SerializableNavType.kt @@ -0,0 +1,36 @@ +package band.effective.office.tablet.navigation + +import androidx.navigation.NavType +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.write +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * A generic [NavType] for any `@Serializable` type, used in the route `typeMap` so that + * complex payloads (`EventInfo`, `RoomInfo`, `List`) can travel inside type-safe routes. + * + * The value is JSON-encoded then URL-safe base64-encoded, so it is safe to embed in a route string. + */ +@OptIn(ExperimentalEncodingApi::class) +inline fun serializableNavType( + isNullableAllowed: Boolean = false, + json: Json = Json, +): NavType = object : NavType(isNullableAllowed) { + + override fun put(bundle: SavedState, key: String, value: T) { + bundle.write { putString(key, serializeAsValue(value)) } + } + + override fun get(bundle: SavedState, key: String): T = + parseValue(bundle.read { getString(key) }) + + override fun parseValue(value: String): T = + json.decodeFromString(Base64.UrlSafe.decode(value).decodeToString()) + + override fun serializeAsValue(value: T): String = + Base64.UrlSafe.encode(json.encodeToString(value).encodeToByteArray()) +} From 5725761a1b922a673571310376cb09687c5e0576 Mon Sep 17 00:00:00 2001 From: denwritescode Date: Wed, 15 Jul 2026 23:04:09 +0300 Subject: [PATCH 2/8] replace Decompose with compose-navigation + koin ViewModels --- .../effective/office/tablet/App.android.kt | 6 +- .../band/effective/office/tablet/App.kt | 55 ++++- .../office/tablet/di/KoinInitializer.kt | 4 + .../office/tablet/navigation/AppNavHost.kt | 154 ++++++++++++++ .../tablet/navigation/DialogBackgroundDim.kt | 46 ++++ .../band/effective/office/tablet/root/Root.kt | 59 ----- .../office/tablet/root/RootComponent.kt | 201 ------------------ .../composeApp/src/iosMain/kotlin/main.kt | 7 +- .../tablet/core/ui/common/ModalWindow.kt | 11 - .../bookingEditor/di/BookingEditorModule.kt | 37 ++++ .../presentation/BookingEditor.kt | 126 +++++------ ...Component.kt => BookingEditorViewModel.kt} | 86 +++----- .../datetimepicker/DateTimePicker.kt | 103 +++++++++ .../datetimepicker/DateTimePickerComponent.kt | 37 +++- .../datetimepicker/DateTimePickerModalView.kt | 128 ----------- .../components/DatePickerView.kt | 24 ++- .../components/TimePickerView.kt | 20 +- .../fastBooking/di/FastBookingModule.kt | 20 ++ .../fastBooking/presentation/FastBooking.kt | 121 +++++------ ...ngComponent.kt => FastBookingViewModel.kt} | 102 ++++----- .../feature/fastBooking/presentation/State.kt | 19 +- .../feature/main/di/MainScreenModule.kt | 10 + .../freeuproom/FreeSelectRoomView.kt | 37 ++-- ...omponent.kt => FreeSelectRoomViewModel.kt} | 32 +-- .../main/presentation/main/MainScreen.kt | 12 +- .../{MainComponent.kt => MainViewModel.kt} | 98 +++++---- .../main/navigation/ModalWindowsConfig.kt | 25 --- .../tablet/feature/settings/SettingsScreen.kt | 25 ++- ...tingsComponent.kt => SettingsViewModel.kt} | 53 ++--- .../feature/settings/di/SettingsModule.kt | 9 + .../tablet/feature/slot/di/SlotDiModule.kt | 22 ++ .../slot/presentation/SlotComponent.kt | 41 ++-- iosApp/iosApp.xcodeproj/project.pbxproj | 4 - iosApp/iosApp/AppDelegate.swift | 1 - iosApp/iosApp/Info.plist | 4 + iosApp/iosApp/RootHolder.swift | 29 --- iosApp/iosApp/RootView.swift | 4 +- iosApp/iosApp/iosApp.swift | 17 +- 38 files changed, 907 insertions(+), 882 deletions(-) create mode 100644 clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/AppNavHost.kt create mode 100644 clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/DialogBackgroundDim.kt delete mode 100644 clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/Root.kt delete mode 100644 clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt delete mode 100644 clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/ModalWindow.kt rename clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/{BookingEditorComponent.kt => BookingEditorViewModel.kt} (88%) create mode 100644 clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePicker.kt delete mode 100644 clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt create mode 100644 clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/di/FastBookingModule.kt rename clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/{FastBookingComponent.kt => FastBookingViewModel.kt} (71%) rename clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/{FreeSelectRoomComponent.kt => FreeSelectRoomViewModel.kt} (60%) rename clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/{MainComponent.kt => MainViewModel.kt} (83%) delete mode 100644 clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/navigation/ModalWindowsConfig.kt rename clients/tablet/feature/settings/src/commonMain/kotlin/band/effective/office/tablet/feature/settings/{SettingsComponent.kt => SettingsViewModel.kt} (51%) create mode 100644 clients/tablet/feature/settings/src/commonMain/kotlin/band/effective/office/tablet/feature/settings/di/SettingsModule.kt delete mode 100644 iosApp/iosApp/RootHolder.swift diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt index 2b032d2e3..730ac0493 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt @@ -7,12 +7,10 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.lifecycle.lifecycleScope -import band.effective.office.tablet.root.RootComponent import band.effective.office.tablet.time.TimeReceiver import band.effective.office.tablet.utils.KioskCommandBus import band.effective.office.tablet.utils.KioskLifecycleObserver import band.effective.office.tablet.utils.KioskManager -import com.arkivanov.decompose.defaultComponentContext val LocalKioskManager = staticCompositionLocalOf { null } @@ -30,11 +28,9 @@ class AppActivity : ComponentActivity() { lifecycle.addObserver(KioskLifecycleObserver(this, kioskManager, kioskCommandBus, lifecycleScope)) - val root = RootComponent(componentContext = defaultComponentContext()) - setContent { CompositionLocalProvider(LocalKioskManager provides kioskManager) { - App(root) + AppRoot() } } } diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/App.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/App.kt index 233cf06fd..e3b4582fb 100644 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/App.kt +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/App.kt @@ -1,20 +1,57 @@ package band.effective.office.tablet -import androidx.compose.runtime.Composable -import band.effective.office.tablet.core.ui.theme.AppTheme -import band.effective.office.tablet.root.Root -import band.effective.office.tablet.root.RootComponent +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import band.effective.office.tablet.components.VersionOverlay +import band.effective.office.tablet.core.domain.useCase.CheckSettingsUseCase +import band.effective.office.tablet.core.domain.useCase.ResourceDisposerUseCase +import band.effective.office.tablet.core.ui.theme.AppTheme +import band.effective.office.tablet.navigation.AppNavHost +import org.koin.compose.koinInject @Composable -fun App(rootComponent: RootComponent) { - AppTheme { - Box(modifier = Modifier.fillMaxSize()) { - Root(rootComponent) - VersionOverlay() +fun AppRoot() { + // Provide a root ViewModelStoreOwner so the NavHost/NavController and its ViewModels have an + // owner on every platform (Android's Activity already provides one; iOS/Aurora need this). + val rootViewModelStoreOwner = rememberRootViewModelStoreOwner() + + CompositionLocalProvider(LocalViewModelStoreOwner provides rootViewModelStoreOwner) { + AppTheme { + Box(modifier = Modifier.fillMaxSize()) { + val resourceDisposerUseCase = koinInject() + val checkSettingsUseCase = koinInject() + + LaunchedEffect(Unit) { resourceDisposerUseCase() } + + val startRoomConfigured = remember { checkSettingsUseCase().isNotEmpty() } + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + .systemBarsPadding() + ) { + AppNavHost(startRoomConfigured = startRoomConfigured) + } + VersionOverlay() + } } } } + +@Composable +private fun rememberRootViewModelStoreOwner(): ViewModelStoreOwner = remember { + object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = ViewModelStore() + } +} diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/KoinInitializer.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/KoinInitializer.kt index 21d67b7ca..1078708f6 100644 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/KoinInitializer.kt +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/KoinInitializer.kt @@ -3,7 +3,9 @@ package band.effective.office.tablet.di import band.effective.office.tablet.core.data.di.dataModule import band.effective.office.tablet.core.domain.di.domainModule import band.effective.office.tablet.feature.bookingEditor.di.bookingEditorModule +import band.effective.office.tablet.feature.fastBooking.di.fastBookingModule import band.effective.office.tablet.feature.main.di.mainScreenModule +import band.effective.office.tablet.feature.settings.di.settingsModule import band.effective.office.tablet.feature.slot.di.slotDiModule import org.koin.core.context.startKoin @@ -16,7 +18,9 @@ class KoinInitializer { dataModule, domainModule, mainScreenModule, + settingsModule, bookingEditorModule, + fastBookingModule, slotDiModule, ) } diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/AppNavHost.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/AppNavHost.kt new file mode 100644 index 000000000..3355687f9 --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/AppNavHost.kt @@ -0,0 +1,154 @@ +package band.effective.office.tablet.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import band.effective.office.tablet.core.domain.model.EventInfo +import band.effective.office.tablet.core.domain.model.RoomInfo +import band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditor +import band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditorViewModel +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePicker +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerComponent +import band.effective.office.tablet.feature.fastBooking.presentation.FastBooking +import band.effective.office.tablet.feature.fastBooking.presentation.FastBookingViewModel +import band.effective.office.tablet.feature.main.presentation.freeuproom.FreeSelectRoomView +import band.effective.office.tablet.feature.main.presentation.freeuproom.FreeSelectRoomViewModel +import band.effective.office.tablet.feature.main.presentation.main.MainNavEvent +import band.effective.office.tablet.feature.main.presentation.main.MainScreen +import band.effective.office.tablet.feature.settings.SettingsScreen +import kotlin.reflect.typeOf +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +/** Stateless nav constants for the modal `dialog<>` destinations (hoisted so they aren't + * re-allocated on recomposition). */ +private object ModalNav { + /** NavType map for routes carrying a single [EventInfo] payload. */ + val eventTypeMap = mapOf(typeOf() to serializableNavType()) + + /** NavType map for the fast-booking route (a [RoomInfo] and a list of them). */ + val fastBookingTypeMap = mapOf( + typeOf() to serializableNavType(), + typeOf>() to serializableNavType>(), + ) + + /** Modals fill the screen so [DialogBackgroundDim] can draw the full-screen dim behind the + * centered content. */ + val dialogProperties = DialogProperties(usePlatformDefaultWidth = false) +} + +/** + * The app's navigation graph. Two full-screen destinations (Settings/Main) and the modals — plus the + * date/time picker — as `dialog<>` destinations. Each modal is its own dialog window (not a + * state-driven overlay); the date/time picker is pushed on top of the booking editor and shares its + * ViewModel. + * + * @param startRoomConfigured whether a room is already configured (drives the start destination) + */ +@Composable +fun AppNavHost(startRoomConfigured: Boolean) { + val navController = rememberNavController() + val startDestination: Any = if (startRoomConfigured) MainRoute else SettingsRoute + + NavHost(navController = navController, startDestination = startDestination) { + composable { + SettingsScreen( + onNavigateToMain = { + navController.navigate(MainRoute) { + popUpTo { inclusive = true } + } + }, + ) + } + + composable { + MainScreen( + onNavigate = { event -> + when (event) { + is MainNavEvent.OpenFastBooking -> navController.navigate( + FastBookingRoute(event.minDuration, event.selectedRoom, event.rooms) + ) + + is MainNavEvent.OpenFreeRoom -> navController.navigate( + FreeRoomRoute(event.event, event.roomName) + ) + + is MainNavEvent.OpenBookingEditor -> navController.navigate( + BookingEditorRoute(event.event, event.room) + ) + } + }, + ) + } + + dialog(typeMap = ModalNav.eventTypeMap, dialogProperties = ModalNav.dialogProperties) { entry -> + val route = entry.toRoute() + val viewModel = koinViewModel { + parametersOf(route.event, route.roomName) + } + DialogBackgroundDim(onDismiss = { navController.popBackStack() }) { + FreeSelectRoomView( + onClose = { navController.popBackStack() }, + viewModel = viewModel, + ) + } + } + + dialog(typeMap = ModalNav.eventTypeMap, dialogProperties = ModalNav.dialogProperties) { entry -> + val route = entry.toRoute() + val viewModel = koinViewModel { + parametersOf(route.event, route.room) + } + DialogBackgroundDim(onDismiss = { navController.popBackStack() }) { + BookingEditor( + viewModel = viewModel, + onClose = { navController.popBackStack() }, + onOpenDateTimePicker = { navController.navigate(DateTimePickerRoute) }, + ) + } + } + + // Date/time picker: its own dialog window, pushed on top of the booking editor. It shares the + // BookingEditorRoute entry's ViewModel (and its dateTimePickerComponent), so the picked date + // flows straight back into the editor's state — no nav-result plumbing. + dialog(dialogProperties = ModalNav.dialogProperties) { + val editorEntry = remember { navController.getBackStackEntry() } + val viewModel = koinViewModel(viewModelStoreOwner = editorEntry) + val component = viewModel.dateTimePickerComponent + val pickerState by component.state.collectAsState() + val close: () -> Unit = { + component.sendIntent(DateTimePickerComponent.Intent.CloseModal) + navController.popBackStack() + } + DialogBackgroundDim(onDismiss = close) { + DateTimePicker( + currentDate = pickerState.currentDate, + onCloseRequest = close, + onChangeDate = { component.sendIntent(DateTimePickerComponent.Intent.OnChangeDate(it)) }, + onChangeTime = { component.sendIntent(DateTimePickerComponent.Intent.OnChangeTime(it)) }, + enableDateButton = pickerState.isEnabledButton, + ) + } + } + + dialog(typeMap = ModalNav.fastBookingTypeMap, dialogProperties = ModalNav.dialogProperties) { entry -> + val route = entry.toRoute() + val viewModel = koinViewModel { + parametersOf(route.minEventDuration, route.selectedRoom, route.rooms) + } + DialogBackgroundDim(onDismiss = { navController.popBackStack() }) { + FastBooking( + viewModel = viewModel, + onClose = { navController.popBackStack() }, + ) + } + } + } +} diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/DialogBackgroundDim.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/DialogBackgroundDim.kt new file mode 100644 index 000000000..f9842f95b --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/DialogBackgroundDim.kt @@ -0,0 +1,46 @@ +package band.effective.office.tablet.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * Full-screen dim (matching the pre-navigation-swap Decompose overlay of `Color.Black` at 0.9 alpha) + * behind a centered modal. Tapping the dim area dismisses; taps on the content are absorbed. Used by + * every `dialog<>` destination in [AppNavHost], which sets `usePlatformDefaultWidth = false` so the + * dialog window can fill the screen and let this scrim draw edge to edge. + */ +@Composable +fun DialogBackgroundDim( + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.9f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) { + content() + } + } +} diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/Root.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/Root.kt deleted file mode 100644 index 802a18f92..000000000 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/Root.kt +++ /dev/null @@ -1,59 +0,0 @@ -package band.effective.office.tablet.root - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditor -import band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditorComponent -import band.effective.office.tablet.feature.fastBooking.presentation.FastBooking -import band.effective.office.tablet.feature.fastBooking.presentation.FastBookingComponent -import band.effective.office.tablet.feature.main.presentation.freeuproom.FreeSelectRoomComponent -import band.effective.office.tablet.feature.main.presentation.freeuproom.FreeSelectRoomView -import band.effective.office.tablet.feature.main.presentation.main.MainScreen -import band.effective.office.tablet.feature.settings.SettingsScreen -import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.subscribeAsState - -@Composable -fun Root(component: RootComponent) { - Box( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .fillMaxSize() - .systemBarsPadding() - ) { - Children( - stack = component.childStack, - ) { child -> - when (val instance = child.instance) { - is RootComponent.Child.MainChild -> MainScreen(instance.component) - is RootComponent.Child.SettingsChild -> SettingsScreen(instance.component) - } - } - - // Display modal windows - val activeWindowSlot by component.modalWindowSlot.subscribeAsState() - Box( - modifier = Modifier - .fillMaxSize() - .background( - color = if (activeWindowSlot.child != null) - Color.Black.copy(alpha = 0.9f) - else Color.Transparent - ) - ) { - when (val activeComponent = activeWindowSlot.child?.instance) { - is FreeSelectRoomComponent -> FreeSelectRoomView(freeSelectRoomComponent = activeComponent) - is BookingEditorComponent -> BookingEditor(component = activeComponent) - is FastBookingComponent -> FastBooking(component = activeComponent) - else -> {} - } - } - } -} diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt deleted file mode 100644 index d50101048..000000000 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt +++ /dev/null @@ -1,201 +0,0 @@ -package band.effective.office.tablet.root - -import band.effective.office.tablet.core.domain.model.EventInfo -import band.effective.office.tablet.core.domain.model.RoomInfo -import band.effective.office.tablet.core.domain.useCase.CheckSettingsUseCase -import band.effective.office.tablet.core.domain.useCase.ResourceDisposerUseCase -import band.effective.office.tablet.core.ui.common.ModalWindow -import band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditorComponent -import band.effective.office.tablet.feature.fastBooking.presentation.FastBookingComponent -import band.effective.office.tablet.feature.main.presentation.freeuproom.FreeSelectRoomComponent -import band.effective.office.tablet.feature.main.presentation.main.MainComponent -import band.effective.office.tablet.feature.settings.SettingsComponent -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.childSlot -import com.arkivanov.decompose.router.slot.dismiss -import com.arkivanov.decompose.router.stack.ChildStack -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.replaceAll -import com.arkivanov.decompose.value.Value -import kotlinx.serialization.Serializable -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class RootComponent( - componentContext: ComponentContext, -) : ComponentContext by componentContext, KoinComponent { - - private val navigation = StackNavigation() - private val modalNavigation = SlotNavigation() - - private val resourceDisposerUseCase: ResourceDisposerUseCase by inject() - private val checkSettingsUseCase: CheckSettingsUseCase by inject() - - val modalWindowSlot = childSlot( - source = modalNavigation, - childFactory = ::createModalWindow, - serializer = ModalWindowsConfig.serializer(), - ) - - val childStack: Value> = childStack( - source = navigation, - initialConfiguration = if (checkSettingsUseCase().isEmpty()) { - Config.Settings - } else { - Config.Main - }, - handleBackButton = true, - serializer = kotlinx.serialization.serializer(), - childFactory = ::createChild, - ) - - init { - resourceDisposerUseCase() - } - - private fun createChild( - config: Config, - componentContext: ComponentContext - ): Child = when (config) { - is Config.Main -> { - Child.MainChild( - MainComponent( - componentContext = componentContext, - onFastBooking = ::handleFastBookingIntent, - onOpenFreeRoomModal = ::handleFreeRoomIntent, - openBookingDialog = ::openBookingDialog, - ) - ) - } - - is Config.Settings -> { - Child.SettingsChild( - SettingsComponent( - componentContext = componentContext, - onExitApp = { - // TODO - }, - onMainScreen = { - navigation.replaceAll(Config.Main) - }, - ) - ) - } - } - - private fun openBookingDialog(event: EventInfo, room: String) { - modalNavigation.activate( - ModalWindowsConfig.UpdateEvent( - event = event, - room = room, - ) - ) - } - - private fun handleFastBookingIntent(minDuration: Int, selectedRoom: RoomInfo, rooms: List) { - modalNavigation.activate( - ModalWindowsConfig.FastEvent( - minEventDuration = minDuration, - selectedRoom = selectedRoom, - rooms = rooms - ) - ) - } - - private fun handleFreeRoomIntent(currentEvent: EventInfo, roomName: String) { - modalNavigation.activate( - ModalWindowsConfig.FreeRoom( - event = currentEvent, - roomName = roomName - ) - ) - } - - private fun createModalWindow( - modalConfig: ModalWindowsConfig, - componentContext: ComponentContext - ): ModalWindow { - return when (modalConfig) { - is ModalWindowsConfig.FreeRoom -> createFreeRoomComponent(modalConfig, componentContext) - is ModalWindowsConfig.UpdateEvent -> createBookingEditorComponent( - modalConfig, - componentContext - ) - - is ModalWindowsConfig.FastEvent -> createFastBookingComponent( - modalConfig, - componentContext - ) - } - } - - private fun createFreeRoomComponent( - config: ModalWindowsConfig.FreeRoom, - componentContext: ComponentContext - ): FreeSelectRoomComponent { - return FreeSelectRoomComponent( - componentContext = componentContext, - eventInfo = config.event, - roomName = config.roomName, - onCloseRequest = modalNavigation::dismiss, - ) - } - - private fun createBookingEditorComponent( - config: ModalWindowsConfig.UpdateEvent, - componentContext: ComponentContext - ): BookingEditorComponent { - return BookingEditorComponent( - componentContext = componentContext, - initialEvent = config.event, - roomName = config.room, - onCloseRequest = modalNavigation::dismiss, - ) - } - - private fun createFastBookingComponent( - config: ModalWindowsConfig.FastEvent, - componentContext: ComponentContext - ): FastBookingComponent { - return FastBookingComponent( - componentContext = componentContext, - minEventDuration = config.minEventDuration, - selectedRoom = config.selectedRoom, - rooms = config.rooms, - onCloseRequest = modalNavigation::dismiss, - ) - } - - sealed class Child { - data class MainChild(val component: MainComponent) : Child() - data class SettingsChild(val component: SettingsComponent) : Child() - } - - @Serializable - sealed class Config { - @Serializable - object Settings : Config() - - @Serializable - object Main : Config() - } - - @Serializable - sealed class ModalWindowsConfig { - @Serializable - data class FreeRoom(val event: EventInfo, val roomName: String) : ModalWindowsConfig() - - @Serializable - data class UpdateEvent(val event: EventInfo, val room: String) : ModalWindowsConfig() - - @Serializable - data class FastEvent( - val minEventDuration: Int, - val selectedRoom: RoomInfo, - val rooms: List - ) : ModalWindowsConfig() - } -} diff --git a/clients/tablet/composeApp/src/iosMain/kotlin/main.kt b/clients/tablet/composeApp/src/iosMain/kotlin/main.kt index 7939a0649..34d604fa5 100644 --- a/clients/tablet/composeApp/src/iosMain/kotlin/main.kt +++ b/clients/tablet/composeApp/src/iosMain/kotlin/main.kt @@ -1,9 +1,8 @@ import androidx.compose.ui.window.ComposeUIViewController -import band.effective.office.tablet.App -import band.effective.office.tablet.root.RootComponent +import band.effective.office.tablet.AppRoot import platform.UIKit.UIViewController -fun rootViewController(root: RootComponent): UIViewController = +fun MainViewController(): UIViewController = ComposeUIViewController { - App(root) + AppRoot() } diff --git a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/ModalWindow.kt b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/ModalWindow.kt deleted file mode 100644 index ba28ba3e5..000000000 --- a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/ModalWindow.kt +++ /dev/null @@ -1,11 +0,0 @@ -package band.effective.office.tablet.core.ui.common - -/** - * Interface for modal windows - * - * This is a marker interface that can be implemented by components that represent modal windows. - */ -interface ModalWindow { - // This is an empty interface, likely used as a marker interface - // or as a placeholder for future implementation. -} \ No newline at end of file diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/di/BookingEditorModule.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/di/BookingEditorModule.kt index 7008b4097..881c364d0 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/di/BookingEditorModule.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/di/BookingEditorModule.kt @@ -1,10 +1,47 @@ package band.effective.office.tablet.feature.bookingEditor.di +import band.effective.office.tablet.core.domain.model.EventInfo +import band.effective.office.tablet.core.domain.useCase.CheckBookingUseCase +import band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditorViewModel +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerComponent +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerComponentFactory import band.effective.office.tablet.feature.bookingEditor.presentation.mapper.EventInfoMapper import band.effective.office.tablet.feature.bookingEditor.presentation.mapper.UpdateEventComponentStateToEventInfoMapper +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val bookingEditorModule = module { single { EventInfoMapper() } single { UpdateEventComponentStateToEventInfoMapper() } + + single { + val checkBookingUseCase = get() + DateTimePickerComponentFactory { scope, onSelectDate, onCloseRequest, event, room, duration, initDate -> + DateTimePickerComponent( + scope = scope, + onSelectDate = onSelectDate, + onCloseRequest = onCloseRequest, + event = event, + room = room, + duration = duration, + initDate = initDate, + checkBookingUseCase = checkBookingUseCase, + ) + } + } + + viewModel { (event: EventInfo, room: String) -> + BookingEditorViewModel( + organizersInfoUseCase = get(), + checkBookingUseCase = get(), + updateBookingUseCase = get(), + createBookingUseCase = get(), + deleteBookingUseCase = get(), + eventInfoMapper = get(), + stateToEventInfoMapper = get(), + dateTimePickerComponentFactory = get(), + initialEvent = event, + roomName = room, + ) + } } \ No newline at end of file diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt index 73baff4f3..eb560726f 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -25,17 +26,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import band.effective.office.tablet.core.domain.model.Organizer import band.effective.office.tablet.core.ui.button.SuccessButton import band.effective.office.tablet.core.ui.common.AlertButton import band.effective.office.tablet.core.ui.common.CrossButtonView import band.effective.office.tablet.core.ui.common.EventDurationView import band.effective.office.tablet.core.ui.common.EventOrganizerView -import band.effective.office.tablet.core.ui.common.FailureSelectRoomView import band.effective.office.tablet.core.ui.common.Loader -import band.effective.office.tablet.core.ui.common.SuccessSelectRoomView import band.effective.office.tablet.core.ui.date.DateTimeView import band.effective.office.tablet.core.ui.theme.h3 import band.effective.office.tablet.core.ui.theme.h6 @@ -49,81 +46,70 @@ import band.effective.office.tablet.feature.bookingEditor.error import band.effective.office.tablet.feature.bookingEditor.error_creating_event import band.effective.office.tablet.feature.bookingEditor.error_deleting_event import band.effective.office.tablet.feature.bookingEditor.is_time_in_past_error -import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerModalView import band.effective.office.tablet.feature.bookingEditor.update_button -import com.arkivanov.decompose.extensions.compose.stack.Children import kotlinx.datetime.LocalDateTime import org.jetbrains.compose.resources.stringResource +/** + * Booking create/edit modal. Hosted as a `dialog<>` destination — the surrounding dialog window + * is provided by the NavHost. + * + * @param viewModel manages the booking-editor state and logic + * @param onClose called when the flow requests to close (pops the dialog destination) + * @param onOpenDateTimePicker called when the date/time field is tapped; navigates to the + * `dialog` destination (which shares this editor's ViewModel). + */ @Composable fun BookingEditor( - component: BookingEditorComponent + viewModel: BookingEditorViewModel, + onClose: () -> Unit, + onOpenDateTimePicker: () -> Unit, ) { - val state by component.state.collectAsState() + val state by viewModel.state.collectAsState() - if (state.showSelectDate) { - DateTimePickerModalView( - dateTimePickerComponent = component.dateTimePickerComponent - ) - } else { - Children(stack = component.childStack, modifier = Modifier.padding(35.dp)) { - Dialog( - onDismissRequest = { component.sendIntent(Intent.OnClose) }, - properties = DialogProperties( - usePlatformDefaultWidth = it.instance != BookingEditorComponent.ModalConfig.FailureModal - ) - ) { - when (it.instance) { - BookingEditorComponent.ModalConfig.FailureModal -> FailureSelectRoomView( - onDismissRequest = { component.sendIntent(Intent.OnClose) }) - - BookingEditorComponent.ModalConfig.SuccessModal -> SuccessSelectRoomView( - roomName = component.roomName, - organizerName = state.selectOrganizer.fullName, - startTime = state.event.startTime, - finishTime = state.event.finishTime, - close = { component.sendIntent(Intent.OnClose) } - ) + LaunchedEffect(Unit) { + viewModel.closeEvents.collect { onClose() } + } - BookingEditorComponent.ModalConfig.UpdateModal -> BookingEditor( - onDismissRequest = { component.sendIntent(Intent.OnClose) }, - incrementData = { component.sendIntent(Intent.OnUpdateDate(1)) }, - decrementData = { component.sendIntent(Intent.OnUpdateDate(-1)) }, - onOpenDateTimePickerModal = { component.sendIntent(Intent.OnOpenSelectDateDialog) }, - incrementDuration = { component.sendIntent(Intent.OnUpdateLength(30)) }, - decrementDuration = { component.sendIntent(Intent.OnUpdateLength(-15)) }, - onExpandedChange = { component.sendIntent(Intent.OnExpandedChange) }, - onSelectOrganizer = { component.sendIntent(Intent.OnSelectOrganizer(it)) }, - selectData = state.date, - selectDuration = state.duration, - selectOrganizer = state.selectOrganizer, - organizers = state.selectOrganizers, - expended = state.expanded, - onUpdateEvent = { component.sendIntent(Intent.OnUpdateEvent(component.roomName)) }, - onDeleteEvent = { component.sendIntent(Intent.OnDeleteEvent) }, - inputText = state.inputText, - onInput = { component.sendIntent(Intent.OnInput(it)) }, - isInputError = state.isInputError, - onDoneInput = { component.sendIntent(Intent.OnDoneInput) }, - isDeleteError = state.isErrorDelete, - isDeleteLoad = state.isLoadDelete, - isUpdateError = state.isErrorUpdate, - isUpdateLoad = state.isLoadUpdate, - isCreateError = state.isErrorCreate, - isCreateLoad = state.isLoadCreate, - enableUpdateButton = state.enableUpdateButton, - isNewEvent = !state.isCreatedEvent(), - onCreateEvent = { component.sendIntent(Intent.OnBooking) }, - start = DateDisplayMapper.formatTime(state.event.startTime), - finish = DateDisplayMapper.formatTime(state.event.finishTime), - room = component.roomName, - isTimeInPastError = state.isTimeInPastError, - isEditable = state.event.isEditable, - canIncrementDuration = state.canIncrementDuration - ) - } - } - } + // Cap the form width so it stays a centered card (its fields are fillMaxWidth and would + // otherwise stretch edge-to-edge inside the full-screen modal scrim). + Box(modifier = Modifier.widthIn(max = 720.dp)) { + BookingEditor( + onDismissRequest = { viewModel.sendIntent(Intent.OnClose) }, + incrementData = { viewModel.sendIntent(Intent.OnUpdateDate(1)) }, + decrementData = { viewModel.sendIntent(Intent.OnUpdateDate(-1)) }, + onOpenDateTimePickerModal = onOpenDateTimePicker, + incrementDuration = { viewModel.sendIntent(Intent.OnUpdateLength(30)) }, + decrementDuration = { viewModel.sendIntent(Intent.OnUpdateLength(-15)) }, + onExpandedChange = { viewModel.sendIntent(Intent.OnExpandedChange) }, + onSelectOrganizer = { viewModel.sendIntent(Intent.OnSelectOrganizer(it)) }, + selectData = state.date, + selectDuration = state.duration, + selectOrganizer = state.selectOrganizer, + organizers = state.selectOrganizers, + expended = state.expanded, + onUpdateEvent = { viewModel.sendIntent(Intent.OnUpdateEvent(viewModel.roomName)) }, + onDeleteEvent = { viewModel.sendIntent(Intent.OnDeleteEvent) }, + inputText = state.inputText, + onInput = { viewModel.sendIntent(Intent.OnInput(it)) }, + isInputError = state.isInputError, + onDoneInput = { viewModel.sendIntent(Intent.OnDoneInput) }, + isDeleteError = state.isErrorDelete, + isDeleteLoad = state.isLoadDelete, + isUpdateError = state.isErrorUpdate, + isUpdateLoad = state.isLoadUpdate, + isCreateError = state.isErrorCreate, + isCreateLoad = state.isLoadCreate, + enableUpdateButton = state.enableUpdateButton, + isNewEvent = !state.isCreatedEvent(), + onCreateEvent = { viewModel.sendIntent(Intent.OnBooking) }, + start = DateDisplayMapper.formatTime(state.event.startTime), + finish = DateDisplayMapper.formatTime(state.event.finishTime), + room = viewModel.roomName, + isTimeInPastError = state.isTimeInPastError, + isEditable = state.event.isEditable, + canIncrementDuration = state.canIncrementDuration + ) } } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorViewModel.kt similarity index 88% rename from clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt rename to clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorViewModel.kt index f36b3321d..f82d666f6 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorViewModel.kt @@ -1,5 +1,7 @@ package band.effective.office.tablet.feature.bookingEditor.presentation +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import band.effective.office.shared.core.domain.Either import band.effective.office.tablet.core.domain.OfficeTime import band.effective.office.tablet.core.domain.model.EventInfo @@ -12,22 +14,20 @@ import band.effective.office.tablet.core.domain.useCase.OrganizersInfoUseCase import band.effective.office.tablet.core.domain.useCase.UpdateBookingUseCase import band.effective.office.shared.core.utils.asInstant import band.effective.office.shared.core.utils.asLocalDateTime -import band.effective.office.tablet.core.ui.common.ModalWindow -import band.effective.office.shared.core.utils.componentCoroutineScope import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerComponent +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerComponentFactory import band.effective.office.tablet.feature.bookingEditor.presentation.mapper.EventInfoMapper import band.effective.office.tablet.feature.bookingEditor.presentation.mapper.UpdateEventComponentStateToEventInfoMapper -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.childStack import io.github.aakira.napier.Napier import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -35,26 +35,32 @@ import kotlin.time.Clock import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -import kotlinx.serialization.Serializable -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject /** - * Component responsible for editing booking events. + * ViewModel responsible for editing booking events. * Handles creating new bookings and updating existing ones. */ const val DURATION_INCREMENT_MINUTES = 30 const val DELETE_SUCCESS_DELAY = 2000L -class BookingEditorComponent( - componentContext: ComponentContext, + +class BookingEditorViewModel( + private val organizersInfoUseCase: OrganizersInfoUseCase, + private val checkBookingUseCase: CheckBookingUseCase, + private val updateBookingUseCase: UpdateBookingUseCase, + private val createBookingUseCase: CreateBookingUseCase, + private val deleteBookingUseCase: DeleteBookingUseCase, + private val eventInfoMapper: EventInfoMapper, + private val stateToEventInfoMapper: UpdateEventComponentStateToEventInfoMapper, + private val dateTimePickerComponentFactory: DateTimePickerComponentFactory, initialEvent: EventInfo, val roomName: String, - private val onCloseRequest: () -> Unit, -) : ComponentContext by componentContext, KoinComponent, ModalWindow { +) : ViewModel() { + + private val coroutineScope = viewModelScope val dateTimePickerComponent: DateTimePickerComponent by lazy { - DateTimePickerComponent( - componentContext = componentContext, + dateTimePickerComponentFactory.create( + scope = coroutineScope, onSelectDate = { newDate -> updateEventDate(newDate) }, onCloseRequest = { mutableState.update { it.copy(showSelectDate = false) } }, event = initialEvent, @@ -64,44 +70,28 @@ class BookingEditorComponent( ) } - private val coroutineScope = componentCoroutineScope() - - // Use cases - private val organizersInfoUseCase: OrganizersInfoUseCase by inject() - private val checkBookingUseCase: CheckBookingUseCase by inject() - private val updateBookingUseCase: UpdateBookingUseCase by inject() - private val createBookingUseCase: CreateBookingUseCase by inject() - private val deleteBookingUseCase: DeleteBookingUseCase by inject() - - // Mappers - private val eventInfoMapper: EventInfoMapper by inject() - private val stateToEventInfoMapper: UpdateEventComponentStateToEventInfoMapper by inject() - // State management private val mutableState = MutableStateFlow(eventInfoMapper.mapToUpdateBookingState(initialEvent)) val state = mutableState.asStateFlow() - // Navigation - private val navigation = StackNavigation() - - val childStack = childStack( - source = navigation, - initialConfiguration = ModalConfig.UpdateModal, - serializer = ModalConfig.serializer(), - childFactory = { config, _ -> config }, - ) + private val closeChannel = Channel(Channel.BUFFERED) + val closeEvents = closeChannel.receiveAsFlow() init { loadOrganizers() } + private fun requestClose() { + coroutineScope.launch { closeChannel.send(Unit) } + } + /** * Handles intents from the UI */ fun sendIntent(intent: Intent) { when (intent) { Intent.OnBooking -> createNewEvent() - Intent.OnClose -> onCloseRequest() + Intent.OnClose -> requestClose() Intent.OnCloseSelectDateDialog -> closeSelectDateDialog() Intent.OnDeleteEvent -> deleteEvent() Intent.OnDoneInput -> finalizeOrganizerSelection() @@ -139,7 +129,7 @@ class BookingEditorComponent( }, successHandler = { mutableState.update { it.copy(isLoadUpdate = false) } - onCloseRequest() + requestClose() } ) } @@ -178,7 +168,7 @@ class BookingEditorComponent( is Either.Success -> { delay(DELETE_SUCCESS_DELAY) - onCloseRequest() + requestClose() } } } @@ -414,7 +404,7 @@ class BookingEditorComponent( }, successHandler = { mutableState.update { it.copy(isLoadCreate = false) } - onCloseRequest() + requestClose() } ) } @@ -458,27 +448,13 @@ class BookingEditorComponent( */ private fun toggleExpandedState() = mutableState.update { it.copy(expanded = !it.expanded) } - /** * Opens the select date dialog */ private fun openSelectDateDialog() = mutableState.update { it.copy(showSelectDate = true) } - /** * Closes the select date dialog */ private fun closeSelectDateDialog() = mutableState.update { it.copy(showSelectDate = false) } - - @Serializable - sealed interface ModalConfig { - @Serializable - object UpdateModal : ModalConfig - - @Serializable - object SuccessModal : ModalConfig - - @Serializable - object FailureModal : ModalConfig - } } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePicker.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePicker.kt new file mode 100644 index 000000000..f4e0ac385 --- /dev/null +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePicker.kt @@ -0,0 +1,103 @@ +package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults.buttonColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import band.effective.office.tablet.core.ui.common.CrossButtonView +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette +import band.effective.office.tablet.core.ui.theme.header8 +import band.effective.office.tablet.core.ui.time_booked +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components.DatePickerView +import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components.TimePickerView +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import org.jetbrains.compose.resources.stringResource + +/** + * Date/time picker UI. Rendered directly inside the `dialog` destination — that + * navigation dialog IS the Compose Dialog window, so there is no extra Dialog here. On iOS the dialog + * window's present animation masks the frame where calf's native UIKit picker hasn't applied our + * colors yet, and the pickers set all colors from the theme, so no delay/alpha/warm-up masking is + * needed. Plain `Box` (no `BoxWithConstraints`/`SubcomposeLayout`) — landscape layout only. + */ +@Composable +fun DateTimePicker( + currentDate: LocalDateTime, + onCloseRequest: () -> Unit, + onChangeDate: (LocalDate) -> Unit, + onChangeTime: (LocalTime) -> Unit, + enableDateButton: Boolean, +) { + Box( + modifier = Modifier + .fillMaxHeight(0.8f) + .fillMaxWidth(0.8f) + .clip(RoundedCornerShape(3)) + .background(LocalCustomColorsPalette.current.elevationBackground) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CrossButtonView( + onDismissRequest = onCloseRequest, + modifier = Modifier.fillMaxWidth(1f) + ) + Row( + modifier = Modifier.padding(10.dp).fillMaxHeight(0.8f), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TimePickerView( + modifier = Modifier.fillMaxWidth(0.3f), + currentDate = currentDate, + onSnap = onChangeTime + ) + Spacer(Modifier.width(40.dp)) + DatePickerView( + modifier = Modifier.fillMaxWidth(0.6f).fillMaxHeight(), + currentDate = currentDate, + onChangeDate = onChangeDate, + ) + } + Box(modifier = Modifier.fillMaxSize()) { + Button( + modifier = Modifier.align(Alignment.Center) + .fillMaxWidth(0.3f), + onClick = { onCloseRequest() }, + enabled = enableDateButton, + colors = buttonColors( + containerColor = LocalCustomColorsPalette.current.pressedPrimaryButton + ) + ) { + Text( + text = when (enableDateButton) { + true -> DateDisplayMapper.formatForPicker(currentDate) + false -> stringResource(band.effective.office.tablet.core.ui.Res.string.time_booked) + }, + style = header8, + color = LocalCustomColorsPalette.current.primaryTextAndIcon, + ) + } + } + } + } +} diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerComponent.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerComponent.kt index b4689561f..e2061ed59 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerComponent.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerComponent.kt @@ -5,11 +5,9 @@ import band.effective.office.tablet.core.domain.useCase.CheckBookingUseCase import band.effective.office.shared.core.utils.asInstant import band.effective.office.shared.core.utils.asLocalDateTime import band.effective.office.shared.core.utils.currentLocalDateTime -import band.effective.office.tablet.core.ui.common.ModalWindow -import band.effective.office.shared.core.utils.componentCoroutineScope -import com.arkivanov.decompose.ComponentContext import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,21 +17,24 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.Month -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +/** + * Presenter for the date/time picker. Owned by [band.effective.office.tablet.feature.bookingEditor.presentation.BookingEditorViewModel]; + * its lifecycle is bound to the parent's [scope] (i.e. `viewModelScope`). + * + * Created via [DateTimePickerComponentFactory] so the parent ViewModel supplies only the runtime + * params and never resolves this presenter's use case itself. + */ class DateTimePickerComponent( - private val componentContext: ComponentContext, + private val scope: CoroutineScope, private val onSelectDate: (LocalDateTime) -> Unit, private val onCloseRequest: () -> Unit, val event: EventInfo, val room: String, val duration: Int, val initDate: () -> LocalDateTime, -) : ComponentContext by componentContext, ModalWindow, KoinComponent { - - private val checkBookingUseCase: CheckBookingUseCase by inject() - private val scope = componentCoroutineScope() + private val checkBookingUseCase: CheckBookingUseCase, +) { private val mutableState = MutableStateFlow(State.default.copy(currentDate = initDate())) val state: StateFlow = mutableState.asStateFlow() @@ -132,3 +133,19 @@ class DateTimePickerComponent( return newInstant.asLocalDateTime } } + +/** + * Assisted-injection factory for [DateTimePickerComponent]: Koin supplies the use case, the caller + * supplies the runtime params. Provided by `bookingEditorModule`. + */ +fun interface DateTimePickerComponentFactory { + fun create( + scope: CoroutineScope, + onSelectDate: (LocalDateTime) -> Unit, + onCloseRequest: () -> Unit, + event: EventInfo, + room: String, + duration: Int, + initDate: () -> LocalDateTime, + ): DateTimePickerComponent +} diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt deleted file mode 100644 index 98a2a7841..000000000 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt +++ /dev/null @@ -1,128 +0,0 @@ -package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults.buttonColors -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import band.effective.office.tablet.core.ui.common.CrossButtonView -import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette -import band.effective.office.tablet.core.ui.theme.header8 -import band.effective.office.tablet.core.ui.time_booked -import band.effective.office.tablet.core.ui.utils.DateDisplayMapper -import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components.DatePickerView -import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components.TimePickerView -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime -import org.jetbrains.compose.resources.stringResource - -@Composable -fun DateTimePickerModalView( - dateTimePickerComponent: DateTimePickerComponent -) { - val stateDateTime by dateTimePickerComponent.state.collectAsState() - - DateTimePickerModalView( - currentDate = stateDateTime.currentDate, - onCloseRequest = { dateTimePickerComponent.sendIntent(DateTimePickerComponent.Intent.CloseModal) }, - onChangeDate = { - dateTimePickerComponent.sendIntent(DateTimePickerComponent.Intent.OnChangeDate(it)) - }, - onChangeTime = { - dateTimePickerComponent.sendIntent(DateTimePickerComponent.Intent.OnChangeTime(it)) - }, - enableDateButton = stateDateTime.isEnabledButton - ) -} - -@Composable -fun DateTimePickerModalView( - currentDate: LocalDateTime, - onCloseRequest: () -> Unit, - onChangeDate: (LocalDate) -> Unit, - onChangeTime: (LocalTime) -> Unit, - enableDateButton: Boolean -) { - Dialog( - onDismissRequest = onCloseRequest, - properties = DialogProperties( - usePlatformDefaultWidth = false - ) - ) { - Box( - modifier = Modifier - .fillMaxHeight(0.8f) - .fillMaxWidth(0.8f) - .clip(RoundedCornerShape(3)) - .background(LocalCustomColorsPalette.current.elevationBackground) - ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CrossButtonView( - onDismissRequest = onCloseRequest, - modifier = Modifier.fillMaxWidth(1f) - ) - Row( - modifier = Modifier.padding(10.dp).fillMaxHeight(0.8f), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TimePickerView( - modifier = Modifier.fillMaxWidth(0.3f), - currentDate = currentDate, - onSnap = onChangeTime - ) - Spacer(Modifier.width(40.dp)) - DatePickerView( - modifier = Modifier.fillMaxWidth(0.6f).fillMaxHeight(), - currentDate = currentDate, - onChangeDate = onChangeDate, - ) - } - Box(modifier = Modifier.fillMaxSize()) { - Button( - modifier = Modifier.align(Alignment.Center) - .fillMaxWidth(0.3f), - onClick = { - onCloseRequest() - }, - enabled = enableDateButton, - colors = buttonColors( - containerColor = LocalCustomColorsPalette.current.pressedPrimaryButton - ) - ) { - Text( - text = when (enableDateButton) { - true -> DateDisplayMapper.formatForPicker(currentDate) - false -> stringResource(band.effective.office.tablet.core.ui.Res.string.time_booked) - }, - style = header8, - color = LocalCustomColorsPalette.current.primaryTextAndIcon, - ) - } - } - } - } - } -} diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt index cfc085a9a..3b35d70c4 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt @@ -3,6 +3,7 @@ package band.effective.office.tablet.feature.bookingEditor.presentation.datetime import androidx.compose.foundation.layout.Column import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -41,13 +42,32 @@ fun DatePickerView( } } + val palette = LocalCustomColorsPalette.current + val accent = MaterialTheme.colorScheme.primary + val onAccent = MaterialTheme.colorScheme.onPrimary + Column { - // Display the date picker + // Display the date picker. All colors come from our theme so the (native, on iOS) picker + // matches the app immediately rather than briefly showing default system colors. AdaptiveDatePicker( state = state, modifier = modifier, colors = DatePickerDefaults.colors( - containerColor = LocalCustomColorsPalette.current.elevationBackground, + containerColor = palette.elevationBackground, + titleContentColor = palette.primaryTextAndIcon, + headlineContentColor = palette.primaryTextAndIcon, + weekdayContentColor = palette.secondaryTextAndIcon, + subheadContentColor = palette.primaryTextAndIcon, + navigationContentColor = accent, + yearContentColor = palette.primaryTextAndIcon, + currentYearContentColor = accent, + selectedYearContentColor = onAccent, + selectedYearContainerColor = accent, + dayContentColor = palette.primaryTextAndIcon, + selectedDayContentColor = onAccent, + selectedDayContainerColor = accent, + todayContentColor = accent, + todayDateBorderColor = accent, ), ) } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt index b13bc3637..8148674bf 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt @@ -1,6 +1,7 @@ package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TimePickerDefaults import androidx.compose.material3.TimePickerLayoutType import androidx.compose.runtime.Composable @@ -33,12 +34,29 @@ fun TimePickerView( onSnap(LocalTime(hour, minute, 0)) } + val palette = LocalCustomColorsPalette.current + val accent = MaterialTheme.colorScheme.primary + val onAccent = MaterialTheme.colorScheme.onPrimary + + // All colors from our theme so the (native, on iOS) picker matches the app from the first frame. AdaptiveTimePicker( state = state, modifier = modifier, layoutType = TimePickerLayoutType.Vertical, colors = TimePickerDefaults.colors( - containerColor = LocalCustomColorsPalette.current.elevationBackground, + containerColor = palette.elevationBackground, + clockDialColor = MaterialTheme.colorScheme.surface, + selectorColor = accent, + clockDialSelectedContentColor = onAccent, + clockDialUnselectedContentColor = palette.primaryTextAndIcon, + periodSelectorSelectedContainerColor = accent, + periodSelectorUnselectedContainerColor = MaterialTheme.colorScheme.surface, + periodSelectorSelectedContentColor = onAccent, + periodSelectorUnselectedContentColor = palette.primaryTextAndIcon, + timeSelectorSelectedContainerColor = accent, + timeSelectorUnselectedContainerColor = MaterialTheme.colorScheme.surface, + timeSelectorSelectedContentColor = onAccent, + timeSelectorUnselectedContentColor = palette.primaryTextAndIcon, ) ) } \ No newline at end of file diff --git a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/di/FastBookingModule.kt b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/di/FastBookingModule.kt new file mode 100644 index 000000000..87c160162 --- /dev/null +++ b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/di/FastBookingModule.kt @@ -0,0 +1,20 @@ +package band.effective.office.tablet.feature.fastBooking.di + +import band.effective.office.tablet.core.domain.model.RoomInfo +import band.effective.office.tablet.feature.fastBooking.presentation.FastBookingViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val fastBookingModule = module { + viewModel { (minEventDuration: Int, selectedRoom: RoomInfo, rooms: List) -> + FastBookingViewModel( + selectRoomUseCase = get(), + createFastBookingUseCase = get(), + deleteBookingUseCase = get(), + timerUseCase = get(), + minEventDuration = minEventDuration, + selectedRoom = selectedRoom, + rooms = rooms, + ) + } +} diff --git a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBooking.kt b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBooking.kt index 6a2eafdee..bb917bf06 100644 --- a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBooking.kt +++ b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBooking.kt @@ -15,16 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import band.effective.office.tablet.core.ui.Res import band.effective.office.tablet.core.ui.common.CrossButtonView import band.effective.office.tablet.core.ui.common.FailureFastSelectRoomView @@ -35,79 +33,74 @@ import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette import band.effective.office.tablet.core.ui.theme.h2 import band.effective.office.tablet.core.ui.theme.h4 import band.effective.office.tablet.core.ui.utils.DateDisplayMapper -import com.arkivanov.decompose.extensions.compose.stack.Children import org.jetbrains.compose.resources.stringResource /** * Main composable for the Fast Booking feature. - * Displays different views based on the current state of the component. + * Displays different views based on [FastBookingModal] in the current state. + * Hosted as a `dialog<>` destination — the surrounding dialog window is provided by the NavHost. * - * @param component The FastBookingComponent that manages the state and logic + * @param viewModel manages the fast-booking state and logic + * @param onClose called when the flow requests to close (pops the dialog destination) */ @Composable -fun FastBooking(component: FastBookingComponent) { - val state by component.state.collectAsState() +fun FastBooking( + viewModel: FastBookingViewModel, + onClose: () -> Unit, +) { + val state by viewModel.state.collectAsState() - Children(stack = component.childStack, modifier = Modifier.padding(35.dp)) { modal -> - Dialog( - onDismissRequest = { component.sendIntent(Intent.OnCloseWindowRequest) }, - properties = DialogProperties( - usePlatformDefaultWidth = modal.instance != FastBookingComponent.ModalConfig.LoadingModal - ) + LaunchedEffect(Unit) { + viewModel.closeEvents.collect { onClose() } + } + + Column( + modifier = Modifier.fillMaxSize().padding(35.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(50.dp)) + Text( + text = DateDisplayMapper.formatTime(state.currentTime), + style = MaterialTheme.typography.h2, + color = LocalCustomColorsPalette.current.primaryTextAndIcon + ) + Row( + modifier = Modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text( - text = DateDisplayMapper.formatTime(state.currentTime), - style = MaterialTheme.typography.h2, - color = LocalCustomColorsPalette.current.primaryTextAndIcon + when (val modal = state.modal) { + FastBookingModal.Loading -> LoadingView( + onDismissRequest = { viewModel.sendIntent(Intent.OnCloseWindowRequest) } ) - Row( - modifier = Modifier.fillMaxHeight(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - when (val modalInstance = modal.instance) { - FastBookingComponent.ModalConfig.LoadingModal -> LoadingView( - onDismissRequest = { component.sendIntent(Intent.OnCloseWindowRequest) } - ) - is FastBookingComponent.ModalConfig.FailureModal -> { - // Show failure view - either due to no available rooms or an error - if (state.isError) { - ErrorView(onDismissRequest = { component.sendIntent(Intent.OnCloseWindowRequest) }) - } else { - FailureFastSelectRoomView( - onDismissRequest = { component.sendIntent(Intent.OnCloseWindowRequest) }, - minutes = state.minutesLeft, - room = modalInstance.room - ) - } - } + is FastBookingModal.Failure -> { + // Show failure view - either due to no available rooms or an error + if (state.isError) { + ErrorView(onDismissRequest = { viewModel.sendIntent(Intent.OnCloseWindowRequest) }) + } else { + FailureFastSelectRoomView( + onDismissRequest = { viewModel.sendIntent(Intent.OnCloseWindowRequest) }, + minutes = state.minutesLeft, + room = modal.room + ) + } + } - is FastBookingComponent.ModalConfig.SuccessModal -> { - // Only show success view if there's no error - if (state.isError) { - ErrorView(onDismissRequest = { component.sendIntent(Intent.OnCloseWindowRequest) }) - } else { - SuccessFastSelectRoomView( - roomName = modalInstance.room, - finishTime = modalInstance.eventInfo.finishTime, - close = { component.sendIntent(Intent.OnCloseWindowRequest) }, - onFreeRoomRequest = { - component.sendIntent( - Intent.OnFreeSelectRequest( - it - ) - ) - }, - isLoading = state.isLoad - ) - } - } + is FastBookingModal.Success -> { + // Only show success view if there's no error + if (state.isError) { + ErrorView(onDismissRequest = { viewModel.sendIntent(Intent.OnCloseWindowRequest) }) + } else { + SuccessFastSelectRoomView( + roomName = modal.room, + finishTime = modal.eventInfo.finishTime, + close = { viewModel.sendIntent(Intent.OnCloseWindowRequest) }, + onFreeRoomRequest = { + viewModel.sendIntent(Intent.OnFreeSelectRequest(it)) + }, + isLoading = state.isLoad + ) } } } diff --git a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingViewModel.kt similarity index 71% rename from clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt rename to clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingViewModel.kt index 138b91eea..32e84bbeb 100644 --- a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt +++ b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingViewModel.kt @@ -1,5 +1,7 @@ package band.effective.office.tablet.feature.fastBooking.presentation +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import band.effective.office.shared.core.domain.Either import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.core.domain.model.RoomInfo @@ -12,44 +14,33 @@ import band.effective.office.shared.core.utils.asLocalDateTime import band.effective.office.shared.core.utils.cropSeconds import band.effective.office.shared.core.utils.currentInstant import band.effective.office.shared.core.utils.currentLocalDateTime -import band.effective.office.tablet.core.ui.common.ModalWindow -import band.effective.office.shared.core.utils.componentCoroutineScope -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.push import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import kotlin.time.Duration.Companion.minutes -import kotlinx.coroutines.delay /** - * Component responsible for fast booking of rooms. + * ViewModel responsible for fast booking of rooms. * Handles finding available rooms and creating quick bookings. */ -class FastBookingComponent( - private val componentContext: ComponentContext, +class FastBookingViewModel( + private val selectRoomUseCase: SelectRoomUseCase, + private val createFastBookingUseCase: CreateBookingUseCase, + private val deleteBookingUseCase: DeleteBookingUseCase, + private val timerUseCase: TimerUseCase, val minEventDuration: Int, val selectedRoom: RoomInfo, val rooms: List, - private val onCloseRequest: () -> Unit -) : ComponentContext by componentContext, KoinComponent, ModalWindow { - - private val coroutineScope = componentCoroutineScope() +) : ViewModel() { - // Use cases - private val selectRoomUseCase: SelectRoomUseCase by inject() - private val createFastBookingUseCase: CreateBookingUseCase by inject() - private val deleteBookingUseCase: DeleteBookingUseCase by inject() - private val timerUseCase: TimerUseCase by inject() + private val coroutineScope = viewModelScope // Timers private val currentTimeTimer = BootstrapperTimer(timerUseCase, coroutineScope) @@ -58,19 +49,17 @@ class FastBookingComponent( private val mutableState = MutableStateFlow(State.defaultState) val state = mutableState.asStateFlow() - // Navigation - private val navigation = StackNavigation() - val childStack = childStack( - source = navigation, - initialConfiguration = ModalConfig.LoadingModal, - serializer = ModalConfig.serializer(), - childFactory = { config, _ -> config }, - ) + private val closeChannel = Channel(Channel.BUFFERED) + val closeEvents = closeChannel.receiveAsFlow() init { initializeComponent() } + private fun requestClose() { + coroutineScope.launch { closeChannel.send(Unit) } + } + /** * Initializes the component, finding available rooms and setting up timers. */ @@ -106,8 +95,9 @@ class FastBookingComponent( } } catch (e: Exception) { Napier.e("Error finding available room", e) - mutableState.update { it.copy(isLoad = false, isSuccess = false, isError = true) } - navigation.push(ModalConfig.FailureModal("")) + mutableState.update { + it.copy(isLoad = false, isSuccess = false, isError = true, modal = FastBookingModal.Failure("")) + } } } @@ -126,13 +116,17 @@ class FastBookingComponent( * Handles the case when no rooms are available for immediate booking. */ private fun handleNoAvailableRooms() { - mutableState.update { it.copy(isLoad = false, isSuccess = false) } - val nearestFreeRoom = selectRoomUseCase.getNearestFreeRoom(rooms, minEventDuration) val minutesUntilAvailable = nearestFreeRoom.second.inWholeMinutes.toInt() - mutableState.update { it.copy(minutesLeft = minutesUntilAvailable) } - navigation.push(ModalConfig.FailureModal(nearestFreeRoom.first.name)) + mutableState.update { + it.copy( + isLoad = false, + isSuccess = false, + minutesLeft = minutesUntilAvailable, + modal = FastBookingModal.Failure(nearestFreeRoom.first.name), + ) + } } /** @@ -141,7 +135,7 @@ class FastBookingComponent( fun sendIntent(intent: Intent) { when (intent) { is Intent.OnFreeSelectRequest -> freeRoom(intent.room) - Intent.OnCloseWindowRequest -> onCloseRequest() + Intent.OnCloseWindowRequest -> requestClose() } } @@ -188,10 +182,10 @@ class FastBookingComponent( event = eventInfo.copy(id = eventId), isLoad = false, isSuccess = true, - isError = false + isError = false, + modal = FastBookingModal.Success(room, eventInfo), ) } - navigation.push(ModalConfig.SuccessModal(room, eventInfo)) } /** @@ -202,10 +196,10 @@ class FastBookingComponent( it.copy( isLoad = false, isSuccess = false, - isError = true + isError = true, + modal = FastBookingModal.Failure(room), ) } - navigation.push(ModalConfig.FailureModal(room)) } /** @@ -219,7 +213,7 @@ class FastBookingComponent( is Either.Success -> { delay(3000) // NOTE(radchenko): wait for the event to be created in an external service mutableState.update { it.copy(isLoad = false) } - onCloseRequest() + requestClose() } is Either.Error -> { @@ -242,28 +236,4 @@ class FastBookingComponent( } } } - - /** - * Modal window configurations. - */ - @Serializable - sealed interface ModalConfig { - /** - * Shown when a booking is successfully created. - */ - @Serializable - data class SuccessModal(val room: String, val eventInfo: EventInfo) : ModalConfig - - /** - * Shown when a booking cannot be created. - */ - @Serializable - data class FailureModal(val room: String) : ModalConfig - - /** - * Shown while loading. - */ - @Serializable - object LoadingModal : ModalConfig - } } diff --git a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/State.kt b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/State.kt index 3b4c9b847..512d7cb58 100644 --- a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/State.kt +++ b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/State.kt @@ -5,7 +5,10 @@ import band.effective.office.shared.core.utils.currentLocalDateTime import kotlinx.datetime.LocalDateTime /** - * State for the FastBookingComponent. + * State for the FastBookingViewModel. + * + * [modal] replaces the former Decompose `ModalConfig` child stack — it is now plain state + * describing which modal view to render. */ data class State( val isLoad: Boolean, @@ -14,6 +17,7 @@ data class State( val event: EventInfo, val minutesLeft: Int, val currentTime: LocalDateTime, + val modal: FastBookingModal, ) { companion object { val defaultState = @@ -24,6 +28,19 @@ data class State( event = EventInfo.emptyEvent, minutesLeft = 0, currentTime = currentLocalDateTime, + modal = FastBookingModal.Loading, ) } } + +/** Which modal view the fast-booking flow is currently showing. Was `FastBookingComponent.ModalConfig`. */ +sealed interface FastBookingModal { + /** Shown while searching / creating the booking. */ + data object Loading : FastBookingModal + + /** Shown when a booking is successfully created. */ + data class Success(val room: String, val eventInfo: EventInfo) : FastBookingModal + + /** Shown when a booking cannot be created (either an error or no available room). */ + data class Failure(val room: String) : FastBookingModal +} diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/di/MainScreenModule.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/di/MainScreenModule.kt index 51b5d977c..84855a59a 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/di/MainScreenModule.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/di/MainScreenModule.kt @@ -1,10 +1,20 @@ package band.effective.office.tablet.feature.main.di +import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.feature.main.domain.GetRoomIndexUseCase import band.effective.office.tablet.feature.main.domain.GetTimeToNextEventUseCase +import band.effective.office.tablet.feature.main.presentation.freeuproom.FreeSelectRoomViewModel +import band.effective.office.tablet.feature.main.presentation.main.MainViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val mainScreenModule = module { single { GetRoomIndexUseCase(get()) } single { GetTimeToNextEventUseCase() } + + viewModelOf(::MainViewModel) + viewModel { (event: EventInfo, room: String) -> + FreeSelectRoomViewModel(deleteBookingUseCase = get(), eventInfo = event, roomName = room) + } } \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomView.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomView.kt index b9499f019..ad60e4369 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomView.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomView.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import band.effective.office.tablet.core.ui.common.CrossButtonView import band.effective.office.tablet.core.ui.common.Loader import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette @@ -35,13 +35,22 @@ import band.effective.office.tablet.feature.main.free_select_room import band.effective.office.tablet.feature.main.free_select_room_button import band.effective.office.tablet.feature.main.try_again import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel @Composable -fun FreeSelectRoomView(freeSelectRoomComponent: FreeSelectRoomComponent) { - val state by freeSelectRoomComponent.state.collectAsState() +fun FreeSelectRoomView( + onClose: () -> Unit, + viewModel: FreeSelectRoomViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.closeEvents.collect { onClose() } + } + FreeSelectRoomView( - onCloseRequest = { freeSelectRoomComponent.sendIntent(Intent.OnCloseWindowRequest) }, - onFreeRoomRequest = { freeSelectRoomComponent.sendIntent(Intent.OnFreeSelectRequest) }, + onCloseRequest = { viewModel.sendIntent(Intent.OnCloseWindowRequest) }, + onFreeRoomRequest = { viewModel.sendIntent(Intent.OnFreeSelectRequest) }, isLoading = state.isLoad, isFail = !state.isSuccess ) @@ -60,16 +69,13 @@ private fun FreeSelectRoomView( val colorButton = if (isPressed.value) LocalCustomColorsPalette.current.pressedPrimaryButton else MaterialTheme.colorScheme.primary - Dialog( - onDismissRequest = { onCloseRequest() } + Column( + modifier = Modifier + .clip(RoundedCornerShape(5)) + .background(LocalCustomColorsPalette.current.elevationBackground), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top ) { - Column( - modifier = Modifier - .clip(RoundedCornerShape(5)) - .background(LocalCustomColorsPalette.current.elevationBackground), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { Spacer(modifier = Modifier.height(30.dp)) CrossButtonView( Modifier.width(518.dp).padding(end = 42.dp), @@ -117,5 +123,4 @@ private fun FreeSelectRoomView( } Spacer(modifier = Modifier.height(60.dp)) } - } -} \ No newline at end of file + } \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomComponent.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomViewModel.kt similarity index 60% rename from clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomComponent.kt rename to clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomViewModel.kt index 012c12d31..c7484fe88 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomComponent.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/freeuproom/FreeSelectRoomViewModel.kt @@ -1,34 +1,32 @@ package band.effective.office.tablet.feature.main.presentation.freeuproom +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.core.domain.useCase.DeleteBookingUseCase -import band.effective.office.tablet.core.ui.common.ModalWindow -import band.effective.office.shared.core.utils.componentCoroutineScope -import com.arkivanov.decompose.ComponentContext +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -class FreeSelectRoomComponent( - componentContext: ComponentContext, +class FreeSelectRoomViewModel( + private val deleteBookingUseCase: DeleteBookingUseCase, private val eventInfo: EventInfo, private val roomName: String, - private val onCloseRequest: () -> Unit, -) : ComponentContext by componentContext, ModalWindow, KoinComponent { - private val scope = componentCoroutineScope() +) : ViewModel() { private val mutableState = MutableStateFlow(State.defaultState) val state = mutableState.asStateFlow() - private val deleteBookingUseCase: DeleteBookingUseCase by inject() + private val closeChannel = Channel(Channel.BUFFERED) + val closeEvents = closeChannel.receiveAsFlow() fun sendIntent(intent: Intent) { when (intent) { Intent.OnCloseWindowRequest -> { - onCloseRequest() + requestClose() mutableState.update { State.defaultState } } @@ -36,13 +34,17 @@ class FreeSelectRoomComponent( } } - private fun freeRoom() = scope.launch { + private fun freeRoom() = viewModelScope.launch { mutableState.update { it.copy(isLoad = true) } deleteBookingUseCase( roomName = roomName, eventInfo = eventInfo, ) - onCloseRequest() + requestClose() mutableState.update { State.defaultState } } -} \ No newline at end of file + + private fun requestClose() { + viewModelScope.launch { closeChannel.send(Unit) } + } +} diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt index 1b5be6381..187c45457 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -13,12 +14,21 @@ import band.effective.office.tablet.core.ui.LoadMainScreen import band.effective.office.tablet.core.ui.common.ErrorMainScreen import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder import kotlin.time.ExperimentalTime +import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalTime::class) @Composable -fun MainScreen(component: MainComponent) { +fun MainScreen( + onNavigate: (MainNavEvent) -> Unit, + viewModel: MainViewModel = koinViewModel(), +) { + val component = viewModel val state by component.state.collectAsState() val currentDate by CurrentTimeHolder.currentTime.collectAsState() + + LaunchedEffect(Unit) { + component.navEvents.collect(onNavigate) + } Column( modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainViewModel.kt similarity index 83% rename from clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt rename to clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainViewModel.kt index b78790e65..b051073b2 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainViewModel.kt @@ -1,5 +1,7 @@ package band.effective.office.tablet.feature.main.presentation.main +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import band.effective.office.shared.core.domain.Either import band.effective.office.tablet.core.domain.ErrorWithData import band.effective.office.tablet.core.domain.manager.DateResetManager @@ -14,55 +16,45 @@ import band.effective.office.tablet.core.domain.util.BootstrapperTimer import band.effective.office.shared.core.utils.currentLocalDateTime import band.effective.office.shared.core.utils.minus import band.effective.office.shared.core.utils.plus -import band.effective.office.shared.core.utils.componentCoroutineScope import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder import band.effective.office.tablet.feature.main.domain.GetRoomIndexUseCase import band.effective.office.tablet.feature.main.domain.GetTimeToNextEventUseCase -import band.effective.office.tablet.feature.slot.presentation.SlotComponent +import band.effective.office.tablet.feature.slot.presentation.SlotComponentFactory import band.effective.office.tablet.feature.slot.presentation.SlotIntent -import com.arkivanov.decompose.ComponentContext import kotlin.math.abs import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDateTime -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject /** - * Main component responsible for managing room information, bookings, and navigation. - * Handles room selection, date selection, and modal windows for booking and room management. + * ViewModel responsible for managing room information, bookings, and navigation triggers. + * Handles room selection, date selection, and requests to open modal windows. */ @OptIn(ExperimentalTime::class) -class MainComponent( - private val componentContext: ComponentContext, - val onFastBooking: (minDuration: Int, selectedRoom: RoomInfo, rooms: List) -> Unit, - val onOpenFreeRoomModal: (currentEvent: EventInfo, roomName: String) -> Unit, - private val openBookingDialog: (event: EventInfo, room: String) -> Unit, -) : ComponentContext by componentContext, KoinComponent { - - private val coroutineScope = componentCoroutineScope() - - // Use cases - private val checkSettingsUseCase: CheckSettingsUseCase by inject() - private val roomInfoUseCase: RoomInfoUseCase by inject() - private val getRoomIndexUseCase: GetRoomIndexUseCase by inject() - private val getTimeToNextEventUseCase: GetTimeToNextEventUseCase by inject() - private val updateUseCase: UpdateUseCase by inject() - private val timerUseCase: TimerUseCase by inject() - private val deleteBookingUseCase: DeleteBookingUseCase by inject() +class MainViewModel( + private val checkSettingsUseCase: CheckSettingsUseCase, + private val roomInfoUseCase: RoomInfoUseCase, + private val getRoomIndexUseCase: GetRoomIndexUseCase, + private val getTimeToNextEventUseCase: GetTimeToNextEventUseCase, + private val updateUseCase: UpdateUseCase, + private val timerUseCase: TimerUseCase, + private val deleteBookingUseCase: DeleteBookingUseCase, + slotComponentFactory: SlotComponentFactory, +) : ViewModel() { + + private val coroutineScope = viewModelScope // Timers private val currentTimeTimer = BootstrapperTimer(timerUseCase, coroutineScope) @@ -71,20 +63,29 @@ class MainComponent( private val mutableState = MutableStateFlow(State.defaultState) val state: StateFlow = mutableState.asStateFlow() - private val mutableLabel = MutableSharedFlow