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..4903a5f5f 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 @@ -13,10 +13,7 @@ kotlin { implementation(project(":clients:tablet:core:domain")) implementation(project(":clients:tablet:core:data")) - implementation(libs.findLibrary("decompose").get()) - implementation(libs.findLibrary("decompose.compose.jetbrains").get()) - 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()) @@ -26,8 +23,5 @@ kotlin { androidMain.dependencies { implementation(libs.findLibrary("koin-android").get()) } - iosMain.dependencies { - implementation(libs.findLibrary("essenty.darwin.runtime").get()) - } } } diff --git a/clients/README.md b/clients/README.md index 85043c6cb..5f78be0d7 100644 --- a/clients/README.md +++ b/clients/README.md @@ -26,9 +26,9 @@ The TV application is a Compose Multiplatform app designed for large-screen devi - **Framework**: Kotlin Multiplatform with Jetpack Compose - **Platforms**: Android and iOS - **UI**: Jetpack Compose -- **Navigation**: Decompose +- **Navigation**: Compose Navigation (NavHost/NavController with type-safe routes) - **Dependency Injection**: Koin -- **State Management**: Decompose with MVI pattern +- **State Management**: Koin ViewModels + MVI pattern (StateFlow) - **API Communication**: Ktor Client - **Date/Time Handling**: Kotlinx.datetime - **Logging**: Napier diff --git a/clients/tablet/composeApp/README.md b/clients/tablet/composeApp/README.md index 1c56ca777..3f5299a9b 100644 --- a/clients/tablet/composeApp/README.md +++ b/clients/tablet/composeApp/README.md @@ -25,8 +25,8 @@ composeApp/ ``` ## Key Components -- **App**: Main application composable that sets up the navigation and theme -- **Navigation**: Handles routing between different screens and features +- **AppRoot**: Main application composable that provides the root `ViewModelStoreOwner`, theme, and `AppNavHost` +- **AppNavHost**: Compose Navigation graph (`NavHost`/`NavController`) with type-safe routes; full-screen `composable<>` destinations (Settings/Main) and modal `dialog<>` destinations (FreeRoom/BookingEditor/FastBooking) - **DI**: Dependency injection setup for the application - **Theme**: Application-wide styling and theming diff --git a/clients/tablet/composeApp/build.gradle.kts b/clients/tablet/composeApp/build.gradle.kts index 6b41e8279..b2f1c74ca 100644 --- a/clients/tablet/composeApp/build.gradle.kts +++ b/clients/tablet/composeApp/build.gradle.kts @@ -23,18 +23,8 @@ kotlin { baseName = "ComposeApp" isStatic = true - export(libs.decompose) - export(libs.decompose.compose.jetbrains) - export(libs.essenty.lifecycle) - export(libs.calf.ui) } - it.compilations.all { - compileTaskProvider.configure { - compilerOptions.freeCompilerArgs.add("-Xdisable-decompose-parcelize") - } - } - } sourceSets { @@ -45,11 +35,13 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) - api(libs.decompose) - api(libs.decompose.compose.jetbrains) 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/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..f9c7df1d3 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,55 @@ 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() { + 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..ca816a544 --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/AppNavHost.kt @@ -0,0 +1,159 @@ +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.navigation +import androidx.navigation.toRoute +import band.effective.office.tablet.core.domain.model.EventInfo +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.main.presentation.main.MainViewModel +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 AppNavHostData { + /** NavType map for routes carrying a single [EventInfo] payload. */ + val eventTypeMap = mapOf(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) + ) + + is MainNavEvent.OpenFreeRoom -> navController.navigate( + FreeRoomRoute(event.event, event.roomName) + ) + + is MainNavEvent.OpenBookingEditor -> navController.navigate( + BookingFlowRoute(event.event, event.room) + ) + } + }, + ) + } + + dialog(typeMap = AppNavHostData.eventTypeMap, dialogProperties = AppNavHostData.dialogProperties) { entry -> + val route = entry.toRoute() + val viewModel = koinViewModel { + parametersOf(route.event, route.roomName) + } + DialogBackgroundDim(onDismiss = { navController.popBackStack() }) { + FreeSelectRoomView( + onClose = { navController.popBackStack() }, + viewModel = viewModel, + ) + } + } + + navigation( + startDestination = BookingEditorRoute, + typeMap = AppNavHostData.eventTypeMap, + ) { + dialog(dialogProperties = AppNavHostData.dialogProperties) { + val flowEntry = remember(it) { navController.getBackStackEntry() } + val route = flowEntry.toRoute() + val viewModel = koinViewModel(viewModelStoreOwner = flowEntry) { + parametersOf(route.event, route.room) + } + DialogBackgroundDim(onDismiss = { navController.popBackStack() }) { + BookingEditor( + viewModel = viewModel, + onClose = { navController.popBackStack() }, + onOpenDateTimePicker = { navController.navigate(DateTimePickerRoute) }, + ) + } + } + + dialog(dialogProperties = AppNavHostData.dialogProperties) { + val flowEntry = remember(it) { navController.getBackStackEntry() } + val viewModel = koinViewModel(viewModelStoreOwner = flowEntry) + 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(dialogProperties = AppNavHostData.dialogProperties) { entry -> + val minDuration = entry.toRoute().minEventDuration + val mainEntry = remember(entry) { navController.getBackStackEntry() } + val mainViewModel = koinViewModel(viewModelStoreOwner = mainEntry) + val mainSnapshot = remember { mainViewModel.state.value } + val viewModel = koinViewModel { + parametersOf( + minDuration, + mainSnapshot.roomList[mainSnapshot.indexSelectRoom], + mainSnapshot.roomList, + ) + } + 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/navigation/Routes.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/Routes.kt new file mode 100644 index 000000000..49631cfc1 --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/navigation/Routes.kt @@ -0,0 +1,55 @@ +package band.effective.office.tablet.navigation + +import band.effective.office.tablet.core.domain.model.EventInfo +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) + +/** + * Nested navigation graph for the booking-editor flow. Carries the runtime payload so the graph + * entry can own the shared `BookingEditorViewModel`; both the editor and the date/time picker + * resolve that same ViewModel from this graph entry (see AppNavHost), so the picked date flows back + * through shared state without nav-result plumbing. + */ +@Serializable +data class BookingFlowRoute(val event: EventInfo, val room: String) + +/** Modal: create/edit a booking — start destination of [BookingFlowRoute]. */ +@Serializable +object BookingEditorRoute + +/** + * Modal: quick booking of the nearest available room. Carries only the requested duration — the + * room list and the currently-selected room are read from the shared [MainRoute] `MainViewModel` + * (see AppNavHost) rather than serialized into the route: each `RoomInfo` carries its full event + * list, so embedding `List` in the route would bloat it and risk a + * `TransactionTooLargeException`. + */ +@Serializable +data class FastBookingRoute(val minEventDuration: Int) + +/** + * Date/time picker for the booking editor. Carries no payload: it lives inside [BookingFlowRoute] + * and shares the graph 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()) +} 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/build.gradle.kts b/clients/tablet/core/ui/build.gradle.kts index a63c42360..2ae642f98 100644 --- a/clients/tablet/core/ui/build.gradle.kts +++ b/clients/tablet/core/ui/build.gradle.kts @@ -7,11 +7,6 @@ kotlin { commonMain.dependencies { api(project(":clients:shared:core")) api(libs.kotlinx.datetime) - implementation(libs.decompose) - implementation(libs.decompose.compose.jetbrains) - implementation(libs.essenty.lifecycle) - implementation(libs.essenty.state.keeper) - api(libs.kotlinx.datetime) } } } 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/README.md b/clients/tablet/feature/bookingEditor/README.md index 3cadb3cf2..117dde65b 100644 --- a/clients/tablet/feature/bookingEditor/README.md +++ b/clients/tablet/feature/bookingEditor/README.md @@ -21,19 +21,19 @@ bookingEditor/ ├── di/ # Dependency injection setup ├── domain/ # Domain models and business logic └── presentation/ # UI components and screens - ├── BookingEditor.kt # Main UI component - ├── BookingEditorComponent.kt # Component implementation - ├── datetimepicker/ # Date and time picker components - ├── Intent.kt # User actions/intents - ├── mapper/ # Model mappers - └── State.kt # UI state definitions + ├── BookingEditor.kt # Main UI component + ├── BookingEditorViewModel.kt # koin ViewModel (MVI) implementation + ├── datetimepicker/ # Date and time picker components + ├── Intent.kt # User actions/intents + ├── mapper/ # Model mappers + └── State.kt # UI state definitions ``` ## Key Components ### Main Components - **BookingEditor**: Main UI component for the booking editor screen -- **BookingEditorComponent**: Implementation of the booking editor component following MVI architecture +- **BookingEditorViewModel**: koin ViewModel for the booking editor, following MVI architecture ### State Management - **Intent**: Defines user actions and events that can occur in the booking editor @@ -74,5 +74,5 @@ To add a new field to the booking form: 2. Update the State.kt file to include the new field in the UI state 3. Modify the Intent.kt file if new user actions are needed 4. Update the BookingEditor.kt UI component to display the new field -5. Update the BookingEditorComponent.kt to handle the new field's logic +5. Update the BookingEditorViewModel.kt to handle the new field's logic 6. Update any mappers if needed to convert between domain and UI models 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..25f191158 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,68 @@ 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 - ) - } - } - } + 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..0f6e512d2 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,30 @@ fun DatePickerView( } } + val palette = LocalCustomColorsPalette.current + val accent = MaterialTheme.colorScheme.primary + val onAccent = MaterialTheme.colorScheme.onPrimary + Column { - // Display the date picker 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..25f7bf861 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,28 @@ fun TimePickerView( onSnap(LocalTime(hour, minute, 0)) } + val palette = LocalCustomColorsPalette.current + val accent = MaterialTheme.colorScheme.primary + val onAccent = MaterialTheme.colorScheme.onPrimary + 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/README.md b/clients/tablet/feature/fastbooking/README.md index 536bb72cd..39ca82e3b 100644 --- a/clients/tablet/feature/fastbooking/README.md +++ b/clients/tablet/feature/fastbooking/README.md @@ -18,17 +18,17 @@ The module follows a feature-based architecture: ``` fastbooking/ └── presentation/ # UI components and screens - ├── FastBooking.kt # Main UI component - ├── FastBookingComponent.kt # Component implementation - ├── Intent.kt # User actions/intents - └── State.kt # UI state definitions + ├── FastBooking.kt # Main UI component + ├── FastBookingViewModel.kt # koin ViewModel (MVI) implementation + ├── Intent.kt # User actions/intents + └── State.kt # UI state definitions ``` ## Key Components ### Main Components - **FastBooking**: Main UI component for the fast booking screen -- **FastBookingComponent**: Implementation of the fast booking component following MVI architecture +- **FastBookingViewModel**: koin ViewModel for the fast booking flow, following MVI architecture ### State Management - **Intent**: Defines user actions and events that can occur in the fast booking screen @@ -75,5 +75,5 @@ To add a new quick booking feature: 2. Update the State.kt file to include any new UI state needed 3. Modify the Intent.kt file to add new user actions if needed 4. Update the FastBooking.kt UI component to implement the new feature -5. Update the FastBookingComponent.kt to handle the new feature's logic +5. Update the FastBookingViewModel.kt to handle the new feature's logic 6. Ensure it integrates with the full booking system 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..e8f85fe96 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,33 +14,41 @@ 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) { - val state by component.state.collectAsState() +fun MainScreen( + onNavigate: (MainNavEvent) -> Unit, + viewModel: MainViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsState() val currentDate by CurrentTimeHolder.currentTime.collectAsState() + + LaunchedEffect(Unit) { + viewModel.navEvents.collect(onNavigate) + } Column( modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { when { - state.isError -> ErrorMainScreen(resetRequest = { component.sendIntent(Intent.RebootRequest) }) + state.isError -> ErrorMainScreen(resetRequest = { viewModel.sendIntent(Intent.RebootRequest) }) state.isLoad -> LoadMainScreen() state.isData -> { MainScreenView( - slotComponent = component.slotComponent, + slotComponent = viewModel.slotComponent, isDisconnect = state.isDisconnect, roomList = state.roomList, indexSelectRoom = state.indexSelectRoom, timeToNextEvent = state.timeToNextEvent, - onRoomButtonClick = { component.sendIntent(Intent.OnSelectRoom(it)) }, - onCancelEventRequest = { component.sendIntent(Intent.OnOpenFreeRoomModal) }, - onFastBooking = { component.sendIntent(Intent.OnFastBooking(it)) }, - onIncrementData = { component.sendIntent(Intent.OnUpdateSelectDate(updateInDays = 1)) }, - onDecrementData = { component.sendIntent(Intent.OnUpdateSelectDate(updateInDays = -1)) }, + onRoomButtonClick = { viewModel.sendIntent(Intent.OnSelectRoom(it)) }, + onCancelEventRequest = { viewModel.sendIntent(Intent.OnOpenFreeRoomModal) }, + onFastBooking = { viewModel.sendIntent(Intent.OnFastBooking(it)) }, + onIncrementData = { viewModel.sendIntent(Intent.OnUpdateSelectDate(updateInDays = 1)) }, + onDecrementData = { viewModel.sendIntent(Intent.OnUpdateSelectDate(updateInDays = -1)) }, selectedDate = state.selectedDate, currentDate = currentDate, onOpenDateTimePickerModalRequest = {}, // TODO 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 84% 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..0941efc14 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