Skip to content

Commit cbb49f5

Browse files
dadachiclaude
andauthored
Add client-side length caps + truncation for Shop name/description (#36)
Mirror the ItemTag pattern (PR #35) for Shop. Server has no caps on Shop name/description; this is a client-only UX guard. ### Changes - NatConstants: MAXIMUM_SHOP_NAME_LENGTH = 100, MAXIMUM_SHOP_DESCRIPTION_LENGTH = 1_000. - strings.xml: new shop_name_is_invalid, shop_description_is_invalid, shop_name_help, shop_description_help (parametric). - ShopCreateViewModel + ShopBasicSettingsViewModel: - UiState gains maximumNameLength / maximumDescriptionLength (defaulted to the constants). - hasInvalidData() splits into hasInvalidDataName() + hasInvalidDataDescription(); the parent now ORs both. - updateName() / updateDescription() reject input over the cap (mirrors the existing ItemTagCreateViewModel / ItemTagEditViewModel Android pattern). - ShopCreateView + ShopBasicSettingsView: switch supportingText to the two-line layout (always-visible help + conditional red "is invalid" line), matching ItemTagCreateView / ItemTagEditView. Description switches to heightIn(min=120) + minLines=4. - Tests: maximumNameLength_matchesConstant / maximumDescriptionLength_matchesConstant, boundary tests at 100 / 101 / 1000 / 1001 chars on both ShopCreateViewModelTest and ShopBasicSettingsViewModelTest. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 337e164 commit cbb49f5

8 files changed

Lines changed: 228 additions & 24 deletions

File tree

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ object NatConstants {
99
const val TERMS_OF_USE_URL: String = "https://nativeapptemplate.com/terms"
1010

1111
const val MINIMUM_PASSWORD_LENGTH: Int = 8
12+
const val MAXIMUM_SHOP_NAME_LENGTH: Int = 100
13+
const val MAXIMUM_SHOP_DESCRIPTION_LENGTH: Int = 1_000
1214
const val MAXIMUM_ITEM_TAG_NAME_LENGTH: Int = 100
1315
const val MAXIMUM_ITEM_TAG_DESCRIPTION_LENGTH: Int = 1_000
1416

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsView.kt

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.defaultMinSize
77
import androidx.compose.foundation.layout.fillMaxHeight
88
import androidx.compose.foundation.layout.fillMaxSize
99
import androidx.compose.foundation.layout.fillMaxWidth
10-
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.heightIn
1111
import androidx.compose.foundation.layout.padding
1212
import androidx.compose.foundation.rememberScrollState
1313
import androidx.compose.foundation.shape.CircleShape
@@ -154,11 +154,18 @@ fun ShopBasicSettingsContentView(
154154
value = uiState.name,
155155
onValueChange = { viewModel.updateName(it) },
156156
supportingText = {
157-
Text(
158-
text = stringResource(id = R.string.shop_name_is_required),
159-
style = MaterialTheme.typography.bodyLarge,
160-
color = if (uiState.name.isBlank()) Color.Red else Color.Transparent,
161-
)
157+
Column {
158+
Text(
159+
text = stringResource(R.string.shop_name_help, uiState.maximumNameLength),
160+
style = MaterialTheme.typography.bodyLarge,
161+
color = MaterialTheme.colorScheme.onSurfaceVariant,
162+
)
163+
Text(
164+
text = stringResource(R.string.shop_name_is_invalid),
165+
style = MaterialTheme.typography.bodyLarge,
166+
color = if (viewModel.hasInvalidDataName()) Color.Red else Color.Transparent,
167+
)
168+
}
162169
},
163170
modifier = Modifier
164171
.fillMaxWidth(),
@@ -172,9 +179,24 @@ fun ShopBasicSettingsContentView(
172179
},
173180
value = uiState.description,
174181
onValueChange = { viewModel.updateDescription(it) },
182+
supportingText = {
183+
Column {
184+
Text(
185+
text = stringResource(R.string.shop_description_help, uiState.maximumDescriptionLength),
186+
style = MaterialTheme.typography.bodyLarge,
187+
color = MaterialTheme.colorScheme.onSurfaceVariant,
188+
)
189+
Text(
190+
text = stringResource(R.string.shop_description_is_invalid),
191+
style = MaterialTheme.typography.bodyLarge,
192+
color = if (viewModel.hasInvalidDataDescription()) Color.Red else Color.Transparent,
193+
)
194+
}
195+
},
196+
minLines = 4,
175197
modifier = Modifier
176198
.fillMaxWidth()
177-
.height(128.dp),
199+
.heightIn(min = 120.dp),
178200
)
179201

180202
ExposedDropdownMenuBox(

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
44
import androidx.lifecycle.ViewModel
55
import androidx.lifecycle.viewModelScope
66
import androidx.navigation.toRoute
7+
import com.nativeapptemplate.nativeapptemplatefree.NatConstants
78
import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription
89
import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository
910
import com.nativeapptemplate.nativeapptemplatefree.model.Shop
@@ -27,6 +28,8 @@ data class ShopBasicSettingsUiState(
2728
val name: String = "",
2829
val description: String = "",
2930
val timeZone: String = TimeZones.DEFAULT_TIME_ZONE,
31+
val maximumNameLength: Int = NatConstants.MAXIMUM_SHOP_NAME_LENGTH,
32+
val maximumDescriptionLength: Int = NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH,
3033

3134
val isLoading: Boolean = true,
3235
val success: Boolean = false,
@@ -127,7 +130,8 @@ class ShopBasicSettingsViewModel @Inject constructor(
127130
}
128131

129132
fun hasInvalidData(): Boolean {
130-
if (uiState.value.name.isBlank()) return true
133+
if (hasInvalidDataName()) return true
134+
if (hasInvalidDataDescription()) return true
131135

132136
val shopData = uiState.value.shop.getData()!!
133137

@@ -136,15 +140,29 @@ class ShopBasicSettingsViewModel @Inject constructor(
136140
shopData.getTimeZone() == uiState.value.timeZone
137141
}
138142

143+
fun hasInvalidDataName(): Boolean {
144+
val name = uiState.value.name
145+
val maximumNameLength = uiState.value.maximumNameLength
146+
return name.isBlank() || name.length > maximumNameLength
147+
}
148+
149+
fun hasInvalidDataDescription(): Boolean {
150+
return uiState.value.description.length > uiState.value.maximumDescriptionLength
151+
}
152+
139153
fun updateName(newName: String) {
140-
_uiState.update {
141-
it.copy(name = newName)
154+
if (newName.length <= uiState.value.maximumNameLength) {
155+
_uiState.update {
156+
it.copy(name = newName)
157+
}
142158
}
143159
}
144160

145161
fun updateDescription(newDescription: String) {
146-
_uiState.update {
147-
it.copy(description = newDescription)
162+
if (newDescription.length <= uiState.value.maximumDescriptionLength) {
163+
_uiState.update {
164+
it.copy(description = newDescription)
165+
}
148166
}
149167
}
150168

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateView.kt

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.defaultMinSize
88
import androidx.compose.foundation.layout.fillMaxHeight
99
import androidx.compose.foundation.layout.fillMaxSize
1010
import androidx.compose.foundation.layout.fillMaxWidth
11-
import androidx.compose.foundation.layout.height
11+
import androidx.compose.foundation.layout.heightIn
1212
import androidx.compose.foundation.layout.padding
1313
import androidx.compose.foundation.rememberScrollState
1414
import androidx.compose.foundation.shape.CircleShape
@@ -145,11 +145,18 @@ fun ShopCreateContentView(
145145
value = uiState.name,
146146
onValueChange = { viewModel.updateName(it) },
147147
supportingText = {
148-
Text(
149-
text = stringResource(id = R.string.shop_name_is_required),
150-
style = MaterialTheme.typography.bodyLarge,
151-
color = if (uiState.name.isBlank()) Color.Red else Color.Transparent,
152-
)
148+
Column {
149+
Text(
150+
text = stringResource(R.string.shop_name_help, uiState.maximumNameLength),
151+
style = MaterialTheme.typography.bodyLarge,
152+
color = MaterialTheme.colorScheme.onSurfaceVariant,
153+
)
154+
Text(
155+
text = stringResource(R.string.shop_name_is_invalid),
156+
style = MaterialTheme.typography.bodyLarge,
157+
color = if (viewModel.hasInvalidDataName()) Color.Red else Color.Transparent,
158+
)
159+
}
153160
},
154161
modifier = Modifier
155162
.fillMaxWidth(),
@@ -163,9 +170,24 @@ fun ShopCreateContentView(
163170
},
164171
value = uiState.description,
165172
onValueChange = { viewModel.updateDescription(it) },
173+
supportingText = {
174+
Column {
175+
Text(
176+
text = stringResource(R.string.shop_description_help, uiState.maximumDescriptionLength),
177+
style = MaterialTheme.typography.bodyLarge,
178+
color = MaterialTheme.colorScheme.onSurfaceVariant,
179+
)
180+
Text(
181+
text = stringResource(R.string.shop_description_is_invalid),
182+
style = MaterialTheme.typography.bodyLarge,
183+
color = if (viewModel.hasInvalidDataDescription()) Color.Red else Color.Transparent,
184+
)
185+
}
186+
},
187+
minLines = 4,
166188
modifier = Modifier
167189
.fillMaxWidth()
168-
.height(128.dp),
190+
.heightIn(min = 120.dp),
169191
)
170192

171193
ExposedDropdownMenuBox(

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModel.kt

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shops
22

33
import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
5+
import com.nativeapptemplate.nativeapptemplatefree.NatConstants
56
import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription
67
import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository
78
import com.nativeapptemplate.nativeapptemplatefree.model.Shop
@@ -22,6 +23,8 @@ data class ShopCreateUiState(
2223
val name: String = "",
2324
val description: String = "",
2425
val timeZone: String = TimeZones.currentTimeZoneKey(),
26+
val maximumNameLength: Int = NatConstants.MAXIMUM_SHOP_NAME_LENGTH,
27+
val maximumDescriptionLength: Int = NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH,
2528

2629
val isLoading: Boolean = false,
2730
val isCreated: Boolean = false,
@@ -72,18 +75,32 @@ class ShopCreateViewModel @Inject constructor(
7275
}
7376

7477
fun hasInvalidData(): Boolean {
75-
return uiState.value.name.isBlank()
78+
return hasInvalidDataName() || hasInvalidDataDescription()
79+
}
80+
81+
fun hasInvalidDataName(): Boolean {
82+
val name = uiState.value.name
83+
val maximumNameLength = uiState.value.maximumNameLength
84+
return name.isBlank() || name.length > maximumNameLength
85+
}
86+
87+
fun hasInvalidDataDescription(): Boolean {
88+
return uiState.value.description.length > uiState.value.maximumDescriptionLength
7689
}
7790

7891
fun updateName(newName: String) {
79-
_uiState.update {
80-
it.copy(name = newName)
92+
if (newName.length <= uiState.value.maximumNameLength) {
93+
_uiState.update {
94+
it.copy(name = newName)
95+
}
8196
}
8297
}
8398

8499
fun updateDescription(newDescription: String) {
85-
_uiState.update {
86-
it.copy(description = newDescription)
100+
if (newDescription.length <= uiState.value.maximumDescriptionLength) {
101+
_uiState.update {
102+
it.copy(description = newDescription)
103+
}
87104
}
88105
}
89106

app/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@
8686
<string name="shops">Shops</string>
8787
<string name="shop_name">Shop Name</string>
8888
<string name="shop_name_is_required">Shop name is required.</string>
89+
<string name="shop_name_is_invalid">Shop name is invalid.</string>
90+
<string name="shop_description_is_invalid">Shop description is invalid.</string>
91+
<string name="shop_name_help">Name must be 1-%1$d characters.</string>
92+
<string name="shop_description_help">Description must be 0-%1$d characters.</string>
8993
<string name="add_shop_description">Add a new shop.</string>
9094
<string name="tap_shop_below">Tap a shop below</string>
9195

app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings
22

33
import androidx.lifecycle.SavedStateHandle
44
import androidx.navigation.testing.invoke
5+
import com.nativeapptemplate.nativeapptemplatefree.NatConstants
56
import com.nativeapptemplate.nativeapptemplatefree.model.Attributes
67
import com.nativeapptemplate.nativeapptemplatefree.model.Data
78
import com.nativeapptemplate.nativeapptemplatefree.model.Shop
@@ -115,6 +116,73 @@ class ShopBasicSettingsViewModelTest {
115116

116117
assertTrue(viewModel.hasInvalidData())
117118
}
119+
120+
@Test
121+
fun maximumNameLength_matchesConstant() = runTest {
122+
assertEquals(
123+
NatConstants.MAXIMUM_SHOP_NAME_LENGTH,
124+
viewModel.uiState.value.maximumNameLength,
125+
)
126+
}
127+
128+
@Test
129+
fun maximumDescriptionLength_matchesConstant() = runTest {
130+
assertEquals(
131+
NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH,
132+
viewModel.uiState.value.maximumDescriptionLength,
133+
)
134+
}
135+
136+
@Test
137+
fun nameAtMaximumLength_isValid() = runTest {
138+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
139+
140+
shopRepository.sendShop(testInputShop)
141+
viewModel.reload()
142+
143+
viewModel.updateName("a".repeat(100))
144+
145+
assertFalse(viewModel.hasInvalidDataName())
146+
}
147+
148+
@Test
149+
fun nameAboveMaximumLength_isRejectedByUpdater() = runTest {
150+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
151+
152+
shopRepository.sendShop(testInputShop)
153+
viewModel.reload()
154+
155+
val previous = viewModel.uiState.value.name
156+
viewModel.updateName("a".repeat(101))
157+
158+
// updater clamps; value should remain unchanged from the loaded shop name
159+
assertEquals(previous, viewModel.uiState.value.name)
160+
}
161+
162+
@Test
163+
fun descriptionAtMaximumLength_isValid() = runTest {
164+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
165+
166+
shopRepository.sendShop(testInputShop)
167+
viewModel.reload()
168+
169+
viewModel.updateDescription("x".repeat(1_000))
170+
171+
assertFalse(viewModel.hasInvalidDataDescription())
172+
}
173+
174+
@Test
175+
fun descriptionAboveMaximumLength_isRejectedByUpdater() = runTest {
176+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
177+
178+
shopRepository.sendShop(testInputShop)
179+
viewModel.reload()
180+
181+
val previous = viewModel.uiState.value.description
182+
viewModel.updateDescription("x".repeat(1_001))
183+
184+
assertEquals(previous, viewModel.uiState.value.description)
185+
}
118186
}
119187

120188
private const val SHOP_TYPE = "shop"

app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.nativeapptemplate.nativeapptemplatefree.ui.shops
22

3+
import com.nativeapptemplate.nativeapptemplatefree.NatConstants
34
import com.nativeapptemplate.nativeapptemplatefree.model.Attributes
45
import com.nativeapptemplate.nativeapptemplatefree.model.Data
56
import com.nativeapptemplate.nativeapptemplatefree.model.Shop
@@ -80,6 +81,56 @@ class ShopCreateViewModelTest {
8081

8182
assertTrue(viewModel.hasInvalidData())
8283
}
84+
85+
@Test
86+
fun maximumNameLength_matchesConstant() = runTest {
87+
assertEquals(NatConstants.MAXIMUM_SHOP_NAME_LENGTH, viewModel.uiState.value.maximumNameLength)
88+
}
89+
90+
@Test
91+
fun maximumDescriptionLength_matchesConstant() = runTest {
92+
assertEquals(
93+
NatConstants.MAXIMUM_SHOP_DESCRIPTION_LENGTH,
94+
viewModel.uiState.value.maximumDescriptionLength,
95+
)
96+
}
97+
98+
@Test
99+
fun nameAtMaximumLength_isValid() = runTest {
100+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
101+
102+
viewModel.updateName("a".repeat(100))
103+
104+
assertFalse(viewModel.hasInvalidDataName())
105+
}
106+
107+
@Test
108+
fun nameAboveMaximumLength_isRejectedByUpdater() = runTest {
109+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
110+
111+
viewModel.updateName("a".repeat(101))
112+
113+
// updater clamps; value should remain blank (initial)
114+
assertEquals("", viewModel.uiState.value.name)
115+
}
116+
117+
@Test
118+
fun descriptionAtMaximumLength_isValid() = runTest {
119+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
120+
121+
viewModel.updateDescription("x".repeat(1_000))
122+
123+
assertFalse(viewModel.hasInvalidDataDescription())
124+
}
125+
126+
@Test
127+
fun descriptionAboveMaximumLength_isRejectedByUpdater() = runTest {
128+
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
129+
130+
viewModel.updateDescription("x".repeat(1_001))
131+
132+
assertEquals("", viewModel.uiState.value.description)
133+
}
83134
}
84135

85136
private const val SHOP_TYPE = "shop"

0 commit comments

Comments
 (0)