added a user system

This commit is contained in:
2026-08-30 17:07:55 +08:00
parent 0b85a2ed57
commit 637101bcdb
13 changed files with 1082 additions and 20 deletions

View File

@@ -32,16 +32,19 @@ import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.TranslateAnimation
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.AuthSession
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
import cn.learningpad.oraltrainer.sdk.LoopMode
@@ -107,8 +110,15 @@ class MainActivity : Activity() {
private var activeItemId: String = ""
private var currentSentenceCount = 0
private var authToken: String = ""
private var authNickname: String = ""
private var authUsernameInput: EditText? = null
private var authPasswordInput: EditText? = null
private var authStatusText: TextView? = null
private var myContentList: LinearLayout? = null
private var sentenceBoundaryRequestGeneration = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
private val courseEnrollment = mutableMapOf<String, Boolean>()
private var catalogStatusTextValue = "正在同步"
private var activeModule = Module.TRAIN
private var continuousPlaybackEnabled = false
@@ -131,11 +141,16 @@ class MainActivity : Activity() {
super.onCreate(savedInstanceState)
configureWindow()
val authPreferences = getSharedPreferences("oral_trainer_auth", MODE_PRIVATE)
authToken = authPreferences.getString("token", "").orEmpty()
authNickname = authPreferences.getString("nickname", "").orEmpty()
sdk = OralTrainerSdk.init(
this,
OralTrainerSdkConfig(
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
assessmentApiKey = ASSESSMENT_API_KEY,
userToken = authToken.takeIf { it.isNotBlank() },
allowHttpFallback = true,
)
)
@@ -152,9 +167,9 @@ class MainActivity : Activity() {
controllerListener = createControllerListener()
controller.addListener(controllerListener)
setContentView(createContentView())
setRootView()
loadCatalog()
refreshCurrentUi()
if (isLoggedIn()) refreshCurrentUi()
registerReceiver(
screenActionReceiver,
@@ -164,7 +179,7 @@ class MainActivity : Activity() {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setContentView(createContentView())
setRootView()
renderCatalog()
refreshCurrentUi()
if (activeModule == Module.TEST) {
@@ -281,6 +296,22 @@ class MainActivity : Activity() {
private fun createContentView(): View {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
if (activeModule == Module.COURSE) {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createModuleTabBar())
addView(createCourseSection())
}
}
if (activeModule == Module.MINE) {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createModuleTabBar())
addView(createMySection())
}
}
return if (isLandscape) {
createLandscapeContentView()
} else {
@@ -288,6 +319,106 @@ class MainActivity : Activity() {
}
}
private fun setRootView() {
setContentView(if (isLoggedIn()) createContentView() else createAuthView())
if (isLoggedIn()) {
renderCatalog()
refreshCurrentUi()
}
}
private fun isLoggedIn(): Boolean = authToken.isNotBlank()
private fun createAuthView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(COLOR_BACKGROUND)
setPadding(28.dp, 28.dp, 28.dp, 28.dp)
addView(TextView(this@MainActivity).apply {
text = "八哥口语"
textSize = 32f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(TextView(this@MainActivity).apply {
text = "请先注册或登录后开始学习"
textSize = 15f
setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 6.dp, 0, 24.dp)
})
authUsernameInput = EditText(this@MainActivity).apply {
hint = "用户名"
setSingleLine(true)
}
authPasswordInput = EditText(this@MainActivity).apply {
hint = "密码(至少 8 位)"
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
setSingleLine(true)
}
authStatusText = TextView(this@MainActivity).apply {
setTextColor(COLOR_ACCENT_LIGHT)
textSize = 13f
setPadding(0, 14.dp, 0, 0)
}
addView(authUsernameInput)
addView(authPasswordInput)
addView(Button(this@MainActivity).apply {
text = "登录"
background = rounded(COLOR_ACCENT, 8f)
setTextColor(Color.WHITE)
setOnClickListener { authenticate(register = false) }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 20.dp, 0, 8.dp)
})
addView(Button(this@MainActivity).apply {
text = "注册"
setOnClickListener { authenticate(register = true) }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
})
addView(authStatusText)
}
}
private fun authenticate(register: Boolean) {
val username = authUsernameInput?.text?.toString()?.trim().orEmpty()
val password = authPasswordInput?.text?.toString().orEmpty()
authStatusText?.text = if (register) "注册中..." else "登录中..."
val callback = { result: Result<AuthSession> ->
result.fold(
onSuccess = ::saveAuthSession,
onFailure = { error -> authStatusText?.text = "失败:${error.message}" },
)
}
if (register) sdk.userApi.register(username, password, callback)
else sdk.userApi.login(username, password, callback)
}
private fun saveAuthSession(session: AuthSession) {
authToken = session.token
authNickname = session.user.nickname
sdk.config.userToken = session.token
getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit()
.putString("token", session.token)
.putString("nickname", session.user.nickname)
.apply()
activeModule = Module.COURSE
setRootView()
loadCatalog()
}
private fun logout() {
getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit().clear().apply()
authToken = ""
authNickname = ""
sdk.config.userToken = null
controller.pause()
activeModule = Module.TRAIN
setRootView()
}
private fun createPortraitContentView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -322,8 +453,10 @@ class MainActivity : Activity() {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16.dp, 40.dp, 16.dp, 0)
addView(moduleTab("课程", Module.COURSE))
addView(moduleTab("训练", Module.TRAIN))
addView(moduleTab("测试", Module.TEST))
addView(moduleTab("我的", Module.MINE))
}
}
@@ -343,10 +476,13 @@ class MainActivity : Activity() {
}
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
setOnClickListener {
if (activeModule == module) {
return@setOnClickListener
}
activeModule = module
if (activeModule == module) {
return@setOnClickListener
}
if (module == Module.COURSE || module == Module.MINE) {
loadUserData()
}
activeModule = module
controller.setContinuousPlayback(
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false
)
@@ -1404,10 +1540,36 @@ class MainActivity : Activity() {
}
private fun loadCatalog() {
if (!isLoggedIn()) return
catalogStatusTextValue = "正在同步"
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = catalogStatusTextValue
}
if (::catalogList.isInitialized) {
catalogList.removeAllViews()
catalogList.addView(infoCard("课程加载中..."))
}
sdk.userApi.courses(authToken) { result ->
result.fold(
onSuccess = { courses ->
catalogVideos = courses.map { it.toVideoSummary() }
courseEnrollment.clear()
courses.forEach { courseEnrollment[it.videoHash] = it.enrolled }
catalogStatusTextValue = "${catalogVideos.size} 个课程"
if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
renderCatalog()
},
onFailure = {
catalogStatusTextValue = "课程加载失败:${it.message}"
if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
courseEnrollment.clear()
renderCatalog()
},
)
}
}
private fun loadLegacyCatalog() {
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
override fun onSuccess(videos: List<TrainingVideoSummary>) {
catalogVideos = videos
@@ -1435,24 +1597,168 @@ class MainActivity : Activity() {
})
}
private fun loadUserData() {
when (activeModule) {
Module.COURSE -> loadCatalog()
Module.MINE -> refreshMyContent()
else -> Unit
}
}
private fun createCourseSection(): View {
return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
addView(TextView(this@MainActivity).apply {
text = "课程首页"
textSize = 26f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(TextView(this@MainActivity).apply {
text = "选择一门课程开始训练"
textSize = 13f
setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 4.dp, 0, 16.dp)
})
addView(catalogList)
})
}
}
private fun createMySection(): View {
myContentList = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
}
return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
addView(TextView(this@MainActivity).apply {
text = authNickname.ifBlank { "我的学习" }
textSize = 26f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(Button(this@MainActivity).apply {
text = "退出登录"
setOnClickListener { logout() }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 12.dp, 0, 20.dp)
})
addView(TextView(this@MainActivity).apply {
text = "我的测试结果"
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(myContentList)
})
}
}
private fun refreshMyContent() {
val list = myContentList ?: return
list.removeAllViews()
list.addView(infoCard("加载中..."))
sdk.userApi.results(authToken) { result ->
list.removeAllViews()
result.fold(
onSuccess = { rows ->
if (rows.isEmpty()) list.addView(infoCard("还没有测试记录"))
rows.forEach { row ->
list.addView(infoCard(
"${row.courseTitle} · 第 ${row.sentenceIndex + 1}\n" +
"得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}"
))
}
},
onFailure = { list.addView(infoCard("测试记录加载失败:${it.message}")) },
)
sdk.userApi.dubShares(authToken) { shareResult ->
list.post {
list.addView(titleDivider("我的配音分享"))
shareResult.fold(
onSuccess = { shares ->
if (shares.isEmpty()) list.addView(infoCard("还没有分享配音"))
shares.forEach { row ->
list.addView(infoCard(
"${row.title}\n${row.courseTitle} · ${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()}"
))
}
},
onFailure = { list.addView(infoCard("配音分享加载失败:${it.message}")) },
)
}
}
}
}
private fun infoCard(text: String): TextView {
return TextView(this).apply {
this.text = text
setTextColor(COLOR_TEXT_DARK)
textSize = 13f
background = rounded(COLOR_SELECTED, 8f)
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 8.dp)
}
}
private fun titleDivider(text: String): TextView {
return TextView(this).apply {
this.text = text
setTextColor(Color.WHITE)
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 20.dp, 0, 8.dp)
}
}
private fun renderCatalog() {
if (!::catalogList.isInitialized) {
return
}
catalogList.removeAllViews()
catalogVideos.forEach { video ->
val enrolled = courseEnrollment[video.videoHash] == true
catalogList.addView(
videoCard(
id = video.videoHash,
title = video.title,
meta = "${video.sentenceCount} 句 · ${formatTime(video.durationMs ?: -1L)}",
meta = if (enrolled) {
"已选修 · ${video.sentenceCount}"
} else {
"未选修 · 点击选修"
},
) {
loadRemoteVideo(video)
if (enrolled) loadRemoteVideo(video) else enrollCourse(video)
}
)
}
}
private fun enrollCourse(video: TrainingVideoSummary) {
sdk.userApi.enroll(authToken, video.videoHash) { result ->
result.fold(
onSuccess = {
courseEnrollment[video.videoHash] = true
renderCatalog()
},
onFailure = {
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = "选修失败:${it.message}"
}
},
)
}
}
private fun loadRemoteVideo(video: TrainingVideoSummary) {
activeItemId = video.videoHash
lessonTitleText.text = video.title
@@ -1668,8 +1974,10 @@ class MainActivity : Activity() {
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
enum class Module {
COURSE,
TRAIN,
TEST,
MINE,
}
val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)