Skip to content

Commit 6d2b86c

Browse files
dadachiclaude
andauthored
Phase 2A-3: Generic CRUD UI for ItemTag (#32)
Mirrors NativeAppTemplate-Android PR #40 (which mirrors iOS PR #50). After this sub-phase, ItemTag is a generic parent-child CRUD UI: Create/Edit accept any unicode/symbol name with multi-line description, Detail screen shows a state badge + completed timestamp + Mark-as-completed/idled toggle, the list card surfaces description preview + state. Differences from the paid PR: this Free repo has no per-action permission system (canCreateShops / canUpdateShops / canDeleteShops / canManageTags), so all permission-gating in views and ViewModels is omitted — the FAB, swipe-delete, edit toolbar, and state-toggle button are always shown. ### Data layer - ItemTag.position: Int? -> Int = 0. Rails server's set_position_if_missing always populates this; client trusts the contract. - ItemTagBodyDetail: dropped position (server auto-assigns). - Data.getPosition() / ItemTag.getPosition(): now non-null Int. ### Rename maximumQueueNumberLength -> maximumNameLength - Meta serial name maximum_queue_number_length -> maximum_name_length; default fallback 256 -> 100. - Threaded through Permissions, UserData, user_preferences.proto, NatPreferencesDataSource, LoginRepository(+Impl), TestLoginRepository, DemoLoginRepository. ### ItemTag Create + Edit - Validation relaxed: drop alphanumeric and count >= 2 checks. Valid name is 1 - maximumNameLength chars (any unicode/symbols/spaces). - New description field: 0 - 1000 chars, optional, multi-line OutlinedTextField (minLines = 4). NatConstants.MAXIMUM_ITEM_TAG_DESCRIPTION_LENGTH = 1_000. - Standard keyboard (no .Ascii). - hasInvalidData checks both name AND description; Edit's also requires that name OR description changed. ### ItemTag Detail full rewrite - HeaderRow: name + IdlingTag() / CompletedTag() badge. - DescriptionSection: hidden when blank. - CompletedAtRow: shown only when state == Completed. - StateToggleButton: flips between Mark as completed and Mark as idled; uses MainButtonView; disabled while isToggling. - ViewModel: new isToggling, completeItemTag(), idleItemTag(). Errors set uiState.message; success silently updates itemTag (matches the no-success-toast pattern from #31). ### ItemTag list card - Headline: name + state badge. - Supporting: description preview (2 lines, ellipsis) + completed timestamp when state == Completed. ### Date helpers - Add ZonedDateTime.cardDateTimeString() and String.cardDateTimeString() to DateUtility. - Card date format MMM dd yyyy -> yyyy/MM/dd. - ShopDetailCardView and ItemTagListCardView use cardDateTimeString(). ### Strings + constants - New: name_label, description_label, completed_at_label, item_tag_name_placeholder, item_tag_name_is_invalid, item_tag_description_is_invalid, item_tag_name_help (format), item_tag_description_help (format), mark_as_completed, mark_as_idled. - Removed queue-specific: tag_number, tag_number_is_invalid, zero_padding. ### Tests - ItemTagCreateViewModelTest: new validation matrix (unicode/symbols, 100/101 boundary, 1000/1001 description boundary). - ItemTagEditViewModelTest: rewritten - unchanged-form invalid, description-only change valid, blank name invalid, unicode/symbols valid. - ItemTagDetailViewModelTest: + completeItemTag / idleItemTag success cases. - DateUtilityTest: + cardDateTimeString tests. ### Test plan - [x] ./gradlew clean assembleDebug passes - [x] ./gradlew test passes - [x] ./gradlew spotlessCheck passes - [x] ./gradlew lint passes - [x] ./gradlew buildHealth passes - [ ] Manual smoke test on emulator: sign in, create tag with "Buy milk 🥛" and multi-line description; mark complete; mark idled; edit description; delete Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 74c36e5 commit 6d2b86c

29 files changed

Lines changed: 512 additions & 139 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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_ITEM_TAG_DESCRIPTION_LENGTH: Int = 1_000
1213

1314
const val PLACEHOLDER_FULLNAME: String = "John Smith"
1415
const val PLACEHOLDER_EMAIL: String = "you@example.com"

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,5 @@ interface LoginRepository {
5656

5757
fun didShowTapShopBelowTip(): Flow<Boolean>
5858

59-
fun getMaximumQueueNumberLength(): Flow<Int>
59+
fun getMaximumNameLength(): Flow<Int>
6060
}

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,5 @@ class LoginRepositoryImpl @Inject constructor(
128128

129129
override fun didShowTapShopBelowTip(): Flow<Boolean> = natPreferencesDataSource.didShowTapShopBelowTip()
130130

131-
override fun getMaximumQueueNumberLength(): Flow<Int> = natPreferencesDataSource.getMaximumQueueNumberLength()
131+
override fun getMaximumNameLength(): Flow<Int> = natPreferencesDataSource.getMaximumNameLength()
132132
}

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSource.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class NatPreferencesDataSource @Inject constructor(
5050
androidAppVersion = it.androidAppVersion,
5151
shouldUpdatePrivacy = it.shouldUpdatePrivacy,
5252
shouldUpdateTerms = it.shouldUpdateTerms,
53-
maximumQueueNumberLength = it.maximumQueueNumberLength,
53+
maximumNameLength = it.maximumNameLength,
5454
shopLimitCount = it.shopLimitCount,
5555

5656
isEmailUpdated = it.isEmailUpdated,
@@ -109,7 +109,7 @@ class NatPreferencesDataSource @Inject constructor(
109109

110110
this.shouldUpdatePrivacy = permissions.getShouldUpdatePrivacy()!!
111111
this.shouldUpdateTerms = permissions.getShouldUpdateTerms()!!
112-
this.maximumQueueNumberLength = permissions.getMaximumQueueNumberLength()!!
112+
this.maximumNameLength = permissions.getMaximumNameLength()!!
113113
this.shopLimitCount = permissions.getShopLimitCount()!!
114114
}
115115
}
@@ -245,8 +245,8 @@ class NatPreferencesDataSource @Inject constructor(
245245
data.didShowTapShopBelowTip
246246
}
247247

248-
fun getMaximumQueueNumberLength(): Flow<Int> = userPreferences.data
248+
fun getMaximumNameLength(): Flow<Int> = userPreferences.data
249249
.map { data ->
250-
data.maximumQueueNumberLength
250+
data.maximumNameLength
251251
}
252252
}

