新增邮箱注册页面表单校验

新增邮箱注册页面表单校验,包括:
- 邮箱格式校验
- 密码格式校验(至少8位,
包含大小写字母和数字)
- 确认密码校验
- 服务条款和推广信息勾选校验

新增错误提示,包括:
- 邮箱格式错误
- 密码格式错误
- 确认密码不一致
- 未勾选服务条款
- 未勾选推广信息

优化
用户体验,包括:
- 使用 CheckboxWithLabel 组件优化勾选框样式
- 使用字符串资源
- 调整页面布局
This commit is contained in:
2024-09-06 15:34:27 +08:00
parent b7da2981aa
commit 025f66ca83
8 changed files with 303 additions and 106 deletions

View File

@@ -1,11 +1,24 @@
package com.aiosman.riderpro
import android.content.Context
object ConstVars {
// api 地址
// const val BASE_SERVER = "http://192.168.31.190:8088"
// const val BASE_SERVER = "http://192.168.31.36:8088"
const val BASE_SERVER = "https://8.137.22.101:8088"
const val BASE_SERVER = "http://192.168.31.36:8088"
// const val BASE_SERVER = "https://8.137.22.101:8088"
const val MOMENT_LIKE_CHANNEL_ID = "moment_like"
const val MOMENT_LIKE_CHANNEL_NAME = "Moment Like"
}
enum class ErrorCode(val code: Int) {
USER_EXIST(10001)
}
fun Context.getErrorMessageCode(code: Int?): String {
return when (code) {
10001 -> getString(R.string.error_10001_user_exist)
else -> getString(R.string.error_unknown)
}
}

View File

@@ -365,7 +365,14 @@ class AccountServiceImpl : AccountService {
}
override suspend fun registerUserWithPassword(loginName: String, password: String) {
ApiClient.api.register(RegisterRequestBody(loginName, password))
val resp = ApiClient.api.register(RegisterRequestBody(loginName, password))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to register")
}
}
override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) {

View File

@@ -1,12 +1,43 @@
package com.aiosman.riderpro.data
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody
/**
* 错误返回
*/
class ServiceException(
override val message: String,
val code: Int = 0,
val data: Any? = null
val code: Int? = 0,
val data: Any? = null,
val error: String? = null,
val name: String? = null,
) : Exception(
message
)
data class ApiErrorResponse(
@SerializedName("code")
val code: Int?,
@SerializedName("error")
val error: String?,
@SerializedName("message")
val name: String?,
) {
fun toServiceException(): ServiceException {
return ServiceException(
message = error ?: name ?: "",
code = code,
error = error,
name = name
)
}
}
fun parseErrorResponse(errorBody: ResponseBody?): ApiErrorResponse? {
return errorBody?.let {
val gson = Gson()
gson.fromJson(it.charStream(), ApiErrorResponse::class.java)
}
}

View File

@@ -0,0 +1,49 @@
package com.aiosman.riderpro.ui.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
@Composable
fun Checkbox(
size: Int = 24,
checked: Boolean = false,
onCheckedChange: (Boolean) -> Unit = {}
) {
val backgroundColor by animateColorAsState(if (checked) Color.Black else Color.Transparent)
val borderColor by animateColorAsState(if (checked) Color.Transparent else Color(0xffebebeb))
val borderWidth by animateDpAsState(if (checked) 0.dp else 1.dp)
Box(
modifier = Modifier
.size(size.dp)
.noRippleClickable {
onCheckedChange(!checked)
}
.background(color = backgroundColor)
.border(width = borderWidth, color = borderColor)
.padding(2.dp)
) {
if (checked) {
Icon(
Icons.Default.Check,
contentDescription = "Checked",
tint = Color.White,
)
}
}
}

View File

@@ -0,0 +1,40 @@
package com.aiosman.riderpro.ui.composables
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun CheckboxWithLabel(
checked: Boolean = false,
checkSize: Int = 16,
label: String = "",
fontSize: Int = 12,
error: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
) {
Checkbox(
checked = checked,
onCheckedChange = {
onCheckedChange(it)
},
size = checkSize
)
Text(
text = label,
modifier = Modifier.padding(start = 8.dp),
fontSize = fontSize.sp,
style = TextStyle(
color = if (error) Color.Red else Color.Black
)
)
}
}

View File

