diff --git a/.gitignore b/.gitignore index a75e9cc19..321e29928 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,11 @@ bin/ ### Mac OS ### .DS_Store +### Xcode (per-user, machine-specific — should never be committed) ### +**/xcuserdata/ +*.xcuserstate +DerivedData/ + ### Kotlin ### .kotlin/ @@ -81,6 +86,7 @@ coverage/ /oldProject/ /clients/tablet/composeApp/google-services.json /local.properties +/localQuickStart/.local-fake-credentials.json /deploy/dev/.env /deploy/dev/*.jar /deploy/prod/.env diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 5738dbb48..6de4b5c3a 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -14,24 +14,30 @@ repositories { } dependencies { - implementation("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.1.21") - implementation("org.springframework.boot:org.springframework.boot.gradle.plugin:3.5.0") - implementation("org.jetbrains.kotlin.plugin.spring:org.jetbrains.kotlin.plugin.spring.gradle.plugin:2.1.21") - implementation("org.jetbrains.kotlin.plugin.jpa:org.jetbrains.kotlin.plugin.jpa.gradle.plugin:2.1.21") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21") - implementation("org.jetbrains.kotlin:kotlin-allopen:2.1.21") - implementation("org.jetbrains.kotlin:kotlin-noarg:2.1.21") - implementation("org.springframework.boot:spring-boot-gradle-plugin:3.5.0") - implementation("io.spring.gradle:dependency-management-plugin:1.1.7") + val kotlinVersion = libs.versions.kotlin.get() + val springBootVersion = libs.versions.springBoot.get() + val springDepManagementVersion = libs.versions.springDependencyManagement.get() + val agpVersion = libs.versions.agp.get() + val composeGradlePluginVersion = libs.versions.compose.get() + val googleServicesVersion = libs.versions.googleServices.get() - implementation("org.jetbrains.kotlin.multiplatform:org.jetbrains.kotlin.multiplatform.gradle.plugin:2.1.21") - implementation("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.1.21") - implementation("org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin:2.1.21") - implementation("com.android.tools.build:gradle:8.9.1") { + implementation("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:$kotlinVersion") + implementation("org.springframework.boot:org.springframework.boot.gradle.plugin:$springBootVersion") + implementation("org.jetbrains.kotlin.plugin.spring:org.jetbrains.kotlin.plugin.spring.gradle.plugin:$kotlinVersion") + implementation("org.jetbrains.kotlin.plugin.jpa:org.jetbrains.kotlin.plugin.jpa.gradle.plugin:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-noarg:$kotlinVersion") + implementation("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion") + implementation("io.spring.gradle:dependency-management-plugin:$springDepManagementVersion") + + implementation("org.jetbrains.kotlin.multiplatform:org.jetbrains.kotlin.multiplatform.gradle.plugin:$kotlinVersion") + implementation("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:$kotlinVersion") + implementation("org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin:$kotlinVersion") + implementation("com.android.tools.build:gradle:$agpVersion") { exclude(group = "org.apache.commons", module = "commons-compress") } - implementation("org.jetbrains.compose:org.jetbrains.compose.gradle.plugin:1.8.1") - implementation("com.google.gms:google-services:4.4.3") - implementation("org.jetbrains.kotlin.kapt:org.jetbrains.kotlin.kapt.gradle.plugin:2.1.21") - + implementation("org.jetbrains.compose:org.jetbrains.compose.gradle.plugin:$composeGradlePluginVersion") + implementation("com.google.gms:google-services:$googleServicesVersion") + implementation("org.jetbrains.kotlin.kapt:org.jetbrains.kotlin.kapt.gradle.plugin:$kotlinVersion") } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.backend.kotlin-common.gradle.kts b/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.backend.kotlin-common.gradle.kts index 476cbc83f..e64e6fa59 100644 --- a/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.backend.kotlin-common.gradle.kts +++ b/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.backend.kotlin-common.gradle.kts @@ -10,9 +10,9 @@ java { } tasks.withType { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } 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/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.ui.gradle.kts b/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.ui.gradle.kts index beeda233f..bb68b6d27 100644 --- a/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.ui.gradle.kts +++ b/build-logic/src/main/kotlin/band/effective/office/backend/band.effective.office.client.kmp.ui.gradle.kts @@ -17,7 +17,6 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.findLibrary("coil").get()) implementation(libs.findLibrary("coil.network.ktor").get()) - api("com.mohamedrejeb.calf:calf-ui:0.8.0") } androidMain.dependencies { diff --git a/build.gradle.kts b/build.gradle.kts index 907a94ae4..4415b01f6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,9 +37,17 @@ subprojects { } tasks.withType { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + // kotlinx-datetime 0.7.x is built on the still-experimental kotlin.time.Clock/Instant, + // so opt in project-wide for every Kotlin compilation (JVM, Android and Native). + tasks.withType>().configureEach { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") } } 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/shared/core/src/commonMain/kotlin/band/effective/office/shared/core/utils/LocalDateTimeExtensions.kt b/clients/shared/core/src/commonMain/kotlin/band/effective/office/shared/core/utils/LocalDateTimeExtensions.kt index bb8c95b63..eac2baab2 100644 --- a/clients/shared/core/src/commonMain/kotlin/band/effective/office/shared/core/utils/LocalDateTimeExtensions.kt +++ b/clients/shared/core/src/commonMain/kotlin/band/effective/office/shared/core/utils/LocalDateTimeExtensions.kt @@ -2,7 +2,7 @@ package band.effective.office.shared.core.utils import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime diff --git a/clients/shared/core/src/iosMain/kotlin/band/effective/office/shared/core/utils/DateTimeUtils.ios.kt b/clients/shared/core/src/iosMain/kotlin/band/effective/office/shared/core/utils/DateTimeUtils.ios.kt index 74ee65211..47c03b2e4 100644 --- a/clients/shared/core/src/iosMain/kotlin/band/effective/office/shared/core/utils/DateTimeUtils.ios.kt +++ b/clients/shared/core/src/iosMain/kotlin/band/effective/office/shared/core/utils/DateTimeUtils.ios.kt @@ -4,12 +4,10 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.toNSDateComponents import platform.Foundation.NSCalendar import platform.Foundation.NSDateFormatter -import platform.Foundation.NSLocale actual fun LocalDateTime.toLocalisedString(pattern: String): String { val dateFormatter = NSDateFormatter() dateFormatter.dateFormat = pattern - dateFormatter.locale = NSLocale.currentLocale val calendar = NSCalendar.currentCalendar val dateComponents = toNSDateComponents() diff --git a/clients/smsrouter/app/build.gradle.kts b/clients/smsrouter/app/build.gradle.kts index bcb0ea09f..efb4c22ba 100644 --- a/clients/smsrouter/app/build.gradle.kts +++ b/clients/smsrouter/app/build.gradle.kts @@ -41,15 +41,18 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } buildFeatures { compose = true buildConfig = true } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) 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 1b90aa857..bf39692f9 100644 --- a/clients/tablet/composeApp/build.gradle.kts +++ b/clients/tablet/composeApp/build.gradle.kts @@ -1,13 +1,13 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties plugins { - id("org.jetbrains.kotlin.multiplatform") - id("org.jetbrains.kotlin.plugin.compose") version "2.1.21" - id("org.jetbrains.compose") - id("com.android.application") - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.21" + alias(libs.plugins.multiplatform) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.buildkonfig) - id("com.google.gms.google-services") version "4.4.3" + alias(libs.plugins.google.services) id("sign-config") } @@ -22,17 +22,7 @@ kotlin { it.binaries.framework { baseName = "ComposeApp" isStatic = true - - export(libs.decompose) - export(libs.decompose.compose.jetbrains) - export(libs.essenty.lifecycle) - - export("com.mohamedrejeb.calf:calf-ui:0.8.0") - } - it.compilations.all { - kotlinOptions.freeCompilerArgs += "-Xdisable-decompose-parcelize" } - } sourceSets { @@ -43,19 +33,18 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) - api(libs.decompose) - api(libs.decompose.compose.jetbrains) - api("com.mohamedrejeb.calf:calf-ui:0.8.0") 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) implementation(libs.napier) - implementation(libs.settings) - implementation(project(":clients:tablet:core:ui")) implementation(project(":clients:tablet:feature:main")) implementation(project(":clients:tablet:feature:settings")) 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/androidMain/kotlin/band/effective/office/tablet/App.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt index eb00f2b5f..49e2e6986 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt @@ -2,12 +2,12 @@ package band.effective.office.tablet import android.app.Application import band.effective.office.tablet.core.domain.manager.DateResetManager -import band.effective.office.tablet.core.domain.model.SettingsManager import band.effective.office.tablet.core.ui.inactivity.InactivityLifecycleCallbacks import band.effective.office.tablet.di.KoinInitializer +import band.effective.office.tablet.di.firebaseTopicsModule import com.google.firebase.messaging.FirebaseMessaging -import com.russhwolf.settings.SharedPreferencesSettings import org.koin.android.ext.android.get +import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import kotlin.time.Duration.Companion.minutes @@ -16,15 +16,10 @@ class App : Application() { override fun onCreate() { super.onCreate() LoggerInitializer().init() - KoinInitializer().init() - SettingsManager.init( - SharedPreferencesSettings( - this.getSharedPreferences( - "settings", - MODE_PRIVATE - ) - ) - ) + KoinInitializer().init { + androidContext(applicationContext) + modules(firebaseTopicsModule) + } subscribeOnFirebaseTopics() initializeInactivitySystem() } diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt similarity index 100% rename from clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt rename to clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt index 60c5e84f7..8e4dc5c21 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt @@ -7,7 +7,7 @@ import android.content.IntentFilter import android.util.Log import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder import kotlinx.coroutines.flow.StateFlow -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime 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..0fbad9387 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,22 +3,27 @@ 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 +import org.koin.dsl.KoinAppDeclaration class KoinInitializer { - fun init() { + fun init(koinConfig: KoinAppDeclaration = {}) { startKoin { modules( appModule, - firebaseTopicsModule, dataModule, domainModule, mainScreenModule, + settingsModule, bookingEditorModule, + fastBookingModule, slotDiModule, ) + koinConfig() } } } 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/debug/AndroidManifest.xml b/clients/tablet/composeApp/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..dc566c6d5 --- /dev/null +++ b/clients/tablet/composeApp/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/Initializers.kt b/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/Initializers.kt index 538fa6357..1c63acf3d 100644 --- a/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/Initializers.kt +++ b/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/Initializers.kt @@ -1,14 +1,11 @@ package band.effective.office.tablet -import band.effective.office.tablet.core.domain.model.SettingsManager import band.effective.office.tablet.di.KoinInitializer -import com.russhwolf.settings.KeychainSettings class Initializers { fun init() { LoggerInitializer().init() KoinInitializer().init() - SettingsManager.init(KeychainSettings("effectiveOffice")) } } \ No newline at end of file diff --git a/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt b/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt index 17e7c1556..9b4d8dbee 100644 --- a/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt +++ b/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt @@ -2,7 +2,7 @@ package band.effective.office.tablet.time import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder import kotlinx.coroutines.flow.StateFlow -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime 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/data/src/iosMain/kotlin/band/effective/office/tablet/core/data/network/HttpClientFactory.ios.kt b/clients/tablet/core/data/src/iosMain/kotlin/band/effective/office/tablet/core/data/network/HttpClientFactory.ios.kt deleted file mode 100644 index 370de4437..000000000 --- a/clients/tablet/core/data/src/iosMain/kotlin/band/effective/office/tablet/core/data/network/HttpClientFactory.ios.kt +++ /dev/null @@ -1,8 +0,0 @@ -package band.effective.office.tablet.core.data.network - -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin - -actual object HttpClientFactory { - actual fun createHttpClient(): HttpClient = HttpClient(Darwin) -} \ No newline at end of file diff --git a/clients/tablet/core/domain/build.gradle.kts b/clients/tablet/core/domain/build.gradle.kts index fe275ca15..6f2765c39 100644 --- a/clients/tablet/core/domain/build.gradle.kts +++ b/clients/tablet/core/domain/build.gradle.kts @@ -8,12 +8,15 @@ kotlin { api(project(":clients:shared:core")) api(libs.kotlinx.datetime) implementation(libs.kotlin.coroutines.core) - implementation(libs.settings) implementation(libs.bundles.koin) } androidMain.dependencies { implementation(libs.coroutines.android) implementation(libs.koin.android) + implementation(libs.settings) + } + iosMain.dependencies { + implementation(libs.settings) } } } diff --git a/clients/tablet/core/domain/src/androidMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.android.kt b/clients/tablet/core/domain/src/androidMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.android.kt new file mode 100644 index 000000000..f5152c2ec --- /dev/null +++ b/clients/tablet/core/domain/src/androidMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.android.kt @@ -0,0 +1,19 @@ +package band.effective.office.tablet.core.domain.di + +import android.content.Context +import band.effective.office.tablet.core.domain.model.SettingsStore +import com.russhwolf.settings.SharedPreferencesSettings +import org.koin.dsl.module + +actual fun settingsStoreModule() = module { + single { + val settings = SharedPreferencesSettings( + get().getSharedPreferences("settings", Context.MODE_PRIVATE) + ) + object : SettingsStore { + override fun getString(key: String, defaultValue: String) = settings.getString(key, defaultValue) + override fun putString(key: String, value: String) = settings.putString(key, value) + override fun remove(key: String) = settings.remove(key) + } + } +} diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.kt index bcee076bf..5ea5b9460 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.kt @@ -1,5 +1,6 @@ package band.effective.office.tablet.core.domain.di +import band.effective.office.tablet.core.domain.model.SettingsManager import band.effective.office.tablet.core.domain.useCase.CheckBookingUseCase import band.effective.office.tablet.core.domain.useCase.CheckSettingsUseCase import band.effective.office.tablet.core.domain.useCase.CreateBookingUseCase @@ -19,9 +20,17 @@ import band.effective.office.tablet.core.domain.useCase.SlotUseCase import band.effective.office.tablet.core.domain.useCase.TimerUseCase import band.effective.office.tablet.core.domain.useCase.UpdateBookingUseCase import band.effective.office.tablet.core.domain.useCase.UpdateUseCase +import org.koin.core.module.Module import org.koin.dsl.module +expect fun settingsStoreModule(): Module + val domainModule = module { + + includes(settingsStoreModule()) + + single { SettingsManager(settings = get()) } + // Booking use cases single { CreateBookingUseCase( @@ -68,9 +77,9 @@ val domainModule = module { // Other use cases single { OrganizersInfoUseCase(repository = get()) } single { CheckBookingUseCase(roomInfoUseCase = get()) } - single { CheckSettingsUseCase() } + single { CheckSettingsUseCase(settingsManager = get()) } single { SelectRoomUseCase() } - single { SetRoomUseCase() } + single { SetRoomUseCase(settingsManager = get()) } single { SlotUseCase() } single { TimerUseCase() } single { UpdateUseCase(roomInfoUseCase = get(), timerUseCase = get()) } diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/RoomInfo.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/RoomInfo.kt index 8f224438e..deb5f5312 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/RoomInfo.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/RoomInfo.kt @@ -1,6 +1,6 @@ package band.effective.office.tablet.core.domain.model -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/Settings.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/Settings.kt index 7eaa5d55a..c4d859519 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/Settings.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/Settings.kt @@ -1,25 +1,6 @@ package band.effective.office.tablet.core.domain.model -import com.russhwolf.settings.* -class SettingsManager(private val settings: Settings) { - - companion object { - private const val KEY_NAME_ROOM = "nameRoom" - - // Singleton-like instance - private var currentInstance: SettingsManager? = null - - fun init(settings: Settings) { - if(currentInstance == null) { - currentInstance = SettingsManager(settings) - } - } - - fun current(): SettingsManager { - return currentInstance - ?: error("SettingsManager not initialized. Call SettingsManager.init(settings) first.") - } - } +class SettingsManager(private val settings: SettingsStore) { var currentRoomName: String get() = settings.getString(KEY_NAME_ROOM, "") @@ -36,5 +17,9 @@ class SettingsManager(private val settings: Settings) { fun removeRoomName() { settings.remove(KEY_NAME_ROOM) } + + private companion object { + const val KEY_NAME_ROOM = "nameRoom" + } } diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/SettingsStore.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/SettingsStore.kt new file mode 100644 index 000000000..4f8e34310 --- /dev/null +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/SettingsStore.kt @@ -0,0 +1,7 @@ +package band.effective.office.tablet.core.domain.model + +interface SettingsStore { + fun getString(key: String, defaultValue: String): String + fun putString(key: String, value: String) + fun remove(key: String) +} diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/CheckSettingsUseCase.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/CheckSettingsUseCase.kt index 544fee56a..01272a7c7 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/CheckSettingsUseCase.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/CheckSettingsUseCase.kt @@ -3,8 +3,8 @@ package band.effective.office.tablet.core.domain.useCase import band.effective.office.tablet.core.domain.model.SettingsManager /**use case for get settings values*/ -class CheckSettingsUseCase { +class CheckSettingsUseCase(private val settingsManager: SettingsManager) { /**Get current room from settings*/ operator fun invoke() = - SettingsManager.current().checkCurrentRoom() + settingsManager.checkCurrentRoom() } \ No newline at end of file diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/RoomInfoUseCase.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/RoomInfoUseCase.kt index 071e1d3cf..632ceff52 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/RoomInfoUseCase.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/RoomInfoUseCase.kt @@ -4,7 +4,7 @@ import band.effective.office.shared.core.domain.map import band.effective.office.tablet.core.domain.model.RoomInfo import band.effective.office.shared.core.domain.unbox import kotlin.collections.filter -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.coroutines.flow.map import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SelectRoomUseCase.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SelectRoomUseCase.kt index 3d16a7085..9000c237a 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SelectRoomUseCase.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SelectRoomUseCase.kt @@ -6,7 +6,7 @@ import band.effective.office.shared.core.utils.currentInstant import kotlin.math.absoluteValue import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SetRoomUseCase.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SetRoomUseCase.kt index b3f05a490..ab4df66cc 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SetRoomUseCase.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SetRoomUseCase.kt @@ -3,8 +3,8 @@ package band.effective.office.tablet.core.domain.useCase import band.effective.office.tablet.core.domain.model.SettingsManager /**Use case for set settings*/ -class SetRoomUseCase { +class SetRoomUseCase(private val settingsManager: SettingsManager) { /**save current room name*/ operator fun invoke(nameRoom: String) = - SettingsManager.current().updateSettings(nameRoom) + settingsManager.updateSettings(nameRoom) } \ No newline at end of file diff --git a/clients/tablet/core/domain/src/iosMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.ios.kt b/clients/tablet/core/domain/src/iosMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.ios.kt new file mode 100644 index 000000000..c434f5044 --- /dev/null +++ b/clients/tablet/core/domain/src/iosMain/kotlin/band/effective/office/tablet/core/domain/di/domainModule.ios.kt @@ -0,0 +1,18 @@ +package band.effective.office.tablet.core.domain.di + +import band.effective.office.tablet.core.domain.model.SettingsStore +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.KeychainSettings +import org.koin.dsl.module + +@OptIn(ExperimentalSettingsImplementation::class) +actual fun settingsStoreModule() = module { + single { + val settings = KeychainSettings("effectiveOffice") + object : SettingsStore { + override fun getString(key: String, defaultValue: String) = settings.getString(key, defaultValue) + override fun putString(key: String, value: String) = settings.putString(key, value) + override fun remove(key: String) = settings.remove(key) + } + } +} 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/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt b/clients/tablet/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt index 28a122dee..74d9d1260 100644 --- a/clients/tablet/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt +++ b/clients/tablet/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt @@ -2,5 +2,6 @@ package band.effective.office.tablet.core.ui.utils import platform.Foundation.NSLocale import platform.Foundation.currentLocale +import platform.Foundation.languageCode -actual fun getCurrentLanguageCode(): String = NSLocale.currentLocale.languageCode \ No newline at end of file +actual fun getCurrentLanguageCode(): String = NSLocale.currentLocale.languageCode ?: "en" \ 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/build.gradle.kts b/clients/tablet/feature/bookingEditor/build.gradle.kts index 51e029ee5..125da6ec6 100644 --- a/clients/tablet/feature/bookingEditor/build.gradle.kts +++ b/clients/tablet/feature/bookingEditor/build.gradle.kts @@ -2,6 +2,17 @@ plugins { id("band.effective.office.client.kmp.feature") } +kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.calf.ui) + } + iosMain.dependencies { + implementation(libs.calf.ui) + } + } +} + compose.resources { publicResClass = false packageOfResClass = "band.effective.office.tablet.feature.bookingEditor" diff --git a/clients/tablet/feature/bookingEditor/src/androidMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.android.kt b/clients/tablet/feature/bookingEditor/src/androidMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.android.kt new file mode 100644 index 000000000..8349311f4 --- /dev/null +++ b/clients/tablet/feature/bookingEditor/src/androidMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.android.kt @@ -0,0 +1,72 @@ +package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components + +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 +import band.effective.office.shared.core.utils.asInstant +import band.effective.office.shared.core.utils.asLocalDateTime +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette +import com.mohamedrejeb.calf.ui.datepicker.AdaptiveDatePicker +import com.mohamedrejeb.calf.ui.datepicker.rememberAdaptiveDatePickerState +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun DatePickerView( + modifier: Modifier, + currentDate: LocalDateTime, + onChangeDate: (LocalDate) -> Unit, +) { + // Create and remember the date picker state + val state = rememberAdaptiveDatePickerState( + initialSelectedDateMillis = currentDate.asInstant.toEpochMilliseconds(), + ) + + // React to date changes + LaunchedEffect(state.selectedDateMillis) { + val selectedDate = state.selectedDateMillis?.let { millis -> + Instant.fromEpochMilliseconds(millis).asLocalDateTime.date + } + + selectedDate?.let { + val year = it.year + val month = it.month + val day = it.dayOfMonth + onChangeDate(LocalDate(year, month, day)) + } + } + + val palette = LocalCustomColorsPalette.current + val accent = MaterialTheme.colorScheme.primary + val onAccent = MaterialTheme.colorScheme.onPrimary + + Column { + AdaptiveDatePicker( + state = state, + modifier = modifier, + colors = DatePickerDefaults.colors( + 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/androidMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.android.kt b/clients/tablet/feature/bookingEditor/src/androidMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.android.kt new file mode 100644 index 000000000..474c4eb4e --- /dev/null +++ b/clients/tablet/feature/bookingEditor/src/androidMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.android.kt @@ -0,0 +1,61 @@ +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 +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper +import com.mohamedrejeb.calf.ui.timepicker.AdaptiveTimePicker +import com.mohamedrejeb.calf.ui.timepicker.rememberAdaptiveTimePickerState +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun TimePickerView( + modifier: Modifier, + currentDate: LocalDateTime, + onSnap: (LocalTime) -> Unit, +) { + val is24Hour = DateDisplayMapper.is24HourFormat() + + val state = rememberAdaptiveTimePickerState( + initialHour = currentDate.hour, + initialMinute = currentDate.minute, + is24Hour = is24Hour + ) + LaunchedEffect(state.hour, state.minute) { + val hour = state.hour + val minute = state.minute + 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 = 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, + ) + ) +} 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 355e9fb96..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,49 +14,53 @@ 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 -import kotlinx.datetime.Clock +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..98989fa2d 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 @@ -1,54 +1,13 @@ package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.DatePickerDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import band.effective.office.shared.core.utils.asInstant -import band.effective.office.shared.core.utils.asLocalDateTime -import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette -import com.mohamedrejeb.calf.ui.datepicker.AdaptiveDatePicker -import com.mohamedrejeb.calf.ui.datepicker.rememberAdaptiveDatePickerState -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun DatePickerView( +expect fun DatePickerView( modifier: Modifier = Modifier, currentDate: LocalDateTime, onChangeDate: (LocalDate) -> Unit, -) { - // Create and remember the date picker state - val state = rememberAdaptiveDatePickerState( - initialSelectedDateMillis = currentDate.asInstant.toEpochMilliseconds(), - ) - - // React to date changes - LaunchedEffect(state.selectedDateMillis) { - val selectedDate = state.selectedDateMillis?.let { millis -> - Instant.fromEpochMilliseconds(millis).asLocalDateTime.date - } - - selectedDate?.let { - val year = it.year - val month = it.month - val day = it.dayOfMonth - onChangeDate(LocalDate(year, month, day)) - } - } - - Column { - // Display the date picker - AdaptiveDatePicker( - state = state, - modifier = modifier, - colors = DatePickerDefaults.colors( - containerColor = LocalCustomColorsPalette.current.elevationBackground, - ), - ) - } -} +) 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..571cb09b4 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,44 +1,13 @@ package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TimePickerDefaults -import androidx.compose.material3.TimePickerLayoutType import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette -import band.effective.office.tablet.core.ui.utils.DateDisplayMapper -import com.mohamedrejeb.calf.ui.timepicker.AdaptiveTimePicker -import com.mohamedrejeb.calf.ui.timepicker.rememberAdaptiveTimePickerState import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun TimePickerView( +expect fun TimePickerView( modifier: Modifier = Modifier, currentDate: LocalDateTime, - onSnap: (LocalTime) -> Unit -) { - val is24Hour = DateDisplayMapper.is24HourFormat() - - val state = rememberAdaptiveTimePickerState( - initialHour = currentDate.hour, - initialMinute = currentDate.minute, - is24Hour = is24Hour - ) - LaunchedEffect(state.hour, state.minute) { - val hour = state.hour - val minute = state.minute - onSnap(LocalTime(hour, minute, 0)) - } - - AdaptiveTimePicker( - state = state, - modifier = modifier, - layoutType = TimePickerLayoutType.Vertical, - colors = TimePickerDefaults.colors( - containerColor = LocalCustomColorsPalette.current.elevationBackground, - ) - ) -} \ No newline at end of file + onSnap: (LocalTime) -> Unit, +) diff --git a/clients/tablet/feature/bookingEditor/src/iosMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.ios.kt b/clients/tablet/feature/bookingEditor/src/iosMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.ios.kt new file mode 100644 index 000000000..8349311f4 --- /dev/null +++ b/clients/tablet/feature/bookingEditor/src/iosMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.ios.kt @@ -0,0 +1,72 @@ +package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components + +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 +import band.effective.office.shared.core.utils.asInstant +import band.effective.office.shared.core.utils.asLocalDateTime +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette +import com.mohamedrejeb.calf.ui.datepicker.AdaptiveDatePicker +import com.mohamedrejeb.calf.ui.datepicker.rememberAdaptiveDatePickerState +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun DatePickerView( + modifier: Modifier, + currentDate: LocalDateTime, + onChangeDate: (LocalDate) -> Unit, +) { + // Create and remember the date picker state + val state = rememberAdaptiveDatePickerState( + initialSelectedDateMillis = currentDate.asInstant.toEpochMilliseconds(), + ) + + // React to date changes + LaunchedEffect(state.selectedDateMillis) { + val selectedDate = state.selectedDateMillis?.let { millis -> + Instant.fromEpochMilliseconds(millis).asLocalDateTime.date + } + + selectedDate?.let { + val year = it.year + val month = it.month + val day = it.dayOfMonth + onChangeDate(LocalDate(year, month, day)) + } + } + + val palette = LocalCustomColorsPalette.current + val accent = MaterialTheme.colorScheme.primary + val onAccent = MaterialTheme.colorScheme.onPrimary + + Column { + AdaptiveDatePicker( + state = state, + modifier = modifier, + colors = DatePickerDefaults.colors( + 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/iosMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.ios.kt b/clients/tablet/feature/bookingEditor/src/iosMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.ios.kt new file mode 100644 index 000000000..474c4eb4e --- /dev/null +++ b/clients/tablet/feature/bookingEditor/src/iosMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.ios.kt @@ -0,0 +1,61 @@ +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 +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper +import com.mohamedrejeb.calf.ui.timepicker.AdaptiveTimePicker +import com.mohamedrejeb.calf.ui.timepicker.rememberAdaptiveTimePickerState +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun TimePickerView( + modifier: Modifier, + currentDate: LocalDateTime, + onSnap: (LocalTime) -> Unit, +) { + val is24Hour = DateDisplayMapper.is24HourFormat() + + val state = rememberAdaptiveTimePickerState( + initialHour = currentDate.hour, + initialMinute = currentDate.minute, + is24Hour = is24Hour + ) + LaunchedEffect(state.hour, state.minute) { + val hour = state.hour + val minute = state.minute + 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 = 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, + ) + ) +} 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/build.gradle.kts b/clients/tablet/feature/main/build.gradle.kts index 842627990..2f317920b 100644 --- a/clients/tablet/feature/main/build.gradle.kts +++ b/clients/tablet/feature/main/build.gradle.kts @@ -6,8 +6,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(project(":clients:tablet:feature:slot")) - implementation("org.jetbrains.compose.components:components-ui-tooling-preview:1.10.0") - + implementation(compose.components.uiToolingPreview) } } } diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/RoomInfoView.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/RoomInfoView.kt index 719ddf98b..75c92db90 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/RoomInfoView.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/RoomInfoView.kt @@ -12,7 +12,7 @@ import band.effective.office.tablet.core.domain.model.nextEvent import band.effective.office.tablet.core.ui.theme.AppTheme import band.effective.office.tablet.feature.main.components.uiComponent.BusyRoomInfoComponent import band.effective.office.tablet.feature.main.components.uiComponent.FreeRoomInfoComponent -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime 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/domain/CurrentTimeHolder.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/CurrentTimeHolder.kt index 50204e610..ec2e56722 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/CurrentTimeHolder.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/CurrentTimeHolder.kt @@ -3,7 +3,7 @@ package band.effective.office.tablet.feature.main.domain import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/GetTimeToNextEventUseCase.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/GetTimeToNextEventUseCase.kt index e78abe2ac..29742246c 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/GetTimeToNextEventUseCase.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/GetTimeToNextEventUseCase.kt @@ -2,7 +2,7 @@ package band.effective.office.tablet.feature.main.domain import band.effective.office.tablet.core.domain.model.RoomInfo import band.effective.office.tablet.core.domain.model.nextEvent -import kotlinx.datetime.Clock +import kotlin.time.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant 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