app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailCardView.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import com.nativeapptemplate.nativeapptemplatefree.model.Data
1616
import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagState
1717
import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.CompletedTag
1818
import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.IdlingTag
19-
import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardTimeString
19+
import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardDateTimeString
2020

2121
@Composable
2222
fun ShopDetailCardView(
@@ -50,7 +50,7 @@ fun ShopDetailCardView(
5050
CompletedTag()
5151

5252
Text(
53-
completedAt.cardTimeString(),
53+
completedAt.cardDateTimeString(),
5454
color = MaterialTheme.colorScheme.onSurfaceVariant,
5555
modifier = Modifier
5656
.padding(top = 4.dp),

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

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_de
33
import androidx.compose.foundation.layout.Arrangement
44
import androidx.compose.foundation.layout.Box
55
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
67
import androidx.compose.foundation.layout.fillMaxHeight
78
import androidx.compose.foundation.layout.fillMaxSize
89
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,17 +32,22 @@ import androidx.compose.runtime.setValue
3132
import androidx.compose.ui.Alignment
3233
import androidx.compose.ui.Modifier
3334
import androidx.compose.ui.res.stringResource
34-
import androidx.compose.ui.text.style.TextAlign
3535
import androidx.compose.ui.unit.dp
3636
import androidx.hilt.navigation.compose.hiltViewModel
3737
import androidx.lifecycle.Lifecycle
3838
import androidx.lifecycle.compose.LifecycleEventEffect
3939
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4040
import com.nativeapptemplate.nativeapptemplatefree.R
41+
import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag
42+
import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagState
4143
import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView
4244
import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView
45+
import com.nativeapptemplate.nativeapptemplatefree.ui.common.MainButtonView
4346
import com.nativeapptemplate.nativeapptemplatefree.ui.common.NatAlertDialog
4447
import com.nativeapptemplate.nativeapptemplatefree.ui.common.SnackbarMessageEffect
48+
import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.CompletedTag
49+
import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.IdlingTag
50+
import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardDateTimeString
4551

4652
@Composable
4753
internal fun ItemTagDetailView(
@@ -158,48 +164,108 @@ private fun ItemTagDetailContentView(
158164
.padding(padding),
159165
) {
160166
Column(
161-
verticalArrangement = Arrangement.spacedBy(12.dp),
167+
verticalArrangement = Arrangement.spacedBy(16.dp),
162168
modifier = Modifier
163169
.padding(horizontal = 16.dp, vertical = 16.dp)
164170
.verticalScroll(rememberScrollState()),
165171
) {
166-
Text(
167-
uiState.itemTag.getName(),
168-
style = MaterialTheme.typography.titleLarge,
169-
color = MaterialTheme.colorScheme.primary,
170-
textAlign = TextAlign.Center,
171-
modifier = Modifier.fillMaxWidth(),
172-
)
173-
174-
Text(
175-
uiState.itemTag.getDescription(),
176-
style = MaterialTheme.typography.bodyMedium,
177-
textAlign = TextAlign.Center,
178-
modifier = Modifier.fillMaxWidth(),
179-
)
172+
HeaderRow(itemTag = uiState.itemTag)
173+
DescriptionSection(description = uiState.itemTag.getDescription())
174+
CompletedAtRow(itemTag = uiState.itemTag)
175+
StateToggleButton(viewModel = viewModel, uiState = uiState)
176+
}
177+
}
178+
}
179+
}
180180

181-
Text(
182-
uiState.itemTag.getState(),
183-
style = MaterialTheme.typography.bodyMedium,
184-
color = MaterialTheme.colorScheme.onSurfaceVariant,
185-
textAlign = TextAlign.Center,
186-
modifier = Modifier.fillMaxWidth(),
187-
)
181+
@Composable
182+
private fun HeaderRow(itemTag: ItemTag) {
183+
Row(
184+
horizontalArrangement = Arrangement.spacedBy(12.dp),
185+
verticalAlignment = Alignment.CenterVertically,
186+
modifier = Modifier.fillMaxWidth(),
187+
) {
188+
Text(
189+
itemTag.getName(),
190+
style = MaterialTheme.typography.titleLarge,
191+
color = MaterialTheme.colorScheme.primary,
192+
modifier = Modifier.weight(1f),
193+
)
188194

189-
uiState.itemTag.getCompletedAt()?.takeIf { it.isNotBlank() }?.let { completedAt ->
190-
Text(
191-
completedAt,
192-
style = MaterialTheme.typography.bodyMedium,
193-
color = MaterialTheme.colorScheme.onSurfaceVariant,
194-
textAlign = TextAlign.Center,
195-
modifier = Modifier.fillMaxWidth(),
196-
)
197-
}
198-
}
195+
when (itemTag.getData()?.getItemTagState()) {
196+
ItemTagState.Completed -> CompletedTag()
197+
ItemTagState.Idled -> IdlingTag()
198+
null -> Unit
199199
}
200200
}
201201
}
202202

203+
@Composable
204+
private fun DescriptionSection(description: String) {
205+
if (description.isBlank()) return
206+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
207+
Text(
208+
stringResource(R.string.description_label),
209+
style = MaterialTheme.typography.titleSmall,
210+
color = MaterialTheme.colorScheme.onSurfaceVariant,
211+
)
212+
Text(
213+
description,
214+
style = MaterialTheme.typography.bodyMedium,
215+
)
216+
}
217+
}
218+
219+
@Composable
220+
private fun CompletedAtRow(itemTag: ItemTag) {
221+
if (itemTag.getData()?.getItemTagState() != ItemTagState.Completed) return
222+
val completedAt = itemTag.getCompletedAt()
223+
if (completedAt.isNullOrBlank()) return
224+
225+
Row(
226+
horizontalArrangement = Arrangement.spacedBy(8.dp),
227+
modifier = Modifier.fillMaxWidth(),
228+
) {
229+
Text(
230+
stringResource(R.string.completed_at_label),
231+
style = MaterialTheme.typography.titleSmall,
232+
color = MaterialTheme.colorScheme.onSurfaceVariant,
233+
)
234+
Text(
235+
completedAt.cardDateTimeString(),
236+
style = MaterialTheme.typography.bodyMedium,
237+
)
238+
}
239+
}
240+
241+
@Composable
242+
private fun StateToggleButton(
243+
viewModel: ItemTagDetailViewModel,
244+
uiState: ItemTagDetailUiState,
245+
) {
246+
val state = uiState.itemTag.getData()?.getItemTagState() ?: return
247+
248+
val title = when (state) {
249+
ItemTagState.Idled -> stringResource(R.string.mark_as_completed)
250+
ItemTagState.Completed -> stringResource(R.string.mark_as_idled)
251+
}
252+
val onClick: () -> Unit = when (state) {
253+
ItemTagState.Idled -> viewModel::completeItemTag
254+
ItemTagState.Completed -> viewModel::idleItemTag
255+
}
256+
257+
MainButtonView(
258+
title = title,
259+
onClick = onClick,
260+
enabled = !uiState.isToggling,
261+
color = MaterialTheme.colorScheme.primary,
262+
titleColor = MaterialTheme.colorScheme.primary,
263+
modifier = Modifier
264+
.fillMaxWidth()
265+
.padding(top = 16.dp),
266+
)
267+
}
268+
203269
@OptIn(ExperimentalMaterial3Api::class)
204270
@Composable
205271
private fun TopAppBar(

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ data class ItemTagDetailUiState(
2424
val isDeleted: Boolean = false,
2525

2626
val isLoading: Boolean = true,
27+
val isToggling: Boolean = false,
2728
val success: Boolean = false,
2829
val message: String = "",
2930
)
@@ -107,6 +108,58 @@ class ItemTagDetailViewModel @Inject constructor(
107108
}
108109
}
109110

