From 3aaa9f42298a13cb997e3abbbea70ff24c919473 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 14 Sep 2026 16:27:55 +0800 Subject: [PATCH] add save function of explanation --- .../oraltrainer/sample/MainActivity.kt | 1 + .../sample/SentenceExplanationOverlay.kt | 74 ++++++++++++++++-- .../sample/SentenceExplanationStore.kt | 39 +++++++++ ios/OralTrainer.xcodeproj/project.pbxproj | 4 + .../IDEFindNavigatorScopes.plist | 5 ++ .../UserInterfaceState.xcuserstate | Bin 23724 -> 27278 bytes .../Services/SentenceExplanationStore.swift | 47 +++++++++++ .../Views/FullscreenPlayerView.swift | 1 + ios/OralTrainer/Views/PlayerSectionView.swift | 43 ++++++++-- 9 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt create mode 100644 ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist create mode 100644 ios/OralTrainer/Services/SentenceExplanationStore.swift 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 8bb68af..40191a8 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 @@ -434,6 +434,7 @@ class MainActivity : Activity() { explanationOverlay.show( sentence, controller.currentTrainingItem()?.title.orEmpty(), + controller.currentTrainingItem()?.id.orEmpty(), ) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt index c31c493..8475beb 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt @@ -18,11 +18,12 @@ internal class SentenceExplanationOverlay( private val activity: Activity, private val api: SentenceExplanationApi, ) { + private val store = SentenceExplanationStore(activity) private var currentOverlay: View? = null private var currentRequest: CancellableExplanationRequest? = null private var requestGeneration = 0 - fun show(sentence: SentenceBoundary, courseTitle: String) { + fun show(sentence: SentenceBoundary, courseTitle: String, mediaId: String) { close() val generation = ++requestGeneration val root = FrameLayout(activity).apply { @@ -51,6 +52,31 @@ internal class SentenceExplanationOverlay( setTextColor(SampleTheme.textDark) textSize = 19f typeface = Typeface.DEFAULT_BOLD + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + } + val saveHint = TextView(activity).apply { + text = HINT_UNSAVED + setTextColor(SampleTheme.textSubtle) + textSize = 12f + setPadding(6.dp, 0, 0, 0) + } + val saveButton = TextView(activity).apply { + text = "保存" + setTextColor(Color.WHITE) + textSize = 13f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + background = rounded(SampleTheme.button, 8f) + setPadding(12.dp, 6.dp, 12.dp, 6.dp) + isEnabled = false + alpha = 0.45f + } + val titleRow = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(title) + addView(saveButton) + addView(saveHint) } val subtitle = TextView(activity).apply { text = sentence.text?.takeIf { it.isNotBlank() } ?: courseTitle @@ -86,7 +112,7 @@ internal class SentenceExplanationOverlay( ).apply { topMargin = 16.dp } setOnClickListener { close() } } - card.addView(title) + card.addView(titleRow) card.addView(subtitle) card.addView(scroll) card.addView(close) @@ -94,6 +120,22 @@ internal class SentenceExplanationOverlay( addToWindow(root) currentOverlay = root + + val cached = store.load(mediaId, sentence.index) + if (!cached.isNullOrBlank()) { + message.text = cached + message.setTextColor(SampleTheme.textDark) + markSaved(saveButton, saveHint) + return + } + + var loadedExplanation: String? = null + saveButton.setOnClickListener { + val explanation = loadedExplanation ?: return@setOnClickListener + if (store.save(mediaId, sentence.index, explanation)) { + markSaved(saveButton, saveHint) + } + } currentRequest = api.explain( sentence.text.orEmpty(), courseTitle, @@ -101,14 +143,20 @@ internal class SentenceExplanationOverlay( sentence.language, object : SentenceExplanationCallback { override fun onSuccess(result: SentenceExplanationResult) { - if (generation == requestGeneration) message.text = result.explanation + if (generation != requestGeneration) return + loadedExplanation = result.explanation + message.text = result.explanation + message.setTextColor(SampleTheme.textDark) + saveButton.isEnabled = true + saveButton.alpha = 1f } override fun onError(error: Throwable) { - if (generation == requestGeneration) { - message.text = "讲解失败:${error.message ?: "请稍后重试"}" - message.setTextColor(SampleTheme.danger) - } + if (generation != requestGeneration) return + message.text = "讲解失败:${error.message ?: "请稍后重试"}" + message.setTextColor(SampleTheme.danger) + saveButton.isEnabled = false + saveButton.alpha = 0.45f } }, ) @@ -128,6 +176,13 @@ internal class SentenceExplanationOverlay( api.release() } + private fun markSaved(saveButton: TextView, saveHint: TextView) { + saveButton.text = "已保存" + saveButton.isEnabled = false + saveButton.alpha = 0.7f + saveHint.text = HINT_SAVED + } + private fun addToWindow(view: View) { (activity.window?.decorView as? ViewGroup)?.addView(view) } @@ -147,4 +202,9 @@ internal class SentenceExplanationOverlay( cornerRadius = radiusDp.dp.toFloat() strokeColor?.let { setStroke(1.dp, it) } } + + private companion object { + const val HINT_UNSAVED = "(下次直接看本地)" + const val HINT_SAVED = "(已保存本地)" + } } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt new file mode 100644 index 0000000..d6adfc6 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt @@ -0,0 +1,39 @@ +package cn.learningpad.oraltrainer.sample + +import android.content.Context +import java.io.File +import java.util.Locale + +/** Persists sentence explanations under app filesDir for reuse without another API call. */ +internal class SentenceExplanationStore(context: Context) { + private val rootDir = File(context.applicationContext.filesDir, "sentence-explanations") + + fun load(mediaId: String, sentenceIndex: Int): String? { + val file = fileFor(mediaId, sentenceIndex) ?: return null + if (!file.isFile || file.length() <= 0L) return null + return file.readText() + } + + fun save(mediaId: String, sentenceIndex: Int, explanation: String): Boolean { + val text = explanation.trim() + if (text.isEmpty()) return false + val file = fileFor(mediaId, sentenceIndex) ?: return false + file.parentFile?.mkdirs() + file.writeText(text) + return file.isFile && file.length() > 0L + } + + private fun fileFor(mediaId: String, sentenceIndex: Int): File? { + val directoryName = sanitize(mediaId) ?: return null + return File(File(rootDir, directoryName), "$sentenceIndex.txt") + } + + companion object { + fun sanitize(mediaId: String): String? { + val filtered = mediaId.trim().lowercase(Locale.US) + .replace(Regex("[^a-z0-9._-]"), "_") + .trim('_') + return filtered.takeIf { it.isNotBlank() && it != "." && it != ".." } + } + } +} diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index 33d9a50..baf1854 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; }; AA00000000000000000000A1 /* UserHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A2 /* UserHistory.swift */; }; AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A4 /* TrainingHistoryStore.swift */; }; + AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SentenceExplanationStore.swift */; }; AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; }; AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; }; AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; }; @@ -41,6 +42,7 @@ AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = ""; }; AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = ""; }; AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = ""; }; + AA00000000000000000000B1 /* SentenceExplanationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceExplanationStore.swift; sourceTree = ""; }; AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -131,6 +133,7 @@ AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, AA00000000000000000000A4 /* TrainingHistoryStore.swift */, + AA00000000000000000000B1 /* SentenceExplanationStore.swift */, AA00000000000000000000A2 /* UserHistory.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, AA0000000000000000000081 /* AudioRecorderSession.swift */, @@ -267,6 +270,7 @@ AA0000000000000000000090 /* AuthAPI.swift in Sources */, AA00000000000000000000A1 /* UserHistory.swift in Sources */, AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */, + AA00000000000000000000B0 /* SentenceExplanationStore.swift in Sources */, AA0000000000000000000092 /* AuthView.swift in Sources */, AA0000000000000000000053 /* TestView.swift in Sources */, AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 8f6a29489ac5c7b6effe9c2977c09d94e3278870..0ce9aee8bf940b74296482ab90059ae3077a77ab 100644 GIT binary patch delta 15512 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 N3F2+Pt=F=@{|{C<9cV#@cGF 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 A0ssI2 diff --git a/ios/OralTrainer/Services/SentenceExplanationStore.swift b/ios/OralTrainer/Services/SentenceExplanationStore.swift new file mode 100644 index 0000000..30e4285 --- /dev/null +++ b/ios/OralTrainer/Services/SentenceExplanationStore.swift @@ -0,0 +1,47 @@ +import Foundation + +final class SentenceExplanationStore { + static let shared = SentenceExplanationStore() + + private let root: URL + + init() { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + root = documents.appendingPathComponent("SentenceExplanations", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func load(mediaId: String, sentenceIndex: Int) -> String? { + let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let text = try? String(contentsOf: url, encoding: .utf8) + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + @discardableResult + func save(mediaId: String, sentenceIndex: Int, explanation: String) -> Bool { + let trimmed = explanation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let url = fileURL(mediaId: mediaId, sentenceIndex: sentenceIndex) + try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + do { + try trimmed.write(to: url, atomically: true, encoding: .utf8) + return true + } catch { + return false + } + } + + private func fileURL(mediaId: String, sentenceIndex: Int) -> URL { + let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true) + return directory.appendingPathComponent("\(sentenceIndex).txt") + } + + static func sanitize(_ mediaId: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + let value = String(filtered).trimmingCharacters(in: CharacterSet(charactersIn: "_")) + return value.isEmpty || value == "." || value == ".." ? "unknown" : value + } +} diff --git a/ios/OralTrainer/Views/FullscreenPlayerView.swift b/ios/OralTrainer/Views/FullscreenPlayerView.swift index ec20e4e..6ed296e 100644 --- a/ios/OralTrainer/Views/FullscreenPlayerView.swift +++ b/ios/OralTrainer/Views/FullscreenPlayerView.swift @@ -97,6 +97,7 @@ struct FullscreenPlayerView: View { } .sheet(item: $explanationSentence) { sentence in SentenceExplanationSheet( + mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", sentence: sentence ) diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift index 8f72914..3e702eb 100644 --- a/ios/OralTrainer/Views/PlayerSectionView.swift +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -22,6 +22,7 @@ struct PlayerSectionView: View { .padding(.horizontal, 16) .sheet(item: $explanationSentence) { sentence in SentenceExplanationSheet( + mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", sentence: sentence ) @@ -96,7 +97,7 @@ struct PlayerSectionView: View { Text(sentenceTextText) .font(.system(size: 15)) .foregroundColor(.white) - .lineLimit(2) + .lineLimit(4) Spacer() Button { guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else { @@ -230,12 +231,14 @@ struct PlayerSectionView: View { } struct SentenceExplanationSheet: View { + let mediaId: String let courseTitle: String let sentence: SentenceBoundary @Environment(\.dismiss) private var dismiss @State private var explanation = "" @State private var errorMessage: String? + @State private var saved = false var body: some View { NavigationView { @@ -267,18 +270,35 @@ struct SentenceExplanationSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 22, weight: .semibold)) + HStack(spacing: 6) { + Button(saved ? "已保存" : "保存") { + saveExplanation() + } + .disabled(saved || explanation.isEmpty || errorMessage != nil) + Text(saved ? "(已保存本地)" : "(下次直接看本地)") + .font(.system(size: 12)) .foregroundColor(Theme.textMuted) + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(Theme.textMuted) + } + .accessibilityLabel("关闭句子讲解") } - .accessibilityLabel("关闭句子讲解") } } } .task { + if let cached = SentenceExplanationStore.shared.load( + mediaId: mediaId, + sentenceIndex: sentence.index + ) { + explanation = cached + saved = true + return + } guard explanation.isEmpty, errorMessage == nil else { return } do { explanation = try await SentenceBoundaryAPI.explain( @@ -290,4 +310,13 @@ struct SentenceExplanationSheet: View { } } } + + private func saveExplanation() { + guard !saved, !explanation.isEmpty, errorMessage == nil else { return } + saved = SentenceExplanationStore.shared.save( + mediaId: mediaId, + sentenceIndex: sentence.index, + explanation: explanation + ) + } }