Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -26,8 +23,5 @@ kotlin {
androidMain.dependencies {
implementation(libs.findLibrary("koin-android").get())
}
iosMain.dependencies {
implementation(libs.findLibrary("essenty.darwin.runtime").get())
}
}
}
4 changes: 2 additions & 2 deletions clients/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions clients/tablet/composeApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 4 additions & 12 deletions clients/tablet/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,8 @@ kotlin {
baseName = "ComposeApp"
isStatic = true

export(libs.decompose)
export(libs.decompose.compose.jetbrains)
export(libs.essenty.lifecycle)

export(libs.calf.ui)
}
it.compilations.all {
compileTaskProvider.configure {
compilerOptions.freeCompilerArgs.add("-Xdisable-decompose-parcelize")
}
}

}

sourceSets {
Expand All @@ -45,11 +35,13 @@ kotlin {
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)

api(libs.decompose)
api(libs.decompose.compose.jetbrains)
api(libs.calf.ui)
api(libs.kotlinx.datetime)

implementation(libs.jetbrains.navigation.compose)
implementation(libs.jetbrains.lifecycle.viewmodel)
implementation(libs.kotlinx.serialization.json)

implementation(libs.kotlin.coroutines.core)

implementation(libs.bundles.koin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<KioskManager?> { null }

Expand All @@ -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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ResourceDisposerUseCase>()
val checkSettingsUseCase = koinInject<CheckSettingsUseCase>()

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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package band.effective.office.tablet.di
import band.effective.office.tablet.core.data.di.dataModule
import band.effective.office.tablet.core.domain.di.domainModule
import band.effective.office.tablet.feature.bookingEditor.di.bookingEditorModule
import band.effective.office.tablet.feature.fastBooking.di.fastBookingModule
import band.effective.office.tablet.feature.main.di.mainScreenModule
import band.effective.office.tablet.feature.settings.di.settingsModule
import band.effective.office.tablet.feature.slot.di.slotDiModule
import org.koin.core.context.startKoin

Expand All @@ -16,7 +18,9 @@ class KoinInitializer {
dataModule,
domainModule,
mainScreenModule,
settingsModule,
bookingEditorModule,
fastBookingModule,
slotDiModule,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EventInfo>() to serializableNavType<EventInfo>())

/** 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<SettingsRoute> {
SettingsScreen(
onNavigateToMain = {
navController.navigate(MainRoute) {
popUpTo<SettingsRoute> { inclusive = true }
}
},
)
}

composable<MainRoute> {
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<FreeRoomRoute>(typeMap = AppNavHostData.eventTypeMap, dialogProperties = AppNavHostData.dialogProperties) { entry ->
val route = entry.toRoute<FreeRoomRoute>()
val viewModel = koinViewModel<FreeSelectRoomViewModel> {
parametersOf(route.event, route.roomName)
}
DialogBackgroundDim(onDismiss = { navController.popBackStack() }) {
FreeSelectRoomView(
onClose = { navController.popBackStack() },
viewModel = viewModel,
)
}
}

navigation<BookingFlowRoute>(
startDestination = BookingEditorRoute,
typeMap = AppNavHostData.eventTypeMap,
) {
dialog<BookingEditorRoute>(dialogProperties = AppNavHostData.dialogProperties) {
val flowEntry = remember(it) { navController.getBackStackEntry<BookingFlowRoute>() }
val route = flowEntry.toRoute<BookingFlowRoute>()
val viewModel = koinViewModel<BookingEditorViewModel>(viewModelStoreOwner = flowEntry) {
parametersOf(route.event, route.room)
}
DialogBackgroundDim(onDismiss = { navController.popBackStack() }) {
BookingEditor(
viewModel = viewModel,
onClose = { navController.popBackStack() },
onOpenDateTimePicker = { navController.navigate(DateTimePickerRoute) },
)
}
}

dialog<DateTimePickerRoute>(dialogProperties = AppNavHostData.dialogProperties) {
val flowEntry = remember(it) { navController.getBackStackEntry<BookingFlowRoute>() }
val viewModel = koinViewModel<BookingEditorViewModel>(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<FastBookingRoute>(dialogProperties = AppNavHostData.dialogProperties) { entry ->
val minDuration = entry.toRoute<FastBookingRoute>().minEventDuration
val mainEntry = remember(entry) { navController.getBackStackEntry<MainRoute>() }
val mainViewModel = koinViewModel<MainViewModel>(viewModelStoreOwner = mainEntry)
val mainSnapshot = remember { mainViewModel.state.value }
val viewModel = koinViewModel<FastBookingViewModel> {
parametersOf(
minDuration,
mainSnapshot.roomList[mainSnapshot.indexSelectRoom],
mainSnapshot.roomList,
)
}
DialogBackgroundDim(onDismiss = { navController.popBackStack() }) {
FastBooking(
viewModel = viewModel,
onClose = { navController.popBackStack() },
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading