Skip to content

Commit 7edbd17

Browse files
committed
feat(add-new-user): more...
1 parent 937c050 commit 7edbd17

File tree

10 files changed

+314
-65
lines changed

10 files changed

+314
-65
lines changed

feature-add/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ dependencies {
6363

6464
implementation(deps.androidx.material)
6565

66-
implementation(deps.arrow.core)
6766
implementation(deps.coroutines.core)
67+
implementation(deps.arrow.core)
68+
implementation(deps.timber)
6869
implementation(deps.flowExt)
6970

7071
implementation(deps.daggerHilt.android)

feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import kotlinx.parcelize.Parceler
1515
import kotlinx.parcelize.Parcelize
1616
import kotlinx.parcelize.TypeParceler
1717

18-
internal class UserValidationErrorPersistentSetParceler :
18+
internal object UserValidationErrorPersistentSetParceler :
1919
Parceler<PersistentSet<UserValidationError>> {
2020
override fun create(parcel: Parcel) =
2121
persistentHashSetOf<UserValidationError>().apply {
@@ -65,10 +65,6 @@ sealed interface ViewIntent : MviIntent {
6565
data class LastNameChanged(val lastName: String) : ViewIntent
6666

6767
object Submit : ViewIntent
68-
69-
object EmailChangedFirstTime : ViewIntent
70-
object FirstNameChangedFirstTime : ViewIntent
71-
object LastNameChangedFirstTime : ViewIntent
7268
}
7369

7470
internal sealed interface PartialStateChange {

feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddNewUserScreen.kt

Lines changed: 239 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
11
package com.hoc.flowmvi.ui.add
22

3+
import android.content.Context
4+
import androidx.compose.animation.Crossfade
5+
import androidx.compose.animation.core.tween
6+
import androidx.compose.foundation.layout.Box
37
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.PaddingValues
9+
import androidx.compose.foundation.layout.Spacer
10+
import androidx.compose.foundation.layout.fillMaxSize
11+
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.height
13+
import androidx.compose.foundation.layout.heightIn
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.layout.wrapContentHeight
16+
import androidx.compose.foundation.layout.wrapContentSize
17+
import androidx.compose.foundation.rememberScrollState
18+
import androidx.compose.foundation.text.KeyboardOptions
19+
import androidx.compose.foundation.verticalScroll
420
import androidx.compose.material.icons.Icons
521
import androidx.compose.material.icons.filled.ArrowBack
22+
import androidx.compose.material.icons.filled.Email
23+
import androidx.compose.material.icons.filled.Person
24+
import androidx.compose.material3.ButtonDefaults
25+
import androidx.compose.material3.ElevatedButton
626
import androidx.compose.material3.ExperimentalMaterial3Api
727
import androidx.compose.material3.Icon
828
import androidx.compose.material3.IconButton
29+
import androidx.compose.material3.MaterialTheme
30+
import androidx.compose.material3.Text
931
import androidx.compose.material3.TextField
1032
import androidx.compose.material3.TopAppBarDefaults
1133
import androidx.compose.runtime.Composable
@@ -14,21 +36,35 @@ import androidx.compose.runtime.getValue
1436
import androidx.compose.runtime.remember
1537
import androidx.compose.runtime.rememberCoroutineScope
1638
import androidx.compose.runtime.rememberUpdatedState
39+
import androidx.compose.ui.Alignment
1740
import androidx.compose.ui.Modifier
41+
import androidx.compose.ui.platform.LocalContext
1842
import androidx.compose.ui.res.stringResource
43+
import androidx.compose.ui.text.input.ImeAction
44+
import androidx.compose.ui.text.input.KeyboardType
45+
import androidx.compose.ui.tooling.preview.Preview
46+
import androidx.compose.ui.unit.dp
1947
import androidx.hilt.navigation.compose.hiltViewModel
2048
import androidx.lifecycle.Lifecycle
2149
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
2250
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2351
import com.hoc.flowmvi.core_ui.AppBarState
2452
import com.hoc.flowmvi.core_ui.ConfigAppBar
53+
import com.hoc.flowmvi.core_ui.LoadingIndicator
2554
import com.hoc.flowmvi.core_ui.LocalSnackbarHostState
2655
import com.hoc.flowmvi.core_ui.OnLifecycleEvent
2756
import com.hoc.flowmvi.core_ui.collectInLaunchedEffectWithLifecycle
57+
import com.hoc.flowmvi.domain.model.UserError
58+
import com.hoc.flowmvi.domain.model.UserValidationError
59+
import com.hoc.flowmvi.ui.theme.AppTheme
60+
import kotlinx.collections.immutable.persistentHashSetOf
61+
import kotlinx.coroutines.Dispatchers
2862
import kotlinx.coroutines.channels.Channel
2963
import kotlinx.coroutines.flow.collect
3064
import kotlinx.coroutines.flow.consumeAsFlow
3165
import kotlinx.coroutines.flow.onEach
66+
import kotlinx.coroutines.launch
67+
import kotlinx.coroutines.withContext
3268

3369
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLifecycleComposeApi::class)
3470
@Composable
@@ -63,18 +99,33 @@ internal fun AddNewUserRoute(
6399

64100
val intentChannel = remember { Channel<ViewIntent>(Channel.UNLIMITED) }
65101
LaunchedEffect(Unit) {
66-
intentChannel
67-
.consumeAsFlow()
68-
.onEach(viewModel::processIntent)
69-
.collect()
102+
withContext(Dispatchers.Main.immediate) {
103+
intentChannel
104+
.consumeAsFlow()
105+
.onEach(viewModel::processIntent)
106+
.collect()
107+
}
70108
}
71109

72110
val snackbarHostState by rememberUpdatedState(LocalSnackbarHostState.current)
73111
val scope = rememberCoroutineScope()
112+
val context = LocalContext.current
74113
viewModel.singleEvent.collectInLaunchedEffectWithLifecycle { event ->
75114
when (event) {
76-
is SingleEvent.AddUserFailure -> TODO()
77-
is SingleEvent.AddUserSuccess -> TODO()
115+
is SingleEvent.AddUserFailure -> {
116+
scope.launch {
117+
snackbarHostState.showSnackbar(
118+
event.error.getReadableMessage(context)
119+
)
120+
}
121+
}
122+
is SingleEvent.AddUserSuccess -> {
123+
scope.launch {
124+
snackbarHostState.showSnackbar(
125+
context.getString(R.string.add_user_success)
126+
)
127+
}
128+
}
78129
}
79130
}
80131

@@ -89,6 +140,9 @@ internal fun AddNewUserRoute(
89140
modifier = modifier,
90141
viewState = viewState,
91142
onEmailChanged = { dispatch(ViewIntent.EmailChanged(it)) },
143+
onFirstNameChanged = { dispatch(ViewIntent.FirstNameChanged(it)) },
144+
onLastNameChanged = { dispatch(ViewIntent.LastNameChanged(it)) },
145+
onSubmit = { dispatch(ViewIntent.Submit) }
92146
)
93147
}
94148

@@ -97,12 +151,187 @@ internal fun AddNewUserRoute(
97151
private fun AddNewUserContent(
98152
viewState: ViewState,
99153
onEmailChanged: (String) -> Unit,
154+
onFirstNameChanged: (String) -> Unit,
155+
onLastNameChanged: (String) -> Unit,
156+
onSubmit: () -> Unit,
100157
modifier: Modifier = Modifier,
101158
) {
102-
Column(modifier = modifier) {
103-
TextField(
104-
value = viewState.email ?: "",
105-
onValueChange = onEmailChanged
159+
val emailError =
160+
if (viewState.emailChanged && UserValidationError.INVALID_EMAIL_ADDRESS in viewState.errors) "Invalid email"
161+
else null
162+
163+
val firstNameError =
164+
if (viewState.firstNameChanged && UserValidationError.TOO_SHORT_FIRST_NAME in viewState.errors) "Too short first name"
165+
else null
166+
167+
val lastNameError =
168+
if (viewState.lastNameChanged && UserValidationError.TOO_SHORT_LAST_NAME in viewState.errors) "Too short last name"
169+
else null
170+
171+
Box(
172+
modifier = modifier
173+
.fillMaxSize()
174+
.padding(horizontal = 16.dp)
175+
) {
176+
Column(
177+
modifier = Modifier
178+
.fillMaxSize()
179+
.wrapContentHeight(Alignment.CenterVertically)
180+
.verticalScroll(rememberScrollState())
181+
) {
182+
Spacer(modifier = Modifier.height(16.dp))
183+
184+
TextField(
185+
modifier = Modifier.fillMaxWidth(),
186+
value = viewState.email ?: "",
187+
onValueChange = onEmailChanged,
188+
label = { Text(text = "Email") },
189+
leadingIcon = {
190+
Icon(
191+
imageVector = Icons.Filled.Email,
192+
contentDescription = "Email"
193+
)
194+
},
195+
maxLines = 1,
196+
singleLine = true,
197+
keyboardOptions = KeyboardOptions(
198+
keyboardType = KeyboardType.Email,
199+
imeAction = ImeAction.Next
200+
),
201+
isError = emailError !== null,
202+
supportingText = {
203+
emailError?.let {
204+
Text(text = it)
205+
}
206+
}
207+
)
208+
209+
Spacer(modifier = Modifier.height(16.dp))
210+
211+
TextField(
212+
modifier = Modifier.fillMaxWidth(),
213+
value = viewState.firstName ?: "",
214+
onValueChange = onFirstNameChanged,
215+
label = { Text(text = "First name") },
216+
leadingIcon = {
217+
Icon(
218+
imageVector = Icons.Filled.Person,
219+
contentDescription = "First name"
220+
)
221+
},
222+
maxLines = 1,
223+
singleLine = true,
224+
keyboardOptions = KeyboardOptions(
225+
keyboardType = KeyboardType.Text,
226+
imeAction = ImeAction.Next
227+
),
228+
isError = firstNameError !== null,
229+
supportingText = {
230+
firstNameError?.let {
231+
Text(text = it)
232+
}
233+
}
234+
)
235+
236+
Spacer(modifier = Modifier.height(16.dp))
237+
238+
TextField(
239+
modifier = Modifier.fillMaxWidth(),
240+
value = viewState.lastName ?: "",
241+
onValueChange = onLastNameChanged,
242+
label = { Text(text = "Last name") },
243+
leadingIcon = {
244+
Icon(
245+
imageVector = Icons.Filled.Person,
246+
contentDescription = "Last name"
247+
)
248+
},
249+
maxLines = 1,
250+
singleLine = true,
251+
keyboardOptions = KeyboardOptions(
252+
keyboardType = KeyboardType.Text,
253+
imeAction = ImeAction.Done
254+
),
255+
isError = lastNameError !== null,
256+
supportingText = {
257+
lastNameError?.let {
258+
Text(text = it)
259+
}
260+
}
261+
)
262+
263+
Spacer(modifier = Modifier.height(24.dp))
264+
265+
Crossfade(
266+
modifier = Modifier
267+
.fillMaxWidth()
268+
.heightIn(min = 64.dp),
269+
targetState = viewState.isLoading,
270+
animationSpec = tween(durationMillis = 200),
271+
label = "LoadingIndicator/ElevatedButton",
272+
) { isLoading ->
273+
if (isLoading) {
274+
LoadingIndicator(
275+
modifier = Modifier
276+
.fillMaxWidth(),
277+
)
278+
} else {
279+
ElevatedButton(
280+
modifier = Modifier
281+
.fillMaxWidth()
282+
.wrapContentSize(Alignment.Center),
283+
colors = ButtonDefaults.elevatedButtonColors(
284+
containerColor = MaterialTheme.colorScheme.primary,
285+
contentColor = MaterialTheme.colorScheme.onPrimary
286+
),
287+
onClick = onSubmit,
288+
contentPadding = PaddingValues(
289+
horizontal = 32.dp,
290+
vertical = 16.dp,
291+
),
292+
) {
293+
Text(text = "Add")
294+
}
295+
}
296+
}
297+
298+
Spacer(modifier = Modifier.height(16.dp))
299+
}
300+
}
301+
}
302+
303+
@Preview(
304+
showBackground = true,
305+
showSystemUi = true,
306+
device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480",
307+
)
308+
@Composable
309+
fun PreviewAddNewUserContent() {
310+
AppTheme {
311+
AddNewUserContent(
312+
viewState = ViewState(
313+
errors = persistentHashSetOf(),
314+
isLoading = true,
315+
emailChanged = false,
316+
firstNameChanged = false,
317+
lastNameChanged = false,
318+
email = "hoc081098@gmail.com",
319+
firstName = "Petrus",
320+
lastName = "Hoc",
321+
),
322+
onEmailChanged = {},
323+
onFirstNameChanged = {},
324+
onLastNameChanged = {},
325+
onSubmit = {}
106326
)
107327
}
108328
}
329+
330+
private fun UserError.getReadableMessage(context: Context): String = when (this) {
331+
is UserError.InvalidId -> context.getString(R.string.invalid_id_error_message)
332+
UserError.NetworkError -> context.getString(R.string.network_error_error_message)
333+
UserError.ServerError -> context.getString(R.string.server_error_error_message)
334+
UserError.Unexpected -> context.getString(R.string.unexpected_error_error_message)
335+
is UserError.UserNotFound -> context.getString(R.string.user_not_found_error_message)
336+
is UserError.ValidationFailed -> context.getString(R.string.validation_failed_error_message)
337+
}

0 commit comments

Comments
 (0)