add registration page with nickname/phone fields and fix lateinit crashes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user