fixed some bugs

This commit is contained in:
2026-10-26 20:23:57 +08:00
parent ed8df744f9
commit 03e7987c7c
6 changed files with 245 additions and 126 deletions

View File

@@ -37,6 +37,7 @@ kotlin {
dependencies { dependencies {
implementation(project(":oral-trainer-sdk")) implementation(project(":oral-trainer-sdk"))
implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.media3:media3-common:1.11.0") implementation("androidx.media3:media3-common:1.11.0")
implementation("androidx.media3:media3-exoplayer:1.11.0") implementation("androidx.media3:media3-exoplayer:1.11.0")
} }

View File

@@ -263,6 +263,9 @@ class MainActivity : Activity() {
if (screenState.activeModule == Module.MINE) { if (screenState.activeModule == Module.MINE) {
screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty()) screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty())
} }
if (screenState.activeModule == Module.READING) {
screenFactory.scrollToReadingCurrentSentence()
}
} }
private fun refreshCurrentUi() { private fun refreshCurrentUi() {
@@ -552,6 +555,17 @@ class MainActivity : Activity() {
return sentenceFavoriteStore.toggle(favoriteUserKey(), item.id, sentence.index) return sentenceFavoriteStore.toggle(favoriteUserKey(), item.id, sentence.index)
} }
override fun onSentenceDoubleTapped(sentence: SentenceBoundary) {
screenState.continuousPlaybackEnabled = false
switchModule(Module.TRAIN)
controller.setContinuousPlayback(false)
controller.seekTo(sentence.startMs)
controller.play()
if (bindings.hasPlayerChrome) {
bindings.statusText.text = "已定位到第 ${sentence.index + 1} 句,单句循环播放中"
}
}
override fun onExplainCurrentSentence() { override fun onExplainCurrentSentence() {
val sentence = controller.currentSentence() ?: return val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return if (sentence.text.isNullOrBlank()) return

View File

@@ -29,6 +29,7 @@ internal interface ReadingScreenActions {
fun onReadingTranslationVisible(visible: Boolean) fun onReadingTranslationVisible(visible: Boolean)
fun isSentenceFavorite(sentence: SentenceBoundary): Boolean fun isSentenceFavorite(sentence: SentenceBoundary): Boolean
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
fun onSentenceDoubleTapped(sentence: SentenceBoundary)
} }
internal interface TestScreenActions : internal interface TestScreenActions :

View File

@@ -85,6 +85,12 @@ internal class SampleScreenFactory(
} }
} }
fun scrollToReadingCurrentSentence() {
if (screenState.activeModule == Module.READING) {
readingScreen.scrollToCurrentSentence()
}
}
fun renderCatalog() { fun renderCatalog() {
if (screenState.activeModule == Module.COURSE) { if (screenState.activeModule == Module.COURSE) {
courseScreen.renderCatalog() courseScreen.renderCatalog()

View File

@@ -19,6 +19,7 @@ internal object SampleTheme {
val textDark = Color.rgb(18, 24, 31) val textDark = Color.rgb(18, 24, 31)
val textMuted = Color.rgb(151, 162, 174) val textMuted = Color.rgb(151, 162, 174)
val textSubtle = Color.rgb(88, 98, 108) val textSubtle = Color.rgb(88, 98, 108)
val readingTranslation = Color.rgb(147, 197, 253)
val danger = Color.rgb(220, 38, 38) val danger = Color.rgb(220, 38, 38)
val textOverlay = Color.rgb(163, 172, 182) val textOverlay = Color.rgb(163, 172, 182)
} }

View File

@@ -3,14 +3,16 @@ package cn.learningpad.oraltrainer.sample.ui
import android.app.Activity import android.app.Activity
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.os.SystemClock
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Switch import android.widget.Switch
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import cn.learningpad.oraltrainer.sample.ReadingScreenActions import cn.learningpad.oraltrainer.sample.ReadingScreenActions
import cn.learningpad.oraltrainer.sample.SampleTheme import cn.learningpad.oraltrainer.sample.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainingScreenState import cn.learningpad.oraltrainer.sample.TrainingScreenState
@@ -18,7 +20,7 @@ import cn.learningpad.oraltrainer.sample.UiStyled
import cn.learningpad.oraltrainer.sdk.OralTrainerController import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.SentenceBoundary import cn.learningpad.oraltrainer.sdk.SentenceBoundary
/** Scrollable full-text reading view for the selected course. */ /** Lazy-loading full-text reading view for the selected course. */
internal class ReadingScreen( internal class ReadingScreen(
private val activity: Activity, private val activity: Activity,
private val screenState: TrainingScreenState, private val screenState: TrainingScreenState,
@@ -28,90 +30,81 @@ internal class ReadingScreen(
) : UiStyled { ) : UiStyled {
override val uiContext get() = activity override val uiContext get() = activity
private data class SentenceRow( private var readingAdapter: ReadingSentenceAdapter? = null
val translation: TextView,
val favoriteButton: Button,
)
private val sentenceRows = mutableMapOf<Int, SentenceRow>()
private var sentenceList: LinearLayout? = null
private var titleText: TextView? = null private var titleText: TextView? = null
private var sentenceListView: RecyclerView? = null
private var lastClickedSentenceIndex = Int.MIN_VALUE
private var lastClickedAtMs = 0L
fun createSection(): View { fun createSection(): View {
val content = LinearLayout(activity).apply { val root = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp) setPadding(16.dp, 12.dp, 16.dp, 16.dp)
} }
val list = LinearLayout(activity).apply { val adapter = ReadingSentenceAdapter()
orientation = LinearLayout.VERTICAL readingAdapter = adapter
} val list = RecyclerView(activity).apply {
sentenceList = list layoutManager = LinearLayoutManager(activity)
this.adapter = adapter
val title = TextView(activity).apply { itemAnimator = null
setTextColor(Color.WHITE) setItemViewCacheSize(12)
textSize = 24f clipToPadding = false
typeface = Typeface.DEFAULT_BOLD setPadding(0, 0, 0, 16.dp)
setPadding(0, 0, 0, 6.dp)
}
titleText = title
content.addView(title)
content.addView(createTranslationToggle())
content.addView(list)
renderSentences()
return ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
0, 0,
1f, 1f,
) )
addView(content)
} }
sentenceListView = list
val title = TextView(activity).apply {
setTextColor(Color.WHITE)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 0, 0, 6.dp)
}
titleText = title
root.addView(title)
root.addView(createTranslationToggle())
root.addView(list)
renderSentences()
return root
} }
fun renderSentences() { fun renderSentences() {
val list = sentenceList ?: return
val item = controller.currentTrainingItem() val item = controller.currentTrainingItem()
titleText?.text = screenState.currentCourse?.title.orEmpty() val baseTitle = screenState.currentCourse?.title.orEmpty()
.ifBlank { item?.title ?: "阅读模式" } .ifBlank { item?.title ?: "阅读模式" }
sentenceRows.clear() val sentences = item?.sentences.orEmpty()
list.removeAllViews() titleText?.text = if (sentences.isEmpty()) {
if (item == null || item.sentences.isEmpty()) { baseTitle
list.addView(widgets.infoCard("当前课程还没有可阅读的句子文本")) } else {
return "$baseTitle · ${sentences.size} 句"
}
val currentTitle = titleText?.text?.toString().orEmpty()
titleText?.text = "$currentTitle · ${item.sentences.size} 句"
item.sentences.forEach { sentence ->
list.addView(sentenceRow(sentence))
} }
readingAdapter?.submitSentences(sentences)
} }
fun refreshTranslations() { fun refreshTranslations() {
val item = controller.currentTrainingItem() ?: return readingAdapter?.notifyDataSetChanged()
item.sentences.forEach { sentence ->
val row = sentenceRows[sentence.index] ?: return@forEach
row.translation.visibility = if (screenState.readingTranslationVisible) {
View.VISIBLE
} else {
View.GONE
}
row.translation.text = translationText(sentence)
}
} }
fun refreshFavorites() { fun refreshFavorites() {
readingAdapter?.notifyDataSetChanged()
}
fun scrollToCurrentSentence() {
val list = sentenceListView ?: return
val currentSentence = controller.currentSentence() ?: return
val item = controller.currentTrainingItem() ?: return val item = controller.currentTrainingItem() ?: return
item.sentences.forEach { sentence -> val position = item.sentences.indexOfFirst { it.index == currentSentence.index }
sentenceRows[sentence.index]?.favoriteButton?.apply { if (position < 0) return
val isFavorite = actions.isSentenceFavorite(sentence) list.post {
setText(if (isFavorite) "已收藏" else "收藏") if (sentenceListView !== list) return@post
background = rounded( val layoutManager = list.layoutManager as? LinearLayoutManager ?: return@post
if (isFavorite) SampleTheme.accent else SampleTheme.button, val offset = (list.height * 0.25f).toInt().coerceAtLeast(0)
8f, layoutManager.scrollToPositionWithOffset(position, offset)
)
}
} }
} }
@@ -136,75 +129,178 @@ internal class ReadingScreen(
}) })
} }
private fun sentenceRow(sentence: SentenceBoundary): View {
val translation = TextView(activity).apply {
text = translationText(sentence)
setTextColor(SampleTheme.textSubtle)
textSize = 14f
setPadding(0, 6.dp, 0, 0)
visibility = if (screenState.readingTranslationVisible) View.VISIBLE else View.GONE
}
val favoriteButton = Button(activity).apply {
setText("收藏")
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(12.dp, 0, 12.dp, 0)
background = rounded(SampleTheme.button, 8f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
32.dp,
).withMargins(10.dp, 0, 0, 0)
setOnClickListener {
val isFavorite = actions.toggleSentenceFavorite(sentence)
setText(if (isFavorite) "已收藏" else "收藏")
background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
}
}
val isFavorite = actions.isSentenceFavorite(sentence)
favoriteButton.setText(if (isFavorite) "已收藏" else "收藏")
favoriteButton.background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
val textColumn = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(activity).apply {
text = "${sentence.index + 1}. ${sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"}"
setTextColor(Color.WHITE)
textSize = 16f
})
addView(translation)
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = rounded(SampleTheme.surface, 10f, SampleTheme.border)
setPadding(14.dp, 12.dp, 10.dp, 12.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 10.dp)
addView(textColumn)
addView(favoriteButton)
}
}
private fun translationText(sentence: SentenceBoundary): String = when { private fun translationText(sentence: SentenceBoundary): String = when {
!sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty() !sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty()
screenState.chineseTranslationLoading -> "正在翻译中文…" screenState.chineseTranslationLoading -> "正在翻译中文…"
screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError
else -> "暂无中文翻译" else -> "暂无中文翻译"
} }
private inner class ReadingSentenceAdapter :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var sentences: List<SentenceBoundary> = emptyList()
init {
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return if (sentences.isEmpty()) EMPTY_ID else sentences[position].index.toLong()
}
override fun getItemViewType(position: Int): Int {
return if (sentences.isEmpty()) TYPE_EMPTY else TYPE_SENTENCE
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == TYPE_EMPTY) {
val empty = TextView(activity).apply {
text = "当前课程还没有可阅读的句子文本"
setTextColor(SampleTheme.textDark)
textSize = 13f
background = rounded(SampleTheme.selected, 8f)
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
}
EmptyViewHolder(empty)
} else {
SentenceViewHolder(createSentenceView())
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder !is SentenceViewHolder || sentences.isEmpty()) return
val sentence = sentences[position]
attachDoubleTap(holder.itemView, sentence)
holder.text.text =
"${sentence.index + 1}. ${sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"}"
holder.translation.visibility = if (screenState.readingTranslationVisible) {
View.VISIBLE
} else {
View.GONE
}
holder.translation.text = translationText(sentence)
bindFavoriteButton(holder.favoriteButton, sentence)
}
override fun getItemCount(): Int {
return if (sentences.isEmpty()) 1 else sentences.size
}
fun submitSentences(updated: List<SentenceBoundary>) {
sentences = updated
notifyDataSetChanged()
}
private fun createSentenceView(): View {
val text = TextView(activity).apply {
setTextColor(Color.WHITE)
textSize = 16f
}
val translation = TextView(activity).apply {
setTextColor(SampleTheme.readingTranslation)
textSize = 14f
setPadding(0, 6.dp, 0, 0)
}
val favoriteButton = Button(activity).apply {
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(12.dp, 0, 12.dp, 0)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
32.dp,
).withMargins(10.dp, 0, 0, 0)
}
val textColumn = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(text)
addView(translation)
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = rounded(SampleTheme.surface, 10f, SampleTheme.border)
setPadding(14.dp, 12.dp, 10.dp, 12.dp)
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
bottomMargin = 10.dp
}
addView(textColumn)
addView(favoriteButton)
}
}
private fun bindFavoriteButton(button: Button, sentence: SentenceBoundary) {
val isFavorite = actions.isSentenceFavorite(sentence)
button.setText(if (isFavorite) "已收藏" else "收藏")
button.background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
button.setOnClickListener {
val favorite = actions.toggleSentenceFavorite(sentence)
button.setText(if (favorite) "已收藏" else "收藏")
button.background = rounded(
if (favorite) SampleTheme.accent else SampleTheme.button,
8f,
)
}
}
private fun attachDoubleTap(view: View, sentence: SentenceBoundary) {
view.isClickable = true
view.setOnClickListener {
val now = SystemClock.elapsedRealtime()
val isDoubleTap = lastClickedSentenceIndex == sentence.index &&
now - lastClickedAtMs <= DOUBLE_TAP_INTERVAL_MS
if (isDoubleTap) {
lastClickedSentenceIndex = Int.MIN_VALUE
lastClickedAtMs = 0L
actions.onSentenceDoubleTapped(sentence)
} else {
lastClickedSentenceIndex = sentence.index
lastClickedAtMs = now
}
}
}
private inner class EmptyViewHolder(root: View) : RecyclerView.ViewHolder(root)
private inner class SentenceViewHolder(
root: View,
) : RecyclerView.ViewHolder(root) {
val text: TextView
val translation: TextView
val favoriteButton: Button
init {
val textColumn = root as ViewGroup
val content = textColumn.getChildAt(0) as ViewGroup
text = content.getChildAt(0) as TextView
translation = content.getChildAt(1) as TextView
favoriteButton = textColumn.getChildAt(1) as Button
}
}
}
private companion object {
private const val TYPE_EMPTY = 0
private const val TYPE_SENTENCE = 1
private const val EMPTY_ID = Long.MIN_VALUE
private const val DOUBLE_TAP_INTERVAL_MS = 300L
}
} }