Introduce generic ConfigurableFormScreen for all settings forms

Add FieldConfig and FormScreenConfig data models

Implement ConfigurableFormScreen and ComponentTextInput to render any settings screen with AndroidView inputs, styling, and validation

Refactor ChangePasswordScreen to use the new generic form instead of its bespoke implementation

Preserve existing theming, padding, and error behavior across all screens
This commit is contained in:
Hafiz 2025-06-26 15:19:20 -05:00
parent 4df5a62151
commit 9e17ab09d6
9 changed files with 641 additions and 284 deletions

View file

@ -63,6 +63,7 @@
<color name="purple400_purple500">@color/brand_500</color>
<color name="text_green10_green500">@color/green_500</color>
<color name="gray100_gray400">@color/gray_400</color>
<color name="gray100_gray500">@color/gray_500</color>
<color name="gray200_gray400">@color/gray_400</color>
<color name="gray600_gray10">@color/gray_10</color>
<color name="gray600_gray50">@color/gray_50</color>
@ -70,4 +71,5 @@
<color name="maroon100_red100">@color/red_100</color>
<color name="brand_button">@color/brand_600</color>
</resources>

View file

@ -129,7 +129,8 @@
<color name="purple400_purple500">@color/brand_400</color>
<color name="text_green10_green500">@color/green_10</color>
<color name="gray100_gray400">@color/gray_10</color>
<color name="gray100_gray400">@color/gray_100</color>
<color name="gray100_gray500">@color/gray_100</color>
<color name="gray200_gray400">@color/gray_200</color>
<color name="gray600_gray10">@color/gray_600</color>
<color name="gray600_gray50">@color/gray_600</color>

View file

@ -628,6 +628,8 @@
<string name="profile_summary">Edit your public profile.</string>
<string name="display_name">Display Name</string>
<string name="photo_url">Photo URL</string>
<string name="photo_url_description">You can display an image on your Habitica profile for others to see by adding a link to the image here.</string>
<string name="save_photo_url">Save Photo URL</string>
<string name="login_name">Login Name</string>
<string name="about">About</string>
<string name="app_settings">App Settings</string>
@ -635,7 +637,9 @@
<string name="authentication_summary">Change your authentication options.</string>
<string name="change_password">Change Password</string>
<string name="change_email">Change Email Address</string>
<string name="change_email_description">This is the email address that you use to log in to Habitica, as well as receive notifications.</string>
<string name="change_username">Change Username</string>
<string name="change_username_description">Usernames must be 1 to 20 characters, containing only letters a to z, numbers 0 to 9, hyphens, or underscores.</string>
<string name="change">Change</string>
<string name="character_level">Character Level</string>
<string name="auto_allocate_points">Auto Allocate Points</string>
@ -763,6 +767,8 @@
<string name="username_copied">Username copied to clipboard</string>
<string name="verification_pet">One of these Veteran Pets will be waiting for you after youve finished confirming!</string>
<string name="welcomeNameTitle">What should we call you?</string>
<string name="change_display_name">Change Display Name</string>
<string name="display_name_description">This is the name that will be displayed for your avatar in Habitica. Unlike username, it does not have to be a unique identifier.</string>
<string name="display_name_length_error">Display names must be between 1 and 30 characters</string>
<string name="setup_task_join_habitica">Join Habitica (Check me off!)</string>
<string name="setup_task_join_habitica_notes">You can either complete this To Do, edit it, or remove it.</string>
@ -1223,6 +1229,8 @@
<string name="my_account">My Account</string>
<string name="public_profile">Public Profile</string>
<string name="about_me">About Me</string>
<string name="about_me_description">Add a small blurb about yourself that will appear on your Habitica profile when others view it.</string>
<string name="save_about_me">Save About Me</string>
<string name="api" translatable="false">API</string>
<string name="account_info">Account Info</string>
<string name="login_methods">Login Methods</string>

View file

