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( fun register(
username: String, username: String,
password: String, password: String,
nickname: String? = null,
phone: String? = null,
onComplete: (Result<AuthSession>) -> Unit, 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( fun login(
username: String, username: String,
password: String, password: String,
onComplete: (Result<AuthSession>) -> Unit, 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( fun courses(
token: String, token: String,
onComplete: (Result<List<Course>>) -> Unit, 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( fun enroll(
token: String, token: String,
videoHash: String, videoHash: String,
onComplete: (Result<Unit>) -> Unit, onComplete: (Result<Unit>) -> Unit,
): CancellableRequest = submitUnit(onComplete, token) { ): CancellableRequest = submitUnit(onComplete, token) {
request("courses/$videoHash/enroll", token, method = "POST") request("api/v1/courses/$videoHash/enroll", token, method = "POST")
} }
fun results( fun results(
token: String, token: String,
onComplete: (Result<List<UserResult>>) -> Unit, 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( fun dubShares(
token: String, token: String,
onComplete: (Result<List<UserDubShare>>) -> Unit, 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() { fun release() {
executor.shutdownNow() executor.shutdownNow()
@@ -163,6 +173,22 @@ class UserApi internal constructor(
return parseSession(JSONObject(response)) 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 { private fun getBlocking(token: String, path: String): JSONArray {
val body = request(path, token = token, method = "GET") val body = request(path, token = token, method = "GET")
return JSONArray(body) return JSONArray(body)

View File

@@ -115,6 +115,12 @@ class MainActivity : Activity() {
private var authUsernameInput: EditText? = null private var authUsernameInput: EditText? = null
private var authPasswordInput: EditText? = null private var authPasswordInput: EditText? = null
private var authStatusText: TextView? = 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 myContentList: LinearLayout? = null
private var sentenceBoundaryRequestGeneration = 0 private var sentenceBoundaryRequestGeneration = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList() private var catalogVideos: List<TrainingVideoSummary> = emptyList()
@@ -320,7 +326,13 @@ class MainActivity : Activity() {
} }
private fun setRootView() { private fun setRootView() {
setContentView(if (isLoggedIn()) createContentView() else createAuthView()) setContentView(
when {
isLoggedIn() -> createContentView()
showingRegisterPage -> createRegisterView()
else -> createAuthView()
}
)
if (isLoggedIn()) { if (isLoggedIn()) {
renderCatalog() renderCatalog()
refreshCurrentUi() refreshCurrentUi()
@@ -375,13 +387,137 @@ class MainActivity : Activity() {
}) })
addView(Button(this@MainActivity).apply { addView(Button(this@MainActivity).apply {
text = "注册" text = "注册"
setOnClickListener { authenticate(register = true) } setOnClickListener {
showingRegisterPage = true
setRootView()
}
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
}) })
addView(authStatusText) 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) { private fun authenticate(register: Boolean) {
val username = authUsernameInput?.text?.toString()?.trim().orEmpty() val username = authUsernameInput?.text?.toString()?.trim().orEmpty()
val password = authPasswordInput?.text?.toString().orEmpty() val password = authPasswordInput?.text?.toString().orEmpty()
@@ -392,7 +528,7 @@ class MainActivity : Activity() {
onFailure = { error -> authStatusText?.text = "失败:${error.message}" }, 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) else sdk.userApi.login(username, password, callback)
} }
@@ -1473,6 +1609,7 @@ class MainActivity : Activity() {
} }
override fun onMediaChanged(item: TrainingMediaItem?) { override fun onMediaChanged(item: TrainingMediaItem?) {
if (!::lessonTitleText.isInitialized) return
lessonTitleText.text = item?.title ?: "未选择课程" lessonTitleText.text = item?.title ?: "未选择课程"
currentSentenceCount = item?.sentences?.size ?: 0 currentSentenceCount = item?.sentences?.size ?: 0
} }
@@ -1482,6 +1619,7 @@ class MainActivity : Activity() {
} }
override fun onGesture(event: GestureEvent) { override fun onGesture(event: GestureEvent) {
if (!::statusText.isInitialized) return
statusText.text = when (event.kind) { statusText.text = when (event.kind) {
GestureKind.SINGLE_TAP -> "播放状态已切换" GestureKind.SINGLE_TAP -> "播放状态已切换"
GestureKind.SWIPE_LEFT -> "已跳到上一句" GestureKind.SWIPE_LEFT -> "已跳到上一句"
@@ -1491,12 +1629,18 @@ class MainActivity : Activity() {
} }
override fun onPlayerError(error: Throwable) { override fun onPlayerError(error: Throwable) {
if (!::statusText.isInitialized) return
statusText.text = "播放失败:${error.message.orEmpty()}" statusText.text = "播放失败:${error.message.orEmpty()}"
} }
} }
} }
private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) { private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) {
if (!::timeText.isInitialized || !::progressBar.isInitialized ||
!::speedText.isInitialized || !::statusText.isInitialized
) {
return
}
if (!progressBarDragging) { if (!progressBarDragging) {
timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}" timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
progressBar.progress = playbackProgress(snapshot) progressBar.progress = playbackProgress(snapshot)
@@ -1514,6 +1658,11 @@ class MainActivity : Activity() {
} }
private fun applySentence(sentence: SentenceBoundary?) { private fun applySentence(sentence: SentenceBoundary?) {
if (!::sentenceMetaText.isInitialized || !::sentenceText.isInitialized ||
!::lessonTitleText.isInitialized
) {
return
}
if (sentence == null) { if (sentence == null) {
sentenceMetaText.text = "暂无句子边界" sentenceMetaText.text = "暂无句子边界"
sentenceText.text = lessonTitleText.text sentenceText.text = lessonTitleText.text
@@ -1606,6 +1755,11 @@ class MainActivity : Activity() {
} }
private fun createCourseSection(): View { private fun createCourseSection(): View {
if (!::catalogList.isInitialized) {
catalogList = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
}
}
return ScrollView(this).apply { return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply { addView(LinearLayout(this@MainActivity).apply {

View File

@@ -165,7 +165,8 @@ def create_app(
user = video_repository.create_user( user = video_repository.create_user(
username=payload.username, username=payload.username,
password=payload.password, password=payload.password,
nickname=payload.username, nickname=payload.nickname or payload.username,
phone=payload.phone,
) )
if user is None: if user is None:
raise HTTPException(status_code=409, detail="Username is already taken.") 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_.-]+$") username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=128) 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): class UserPublic(BaseModel):
id: str id: str
username: str username: str
nickname: str nickname: str
phone: Optional[str] = None
created_at: datetime created_at: datetime

View File

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