diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 744f07163..437002b7f 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -9,11 +9,12 @@ on: branches: - main - develop + - release/* pull_request: branches: - main - develop - + - release/* # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/rebuild-docs.yml b/.github/workflows/rebuild-docs.yml new file mode 100644 index 000000000..a5b940923 --- /dev/null +++ b/.github/workflows/rebuild-docs.yml @@ -0,0 +1,18 @@ +name: 'Rebuild developer docs' + +on: + push: + branches: + - main + paths: + - 'docs/**' + +concurrency: + group: ${{ github.workflow}}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rebuild-docs: + runs-on: ubuntu-latest + steps: + - run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_DEVELOPER_DOCS_TOKEN }} diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml index 7e0124512..3e4038a70 100644 --- a/.github/workflows/release-start.yml +++ b/.github/workflows/release-start.yml @@ -42,7 +42,7 @@ jobs: run: git checkout -b release/${{ inputs.release_version_name }} - name: Run Python script to update release branch version - run: python scripts/updateVersionName.py ${{ inputs.release_version_name }} + run: python .github/workflows/scripts/updateVersionName.py ${{ inputs.release_version_name }} - name: Push run: | @@ -73,7 +73,7 @@ jobs: run: git checkout -b update_version_to${{ inputs.development_version_name }} - name: Run Python script to update base branch version - run: python scripts/updateVersionName.py ${{ inputs.development_version_name }} + run: python .github/workflows/scripts/updateVersionName.py ${{ inputs.development_version_name }} - name: Commit and Push Changes run: | diff --git a/android/src/main/java/org/hisp/dhis/android/MainActivity.kt b/android/src/main/java/org/hisp/dhis/android/MainActivity.kt index b17de2cd7..4e9416a17 100644 --- a/android/src/main/java/org/hisp/dhis/android/MainActivity.kt +++ b/android/src/main/java/org/hisp/dhis/android/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.core.view.WindowCompat import org.hisp.dhis.common.App +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -28,6 +29,26 @@ class MainActivity : AppCompatActivity() { .also { it.inPreferredConfig = Bitmap.Config.ARGB_8888 }, ).asImageBitmap() }, + onLocationRequest = { locationQuery, locationSearchCallback -> + + if (locationQuery.isNotBlank()) { + val fakeList = buildList { + repeat(20) { + add( + LocationItemModel.SearchResult( + "Fake Location Title #$it", + "Fake Location Address, Fake Country, Fake City", + 0.0, + 0.0, + ), + ) + } + } + locationSearchCallback(fakeList) + } else { + locationSearchCallback(emptyList()) + } + }, ) } } diff --git a/build.gradle.kts b/build.gradle.kts index 78785e629..f3cb3a214 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -version = "0.3.0-SNAPSHOT" +version = "0.4.0-SNAPSHOT" group = "org.hisp.dhis.mobile" plugins { diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 3c7df18a1..1cd8290cc 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -18,7 +18,7 @@ kotlin { implementation(compose.material3) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(project(":designsystem")) + api(project(":designsystem")) } commonTest.dependencies { implementation(kotlin("test")) diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index c62bdb5b9..e8ad47b1a 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -19,6 +19,8 @@ import org.hisp.dhis.common.screens.basicTextInputs.BasicTextInputsScreen import org.hisp.dhis.common.screens.bottomSheets.BottomSheetsScreen import org.hisp.dhis.common.screens.buttons.ButtonsScreen import org.hisp.dhis.common.screens.cards.CardsScreen +import org.hisp.dhis.common.screens.location.LocationSearchBarScreen +import org.hisp.dhis.common.screens.menu.MenuScreen import org.hisp.dhis.common.screens.others.BadgesScreen import org.hisp.dhis.common.screens.others.ChipsScreen import org.hisp.dhis.common.screens.others.IndicatorScreen @@ -29,6 +31,7 @@ import org.hisp.dhis.common.screens.others.ProgressScreen import org.hisp.dhis.common.screens.others.SearchBarScreen import org.hisp.dhis.common.screens.others.SectionScreen import org.hisp.dhis.common.screens.others.TagsScreen +import org.hisp.dhis.common.screens.others.TopBarScreen import org.hisp.dhis.common.screens.parameter.ParameterSelectorScreen import org.hisp.dhis.common.screens.toggleableInputs.ToggleableInputsScreen import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem @@ -36,21 +39,36 @@ import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle import org.hisp.dhis.mobile.ui.designsystem.component.MetadataAvatarSize +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme import org.hisp.dhis.mobile.ui.designsystem.theme.Shape import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @Composable -fun App(imageBitmapLoader: (() -> ImageBitmap)? = null) { +fun App( + imageBitmapLoader: (() -> ImageBitmap)? = null, + onLocationRequest: ( + ( + locationQuery: String, + locationSearchCallback: (List) -> Unit, + ) -> Unit + )? = null, +) { DHIS2Theme { - Main(imageBitmapLoader) + Main(imageBitmapLoader, onLocationRequest) } } @Composable fun Main( imageBitmapLoader: (() -> ImageBitmap)?, + onLocationRequest: ( + ( + locationQuery: String, + locationSearchCallback: (List) -> Unit, + ) -> Unit + )?, ) { val currentScreen = remember { mutableStateOf(Groups.NO_GROUP_SELECTED) } var isComponentSelected by remember { mutableStateOf(false) } @@ -79,7 +97,8 @@ fun Main( state = InputShellState.UNFOCUSED, expanded = true, selectedItem = DropdownItem(currentScreen.value.label), - inputStyle = InputStyle.DataInputStyle().apply { backGroundColor = SurfaceColor.SurfaceBright }, + inputStyle = InputStyle.DataInputStyle() + .apply { backGroundColor = SurfaceColor.SurfaceBright }, ) when (currentScreen.value) { @@ -100,7 +119,12 @@ fun Main( Groups.TAGS -> TagsScreen() Groups.SEARCH_BAR -> SearchBarScreen() Groups.NAVIGATION_BAR -> NavigationBarScreen() + Groups.MENU -> MenuScreen() Groups.NO_GROUP_SELECTED -> NoComponentSelectedScreen() + Groups.TOP_BAR -> TopBarScreen() + Groups.LOCATION_SEARCH_BAR -> LocationSearchBarScreen { locationQuery, locationCallback -> + onLocationRequest?.invoke(locationQuery, locationCallback) + } } } else { NoComponentSelectedScreen( diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt index 403da1712..e48911006 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Groups.kt @@ -18,5 +18,8 @@ enum class Groups(val label: String) { INDICATOR("Indicators"), PARAMETER_SELECTOR("Parameter selector"), NAVIGATION_BAR("Navigation Bar"), + TOP_BAR("Top Bar"), + MENU("Menu"), NO_GROUP_SELECTED("No group selected"), + LOCATION_SEARCH_BAR("Location Search Bar"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt index 733b5a0ba..6af361b3c 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/actionInputs/InputAgeScreen.kt @@ -11,10 +11,11 @@ import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer import org.hisp.dhis.mobile.ui.designsystem.component.InputAge -import org.hisp.dhis.mobile.ui.designsystem.component.InputAgeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState import org.hisp.dhis.mobile.ui.designsystem.component.LegendData import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @Composable @@ -24,100 +25,108 @@ fun InputAgeScreen() { ColumnComponentContainer("Input Age Component - Idle") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { newInputType -> - inputType = newInputType - }, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Idle Disabled") { InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.None, - state = InputShellState.DISABLED, - onValueChanged = { newInputType -> - inputType = newInputType - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputState = InputShellState.DISABLED, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Date Of Birth") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth(TextFieldValue("01011985")), - state = InputShellState.DISABLED, - - onValueChanged = { newInputType -> - inputType = newInputType - }, + inputState = InputShellState.DISABLED, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Date Of Birth Required Error") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + isRequired = true, + ), inputType = AgeInputType.DateOfBirth(TextFieldValue("010")), - state = InputShellState.ERROR, - isRequired = true, - - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + onValueChanged = { + // no-op + }, ) } ColumnComponentContainer("Input Age Component - Age Disabled") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.DISABLED, - - onValueChanged = { newInputType -> - inputType = newInputType - }, + inputState = InputShellState.DISABLED, ), + onValueChanged = { newInputType -> + inputType = newInputType ?: AgeInputType.None + }, ) } ColumnComponentContainer("Input Age Component - Age Required Error") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + isRequired = true, + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.ERROR, - isRequired = true, - - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + onValueChanged = { + // no-op + }, ) } ColumnComponentContainer("Input Age Component - Legend") { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + isRequired = true, + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.ERROR, - isRequired = true, - - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, legendData = LegendData(SurfaceColor.CustomGreen, "Legend", popUpLegendDescriptionData = regularLegendList), ), + onValueChanged = { + // no-op + }, ) } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/bottomSheets/OrgTreeBottomSheetScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/bottomSheets/OrgTreeBottomSheetScreen.kt index 627d056a6..348eb0df0 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/bottomSheets/OrgTreeBottomSheetScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/bottomSheets/OrgTreeBottomSheetScreen.kt @@ -1,5 +1,7 @@ package org.hisp.dhis.common.screens.bottomSheets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoveDown import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -7,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.text.style.TextAlign import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +33,7 @@ fun OrgTreeBottomSheetScreen() { var showTwoOrgTreeBottomSheet by rememberSaveable { mutableStateOf(false) } var showMediumOrgTreeBottomSheet by rememberSaveable { mutableStateOf(false) } var showLargeOrgTreeBottomSheet by rememberSaveable { mutableStateOf(false) } + var showTransferOrgBottomSheet by rememberSaveable { mutableStateOf(false) } if (showOneOrgTreeBottomSheet) { val orgTreeItemsRepo = remember { OrgTreeItemsFakeRepo() } @@ -115,6 +119,31 @@ fun OrgTreeBottomSheetScreen() { ) } + if (showTransferOrgBottomSheet) { + val orgTreeItemsRepo = remember { OrgTreeItemsFakeRepo() } + val oneOrgTreeItem by orgTreeItemsRepo.state.collectAsState(emptyList()) + + OrgBottomSheet( + title = "Transfer [tracked entity type]", + subtitle = "From [current owner org. unit] to...", + orgTreeItems = oneOrgTreeItem, + doneButtonText = "Transfer", + doneButtonIcon = Icons.Outlined.MoveDown, + headerTextAlignment = TextAlign.Left, + onDismiss = { + showTransferOrgBottomSheet = false + }, + onSearch = orgTreeItemsRepo::search, + onItemClick = orgTreeItemsRepo::toggleItemExpansion, + onItemSelected = { uid, checked -> + orgTreeItemsRepo.toggleItemSelection(uid, checked) + }, + onDone = { + // no-op + }, + ) + } + ColumnScreenContainer(title = BottomSheets.ORG_TREE_BOTTOM_SHEET.label) { ColumnComponentContainer("Org Tree Bottom Sheet with single item") { Button( @@ -155,6 +184,16 @@ fun OrgTreeBottomSheetScreen() { showLargeOrgTreeBottomSheet = !showLargeOrgTreeBottomSheet } } + + ColumnComponentContainer("Transfer Org Tree Bottom Sheet") { + Button( + enabled = true, + ButtonStyle.FILLED, + text = "Show Transfer Org Tree Bottom Sheet", + ) { + showTransferOrgBottomSheet = !showTransferOrgBottomSheet + } + } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ExpandableListCardScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ExpandableListCardScreen.kt index 759245159..5d5d1d3cf 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ExpandableListCardScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ExpandableListCardScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.material.icons.outlined.SyncDisabled import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import org.hisp.dhis.common.screens.previews.lorem_medium +import org.hisp.dhis.common.screens.previews.lorem import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem import org.hisp.dhis.mobile.ui.designsystem.component.Avatar import org.hisp.dhis.mobile.ui.designsystem.component.AvatarStyleData @@ -51,8 +51,9 @@ fun ExpandableListCardScreen() { } add( AdditionalInfoItem( - value = lorem_medium, + value = lorem, color = TextColor.OnSurfaceLight, + truncate = false, ), ) } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ListCardScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ListCardScreen.kt index 522e2212c..eb5ffa5e3 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ListCardScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/cards/ListCardScreen.kt @@ -41,6 +41,7 @@ import org.hisp.dhis.mobile.ui.designsystem.component.ListCard import org.hisp.dhis.mobile.ui.designsystem.component.ListCardDescriptionModel import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel import org.hisp.dhis.mobile.ui.designsystem.component.MetadataAvatarSize +import org.hisp.dhis.mobile.ui.designsystem.component.SelectionState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon @@ -49,37 +50,37 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor @Composable fun ListCardScreen(horizontal: Boolean) { - if (horizontal) { - LazyRow( - modifier = Modifier.heightIn(0.dp, 500.dp), - horizontalArrangement = spacedBy(4.dp), - verticalAlignment = Alignment.Top, - contentPadding = PaddingValues(vertical = 4.dp, horizontal = 16.dp), - ) { - items(count = 4) { index -> - ListCard( - listCardState = rememberListCardState( - title = ListCardTitleModel(text = "Palak Khanna, F, 61"), - lastUpdated = "5 hours", - additionalInfoColumnState = rememberAdditionalInfoColumnState( - additionalInfoList = largeItemList, - syncProgressItem = syncProgressItem(), - scrollableContent = true, + ColumnScreenContainer(title = if (horizontal) Cards.LIST_CARD_HORIZONTAL.label else Cards.LIST_CARD.label) { + if (horizontal) { + LazyRow( + modifier = Modifier.heightIn(0.dp, 500.dp), + horizontalArrangement = spacedBy(4.dp), + verticalAlignment = Alignment.Top, + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 16.dp), + ) { + items(count = 4) { index -> + ListCard( + listCardState = rememberListCardState( + title = ListCardTitleModel(text = "Palak Khanna, F, 61"), + lastUpdated = "5 hours", + additionalInfoColumnState = rememberAdditionalInfoColumnState( + additionalInfoList = largeItemList, + syncProgressItem = syncProgressItem(), + scrollableContent = true, + ), + loading = false, ), - loading = false, - ), - modifier = Modifier.fillParentMaxWidth(), - listAvatar = { - Avatar( - style = AvatarStyleData.Text("$index"), - ) - }, - onCardClick = {}, - ) + modifier = Modifier.fillParentMaxWidth(), + listAvatar = { + Avatar( + style = AvatarStyleData.Text("$index"), + ) + }, + onCardClick = {}, + ) + } } - } - } else { - ColumnScreenContainer(title = Cards.LIST_CARD.label) { + } else { var showLoading1 by remember { mutableStateOf(false) } @@ -650,6 +651,53 @@ fun ListCardScreen(horizontal: Boolean) { onCardClick = {}, ) } + + ColumnComponentContainer("Selectable list cards") { + var selectionState by remember { + mutableStateOf(SelectionState.NONE) + } + + ListCard( + listCardState = rememberListCardState( + title = ListCardTitleModel(text = "Palak Khanna, F, 61"), + lastUpdated = "5 hours", + additionalInfoColumnState = rememberAdditionalInfoColumnState( + additionalInfoList = basicAdditionalItemList.toMutableList(), + syncProgressItem = syncProgressItem(), + ), + selectionState = selectionState, + ), + listAvatar = { + Avatar( + style = AvatarStyleData.Text("P"), + ) + }, + onCardClick = {}, + onCardSelected = { selectionState = it }, + ) + var selectionState2 by remember { + mutableStateOf(SelectionState.NONE) + } + + ListCard( + listCardState = rememberListCardState( + title = ListCardTitleModel(text = "Palak Khanna, F, 61"), + lastUpdated = "5 hours", + additionalInfoColumnState = rememberAdditionalInfoColumnState( + additionalInfoList = basicAdditionalItemListWithLongKeyText.toMutableList(), + syncProgressItem = syncProgressItem(), + ), + selectionState = selectionState2, + ), + listAvatar = { + Avatar( + style = AvatarStyleData.Text("P"), + ) + }, + onCardClick = {}, + onCardSelected = { selectionState2 = it }, + ) + } } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt new file mode 100644 index 000000000..968898393 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/location/LocationSearchBarScreen.kt @@ -0,0 +1,147 @@ +package org.hisp.dhis.common.screens.location + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.TopCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.hisp.dhis.common.screens.components.GroupComponentDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem +import org.hisp.dhis.mobile.ui.designsystem.component.LocationBar +import org.hisp.dhis.mobile.ui.designsystem.component.OnSearchAction +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel + +@Composable +fun LocationSearchBarScreen( + onSearchLocation: ( + locationQuery: String, + locationSearchCallback: (List) -> Unit, + + ) -> Unit, +) { + var itemList: List by remember { + mutableStateOf(defaultLocationItems) + } + + val currentScreen = remember { mutableStateOf(LocationSearchBarOptions.DEFAULT_BEHAVIOUR) } + val screenDropdownItemList = mutableListOf() + LocationSearchBarOptions.entries.forEach { + screenDropdownItemList.add(DropdownItem(it.label)) + } + + val scope = rememberCoroutineScope() + + GroupComponentDropDown( + dropdownItems = screenDropdownItemList.toList(), + onItemSelected = { + itemList = defaultLocationItems + currentScreen.value = getCurrentScreen(it.label) + }, + onResetButtonClicked = { + itemList = defaultLocationItems + currentScreen.value = LocationSearchBarOptions.DEFAULT_BEHAVIOUR + }, + selectedItem = DropdownItem(currentScreen.value.label), + ) + + when (currentScreen.value) { + LocationSearchBarOptions.DEFAULT_BEHAVIOUR -> { + var searching by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.fillMaxSize() + .background(Color.White) + .padding(16.dp), + contentAlignment = TopCenter, + ) { + LocationBar( + currentResults = itemList, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { locationQuery -> + searching = true + scope.launch { + delay(3000) + onSearchLocation(locationQuery) { + itemList = + it.takeIf { locationQuery.isNotBlank() } ?: defaultLocationItems + searching = false + } + } + }, + onLocationSelected = { locationItemModel -> + }, + searching = searching, + ) + } + } + + LocationSearchBarOptions.AUTOSELECT_ON_ONE_ITEM_FOUND -> { + var searching by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.fillMaxSize() + .background(Color.White) + .padding(16.dp), + contentAlignment = TopCenter, + ) { + LocationBar( + currentResults = itemList, + searchAction = OnSearchAction.OnOneItemSelect, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { locationQuery -> + searching = true + scope.launch { + onSearchLocation(locationQuery) { + itemList = + it.take(1).takeIf { locationQuery.isNotBlank() } + ?: defaultLocationItems + searching = false + } + } + }, + onLocationSelected = { locationItemModel -> + }, + searching = searching, + ) + } + } + } +} + +private val defaultLocationItems = listOf( + LocationItemModel.StoredResult( + storedTitle = "Location #1", + storedSubtitle = "Location description, location description, location description", + storedLatitude = 0.0, + storedLongitude = 0.0, + ), + LocationItemModel.StoredResult( + storedTitle = "Location #2", + storedSubtitle = "Location description, location description, location description", + storedLatitude = 0.0, + storedLongitude = 0.0, + ), +) + +private enum class LocationSearchBarOptions(val label: String) { + DEFAULT_BEHAVIOUR("Default behaviour"), + AUTOSELECT_ON_ONE_ITEM_FOUND("Autoselect on one item found"), +} + +private fun getCurrentScreen(label: String): LocationSearchBarOptions { + return LocationSearchBarOptions.entries.firstOrNull { it.label == label } + ?: LocationSearchBarOptions.DEFAULT_BEHAVIOUR +} diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/DropDownMenuScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/DropDownMenuScreen.kt new file mode 100644 index 000000000..0b54f180e --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/DropDownMenuScreen.kt @@ -0,0 +1,148 @@ +package org.hisp.dhis.common.screens.menu + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Assignment +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Workspaces +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 org.hisp.dhis.common.screens.Groups +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.ColumnScreenContainer +import org.hisp.dhis.mobile.ui.designsystem.component.menu.DropDownMenu +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemData +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemStyle +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuLeadingElement +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +enum class EnrollmentMenuItem { + SYNC, + FOLLOW_UP, + GROUP_BY_STAGE, + HELP, + ENROLLMENTS, + SHARE, + DEACTIVATE, + COMPLETE, + DELETE, + REMOVE, +} + +@Composable +fun DropDownMenuScreen() { + ColumnScreenContainer(Groups.MENU.label) { + ColumnComponentContainer( + "Enrollment dashboard menu", + ) { + val enrollmentMenuItems by remember { + mutableStateOf( + listOf( + MenuItemData( + id = EnrollmentMenuItem.SYNC, + label = "Refresh this record", + leadingElement = MenuLeadingElement.Icon(icon = Icons.Outlined.Sync), + ), + MenuItemData( + id = EnrollmentMenuItem.FOLLOW_UP, + label = "Mark for follow-up", + leadingElement = MenuLeadingElement.Icon(icon = Icons.Outlined.Flag), + ), + MenuItemData( + id = EnrollmentMenuItem.GROUP_BY_STAGE, + label = "Group by stage", + leadingElement = MenuLeadingElement.Icon(icon = Icons.Outlined.Workspaces), + ), + MenuItemData( + id = EnrollmentMenuItem.HELP, + label = "Show help", + leadingElement = MenuLeadingElement.Icon(icon = Icons.AutoMirrored.Outlined.HelpOutline), + ), + MenuItemData( + id = EnrollmentMenuItem.ENROLLMENTS, + label = "More enrollments", + leadingElement = MenuLeadingElement.Icon(icon = Icons.AutoMirrored.Outlined.Assignment), + ), + MenuItemData( + id = EnrollmentMenuItem.SHARE, + label = "Share", + supportingText = "Using QR code", + showDivider = true, + leadingElement = MenuLeadingElement.Icon(icon = Icons.Outlined.Share), + ), + MenuItemData( + id = EnrollmentMenuItem.COMPLETE, + label = "Complete", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.CheckCircle, + defaultTintColor = SurfaceColor.CustomGreen, + selectedTintColor = SurfaceColor.CustomGreen, + ), + ), + MenuItemData( + id = EnrollmentMenuItem.DEACTIVATE, + label = "Deactivate", + showDivider = true, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Cancel, + defaultTintColor = TextColor.OnDisabledSurface, + selectedTintColor = TextColor.OnDisabledSurface, + ), + ), + MenuItemData( + id = EnrollmentMenuItem.REMOVE, + label = "Remove from [program]", + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon(icon = Icons.Outlined.DeleteOutline), + ), + MenuItemData( + id = EnrollmentMenuItem.DELETE, + label = "Delete [TEI Type]", + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon(icon = Icons.Outlined.DeleteForever), + ), + ), + ) + } + + var selectedItemIndex by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + + Box { + Button( + enabled = true, + ButtonStyle.FILLED, + text = "Show Dropdown menu", + ) { + expanded = !expanded + } + + DropDownMenu( + items = enrollmentMenuItems, + expanded = expanded, + selectedItemIndex = selectedItemIndex, + onDismissRequest = { + expanded = false + }, + onItemClick = { itemId -> + expanded = !expanded + selectedItemIndex = enrollmentMenuItems.indexOfFirst { it.id == itemId } + }, + ) + } + } + } +} diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/MenuItemScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/MenuItemScreen.kt new file mode 100644 index 000000000..5db4f61c1 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/MenuItemScreen.kt @@ -0,0 +1,506 @@ +package org.hisp.dhis.common.screens.menu + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowRight +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Done +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.hisp.dhis.common.screens.Groups +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItem +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemData +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemState +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemStyle +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuLeadingElement +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuTrailingElement + +@Composable +fun MenuItemScreen() { + ColumnScreenContainer(Groups.MENU.label) { + ColumnComponentContainer("Menu list item") { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Support Text", + showDivider = true, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Support Text", + showDivider = true, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + } + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.SELECTED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.SELECTED, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + } + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.DISABLED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.DISABLED, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + } + } + } + + ColumnComponentContainer("Menu item with leading element variations") { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "No Leading Element", + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Indent Leading Element", + leadingElement = MenuLeadingElement.Indent, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Icon Leading Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Indent Leading Element", + leadingElement = MenuLeadingElement.Indent, + state = MenuItemState.SELECTED, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Icon Leading Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + state = MenuItemState.SELECTED, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Indent Leading Element", + leadingElement = MenuLeadingElement.Indent, + state = MenuItemState.DISABLED, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Icon Leading Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + state = MenuItemState.DISABLED, + ), + ) {} + } + + ColumnComponentContainer("Menu item with divider") { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + showDivider = true, + ), + ) {} + } + + ColumnComponentContainer("Menu item with trailing element variations") { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "No Trailing Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Icon Trailing Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Text Trailing Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Text( + text = "⌘C", + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Icon Trailing Element", + state = MenuItemState.SELECTED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Text Trailing Element", + state = MenuItemState.SELECTED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Text( + text = "⌘C", + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Icon Trailing Element", + state = MenuItemState.DISABLED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Text Trailing Element", + state = MenuItemState.DISABLED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Text( + text = "⌘C", + ), + ), + ) {} + } + + ColumnComponentContainer("Alert Menu item with leading element variations") { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "No Leading Element", + style = MenuItemStyle.ALERT, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Indent Leading Element", + leadingElement = MenuLeadingElement.Indent, + style = MenuItemStyle.ALERT, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Icon Leading Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + style = MenuItemStyle.ALERT, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Indent Leading Element", + leadingElement = MenuLeadingElement.Indent, + style = MenuItemStyle.ALERT, + state = MenuItemState.SELECTED, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Icon Leading Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + style = MenuItemStyle.ALERT, + state = MenuItemState.SELECTED, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Indent Leading Element", + leadingElement = MenuLeadingElement.Indent, + style = MenuItemStyle.ALERT, + state = MenuItemState.DISABLED, + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Diasbled Icon Leading Element", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + style = MenuItemStyle.ALERT, + state = MenuItemState.DISABLED, + ), + ) {} + } + + ColumnComponentContainer("Alert Menu item with divider") { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + showDivider = true, + style = MenuItemStyle.ALERT, + ), + ) {} + } + + ColumnComponentContainer("Alert Menu item with trailing element variations") { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "No Trailing Element", + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Icon Trailing Element", + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Text Trailing Element", + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Text( + text = "⌘C", + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Icon Trailing Element", + style = MenuItemStyle.ALERT, + state = MenuItemState.SELECTED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Selected Text Trailing Element", + style = MenuItemStyle.ALERT, + state = MenuItemState.SELECTED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Text( + text = "⌘C", + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Icon Trailing Element", + state = MenuItemState.DISABLED, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Disabled Text Trailing Element", + state = MenuItemState.DISABLED, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Check, + ), + trailingElement = MenuTrailingElement.Text( + text = "⌘C", + ), + ), + ) {} + } + } +} diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/MenuScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/MenuScreen.kt new file mode 100644 index 000000000..2d8242674 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/menu/MenuScreen.kt @@ -0,0 +1,41 @@ +package org.hisp.dhis.common.screens.menu + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import org.hisp.dhis.common.screens.NoComponentSelectedScreen +import org.hisp.dhis.common.screens.components.GroupComponentDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem + +@Composable +fun MenuScreen() { + val currentScreen = remember { mutableStateOf(MENU.NO_COMPONENT_SELECTED) } + + val screenDropdownItemList = mutableListOf() + MENU.entries.forEach { + if (it != MENU.NO_COMPONENT_SELECTED) { + screenDropdownItemList.add(DropdownItem(it.label)) + } + } + GroupComponentDropDown( + dropdownItems = screenDropdownItemList.toList(), + onItemSelected = { currentScreen.value = getCurrentScreen(it.label) }, + onResetButtonClicked = { currentScreen.value = MENU.NO_COMPONENT_SELECTED }, + selectedItem = DropdownItem(currentScreen.value.label), + ) + when (currentScreen.value) { + MENU.DROPDOWN_MENU -> DropDownMenuScreen() + MENU.MENU_ITEM -> MenuItemScreen() + MENU.NO_COMPONENT_SELECTED -> NoComponentSelectedScreen() + } +} + +enum class MENU(val label: String) { + DROPDOWN_MENU("Drop down menu"), + MENU_ITEM("Menu item"), + NO_COMPONENT_SELECTED("No component selected"), +} + +fun getCurrentScreen(label: String): MENU { + return MENU.entries.firstOrNull { it.label == label } ?: MENU.DROPDOWN_MENU +} diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/others/TopBarScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/others/TopBarScreen.kt new file mode 100644 index 000000000..05e8d97d4 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/others/TopBarScreen.kt @@ -0,0 +1,261 @@ +package org.hisp.dhis.common.screens.others + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import org.hisp.dhis.common.screens.Groups +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer +import org.hisp.dhis.mobile.ui.designsystem.component.IconButton +import org.hisp.dhis.mobile.ui.designsystem.component.TopBar +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarActionIcon +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarDropdownMenuIcon +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarType + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBarScreen() { + ColumnScreenContainer( + title = Groups.TOP_BAR.label, + ) { + ColumnComponentContainer("Default") { + TopBar( + type = TopBarType.DEFAULT, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.Outlined.Menu, + contentDescription = "Menu Button", + ) + }, + ) + }, + actions = { + TopBarActionIcon( + icon = Icons.Outlined.Share, + onClick = { }, + ) + TopBarActionIcon( + icon = Icons.Outlined.FileDownload, + onClick = { }, + ) + TopBarDropdownMenuIcon { showMenu, onDismissRequest -> + DropdownMenu( + expanded = showMenu, + onDismissRequest = onDismissRequest, + ) { + DropdownMenuItem( + text = { Text("Action 1") }, + onClick = {}, + leadingIcon = { + IconButton( + onClick = { + onDismissRequest() + }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Edit Button", + ) + }, + ) + }, + ) + } + } + }, + ) + } + + ColumnComponentContainer("Back") { + TopBar( + type = TopBarType.DEFAULT, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back Button", + ) + }, + ) + }, + actions = { + TopBarActionIcon( + icon = Icons.Outlined.Share, + onClick = { }, + ) + }, + ) + } + + ColumnComponentContainer("Back") { + TopBar( + type = TopBarType.DEFAULT, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back Button", + tint = Color.White, + ) + }, + ) + }, + actions = { + TopBarActionIcon( + icon = Icons.Outlined.Share, + tint = Color.White, + onClick = { }, + ) + TopBarDropdownMenuIcon( + iconTint = Color.White, + ) { showMenu, onDismissRequest -> + DropdownMenu( + expanded = showMenu, + onDismissRequest = onDismissRequest, + ) { + DropdownMenuItem( + text = { Text("Action 1") }, + onClick = {}, + leadingIcon = { + IconButton( + onClick = { + onDismissRequest() + }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Edit Button", + ) + }, + ) + }, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Black, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White, + ), + ) + } + + ColumnComponentContainer("Without Icons") { + TopBar( + type = TopBarType.DEFAULT, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back Button", + ) + }, + ) + }, + actions = { + }, + ) + } + + ColumnComponentContainer("Centered") { + TopBar( + type = TopBarType.CENTERED, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back Button", + ) + }, + ) + }, + actions = { + TopBarDropdownMenuIcon { showMenu, onDismissRequest -> + DropdownMenu( + expanded = showMenu, + onDismissRequest = onDismissRequest, + ) { + DropdownMenuItem( + text = { Text("Action 1") }, + onClick = {}, + leadingIcon = { + IconButton( + onClick = { + onDismissRequest() + }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Edit Button", + ) + }, + ) + }, + ) + } + } + }, + ) + } + } +} diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt index 58c58c1e2..224075a9e 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt @@ -18,7 +18,6 @@ import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem import org.hisp.dhis.mobile.ui.designsystem.component.ImageCardData import org.hisp.dhis.mobile.ui.designsystem.component.InputAge -import org.hisp.dhis.mobile.ui.designsystem.component.InputAgeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputBarCode import org.hisp.dhis.mobile.ui.designsystem.component.InputCheckBox import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime @@ -43,7 +42,9 @@ import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterS import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel.Status.CLOSED import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel.Status.FOCUSED import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel.Status.UNFOCUSED +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputDateTimeState import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @@ -134,14 +135,16 @@ fun ParameterSelectorScreen() { helper = "Optional", inputField = { InputAge( - InputAgeModel( - title = "Age parameter", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Age parameter", + inputStyle = InputStyle.ParameterInputStyle(), + ), inputType = ageInputType, - inputStyle = InputStyle.ParameterInputStyle(), - onValueChanged = { - ageInputType = it - }, ), + onValueChanged = { + ageInputType = it ?: AgeInputType.None + }, ) }, status = when (ageInputType) { diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt index 0b5d7bf56..c4b79d1ca 100644 --- a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputAgeSnapshotTest.kt @@ -4,10 +4,11 @@ import androidx.compose.ui.text.input.TextFieldValue import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer import org.hisp.dhis.mobile.ui.designsystem.component.InputAge -import org.hisp.dhis.mobile.ui.designsystem.component.InputAgeModel import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.junit.Rule import org.junit.Test @@ -22,74 +23,96 @@ class InputAgeSnapshotTest { ColumnScreenContainer { SubTitle("Input Age Component - Idle") InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.None, - - onValueChanged = { - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Idle Disabled") InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.None, - state = InputShellState.DISABLED, - onValueChanged = { - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputState = InputShellState.DISABLED, ), + onValueChanged = { + }, ) - SubTitle("Input Age Component - Date Of Birth") + SubTitle("Input Age Component - Invalid Date Of Birth") InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth( TextFieldValue("01011985"), ), - state = InputShellState.DISABLED, - onValueChanged = { - }, + inputState = InputShellState.DISABLED, + ), + onValueChanged = { + }, + ) + + SubTitle("Input Age Component - Date Of Birth") + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth( + TextFieldValue("1991-11-27"), + ), + inputState = InputShellState.DISABLED, ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Date Of Birth Required Error") InputAge( - InputAgeModel( - title = "Label", - inputType = AgeInputType.DateOfBirth(TextFieldValue("010")), - state = InputShellState.ERROR, - isRequired = true, - onValueChanged = { - // no-op - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth( + TextFieldValue("010"), + ), + inputState = InputShellState.ERROR, ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Age Disabled") InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.DISABLED, - onValueChanged = { - }, + inputState = InputShellState.DISABLED, ), + onValueChanged = { + }, ) SubTitle("Input Age Component - Age Required Error") InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age(value = TextFieldValue("56"), unit = TimeUnitValues.YEARS), - state = InputShellState.ERROR, - isRequired = true, - onValueChanged = { - // no-op - }, + inputState = InputShellState.ERROR, ), + onValueChanged = { + }, ) } } diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/ListCardSelectableSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/ListCardSelectableSnapshotTest.kt new file mode 100644 index 000000000..ba0a5647a --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/ListCardSelectableSnapshotTest.kt @@ -0,0 +1,70 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material3.Icon +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem +import org.hisp.dhis.mobile.ui.designsystem.component.Avatar +import org.hisp.dhis.mobile.ui.designsystem.component.AvatarStyleData +import org.hisp.dhis.mobile.ui.designsystem.component.ListCard +import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel +import org.hisp.dhis.mobile.ui.designsystem.component.SelectionState +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState +import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class ListCardSelectableSnapshotTest { + + @get:Rule + val paparazzi = paparazzi() + + @Test + fun launchListCard() { + paparazzi.snapshot { + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.Spacing4), + modifier = Modifier.padding(horizontal = Spacing.Spacing8), + ) { + SelectionState.entries.forEach { selectionState -> + ListCard( + listCardState = rememberListCardState( + title = ListCardTitleModel(text = "Kunal Choudary, M, 55"), + lastUpdated = "24 min", + additionalInfoColumnState = rememberAdditionalInfoColumnState( + additionalInfoList = emptyList(), + syncProgressItem = AdditionalInfoItem( + icon = { + Icon( + imageVector = Icons.Outlined.Sync, + contentDescription = "Icon Button", + tint = SurfaceColor.Primary, + ) + }, + value = "Syncing...", + color = SurfaceColor.Primary, + isConstantItem = false, + ), + ), + selectionState = selectionState, + loading = true, + ), + listAvatar = { + Avatar( + style = AvatarStyleData.Image(provideDHIS2Icon("dhis2_microscope_outline")), + ) + }, + onCardClick = {}, + ) + } + } + } + } +} diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt new file mode 100644 index 000000000..65e221aa2 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarButtonSnapshotTest.kt @@ -0,0 +1,24 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import org.hisp.dhis.mobile.ui.designsystem.component.LocationBar +import org.junit.Rule +import org.junit.Test + +class LocationBarButtonSnapshotTest { + @get:Rule + val paparazzi = paparazzi() + + @Test + fun launchSearchBarButtonTest() { + paparazzi.snapshot { + LocationBar( + currentResults = emptyList(), + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + searching = false, + ) + } + } +} diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt new file mode 100644 index 000000000..ccdb47055 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/LocationBarSearchSnapshotTest.kt @@ -0,0 +1,40 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import org.hisp.dhis.mobile.ui.designsystem.component.LocationBar +import org.hisp.dhis.mobile.ui.designsystem.component.SearchBarMode +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel +import org.junit.Rule +import org.junit.Test + +class LocationBarSearchSnapshotTest { + @get:Rule + val paparazzi = paparazzi() + + @Test + fun launchSearchBarButtonTest() { + paparazzi.snapshot { + LocationBar( + currentResults = listOf( + LocationItemModel.StoredResult( + "Location Item title", + "Location Item address", + 0.0, + 0.0, + ), + LocationItemModel.SearchResult( + "Location Item title 2", + "Location Item address 2", + 0.0, + 0.0, + ), + ), + mode = SearchBarMode.SEARCH, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + searching = false, + ) + } + } +} diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/MenuItemSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/MenuItemSnapshotTest.kt new file mode 100644 index 000000000..a2e47ea10 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/MenuItemSnapshotTest.kt @@ -0,0 +1,155 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowRight +import androidx.compose.material.icons.outlined.Done +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItem +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemData +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemState +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemStyle +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuLeadingElement +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuTrailingElement +import org.junit.Rule +import org.junit.Test + +class MenuItemSnapshotTest { + @get:Rule + val paparazzi = paparazzi() + + @Test + fun launchMenuItemTest() { + paparazzi.snapshot { + ColumnScreenContainer("Menu Item") { + ColumnComponentContainer("Menu item") { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Label", + supportingText = "Support Text", + showDivider = true, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Label", + supportingText = "Support Text", + showDivider = true, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + } + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Label", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.SELECTED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Label", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.SELECTED, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + } + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Label", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.DISABLED, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + + MenuItem( + modifier = Modifier.weight(1f), + menuItemData = MenuItemData( + id = "menu_item", + label = "Label", + supportingText = "Support Text", + showDivider = true, + state = MenuItemState.DISABLED, + style = MenuItemStyle.ALERT, + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + trailingElement = MenuTrailingElement.Icon( + icon = Icons.AutoMirrored.Outlined.ArrowRight, + ), + ), + ) {} + } + } + } + } + } + } +} diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/TopBarSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/TopBarSnapshotTest.kt new file mode 100644 index 000000000..9a670a4f9 --- /dev/null +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/TopBarSnapshotTest.kt @@ -0,0 +1,102 @@ +package org.hisp.dhis.mobile.ui.designsystem + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.ui.text.style.TextOverflow +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnScreenContainer +import org.hisp.dhis.mobile.ui.designsystem.component.IconButton +import org.hisp.dhis.mobile.ui.designsystem.component.TopBar +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarActionIcon +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarDropdownMenuIcon +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarType +import org.junit.Rule +import org.junit.Test + +class TopBarSnapshotTest { + @get:Rule + val paparazzi = paparazzi() + + @OptIn(ExperimentalMaterial3Api::class) + @Test + fun launchTopBar() { + paparazzi.snapshot { + ColumnScreenContainer(title = "Top bars") { + ColumnComponentContainer(subTitle = "Default") { + TopBar( + type = TopBarType.DEFAULT, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.Outlined.Menu, + contentDescription = "Menu Button", + ) + }, + ) + }, + actions = { + TopBarActionIcon( + icon = Icons.Outlined.Share, + onClick = { }, + ) + TopBarActionIcon( + icon = Icons.Outlined.FileDownload, + onClick = { }, + ) + TopBarDropdownMenuIcon { _, _ -> + } + }, + ) + } + + ColumnComponentContainer(subTitle = "Centered") { + TopBar( + type = TopBarType.CENTERED, + title = { + Text( + text = "Title", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { }, + icon = { + Icon( + imageVector = Icons.Outlined.Menu, + contentDescription = "Menu Button", + ) + }, + ) + }, + actions = { + TopBarActionIcon( + icon = Icons.Outlined.Share, + onClick = { }, + ) + TopBarActionIcon( + icon = Icons.Outlined.FileDownload, + onClick = { }, + ) + }, + ) + } + } + } + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BaseCard.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BaseCard.kt index 0561ab189..c64b93ed1 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BaseCard.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BaseCard.kt @@ -5,8 +5,10 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.animateContentSize import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -53,6 +55,7 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow import org.hisp.dhis.mobile.ui.designsystem.theme.hoverPointerIcon +@OptIn(ExperimentalFoundationApi::class) @Composable fun BaseCard( modifier: Modifier = Modifier, @@ -62,25 +65,36 @@ fun BaseCard( expandable: Boolean, itemVerticalPadding: Dp?, onSizeChanged: ((IntSize) -> Unit)?, + selectionMode: SelectionState, + onCardSelected: () -> Unit, content: @Composable () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Row( modifier = modifier - .conditional(showShadow, { - dropShadow( - RoundedCornerShape(Radius.S), - ) - }) - .background(color = TextColor.OnPrimary, shape = RoundedCornerShape(Radius.S)) + .conditional( + selectionMode != SelectionState.SELECTED && showShadow, + { dropShadow(RoundedCornerShape(Radius.S)) }, + ) + .background( + color = when (selectionMode) { + SelectionState.SELECTED -> SurfaceColor.ContainerLow + else -> SurfaceColor.SurfaceBright + }, + shape = RoundedCornerShape(Radius.S), + ) .clip(shape = RoundedCornerShape(Radius.S)) - .clickable( + .combinedClickable( role = Role.Button, interactionSource = interactionSource, indication = rememberRipple( color = SurfaceColor.Primary, ), - onClick = onCardClick, + onClick = when { + selectionMode != SelectionState.NONE -> onCardSelected + else -> onCardClick + }, + onLongClick = onCardSelected, ) .hoverPointerIcon(true) .padding(paddingValues) @@ -105,7 +119,8 @@ fun BaseCard( fun ExpandableItemColumn( modifier: Modifier = Modifier, itemList: List, - itemSpacing: Dp = 16.dp, + itemSpacing: Dp = Spacing16, + contentPadding: Dp = Spacing16, itemLayout: @Composable (T, itemVerticalPadding: Dp, onSizeChanged: (IntSize) -> Unit) -> Unit, ) { val density = LocalDensity.current @@ -123,8 +138,11 @@ fun ExpandableItemColumn( val itemVerticalPadding by remember(childrenSize) { derivedStateOf { val value = if (childrenSize.size == itemCount) { - var availableHeight = - parentSize - childrenSize.values.sum() - with(density) { itemSpacing.toPx() * (itemCount - 1) } + var availableHeight = parentSize - + childrenSize.values.sum() - + with(density) { + itemSpacing.toPx() * (itemCount - 1) + contentPadding.toPx() * 2 + } if (itemCount == 1) availableHeight /= 4 with(density) { (availableHeight / (2 * itemCount)).toDp() }.takeIf { it >= 16.dp } ?: 16.dp @@ -143,7 +161,7 @@ fun ExpandableItemColumn( } }, verticalArrangement = spacedBy(itemSpacing), - contentPadding = PaddingValues(Spacing16), + contentPadding = PaddingValues(contentPadding), ) { itemList.forEachIndexed { index, item -> item { diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt index b5558dfd6..a24dc5bb8 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/BottomSheet.kt @@ -1,5 +1,6 @@ package org.hisp.dhis.mobile.ui.designsystem.component +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.ScrollableState @@ -117,7 +118,7 @@ fun BottomSheetHeader( text = description, style = MaterialTheme.typography.bodyMedium, color = TextColor.OnSurfaceLight, - textAlign = headerTextAlignment, + textAlign = TextAlign.Start, ) } } @@ -165,6 +166,7 @@ fun BottomSheetShell( headerTextAlignment: TextAlign = TextAlign.Center, scrollableContainerMinHeight: Dp = Spacing0, scrollableContainerMaxHeight: Dp = InternalSizeValues.Size386, + animateHeaderOnKeyboardAppearance: Boolean = true, onSearchQueryChanged: ((String) -> Unit)? = null, onSearch: ((String) -> Unit)? = null, onDismiss: () -> Unit, @@ -174,7 +176,15 @@ fun BottomSheetShell( val keyboardState by keyboardAsState() var isKeyboardOpen by remember { mutableStateOf(false) } - val showHeader by remember { derivedStateOf { !title.isNullOrBlank() && !isKeyboardOpen } } + val showHeader by remember { + derivedStateOf { + if (animateHeaderOnKeyboardAppearance) { + !title.isNullOrBlank() && !isKeyboardOpen + } else { + !title.isNullOrBlank() + } + } + } LaunchedEffect(keyboardState) { isKeyboardOpen = keyboardState == Keyboard.Opened @@ -222,7 +232,9 @@ fun BottomSheetShell( ) { val hasSearch = searchQuery != null && onSearchQueryChanged != null && onSearch != null - if (showHeader) { + AnimatedVisibility( + visible = showHeader, + ) { BottomSheetHeader( title = title!!, subTitle = subtitle, diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt index bd1249127..66f897397 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAge.kt @@ -21,8 +21,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction @@ -34,10 +36,15 @@ import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType.DateOfBirth import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType.None import org.hisp.dhis.mobile.ui.designsystem.component.TimeUnitValues.YEARS import org.hisp.dhis.mobile.ui.designsystem.component.internal.dateIsInRange +import org.hisp.dhis.mobile.ui.designsystem.component.internal.formatStoredDateToUI +import org.hisp.dhis.mobile.ui.designsystem.component.internal.formatUIDateToStored +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getDateSupportingText +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getSelectableDates import org.hisp.dhis.mobile.ui.designsystem.component.internal.isValidDate import org.hisp.dhis.mobile.ui.designsystem.component.internal.parseStringDateToMillis import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTransformation.Companion.DATE_MASK import org.hisp.dhis.mobile.ui.designsystem.component.model.RegExValidations +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeState import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2LightColorScheme import org.hisp.dhis.mobile.ui.designsystem.theme.Outline @@ -53,6 +60,8 @@ import java.util.Calendar * @param uiModel: data class [InputAgeModel] with all parameters for component. * @param modifier: optional modifier. */ +@Suppress("DEPRECATION") +@Deprecated("This component is deprecated and will be removed in the next release. Use InputAge instead.") @OptIn(ExperimentalMaterial3Api::class) @Composable fun InputAge( @@ -117,6 +126,7 @@ fun InputAge( previousInputType == None && (uiModel.inputType is DateOfBirth || uiModel.inputType is Age) -> { focusRequester.requestFocus() } + else -> { // no-op } @@ -154,6 +164,7 @@ fun InputAge( enabled = uiModel.state != InputShellState.DISABLED, ) } + is DateOfBirth, is Age -> { BasicTextField( modifier = Modifier @@ -255,7 +266,8 @@ fun InputAge( showDatePicker = false if (uiModel.inputType is DateOfBirth) { datePickerState.selectedDateMillis?.let { - val newInputType: AgeInputType = updateDateOfBirth(uiModel.inputType, TextFieldValue(getDate(it), TextRange(getDate(it).length))) + val newInputType: AgeInputType = + updateDateOfBirth(uiModel.inputType, TextFieldValue(getDate(it), TextRange(getDate(it).length))) uiModel.onValueChanged.invoke(newInputType) } } @@ -268,7 +280,6 @@ fun InputAge( ButtonStyle.TEXT, ColorStyle.DEFAULT, uiModel.cancelText ?: provideStringResource("cancel"), - ) { showDatePicker = false } @@ -296,30 +307,315 @@ fun InputAge( } } -private fun transformInputText(inputType: AgeInputType): String { - return when (inputType) { - is Age -> inputType.value.text - is DateOfBirth -> inputType.value.text - None -> "" - } -} +/** + * DHIS2 Input Age + * Input field to enter age. It will format content based on given visual + * transformation. + * component uses Material 3 [DatePicker] + * input formats supported are mentioned in the age input ui model documentation. + * [DatePicker] Input mode will always follow locale format. + * @param state: an [InputAgeState] with all the parameters for the input + * @param modifier: optional modifier. + */ -private fun getTextFieldValue(inputType: AgeInputType): TextFieldValue { - return when (inputType) { - is Age -> TextFieldValue(transformInputText(inputType), inputType.value.selection) - is DateOfBirth -> TextFieldValue(transformInputText(inputType), inputType.value.selection) - None -> TextFieldValue() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputAge( + state: InputAgeState, + onValueChanged: (AgeInputType?) -> Unit, + onNextClicked: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val uiData = state.uiData + val inputType = state.inputType + val uiValue = remember(getTextFieldValue(inputType)) { formatStoredDateToUI(getTextFieldValue(inputType), DateTimeActionType.DATE) } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + + val maxAgeCharLimit = 3 + var showDatePicker by rememberSaveable { mutableStateOf(false) } + + val helperText = remember(inputType) { + if (inputType is Age) { + inputType.unit.value + } else { + null + } } -} + val helperStyle = remember(inputType) { + when (inputType) { + None -> HelperStyle.NONE + is DateOfBirth -> HelperStyle.WITH_DATE_OF_BIRTH_HELPER + is Age -> HelperStyle.WITH_HELPER_AFTER + } + } + val selectableDates = uiData.selectableDates ?: SelectableDates( + MIN_DATE, + SimpleDateFormat(DATE_FORMAT).format(Calendar.getInstance().time), + ) -private fun updateDateOfBirth(inputType: DateOfBirth, newText: TextFieldValue): AgeInputType { - return if (newText.text.length <= DATE_MASK.length) { - inputType.copy(value = newText) + val datePickerState = rememberDatePickerState( + selectableDates = getSelectableDates(selectableDates), + ) + + val calendarButton: (@Composable () -> Unit)? = if (inputType is DateOfBirth) { + @Composable { + SquareIconButton( + modifier = Modifier.testTag("INPUT_AGE_OPEN_CALENDAR_BUTTON"), + icon = { + Icon( + imageVector = Icons.Filled.Event, + contentDescription = null, + ) + }, + onClick = { + focusRequester.requestFocus() + showDatePicker = !showDatePicker + }, + enabled = state.inputState != InputShellState.DISABLED, + ) + } } else { - inputType + null } + + var previousInputType by remember { mutableStateOf(inputType) } + LaunchedEffect(inputType) { + when { + previousInputType == None && (inputType is DateOfBirth || inputType is Age) -> { + focusRequester.requestFocus() + } + + else -> { + // no-op + } + } + + if (previousInputType != inputType) { + previousInputType = inputType + } + } + + val dateOutOfRangeText = "${provideStringResource("date_out_of_range")} (" + + formatStringToDate(selectableDates.initialDate) + " - " + + formatStringToDate(selectableDates.endDate) + ")" + val dateOutOfRangeItem = SupportingTextData( + text = dateOutOfRangeText, + SupportingTextState.ERROR, + ) + val incorrectDateFormatItem = SupportingTextData( + text = provideStringResource("incorrect_date_format"), + SupportingTextState.ERROR, + ) + + val supportingTextList = provideSupportingText( + inputType, + uiValue, + state.supportingText, + dateOutOfRangeItem, + incorrectDateFormatItem, + selectableDates, + ) + + InputShell( + modifier = modifier.testTag("INPUT_AGE").focusRequester(focusRequester), + title = uiData.title, + state = getInputState(supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem, state.inputState), + isRequiredField = uiData.isRequired, + inputField = { + when (inputType) { + None -> { + TextButtonSelector( + modifier = Modifier.testTag("INPUT_AGE_MODE_SELECTOR"), + firstOptionText = uiData.dateOfBirthLabel ?: provideStringResource("date_birth"), + onClickFirstOption = { + onValueChanged.invoke(DateOfBirth.EMPTY) + }, + middleText = uiData.orLabel ?: provideStringResource("or"), + secondOptionText = uiData.ageLabel ?: provideStringResource("age"), + onClickSecondOption = { + onValueChanged.invoke(Age.EMPTY) + }, + enabled = state.inputState != InputShellState.DISABLED, + ) + } + + is DateOfBirth, is Age -> { + BasicTextField( + modifier = Modifier + .testTag("INPUT_AGE_TEXT_FIELD") + .fillMaxWidth(), + inputTextValue = uiValue, + helper = if (helperText != null) provideStringResource(helperText).lowercase() else null, + isSingleLine = true, + helperStyle = helperStyle, + onInputChanged = { newText -> + if ((inputType is Age && newText.text.length > maxAgeCharLimit) || + (inputType is DateOfBirth && newText.text.length > DATE_MASK.length) + ) { + return@BasicTextField + } + manageOnValueChanged(newText, inputType, onValueChanged) + }, + enabled = state.inputState != InputShellState.DISABLED, + state = state.inputState, + keyboardOptions = KeyboardOptions(imeAction = uiData.imeAction, keyboardType = KeyboardType.Number), + onNextClicked = { + if (onNextClicked != null) { + onNextClicked.invoke() + } else { + focusManager.moveFocus(FocusDirection.Down) + } + }, + ) + } + } + }, + primaryButton = { + if (inputType != None && state.inputState != InputShellState.DISABLED) { + IconButton( + modifier = Modifier.testTag("INPUT_AGE_RESET_BUTTON").padding(Spacing.Spacing0), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Icon Button", + ) + }, + onClick = { + focusRequester.requestFocus() + onValueChanged.invoke(None) + }, + ) + } + }, + secondaryButton = calendarButton, + supportingText = { + supportingTextList.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = Modifier.testTag("INPUT_AGE_SUPPORTING_TEXT"), + ) + } + }, + legend = { + if (inputType is Age) { + TimeUnitSelector( + modifier = Modifier.fillMaxWidth() + .testTag("INPUT_AGE_TIME_UNIT_SELECTOR"), + orientation = Orientation.HORIZONTAL, + optionSelected = YEARS, + enabled = state.inputState != InputShellState.DISABLED, + onClick = { timeUnit -> + onValueChanged.invoke(inputType.copy(unit = timeUnit)) + }, + ) + } + + state.legendData?.let { + Legend(it, Modifier.testTag("INPUT_AGE_LEGEND")) + } + }, + inputStyle = uiData.inputStyle, + ) + + if (showDatePicker) { + MaterialTheme( + colorScheme = DHIS2LightColorScheme.copy( + outlineVariant = Outline.Medium, + ), + ) { + DatePickerDialog( + modifier = Modifier.testTag("DATE_PICKER"), + onDismissRequest = { showDatePicker = false }, + confirmButton = { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiData.acceptText ?: provideStringResource("ok"), + ) { + showDatePicker = false + if (inputType is DateOfBirth) { + datePickerState.selectedDateMillis?.let { + val newInputType: AgeInputType = updateDateOfBirth( + inputType, + TextFieldValue(getDate(it), TextRange(getDate(it).length)), + ) + onValueChanged.invoke(newInputType) + } + } + } + }, + colors = datePickerColors(), + dismissButton = { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiData.cancelText ?: provideStringResource("cancel"), + ) { + showDatePicker = false + } + }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = true, + ), + ) { + DatePicker( + title = { + Text( + text = uiData.title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = Spacing.Spacing24, top = Spacing.Spacing24), + ) + }, + state = datePickerState, + showModeToggle = true, + modifier = Modifier.padding(Spacing.Spacing0), + ) + } + } + } +} + +private fun getInputState( + supportingTextList: List, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + currentState: InputShellState, +): InputShellState { + return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else currentState +} + +@Composable +private fun provideSupportingText( + inputType: AgeInputType, + uiValue: TextFieldValue, + supportingText: List?, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + selectableDates: SelectableDates, +): List { + val supportingTextList = supportingText?.toMutableList() ?: mutableListOf() + + return (inputType as? DateOfBirth)?.value?.let { + getDateSupportingText( + uiValue = uiValue, + selectableDates = selectableDates, + actionType = DateTimeActionType.DATE, + yearRange = IntRange(MIN_YEAR, MAX_YEAR), + supportingTextList = supportingTextList, + dateOutOfRangeItem = dateOutOfRangeItem, + incorrectDateFormatItem = incorrectDateFormatItem, + ) + } ?: supportingTextList } +@Suppress("DEPRECATION") +@Deprecated("This component is deprecated and will be removed in the next release. Use InputDateTime instead.") @Composable private fun provideSupportingText( uiModel: InputAgeModel, @@ -355,7 +651,44 @@ private fun provideSupportingText( } } ?: uiModel.supportingText +private fun manageOnValueChanged(newText: TextFieldValue, inputType: AgeInputType, onValueChanged: (AgeInputType?) -> Unit) { + val allowedCharacters = RegExValidations.DATE_TIME.regex + if (allowedCharacters.containsMatchIn(newText.text) || newText.text.isBlank()) { + when (inputType) { + is Age -> onValueChanged.invoke((inputType as? Age)?.copy(value = newText)) + is DateOfBirth -> onValueChanged.invoke(DateOfBirth(formatUIDateToStored(newText, DateTimeActionType.DATE))) + None -> onValueChanged.invoke(None) + } + } +} + +private fun transformInputText(inputType: AgeInputType): String { + return when (inputType) { + is Age -> inputType.value.text + is DateOfBirth -> inputType.value.text + None -> "" + } +} + +private fun getTextFieldValue(inputType: AgeInputType): TextFieldValue { + return when (inputType) { + is Age -> TextFieldValue(transformInputText(inputType), inputType.value.selection) + is DateOfBirth -> TextFieldValue(transformInputText(inputType), inputType.value.selection) + None -> TextFieldValue() + } +} + +private fun updateDateOfBirth(inputType: DateOfBirth, newText: TextFieldValue): AgeInputType { + return if (newText.text.length <= DATE_MASK.length) { + inputType.copy(value = newText) + } else { + inputType + } +} + internal const val MIN_DATE = "10111901" +internal const val MIN_YEAR = 1901 +internal val MAX_YEAR = Calendar.getInstance().get(Calendar.YEAR) internal const val DATE_FORMAT = "ddMMYYYY" sealed interface AgeInputType { diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt index 24360fe15..db293b4a7 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDateTime.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.TimePickerState +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,11 +55,16 @@ import androidx.compose.ui.window.DialogProperties import org.hisp.dhis.mobile.ui.designsystem.component.internal.convertStringToTextFieldValue import org.hisp.dhis.mobile.ui.designsystem.component.internal.formatStoredDateToUI import org.hisp.dhis.mobile.ui.designsystem.component.internal.formatUIDateToStored +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getDefaultFormat +import org.hisp.dhis.mobile.ui.designsystem.component.internal.getSelectableDates import org.hisp.dhis.mobile.ui.designsystem.component.internal.getSupportingTextList import org.hisp.dhis.mobile.ui.designsystem.component.internal.getTime import org.hisp.dhis.mobile.ui.designsystem.component.internal.getTimePickerState +import org.hisp.dhis.mobile.ui.designsystem.component.internal.isValidHourFormat +import org.hisp.dhis.mobile.ui.designsystem.component.internal.parseDate import org.hisp.dhis.mobile.ui.designsystem.component.internal.provideDatePickerState import org.hisp.dhis.mobile.ui.designsystem.component.internal.timePickerColors +import org.hisp.dhis.mobile.ui.designsystem.component.internal.yearIsInRange import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTimeVisualTransformation import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTransformation import org.hisp.dhis.mobile.ui.designsystem.component.model.RegExValidations @@ -75,11 +82,400 @@ import java.util.GregorianCalendar import java.util.Locale import java.util.TimeZone -fun getInputState(supportingTextList: List, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, currentState: InputShellState): InputShellState { - return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains(incorrectDateFormatItem)) InputShellState.ERROR else currentState +/** + * DHIS2 Input Date Time + * Input field to enter date, time or date&time. It will format content based on given visual + * transformation. + * component uses Material 3 [DatePicker] and [TimePicker] + * input formats supported are mentioned in the date time input ui model documentation. + * [DatePicker] Input mode will always follow locale format. + * @param uiModel: an [InputDateTimeModel] with all the parameters for the input + * @param modifier: optional modifier. + */ + +@Suppress("DEPRECATION") +@Deprecated("This component is deprecated and will be removed in the next release. Use InputDateTime instead.") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputDateTime( + uiModel: InputDateTimeModel, + modifier: Modifier = Modifier, +) { + val allowedCharacters = RegExValidations.DATE_TIME.regex + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + var showDatePicker by rememberSaveable { mutableStateOf(false) } + var showTimePicker by rememberSaveable { mutableStateOf(false) } + var dateOutOfRangeText = uiModel.outOfRangeText ?: provideStringResource("date_out_of_range") + + dateOutOfRangeText = "$dateOutOfRangeText (" + formatStringToDate( + uiModel.selectableDates.initialDate, + ) + " - " + + formatStringToDate(uiModel.selectableDates.endDate) + ")" + val incorrectHourFormatText = + uiModel.incorrectHourFormatText ?: provideStringResource("wrong_hour_format") + val incorrectHourFormatItem = SupportingTextData( + text = incorrectHourFormatText, + SupportingTextState.ERROR, + ) + val incorrectDateFormatItem = SupportingTextData( + text = provideStringResource("incorrect_date_format"), + SupportingTextState.ERROR, + ) + val dateOutOfRangeItem = SupportingTextData( + text = dateOutOfRangeText, + SupportingTextState.ERROR, + ) + val supportingTextList = + getSupportingTextList( + uiModel, + dateOutOfRangeItem, + incorrectHourFormatItem, + incorrectDateFormatItem, + ) + + InputShell( + modifier = modifier.testTag("INPUT_DATE_TIME") + .focusRequester(focusRequester), + title = uiModel.title, + state = if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains( + incorrectDateFormatItem, + ) + ) { + InputShellState.ERROR + } else { + uiModel.state + }, + isRequiredField = uiModel.isRequired, + onFocusChanged = uiModel.onFocusChanged, + inputField = { + if (uiModel.allowsManualInput) { + BasicTextField( + modifier = Modifier + .testTag("INPUT_DATE_TIME_TEXT_FIELD") + .fillMaxWidth(), + inputTextValue = TextFieldValue( + uiModel.inputTextFieldValue?.text ?: "", + TextRange(uiModel.inputTextFieldValue?.text?.length ?: 0), + ), + isSingleLine = true, + onInputChanged = { newText -> + if (newText.text.length > uiModel.visualTransformation.maskLength) { + return@BasicTextField + } + + if (allowedCharacters.containsMatchIn(newText.text) || newText.text.isBlank()) { + uiModel.onValueChanged.invoke(newText) + } + }, + enabled = uiModel.state != InputShellState.DISABLED, + state = uiModel.state, + keyboardOptions = KeyboardOptions( + imeAction = uiModel.imeAction, + keyboardType = KeyboardType.Number, + ), + visualTransformation = uiModel.visualTransformation, + onNextClicked = { + if (uiModel.onNextClicked != null) { + uiModel.onNextClicked.invoke() + } else { + focusManager.moveFocus(FocusDirection.Down) + } + }, + ) + } else { + Box { + Text( + modifier = Modifier + .testTag("INPUT_DATE_TIME_TEXT") + .fillMaxWidth(), + text = uiModel.visualTransformation.filter(AnnotatedString(uiModel.inputTextFieldValue?.text.orEmpty())).text, + style = MaterialTheme.typography.bodyLarge.copy( + color = if (uiModel.state != InputShellState.DISABLED && !uiModel.inputTextFieldValue?.text.isNullOrEmpty()) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + }, + ), + ) + Box( + modifier = Modifier + .matchParentSize() + .alpha(0f) + .clickable( + enabled = uiModel.state != InputShellState.DISABLED, + onClick = { + if (uiModel.actionType == DateTimeActionType.TIME) { + showTimePicker = !showTimePicker + } else { + showDatePicker = !showDatePicker + } + }, + ), + ) + } + } + }, + primaryButton = { + if (!uiModel.inputTextFieldValue?.text.isNullOrBlank() && uiModel.state != InputShellState.DISABLED) { + IconButton( + modifier = Modifier.testTag("INPUT_DATE_TIME_RESET_BUTTON") + .padding(Spacing.Spacing0), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Icon Button", + ) + }, + onClick = { + uiModel.onValueChanged.invoke(TextFieldValue()) + focusRequester.requestFocus() + }, + ) + } + }, + secondaryButton = { + val icon = when (uiModel.actionType) { + DateTimeActionType.DATE, DateTimeActionType.DATE_TIME -> Icons.Filled.Event + DateTimeActionType.TIME -> Icons.Filled.Schedule + } + + SquareIconButton( + modifier = Modifier.testTag("INPUT_DATE_TIME_ACTION_BUTTON") + .focusable(), + icon = { + Icon( + imageVector = icon, + contentDescription = null, + ) + }, + onClick = { + focusRequester.requestFocus() + if (uiModel.onActionClicked != null) { + uiModel.onActionClicked.invoke() + } else { + if (uiModel.actionType == DateTimeActionType.TIME) { + showTimePicker = !showTimePicker + } else { + showDatePicker = !showDatePicker + } + } + }, + enabled = uiModel.state != InputShellState.DISABLED, + ) + }, + supportingText = + { + supportingTextList.forEach { item -> + SupportingText( + item.text, + item.state, + modifier = Modifier.testTag("INPUT_DATE_TIME_SUPPORTING_TEXT" + item.text), + ) + } + }, + legend = { + uiModel.legendData?.let { + Legend(uiModel.legendData, Modifier.testTag("INPUT_DATE_TIME_LEGEND")) + } + }, + inputStyle = uiModel.inputStyle, + ) + val datePickerState = provideDatePickerState(uiModel) + + if (showDatePicker) { + MaterialTheme( + colorScheme = DHIS2LightColorScheme.copy( + outlineVariant = Outline.Medium, + ), + ) { + DatePickerDialog( + modifier = Modifier.testTag("DATE_PICKER"), + onDismissRequest = { showDatePicker = false }, + confirmButton = { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiModel.acceptText ?: provideStringResource("ok"), + ) { + showDatePicker = false + if (uiModel.actionType != DateTimeActionType.DATE_TIME) { + datePickerState.selectedDateMillis?.let { + uiModel.onValueChanged( + TextFieldValue( + getDate(it, uiModel.format), + selection = TextRange( + uiModel.inputTextFieldValue?.text?.length ?: 0, + ), + ), + ) + } + } else { + showTimePicker = true + } + } + }, + colors = datePickerColors(), + dismissButton = { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiModel.cancelText ?: provideStringResource("cancel"), + + ) { + showDatePicker = false + } + }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = true, + ), + ) { + DatePicker( + title = { + Text( + text = uiModel.title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding( + start = Spacing.Spacing24, + top = Spacing.Spacing24, + ), + ) + }, + state = datePickerState, + showModeToggle = true, + modifier = Modifier.padding(Spacing.Spacing0), + ) + } + } + } + + if (showTimePicker) { + var timePickerState = rememberTimePickerState(0, 0, is24Hour = uiModel.is24hourFormat) + if (!uiModel.inputTextFieldValue?.text.isNullOrEmpty() && uiModel.actionType == DateTimeActionType.TIME && isValidHourFormat( + uiModel.inputTextFieldValue?.text ?: "", + ) + ) { + timePickerState = rememberTimePickerState( + initialHour = uiModel.inputTextFieldValue?.text?.substring(0, 2)!! + .toInt(), + uiModel.inputTextFieldValue.text.substring(2, 4).toInt(), + is24Hour = uiModel.is24hourFormat, + ) + } else { + if (uiModel.inputTextFieldValue?.text?.length == 12 && isValidHourFormat( + uiModel.inputTextFieldValue.text.substring( + 8, + 12, + ), + ) + ) { + timePickerState = rememberTimePickerState( + initialHour = uiModel.inputTextFieldValue.text.substring( + uiModel.inputTextFieldValue.text.length - 4, + uiModel.inputTextFieldValue.text.length - 2, + ) + .toInt(), + uiModel.inputTextFieldValue.text.substring( + uiModel.inputTextFieldValue.text.length - 2, + uiModel.inputTextFieldValue.text.length, + ).toInt(), + is24Hour = uiModel.is24hourFormat, + ) + } + } + Dialog( + onDismissRequest = { showDatePicker = false }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = true, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.background( + color = SurfaceColor.Container, + shape = RoundedCornerShape(Radius.L), + ).testTag("TIME_PICKER") + .padding(vertical = Spacing.Spacing16, horizontal = Spacing.Spacing24), + ) { + Text( + text = uiModel.title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(bottom = Spacing.Spacing16).align(Alignment.Start), + ) + TimePicker( + state = timePickerState, + layoutType = TimePickerLayoutType.Vertical, + colors = timePickerColors(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Row(Modifier.align(Alignment.End)) { + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiModel.cancelText ?: provideStringResource("cancel"), + + ) { + showTimePicker = false + } + Button( + enabled = true, + ButtonStyle.TEXT, + ColorStyle.DEFAULT, + uiModel.acceptText ?: provideStringResource("ok"), + ) { + showTimePicker = false + if (uiModel.actionType != DateTimeActionType.DATE_TIME) { + uiModel.onValueChanged( + TextFieldValue( + getTime(timePickerState), + selection = TextRange( + uiModel.inputTextFieldValue?.text?.length ?: 0, + ), + ), + ) + } else { + uiModel.onValueChanged( + TextFieldValue( + getDate(datePickerState.selectedDateMillis) + getTime( + timePickerState, + ), + selection = TextRange( + uiModel.inputTextFieldValue?.text?.length ?: 0, + ), + ), + ) + } + } + } + } + } + } +} + +private fun getInputState( + supportingTextList: List, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, + currentState: InputShellState, +): InputShellState { + return if (supportingTextList.contains(dateOutOfRangeItem) || supportingTextList.contains( + incorrectDateFormatItem, + ) + ) { + InputShellState.ERROR + } else { + currentState + } } -fun getActionButtonIcon(actionType: DateTimeActionType): ImageVector { +private fun getActionButtonIcon(actionType: DateTimeActionType): ImageVector { return when (actionType) { DateTimeActionType.DATE, DateTimeActionType.DATE_TIME -> Icons.Filled.Event DateTimeActionType.TIME -> Icons.Filled.Schedule @@ -108,7 +504,12 @@ fun InputDateTime( ) { val uiData = state.uiData - val uiValue = remember(state.inputTextFieldValue) { formatStoredDateToUI(state.inputTextFieldValue ?: TextFieldValue(), uiData.actionType) } + val uiValue = remember(state.inputTextFieldValue) { + formatStoredDateToUI( + state.inputTextFieldValue ?: TextFieldValue(), + uiData.actionType, + ) + } val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } var showDatePicker by rememberSaveable { mutableStateOf(false) } @@ -119,7 +520,8 @@ fun InputDateTime( uiData.selectableDates.initialDate, ) + " - " + formatStringToDate(uiData.selectableDates.endDate) + ")" - val incorrectHourFormatTextdd = uiData.incorrectHourFormatText ?: provideStringResource("wrong_hour_format") + val incorrectHourFormatTextdd = + uiData.incorrectHourFormatText ?: provideStringResource("wrong_hour_format") val incorrectHourFormatItem = SupportingTextData( text = incorrectHourFormatTextdd, SupportingTextState.ERROR, @@ -133,13 +535,25 @@ fun InputDateTime( SupportingTextState.ERROR, ) val supportingTextList = - getSupportingTextList(state, uiValue, uiData, dateOutOfRangeItem, incorrectHourFormatItem, incorrectDateFormatItem) + getSupportingTextList( + state, + uiValue, + uiData, + dateOutOfRangeItem, + incorrectHourFormatItem, + incorrectDateFormatItem, + ) InputShell( modifier = modifier.testTag("INPUT_DATE_TIME") .focusRequester(focusRequester), title = uiData.title, - state = getInputState(supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem, state.inputState), + state = getInputState( + supportingTextList, + dateOutOfRangeItem, + incorrectDateFormatItem, + state.inputState, + ), isRequiredField = uiData.isRequired, onFocusChanged = onFocusChanged, inputField = { @@ -159,7 +573,10 @@ fun InputDateTime( }, enabled = state.inputState != InputShellState.DISABLED, state = state.inputState, - keyboardOptions = KeyboardOptions(imeAction = uiData.imeAction, keyboardType = KeyboardType.Number), + keyboardOptions = KeyboardOptions( + imeAction = uiData.imeAction, + keyboardType = KeyboardType.Number, + ), visualTransformation = uiData.visualTransformation, onNextClicked = { manageOnNext(focusManager, onNextClicked) @@ -260,7 +677,14 @@ fun InputDateTime( showDatePicker = false if (uiData.actionType != DateTimeActionType.DATE_TIME) { datePickerState.selectedDateMillis?.let { - onValueChanged(TextFieldValue(getDate(it), selection = TextRange(state.inputTextFieldValue?.text?.length ?: 0))) + onValueChanged( + TextFieldValue( + getDate(it), + selection = TextRange( + state.inputTextFieldValue?.text?.length ?: 0, + ), + ), + ) } } else { showTimePicker = true @@ -290,7 +714,10 @@ fun InputDateTime( Text( text = uiData.title, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = Spacing.Spacing24, top = Spacing.Spacing24), + modifier = Modifier.padding( + start = Spacing.Spacing24, + top = Spacing.Spacing24, + ), ) }, state = datePickerState, @@ -306,7 +733,11 @@ fun InputDateTime( Dialog( onDismissRequest = { showDatePicker = false }, - properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true, usePlatformDefaultWidth = true), + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = true, + ), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -344,7 +775,17 @@ fun InputDateTime( uiData.acceptText ?: provideStringResource("ok"), ) { showTimePicker = false - manageOnValueChangedFromDateTimePicker(convertStringToTextFieldValue(getTime(timePickerState)), onValueChanged, uiData.actionType, datePickerState, timePickerState) + manageOnValueChangedFromDateTimePicker( + convertStringToTextFieldValue( + getTime( + timePickerState, + ), + ), + onValueChanged, + uiData.actionType, + datePickerState, + timePickerState, + ) } } } @@ -353,7 +794,11 @@ fun InputDateTime( } @Composable -fun InputDateResetButton(state: InputDateTimeState, onValueChanged: (TextFieldValue?) -> Unit, focusRequester: FocusRequester) { +fun InputDateResetButton( + state: InputDateTimeState, + onValueChanged: (TextFieldValue?) -> Unit, + focusRequester: FocusRequester, +) { if (!state.inputTextFieldValue?.text.isNullOrBlank() && state.inputState != InputShellState.DISABLED) { IconButton( modifier = Modifier.testTag("INPUT_DATE_TIME_RESET_BUTTON").padding(Spacing.Spacing0), @@ -387,7 +832,11 @@ fun manageOnNext(focusManager: FocusManager, onNextClicked: (() -> Unit)?) { } } -private fun manageOnValueChanged(newText: TextFieldValue, onValueChanged: (TextFieldValue?) -> Unit, actionType: DateTimeActionType) { +private fun manageOnValueChanged( + newText: TextFieldValue, + onValueChanged: (TextFieldValue?) -> Unit, + actionType: DateTimeActionType, +) { val allowedCharacters = RegExValidations.DATE_TIME.regex if (allowedCharacters.containsMatchIn(newText.text) || newText.text.isBlank()) { onValueChanged.invoke(formatUIDateToStored(newText, actionType)) @@ -395,14 +844,55 @@ private fun manageOnValueChanged(newText: TextFieldValue, onValueChanged: (TextF } @OptIn(ExperimentalMaterial3Api::class) -private fun manageOnValueChangedFromDateTimePicker(newValue: TextFieldValue?, onValueChanged: (TextFieldValue?) -> Unit, actionType: DateTimeActionType, datePickerState: DatePickerState, timePickerState: TimePickerState) { +private fun manageOnValueChangedFromDateTimePicker( + newValue: TextFieldValue?, + onValueChanged: (TextFieldValue?) -> Unit, + actionType: DateTimeActionType, + datePickerState: DatePickerState, + timePickerState: TimePickerState, +) { if (actionType != DateTimeActionType.DATE_TIME) { - onValueChanged(TextFieldValue(getTime(timePickerState), selection = TextRange(newValue?.text?.length ?: 0))) + onValueChanged( + TextFieldValue( + getTime(timePickerState), + selection = TextRange(newValue?.text?.length ?: 0), + ), + ) } else { - onValueChanged(TextFieldValue(getDate(datePickerState.selectedDateMillis) + getTime(timePickerState), selection = TextRange(newValue?.text?.length ?: 0))) + onValueChanged( + TextFieldValue( + getDate(datePickerState.selectedDateMillis) + getTime( + timePickerState, + ), + selection = TextRange(newValue?.text?.length ?: 0), + ), + ) } } +@Suppress("deprecation") +@Deprecated( + "This function is deprecated and will be removed in the next release.", + replaceWith = ReplaceWith("provideDatePickerState(state: InputDateTimeState, data: InputDateTimeData)"), +) +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun provideDatePickerState(uiModel: InputDateTimeModel): DatePickerState { + return uiModel.inputTextFieldValue?.text?.takeIf { + it.isNotEmpty() && + yearIsInRange(it, getDefaultFormat(uiModel.actionType), uiModel.yearRange) + }?.let { + rememberDatePickerState( + initialSelectedDateMillis = parseStringDateToMillis( + dateString = it, + pattern = getDefaultFormat(uiModel.actionType), + ), + yearRange = uiModel.yearRange, + selectableDates = getSelectableDates(uiModel), + ) + } ?: rememberDatePickerState(selectableDates = getSelectableDates(uiModel)) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun datePickerColors(): DatePickerColors { @@ -416,6 +906,18 @@ fun datePickerColors(): DatePickerColors { ) } +@Deprecated( + "This function is deprecated and will be removed in the near future", + replaceWith = ReplaceWith("parseStringDateToMillis(dateString: String, pattern: String)"), +) +private fun parseStringDateToMillis(dateString: String, pattern: String = "ddMMyyyy"): Long { + val cal = Calendar.getInstance() + return dateString.parseDate(pattern)?.let { + cal.time = it + cal.timeInMillis + } ?: 0L +} + internal fun getDate(milliSeconds: Long?, format: String? = "ddMMyyyy"): String { val cal = Calendar.getInstance() val currentTimeZone: TimeZone = cal.getTimeZone() @@ -447,7 +949,10 @@ internal fun getDate(milliSeconds: Long?, format: String? = "ddMMyyyy"): String fun formatStringToDate(dateString: String): String { return if (dateString.length == 8) { - dateString.substring(0, 2) + "/" + dateString.substring(2, 4) + "/" + dateString.substring(4, 8) + dateString.substring(0, 2) + "/" + dateString.substring( + 2, + 4, + ) + "/" + dateString.substring(4, 8) } else { dateString } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/ListCard.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/ListCard.kt index 4e840ce20..60691af31 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/ListCard.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/ListCard.kt @@ -94,6 +94,7 @@ fun ListCard( actionButton: @Composable (() -> Unit)? = null, onCardClick: () -> Unit, onSizeChanged: ((IntSize) -> Unit)? = null, + onCardSelected: ((SelectionState) -> Unit)? = null, ) { BaseCard( modifier = modifier, @@ -102,6 +103,10 @@ fun ListCard( expandable = listCardState.expandable, itemVerticalPadding = listCardState.itemVerticalPadding, onSizeChanged = onSizeChanged, + selectionMode = listCardState.selectionState, + onCardSelected = { + onCardSelected?.invoke(listCardState.selectionState.changeState()) + }, paddingValues = getPaddingValues( expandable = listCardState.expandable, hasShadow = listCardState.shadow, @@ -109,7 +114,12 @@ fun ListCard( ), ) { Row(horizontalArrangement = spacedBy(Spacing.Spacing16)) { - listAvatar?.invoke() + when (listCardState.selectionState) { + SelectionState.SELECTABLE -> UnselectedItemIcon() + SelectionState.SELECTED -> SelectedItemIcon() + SelectionState.NONE -> listAvatar?.invoke() + } + Column(Modifier.fillMaxWidth().weight(1f)) { Row(horizontalArrangement = Arrangement.SpaceBetween) { ListCardTitle( @@ -119,7 +129,9 @@ fun ListCard( ) listCardState.lastUpdateBasedOnLoading()?.let { ListCardLastUpdated(it) } } - listCardState.descriptionBasedOnLoading()?.let { ListCardDescription(it, Modifier) } + listCardState.descriptionBasedOnLoading()?.let { + ListCardDescription(it, Modifier.padding(bottom = Spacing.Spacing8)) + } AdditionalInfoColumn( additionalInfoColumnState = listCardState.additionalInfoColumnState, @@ -245,6 +257,8 @@ fun VerticalInfoListCard( ), expandable = listCardState.expandable, itemVerticalPadding = listCardState.itemVerticalPadding, + selectionMode = SelectionState.NONE, + onCardSelected = {}, onSizeChanged = onSizeChanged, ) { Column( @@ -256,7 +270,7 @@ fun VerticalInfoListCard( Column( modifier = Modifier.wrapContentHeight(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = spacedBy(Spacing.Spacing4), + verticalArrangement = spacedBy(Spacing4), ) { ListCardTitle( title = listCardState.title, @@ -264,7 +278,7 @@ fun VerticalInfoListCard( .padding(bottom = if (listCardState.description?.text != null) Spacing.Spacing0 else Spacing4), ) listCardState.descriptionBasedOnLoading()?.let { - ListCardDescription(it, Modifier) + ListCardDescription(it) } listCardState.lastUpdateBasedOnLoading()?.let { ListCardLastUpdated(it) @@ -673,8 +687,14 @@ fun ProvideKeyValueItem( text = finalAnnotatedString, textAlign = TextAlign.Start, style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 2, + overflow = when { + additionalInfoItem.truncate -> TextOverflow.Ellipsis + else -> TextOverflow.Clip + }, + maxLines = when { + additionalInfoItem.truncate -> 2 + else -> Int.MAX_VALUE + }, modifier = Modifier, ) @@ -811,6 +831,7 @@ data class AdditionalInfoItem( val value: String, val isConstantItem: Boolean = false, val color: Color? = null, + val truncate: Boolean = true, val action: (() -> Unit)? = null, ) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt new file mode 100644 index 000000000..a696d588f --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBar.kt @@ -0,0 +1,470 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.Place +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.WatchLater +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.Top +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.hisp.dhis.mobile.ui.designsystem.component.internal.modifiers.clickableWithRipple +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2SCustomTextStyles +import org.hisp.dhis.mobile.ui.designsystem.theme.Outline +import org.hisp.dhis.mobile.ui.designsystem.theme.Shape +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing.Spacing16 +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +/** + * DHIS2 Location Bar modes + * BUTTON: the Location Bar is displayed as a button and shows current search query if available. + * SEARCH: The Location Bar is displayed as an input and displays available location items. + */ +enum class SearchBarMode { + BUTTON, + SEARCH, +} + +/** + * DHIS2 Location Bar search actions + * Default: The user will need to select a result to mark it as selected + * OnOneItemSelect: If only one item is available, it will be selected automatically + * */ +enum class OnSearchAction { + Default, + OnOneItemSelect, +} + +/** + * DHIS2 Location Bar. + * @param currentResults: the available location items to display before/after search. + * @param mode: the initial mode for the composable. + * @param searchAction: How the search result selection is carried out. + * @param searching: whether the search is currently in progress. + * @param onBackClicked: callback for when the back button is clicked. + * @param onClearLocation: callback for when the clear location button is clicked. + * @param onSearchLocation: callback for when the search location button is clicked. + * @param onLocationSelected: callback for when a location item is selected. + * @param onModeChanged: optional callback for when the mode is changed. + */ +@Composable +fun LocationBar( + currentResults: List, + mode: SearchBarMode = SearchBarMode.BUTTON, + searchAction: OnSearchAction = OnSearchAction.Default, + searching: Boolean, + onBackClicked: () -> Unit, + onClearLocation: () -> Unit, + onSearchLocation: (query: String) -> Unit, + onLocationSelected: (LocationItemModel) -> Unit, + onModeChanged: (currentMode: SearchBarMode) -> Unit = {}, +) { + var currentMode by remember { mutableStateOf(mode) } + var currentSearch: String by remember { mutableStateOf("") } + fun selectItem(item: LocationItemModel) { + currentSearch = item.title + currentMode = SearchBarMode.BUTTON + onLocationSelected(item) + } + + LaunchedEffect(currentMode) { + onModeChanged(currentMode) + } + + LaunchedEffect(searchAction, currentResults) { + if (searchAction == OnSearchAction.OnOneItemSelect) { + currentResults.filterIsInstance() + .takeIf { it.size == 1 }?.let { + selectItem(it.first()) + } + } + } + + when (currentMode) { + SearchBarMode.BUTTON -> LocationSearchBarButton( + currentSearch = currentSearch, + onBackClicked = onBackClicked, + onClearLocation = { + currentSearch = "" + onClearLocation() + }, + onClick = { + currentMode = SearchBarMode.SEARCH + }, + ) + + SearchBarMode.SEARCH -> LocationSearchBar( + currentSearch = currentSearch, + currentResults = currentResults, + activeSearching = searching, + onSearchChanged = { + currentSearch = it + onSearchLocation(currentSearch) + }, + onBackClicked = { + currentMode = SearchBarMode.BUTTON + }, + onSearch = { searchQuery -> + currentMode = SearchBarMode.BUTTON + }, + onLocationSelected = { + selectItem(it) + currentSearch = it.title + currentMode = SearchBarMode.BUTTON + onLocationSelected(it) + }, + ) + } +} + +@Composable +private fun LocationSearchBarButton( + currentSearch: String = "", + onBackClicked: () -> Unit, + onClearLocation: () -> Unit, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .testTag("SEARCH_BAR_BUTTON") + .fillMaxWidth() + .wrapContentHeight() + .clip(Shape.Full) + .clickable(onClick = onClick) + .background( + color = SurfaceColor.ContainerLow, + shape = Shape.Full, + ) + .padding(Spacing.Spacing4), + verticalAlignment = CenterVertically, + horizontalArrangement = spacedBy(4.dp), + ) { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = onBackClicked, + icon = { + Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = null) + }, + ) + Text( + modifier = Modifier.weight(1f), + text = currentSearch.takeIf { it.isNotBlank() } ?: "Search location", + style = MaterialTheme.typography.bodyLarge, + color = if (currentSearch.isBlank()) { + TextColor.OnDisabledSurface + } else { + TextColor.OnSurface + }, + ) + if (currentSearch.isEmpty()) { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = {}, + icon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = null) + }, + ) + } else { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = onClearLocation, + icon = { + Icon(imageVector = Icons.Outlined.Cancel, contentDescription = null) + }, + ) + } + } +} + +@Composable +private fun LocationSearchBar( + currentSearch: String = "", + currentResults: List, + activeSearching: Boolean, + onSearchChanged: (String) -> Unit, + onSearch: (String) -> Unit, + onBackClicked: () -> Unit, + onLocationSelected: (LocationItemModel) -> Unit, +) { + val scrollState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + var needsToFocus by remember { mutableStateOf(true) } + var searching by remember(currentSearch) { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = spacedBy(Spacing.Spacing16), + ) { + SearchBar( + text = currentSearch, + placeHolderText = provideStringResource("search_location"), + onActiveChange = {}, + onQueryChange = onSearchChanged, + onSearch = onSearch, + leadingIcon = { + IconButton( + style = IconButtonStyle.STANDARD, + onClick = onBackClicked, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = null, + ) + }, + ) + }, + focusRequester = focusRequester, + ) + + LaunchedEffect(scrollState.isScrollInProgress, needsToFocus) { + if (scrollState.isScrollInProgress) { + focusRequester.freeFocus() + keyboardController?.hide() + } else if (needsToFocus) { + focusRequester.requestFocus() + needsToFocus = false + } + } + + LazyColumn(modifier = Modifier.fillMaxWidth(), state = scrollState) { + when { + activeSearching -> + item { + SearchProgressMessage() + } + + currentResults.isNotEmpty() -> + itemsIndexed(items = currentResults) { index, locationItemModel -> + SearchResultLocationItem( + modifier = Modifier.testTag("LOCATION_ITEM_$index"), + locationItemModel, + ) { + onLocationSelected(locationItemModel) + } + } + + else -> + item { + NoResultsMessage(isSearching = currentSearch.isNotBlank()) + } + } + + item { + HorizontalDivider( + thickness = 1.dp, + color = Outline.Medium, + modifier = Modifier.padding(vertical = Spacing16), + ) + } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + style = ButtonStyle.TONAL, + icon = { + Icon( + imageVector = Icons.Outlined.Map, + contentDescription = "touch app", + ) + }, + text = provideStringResource("select_in_map"), + onClick = onBackClicked, + ) + } + } + } + } +} + +@Composable +fun LocationItem( + modifier: Modifier = Modifier, + locationItemModel: LocationItemModel, + icon: @Composable () -> Unit, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(Shape.Small) + .clickableWithRipple(onClick = onClick) + .padding(Spacing.Spacing8), + horizontalArrangement = spacedBy(Spacing.Spacing16), + verticalAlignment = Top, + ) { + icon() + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = locationItemModel.title, + style = DHIS2SCustomTextStyles.titleMediumBold, + color = TextColor.OnPrimaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = locationItemModel.subtitle, + style = MaterialTheme.typography.bodySmall, + color = TextColor.OnSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun SearchResultLocationItem( + modifier: Modifier = Modifier, + locationItemModel: LocationItemModel, + onClick: () -> Unit, +) { + val icon = when (locationItemModel) { + is LocationItemModel.StoredResult -> Icons.Outlined.WatchLater + is LocationItemModel.SearchResult -> Icons.Outlined.Place + } + val tintedColor = when (locationItemModel) { + is LocationItemModel.StoredResult -> SurfaceColor.Warning + is LocationItemModel.SearchResult -> SurfaceColor.Primary + } + + val bgColor = when (locationItemModel) { + is LocationItemModel.StoredResult -> SurfaceColor.WarningContainer + is LocationItemModel.SearchResult -> SurfaceColor.PrimaryContainer + } + + LocationItem( + modifier = modifier, + locationItemModel = locationItemModel, + icon = { + LocationItemIcon( + icon = icon, + tintedColor = tintedColor, + bgColor = bgColor, + ) + }, + onClick = onClick, + ) +} + +/** + * DHIS2 Location Item icon. + * @param icon: the ImageVector to display as an icon. + * @param tintedColor: the color to tint the icon with. + * @param bgColor: the color for the background. + */ +@Composable +fun LocationItemIcon( + icon: ImageVector, + tintedColor: Color, + bgColor: Color, +) { + Box( + modifier = Modifier.size(Spacing.Spacing40) + .clip(Shape.Full) + .background(color = bgColor, shape = Shape.Full), + contentAlignment = Center, + ) { + Icon( + imageVector = icon, + tint = tintedColor, + contentDescription = "location icon", + ) + } +} + +@Composable +private fun NoResultsMessage(isSearching: Boolean) { + val message = if (!isSearching) { + provideStringResource("no_recent_results") + } else { + provideStringResource("no_results") + } + + Column( + modifier = Modifier + .testTag(if (!isSearching) "NO_RECENT_RESULTS" else "NO_RESULTS") + .fillMaxWidth() + .padding(vertical = 64.dp), + verticalArrangement = spacedBy(16.dp), + horizontalAlignment = CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.TravelExplore, + tint = SurfaceColor.ContainerHighest, + contentDescription = "Travel explore", + ) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = TextColor.OnSurfaceVariant, + ) + } +} + +@Composable +private fun SearchProgressMessage() { + val message = provideStringResource("searching_location") + + Column( + modifier = Modifier + .testTag("SEARCHING_LOCATION") + .fillMaxWidth() + .padding(vertical = 64.dp), + verticalArrangement = spacedBy(16.dp), + horizontalAlignment = CenterHorizontally, + ) { + ProgressIndicator(type = ProgressIndicatorType.CIRCULAR) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = TextColor.OnSurfaceVariant, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt index 05b151a06..74fd87cd0 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt @@ -24,13 +24,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -59,7 +60,9 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor * @param description: optional description. * @param clearAllButtonText: text for clear all button. * @param doneButtonText: text for accept button. + * @param doneButtonIcon: icon for accept button. * @param noResultsFoundText: text for no results found. + * @param headerTextAlignment [Alignment] for header text. * @param icon: optional icon to be shown above the header . * @param onSearch: access to the on search event. * @param onDismiss: access to the on dismiss event. @@ -77,14 +80,16 @@ fun OrgBottomSheet( subtitle: String? = null, description: String? = null, clearAllButtonText: String = provideStringResource("clear_all"), - doneButtonText: String = provideStringResource("done"), + doneButtonText: String? = null, + doneButtonIcon: ImageVector = Icons.Filled.Check, noResultsFoundText: String = provideStringResource("no_results_found"), + headerTextAlignment: TextAlign = TextAlign.Center, icon: @Composable (() -> Unit)? = null, onSearch: ((String) -> Unit)? = null, onDismiss: () -> Unit, onItemClick: (uid: String) -> Unit, onItemSelected: (uid: String, checked: Boolean) -> Unit, - onClearAll: () -> Unit, + onClearAll: (() -> Unit)? = null, onDone: () -> Unit, ) { var searchQuery by remember { mutableStateOf("") } @@ -96,6 +101,7 @@ fun OrgBottomSheet( title = title, subtitle = subtitle, description = description, + headerTextAlignment = headerTextAlignment, icon = icon, searchQuery = searchQuery, onSearchQueryChanged = { query -> @@ -125,32 +131,35 @@ fun OrgBottomSheet( Row( verticalAlignment = Alignment.CenterVertically, ) { - Button( - modifier = Modifier.weight(1f) - .testTag("CLEAR_ALL_BUTTON"), - onClick = onClearAll, - icon = { - Icon( - imageVector = Icons.Filled.ClearAll, - contentDescription = null, - ) - }, - text = clearAllButtonText, - enabled = orgTreeItems.any { it.selected }, - ) + if (onClearAll != null) { + Button( + modifier = Modifier.weight(1f) + .testTag("CLEAR_ALL_BUTTON"), + onClick = onClearAll, + icon = { + Icon( + imageVector = Icons.Filled.ClearAll, + contentDescription = null, + ) + }, + text = clearAllButtonText, + enabled = orgTreeItems.any { it.selected }, + ) - Spacer(Modifier.requiredWidth(Spacing.Spacing16)) + Spacer(Modifier.requiredWidth(Spacing.Spacing16)) + } Button( modifier = Modifier.weight(1f), onClick = onDone, icon = { Icon( - imageVector = Icons.Filled.Check, + imageVector = doneButtonIcon, contentDescription = null, ) }, - text = doneButtonText, + enabled = orgTreeItems.any { it.selected }, + text = doneButtonText ?: provideStringResource("done"), style = ButtonStyle.FILLED, ) } @@ -191,13 +200,15 @@ private fun OrgTreeList( horizontalAlignment = Alignment.Start, ) { orgTreeItems.forEach { item -> - OrgUnitSelectorItem( - orgTreeItem = item, - higherLevel = orgTreeItems.minBy { it.level }.level, - searchQuery = searchQuery, - onItemClick = onItemClick, - onItemSelected = onItemSelected, - ) + key(item.uid) { + OrgUnitSelectorItem( + orgTreeItem = item, + higherLevel = orgTreeItems.minBy { it.level }.level, + searchQuery = searchQuery, + onItemClick = onItemClick, + onItemSelected = onItemSelected, + ) + } } } } @@ -229,20 +240,9 @@ fun OrgUnitSelectorItem( .padding(start = ((orgTreeItem.level - higherLevel) * 16).dp), verticalAlignment = Alignment.CenterVertically, ) { - val icon = orgTreeItemIcon(orgTreeItem) - val iconTint = if (orgTreeItem.isOpen && orgTreeItem.hasChildren) { - TextColor.OnDisabledSurface - } else if (!orgTreeItem.isOpen && !orgTreeItem.hasChildren) { - TextColor.OnDisabledSurface - } else { - TextColor.OnSurfaceVariant - } - - Icon( + OrgTreeItemIcon( modifier = Modifier.padding(Spacing.Spacing8), - painter = icon, - tint = iconTint, - contentDescription = "", + orgTreeItem = orgTreeItem, ) val clickableModifier = if (orgTreeItem.canBeSelected) { @@ -294,13 +294,31 @@ fun OrgUnitSelectorItem( } @Composable -private fun orgTreeItemIcon(orgTreeItem: OrgTreeItem): Painter { - if (!orgTreeItem.hasChildren) return provideDHIS2Icon("material_circle_outline") - - return if (orgTreeItem.isOpen) { - rememberVectorPainter(Icons.Filled.KeyboardArrowDown) +private fun OrgTreeItemIcon( + modifier: Modifier = Modifier, + orgTreeItem: OrgTreeItem, +) { + if (!orgTreeItem.hasChildren) { + Icon( + modifier = modifier, + painter = provideDHIS2Icon("material_circle_outline"), + contentDescription = null, + tint = TextColor.OnDisabledSurface, + ) + } else if (orgTreeItem.isOpen) { + Icon( + modifier = modifier, + painter = rememberVectorPainter(Icons.Filled.KeyboardArrowDown), + contentDescription = null, + tint = TextColor.OnDisabledSurface, + ) } else { - rememberVectorPainter(Icons.AutoMirrored.Filled.KeyboardArrowRight) + Icon( + modifier = modifier, + painter = rememberVectorPainter(Icons.AutoMirrored.Filled.KeyboardArrowRight), + contentDescription = null, + tint = TextColor.OnSurfaceVariant, + ) } } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt index f37f5f7e5..3998263a1 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/SearchBar.kt @@ -35,6 +35,8 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -56,6 +58,8 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor * @param onQueryChange: on query change callback. * @param state: input shell state. * @param modifier: optional modifier. + * @param leadingIcon: optional leading icon to display. + * @param focusRequester: optional focus requester. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,10 +71,14 @@ fun SearchBar( onQueryChange: (String) -> Unit = {}, state: InputShellState = InputShellState.FOCUSED, modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + focusRequester: FocusRequester = remember { FocusRequester() }, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() - val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val containerColor = if (!isPressed) { SurfaceColor.ContainerLow } else { @@ -111,14 +119,18 @@ fun SearchBar( false } } - .padding(end = Spacing.Spacing4) + .padding(horizontal = Spacing.Spacing4) .semantics { contentDescription = "Search" }, enabled = true, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch(text) }), + keyboardActions = KeyboardActions(onSearch = { + keyboardController?.hide() + focusManager.clearFocus() + onSearch(text) + }), interactionSource = interactionSource, textStyle = MaterialTheme.typography.bodyLarge, decorationBox = @Composable { innerTextField -> @@ -135,6 +147,7 @@ fun SearchBar( color = TextColor.OnDisabledSurface, ) }, + leadingIcon = leadingIcon, trailingIcon = { if (text != "") { IconButton( diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Selection.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Selection.kt new file mode 100644 index 000000000..1082bb904 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Selection.kt @@ -0,0 +1,70 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.theme.Radius +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +enum class SelectionState { + SELECTABLE, + SELECTED, + NONE, + ; + + fun changeState(): SelectionState { + return when (this) { + SELECTABLE -> SELECTED + SELECTED -> SELECTABLE + NONE -> SELECTED + } + } +} + +@Composable +fun UnselectedItemIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(Spacing.Spacing40) + .background( + color = SurfaceColor.PrimaryContainer, + shape = RoundedCornerShape(Radius.Full), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.CheckBoxOutlineBlank, + contentDescription = "Unselected", + tint = SurfaceColor.ContainerHighest, + ) + } +} + +@Composable +fun SelectedItemIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(Spacing.Spacing40) + .background( + color = SurfaceColor.Primary, + shape = RoundedCornerShape(Radius.Full), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = "Selected", + tint = TextColor.OnPrimary, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Text.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Text.kt index 9e07b66af..71269f396 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Text.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/Text.kt @@ -151,7 +151,7 @@ internal fun ListCardDescription( description.text, color = description.color ?: TextColor.OnSurface, style = description.style ?: MaterialTheme.typography.bodyMedium, - modifier = modifier.padding(bottom = Spacing.Spacing8), + modifier = modifier, ) } } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/TopBar.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/TopBar.kt new file mode 100644 index 000000000..ae36fac57 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/TopBar.kt @@ -0,0 +1,123 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +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 androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * A composable function that renders a top app bar. Depending on the [type], it can display either a + * default aligned top app bar or a center-aligned top app bar. + * + * @param modifier The [Modifier] to be applied to the TopBar. + * @param type The type of the TopBar, either [TopBarType.DEFAULT] for a default aligned top app bar or [TopBarType.CENTERED] for a center-aligned top app bar. + * @param navigationIcon A composable function that represents the navigation icon displayed in the TopBar, typically a back arrow or a menu icon. + * @param actions A composable function that represents the actions (e.g., icons, menus) displayed on the right side of the TopBar. + * @param title A composable function that represents the title content of the TopBar. + * @param colors A [TopAppBarColors] that defines the color scheme for the TopBar. Default is [TopAppBarDefaults.topAppBarColors]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBar( + modifier: Modifier = Modifier, + type: TopBarType = TopBarType.DEFAULT, + navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, + title: @Composable () -> Unit, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), +) { + if (type == TopBarType.DEFAULT) { + TopAppBar( + modifier = modifier, + title = title, + navigationIcon = navigationIcon, + actions = actions, + colors = colors, + ) + } else { + CenterAlignedTopAppBar( + modifier = modifier, + title = title, + navigationIcon = navigationIcon, + actions = actions, + colors = colors, + ) + } +} + +/** + * A composable function that renders an action icon within the TopBar. + * + * @param icon The [ImageVector] representing the icon to be displayed. + * @param tint The tint color for the icon. Default is [Color.Unspecified]. + * @param contentDescription A description of the icon for accessibility purposes. + * @param onClick The callback to be invoked when the icon is clicked. + */ +@Composable +fun TopBarActionIcon( + icon: ImageVector, + tint: Color = Color.Unspecified, + contentDescription: String = "", + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + icon = { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + ) + }, + ) +} + +/** + * A composable function that renders an icon button which toggles a dropdown menu. + * + * @param iconTint The tint color for the dropdown icon. Default is [Color.Unspecified]. + * @param dropDownMenu A composable function that renders the dropdown menu, receiving the current state (shown or hidden) and a callback to dismiss the menu. + */ +@Composable +fun TopBarDropdownMenuIcon( + iconTint: Color = Color.Unspecified, + dropDownMenu: @Composable (showMenu: Boolean, onDismissRequest: () -> Unit) -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + IconButton( + onClick = { showMenu = !showMenu }, + icon = { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More", + tint = iconTint, + ) + }, + ) + dropDownMenu(showMenu) { showMenu = false } +} + +/** + * Enum class representing the type of the TopBar. + * + * @property DEFAULT The default TopBar alignment with the title left-aligned. + * @property CENTERED A center-aligned TopBar with the title centered. + */ +enum class TopBarType { + DEFAULT, + CENTERED, +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt index 443a1d668..e6fc295c6 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/DateTimeUtils.kt @@ -270,7 +270,12 @@ internal fun getSelectableDates(selectableDates: SelectableDates): androidx.comp @Deprecated("This function is deprecated and will be removed in the next release. Use overloaded fun instead.") @Suppress("DEPRECATION") -internal fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { +internal fun getSupportingTextList( + uiModel: InputDateTimeModel, + dateOutOfRangeItem: SupportingTextData, + incorrectHourFormatItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, +): List { val supportingTextList = mutableListOf() uiModel.supportingText?.forEach { item -> @@ -290,6 +295,7 @@ internal fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeIt uiModel.supportingText } } + DateTimeActionType.DATE_TIME -> { if (uiModel.inputTextFieldValue?.text!!.length == 12) { dateIsInRange = dateIsInRange( @@ -306,6 +312,7 @@ internal fun getSupportingTextList(uiModel: InputDateTimeModel, dateOutOfRangeIt if (!isValidHourFormat) supportingTextList.add(incorrectHourFormatItem) } } + DateTimeActionType.DATE -> { if (uiModel.inputTextFieldValue?.text!!.length == 8) { dateIsInRange = dateIsInRange(parseStringDateToMillis(uiModel.inputTextFieldValue.text), uiModel.selectableDates, uiModel.format) @@ -327,7 +334,6 @@ internal fun getSupportingTextList( dateOutOfRangeItem: SupportingTextData, incorrectHourFormatItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData, - ): List { val supportingTextList = state.supportingText?.toMutableList() ?: mutableListOf() @@ -336,22 +342,48 @@ internal fun getSupportingTextList( DateTimeActionType.TIME -> { getTimeSupportingTextList(uiValue, supportingTextList, incorrectHourFormatItem) } + DateTimeActionType.DATE_TIME -> { - getDateTimeSupportingTextList(uiValue, dateOutOfRangeItem, incorrectDateFormatItem, incorrectHourFormatItem, state, data, supportingTextList) + getDateTimeSupportingTextList( + uiValue, + dateOutOfRangeItem, + incorrectDateFormatItem, + incorrectHourFormatItem, + state, + data, + supportingTextList, + ) } + DateTimeActionType.DATE -> { - getDateSupportingText(uiValue, data, supportingTextList, dateOutOfRangeItem, incorrectDateFormatItem) + getDateSupportingText( + uiValue, + data.selectableDates, + data.actionType, + data.yearRange, + supportingTextList, + dateOutOfRangeItem, + incorrectDateFormatItem, + ) } } } return supportingTextList.toList() } -internal fun getDateSupportingText(uiValue: TextFieldValue, data: InputDateTimeData, supportingTextList: MutableList, dateOutOfRangeItem: SupportingTextData, incorrectDateFormatItem: SupportingTextData): List { +internal fun getDateSupportingText( + uiValue: TextFieldValue, + selectableDates: SelectableDates, + actionType: DateTimeActionType, + yearRange: IntRange, + supportingTextList: MutableList, + dateOutOfRangeItem: SupportingTextData, + incorrectDateFormatItem: SupportingTextData, +): List { if (uiValue.text.length == 8) { - val dateIsInRange = dateIsInRange(parseStringDateToMillis(uiValue.text), data.selectableDates) + val dateIsInRange = dateIsInRange(parseStringDateToMillis(uiValue.text), selectableDates) val isValidDateFormat = isValidDate(uiValue.text) - val dateIsInYearRange = yearIsInRange(uiValue.text, getDefaultFormat(data.actionType), data.yearRange) + val dateIsInYearRange = yearIsInRange(uiValue.text, getDefaultFormat(actionType), yearRange) if (!dateIsInRange || !dateIsInYearRange) supportingTextList.add(dateOutOfRangeItem) if (!isValidDateFormat) supportingTextList.add(incorrectDateFormatItem) } @@ -384,7 +416,11 @@ internal fun getDateTimeSupportingTextList( return supportingTextList } -internal fun getTimeSupportingTextList(inputTextFieldValue: TextFieldValue?, supportingTextList: MutableList, incorrectHourFormatItem: SupportingTextData): List { +internal fun getTimeSupportingTextList( + inputTextFieldValue: TextFieldValue?, + supportingTextList: MutableList, + incorrectHourFormatItem: SupportingTextData, +): List { if (inputTextFieldValue?.text!!.length == 4 && !isValidHourFormat(inputTextFieldValue.text)) { supportingTextList.add(incorrectHourFormatItem) } @@ -394,7 +430,10 @@ internal fun getTimeSupportingTextList(inputTextFieldValue: TextFieldValue?, sup @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun getTimePickerState(state: InputDateTimeState, uiData: InputDateTimeData): TimePickerState { - return if (state.inputTextFieldValue?.text?.isNotEmpty() == true && uiData.actionType == DateTimeActionType.TIME && isValidHourFormat(state.inputTextFieldValue?.text ?: "")) { + return if (state.inputTextFieldValue?.text?.isNotEmpty() == true && uiData.actionType == DateTimeActionType.TIME && isValidHourFormat( + state.inputTextFieldValue?.text ?: "", + ) + ) { rememberTimePickerState( initialHour = state.inputTextFieldValue!!.text.substring(0, 2) .toInt(), @@ -403,7 +442,10 @@ internal fun getTimePickerState(state: InputDateTimeState, uiData: InputDateTime ) } else if (state.inputTextFieldValue?.text?.length == 12 && isValidHourFormat(state.inputTextFieldValue!!.text.substring(8, 12))) { rememberTimePickerState( - initialHour = state.inputTextFieldValue?.text?.substring(state.inputTextFieldValue!!.text.length - 4, state.inputTextFieldValue!!.text.length - 2)!! + initialHour = state.inputTextFieldValue?.text?.substring( + state.inputTextFieldValue!!.text.length - 4, + state.inputTextFieldValue!!.text.length - 2, + )!! .toInt(), state.inputTextFieldValue!!.text.substring(state.inputTextFieldValue!!.text.length - 2, state.inputTextFieldValue!!.text.length).toInt(), is24Hour = uiData.is24hourFormat, diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt new file mode 100644 index 000000000..44053c380 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/internal/modifiers/ClickableWithRipple.kt @@ -0,0 +1,25 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.internal.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor + +@Composable +internal fun Modifier.clickableWithRipple( + role: Role = Role.Button, + color: Color = SurfaceColor.Primary, + onClick: () -> Unit, +): Modifier = this.then( + Modifier.clickable( + role = role, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = color), + onClick = onClick, + ), +) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/DropDownMenu.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/DropDownMenu.kt new file mode 100644 index 000000000..062a6629e --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/DropDownMenu.kt @@ -0,0 +1,46 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.hisp.dhis.mobile.ui.designsystem.theme.DHISShapes +import org.hisp.dhis.mobile.ui.designsystem.theme.Shape +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor + +@Composable +fun DropDownMenu( + modifier: Modifier = Modifier, + items: List>, + expanded: Boolean = false, + selectedItemIndex: Int? = null, + onDismissRequest: () -> Unit, + onItemClick: (T) -> Unit, +) { + MaterialTheme(shapes = DHISShapes.copy(extraSmall = Shape.Small)) { + DropdownMenu( + modifier = modifier + .background(SurfaceColor.ContainerLow) + .widthIn(min = 270.dp), + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + items.forEachIndexed { index, item -> + MenuItem( + menuItemData = item.copy( + state = if (selectedItemIndex == index) { + MenuItemState.SELECTED + } else { + item.state + }, + ), + ) { + onItemClick(item.id) + } + } + } + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/MenuItem.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/MenuItem.kt new file mode 100644 index 000000000..84bd3fc3e --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/MenuItem.kt @@ -0,0 +1,301 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_CONTAINER +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_DIVIDER +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_LEADING_ICON +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_LEADING_INDENT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_SUPPORTING_TEXT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_TEXT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_TRAILING_ICON +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_TRAILING_TEXT +import org.hisp.dhis.mobile.ui.designsystem.theme.Border +import org.hisp.dhis.mobile.ui.designsystem.theme.Outline +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor +import org.hisp.dhis.mobile.ui.designsystem.theme.hoverPointerIcon + +/** + * DHIS2 [MenuItem] Used for dropdown menu. + * @param modifier: allows a modifier to be passed externally. + * @param menuItemData: manages the [MenuItemData] + * @param onItemClick: callback to when menu item is clicked. + */ +@Composable +fun MenuItem( + modifier: Modifier = Modifier, + menuItemData: MenuItemData, + onItemClick: (T) -> Unit, +) { + val itemContainerBackground = when (menuItemData.state) { + MenuItemState.SELECTED -> { + if (menuItemData.style == MenuItemStyle.ALERT) { + SurfaceColor.ErrorContainer + } else { + SurfaceColor.Container + } + } + + else -> Color.Transparent + } + + Column( + modifier = modifier.testTag(MENU_ITEM_CONTAINER), + ) { + Row( + modifier = Modifier + .background(itemContainerBackground) + .alpha(if (menuItemData.state != MenuItemState.DISABLED) 1f else 0.38f) + .clickable( + enabled = menuItemData.state != MenuItemState.DISABLED, + onClick = { + onItemClick(menuItemData.id) + }, + ) + .hoverPointerIcon(menuItemData.state != MenuItemState.DISABLED) + .height(Spacing.Spacing48) + .padding(horizontal = Spacing.Spacing12), + verticalAlignment = Alignment.CenterVertically, + ) { + MenuItemLeadingElement( + leadingElement = menuItemData.leadingElement, + style = menuItemData.style, + state = menuItemData.state, + ) + Column( + modifier = Modifier.weight(1f), + ) { + Text( + maxLines = if (!menuItemData.supportingText.isNullOrEmpty()) 1 else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(MENU_ITEM_TEXT), + style = MaterialTheme.typography.bodyLarge, + color = if (menuItemData.style == MenuItemStyle.ALERT) SurfaceColor.Error else TextColor.OnSurface, + text = menuItemData.label, + ) + if (!menuItemData.supportingText.isNullOrEmpty()) { + Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(MENU_ITEM_SUPPORTING_TEXT), + style = MaterialTheme.typography.bodyMedium, + color = if (menuItemData.style == MenuItemStyle.ALERT) TextColor.OnErrorContainer else TextColor.OnSurfaceVariant, + text = menuItemData.supportingText, + ) + } + } + + MenuItemTrailingElement( + trailingElement = menuItemData.trailingElement, + state = menuItemData.state, + ) + } + if (menuItemData.showDivider) { + HorizontalDivider( + modifier = modifier + .testTag(MENU_ITEM_DIVIDER) + .padding(vertical = Spacing.Spacing8), + thickness = Border.Thin, + color = Outline.Medium, + ) + } + } +} + +@Composable +private fun MenuItemLeadingElement( + leadingElement: MenuLeadingElement? = null, + style: MenuItemStyle, + state: MenuItemState, +) { + when (leadingElement) { + is MenuLeadingElement.Indent -> { + Box( + modifier = Modifier + .testTag(MENU_ITEM_LEADING_INDENT) + .padding(end = Spacing.Spacing12) + .size(Spacing.Spacing24), + ) + } + + is MenuLeadingElement.Icon -> { + val iconTint = when (state) { + MenuItemState.SELECTED -> { + if (style == MenuItemStyle.ALERT) { + leadingElement.selectedErrorTintColor + } else { + leadingElement.selectedTintColor + } + } + + else -> if (style == MenuItemStyle.ALERT) { + leadingElement.defaultErrorTintColor + } else { + leadingElement.defaultTintColor + } + } + Icon( + imageVector = leadingElement.icon, + modifier = Modifier + .testTag(MENU_ITEM_LEADING_ICON) + .padding(end = Spacing.Spacing12) + .size(Spacing.Spacing24), + contentDescription = null, + tint = iconTint, + ) + } + + else -> {} + } +} + +@Composable +private fun MenuItemTrailingElement( + trailingElement: MenuTrailingElement? = null, + state: MenuItemState, +) { + when (trailingElement) { + is MenuTrailingElement.Icon -> { + Icon( + modifier = Modifier + .testTag(MENU_ITEM_TRAILING_ICON) + .padding(start = Spacing.Spacing12) + .size(Spacing.Spacing24), + imageVector = trailingElement.icon, + contentDescription = null, + tint = if (state == MenuItemState.SELECTED) trailingElement.selectedTintColor else trailingElement.defaultTintColor, + ) + } + + is MenuTrailingElement.Text -> { + Text( + modifier = Modifier + .testTag(MENU_ITEM_TRAILING_TEXT) + .padding(start = Spacing.Spacing12), + style = MaterialTheme.typography.bodyLarge, + color = if (state == MenuItemState.SELECTED) TextColor.OnSurface else TextColor.OnSurfaceVariant, + text = trailingElement.text, + ) + } + + else -> {} + } +} + +/** + * DHIS2 [MenuItemData], + * class to control the [MenuItem] + * @param label: controls the text to be shown. + * @param state: controls the [MenuItem] state. + * @param style: controls the [MenuItem] style. + * @param leadingElement: controls the [MenuLeadingElement]. + * @param trailingElement: controls the [MenuTrailingElement]. + * @param supportingText: controls the supporting text to be shown. + * @param showDivider: controls whether a divider should be shown. + */ +data class MenuItemData( + val id: T, + val label: String, + val state: MenuItemState = MenuItemState.ENABLED, + val style: MenuItemStyle = MenuItemStyle.DEFAULT, + val leadingElement: MenuLeadingElement? = null, + val trailingElement: MenuTrailingElement? = null, + val supportingText: String? = null, + val showDivider: Boolean = false, +) + +/** + * DHIS2 MenuItemState, + * enum class to control the [MenuItem] state + */ +enum class MenuItemState { + ENABLED, + SELECTED, + DISABLED, +} + +/** + * DHIS2 MenuItemStyle, + * enum class to control the [MenuItem] style + */ +enum class MenuItemStyle { + DEFAULT, + ALERT, +} + +/** + * DHIS2 [MenuLeadingElement], + * class to control the [MenuItem] leading element + */ +sealed class MenuLeadingElement { + /** + * DHIS2 [Indent], + * class to control the [MenuLeadingElement] trailing element indent. + */ + data object Indent : MenuLeadingElement() + + /** + * DHIS2 [Icon], + * class to control the [MenuLeadingElement] trailing element icon. + * @param icon: controls the icon to be shown. + * @param defaultErrorTintColor: controls the error style tint color. + * @param defaultTintColor: controls the default tint color. + * @param selectedErrorTintColor: controls the error style tint color when selected. + * @param selectedTintColor: controls the tint color when selected. + */ + data class Icon( + val icon: ImageVector, + val defaultErrorTintColor: Color = SurfaceColor.Error, + val defaultTintColor: Color = SurfaceColor.Primary, + val selectedErrorTintColor: Color = TextColor.OnErrorContainer, + val selectedTintColor: Color = TextColor.OnPrimaryContainer, + ) : MenuLeadingElement() +} + +/** + * DHIS2 [MenuTrailingElement], + * class to control the [MenuItem] trailing element + */ +sealed class MenuTrailingElement { + /** + * DHIS2 [Icon], + * class to control the [MenuTrailingElement] trailing element icon. + * @param icon: controls the icon to be shown. + * @param defaultTintColor: controls the default tint color. + * @param selectedTintColor: controls the tint color when selected. + */ + data class Icon( + val icon: ImageVector, + val defaultTintColor: Color = TextColor.OnSurfaceVariant, + val selectedTintColor: Color = TextColor.OnSurface, + ) : MenuTrailingElement() + + /** + * DHIS2 [Text], + * class to control the [MenuTrailingElement] trailing element text. + * @param text: controls the text to be shown. + */ + data class Text( + val text: String, + ) : MenuTrailingElement() +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/MenuItemTestTags.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/MenuItemTestTags.kt new file mode 100644 index 000000000..aca398680 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/menu/MenuItemTestTags.kt @@ -0,0 +1,12 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.menu + +object MenuItemTestTags { + const val MENU_ITEM_CONTAINER = "MENU_ITEM_CONTAINER" + const val MENU_ITEM_TEXT = "MENU_ITEM_TEXT" + const val MENU_ITEM_SUPPORTING_TEXT = "MENU_ITEM_SUPPORTING_TEXT" + const val MENU_ITEM_DIVIDER = "MENU_ITEM_DIVIDER" + const val MENU_ITEM_LEADING_INDENT = "MENU_ITEM_LEADING_INDENT" + const val MENU_ITEM_LEADING_ICON = "MENU_ITEM_LEADING_ICON" + const val MENU_ITEM_TRAILING_ICON = "MENU_ITEM_TRAILING_ICON" + const val MENU_ITEM_TRAILING_TEXT = "MENU_ITEM_TRAILING_TEXT" +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt new file mode 100644 index 000000000..90c171a1d --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/model/LocationItemModel.kt @@ -0,0 +1,46 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.model + +sealed class LocationItemModel( + val title: String, + val subtitle: String, + val latitude: Double, + val longitude: Double, +) { + /** + * UiModel used for Location Items which are stored in cache or local database. + * @param storedTitle: the label to display. + * @param storedSubtitle: the subtitle to display. + * @param storedLatitude: the latitude of the location. + * @param storedLongitude the longitude of the location. + */ + data class StoredResult( + private val storedTitle: String, + private val storedSubtitle: String, + private val storedLatitude: Double, + private val storedLongitude: Double, + ) : LocationItemModel( + title = storedTitle, + subtitle = storedSubtitle, + latitude = storedLatitude, + longitude = storedLongitude, + ) + + /** + * UiModel used for Location Items which are provided by external apis. + * @param searchedTitle: the label to display. + * @param searchedSubtitle: the subtitle to display. + * @param searchedLatitude: the latitude of the location. + * @param searchedLongitude the longitude of the location. + */ + data class SearchResult( + private val searchedTitle: String, + private val searchedSubtitle: String, + private val searchedLatitude: Double, + private val searchedLongitude: Double, + ) : LocationItemModel( + title = searchedTitle, + subtitle = searchedSubtitle, + latitude = searchedLatitude, + longitude = searchedLongitude, + ) +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputAgeState.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputAgeState.kt new file mode 100644 index 000000000..a4bfc7672 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/InputAgeState.kt @@ -0,0 +1,66 @@ +package org.hisp.dhis.mobile.ui.designsystem.component.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.input.ImeAction +import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.LegendData +import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData + +@Stable +interface InputAgeState { + val uiData: InputAgeData + val inputType: AgeInputType + val inputState: InputShellState + val legendData: LegendData? + val supportingText: List? +} + +@Stable +internal class InputAgeStateImpl( + override val uiData: InputAgeData, + override val inputType: AgeInputType, + override val inputState: InputShellState, + override val legendData: LegendData?, + override val supportingText: List?, +) : InputAgeState + +@Composable +fun rememberInputAgeState( + inputAgeData: InputAgeData, + inputType: AgeInputType = AgeInputType.None, + inputState: InputShellState = InputShellState.UNFOCUSED, + legendData: LegendData? = null, + supportingText: List? = null, +): InputAgeState = remember( + inputType, + inputState, + legendData, + supportingText, +) { + InputAgeStateImpl( + inputAgeData, + inputType, + inputState, + legendData, + supportingText, + ) +} + +data class InputAgeData( + val title: String, + val inputStyle: InputStyle = InputStyle.DataInputStyle(), + val isRequired: Boolean = false, + val imeAction: ImeAction = ImeAction.Next, + val dateOfBirthLabel: String? = null, + val orLabel: String? = null, + val ageLabel: String? = null, + val acceptText: String? = null, + val cancelText: String? = null, + val is24hourFormat: Boolean = false, + val selectableDates: SelectableDates? = null, +) diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/ListCardState.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/ListCardState.kt index 63f494b67..cbcab6589 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/ListCardState.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/state/ListCardState.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.unit.Dp import org.hisp.dhis.mobile.ui.designsystem.component.ListCardDescriptionModel import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel +import org.hisp.dhis.mobile.ui.designsystem.component.SelectionState @Stable interface ListCardState { @@ -17,6 +18,7 @@ interface ListCardState { val shadow: Boolean val expandable: Boolean val itemVerticalPadding: Dp? + val selectionState: SelectionState fun descriptionBasedOnLoading() = description?.takeIf { !loading } fun lastUpdateBasedOnLoading() = lastUpdated?.takeIf { !loading } @@ -32,6 +34,7 @@ internal class ListCardStateImpl( override val shadow: Boolean, override val expandable: Boolean, override val itemVerticalPadding: Dp?, + override val selectionState: SelectionState, ) : ListCardState @Composable @@ -44,12 +47,14 @@ fun rememberListCardState( shadow: Boolean = true, expandable: Boolean = false, itemVerticalPadding: Dp? = null, + selectionState: SelectionState = SelectionState.NONE, ): ListCardState = remember( description, itemVerticalPadding, loading, additionalInfoColumnState, lastUpdated, + selectionState, ) { ListCardStateImpl( title, @@ -60,5 +65,6 @@ fun rememberListCardState( shadow, expandable, itemVerticalPadding, + selectionState, ) } diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/theme/Shadow.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/theme/Shadow.kt index 22affe405..14df826bc 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/theme/Shadow.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/theme/Shadow.kt @@ -98,7 +98,7 @@ internal fun Modifier.iconCardShadow( } }.padding(bottom = shadowRadius) -internal fun Modifier.dropShadow( +fun Modifier.dropShadow( shape: Shape, color: Color = SurfaceColor.Container, blur: Dp = 10.dp, @@ -112,10 +112,10 @@ internal fun Modifier.dropShadow( // Create a Paint object val paint = Paint() -// Apply specified color + // Apply specified color paint.color = color -// Check for valid blur radius + // Check for valid blur radius if (blur.toPx() > 0) { paint.asFrameworkPaint().apply { // Apply blur to the Paint diff --git a/designsystem/src/commonMain/resources/values-es/strings.xml b/designsystem/src/commonMain/resources/values-es/strings.xml index 60a7045a4..e347b3ed5 100644 --- a/designsystem/src/commonMain/resources/values-es/strings.xml +++ b/designsystem/src/commonMain/resources/values-es/strings.xml @@ -42,4 +42,9 @@ Formato de fecha incorrecto Formato de hora incorrecto No se muestran todas las opciones.\n Busca para ver más. + Buscar localización + Seleccionar en el mapa + No hay resultados recientes + No hay resultados + Buscando... diff --git a/designsystem/src/commonMain/resources/values/strings.xml b/designsystem/src/commonMain/resources/values/strings.xml index fcfa8e63c..523a10f8c 100644 --- a/designsystem/src/commonMain/resources/values/strings.xml +++ b/designsystem/src/commonMain/resources/values/strings.xml @@ -42,4 +42,9 @@ Incorrect date format Incorrect time format Not all options are displayed.\n Search to see more. + Search location + Select in map + No recent results + No results + Searching... diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt index c3dbd2a6f..fcbcb815f 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputAgeTest.kt @@ -3,11 +3,15 @@ package org.hisp.dhis.mobile.ui.designsystem.component import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.input.TextFieldValue +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState import org.junit.Rule import org.junit.Test import java.text.SimpleDateFormat @@ -22,12 +26,14 @@ class InputAgeTest { fun modeSelectionShouldBeShownWhenComponentIsInitialised() { rule.setContent { InputAge( - InputAgeModel( - title = "Label", - onValueChanged = { - // no-op - }, + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), ), + onValueChanged = { + // no-op + }, ) } @@ -42,13 +48,15 @@ class InputAgeTest { fun dateOfBirthFieldShouldBeShownCorrectly() { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth.EMPTY, - onValueChanged = { - // no-op - }, ), + onValueChanged = { + // no-op + }, ) } @@ -64,13 +72,15 @@ class InputAgeTest { var inputType by mutableStateOf(AgeInputType.None) rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.DateOfBirth.EMPTY, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -84,13 +94,15 @@ class InputAgeTest { fun ageFieldShouldBeShownCorrectly() { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age.EMPTY, - onValueChanged = { - // no-op - }, ), + onValueChanged = { + // no-op + }, ) } @@ -106,13 +118,15 @@ class InputAgeTest { var inputType by mutableStateOf(AgeInputType.None) rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = AgeInputType.Age.EMPTY, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -127,13 +141,15 @@ class InputAgeTest { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -156,14 +172,15 @@ class InputAgeTest { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { - inputType = it - }, ), - + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -178,13 +195,15 @@ class InputAgeTest { rule.setContent { InputAge( - InputAgeModel( - title = "Label", + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), inputType = inputType, - onValueChanged = { - inputType = it - }, ), + onValueChanged = { + inputType = it ?: AgeInputType.None + }, ) } @@ -206,4 +225,62 @@ class InputAgeTest { assert(newInputDaysType.value.text == "28") assert(newInputDaysType.unit == TimeUnitValues.DAYS) } + + @Test + fun shouldFormatDateCorrectly() { + rule.setContent { + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth(TextFieldValue("1991-11-27")), + ), + onValueChanged = { + // no-op + }, + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").assertExists().assertTextEquals("27/11/1991") + } + + @Test + fun shouldShowErrorForOutsideRangeDate() { + rule.setContent { + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth(TextFieldValue("2025-11-27")), + ), + onValueChanged = { + // no-op + }, + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").assertExists().assertTextEquals("27/11/2025") + rule.onNodeWithTag("INPUT_AGE_SUPPORTING_TEXT").assertExists() + } + + @Test + fun shouldWorkWithInvalidDate() { + rule.setContent { + InputAge( + state = rememberInputAgeState( + inputAgeData = InputAgeData( + title = "Label", + ), + inputType = AgeInputType.DateOfBirth(TextFieldValue("1004-9999-9999")), + ), + onValueChanged = { + // no-op + }, + ) + } + + rule.onNodeWithTag("INPUT_AGE_TEXT_FIELD").assertExists().assertTextEquals("99/99/9999") + } } diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt new file mode 100644 index 000000000..0069e0b5b --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/LocationSearchBarTest.kt @@ -0,0 +1,219 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel +import org.junit.Rule +import org.junit.Test + +class LocationSearchBarTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplaySearchBarWithNoRecentResults() { + rule.setContent { + LocationBar( + currentResults = emptyList(), + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + searching = false, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + onNodeWithTag("NO_RECENT_RESULTS") + .assertIsDisplayed() + } + } + + @Test + fun shouldDisplaySearchBarWithResults() { + rule.setContent { + LocationBar( + currentResults = listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = {}, + onLocationSelected = {}, + searching = false, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + onNodeWithTag("LOCATION_ITEM_0") + .assertIsDisplayed() + } + } + + @Test + fun shouldDisplayNoResultsMessage() { + rule.setContent { + var items: List by remember { + mutableStateOf( + listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + ) + } + LocationBar( + currentResults = items, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { querySearch -> + items = listOf() + }, + onLocationSelected = {}, + searching = false, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + .performTextInput("Hospital la") + waitForIdle() + onNodeWithTag("NO_RESULTS") + .assertIsDisplayed() + } + } + + @Test + fun shouldDisplaySearchResults() { + rule.setContent { + var items: List by remember { + mutableStateOf( + listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + ) + } + + LocationBar( + currentResults = items, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { + items = listOf( + LocationItemModel.SearchResult( + "title search result", + "subtitle search result", + 0.0, + 0.0, + ), + ) + }, + onLocationSelected = {}, + searching = false, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + .performTextInput("Hospital la") + waitForIdle() + onNodeWithTag("LOCATION_ITEM_0", useUnmergedTree = true) + .assertIsDisplayed() + .assert(hasAnyDescendant(hasText("title search result"))) + } + } + + @Test + fun shouldDisplaySelectedLocationInfo() { + rule.setContent { + var items: List by remember { + mutableStateOf( + listOf( + LocationItemModel.StoredResult( + "title", + "subtitle", + 0.0, + 0.0, + ), + ), + ) + } + + LocationBar( + currentResults = items, + onBackClicked = {}, + onClearLocation = {}, + onSearchLocation = { + items = listOf( + LocationItemModel.SearchResult( + "title search result", + "subtitle search result", + 0.0, + 0.0, + ), + ) + }, + onLocationSelected = {}, + searching = false, + ) + } + + with(rule) { + onNodeWithTag("SEARCH_BAR_BUTTON") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_INPUT") + .assertIsDisplayed() + .performTextInput("Hospital la") + waitForIdle() + onNodeWithTag("LOCATION_ITEM_0") + .assertIsDisplayed() + .performClick() + onNodeWithTag("SEARCH_BAR_BUTTON", useUnmergedTree = true) + .assertIsDisplayed() + .assert(hasAnyDescendant(hasText("title search result"))) + } + } +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/MenuItemTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/MenuItemTest.kt new file mode 100644 index 000000000..5596f3194 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/MenuItemTest.kt @@ -0,0 +1,135 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItem +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemData +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_CONTAINER +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_DIVIDER +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_LEADING_ICON +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_LEADING_INDENT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_SUPPORTING_TEXT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_TEXT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_TRAILING_ICON +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemTestTags.MENU_ITEM_TRAILING_TEXT +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuLeadingElement +import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuTrailingElement +import org.junit.Rule +import org.junit.Test + +class MenuItemTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayMenuItemWithLabelCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_TEXT, true).assertExists() + } + + @Test + fun shouldDisplaySupportingTextCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + supportingText = "Supporting Text", + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_SUPPORTING_TEXT, true).assertExists() + } + + @Test + fun shouldDisplayDividerCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + showDivider = true, + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_DIVIDER, true).assertExists() + } + + @Test + fun shouldDisplayLeadingIndentCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + leadingElement = MenuLeadingElement.Indent, + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_LEADING_INDENT, true).assertExists() + } + + @Test + fun shouldDisplayLeadingIconCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + leadingElement = MenuLeadingElement.Icon( + icon = Icons.Outlined.Done, + ), + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_LEADING_ICON, true).assertExists() + } + + @Test + fun shouldDisplayTrailingIconCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + trailingElement = MenuTrailingElement.Icon( + icon = Icons.Outlined.Done, + ), + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_TRAILING_ICON, true).assertExists() + } + + @Test + fun shouldDisplayTrailingTextCorrectly() { + rule.setContent { + MenuItem( + menuItemData = MenuItemData( + id = "menu_item", + label = "Menu Item", + trailingElement = MenuTrailingElement.Text( + text = "Trailing Text", + ), + ), + ) {} + } + rule.onNodeWithTag(MENU_ITEM_CONTAINER).assertExists() + rule.onNodeWithTag(MENU_ITEM_TRAILING_TEXT, true).assertExists() + } +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt index cd382796d..09d5312b1 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt @@ -123,4 +123,38 @@ class OrgBottomSheetTest { rule.onNodeWithTag("ORG_TREE_ITEM_CHECKBOX_Item 1").assertExists() rule.onNodeWithTag("ORG_TREE_ITEM_CHECKBOX_Item 2").assertDoesNotExist() } + + @Test + fun shouldHideClearButtonWhenOnClearAllMethodIsNotProvided() { + rule.setContent { + OrgBottomSheet( + orgTreeItems = listOf( + OrgTreeItem( + uid = "1", + label = "Item 1", + canBeSelected = true, + ), + OrgTreeItem( + uid = "2", + label = "Item 2", + canBeSelected = false, + ), + ), + onDismiss = { + // no-op + }, + onItemClick = { + // no-op + }, + onItemSelected = { _, _ -> + // no-op + }, + onDone = { + // no-op + }, + ) + } + + rule.onNodeWithTag("CLEAR_ALL_BUTTON").assertDoesNotExist() + } } diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ExpandableItemColumnSnapshotTest_launchAvatarTest.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ExpandableItemColumnSnapshotTest_launchAvatarTest.png index 32085bdc3..19201b69d 100644 --- a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ExpandableItemColumnSnapshotTest_launchAvatarTest.png +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ExpandableItemColumnSnapshotTest_launchAvatarTest.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f465eff16374fd649cf2b828e43cd17143991c51117209ac6653d8a61585af83 -size 33765 +oid sha256:9dec53f2ba3dc98f627d0a29b6ce2376d2b982833d0b453fc1b54556c0cd7d1a +size 33643 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png index e093a22a5..3f72419b6 100644 --- a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_InputAgeSnapshotTest_launchInputAgeSnapshot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dee2621842a63270629e686181124a807ecb86f3fed4b73d4d8870e4249a78ab -size 68365 +oid sha256:4d88b026f362ca3b62b907f7b45bd6fe514ff10d9e172cb5a41f2b0cf3f31865 +size 69894 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ListCardSelectableSnapshotTest_launchListCard.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ListCardSelectableSnapshotTest_launchListCard.png new file mode 100644 index 000000000..89bf620b6 --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_ListCardSelectableSnapshotTest_launchListCard.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d090bb6e8d311ae481a4ff231623d5f2a7307150a5e2fb2717bec6f4eb0d4d59 +size 66174 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png new file mode 100644 index 000000000..695f102bd --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarButtonSnapshotTest_launchSearchBarButtonTest.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a4fa9a4ae1c48ec75abfe77699bfcf81c80f57a816f62c6442974c236d3a286 +size 11447 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png new file mode 100644 index 000000000..f97258c81 --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_LocationBarSearchSnapshotTest_launchSearchBarButtonTest.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ff26d23a3074d910416255857ce4a84cc845c321d5da58bcb72ba75a5fe5868 +size 25199 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_MenuItemSnapshotTest_launchMenuItemTest.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_MenuItemSnapshotTest_launchMenuItemTest.png new file mode 100644 index 000000000..1ba3ca99c --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_MenuItemSnapshotTest_launchMenuItemTest.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fd48d9cef62003a7b1b063a4bd6bfba1c7dc8f2b4217fa3845986c655a2f3bb +size 24529 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_NavigationBarSnapShotTest_launchNavigationBar.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_NavigationBarSnapShotTest_launchNavigationBar.png index 228afbf63..fb8ebe0a1 100644 --- a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_NavigationBarSnapShotTest_launchNavigationBar.png +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_NavigationBarSnapShotTest_launchNavigationBar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:defbf0c5c4111d0fb822c2ebe45023e2733c2dda251112154a6724adce464801 -size 34021 +oid sha256:a10ed3c7e83546fffee9ca111e2a081f47b0a43436e9b70a6c86b63387344790 +size 33980 diff --git a/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_TopBarSnapshotTest_launchTopBar.png b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_TopBarSnapshotTest_launchTopBar.png new file mode 100644 index 000000000..88dcb99c6 --- /dev/null +++ b/designsystem/src/test/snapshots/images/org.hisp.dhis.mobile.ui.designsystem_TopBarSnapshotTest_launchTopBar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03c798becd02291a3829dcacdc715ed940d56bf5bdfd746b816b98978dc56e4c +size 15089