Add dashboard arrangement dropdown to choose items per row (#411)

Co-authored-by: Sebastian Neubauer <sebastian.neubauer@exaring.de>
This commit is contained in:
Sebastian Neubauer 2025-12-01 09:47:26 +01:00 • committed by GitHub
parent fa7aa8c521
commit a8fef6dfaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 222 additions and 2 deletions

View file

@ -3,17 +3,20 @@ package io.github.openflocon.flocondesktop.features.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.openflocon.domain.common.DispatcherProvider
import io.github.openflocon.domain.dashboard.models.DashboardId
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.usecase.DeleteCurrentDeviceSelectedDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.DeleteDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.ObserveCurrentDeviceDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.ObserveDashboardArrangementUseCase
import io.github.openflocon.domain.dashboard.usecase.SendCheckBoxUpdateDeviceDeviceUseCase
import io.github.openflocon.domain.dashboard.usecase.SendClickEventToDeviceDeviceUseCase
import io.github.openflocon.domain.dashboard.usecase.SelectDashboardArrangementUseCase
import io.github.openflocon.domain.dashboard.usecase.SubmitFormToDeviceDeviceUseCase
import io.github.openflocon.domain.dashboard.usecase.SubmitTextFieldToDeviceDeviceUseCase
import io.github.openflocon.domain.feedback.FeedbackDisplayer
import io.github.openflocon.flocondesktop.features.dashboard.delegate.DashboardSelectorDelegate
import io.github.openflocon.flocondesktop.features.dashboard.mapper.toUi
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardArrangement
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardViewState
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardsStateUiModel
import io.github.openflocon.flocondesktop.features.dashboard.model.DeviceDashboardUiModel
@ -35,10 +38,22 @@ class DashboardViewModel(
private val feedbackDisplayer: FeedbackDisplayer,
private val deleteCurrentDeviceSelectedDashboardUseCase: DeleteCurrentDeviceSelectedDashboardUseCase,
private val deleteDashboardUseCase: DeleteDashboardUseCase,
private val observeDashboardArrangementUseCase: ObserveDashboardArrangementUseCase,
private val selectDashboardArrangementUseCase: SelectDashboardArrangementUseCase,
) : ViewModel(dashboardSelectorDelegate) {
val deviceDashboards: StateFlow<DashboardsStateUiModel> = dashboardSelectorDelegate.deviceDashboards
val arrangement: StateFlow<DashboardArrangement> =
observeDashboardArrangementUseCase()
.map { it.toUi() }
.flowOn(dispatcherProvider.viewModel)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
DashboardArrangement.Adaptive,
)
val state: StateFlow<DashboardViewState?> =
observeCurrentDeviceDashboardUseCase()
.map { it?.toUi() }
@ -98,4 +113,15 @@ class DashboardViewModel(
feedbackDisplayer.displayMessage("Dashboard removed")
}
}
fun onArrangementClicked(arrangement: DashboardArrangement) {
viewModelScope.launch(dispatcherProvider.viewModel) {
selectDashboardArrangementUseCase(
when (arrangement) {
is DashboardArrangement.Adaptive -> DashboardArrangementDomainModel.Adaptive
is DashboardArrangement.Fixed -> DashboardArrangementDomainModel.Fixed(itemsPerRow = arrangement.itemsPerRow)
}
)
}
}
}

View file

