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

新增邮箱注册页面表单校验,包括:
- 邮箱格式校验
- 密码格式校验(至少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 package com.aiosman.riderpro
import android.content.Context
object ConstVars { object ConstVars {
// api 地址 // api 地址
// const val BASE_SERVER = "http://192.168.31.190:8088" // const val BASE_SERVER = "http://192.168.31.190:8088"
// const val BASE_SERVER = "http://192.168.31.36: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 = "https://8.137.22.101:8088"
const val MOMENT_LIKE_CHANNEL_ID = "moment_like" const val MOMENT_LIKE_CHANNEL_ID = "moment_like"
const val MOMENT_LIKE_CHANNEL_NAME = "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) { 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) { override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) {

View File

@@ -1,12 +1,43 @@
package com.aiosman.riderpro.data package com.aiosman.riderpro.data
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody
/** /**
* 错误返回 * 错误返回
*/ */
class ServiceException( class ServiceException(
override val message: String, override val message: String,
val code: Int = 0, val code: Int? = 0,
val data: Any? = null val data: Any? = null,
val error: String? = null,
val name: String? = null,
) : Exception( ) : Exception(
message 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 package com.aiosman.riderpro.ui.login
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -20,30 +24,37 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.AppStore import com.aiosman.riderpro.AppStore
import com.aiosman.riderpro.ErrorCode
import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.Messaging import com.aiosman.riderpro.Messaging
import com.aiosman.riderpro.R import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.AccountService import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.ServiceException import com.aiosman.riderpro.data.ServiceException
import com.aiosman.riderpro.data.AccountServiceImpl import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.getErrorMessageCode
import com.aiosman.riderpro.ui.NavigationRoute import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.ActionButton 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.StatusBarMaskLayout
import com.aiosman.riderpro.ui.composables.StatusBarSpacer
import com.aiosman.riderpro.ui.composables.TextInputField import com.aiosman.riderpro.ui.composables.TextInputField
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun EmailSignupScreen() { fun EmailSignupScreen() {
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("takayamaaren@gmail.com") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("Dzh17217.") }
var confirmPassword by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("Dzh17217.") }
var rememberMe by remember { mutableStateOf(false) } var rememberMe by remember { mutableStateOf(false) }
var acceptTerms by remember { mutableStateOf(false) } var acceptTerms by remember { mutableStateOf(false) }
var acceptPromotions by remember { mutableStateOf(false) } var acceptPromotions by remember { mutableStateOf(false) }
@@ -51,32 +62,62 @@ fun EmailSignupScreen() {
val navController = LocalNavController.current val navController = LocalNavController.current
val context = LocalContext.current val context = LocalContext.current
val accountService: AccountService = AccountServiceImpl() 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 { fun validateForm(): Boolean {
if (email.isEmpty()) { emailError = when {
Toast.makeText(context, "Email is required", Toast.LENGTH_SHORT).show() // 非空
return false 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()) { passwordError = when {
Toast.makeText(context, "Password is required", Toast.LENGTH_SHORT).show() // 非空
return false 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()) { confirmPasswordError = when {
Toast.makeText(context, "Confirm password is required", Toast.LENGTH_SHORT).show() // 非空
return false confirmPassword.isEmpty() -> context.getString(R.string.text_error_confirm_password_required)
} // 与密码一致
if (password != confirmPassword) { confirmPassword != password -> context.getString(R.string.text_error_confirm_password_mismatch)
Toast.makeText(context, "Password does not match", Toast.LENGTH_SHORT).show() else -> null
return false
} }
if (!acceptTerms) { 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 return false
}else{
termsError = false
} }
if (!acceptPromotions) { if (!acceptPromotions) {
Toast.makeText(context, "You must accept promotions", Toast.LENGTH_SHORT).show() scope.launch(Dispatchers.Main) {
return false 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() { suspend fun registerUser() {
@@ -86,8 +127,18 @@ fun EmailSignupScreen() {
accountService.registerUserWithPassword(email, password) accountService.registerUserWithPassword(email, password)
} catch (e: ServiceException) { } catch (e: ServiceException) {
scope.launch(Dispatchers.Main) { 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 // 获取 token
val authResp = accountService.loginUserWithPassword(email, password) val authResp = accountService.loginUserWithPassword(email, password)
@@ -115,118 +166,98 @@ fun EmailSignupScreen() {
popUpTo(NavigationRoute.Login.route) { inclusive = true } popUpTo(NavigationRoute.Login.route) { inclusive = true }
} }
} }
} }
StatusBarMaskLayout {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) { ) {
StatusBarSpacer()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp) .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(32.dp))
Spacer(modifier = Modifier.padding(68.dp)) Column(
TextInputField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp), .weight(1f)
.padding(horizontal = 24.dp)
) {
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = email, text = email,
onValueChange = { onValueChange = {
email = it email = it
}, },
label = "What's your email?", label = stringResource(R.string.login_email_label),
hint = "Enter your email" hint = stringResource(R.string.text_hint_email),
error = emailError
) )
Spacer(modifier = Modifier.padding(16.dp)) Spacer(modifier = Modifier.padding(8.dp))
TextInputField( TextInputField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.padding(horizontal = 24.dp),
text = password, text = password,
onValueChange = { onValueChange = {
password = it password = it
}, },
password = true, password = true,
label = "What's your password?", label = stringResource(R.string.login_password_label),
hint = "Enter your password" hint = stringResource(R.string.text_hint_password),
error = passwordError
) )
Spacer(modifier = Modifier.padding(16.dp)) Spacer(modifier = Modifier.padding(8.dp))
TextInputField( TextInputField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.padding(horizontal = 24.dp),
text = confirmPassword, text = confirmPassword,
onValueChange = { onValueChange = {
confirmPassword = it confirmPassword = it
}, },
password = true, password = true,
label = "Confirm password", label = stringResource(R.string.login_confirm_password_label),
hint = "Enter your password" hint = stringResource(R.string.text_hint_confirm_password),
error = confirmPasswordError
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(16.dp))
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
) { ) {
Row( CheckboxWithLabel(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = rememberMe, checked = rememberMe,
onCheckedChange = { checkSize = 16,
fontSize = 12,
label = stringResource(R.string.remember_me),
) {
rememberMe = it rememberMe = it
},
modifier = Modifier.padding(start = 16.dp),
colors = CheckboxDefaults.colors(
checkedColor = Color.Black
),
)
Text("Remember me", modifier = Modifier.padding(start = 4.dp))
} }
Row( Spacer(modifier = Modifier.height(16.dp))
verticalAlignment = Alignment.CenterVertically CheckboxWithLabel(
) {
Checkbox(
checked = acceptTerms, checked = acceptTerms,
onCheckedChange = { checkSize = 16,
acceptTerms = it fontSize = 12,
}, label = stringResource(R.string.agree_terms_of_service),
modifier = Modifier.padding(start = 16.dp), error = termsError
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
) { ) {
Checkbox( acceptTerms = it
}
Spacer(modifier = Modifier.height(16.dp))
CheckboxWithLabel(
checked = acceptPromotions, checked = acceptPromotions,
onCheckedChange = { checkSize = 16,
fontSize = 12,
label = stringResource(R.string.agree_promotion),
error = promotionsError
) {
acceptPromotions = it 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 modifier = Modifier
.width(345.dp) .width(345.dp)
.height(48.dp), .height(48.dp),
text = "LET'S RIDE".uppercase(), text = stringResource(R.string.lets_ride_upper),
backgroundImage = R.mipmap.rider_pro_signup_red_bg backgroundImage = R.mipmap.rider_pro_signup_red_bg
) { ) {
scope.launch(Dispatchers.IO) { 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_email">使用邮箱注册</string>
<string name="sign_in_with_google">使用 Google 账号登录</string> <string name="sign_in_with_google">使用 Google 账号登录</string>
<string name="back_upper">返回</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> </resources>

View File

@@ -32,4 +32,16 @@
<string name="sign_in_with_email">CONTINUE WITH EMAIL</string> <string name="sign_in_with_email">CONTINUE WITH EMAIL</string>
<string name="sign_in_with_google">CONTINUE WITH GOOGLE</string> <string name="sign_in_with_google">CONTINUE WITH GOOGLE</string>
<string name="back_upper">BACK</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> </resources>