Skip to content

Commit

Permalink
ANDROAPP-5672-mobile-ui-org-unit-tree-component (#170)
Browse files Browse the repository at this point in the history
* Add custom DHIS2 icons for org tree items

* Add `OrgBottomSheet`

* Add org tree bottom sheet screen

* Add no results found view to org tree bottom sheet

* Disable clear all button if no items are selected in org tree

* Mark title as nullable in `OrgBottomSheet`

* Lock org tree dialog height based on org tree list to avoid jumping when list is updated

* Highlight search query in matching org tree items

* Rename `dhis2_org_tree_item_circle` to `material_circle_outline`

* Replace `material_circle_outline` icon with 24dp frame icon

* Use material icons for arrow icons in org tree item

* Remove unused icons

* Fix org bottom sheet sample crash

* Add tests for org bottom sheet
  • Loading branch information
msasikanth authored Jan 24, 2024
1 parent fab6881 commit ce0ac08
Show file tree
Hide file tree
Showing 7 changed files with 674 additions and 1 deletion.
4 changes: 3 additions & 1 deletion common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import org.hisp.dhis.common.screens.LegendDescriptionScreen
import org.hisp.dhis.common.screens.LegendScreen
import org.hisp.dhis.common.screens.ListCardScreen
import org.hisp.dhis.common.screens.MetadataAvatarScreen
import org.hisp.dhis.common.screens.OrgTreeBottomSheetScreen
import org.hisp.dhis.common.screens.ProgressScreen
import org.hisp.dhis.common.screens.RadioButtonScreen
import org.hisp.dhis.common.screens.SearchBarScreen
Expand All @@ -92,7 +93,7 @@ fun App() {

@Composable
fun Main() {
val currentScreen = remember { mutableStateOf(Components.BUTTON) }
val currentScreen = remember { mutableStateOf(Components.ORG_TREE_BOTTOM_SHEET) }
var expanded by remember { mutableStateOf(false) }

Column(
Expand Down Expand Up @@ -197,6 +198,7 @@ fun Main() {
Components.SEARCH_BAR -> SearchBarScreen()
Components.INPUT_NOT_SUPPORTED -> InputNotSupportedScreen()
Components.FULL_SCREEN_IMAGE -> FullScreenImageScreen()
Components.ORG_TREE_BOTTOM_SHEET -> OrgTreeBottomSheetScreen()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@ enum class Components(val label: String) {
SEARCH_BAR("Search bar"),
INPUT_NOT_SUPPORTED("Input Not Supported"),
FULL_SCREEN_IMAGE("Full Screen Image"),
ORG_TREE_BOTTOM_SHEET("Org Tree Bottom Sheet"),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package org.hisp.dhis.common.screens

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.hisp.dhis.mobile.ui.designsystem.component.Button
import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle
import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer
import org.hisp.dhis.mobile.ui.designsystem.component.OrgBottomSheet
import org.hisp.dhis.mobile.ui.designsystem.component.OrgTreeItem
import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle

@Composable
fun OrgTreeBottomSheetScreen() {
var showOrgTreeBottomSheet by rememberSaveable { mutableStateOf(false) }

if (showOrgTreeBottomSheet) {
val orgTreeItemsRepo = remember { OrgTreeItemsFakeRepo() }
val orgTreeItems by orgTreeItemsRepo.state.collectAsState(emptyList())

OrgBottomSheet(
orgTreeItems = orgTreeItems,
onDismiss = {
showOrgTreeBottomSheet = false
},
onSearch = orgTreeItemsRepo::search,
onItemClick = orgTreeItemsRepo::toggleItemExpansion,
onItemSelected = { uid, checked ->
orgTreeItemsRepo.toggleItemSelection(uid, checked)
},
onClearAll = { orgTreeItemsRepo.clearItemSelections() },
onDone = {
// no-op
},
)
}

ColumnComponentContainer {
SubTitle("Org Tree Bottom Sheet")
Button(
enabled = true,
ButtonStyle.FILLED,
text = "Show Org Tree Bottom Sheet",
) {
showOrgTreeBottomSheet = !showOrgTreeBottomSheet
}
}
}

private class OrgTreeItemsFakeRepo {

private val originalOrgTreeItems = listOf(
OrgTreeItem(
uid = "12",
label = "Krishna",
isOpen = true,
hasChildren = true,
),
OrgTreeItem(
uid = "21",
label = "Guntur",
isOpen = false,
hasChildren = false,
),
)

private val childrenOrgItems = listOf(
OrgTreeItem(
uid = "12-1",
label = "Vijayawada",
isOpen = false,
level = 1,
hasChildren = false,
),
OrgTreeItem(
uid = "12-2",
label = "Gudivada",
isOpen = false,
level = 1,
hasChildren = false,
),
)

private val coroutineScope = CoroutineScope(Dispatchers.Default)

private val _state = MutableStateFlow(createList(originalOrgTreeItems, childrenOrgItems))
val state: StateFlow<List<OrgTreeItem>> =
_state.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = createList(originalOrgTreeItems, childrenOrgItems),
)

fun search(query: String) {
coroutineScope.launch {
if (query.isNotBlank()) {
val filteredList = originalOrgTreeItems.filter { it.label.contains(query, ignoreCase = true) }
_state.emit(filteredList)
} else {
_state.emit(originalOrgTreeItems)
}
}
}

fun toggleItemExpansion(uid: String) {
coroutineScope.launch {
val updatedList = _state.value
.map {
if (it.hasChildren && it.uid == uid) {
it.copy(isOpen = !it.isOpen)
} else {
it
}
}
val parentItem = updatedList.first { it.uid == uid }

val newList = if (parentItem.isOpen) {
createList(updatedList, childrenOrgItems)
} else {
updatedList.filterNot { it.uid.contains(uid) && it.level > 0 }
}

_state.emit(newList)
}
}

fun toggleItemSelection(uid: String, selected: Boolean) {
coroutineScope.launch {
val selectionToggledList = _state.value.map {
if (it.uid.contains(uid, ignoreCase = true)) {
val selectedChildrenCount =
_state.value.count { it.uid.contains("12") && it.level > 0 && it.selected }

it.copy(selected = selected, selectedChildrenCount = selectedChildrenCount)
} else {
it
}
}

val newList = selectionToggledList.map {
if (!uid.contains("12")) {
return@map it
}

when (it.uid) {
"12" -> {
val selectedChildrenCount = getSelectedChildrenCount(selectionToggledList, it)
it.copy(selectedChildrenCount = selectedChildrenCount)
}
else -> {
it.copy(selectedChildrenCount = 0)
}
}
}

_state.emit(newList)
}
}

private fun getSelectedChildrenCount(
selectionToggledList: List<OrgTreeItem>,
it: OrgTreeItem,
): Int {
val hasChildrenItems = selectionToggledList.any { it.uid == "12-1" || it.uid == "12-2" }

return if (hasChildrenItems) {
selectionToggledList.count { it.uid.contains("12") && it.level > 0 && it.selected }
} else {
if (it.selected) 2 else 0
}
}

fun clearItemSelections() {
coroutineScope.launch {
_state.emit(_state.value.map { it.copy(selected = false) })
}
}

private fun createList(parentItems: List<OrgTreeItem>, childrenItems: List<OrgTreeItem>): List<OrgTreeItem> {
val updatedChildrenItems = childrenItems.map {
if (parentItems.first { it.uid == "12" }.selected) {
it.copy(selected = true)
} else {
it
}
}

return (parentItems + updatedChildrenItems).sortedBy { it.uid }
}
}
Loading

0 comments on commit ce0ac08

Please sign in to comment.