@ -2,6 +2,7 @@ package io.github.openflocon.flocondesktop.features.dashboard.mapper
import androidx.compose.ui.graphics.Color
import io.github.openflocon.domain.dashboard.models.ContainerConfigDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardElementDomainModel
import io.github.openflocon.domain.dashboard.models.FormContainerConfigDomainModel
@ -10,6 +11,7 @@ import io.github.openflocon.flocondesktop.common.ui.JsonPrettyPrinter
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardContainerViewState
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardContainerViewState.ContainerConfig
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardViewState
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardArrangement
internal fun DashboardDomainModel.toUi(): DashboardViewState = DashboardViewState(
items = containers.map { container ->
@ -71,3 +73,9 @@ internal fun ContainerConfigDomainModel.toUI(): ContainerConfig =
SectionContainerConfigDomainModel -> ContainerConfig.Section
}
internal fun DashboardArrangementDomainModel.toUi(): DashboardArrangement =
when (this) {
is DashboardArrangementDomainModel.Adaptive -> DashboardArrangement.Adaptive
is DashboardArrangementDomainModel.Fixed -> DashboardArrangement.Fixed(itemsPerRow = itemsPerRow)
}

View file

@ -0,0 +1,8 @@
package io.github.openflocon.flocondesktop.features.dashboard.model
sealed interface DashboardArrangement {
data object Adaptive : DashboardArrangement
data class Fixed(val itemsPerRow: Int) : DashboardArrangement
}

View file

@ -0,0 +1,61 @@
package io.github.openflocon.flocondesktop.features.dashboard.view
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardArrangement
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.FloconButton
import io.github.openflocon.library.designsystem.components.FloconDropdownMenu
import io.github.openflocon.library.designsystem.components.FloconDropdownMenuItem
@Composable
internal fun DashboardArrangementView(
arrangement: DashboardArrangement,
onArrangementClicked: (DashboardArrangement) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
FloconDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
onExpandRequest = { expanded = true },
anchorContent = {
FloconButton(
onClick = { expanded = true },
containerColor = FloconTheme.colorPalette.secondary
) {
Text(
text = when (arrangement) {
is DashboardArrangement.Adaptive -> "Items per row: Auto"
is DashboardArrangement.Fixed -> "Items per row: ${arrangement.itemsPerRow}"
},
style = FloconTheme.typography.bodySmall
)
}
},
modifier = modifier
) {
FloconDropdownMenuItem(
text = "Auto",
onClick = {
onArrangementClicked(DashboardArrangement.Adaptive)
expanded = false
},
)
repeat(5) { itemCount ->
FloconDropdownMenuItem(
text = "${itemCount + 1}",
onClick = {
onArrangementClicked(DashboardArrangement.Fixed(itemsPerRow = itemCount + 1))
expanded = false
}
)
}
}
}

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.github.openflocon.flocondesktop.features.dashboard.DashboardViewModel
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardArrangement
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardViewState
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardsStateUiModel
import io.github.openflocon.flocondesktop.features.dashboard.model.DeviceDashboardUiModel
@ -30,6 +31,7 @@ fun DashboardScreen(modifier: Modifier = Modifier) {
val viewModel: DashboardViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val deviceDashboards by viewModel.deviceDashboards.collectAsStateWithLifecycle()
val arrangement by viewModel.arrangement.collectAsStateWithLifecycle()
DisposableEffect(viewModel) {
viewModel.onVisible()
@ -40,6 +42,7 @@ fun DashboardScreen(modifier: Modifier = Modifier) {
DashboardScreen(
state = state,
deviceDashboards = deviceDashboards,
arrangement = arrangement,
modifier = modifier,
onDashboardSelected = viewModel::onDashboardSelected,
onClickButton = viewModel::onButtonClicked,
@ -48,6 +51,7 @@ fun DashboardScreen(modifier: Modifier = Modifier) {
onUpdateCheckBox = viewModel::onUpdateCheckBox,
deleteCurrentDashboard = viewModel::deleteCurrentDashboard,
onDeleteClicked = viewModel::onDeleteClicked,
onArrangementClicked = viewModel::onArrangementClicked
)
}
@ -55,9 +59,11 @@ fun DashboardScreen(modifier: Modifier = Modifier) {
fun DashboardScreen(
state: DashboardViewState?,
deviceDashboards: DashboardsStateUiModel,
arrangement: DashboardArrangement,
onDashboardSelected: (DeviceDashboardUiModel) -> Unit,
onDeleteClicked: (DeviceDashboardUiModel) -> Unit,
deleteCurrentDashboard: () -> Unit,
onArrangementClicked: (DashboardArrangement) -> Unit,
onClickButton: (buttonId: String) -> Unit,
submitTextField: (textFieldId: String, value: String) -> Unit,
submitForm: (formId: String, formValues: Map<String, Any>) -> Unit,
@ -81,6 +87,11 @@ fun DashboardScreen(
Box(modifier = Modifier.weight(1f)) // to have actions on the right
},
actions = {
DashboardArrangementView(
onArrangementClicked = onArrangementClicked,
arrangement = arrangement
)
FloconOverflow {
FloconDropdownMenuItem(
text = "Delete Dashboards",
@ -103,6 +114,7 @@ fun DashboardScreen(
submitTextField = submitTextField,
submitForm = submitForm,
onUpdateCheckBox = onUpdateCheckBox,
arrangement = arrangement
)
}
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardArrangement
import io.github.openflocon.flocondesktop.features.dashboard.model.DashboardViewState
import io.github.openflocon.flocondesktop.features.dashboard.model.previewDashboardViewState
import io.github.openflocon.library.designsystem.FloconTheme
@ -20,11 +21,15 @@ fun DashboardView(
submitTextField: (textFieldId: String, value: String) -> Unit,
submitForm: (formId: String, values: Map<String, Any>) -> Unit,
onUpdateCheckBox: (checkBoxId: String, value: Boolean) -> Unit,
arrangement: DashboardArrangement,
modifier: Modifier = Modifier,
) {
LazyVerticalStaggeredGrid(
modifier = modifier,
columns = StaggeredGridCells.Adaptive(minSize = 300.dp),
columns = when(arrangement) {
is DashboardArrangement.Adaptive -> StaggeredGridCells.Adaptive(minSize = 300.dp)
is DashboardArrangement.Fixed -> StaggeredGridCells.Fixed(arrangement.itemsPerRow)
},
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalItemSpacing = 8.dp,
) {
@ -51,6 +56,7 @@ private fun DashboardViewPreview() {
submitTextField = { _, _ -> },
submitForm = { _, _ -> },
onUpdateCheckBox = { _, _ -> },
arrangement = DashboardArrangement.Adaptive
)
}
}

View file

@ -1,5 +1,6 @@
package io.github.openflocon.data.core.dashboard.datasource
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardId
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import kotlinx.coroutines.flow.Flow
@ -17,4 +18,11 @@ interface DeviceDashboardsDataSource {
dashboardId: DashboardId,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel
)
fun observeDashboardArrangement(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<DashboardArrangementDomainModel>
fun selectDashboardArrangement(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
arrangement: DashboardArrangementDomainModel,
)
}

View file

@ -5,6 +5,7 @@ import io.github.openflocon.data.core.dashboard.datasource.DeviceDashboardsDataS
import io.github.openflocon.data.core.dashboard.datasource.ToDeviceDashboardDataSource
import io.github.openflocon.domain.Protocol
import io.github.openflocon.domain.common.DispatcherProvider
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardId
import io.github.openflocon.domain.dashboard.repository.DashboardRepository
@ -136,6 +137,23 @@ class DashboardRepositoryImpl(
deviceIdAndPackageName = deviceIdAndPackageName,
).flowOn(dispatcherProvider.data)
override fun observeDashboardArrangement(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<DashboardArrangementDomainModel> =
deviceDashboardsDataSource.observeDashboardArrangement(
deviceIdAndPackageName = deviceIdAndPackageName,
).flowOn(dispatcherProvider.data)
override suspend fun selectDashboardArrangement(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
arrangement: DashboardArrangementDomainModel
) {
withContext(dispatcherProvider.data) {
deviceDashboardsDataSource.selectDashboardArrangement(
deviceIdAndPackageName = deviceIdAndPackageName,
arrangement = arrangement,
)
}
}
override suspend fun deleteDashboard(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
dashboardId: DashboardId

View file

@ -1,6 +1,7 @@
package io.github.openflocon.data.local.dashboard.datasource
import io.github.openflocon.data.core.dashboard.datasource.DeviceDashboardsDataSource
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardId
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import kotlinx.coroutines.flow.Flow
@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.update
class DeviceDashboardsDataSourceInMemory : DeviceDashboardsDataSource {
private val selectedDeviceDashboards = MutableStateFlow<Map<DeviceIdAndPackageNameDomainModel, DashboardId?>>(emptyMap())
private val selectedDashboardArrangements = MutableStateFlow<Map<DeviceIdAndPackageNameDomainModel, DashboardArrangementDomainModel>>(emptyMap())
override fun observeSelectedDeviceDashboard(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<DashboardId?> =
selectedDeviceDashboards
@ -31,4 +33,18 @@ class DeviceDashboardsDataSourceInMemory : DeviceDashboardsDataSource {
}
}
override fun observeDashboardArrangement(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<DashboardArrangementDomainModel> =
selectedDashboardArrangements
.map { it[deviceIdAndPackageName] ?: DashboardArrangementDomainModel.Adaptive }
.distinctUntilChanged()
override fun selectDashboardArrangement(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
arrangement: DashboardArrangementDomainModel
) {
selectedDashboardArrangements.update {
it + (deviceIdAndPackageName to arrangement)
}
}
}

View file

@ -5,10 +5,12 @@ import io.github.openflocon.domain.dashboard.usecase.DeleteDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.GetCurrentDeviceSelectedDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.ObserveCurrentDeviceDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.ObserveCurrentDeviceSelectedDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.ObserveDashboardArrangementUseCase
import io.github.openflocon.domain.dashboard.usecase.ObserveDeviceDashboardsUseCase
import io.github.openflocon.domain.dashboard.usecase.SelectCurrentDeviceDashboardUseCase
import io.github.openflocon.domain.dashboard.usecase.SendCheckBoxUpdateDeviceDeviceUseCase
import io.github.openflocon.domain.dashboard.usecase.SendClickEventToDeviceDeviceUseCase
import io.github.openflocon.domain.dashboard.usecase.SelectDashboardArrangementUseCase
import io.github.openflocon.domain.dashboard.usecase.SubmitFormToDeviceDeviceUseCase
import io.github.openflocon.domain.dashboard.usecase.SubmitTextFieldToDeviceDeviceUseCase
import org.koin.core.module.dsl.factoryOf
@ -26,6 +28,9 @@ internal val dashboardModule = module {
factoryOf(::ObserveDeviceDashboardsUseCase)
factoryOf(::SelectCurrentDeviceDashboardUseCase)
factoryOf(::ObserveDashboardArrangementUseCase)
factoryOf(::SelectDashboardArrangementUseCase)
factoryOf(::DeleteDashboardUseCase)
factoryOf(::DeleteCurrentDeviceSelectedDashboardUseCase)
}

View file

@ -0,0 +1,6 @@
package io.github.openflocon.domain.dashboard.models
sealed interface DashboardArrangementDomainModel {
data object Adaptive : DashboardArrangementDomainModel
data class Fixed(val itemsPerRow: Int) : DashboardArrangementDomainModel
}

View file

@ -1,5 +1,6 @@
package io.github.openflocon.domain.dashboard.repository
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardDomainModel
import io.github.openflocon.domain.dashboard.models.DashboardId
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
@ -12,6 +13,9 @@ interface DashboardRepository {
fun observeSelectedDeviceDashboard(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<DashboardId?>
fun observeDeviceDashboards(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<List<DashboardId>>
fun observeDashboardArrangement(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel): Flow<DashboardArrangementDomainModel>
suspend fun selectDashboardArrangement(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel, arrangement: DashboardArrangementDomainModel)
suspend fun sendClickEvent(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
dashboardId: DashboardId,

View file

@ -0,0 +1,23 @@
package io.github.openflocon.domain.dashboard.usecase
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.repository.DashboardRepository
import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
class ObserveDashboardArrangementUseCase(
private val dashboardRepository: DashboardRepository,
private val observeCurrentDeviceIdAndPackageNameUseCase: ObserveCurrentDeviceIdAndPackageNameUseCase
) {
operator fun invoke(): Flow<DashboardArrangementDomainModel> = observeCurrentDeviceIdAndPackageNameUseCase().flatMapLatest { model ->
if (model == null) {
flowOf(DashboardArrangementDomainModel.Adaptive)
} else {
dashboardRepository.observeDashboardArrangement(
deviceIdAndPackageName = model,
)
}
}
}

View file

@ -0,0 +1,19 @@
package io.github.openflocon.domain.dashboard.usecase
import io.github.openflocon.domain.dashboard.models.DashboardArrangementDomainModel
import io.github.openflocon.domain.dashboard.repository.DashboardRepository
import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdAndPackageNameUseCase
class SelectDashboardArrangementUseCase(
private val dashboardRepository: DashboardRepository,
private val getCurrentDeviceIdAndPackageNameUseCase: GetCurrentDeviceIdAndPackageNameUseCase,
) {
suspend operator fun invoke(arrangement: DashboardArrangementDomainModel) {
val current = getCurrentDeviceIdAndPackageNameUseCase() ?: return
dashboardRepository.selectDashboardArrangement(
deviceIdAndPackageName = current,
arrangement = arrangement,
)
}
}