diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt index 3a11a1c..4ee6899 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt @@ -9,6 +9,8 @@ object NatConstants { const val TERMS_OF_USE_URL: String = "https://nativeapptemplate.com/terms" const val MINIMUM_PASSWORD_LENGTH: Int = 8 + const val MAXIMUM_SHOP_NAME_LENGTH: Int = 100 + const val MAXIMUM_SHOP_DESCRIPTION_LENGTH: Int = 1_000 const val MAXIMUM_ITEM_TAG_NAME_LENGTH: Int = 100 const val MAXIMUM_ITEM_TAG_DESCRIPTION_LENGTH: Int = 1_000 diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsView.kt index 2907cc8..c29759c 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsView.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -154,11 +154,18 @@ fun ShopBasicSettingsContentView( value = uiState.name, onValueChange = { viewModel.updateName(it) }, supportingText = { - Text( - text = stringResource(id = R.string.shop_name_is_required), - style = MaterialTheme.typography.bodyLarge, - color = if (uiState.name.isBlank()) Color.Red else Color.Transparent, - ) + Column { + Text( + text = stringResource(R.string.shop_name_help, uiState.maximumNameLength), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.shop_name_is_invalid), + style = MaterialTheme.typography.bodyLarge, + color = if (viewModel.hasInvalidDataName()) Color.Red else Color.Transparent, + ) + } }, modifier = Modifier .fillMaxWidth(), @@ -172,9 +179,24 @@ fun ShopBasicSettingsContentView( }, value = uiState.description, onValueChange = { viewModel.updateDescription(it) }, + supportingText = { + Column { + Text( + text = stringResource(R.string.shop_description_help, uiState.maximumDescriptionLength), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.shop_description_is_invalid), + style = MaterialTheme.typography.bodyLarge, + color = if (viewModel.hasInvalidDataDescription()) Color.Red else Color.Transparent, + ) + } + }, + minLines = 4, modifier = Modifier .fillMaxWidth() - .height(128.dp), + .heightIn(min = 120.dp), ) ExposedDropdownMenuBox( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt index 87519bf..2c49468 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository import com.nativeapptemplate.nativeapptemplatefree.model.Shop @@ -27,6 +28,8 @@ data class ShopBasicSettingsUiState( val name: String = "", val description: String = "", val timeZone: String = TimeZones.DEFAULT_TIME_ZONE, + val maximumNameLength: Int = NatConstants.MAXIMUM_SHOP_NAME_LENGTH, + val maximumDescriptionLength: Int = NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH, val isLoading: Boolean = true, val success: Boolean = false, @@ -127,7 +130,8 @@ class ShopBasicSettingsViewModel @Inject constructor( } fun hasInvalidData(): Boolean { - if (uiState.value.name.isBlank()) return true + if (hasInvalidDataName()) return true + if (hasInvalidDataDescription()) return true val shopData = uiState.value.shop.getData()!! @@ -136,15 +140,29 @@ class ShopBasicSettingsViewModel @Inject constructor( shopData.getTimeZone() == uiState.value.timeZone } + fun hasInvalidDataName(): Boolean { + val name = uiState.value.name + val maximumNameLength = uiState.value.maximumNameLength + return name.isBlank() || name.length > maximumNameLength + } + + fun hasInvalidDataDescription(): Boolean { + return uiState.value.description.length > uiState.value.maximumDescriptionLength + } + fun updateName(newName: String) { - _uiState.update { - it.copy(name = newName) + if (newName.length <= uiState.value.maximumNameLength) { + _uiState.update { + it.copy(name = newName) + } } } fun updateDescription(newDescription: String) { - _uiState.update { - it.copy(description = newDescription) + if (newDescription.length <= uiState.value.maximumDescriptionLength) { + _uiState.update { + it.copy(description = newDescription) + } } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateView.kt index 7ca88d3..596fd45 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateView.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -145,11 +145,18 @@ fun ShopCreateContentView( value = uiState.name, onValueChange = { viewModel.updateName(it) }, supportingText = { - Text( - text = stringResource(id = R.string.shop_name_is_required), - style = MaterialTheme.typography.bodyLarge, - color = if (uiState.name.isBlank()) Color.Red else Color.Transparent, - ) + Column { + Text( + text = stringResource(R.string.shop_name_help, uiState.maximumNameLength), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.shop_name_is_invalid), + style = MaterialTheme.typography.bodyLarge, + color = if (viewModel.hasInvalidDataName()) Color.Red else Color.Transparent, + ) + } }, modifier = Modifier .fillMaxWidth(), @@ -163,9 +170,24 @@ fun ShopCreateContentView( }, value = uiState.description, onValueChange = { viewModel.updateDescription(it) }, + supportingText = { + Column { + Text( + text = stringResource(R.string.shop_description_help, uiState.maximumDescriptionLength), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.shop_description_is_invalid), + style = MaterialTheme.typography.bodyLarge, + color = if (viewModel.hasInvalidDataDescription()) Color.Red else Color.Transparent, + ) + } + }, + minLines = 4, modifier = Modifier .fillMaxWidth() - .height(128.dp), + .heightIn(min = 120.dp), ) ExposedDropdownMenuBox( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModel.kt index 31cbd69..ddc29bd 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModel.kt @@ -2,6 +2,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shops import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository import com.nativeapptemplate.nativeapptemplatefree.model.Shop @@ -22,6 +23,8 @@ data class ShopCreateUiState( val name: String = "", val description: String = "", val timeZone: String = TimeZones.currentTimeZoneKey(), + val maximumNameLength: Int = NatConstants.MAXIMUM_SHOP_NAME_LENGTH, + val maximumDescriptionLength: Int = NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH, val isLoading: Boolean = false, val isCreated: Boolean = false, @@ -72,18 +75,32 @@ class ShopCreateViewModel @Inject constructor( } fun hasInvalidData(): Boolean { - return uiState.value.name.isBlank() + return hasInvalidDataName() || hasInvalidDataDescription() + } + + fun hasInvalidDataName(): Boolean { + val name = uiState.value.name + val maximumNameLength = uiState.value.maximumNameLength + return name.isBlank() || name.length > maximumNameLength + } + + fun hasInvalidDataDescription(): Boolean { + return uiState.value.description.length > uiState.value.maximumDescriptionLength } fun updateName(newName: String) { - _uiState.update { - it.copy(name = newName) + if (newName.length <= uiState.value.maximumNameLength) { + _uiState.update { + it.copy(name = newName) + } } } fun updateDescription(newDescription: String) { - _uiState.update { - it.copy(description = newDescription) + if (newDescription.length <= uiState.value.maximumDescriptionLength) { + _uiState.update { + it.copy(description = newDescription) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f17c929..e956061 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,6 +86,10 @@ Shops Shop Name Shop name is required. + Shop name is invalid. + Shop description is invalid. + Name must be 1-%1$d characters. + Description must be 0-%1$d characters. Add a new shop. Tap a shop below diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt index fd8f5a2..659d07a 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt @@ -2,6 +2,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings import androidx.lifecycle.SavedStateHandle import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.Shop @@ -115,6 +116,73 @@ class ShopBasicSettingsViewModelTest { assertTrue(viewModel.hasInvalidData()) } + + @Test + fun maximumNameLength_matchesConstant() = runTest { + assertEquals( + NatConstants.MAXIMUM_SHOP_NAME_LENGTH, + viewModel.uiState.value.maximumNameLength, + ) + } + + @Test + fun maximumDescriptionLength_matchesConstant() = runTest { + assertEquals( + NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH, + viewModel.uiState.value.maximumDescriptionLength, + ) + } + + @Test + fun nameAtMaximumLength_isValid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + viewModel.reload() + + viewModel.updateName("a".repeat(100)) + + assertFalse(viewModel.hasInvalidDataName()) + } + + @Test + fun nameAboveMaximumLength_isRejectedByUpdater() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + viewModel.reload() + + val previous = viewModel.uiState.value.name + viewModel.updateName("a".repeat(101)) + + // updater clamps; value should remain unchanged from the loaded shop name + assertEquals(previous, viewModel.uiState.value.name) + } + + @Test + fun descriptionAtMaximumLength_isValid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + viewModel.reload() + + viewModel.updateDescription("x".repeat(1_000)) + + assertFalse(viewModel.hasInvalidDataDescription()) + } + + @Test + fun descriptionAboveMaximumLength_isRejectedByUpdater() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + viewModel.reload() + + val previous = viewModel.uiState.value.description + viewModel.updateDescription("x".repeat(1_001)) + + assertEquals(previous, viewModel.uiState.value.description) + } } private const val SHOP_TYPE = "shop" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt index bd93e03..07dc796 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt @@ -1,5 +1,6 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shops +import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.Shop @@ -80,6 +81,56 @@ class ShopCreateViewModelTest { assertTrue(viewModel.hasInvalidData()) } + + @Test + fun maximumNameLength_matchesConstant() = runTest { + assertEquals(NatConstants.MAXIMUM_SHOP_NAME_LENGTH, viewModel.uiState.value.maximumNameLength) + } + + @Test + fun maximumDescriptionLength_matchesConstant() = runTest { + assertEquals( + NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH, + viewModel.uiState.value.maximumDescriptionLength, + ) + } + + @Test + fun nameAtMaximumLength_isValid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName("a".repeat(100)) + + assertFalse(viewModel.hasInvalidDataName()) + } + + @Test + fun nameAboveMaximumLength_isRejectedByUpdater() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName("a".repeat(101)) + + // updater clamps; value should remain blank (initial) + assertEquals("", viewModel.uiState.value.name) + } + + @Test + fun descriptionAtMaximumLength_isValid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateDescription("x".repeat(1_000)) + + assertFalse(viewModel.hasInvalidDataDescription()) + } + + @Test + fun descriptionAboveMaximumLength_isRejectedByUpdater() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateDescription("x".repeat(1_001)) + + assertEquals("", viewModel.uiState.value.description) + } } private const val SHOP_TYPE = "shop"