@@ -1,18 +1,22 @@
package com.aiosman.riderpro.ui.login
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -20,30 +24,37 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.AppStore
import com.aiosman.riderpro.ErrorCode
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.Messaging
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.ServiceException
import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.getErrorMessageCode
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.ActionButton
import com.aiosman.riderpro.ui.composables.Checkbox
import com.aiosman.riderpro.ui.composables.CheckboxWithLabel
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import com.aiosman.riderpro.ui.composables.StatusBarSpacer
import com.aiosman.riderpro.ui.composables.TextInputField
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun EmailSignupScreen() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var email by remember { mutableStateOf("takayamaaren@gmail.com") }
var password by remember { mutableStateOf("Dzh17217.") }
var confirmPassword by remember { mutableStateOf("Dzh17217.") }
var rememberMe by remember { mutableStateOf(false) }
var acceptTerms by remember { mutableStateOf(false) }
var acceptPromotions by remember { mutableStateOf(false) }
@@ -51,32 +62,62 @@ fun EmailSignupScreen() {
val navController = LocalNavController.current
val context = LocalContext.current
val accountService: AccountService = AccountServiceImpl()
var emailError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
var termsError by remember { mutableStateOf<Boolean>(false) }
var promotionsError by remember { mutableStateOf<Boolean>(false) }
fun validateForm(): Boolean {
if (email.isEmpty()) {
Toast.makeText(context, "Email is required", Toast.LENGTH_SHORT).show()
return false
emailError = when {
// 非空
email.isEmpty() -> context.getString(R.string.text_error_email_required)
// 邮箱格式
!android.util.Patterns.EMAIL_ADDRESS.matcher(email)
.matches() -> context.getString(R.string.text_error_email_format)
else -> null
}
if (password.isEmpty()) {
Toast.makeText(context, "Password is required", Toast.LENGTH_SHORT).show()
return false
passwordError = when {
// 非空
password.isEmpty() -> context.getString(R.string.text_error_password_required)
// 包含大写字母
!password.matches(Regex(".*[A-Z].*")) -> context.getString(R.string.text_error_password_format)
// 至少8位
password.length < 8 -> context.getString(R.string.text_error_password_format)
// 至少一个数字
!password.matches(Regex(".*\\d.*")) -> context.getString(R.string.text_error_password_format)
// 包含小写字母
!password.matches(Regex(".*[a-z].*")) -> context.getString(R.string.text_error_password_format)
else -> null
}
if (confirmPassword.isEmpty()) {
Toast.makeText(context, "Confirm password is required", Toast.LENGTH_SHORT).show()
return false
}
if (password != confirmPassword) {
Toast.makeText(context, "Password does not match", Toast.LENGTH_SHORT).show()
return false
confirmPasswordError = when {
// 非空
confirmPassword.isEmpty() -> context.getString(R.string.text_error_confirm_password_required)
// 与密码一致
confirmPassword != password -> context.getString(R.string.text_error_confirm_password_mismatch)
else -> null
}
if (!acceptTerms) {
Toast.makeText(context, "You must accept terms", Toast.LENGTH_SHORT).show()
scope.launch(Dispatchers.Main) {
Toast.makeText(context, context.getString(R.string.error_not_accept_term), Toast.LENGTH_SHORT).show()
}
termsError = true
return false
}else{
termsError = false
}
if (!acceptPromotions) {
Toast.makeText(context, "You must accept promotions", Toast.LENGTH_SHORT).show()
return false
scope.launch(Dispatchers.Main) {
Toast.makeText(context, context.getString(R.string.error_not_accept_recive_notice), Toast.LENGTH_SHORT).show()
}
return true
promotionsError = true
return false
}else{
promotionsError = false
}
return emailError == null && passwordError == null && confirmPasswordError == null
}
suspend fun registerUser() {
@@ -86,8 +127,18 @@ fun EmailSignupScreen() {
accountService.registerUserWithPassword(email, password)
} catch (e: ServiceException) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, "Failed to register", Toast.LENGTH_SHORT).show()
if (e.code == ErrorCode.USER_EXIST.code) {
emailError = context.getString(R.string.error_10001_user_exist)
return@launch
}
Toast.makeText(context, context.getErrorMessageCode(e.code), Toast.LENGTH_SHORT).show()
}
return
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
return
}
// 获取 token
val authResp = accountService.loginUserWithPassword(email, password)
@@ -115,118 +166,98 @@ fun EmailSignupScreen() {
popUpTo(NavigationRoute.Login.route) { inclusive = true }
}
}
}
StatusBarMaskLayout {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
StatusBarSpacer()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
NoticeScreenHeader("SIGNUP", moreIcon = false)
NoticeScreenHeader(stringResource(R.string.sign_in_upper), moreIcon = false)
}
Spacer(modifier = Modifier.padding(68.dp))
TextInputField(
Spacer(modifier = Modifier.padding(32.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
.weight(1f)
.padding(horizontal = 24.dp)
) {
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = email,
onValueChange = {
email = it
},
label = "What's your email?",
hint = "Enter your email"
label = stringResource(R.string.login_email_label),
hint = stringResource(R.string.text_hint_email),
error = emailError
)
Spacer(modifier = Modifier.padding(16.dp))
Spacer(modifier = Modifier.padding(8.dp))
TextInputField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
.fillMaxWidth(),
text = password,
onValueChange = {
password = it
},
password = true,
label = "What's your password?",
hint = "Enter your password"
label = stringResource(R.string.login_password_label),
hint = stringResource(R.string.text_hint_password),
error = passwordError
)
Spacer(modifier = Modifier.padding(16.dp))
Spacer(modifier = Modifier.padding(8.dp))
TextInputField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
.fillMaxWidth(),
text = confirmPassword,
onValueChange = {
confirmPassword = it
},
password = true,
label = "Confirm password",
hint = "Enter your password"
label = stringResource(R.string.login_confirm_password_label),
hint = stringResource(R.string.text_hint_confirm_password),
error = confirmPasswordError
)
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
CheckboxWithLabel(
checked = rememberMe,
onCheckedChange = {
checkSize = 16,
fontSize = 12,
label = stringResource(R.string.remember_me),
) {
rememberMe = it
},
modifier = Modifier.padding(start = 16.dp),
colors = CheckboxDefaults.colors(
checkedColor = Color.Black
),
)
Text("Remember me", modifier = Modifier.padding(start = 4.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
Spacer(modifier = Modifier.height(16.dp))
CheckboxWithLabel(
checked = acceptTerms,
onCheckedChange = {
acceptTerms = it
},
modifier = Modifier.padding(start = 16.dp),
colors = CheckboxDefaults.colors(
checkedColor = Color.Black
),
)
Text(
"Yes, I have read and agree to RiderPros Terms of Service.",
modifier = Modifier.padding(start = 4.dp),
fontSize = 12.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically
checkSize = 16,
fontSize = 12,
label = stringResource(R.string.agree_terms_of_service),
error = termsError
) {
Checkbox(
acceptTerms = it
}
Spacer(modifier = Modifier.height(16.dp))
CheckboxWithLabel(
checked = acceptPromotions,
onCheckedChange = {
checkSize = 16,
fontSize = 12,
label = stringResource(R.string.agree_promotion),
error = promotionsError
) {
acceptPromotions = it
},
modifier = Modifier.padding(start = 16.dp),
colors = CheckboxDefaults.colors(
checkedColor = Color.Black
),
)
Text(
"Yes, Send me news and promotional content from RiderPro.",
modifier = Modifier.padding(start = 4.dp),
fontSize = 12.sp
)
}
}
@@ -235,7 +266,7 @@ fun EmailSignupScreen() {
modifier = Modifier
.width(345.dp)
.height(48.dp),
text = "LET'S RIDE".uppercase(),
text = stringResource(R.string.lets_ride_upper),
backgroundImage = R.mipmap.rider_pro_signup_red_bg
) {
scope.launch(Dispatchers.IO) {
@@ -243,5 +274,7 @@ fun EmailSignupScreen() {
}
}
}
}
}

