added a user system
This commit is contained in:
@@ -11,6 +11,7 @@ class OralTrainerSdk private constructor(
|
||||
|
||||
val cache: OralTrainerCache = OralTrainerCache(appContext, config)
|
||||
val sentenceBoundaryApi: SentenceBoundaryApi = SentenceBoundaryApi(appContext, config)
|
||||
val userApi: UserApi = UserApi(appContext, config)
|
||||
val videoCatalogApi: VideoCatalogApi = VideoCatalogApi(appContext, config)
|
||||
private val remoteAssessors = CopyOnWriteArrayList<RemoteImitationQualityAssessor>()
|
||||
|
||||
|
||||
@@ -10,5 +10,6 @@ data class OralTrainerSdkConfig @JvmOverloads constructor(
|
||||
val readTimeoutMs: Int = 30_000,
|
||||
val sentenceBoundaryApiBaseUrl: String = "https://videoservice.d1kt.cn",
|
||||
val assessmentApiKey: String? = null,
|
||||
var userToken: String? = null,
|
||||
val allowHttpFallback: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -89,6 +89,9 @@ class RemoteImitationQualityAssessor internal constructor(
|
||||
config.assessmentApiKey?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("X-Client-Key", it)
|
||||
}
|
||||
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
BufferedOutputStream(connection.outputStream).use { output ->
|
||||
writeFormField(output, boundary, "language", request.locale ?: request.sentence.language.orEmpty())
|
||||
writeFilePart(output, boundary, request.recordingUri)
|
||||
|
||||
@@ -114,6 +114,9 @@ class SentenceBoundaryApi internal constructor(
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
config.userToken?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) {
|
||||
connection.inputStream
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package cn.learningpad.oraltrainer.sdk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class AuthUser(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val nickname: String,
|
||||
)
|
||||
|
||||
data class AuthSession(
|
||||
val token: String,
|
||||
val user: AuthUser,
|
||||
)
|
||||
|
||||
data class Course(
|
||||
val videoHash: String,
|
||||
val title: String,
|
||||
val durationMs: Long?,
|
||||
val language: String?,
|
||||
val sentenceCount: Int,
|
||||
val streamUrl: String,
|
||||
val enrolled: Boolean,
|
||||
) {
|
||||
fun toVideoSummary(): TrainingVideoSummary {
|
||||
return TrainingVideoSummary(
|
||||
videoHash = videoHash,
|
||||
title = title,
|
||||
streamUri = android.net.Uri.parse(streamUrl),
|
||||
durationMs = durationMs,
|
||||
sizeBytes = 0L,
|
||||
language = language,
|
||||
sentenceCount = sentenceCount,
|
||||
status = "ready",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UserResult(
|
||||
val attemptId: String,
|
||||
val videoHash: String,
|
||||
val courseTitle: String,
|
||||
val sentenceIndex: Int,
|
||||
val sentenceText: String,
|
||||
val overallScore: Float,
|
||||
val passed: Boolean,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class UserDubShare(
|
||||
val shareId: String,
|
||||
val videoHash: String,
|
||||
val courseTitle: String,
|
||||
val title: String,
|
||||
val segmentCount: Int,
|
||||
val averageScore: Float,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
class UserApiException(
|
||||
val statusCode: Int,
|
||||
responseBody: String,
|
||||
) : IOException("HTTP $statusCode: ${responseBody.take(300)}")
|
||||
|
||||
class UserApi internal constructor(
|
||||
context: Context,
|
||||
private val config: OralTrainerSdkConfig,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val executor: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val baseUri = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||
|
||||
init {
|
||||
require(baseUri.scheme == "https" || baseUri.scheme == "http") {
|
||||
"sentenceBoundaryApiBaseUrl must use http or https."
|
||||
}
|
||||
}
|
||||
|
||||
fun register(
|
||||
username: String,
|
||||
password: String,
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) { requestBlocking("auth/register", username, password, auth = false) }
|
||||
|
||||
fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) { requestBlocking("auth/login", username, password, auth = false) }
|
||||
|
||||
fun courses(
|
||||
token: String,
|
||||
onComplete: (Result<List<Course>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "courses")) }
|
||||
|
||||
fun enroll(
|
||||
token: String,
|
||||
videoHash: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
request("courses/$videoHash/enroll", token, method = "POST")
|
||||
}
|
||||
|
||||
fun results(
|
||||
token: String,
|
||||
onComplete: (Result<List<UserResult>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "me/results")) }
|
||||
|
||||
fun dubShares(
|
||||
token: String,
|
||||
onComplete: (Result<List<UserDubShare>>) -> Unit,
|
||||
): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "me/dub-shares")) }
|
||||
|
||||
fun release() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun <T> submit(
|
||||
onComplete: (Result<T>) -> Unit,
|
||||
operation: () -> T,
|
||||
): CancellableRequest {
|
||||
val future: Future<*> = executor.submit {
|
||||
post(onComplete, runCatching(operation))
|
||||
}
|
||||
return CancellableRequest(future)
|
||||
}
|
||||
|
||||
private fun <T> submitObject(
|
||||
onComplete: (Result<T>) -> Unit,
|
||||
token: String,
|
||||
operation: (String) -> T,
|
||||
): CancellableRequest = submit(onComplete) { operation(token) }
|
||||
|
||||
private fun submitUnit(
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
token: String,
|
||||
operation: (String) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
operation(token)
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun <T> post(onComplete: (Result<T>) -> Unit, result: Result<T>) {
|
||||
if (!Thread.currentThread().isInterrupted) {
|
||||
mainHandler.post { onComplete(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBlocking(path: String, username: String, password: String, auth: Boolean): AuthSession {
|
||||
val root = JSONObject().put("username", username).put("password", password)
|
||||
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)
|
||||
}
|
||||
|
||||
private fun parseCourses(items: JSONArray): List<Course> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
Course(
|
||||
videoHash = item.getString("video_hash"),
|
||||
title = item.getString("title"),
|
||||
durationMs = if (item.isNull("duration_ms")) null else item.getLong("duration_ms"),
|
||||
language = if (item.isNull("language")) null else item.getString("language"),
|
||||
sentenceCount = item.getInt("sentence_count"),
|
||||
streamUrl = item.getString("stream_url"),
|
||||
enrolled = item.getBoolean("enrolled"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseResults(items: JSONArray): List<UserResult> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
UserResult(
|
||||
attemptId = item.getString("attempt_id"),
|
||||
videoHash = item.getString("video_hash"),
|
||||
courseTitle = item.getString("course_title"),
|
||||
sentenceIndex = item.getInt("sentence_index"),
|
||||
sentenceText = item.getString("sentence_text"),
|
||||
overallScore = item.getDouble("overall_score").toFloat(),
|
||||
passed = item.getBoolean("passed"),
|
||||
createdAt = item.getString("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDubShares(items: JSONArray): List<UserDubShare> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
UserDubShare(
|
||||
shareId = item.getString("share_id"),
|
||||
videoHash = item.getString("video_hash"),
|
||||
courseTitle = item.getString("course_title"),
|
||||
title = item.getString("title"),
|
||||
segmentCount = item.getInt("segment_count"),
|
||||
averageScore = item.getDouble("average_score").toFloat(),
|
||||
createdAt = item.getString("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
path: String,
|
||||
token: String? = null,
|
||||
body: String? = null,
|
||||
method: String = body?.let { "POST" } ?: "GET",
|
||||
): String {
|
||||
val endpoint = android.net.Uri.parse(config.sentenceBoundaryApiBaseUrl.trimEnd('/'))
|
||||
.buildUpon().appendEncodedPath(path).build()
|
||||
val connection = URL(endpoint.toString()).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = config.connectTimeoutMs
|
||||
connection.readTimeout = config.readTimeoutMs
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("User-Agent", config.userAgent)
|
||||
token?.takeIf { it.isNotBlank() }?.let {
|
||||
connection.setRequestProperty("Authorization", "Bearer $it")
|
||||
}
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw UserApiException(statusCode, text)
|
||||
return text
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSession(root: JSONObject): AuthSession {
|
||||
val user = root.getJSONObject("user")
|
||||
return AuthSession(
|
||||
token = root.getString("token"),
|
||||
user = AuthUser(
|
||||
id = user.getString("id"),
|
||||
username = user.getString("username"),
|
||||
nickname = user.getString("nickname"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user