@ -952,6 +952,21 @@
<item name="hintTextAppearance">@style/TaskFormHintTextAppearance</item>
</style>
<style name="SettingTextInputLayoutAppearance" parent="Widget.MaterialComponents.TextInputLayout.FilledBox">
<item name="boxBackgroundColor">?colorTintedBackground</item>
<item name="boxStrokeColor">@color/gray200_gray400</item>
<item name="boxStrokeWidth">2dp</item>
<item name="boxStrokeWidthFocused">2dp</item>
<item name="android:textColor">?attr/colorPrimaryText</item>
<item name="android:textColorHint">?colorPrimaryText</item>
<item name="colorControlNormal">?attr/colorPrimary</item>
<item name="colorControlActivated">?attr/colorPrimary</item>
<item name="colorControlHighlight">?attr/colorPrimary</item>
<item name="colorAccent">?attr/colorPrimaryText</item>
<item name="colorPrimary">?attr/colorPrimaryText</item>
<item name="hintTextAppearance">@style/TaskFormHintTextAppearance</item>
</style>
<style name="TaskFormHintTextAppearance">
<item name="android:colorPrimary">?attr/colorPrimaryText</item>
<item name="colorPrimary">?attr/colorPrimaryText</item>

View file

@ -276,9 +276,9 @@ class AccountPreferenceFragment :
}
private fun showChangePasswordDialog() {
ChangePasswordBottomSheet(
onForgotPassword = { showForgotPasswordDialog() },
onPasswordChanged = { oldPassword, newPassword ->
ChangePasswordBottomSheet().also { changePasswordBottomSheet ->
changePasswordBottomSheet.onForgotPassword = { showForgotPasswordDialog() }
changePasswordBottomSheet.onPasswordChanged = { oldPassword, newPassword ->
lifecycleScope.launchCatching {
KeyboardUtil.dismissKeyboard(activity)
lifecycleScope.launchCatching {
@ -289,12 +289,12 @@ class AccountPreferenceFragment :
)
response?.apiToken?.let {
viewModel.saveTokens(it, user?.id ?: "")
changePasswordBottomSheet.dismiss()
}
}
}
}
).show(childFragmentManager, ChangePasswordBottomSheet.TAG)
}.show(childFragmentManager, ChangePasswordBottomSheet.TAG)
}
private fun showForgotPasswordDialog() {

View file

@ -18,9 +18,12 @@ import com.habitrpg.common.habitica.theme.HabiticaTheme
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.habitrpg.android.habitica.R
import androidx.lifecycle.MutableLiveData
class ChangePasswordBottomSheet(val onForgotPassword: () -> Unit = {}, val onPasswordChanged: (oldPassword: String, newPassword: String) -> Unit = { _, _ -> }) : BottomSheetDialogFragment() {
class ChangePasswordBottomSheet(
var onForgotPassword: () -> Unit? = {},
var onPasswordChanged: (oldPassword: String, newPassword: String) -> Unit = { _, _ -> }
) : BottomSheetDialogFragment() {
override fun onStart() {
super.onStart()
@ -73,11 +76,10 @@ class ChangePasswordBottomSheet(val onForgotPassword: () -> Unit = {}, val onPas
) {
ChangePasswordScreen(
onBack = { dismiss() },
onSave = { oldPassword, newPassword ->
onSave = { oldPassword, newPassword->
onPasswordChanged(oldPassword, newPassword)
dismiss()
},
onForgotPassword = {
onForgot = {
onForgotPassword()
dismiss()
}
@ -88,7 +90,6 @@ class ChangePasswordBottomSheet(val onForgotPassword: () -> Unit = {}, val onPas
}
}
companion object {
const val TAG = "ChangePasswordFragment"
}

View file

@ -1,270 +0,0 @@
package com.habitrpg.android.habitica.ui.views
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.text.InputType
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.textfield.TextInputLayout
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.ui.theme.colors
import com.habitrpg.common.habitica.theme.HabiticaTheme
@Composable
fun ChangePasswordScreen(
onBack: () -> Unit,
onSave: (oldPassword: String, newPassword: String) -> Unit,
onForgotPassword: () -> Unit
) {
val colors = HabiticaTheme.colors
val backgroundColor = colors.windowBackground
val fieldColor = colorResource(id = R.color.gray600_gray10)
val labelColor = colors.textSecondary
val buttonColor = colors.tintedUiMain
val textColor = colors.textPrimary
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var attemptedSave by remember { mutableStateOf(false) }
val passwordValid = newPassword.length >= 8
val passwordsMatch = newPassword == confirmPassword && newPassword.isNotEmpty()
val canSave = passwordValid && passwordsMatch && oldPassword.isNotBlank()
Surface(
modifier = Modifier.fillMaxSize(),
color = backgroundColor
) {
Box(modifier = Modifier.fillMaxSize()) {
IconButton(
onClick = onBack,
modifier = Modifier
.size(48.dp)
.align(Alignment.TopStart)
.padding(start = 22.dp, top = 16.dp)
) {
Icon(
painterResource(id = R.drawable.arrow_back),
contentDescription = stringResource(R.string.action_back),
tint = textColor
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(
start = 16.dp,
end = 16.dp,
top = 16.dp + 40.dp + 8.dp
)
) {
Text(
text = stringResource(R.string.change_password),
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
color = textColor,
modifier = Modifier
.align(Alignment.Start)
.padding(start = 6.dp, bottom = 12.dp)
)
Text(
text = stringResource(R.string.password_change_info),
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
lineHeight = 20.sp,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(start = 6.dp,bottom = 22.dp)
)
PasswordField(
label = stringResource(R.string.old_password),
value = oldPassword,
onValueChange = { oldPassword = it },
fieldColor = fieldColor,
labelColor = labelColor,
textColor = textColor,
modifier = Modifier.fillMaxWidth()
)
PasswordField(
label = stringResource(R.string.new_password),
value = newPassword,
onValueChange = { newPassword = it },
fieldColor = fieldColor,
labelColor = labelColor,
textColor = textColor,
isError = attemptedSave && !passwordValid,
errorMessage = if (attemptedSave && !passwordValid) stringResource(R.string.password_too_short, 8) else null,
modifier = Modifier.fillMaxWidth()
)
PasswordField(
label = stringResource(R.string.confirm_new_password),
value = confirmPassword,
onValueChange = { confirmPassword = it },
fieldColor = fieldColor,
labelColor = labelColor,
textColor = textColor,
isError = attemptedSave && !passwordsMatch,
errorMessage = if (attemptedSave && !passwordsMatch) stringResource(R.string.password_not_matching) else null,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.padding(top = 24.dp))
Button(
onClick = {
attemptedSave = true
if (canSave) onSave(oldPassword, newPassword)
},
enabled = true,
colors = ButtonDefaults.buttonColors(
containerColor = colorResource(id = R.color.brand_400),
disabledContainerColor = colorResource(id = R.color.brand_400),
contentColor = Color.White,
disabledContentColor = Color.White
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
) {
Text(
text = stringResource(R.string.change_password),
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(18.dp))
TextButton(
onClick = onForgotPassword,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(
text = stringResource(R.string.forgot_pw_btn),
color = buttonColor,
fontWeight = FontWeight.Medium,
fontSize = 16.sp
)
}
}
}
}
}
@Composable
fun PasswordField(
label: String,
value: String,
onValueChange: (String) -> Unit,
fieldColor: Color,
labelColor: Color,
textColor: Color,
isError: Boolean = false,
errorMessage: String? = null,
modifier: Modifier = Modifier
) {
val onTextChangedColor = if (value.isNotBlank())
colorResource(id = R.color.purple400_purple500)
else
colorResource(id = R.color.gray_400)
AndroidView(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
factory = { ctx ->
LayoutInflater.from(ctx)
.inflate(R.layout.component_text_input, null, false)
},
update = { view ->
val til = view.findViewById<TextInputLayout>(R.id.text_input_layout)
val edit = view.findViewById<AppCompatEditText>(R.id.text_edit_text)
til.hint = label
edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
edit.transformationMethod = PasswordTransformationMethod.getInstance()
fun syncColors(focused: Boolean) {
val active = focused || edit.text?.isNotBlank() == true
val stroke = if (active) onTextChangedColor else labelColor
til.defaultHintTextColor = ColorStateList.valueOf(stroke.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(stroke.toArgb()))
}
syncColors(edit.isFocused)
edit.setOnFocusChangeListener { _, focused ->
syncColors(focused)
}
edit.doAfterTextChanged {
syncColors(edit.isFocused)
onValueChange(it.toString())
}
if (edit.text.toString() != value) {
edit.setText(value)
edit.setSelection(value.length)
}
til.isErrorEnabled = isError
til.error = errorMessage
til.setBoxStrokeWidth(2)
edit.setTextColor(textColor.toArgb())
}
)
}
@Preview(showBackground = true, widthDp = 327, heightDp = 704, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun ChangePasswordScreenPreview() {
HabiticaTheme {
ChangePasswordScreen(
onBack = {},
onSave = { _, _ -> },
onForgotPassword = {}
)
}
}

View file

@ -0,0 +1,599 @@
package com.habitrpg.android.habitica.ui.views
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.text.InputType
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.textfield.TextInputLayout
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.ui.theme.colors
import com.habitrpg.common.habitica.theme.HabiticaTheme
@Composable
fun ConfigurableFormScreen(cfg: FormScreenConfig) {
val colors = HabiticaTheme.colors
var values by remember { mutableStateOf(cfg.fields.associate { it.key to it.initialValue }) }
var attempted by remember { mutableStateOf(false) }
val touchedFields = remember { mutableStateMapOf<String, Boolean>() }
val errorRes: Map<String, Int?> = remember(values, attempted, touchedFields) {
cfg.fields.associate { f ->
val show = attempted || (touchedFields[f.key] == true)
val rawError: Int? = when (f.key) {
"confirmPw" -> {
if (show && values["confirmPw"].orEmpty() != values["newPw"].orEmpty()) {
R.string.password_not_matching
} else {
f.validator(values[f.key].orEmpty())
}
}
else -> f.validator(values[f.key].orEmpty())
}
f.key to if (show) rawError else null
}
}
val errors: Map<String, String?> = errorRes.mapValues { (key, resId) ->
resId?.let {
if (it == R.string.password_too_short) {
stringResource(it, 8)
} else {
stringResource(it)
}
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = colors.windowBackground
) {
Box(Modifier.fillMaxSize()) {
IconButton(
onClick = cfg.onBack,
modifier = Modifier
.size(48.dp)
.align(Alignment.TopStart)
.padding(start = 22.dp, top = 16.dp)
) {
Icon(
painterResource(R.drawable.arrow_back),
contentDescription = stringResource(R.string.action_back),
tint = colors.textPrimary
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 16.dp, end = 16.dp, top = (16.dp + 40.dp + 8.dp))
) {
Text(
text = stringResource(cfg.titleRes),
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
color = colors.textPrimary,
modifier = Modifier
.align(Alignment.Start)
.padding(start = 6.dp, bottom = 12.dp)
)
cfg.descriptionRes?.let {
Text(
text = stringResource(it),
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
lineHeight = 20.sp,
color = colors.textPrimary,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(start = 6.dp, bottom = 22.dp)
)
}
cfg.fields.forEach { f ->
ComponentTextInput(
hintRes = f.labelRes,
value = values[f.key].orEmpty(),
onValueChange = { v -> values = values.toMutableMap().also { it[f.key] = v } },
kind = f.kind,
isError = errors[f.key] != null,
errorMessage = errors[f.key],
onFocusChanged = { focused ->
if (!focused) touchedFields[f.key] = true
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
attempted = true
if (cfg.canSubmit(values)) cfg.onSubmit(values)
},
enabled = true,
colors = ButtonDefaults.buttonColors(
containerColor = colorResource(id = R.color.brand_400),
disabledContainerColor = colorResource(id = R.color.brand_400),
contentColor = Color.White,
disabledContentColor = Color.White
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
) {
Text(
text = stringResource(cfg.submitButtonRes),
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(18.dp))
cfg.textButtonRes?.let { btnRes ->
TextButton(
onClick = cfg.onTextButton,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(
text = stringResource(btnRes),
color = colorResource(id = R.color.purple400_purple500),
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
@Composable
fun ComponentTextInput(
@StringRes hintRes: Int,
value: String,
onValueChange: (String) -> Unit,
kind: FieldKind,
isError: Boolean,
errorMessage: String?,
onFocusChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val onTextChangedColor = if (value.isNotBlank())
colorResource(id = R.color.purple400_purple500)
else
colorResource(id = R.color.gray200_gray400)
val activeNotFilledColor = colorResource(id = R.color.purple400_purple500)
val filledNotActiveColor = colorResource(id = R.color.gray_400)
val labelColor = colorResource(id = R.color.gray200_gray400)
val textColorArgb = HabiticaTheme.colors.textPrimary.toArgb()
AndroidView(
factory = { ctx ->
LayoutInflater.from(ctx)
.inflate(R.layout.component_text_input, null, false)
.apply {
findViewById<TextInputLayout>(R.id.text_input_layout)
.setBoxBackgroundColorResource(R.color.gray600_gray10)
}
},
update = { view ->
val til = view.findViewById<TextInputLayout>(R.id.text_input_layout)
val edit = view.findViewById<AppCompatEditText>(R.id.text_edit_text)
til.hint = view.context.getString(hintRes)
edit.inputType = when (kind) {
FieldKind.EMAIL -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
FieldKind.URI -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
FieldKind.MULTILINE -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
else ->
InputType.TYPE_CLASS_TEXT or
if (kind == FieldKind.PASSWORD) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
}
edit.transformationMethod =
if (kind == FieldKind.PASSWORD) PasswordTransformationMethod.getInstance() else null
fun syncColors(focused: Boolean) {
val active = focused || edit.text?.isNotBlank() == true
val filledNotActive = !focused && edit.text?.isNotBlank() == true
val activeNotFilled = focused && edit.text?.isBlank() == true
val strokeColor = if (active) onTextChangedColor else labelColor
til.defaultHintTextColor = ColorStateList.valueOf(strokeColor.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(strokeColor.toArgb()))
if (activeNotFilled) {
til.defaultHintTextColor = ColorStateList.valueOf(activeNotFilledColor.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(activeNotFilledColor.toArgb()))
}
if (filledNotActive) {
til.defaultHintTextColor = ColorStateList.valueOf(filledNotActiveColor.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(filledNotActiveColor.toArgb()))
}
}
syncColors(edit.isFocused)
edit.setOnFocusChangeListener { _, focused ->
syncColors(focused)
onFocusChanged(focused)
}
edit.doAfterTextChanged {
syncColors(edit.isFocused)
onValueChange(it.toString())
}
if (edit.text.toString() != value) {
edit.setText(value)
edit.setSelection(value.length)
}
til.isErrorEnabled = isError
til.error = errorMessage
til.setBoxStrokeWidth(2)
edit.setTextColor(textColorArgb)
},
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
enum class FieldKind { TEXT, PASSWORD, MULTILINE, URI, EMAIL }
data class FieldConfig(
val key: String,
@StringRes val labelRes: Int,
val kind: FieldKind = FieldKind.TEXT,
val initialValue: String = "",
val validator: (String) -> Int? = { null }
)
data class FormScreenConfig(
@StringRes val titleRes: Int,
@StringRes val descriptionRes: Int? = null,
val fields: List<FieldConfig>,
@StringRes val submitButtonRes: Int,
val onSubmit: (Map<String, String>) -> Unit,
val canSubmit: (Map<String, String>) -> Boolean,
@StringRes val textButtonRes: Int? = null,
val onTextButton: () -> Unit = {},
val onBack: () -> Unit
)
@Composable
fun ChangePasswordScreen(
onBack: () -> Unit,
onSave: (old: String, new: String) -> Unit,
onForgot: () -> Unit
) {
val fields = listOf(
FieldConfig(
key = "oldPw",
labelRes = R.string.old_password,
kind = FieldKind.PASSWORD,
validator = { if (it.length < 8) R.string.password_too_short else null }
),
FieldConfig(
key = "newPw",
labelRes = R.string.new_password,
kind = FieldKind.PASSWORD,
validator = { if (it.length < 8) R.string.password_too_short else null }
),
FieldConfig(
key = "confirmPw",
labelRes = R.string.confirm_new_password,
kind = FieldKind.PASSWORD,
validator = { if (it.isBlank()) R.string.password_not_matching else null }
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_password,
descriptionRes = R.string.password_change_info,
fields = fields,
submitButtonRes = R.string.change_password,
canSubmit = { vals ->
fields.all { it.validator(vals[it.key].orEmpty()) == null }
&& vals["newPw"] == vals["confirmPw"]
},
onSubmit = { v -> onSave(v["oldPw"]!!, v["newPw"]!!) },
textButtonRes = R.string.forgot_pw_btn,
onTextButton = onForgot,
onBack = onBack
)
)
}
@Composable
fun ChangeUsernameScreen(
onBack: () -> Unit,
onSave: (newUsername: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "username",
labelRes = R.string.username,
kind = FieldKind.TEXT,
validator = {
when {
it.isBlank() -> R.string.username_requirements
it.length > 20 -> R.string.username_requirements
!Regex("^[A-Za-z0-9_-]+$").matches(it) -> R.string.username_requirements
else -> null
}
}
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_username,
descriptionRes = R.string.change_username_description,
fields = fields,
submitButtonRes = R.string.change_username,
canSubmit = { vals -> fields.all { f -> f.validator(vals[f.key].orEmpty()) == null } },
onSubmit = { vals -> onSave(vals["username"]!!.trim()) },
onBack = onBack
)
)
}
@Composable
fun ChangeEmailScreen(
onBack: () -> Unit,
onSave: (newEmail: String, password: String) -> Unit,
onForgotPassword: () -> Unit
) {
val fields = listOf(
FieldConfig(
key = "email",
labelRes = R.string.email,
kind = FieldKind.EMAIL,
validator = { if (it.isBlank()) R.string.email_invalid else null }
),
FieldConfig(
key = "password",
labelRes = R.string.password,
kind = FieldKind.PASSWORD,
validator = { if (it.length < 8) R.string.password_too_short else null }
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_email,
descriptionRes = R.string.change_email_description,
fields = fields,
submitButtonRes = R.string.change_email,
canSubmit = { vals -> fields.all { f -> f.validator(vals[f.key].orEmpty()) == null } },
onSubmit = { vals -> onSave(vals["email"]!!, vals["password"]!!) },
textButtonRes = R.string.forgot_pw_btn,
onTextButton = onForgotPassword,
onBack = onBack
)
)
}
@Composable
fun ChangeDisplayNameScreen(
onBack: () -> Unit,
onSave: (newDisplayName: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "displayName",
labelRes = R.string.display_name,
kind = FieldKind.TEXT,
validator = { if (it.isBlank()) R.string.display_name_length_error else null }
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_display_name,
descriptionRes = R.string.display_name_description,
fields = fields,
submitButtonRes = R.string.change_display_name,
canSubmit = { vals -> vals["displayName"]!!.isNotBlank() },
onSubmit = { vals -> onSave(vals["displayName"]!!) },
onBack = onBack
)
)
}
@Composable
fun AboutMeScreen(
onBack: () -> Unit,
onSave: (aboutText: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "about",
labelRes = R.string.about_me,
kind = FieldKind.MULTILINE
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.about_me,
descriptionRes = R.string.about_me_description,
fields = fields,
submitButtonRes = R.string.save_about_me,
canSubmit = { true },
onSubmit = { vals -> onSave(vals["about"]!!.trim()) },
onBack = onBack
)
)
}
@Composable
fun PhotoUrlScreen(
onBack: () -> Unit,
onSave: (photoUrl: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "photoUrl",
labelRes = R.string.photo_url,
kind = FieldKind.URI
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.photo_url,
descriptionRes = R.string.photo_url_description,
fields = fields,
submitButtonRes = R.string.save_photo_url,
canSubmit = { vals -> vals["photoUrl"]!!.isNotBlank() },
onSubmit = { vals -> onSave(vals["photoUrl"]!!.trim()) },
onBack = onBack
)
)
}
@Preview(
name = "ChangeUsername Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangeUsernameScreenDark() {
HabiticaTheme {
ChangeUsernameScreen(
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "ChangeEmail Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangeEmailScreenDark() {
HabiticaTheme {
ChangeEmailScreen(
onBack = {},
onSave = { newEmail, password -> },
onForgotPassword = {}
)
}
}
@Preview(
name = "ChangeDisplayName Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangeDisplayNameScreenDark() {
HabiticaTheme {
ChangeDisplayNameScreen(
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "AboutMe Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewAboutMeScreenDark() {
HabiticaTheme {
AboutMeScreen(
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "PhotoURL Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewPhotoUrlScreenDark() {
HabiticaTheme {
PhotoUrlScreen(
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "ChangePasswordScreen Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangePasswordScreenDark() {
HabiticaTheme {
ChangePasswordScreen(
onBack = {},
onSave = { old, new -> },
onForgot = {}
)
}
}

View file

@ -8,11 +8,12 @@
android:id="@+id/text_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TaskFormTextInputLayoutAppearance"
style="@style/SettingTextInputLayoutAppearance"
app:boxStrokeWidth="2dp"
app:boxStrokeWidthFocused="2dp"
app:boxBackgroundColor="@color/gray600_gray10"
app:hintTextColor="?colorPrimaryText"
app:hintTextColor="@color/gray100_gray500"
app:boxStrokeColor="@color/gray200_gray400"
android:backgroundTint="?attr/colorPrimaryText"
android:hint="@string/task_title"
android:alpha="0.75">