diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt index 2d54eeea3e..75192be703 100644 --- a/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/settings/SettingsScreen.kt @@ -99,6 +99,7 @@ internal fun SettingsScreen( SettingsCategory(stringResource(R.string.general_title)) { // Upload Media SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, title = stringResource(R.string.upload_media_title), summary = stringResource(R.string.over_wifi_summary), checked = settings.shouldUploadPhotosOnWifiOnly, @@ -107,6 +108,7 @@ internal fun SettingsScreen( // Language SettingsSelectItem( + icon = R.drawable.ic_language, title = stringResource(R.string.select_language_title), entriesResId = R.array.language_entries, entryValues = R.array.language_entry_values, @@ -116,6 +118,7 @@ internal fun SettingsScreen( // Measurement Units SettingsSelectItem( + icon = R.drawable.ic_measurement, title = stringResource(R.string.select_length_title), entriesResId = R.array.length_entries, entryValues = R.array.length_entry_values, @@ -129,6 +132,7 @@ internal fun SettingsScreen( // Help Section SettingsCategory(stringResource(R.string.help_title)) { SettingsItem( + icon = R.drawable.ic_open_in_new, title = stringResource(R.string.visit_website_title), summary = stringResource(R.string.ground_website), onClick = onVisitWebsiteClick, diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt index 526fdfbe36..5595b906bd 100644 --- a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsItem.kt @@ -15,37 +15,59 @@ */ package org.groundplatform.android.ui.settings.components +import androidx.annotation.DrawableRes import androidx.compose.foundation.clickable 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.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import org.groundplatform.android.R import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.ui.theme.AppTheme +import org.groundplatform.ui.theme.sizes /** * A reusable UI component representing a single row in a settings screen. * + * @param modifier The [Modifier] to be applied to the root of this item. + * @param icon Drawable resource for the icon shown next to the title. * @param title The primary text to be displayed for the setting. * @param summary Optional secondary text to be displayed below the title, providing more detail. * @param onClick The callback to be invoked when the item is clicked. */ @Composable -internal fun SettingsItem(title: String, summary: String? = null, onClick: () -> Unit) { +internal fun SettingsItem( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, + title: String, + summary: String? = null, + onClick: () -> Unit, +) { Row( modifier = - Modifier.fillMaxWidth().clickable(onClick = onClick, role = Role.Button).padding(16.dp), + modifier.fillMaxWidth().clickable(onClick = onClick, role = Role.Button).padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon( + modifier = + Modifier.padding(end = MaterialTheme.sizes.settingsItemIconEndPadding) + .size(MaterialTheme.sizes.settingsItemIconSize), + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) Column(modifier = Modifier.weight(1f)) { Text(text = title, style = MaterialTheme.typography.titleMedium) if (summary != null) { @@ -65,8 +87,14 @@ internal fun SettingsItem(title: String, summary: String? = null, onClick: () -> private fun Preview() { AppTheme { Column(verticalArrangement = Arrangement.SpaceEvenly) { - SettingsItem(title = "Name", summary = "Summary", onClick = {}) - SettingsItem(title = "Name", summary = null, onClick = {}) + SettingsItem(icon = R.drawable.ic_language, title = "Name", summary = "Summary", onClick = {}) + SettingsItem(icon = R.drawable.ic_language, title = "Name", summary = null, onClick = {}) + SettingsItem( + icon = R.drawable.ic_language, + title = "Language", + summary = "English", + onClick = {}, + ) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt index c22c5bd0f1..03a5a4fd33 100644 --- a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSelectItem.kt @@ -15,6 +15,8 @@ */ package org.groundplatform.android.ui.settings.components +import androidx.annotation.ArrayRes +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn @@ -41,6 +43,7 @@ import org.groundplatform.ui.theme.AppTheme * * When clicked, it displays a dropdown menu with options populated from the provided resource IDs. * + * @param icon Drawable resource for the icon shown next to the title. * @param title The title of the settings item. * @param entriesResId The resource ID of the string array containing the display labels. * @param entryValues The resource ID of the string array containing the underlying values. @@ -49,9 +52,10 @@ import org.groundplatform.ui.theme.AppTheme */ @Composable internal fun SettingsSelectItem( + @DrawableRes icon: Int, title: String, - entriesResId: Int, - entryValues: Int, + @ArrayRes entriesResId: Int, + @ArrayRes entryValues: Int, currentValue: String, onValueChanged: (String) -> Unit, ) { @@ -70,6 +74,7 @@ internal fun SettingsSelectItem( Box(modifier = Modifier.fillMaxWidth()) { SettingsItem( + icon = icon, title = title, summary = selectedOption?.label ?: "", onClick = { expanded = true }, @@ -102,6 +107,7 @@ internal data class Option(val label: String, val value: String) private fun PreviewSelectItem() { AppTheme { SettingsSelectItem( + icon = R.drawable.ic_language, title = "Language", entriesResId = R.array.language_entries, entryValues = R.array.language_entry_values, diff --git a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt index 4a3ea549e4..a1dfb8d4fb 100644 --- a/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt +++ b/app/src/main/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItem.kt @@ -15,27 +15,35 @@ */ package org.groundplatform.android.ui.settings.components +import androidx.annotation.DrawableRes 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.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import org.groundplatform.android.R import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.ui.theme.AppTheme +import org.groundplatform.ui.theme.sizes /** * A reusable settings item component with a title, optional summary, and a switch toggle. * + * @param modifier The [Modifier] to be applied to the root of this item. + * @param icon Drawable resource for the icon shown next to the title. * @param title The primary text to be displayed for the setting. * @param summary Optional secondary text providing additional details about the setting. * @param checked Whether the switch is currently in the "on" position. @@ -43,6 +51,8 @@ import org.groundplatform.ui.theme.AppTheme */ @Composable internal fun SettingsSwitchItem( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, title: String, summary: String? = null, checked: Boolean, @@ -50,11 +60,20 @@ internal fun SettingsSwitchItem( ) { Row( modifier = - Modifier.fillMaxWidth() + modifier + .fillMaxWidth() .toggleable(value = checked, onValueChange = onCheckedChange, role = Role.Switch) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon( + modifier = + Modifier.padding(end = MaterialTheme.sizes.settingsItemIconEndPadding) + .size(MaterialTheme.sizes.settingsItemIconSize), + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) Column(modifier = Modifier.weight(1f)) { Text(text = title, style = MaterialTheme.typography.titleMedium) if (summary != null) { @@ -75,8 +94,20 @@ internal fun SettingsSwitchItem( private fun Preview() { AppTheme { Column(verticalArrangement = Arrangement.SpaceEvenly) { - SettingsSwitchItem(title = "Name", summary = "Value", checked = true, onCheckedChange = {}) - SettingsSwitchItem(title = "Name", summary = null, checked = false, onCheckedChange = {}) + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Name", + summary = "Value", + checked = true, + onCheckedChange = {}, + ) + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Name", + summary = null, + checked = false, + onCheckedChange = {}, + ) } } } diff --git a/app/src/main/res/drawable/ic_cloud_upload.xml b/app/src/main/res/drawable/ic_cloud_upload.xml new file mode 100644 index 0000000000..d3fc111e32 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_upload.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000000..5f6c727156 --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_measurement.xml b/app/src/main/res/drawable/ic_measurement.xml new file mode 100644 index 0000000000..4500c21772 --- /dev/null +++ b/app/src/main/res/drawable/ic_measurement.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000000..ca844c99fc --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0570cd77db..d95ae9829b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -205,14 +205,6 @@ Máx. 255 caracteres Área: %s - Inglés - Francés - Español - Portugués - - Vietnamita - Tailandés - Lao Restringido No listado Público diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index bcae668f71..e8a0c731d4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -186,13 +186,7 @@ Claire 255 caractères maximum Surface: %s - Anglais - Français - Espagnol - Portugais - Vietnamien - Thaï - Lao + Restreint Non répertorié Public diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 2d5a7eed3f..66ce194676 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -182,13 +182,6 @@ ລ້າງ ສູງສຸດ 255 ຕົວອັກສອນ ພື້ນທີ່: %s - ພາສາອັງກິດ - ພາສາຝຣັ່ງ - ພາສາສະແປນ - ພາສາໂປຣຕຸເກດ - ພາສາຫວຽດນາມ - ພາສາໄທ - ພາສາລາວ ຈຳກັດການເຂົ້າເຖິງ ບໍ່ສະແດງໃນລາຍການ ສາທາລະນະ diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5b4289adfe..026589eabc 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -206,14 +206,6 @@ 255 caracteres no máx. Área: %s - Inglês - Francês - Espanhol - Português - - Vietnamita - Tailandês - Lao Restrito Não listado Público diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index e919d88be4..851bf81a78 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -184,13 +184,6 @@ ล้างข้อมูล สูงสุด 255 ตัวอักษร พื้นที่: %s - อังกฤษ - ฝรั่งเศส - สเปน - โปรตุเกส - เวียดนาม - ไทย - ลาว จำกัดสิทธิ์ ไม่แสดงสาธารณะ สาธารณะ diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index e5bc958b98..931bc22e97 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -181,13 +181,6 @@ Xóa Tối đa 255 ký tự Diện tích: %s - Tiếng Anh - Tiếng Pháp - Tiếng Tây Ban Nha - Tiếng Bồ Đào Nha - Tiếng Việt - Tiếng Thái - Tiếng Lào Hạn chế Không công khai Công khai diff --git a/app/src/main/res/values/strings-untranslated.xml b/app/src/main/res/values/strings-untranslated.xml index 1ad4ead270..db3f97b500 100644 --- a/app/src/main/res/values/strings-untranslated.xml +++ b/app/src/main/res/values/strings-untranslated.xml @@ -22,4 +22,11 @@ https://groundplatform.org/ ground-dev-sig.web.app /android/survey/ + English + Français + Español + ພາສາລາວ + Português + ไทย + Tiếng Việt diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5eb95adcf9..5c599718b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,14 +208,6 @@ Area: %s - English - French - Spanish - Portuguese - Vietnamese - Thai - Lao - Restricted Unlisted Public diff --git a/app/src/test/java/org/groundplatform/android/ui/settings/components/SettingsItemTest.kt b/app/src/test/java/org/groundplatform/android/ui/settings/components/SettingsItemTest.kt new file mode 100644 index 0000000000..c5b020bccb --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/settings/components/SettingsItemTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.groundplatform.android.R +import org.groundplatform.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsItemTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun `Displays title and summary`() { + composeTestRule.setContent { + AppTheme { + SettingsItem( + icon = R.drawable.ic_language, + title = "Title", + summary = "Summary", + onClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Title").assertIsDisplayed() + composeTestRule.onNodeWithText("Summary").assertIsDisplayed() + } + + @Test + fun `Should hide summary when null`() { + composeTestRule.setContent { + AppTheme { + SettingsItem( + icon = R.drawable.ic_language, + title = "Title", + summary = null, + onClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Title").assertIsDisplayed() + composeTestRule.onNodeWithText("Summary").assertDoesNotExist() + } + + @Test + fun `Invokes onClick action when clicked`() { + var clicked = false + + composeTestRule.setContent { + AppTheme { + SettingsItem( + icon = R.drawable.ic_language, + title = "Title", + summary = "Summary", + onClick = { clicked = true }, + ) + } + } + + composeTestRule.onNodeWithText("Title").performClick() + assert(clicked) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItemTest.kt b/app/src/test/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItemTest.kt new file mode 100644 index 0000000000..1df96f6e44 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/settings/components/SettingsSwitchItemTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.settings.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.groundplatform.android.R +import org.groundplatform.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsSwitchItemTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun `Displays title and summary`() { + composeTestRule.setContent { + AppTheme { + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Title", + summary = "Summary", + checked = true, + onCheckedChange = {}, + ) + } + } + + composeTestRule.onNodeWithText("Title").assertIsDisplayed() + composeTestRule.onNodeWithText("Summary").assertIsDisplayed() + } + + @Test + fun `Hides summary when null`() { + composeTestRule.setContent { + AppTheme { + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Title", + summary = null, + checked = false, + onCheckedChange = {}, + ) + } + } + + composeTestRule.onNodeWithText("Title").assertIsDisplayed() + composeTestRule.onNodeWithText("Summary").assertDoesNotExist() + } + + @Test + fun `Switch should be on when checked state is true`() { + composeTestRule.setContent { + AppTheme { + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Title", + checked = true, + onCheckedChange = {}, + ) + } + } + + composeTestRule.onNodeWithText("Title").assertIsOn() + } + + @Test + fun `Switch should be off when checked state is false`() { + composeTestRule.setContent { + AppTheme { + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Title", + checked = false, + onCheckedChange = {}, + ) + } + } + + composeTestRule.onNodeWithText("Title").assertIsOff() + } + + @Test + fun `Invokes onCheckedChange with the correct value when clicked on`() { + var newValue: Boolean? = null + + composeTestRule.setContent { + AppTheme { + SettingsSwitchItem( + icon = R.drawable.ic_cloud_upload, + title = "Title", + checked = false, + onCheckedChange = { newValue = it }, + ) + } + } + + composeTestRule.onNodeWithText("Title").performClick() + assert(newValue == true) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/theme/Size.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/theme/Size.kt index fd39f7dda1..013d030261 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/theme/Size.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/theme/Size.kt @@ -30,6 +30,8 @@ data class Size( val progressIndicatorSize: Dp = 24.dp, val progressIndicatorStrokeWidth: Dp = 2.dp, val taskViewPadding: Dp = 16.dp, + val settingsItemIconSize: Dp = 24.dp, + val settingsItemIconEndPadding: Dp = 16.dp, ) internal val LocalSizes = compositionLocalOf { Size() }