View File

@@ -33,4 +33,16 @@
<string name="sign_in_with_email">使用邮箱注册</string>
<string name="sign_in_with_google">使用 Google 账号登录</string>
<string name="back_upper">返回</string>
<string name="text_hint_confirm_password">再次输入密码</string>
<string name="login_confirm_password_label">再次输入密码</string>
<string name="agree_terms_of_service">我已阅读用户协议</string>
<string name="agree_promotion">我同意 Rider Pro 推送消息</string>
<string name="text_error_email_format">邮箱格式错误</string>
<string name="text_error_password_format">密码至少为 8 位,包含大写字母、小写字母、数字</string>
<string name="text_error_confirm_password_mismatch">密码和确认密码必须相同</string>
<string name="text_error_confirm_password_required">请输入确认密码</string>
<string name="error_10001_user_exist">用户已存在</string>
<string name="error_unknown">服务端未知错误</string>
<string name="error_not_accept_recive_notice">为了为您提供更个性化的服务,请允许我们向您推送相关信息。</string>
<string name="error_not_accept_term">"为了提供更好的服务,请您在注册前仔细阅读并同意《用户协议》。 "</string>
</resources>

View File

@@ -32,4 +32,16 @@
<string name="sign_in_with_email">CONTINUE WITH EMAIL</string>
<string name="sign_in_with_google">CONTINUE WITH GOOGLE</string>
<string name="back_upper">BACK</string>
<string name="text_hint_confirm_password">Enter your password again</string>
<string name="login_confirm_password_label">Confirm password</string>
<string name="agree_terms_of_service">Yes, I have read and agree to RiderPros Terms of Service.</string>
<string name="agree_promotion">Yes, I would like to receive promotions and updates from RiderPro.</string>
<string name="text_error_email_format">Invalid email</string>
<string name="text_error_password_format">The password must be at least 8 characters long and contain a combination of uppercase letters, lowercase letters, and numbers.</string>
<string name="text_error_confirm_password_mismatch">Please ensure that the passwords entered twice are consistent.</string>
<string name="text_error_confirm_password_required">Confirm password is required</string>
<string name="error_10001_user_exist">User existed</string>
<string name="error_unknown">Unknown error</string>
<string name="error_not_accept_recive_notice">To provide you with a more personalized experience, please allow us to send you relevant notifications.</string>
<string name="error_not_accept_term">To provide you with the best service, please read and agree to our User Agreement before registering.</string>
</resources>