diff --git a/android/sample-app/build.gradle.kts b/android/sample-app/build.gradle.kts index a884978..1f864fd 100644 --- a/android/sample-app/build.gradle.kts +++ b/android/sample-app/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { dependencies { implementation(project(":oral-trainer-sdk")) 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-exoplayer:1.11.0") } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index fe53f19..db5baac 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -263,6 +263,9 @@ class MainActivity : Activity() { if (screenState.activeModule == Module.MINE) { screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty()) } + if (screenState.activeModule == Module.READING) { + screenFactory.scrollToReadingCurrentSentence() + } } private fun refreshCurrentUi() { @@ -552,6 +555,17 @@ class MainActivity : Activity() { 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() { val sentence = controller.currentSentence() ?: return if (sentence.text.isNullOrBlank()) return diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt index a67d388..71898e7 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt @@ -29,6 +29,7 @@ internal interface ReadingScreenActions { fun onReadingTranslationVisible(visible: Boolean) fun isSentenceFavorite(sentence: SentenceBoundary): Boolean fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean + fun onSentenceDoubleTapped(sentence: SentenceBoundary) } internal interface TestScreenActions : diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt index 7619adf..f3969bf 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt @@ -85,6 +85,12 @@ internal class SampleScreenFactory( } } + fun scrollToReadingCurrentSentence() { + if (screenState.activeModule == Module.READING) { + readingScreen.scrollToCurrentSentence() + } + } + fun renderCatalog() { if (screenState.activeModule == Module.COURSE) { courseScreen.renderCatalog() diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt index a78d97f..0e59629 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt @@ -19,6 +19,7 @@ internal object SampleTheme { val textDark = Color.rgb(18, 24, 31) val textMuted = Color.rgb(151, 162, 174) val textSubtle = Color.rgb(88, 98, 108) + val readingTranslation = Color.rgb(147, 197, 253) val danger = Color.rgb(220, 38, 38) val textOverlay = Color.rgb(163, 172, 182) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt index 7d0b6dd..c218994 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/ReadingScreen.kt @@ -3,14 +3,16 @@ package cn.learningpad.oraltrainer.sample.ui import android.app.Activity import android.graphics.Color import android.graphics.Typeface +import android.os.SystemClock import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout -import android.widget.ScrollView import android.widget.Switch 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.SampleTheme 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.SentenceBoundary -/** Scrollable full-text reading view for the selected course. */ +/** Lazy-loading full-text reading view for the selected course. */ internal class ReadingScreen( private val activity: Activity, private val screenState: TrainingScreenState, @@ -28,90 +30,81 @@ internal class ReadingScreen( ) : UiStyled { override val uiContext get() = activity - private data class SentenceRow( - val translation: TextView, - val favoriteButton: Button, - ) - - private val sentenceRows = mutableMapOf() - private var sentenceList: LinearLayout? = null + private var readingAdapter: ReadingSentenceAdapter? = null private var titleText: TextView? = null + private var sentenceListView: RecyclerView? = null + private var lastClickedSentenceIndex = Int.MIN_VALUE + private var lastClickedAtMs = 0L fun createSection(): View { - val content = LinearLayout(activity).apply { + val root = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL setPadding(16.dp, 12.dp, 16.dp, 16.dp) } - val list = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - } - sentenceList = list - - val title = TextView(activity).apply { - setTextColor(Color.WHITE) - textSize = 24f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, 0, 0, 6.dp) - } - titleText = title - content.addView(title) - - content.addView(createTranslationToggle()) - content.addView(list) - renderSentences() - - return ScrollView(activity).apply { + val adapter = ReadingSentenceAdapter() + readingAdapter = adapter + val list = RecyclerView(activity).apply { + layoutManager = LinearLayoutManager(activity) + this.adapter = adapter + itemAnimator = null + setItemViewCacheSize(12) + clipToPadding = false + setPadding(0, 0, 0, 16.dp) layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, 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() { - val list = sentenceList ?: return val item = controller.currentTrainingItem() - titleText?.text = screenState.currentCourse?.title.orEmpty() + val baseTitle = screenState.currentCourse?.title.orEmpty() .ifBlank { item?.title ?: "阅读模式" } - sentenceRows.clear() - list.removeAllViews() - if (item == null || item.sentences.isEmpty()) { - list.addView(widgets.infoCard("当前课程还没有可阅读的句子文本")) - return - } - val currentTitle = titleText?.text?.toString().orEmpty() - titleText?.text = "$currentTitle · ${item.sentences.size} 句" - item.sentences.forEach { sentence -> - list.addView(sentenceRow(sentence)) + val sentences = item?.sentences.orEmpty() + titleText?.text = if (sentences.isEmpty()) { + baseTitle + } else { + "$baseTitle · ${sentences.size} 句" } + readingAdapter?.submitSentences(sentences) } fun refreshTranslations() { - val item = controller.currentTrainingItem() ?: return - 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) - } + readingAdapter?.notifyDataSetChanged() } fun refreshFavorites() { + readingAdapter?.notifyDataSetChanged() + } + + fun scrollToCurrentSentence() { + val list = sentenceListView ?: return + val currentSentence = controller.currentSentence() ?: return val item = controller.currentTrainingItem() ?: return - item.sentences.forEach { sentence -> - sentenceRows[sentence.index]?.favoriteButton?.apply { - val isFavorite = actions.isSentenceFavorite(sentence) - setText(if (isFavorite) "已收藏" else "收藏") - background = rounded( - if (isFavorite) SampleTheme.accent else SampleTheme.button, - 8f, - ) - } + val position = item.sentences.indexOfFirst { it.index == currentSentence.index } + if (position < 0) return + list.post { + if (sentenceListView !== list) return@post + val layoutManager = list.layoutManager as? LinearLayoutManager ?: return@post + val offset = (list.height * 0.25f).toInt().coerceAtLeast(0) + 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 { !sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty() screenState.chineseTranslationLoading -> "正在翻译中文…" screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError else -> "暂无中文翻译" } + + private inner class ReadingSentenceAdapter : + RecyclerView.Adapter() { + + private var sentences: List = 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) { + 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 + } }