111+
fun completeItemTag() {
112+
_uiState.update { it.copy(isToggling = true) }
113+
114+
viewModelScope.launch {
115+
val itemTagFlow: Flow<ItemTag> = itemTagRepository.completeItemTag(itemTagId)
116+
117+
itemTagFlow
118+
.catch { exception ->
119+
_uiState.update {
120+
it.copy(
121+
message = exception.codedDescription,
122+
isToggling = false,
123+
)
124+
}
125+
}
126+
.collect { itemTag ->
127+
_uiState.update {
128+
it.copy(
129+
itemTag = itemTag,
130+
isToggling = false,
131+
)
132+
}
133+
}
134+
}
135+
}
136+
137+
fun idleItemTag() {
138+
_uiState.update { it.copy(isToggling = true) }
139+
140+
viewModelScope.launch {
141+
val itemTagFlow: Flow<ItemTag> = itemTagRepository.idleItemTag(itemTagId)
142+
143+
itemTagFlow
144+
.catch { exception ->
145+
_uiState.update {
146+
it.copy(
147+
message = exception.codedDescription,
148+
isToggling = false,
149+
)
150+
}
151+
}
152+
.collect { itemTag ->
153+
_uiState.update {
154+
it.copy(
155+
itemTag = itemTag,
156+
isToggling = false,
157+
)
158+
}
159+
}
160+
}
161+
}
162+
110163
fun updateMessage(newMessage: String) {
111164
_uiState.update {
112165
it.copy(message = newMessage)

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
1010
import androidx.compose.foundation.layout.padding
1111
import androidx.compose.foundation.rememberScrollState
1212
import androidx.compose.foundation.shape.CircleShape
13-
import androidx.compose.foundation.text.KeyboardOptions
1413
import androidx.compose.foundation.verticalScroll
1514
import androidx.compose.material.icons.Icons
1615
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -33,12 +32,12 @@ import androidx.compose.ui.Alignment
3332
import androidx.compose.ui.Modifier
3433
import androidx.compose.ui.graphics.Color
3534
import androidx.compose.ui.res.stringResource
36-
import androidx.compose.ui.text.input.KeyboardType
3735
import androidx.compose.ui.unit.dp
3836
import androidx.hilt.navigation.compose.hiltViewModel
3937
import androidx.lifecycle.Lifecycle
4038
import androidx.lifecycle.compose.LifecycleEventEffect
4139
import androidx.lifecycle.compose.collectAsStateWithLifecycle
40+
import com.nativeapptemplate.nativeapptemplatefree.NatConstants
4241
import com.nativeapptemplate.nativeapptemplatefree.R
4342
import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView
4443
import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView
@@ -135,32 +134,53 @@ fun ItemTagEditContentView(
135134
OutlinedTextField(
136135
label = {
137136
Text(
138-
text = stringResource(R.string.tag_number),
137+
text = stringResource(R.string.name_label),
139138
)
140139
},
141-
placeholder = { Text("A001") },
140+
placeholder = { Text(stringResource(R.string.item_tag_name_placeholder)) },
142141
value = uiState.name,
143142
onValueChange = { viewModel.updateName(it) },
144143
supportingText = {
145144
Column {
146145
Text(
147-
text = "Name must be a 2-${uiState.maximumQueueNumberLength} alphanumeric characters.",
146+
text = stringResource(R.string.item_tag_name_help, uiState.maximumNameLength),
148147
style = MaterialTheme.typography.bodyLarge,
149148
color = MaterialTheme.colorScheme.onSurfaceVariant,
150149
)
151150
Text(
152-
text = stringResource(R.string.zero_padding),
151+
text = stringResource(R.string.item_tag_name_is_invalid),
152+
style = MaterialTheme.typography.bodyLarge,
153+
color = if (viewModel.hasInvalidDataName()) Color.Red else Color.Transparent,
154+
)
155+
}
156+
},
157+
modifier = Modifier
158+
.fillMaxWidth(),
159+
)
160+
161+
OutlinedTextField(
162+
label = {
163+
Text(
164+
text = stringResource(R.string.description_label),
165+
)
166+
},
167+
value = uiState.description,
168+
onValueChange = { viewModel.updateDescription(it) },
169+
minLines = 4,
170+
supportingText = {
171+
Column {
172+
Text(
173+
text = stringResource(R.string.item_tag_description_help, NatConstants.MAXIMUM_ITEM_TAG_DESCRIPTION_LENGTH),
153174
style = MaterialTheme.typography.bodyLarge,
154175
color = MaterialTheme.colorScheme.onSurfaceVariant,
155176
)
156177
Text(
157-
text = stringResource(id = R.string.tag_number_is_invalid),
178+
text = stringResource(R.string.item_tag_description_is_invalid),
158179
style = MaterialTheme.typography.bodyLarge,
159-
color = if (viewModel.hasInvalidDataName()) Color.Red else Color.Transparent,
180+
color = if (viewModel.hasInvalidDataDescription()) Color.Red else Color.Transparent,
160181
)
161182
}
162183
},
163-
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
164184
modifier = Modifier
165185
.fillMaxWidth(),
166186
)

0 commit comments

Comments
 (0)