mirror of
https://github.com/sudoxnym/habitica-android.git
synced 2026-04-14 19:56:32 +00:00
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:
parent
4df5a62151
commit
9e17ab09d6
9 changed files with 641 additions and 284 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 you’ve 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue