add registration page with nickname/phone fields and fix lateinit crashes

This commit is contained in:
2026-08-31 15:06:43 +08:00
parent f2042e638f
commit 4ae77de307
5 changed files with 215 additions and 14 deletions

View File

@@ -90,37 +90,47 @@ class UserApi internal constructor(
fun register(
username: String,
password: String,
nickname: String? = null,
phone: String? = null,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { requestBlocking("auth/register", username, password, auth = false) }
): CancellableRequest = submit(onComplete) {
requestRegisterBlocking(
"api/v1/auth/register",
username = username,
password = password,
nickname = nickname,
phone = phone,
)
}
fun login(
username: String,
password: String,
onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { requestBlocking("auth/login", username, password, auth = false) }
): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) }
fun courses(
token: String,
onComplete: (Result<List<Course>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "courses")) }
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "api/v1/courses")) }
fun enroll(
token: String,
videoHash: String,
onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) {
request("courses/$videoHash/enroll", token, method = "POST")
request("api/v1/courses/$videoHash/enroll", token, method = "POST")
}
fun results(
token: String,
onComplete: (Result<List<UserResult>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "me/results")) }
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "api/v1/me/results")) }
fun dubShares(
token: String,
onComplete: (Result<List<UserDubShare>>) -> Unit,
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "me/dub-shares")) }
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) }
fun release() {
executor.shutdownNow()
@@ -163,6 +173,22 @@ class UserApi internal constructor(
return parseSession(JSONObject(response))
}
private fun requestRegisterBlocking(
path: String,
username: String,
password: String,
nickname: String?,
phone: String?,
): AuthSession {
val root = JSONObject()
.put("username", username)
.put("password", password)
nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) }
phone?.takeIf { it.isNotBlank() }?.let { root.put("phone", it) }
val response = request(path, body = root.toString(), method = "POST")
return parseSession(JSONObject(response))
}
private fun getBlocking(token: String, path: String): JSONArray {
val body = request(path, token = token, method = "GET")
return JSONArray(body)

View File

@@ -115,6 +115,12 @@ class MainActivity : Activity() {
private var authUsernameInput: EditText? = null
private var authPasswordInput: EditText? = null
private var authStatusText: TextView? = null
private var regUsernameInput: EditText? = null
private var regPasswordInput: EditText? = null
private var regNicknameInput: EditText? = null
private var regPhoneInput: EditText? = null
private var regStatusText: TextView? = null
private var showingRegisterPage = false
private var myContentList: LinearLayout? = null
private var sentenceBoundaryRequestGeneration = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
@@ -320,7 +326,13 @@ class MainActivity : Activity() {
}
private fun setRootView() {
setContentView(if (isLoggedIn()) createContentView() else createAuthView())
setContentView(
when {
isLoggedIn() -> createContentView()
showingRegisterPage -> createRegisterView()
else -> createAuthView()
}
)
if (isLoggedIn()) {
renderCatalog()
refreshCurrentUi()
@@ -375,13 +387,137 @@ class MainActivity : Activity() {
})
addView(Button(this@MainActivity).apply {
text = "注册"
setOnClickListener { authenticate(register = true) }
setOnClickListener {
showingRegisterPage = true
setRootView()
}
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
})
addView(authStatusText)
}
}
private fun createRegisterView(): View {
return ScrollView(this).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
setBackgroundColor(COLOR_BACKGROUND)
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
addView(TextView(this@MainActivity).apply {
text = "注册账号"
textSize = 28f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(TextView(this@MainActivity).apply {
text = "填写以下信息完成注册"
textSize = 14f
setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 6.dp, 0, 24.dp)
})
addView(fieldLabel("用户名"))
regUsernameInput = EditText(this@MainActivity).apply {
hint = "3-50 位字母、数字或下划线"
setSingleLine(true)
}
addView(regUsernameInput)
addView(fieldLabel("密码"))
regPasswordInput = EditText(this@MainActivity).apply {
hint = "至少 8 位"
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
setSingleLine(true)
}
addView(regPasswordInput)
addView(fieldLabel("姓名"))
regNicknameInput = EditText(this@MainActivity).apply {
hint = "你的真实姓名或昵称"
setSingleLine(true)
}
addView(regNicknameInput)
addView(fieldLabel("手机号"))
regPhoneInput = EditText(this@MainActivity).apply {
hint = "选填"
inputType = android.text.InputType.TYPE_CLASS_PHONE
setSingleLine(true)
}
addView(regPhoneInput)
regStatusText = TextView(this@MainActivity).apply {
setTextColor(COLOR_ACCENT_LIGHT)
textSize = 13f
setPadding(0, 14.dp, 0, 0)
}
addView(Button(this@MainActivity).apply {
text = "注册"
background = rounded(COLOR_ACCENT, 8f)
setTextColor(Color.WHITE)
setOnClickListener { submitRegistration() }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 8.dp)
})
addView(Button(this@MainActivity).apply {
text = "返回登录"
setOnClickListener {
showingRegisterPage = false
regStatusText = null
setRootView()
}
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
})
addView(regStatusText)
})
}
}
private fun fieldLabel(label: String): TextView {
return TextView(this).apply {
text = label
textSize = 13f
setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 12.dp, 0, 4.dp)
}
}
private fun submitRegistration() {
val username = regUsernameInput?.text?.toString()?.trim().orEmpty()
val password = regPasswordInput?.text?.toString().orEmpty()
val nickname = regNicknameInput?.text?.toString()?.trim().orEmpty()
val phone = regPhoneInput?.text?.toString()?.trim().orEmpty()
if (username.length < 3) {
regStatusText?.text = "用户名至少 3 个字符"
return
}
if (password.length < 8) {
regStatusText?.text = "密码至少 8 位"
return
}
if (nickname.isBlank()) {
regStatusText?.text = "请填写姓名"
return
}
regStatusText?.text = "注册中..."
sdk.userApi.register(username, password, nickname, phone) { result ->
result.fold(
onSuccess = { session ->
showingRegisterPage = false
saveAuthSession(session)
},
onFailure = { error ->
regStatusText?.text = "注册失败:${error.message}"
},
)
}
}
private fun authenticate(register: Boolean) {
val username = authUsernameInput?.text?.toString()?.trim().orEmpty()
val password = authPasswordInput?.text?.toString().orEmpty()
@@ -392,7 +528,7 @@ class MainActivity : Activity() {
onFailure = { error -> authStatusText?.text = "失败:${error.message}" },
)
}
if (register) sdk.userApi.register(username, password, callback)
if (register) sdk.userApi.register(username, password, onComplete = callback)
else sdk.userApi.login(username, password, callback)
}
@@ -1473,6 +1609,7 @@ class MainActivity : Activity() {
}
override fun onMediaChanged(item: TrainingMediaItem?) {
if (!::lessonTitleText.isInitialized) return
lessonTitleText.text = item?.title ?: "未选择课程"
currentSentenceCount = item?.sentences?.size ?: 0
}
@@ -1482,6 +1619,7 @@ class MainActivity : Activity() {
}
override fun onGesture(event: GestureEvent) {
if (!::statusText.isInitialized) return
statusText.text = when (event.kind) {
GestureKind.SINGLE_TAP -> "播放状态已切换"
GestureKind.SWIPE_LEFT -> "已跳到上一句"
@@ -1491,12 +1629,18 @@ class MainActivity : Activity() {
}
override fun onPlayerError(error: Throwable) {
if (!::statusText.isInitialized) return
statusText.text = "播放失败:${error.message.orEmpty()}"
}
}
}
private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) {
if (!::timeText.isInitialized || !::progressBar.isInitialized ||
!::speedText.isInitialized || !::statusText.isInitialized
) {
return
}
if (!progressBarDragging) {
timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
progressBar.progress = playbackProgress(snapshot)
@@ -1514,6 +1658,11 @@ class MainActivity : Activity() {
}
private fun applySentence(sentence: SentenceBoundary?) {
if (!::sentenceMetaText.isInitialized || !::sentenceText.isInitialized ||
!::lessonTitleText.isInitialized
) {
return
}
if (sentence == null) {
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = lessonTitleText.text
@@ -1606,6 +1755,11 @@ class MainActivity : Activity() {
}
private fun createCourseSection(): View {
if (!::catalogList.isInitialized) {
catalogList = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
}
}
return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply {

View File

@@ -165,7 +165,8 @@ def create_app(
user = video_repository.create_user(
username=payload.username,
password=payload.password,
nickname=payload.username,
nickname=payload.nickname or payload.username,
phone=payload.phone,
)
if user is None:
raise HTTPException(status_code=409, detail="Username is already taken.")

View File

@@ -138,12 +138,15 @@ class AuthRequest(BaseModel):
username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=128)
nickname: Optional[str] = Field(default=None, max_length=80)
phone: Optional[str] = Field(default=None, max_length=20)
class UserPublic(BaseModel):
id: str
username: str
nickname: str
phone: Optional[str] = None
created_at: datetime

View File

@@ -75,6 +75,7 @@ class VideoRepository:
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
nickname TEXT NOT NULL,
phone TEXT,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
@@ -147,6 +148,14 @@ class VideoRepository:
}
if "user_id" not in attempt_columns:
connection.execute("ALTER TABLE attempts ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
with self._connect() as connection:
user_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(users)")
}
if "phone" not in user_columns:
connection.execute("ALTER TABLE users ADD COLUMN phone TEXT")
connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)")
with self._connect() as connection:
@@ -750,20 +759,28 @@ class VideoRepository:
),
)
def create_user(self, *, username: str, password: str, nickname: Optional[str]) -> Optional[Dict[str, Any]]:
def create_user(
self,
*,
username: str,
password: str,
nickname: Optional[str] = None,
phone: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
user_id = uuid.uuid4().hex
now = utc_now()
try:
with self._connect() as connection:
row = connection.execute(
"""
INSERT INTO users (id, username, nickname, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO users (id, username, nickname, phone, password_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
user_id,
username.strip().lower(),
(nickname or username).strip()[:80],
(phone or "").strip()[:20] or None,
hash_password(password),
now,
),
@@ -783,7 +800,7 @@ class VideoRepository:
def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection:
row = connection.execute(
"SELECT id, username, nickname, created_at FROM users WHERE id = ?",
"SELECT id, username, nickname, phone, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return dict(row) if row else None