手机适配

This commit is contained in:
pengxiaolong
2026-02-10 18:26:31 +08:00
parent 63415e1fde
commit d10524c597
262 changed files with 107341 additions and 32222 deletions

View File

@@ -7,7 +7,12 @@ plugins {
android {
namespace = "com.example.myapplication"
compileSdk = 34
// 禁止压缩 .bin 文件,允许内存映射加载
androidResources {
noCompress += listOf("bin")
}
defaultConfig {
applicationId = "com.boshan.key.of.love"
minSdk = 21

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="true"
@@ -50,8 +51,9 @@
<!-- 主界面 -->
<activity
android:name=".MainActivity"
android:screenOrientation="portrait"
android:exported="true">
android:screenOrientation="portrait"
android:exported="true"
android:windowSoftInputMode="stateHidden|adjustResize">
</activity>
<!-- 输入法服务 -->

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,198 +0,0 @@
package com.example.myapplication.data
import android.content.Context
import com.example.myapplication.Trie
import java.util.concurrent.atomic.AtomicBoolean
import java.util.PriorityQueue
import kotlin.math.max
class BigramPredictor(
private val context: Context,
private val trie: Trie
) {
@Volatile private var model: BigramModel? = null
private val loading = AtomicBoolean(false)
// 词 ↔ id 映射
@Volatile private var word2id: Map<String, Int> = emptyMap()
@Volatile private var id2word: List<String> = emptyList()
@Volatile private var topUnigrams: List<String> = emptyList()
private val unigramCacheSize = 2000
//预先加载语言模型并构建词到ID和ID到词的双向映射。
fun preload() {
if (!loading.compareAndSet(false, true)) return
Thread {
try {
val m = LanguageModelLoader.load(context)
model = m
// 建索引vocab 与 bigram 索引对齐,注意不丢前三个符号)
val map = HashMap<String, Int>(m.vocab.size * 2)
m.vocab.forEachIndexed { idx, w -> map[w] = idx }
word2id = map
id2word = m.vocab
topUnigrams = buildTopUnigrams(m, unigramCacheSize)
} catch (_: Throwable) {
// 保持静默,允许无模型运行(仅 Trie 起作用)
} finally {
loading.set(false)
}
}.start()
}
// 模型是否已准备好
fun isReady(): Boolean = model != null
//基于上文 lastWord可空与前缀 prefix 联想,优先bigram 条件概率 → Trie 过滤 → Top-K,兜底unigram Top-K同样做 Trie 过滤)
fun suggest(prefix: String, lastWord: String?, topK: Int = 10): List<String> {
val m = model
val pfx = prefix.trim()
if (m == null) {
// 模型未载入时,纯 Trie 前缀联想(你的 Trie 应提供类似 startsWith
return safeTriePrefix(pfx, topK)
}
val candidates = mutableListOf<Pair<String, Float>>()
val lastId = lastWord?.let { word2id[it] }
if (lastId != null) {
// 1) bigram 邻域
val start = m.biRowptr[lastId]
val end = m.biRowptr[lastId + 1]
if (start in 0..end && end <= m.biCols.size) {
// 先把 bigram 候选过一遍前缀过滤
for (i in start until end) {
val nextId = m.biCols[i]
val w = m.vocab[nextId]
if (pfx.isEmpty() || w.startsWith(pfx, ignoreCase = true)) {
val score = m.biLogp[i] // logP(next|last)
candidates += w to score
}
}
}
}
// 2) 如果有 bigram 过滤后的候选,直接取 topK
if (candidates.isNotEmpty()) {
return topKByScore(candidates, topK)
}
// 3) 兜底:用预计算的 unigram Top-N + 前缀过滤
if (topK <= 0) return emptyList()
val cachedUnigrams = getTopUnigrams(m)
if (pfx.isEmpty()) {
return cachedUnigrams.take(topK)
}
val results = ArrayList<String>(topK)
if (cachedUnigrams.isNotEmpty()) {
for (w in cachedUnigrams) {
if (w.startsWith(pfx, ignoreCase = true)) {
results.add(w)
if (results.size >= topK) return results
}
}
}
if (results.size < topK) {
val fromTrie = safeTriePrefix(pfx, topK)
for (w in fromTrie) {
if (w !in results) {
results.add(w)
if (results.size >= topK) break
}
}
}
return results
}
//供上层在用户选中词时更新“上文”状态
fun normalizeWordForContext(word: String): String? {
// 你可以在这里做大小写/符号处理,或将 OOV 映射为 <unk>
return if (word2id.containsKey(word)) word else "<unk>"
}
//在Trie数据结构中查找与给定前缀匹配的字符串并返回其中评分最高的topK个结果。
private fun safeTriePrefix(prefix: String, topK: Int): List<String> {
if (prefix.isEmpty()) return emptyList()
return try {
trie.startsWith(prefix, topK)
} catch (_: Throwable) {
emptyList()
}
}
private fun getTopUnigrams(model: BigramModel): List<String> {
val cached = topUnigrams
if (cached.isNotEmpty()) return cached
val built = buildTopUnigrams(model, unigramCacheSize)
topUnigrams = built
return built
}
private fun buildTopUnigrams(model: BigramModel, limit: Int): List<String> {
if (limit <= 0) return emptyList()
val heap = topKHeap(limit)
for (i in model.vocab.indices) {
heap.offer(model.vocab[i] to model.uniLogp[i])
if (heap.size > limit) heap.poll()
}
return heap.toSortedListDescending()
}
//从给定的候选词对列表中通过一个小顶堆来过滤出评分最高的前k个词
private fun topKByScore(pairs: List<Pair<String, Float>>, k: Int): List<String> {
val heap = topKHeap(k)
for (p in pairs) {
heap.offer(p)
if (heap.size > k) heap.poll()
}
return heap.toSortedListDescending()
}
//创建一个优先队列,用于在一组候选词对中保持评分最高的 k 个词。
private fun topKHeap(k: Int): PriorityQueue<Pair<String, Float>> {
// 小顶堆,比较 Float 分数
return PriorityQueue(k.coerceAtLeast(1)) { a, b ->
a.second.compareTo(b.second) // 分数小的优先被弹出
}
}
// 排序后的候选词列表
private fun PriorityQueue<Pair<String, Float>>.toSortedListDescending(): List<String> {
val list = ArrayList<Pair<String, Float>>(this.size)
while (this.isNotEmpty()) {
val p = this.poll() ?: continue // 防御性判断,避免 null
list.add(p)
}
list.reverse() // 从高分到低分
return list.map { it.first }
}
}

View File

@@ -159,11 +159,25 @@ class GuideActivity : AppCompatActivity() {
}
// 键盘发送
inputMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
inputMessage.setOnEditorActionListener { _, actionId, event ->
if (event != null) {
return@setOnEditorActionListener false
}
if (actionId == EditorInfo.IME_ACTION_SEND ||
actionId == EditorInfo.IME_ACTION_DONE ||
actionId == EditorInfo.IME_ACTION_UNSPECIFIED
) {
// 走同一套发送逻辑
sendMessage()
true
true
} else {
false
}
}
inputMessage.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
sendMessage()
true
} else {
false
}

View File

@@ -17,8 +17,6 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import eightbitlab.com.blurview.BlurView
import eightbitlab.com.blurview.RenderEffectBlur
import eightbitlab.com.blurview.RenderScriptBlur
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
@@ -236,6 +234,15 @@ class MainActivity : AppCompatActivity() {
is AuthEvent.OpenGlobalPage -> {
openGlobal(event.destinationId, event.bundle, event.clearGlobalBackStack)
}
// Circle 页面内跳转(保留原页面栈)
is AuthEvent.OpenCirclePage -> {
switchTab(TAB_CIRCLE, force = true)
try {
circleHost.navController.navigate(event.destinationId, event.bundle)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
is AuthEvent.UserUpdated -> {
// 不需要处理
@@ -357,6 +364,9 @@ class MainActivity : AppCompatActivity() {
R.id.feedbackFragment,
R.id.MyKeyboard,
R.id.PersonalSettings,
R.id.circleCharacterDetailsFragment,
R.id.circleAiCharacterReportFragment,
R.id.CircleMyAiCharacterFragment
)
}
@@ -476,13 +486,10 @@ class MainActivity : AppCompatActivity() {
private fun applyCircleTabBackground() {
bottomNav.itemBackground = null
if (blurReady) {
bottomNavBlur.visibility = View.VISIBLE
bottomNav.background = ColorDrawable(android.graphics.Color.TRANSPARENT)
} else {
bottomNavBlur.visibility = View.GONE
bottomNav.background = ColorDrawable(ContextCompat.getColor(this, R.color.black_30_percent))
}
bottomNav.backgroundTintList = null
// Circle 页底栏保持完全透明
bottomNavBlur.visibility = View.GONE
bottomNav.background = ColorDrawable(android.graphics.Color.TRANSPARENT)
}
private fun resetBottomNavBackground() {
@@ -492,35 +499,9 @@ class MainActivity : AppCompatActivity() {
}
private fun setupBottomNavBlur() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
blurReady = false
bottomNavBlur.visibility = View.GONE
return
}
val rootView = findViewById<ViewGroup>(android.R.id.content)?.getChildAt(0) as? ViewGroup
?: run { blurReady = false; bottomNavBlur.visibility = View.GONE; return }
// Lighter blur for higher transparency
val blurRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 8f else 6f
try {
val algorithm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RenderEffectBlur()
} else {
RenderScriptBlur(this)
}
bottomNavBlur.setupWith(rootView, algorithm)
.setFrameClearDrawable(window.decorView.background)
.setBlurRadius(blurRadius)
.setBlurAutoUpdate(true)
.setOverlayColor(ContextCompat.getColor(this, R.color.frosted_glass_bg))
blurReady = true
} catch (e: Exception) {
blurReady = false
bottomNavBlur.visibility = View.GONE
}
// 全局移除底栏毛玻璃效果
blurReady = false
bottomNavBlur.visibility = View.GONE
}
/** 打开全局页login/recharge等 */

View File

@@ -20,7 +20,7 @@ import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import com.example.myapplication.data.WordDictionary
import com.example.myapplication.data.LanguageModelLoader
import com.example.myapplication.data.LanguageModel
import com.example.myapplication.theme.ThemeManager
import com.example.myapplication.keyboard.KeyboardEnvironment
import com.example.myapplication.keyboard.MainKeyboard
@@ -70,19 +70,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
private var currentInput = StringBuilder() // 当前输入前缀
private var completionSuggestions = emptyList<String>() // 自动完成建议
private val suggestionViews = mutableListOf<TextView>() // 缓存动态创建的候选视图
private var suggestionSlotCount: Int = 21 // 包含前缀位,调这里可修改渲染数量
private var suggestionSlotCount: Int = 10 // 包含前缀位,调这里可修改渲染数量
private val completionCapacity: Int
get() = (suggestionSlotCount - 1).coerceAtLeast(0)
@Volatile private var isSpecialToken: BooleanArray = BooleanArray(0)
private val suggestionStats by lazy { SuggestionStats(applicationContext) }
private val specialTokens = setOf("<unk>", "<s>", "</s>")
@Volatile private var bigramModel: com.example.myapplication.data.BigramModel? = null
@Volatile private var word2id: Map<String, Int> = emptyMap()
@Volatile private var id2word: List<String> = emptyList()
@Volatile private var bigramReady: Boolean = false
private val languageModel by lazy { LanguageModel(applicationContext, wordDictionary.wordTrie) }
companion object {
private const val TAG = "MyIME"
@@ -124,6 +118,12 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// Shift 状态
private var isShiftOn = false
private fun setShiftState(on: Boolean) {
if (isShiftOn == on) return
isShiftOn = on
mainKeyboard?.setShiftState(on)
}
// 删除长按
private var isDeleting = false
private val repeatDelInitialDelay = 350L
@@ -223,7 +223,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
ThemeManager.init(this)
}.start()
// 异步加载词典与 bigram 模型
// 异步加载词典与语言模型
Thread {
// 1) Trie 词典
try {
@@ -232,35 +232,11 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
Log.w(TAG, "Trie load failed: ${e.message}", e)
}
// 2) Bigram 模型
// 2) N-gram 语言模型
try {
val m = LanguageModelLoader.load(this)
require(m.biRowptr.size == m.vocab.size + 1) {
"biRowptr size ${m.biRowptr.size} != vocab.size+1 ${m.vocab.size + 1}"
}
require(m.biCols.size == m.biLogp.size) {
"biCols size ${m.biCols.size} != biLogp size ${m.biLogp.size}"
}
bigramModel = m
val map = HashMap<String, Int>(m.vocab.size * 2)
m.vocab.forEachIndexed { idx, w -> map[w.lowercase()] = idx }
word2id = map
id2word = m.vocab
isSpecialToken = BooleanArray(id2word.size)
for (i in id2word.indices) {
if (specialTokens.contains(id2word[i])) {
isSpecialToken[i] = true
}
}
bigramReady = true
languageModel.preload()
} catch (e: Throwable) {
bigramReady = false
Log.w(TAG, "Bigram load failed: ${e.message}", e)
Log.w(TAG, "Language model load failed: ${e.message}", e)
}
}.start()
@@ -371,6 +347,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 删除键长按连删
val delId = resources.getIdentifier("key_del", "id", packageName)
mainKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
// 同步当前 Shift 状态到主键盘 UI
mainKeyboard?.setShiftState(isShiftOn)
}
return mainKeyboard!!
}
@@ -388,7 +367,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val full = et.text?.toString().orEmpty()
if (full.isEmpty()) {
// 已经空了就不做
clearEditorState()
clearEditorState(resetShift = false)
return
}
@@ -406,7 +385,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
ic.endBatchEdit()
}
clearEditorState()
clearEditorState(resetShift = false)
// 清空后立即更新所有键盘的按钮可见性
mainHandler.post {
@@ -678,6 +657,28 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
return word
}
/**
* 获取光标前的最后N个完整词用于 N-gram 预测)
*/
private fun getPrevWordsBeforeCursor(count: Int = 2, maxLen: Int = 128): List<String> {
val before = currentInputConnection
?.getTextBeforeCursor(maxLen, 0)
?.toString()
?: return emptyList()
// 如果最后一个字符是字母,说明词不完整
if (before.isNotEmpty() && before.last().isLetter()) return emptyList()
val toks = before
.replace(Regex("[^A-Za-z]"), " ")
.trim()
.split(Regex("\\s+"))
.filter { it.isNotEmpty() }
.map { it.lowercase() }
return toks.takeLast(count)
}
// 提交一个字符(原 sendKey
override fun commitKey(c: Char) {
val ic = currentInputConnection ?: return
@@ -700,6 +701,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
c == ' ' || !c.isLetter() -> {
updateCompletionsAndRender(prefix = "")
}
}
playKeyClick()
@@ -803,7 +805,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val editorReallyEmpty = before1.isEmpty() && after1.isEmpty()
if (editorReallyEmpty) {
clearEditorState()
clearEditorState(resetShift = false)
} else {
// prefix 也不要取太长
val prefix = getCurrentWordPrefix(maxLen = 64)
@@ -823,22 +825,49 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val ic = currentInputConnection ?: return
val info = currentInputEditorInfo
var handled = false
var actionId = EditorInfo.IME_ACTION_UNSPECIFIED
var imeOptions = 0
var isMultiLine = false
var noEnterAction = false
if (info != null) {
// 取出当前 EditText 声明的 action
val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION
// 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用
if (actionId == EditorInfo.IME_ACTION_SEND) {
handled = ic.performEditorAction(actionId)
}
imeOptions = info.imeOptions
actionId = imeOptions and EditorInfo.IME_MASK_ACTION
isMultiLine = (info.inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0
noEnterAction = (imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0
}
if (isMultiLine && (noEnterAction ||
actionId == EditorInfo.IME_ACTION_UNSPECIFIED ||
actionId == EditorInfo.IME_ACTION_NONE)
) {
ic.commitText("\n", 1)
playKeyClick()
clearEditorState()
return
}
// 兼容 SEND / DONE / GO / NEXT / PREVIOUS / SEARCH
val handled = when (actionId) {
EditorInfo.IME_ACTION_SEND,
EditorInfo.IME_ACTION_DONE,
EditorInfo.IME_ACTION_GO,
EditorInfo.IME_ACTION_NEXT,
EditorInfo.IME_ACTION_PREVIOUS,
EditorInfo.IME_ACTION_SEARCH -> ic.performEditorAction(actionId)
else -> false
}
Log.d("1314520-IME", "performSendAction actionId=$actionId imeOptions=$imeOptions handled=$handled")
// 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false
// 就降级为“标准回车”
if (!handled) {
sendEnterKey(ic)
if (isMultiLine) {
ic.commitText("\n", 1)
} else {
sendEnterKey(ic)
}
}
playKeyClick()
@@ -864,7 +893,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
override fun getCurrentWordPrefix(maxLen: Int): String {
val before = currentInputConnection?.getTextBeforeCursor(maxLen, 0)?.toString() ?: ""
val match = Regex("[A-Za-z]+$").find(before)
return (match?.value ?: "").lowercase()
return (match?.value ?: "")
}
// 统一处理补全/联想
@@ -875,15 +904,19 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val afterAll = ic?.getTextAfterCursor(256, 0)?.toString().orEmpty()
val editorReallyEmpty = beforeAll.isEmpty() && afterAll.isEmpty()
val rawPrefix = prefix
val lookupPrefix = prefix // 严格按原始大小写查询
currentInput.clear()
currentInput.append(prefix)
currentInput.append(rawPrefix)
if (editorReallyEmpty) {
clearEditorState()
clearEditorState(resetShift = false)
return
}
val lastWord = getPrevWordBeforeCursor()
val prevWords = getPrevWordsBeforeCursor(2) // 获取最后2个词用于 trigram
val maxCompletions = completionCapacity
Thread {
@@ -892,25 +925,30 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
emptyList()
} else {
try {
if (prefix.isEmpty()) {
if (lastWord == null) {
val modelResult = if (lookupPrefix.isEmpty()) {
if (prevWords.isEmpty()) {
emptyList()
} else {
suggestWithBigram("", lastWord, topK = maxCompletions)
// 无前缀但有上文预测下一词传入最后2个词以支持 trigram
languageModel.predictNext(prevWords, maxCompletions)
}
} else {
val fromBi = suggestWithBigram(prefix, lastWord, topK = maxCompletions)
if (fromBi.isNotEmpty()) {
fromBi.filter { it != prefix }
} else {
wordDictionary.wordTrie.startsWith(prefix, maxCompletions)
.filter { it != prefix }
}
// 有前缀:严格按原始大小写查询
languageModel.suggest(lookupPrefix, lastWord, maxCompletions)
.filter { it != lookupPrefix }
}
// 如果语言模型返回空结果,回退到 Trie严格按原始大小写
if (modelResult.isEmpty() && lookupPrefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(lookupPrefix, maxCompletions)
.filterNot { it == lookupPrefix }
} else {
modelResult
}
} catch (_: Throwable) {
if (prefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(prefix, maxCompletions)
.filterNot { it == prefix }
if (lookupPrefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(lookupPrefix, maxCompletions)
.filterNot { it == lookupPrefix }
} else {
emptyList()
}
@@ -919,7 +957,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
mainHandler.post {
val limited = if (maxCompletions > 0) list.distinct().take(maxCompletions) else emptyList()
completionSuggestions = suggestionStats.sortByCount(limited)
completionSuggestions = limited
showCompletionSuggestions()
}
}.start()
@@ -1022,7 +1060,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
textView.text = word
textView.visibility = View.VISIBLE
textView.setOnClickListener {
suggestionStats.incClick(word)
languageModel.recordSelection(word)
insertCompletion(word)
}
} else {
@@ -1313,107 +1351,6 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
// ================== bigram & 联想实现 ==================
private fun suggestWithBigram(prefix: String, lastWord: String?, topK: Int = 20): List<String> {
if (topK <= 0) return emptyList()
val m = bigramModel
if (m == null || !bigramReady) {
return if (prefix.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(prefix, topK)
} else {
emptyList()
}
}
val pf = prefix.lowercase()
val last = lastWord?.lowercase()
val lastId = last?.let { word2id[it] }
if (lastId != null && lastId >= 0 && lastId + 1 < m.biRowptr.size) {
val start = m.biRowptr[lastId]
val end = m.biRowptr[lastId + 1]
if (start in 0..end && end <= m.biCols.size) {
// 带前缀过滤
val buf = ArrayList<Pair<String, Float>>(maxOf(0, end - start))
var i = start
while (i < end) {
val nextId = m.biCols[i]
if (nextId in id2word.indices && !isSpecialToken[nextId]) {
val w = id2word[nextId]
if (pf.isEmpty() || w.startsWith(pf)) {
buf.add(w to m.biLogp[i])
}
}
i++
}
if (buf.isNotEmpty()) return topKByScore(buf, topK)
// 无前缀兜底
val allBuf = ArrayList<Pair<String, Float>>(maxOf(0, end - start))
i = start
while (i < end) {
val nextId = m.biCols[i]
if (nextId in id2word.indices && !isSpecialToken[nextId]) {
allBuf.add(id2word[nextId] to m.biLogp[i])
}
i++
}
if (allBuf.isNotEmpty()) return topKByScore(allBuf, topK)
}
}
// —— 无上文 或 无出边 ——
return if (pf.isNotEmpty()) {
wordDictionary.wordTrie.startsWith(pf, topK)
} else {
unigramTopKFiltered(topK)
}
}
private fun unigramTopKFiltered(topK: Int): List<String> {
if (topK <= 0) return emptyList()
val m = bigramModel ?: return emptyList()
if (!bigramReady) return emptyList()
val heap = java.util.PriorityQueue<Pair<String, Float>>(topK.coerceAtLeast(1)) { a, b ->
a.second.compareTo(b.second)
}
var i = 0
val n = id2word.size
while (i < n) {
if (!isSpecialToken[i]) {
heap.offer(id2word[i] to m.uniLogp[i])
if (heap.size > topK) heap.poll()
}
i++
}
val out = ArrayList<String>(heap.size)
while (heap.isNotEmpty()) out.add(heap.poll().first)
out.reverse()
return out
}
private fun topKByScore(pairs: List<Pair<String, Float>>, k: Int): List<String> {
if (k <= 0) return emptyList()
val heap = java.util.PriorityQueue<Pair<String, Float>>(k.coerceAtLeast(1)) { a, b ->
a.second.compareTo(b.second)
}
for (p in pairs) {
heap.offer(p)
if (heap.size > k) heap.poll()
}
val out = ArrayList<String>(heap.size)
while (heap.isNotEmpty()) out.add(heap.poll().first)
out.reverse()
return out
}
override fun onUpdateSelection(
oldSelStart: Int,
oldSelEnd: Int,
@@ -1439,20 +1376,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 当编辑框光标前后都没有任何字符,说明真的完全空了
if (before.isEmpty() && after.isEmpty()) {
clearEditorState()
clearEditorState(resetShift = false)
}
}
// 清理本次编辑框相关的状态(光标、联想、长按等)
private fun clearEditorState() {
private fun clearEditorState(resetShift: Boolean = true) {
// 1. 文本联想/补全相关
currentInput.clear()
completionSuggestions = emptyList()
lastWordForLM = null
// 2. Shift 状态
isShiftOn = false
if (resetShift) {
setShiftState(false)
}
// 3. 停止长按删除
stopRepeatDelete()

View File

@@ -7,6 +7,9 @@ import android.os.Looper
// import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.ui.circle.CircleChatRepository
import com.example.myapplication.ui.circle.CircleChatRepositoryProvider
class SplashActivity : AppCompatActivity() {
@@ -19,6 +22,13 @@ class SplashActivity : AppCompatActivity() {
setContentView(R.layout.activity_splash)
// progressBar = findViewById(R.id.progressBar)
val preloadCount = CircleChatRepository.computePreloadCount(this)
CircleChatRepositoryProvider.warmUp(
context = this,
apiService = RetrofitClient.apiService,
totalPages = CircleChatRepository.DEFAULT_PAGE_COUNT,
preloadCount = preloadCount
)
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
val isFirstLaunch = prefs.getBoolean("is_first_launch", true)

View File

@@ -1,71 +1,156 @@
package com.example.myapplication
import java.util.ArrayDeque
import java.util.PriorityQueue
class Trie {
//表示Trie数据结构中的一个节点,该节点可以存储其子节点,并且可以标记是否是一个完整单词的结尾
// Trie 节点,包含子节点、终结词集合、是否是词尾,以及该子树的最大词频
private data class TrieNode(
val children: MutableMap<Char, TrieNode> = mutableMapOf(),
var isEndOfWord: Boolean = false
val terminalWords: LinkedHashSet<String> = linkedSetOf(),
var isEndOfWord: Boolean = false,
var maxFreq: Int = 0, // 该节点及其子树中的最高词频
var selfFreq: Int = 0 // 该词自身的词频(仅终结节点有效)
)
private val root = TrieNode()//根节点
private val root = TrieNode()
//将一个单词插入到Trie数据结构中。通过遍历单词的每个字符创建并连接相应的节点最终在最后一个字符的节点上标记该路径代表一个完整单词。
fun insert(word: String) {
// 用户点击权重倍数(点击一次相当于增加多少词频分数)
companion object {
const val CLICK_WEIGHT = 1000 // 点击一次增加 1000 分,确保优先于静态词频
}
/**
* 将一个单词插入到 Trie 中
* @param word 要插入的单词
* @param freq 词频分数(越高越常用)
*/
fun insert(word: String, freq: Int = 0) {
var current = root
for (char in word.lowercase()) {
for (char in word) {
current = current.children.getOrPut(char) { TrieNode() }
// 沿路径更新每个节点的最大词频
if (freq > current.maxFreq) {
current.maxFreq = freq
}
}
current.isEndOfWord = true
current.terminalWords.add(word)
current.selfFreq = freq
// 确保终结节点的 maxFreq 至少是自己的词频
if (freq > current.maxFreq) {
current.maxFreq = freq
}
}
//在Trie数据结构中查找指定的单词是否存在。
/**
* 插入单词(无词频,兼容旧接口)
*/
fun insert(word: String) {
insert(word, 0)
}
/**
* 更新词的优先级(用户点击时调用)
* 沿路径更新所有节点的 maxFreq确保该词在遍历时优先返回
*
* @param word 被点击的词
* @param clickCount 累计点击次数
*/
fun updateClickFreq(word: String, clickCount: Int) {
if (word.isEmpty()) return
// 计算新的优先级:原始词频 + 点击次数 * 权重
var current = root
val path = mutableListOf<TrieNode>()
for (char in word) {
current = current.children[char] ?: return // 词不存在则返回
path.add(current)
}
if (!current.isEndOfWord) return // 不是有效词
// 新优先级 = 原始词频 + 点击加权
val newFreq = current.selfFreq + clickCount * CLICK_WEIGHT
// 更新终结节点
if (newFreq > current.maxFreq) {
current.maxFreq = newFreq
}
// 沿路径向上更新所有祖先节点的 maxFreq
for (node in path) {
if (newFreq > node.maxFreq) {
node.maxFreq = newFreq
}
}
}
/**
* 在 Trie 中查找指定的单词是否存在
*/
fun search(word: String): Boolean {
var current = root
for (char in word.lowercase()) {
for (char in word) {
current = current.children[char] ?: return false
}
return current.isEndOfWord
}
//查找以prefix为前缀的所有单词。通过遍历prefix的每个字符找到相应的节点然后从该节点开始迭代搜索所有以该节点为起点的单词。
/**
* 查找以 prefix 为前缀的所有单词(不限数量)
*/
fun startsWith(prefix: String): List<String> {
return startsWith(prefix, Int.MAX_VALUE)
}
/**
* 查找以 prefix 为前缀的单词,按词频降序返回
* 使用优先队列,优先遍历高词频分支
*
* @param prefix 前缀
* @param limit 最大返回数量
*/
fun startsWith(prefix: String, limit: Int): List<String> {
var current = root
val normalized = prefix.lowercase()
for (char in normalized) {
for (char in prefix) {
current = current.children[char] ?: return emptyList()
}
val max = if (limit < 0) 0 else limit
if (max == 0) return emptyList()
val results = ArrayList<String>(minOf(max, 16))
val stack = ArrayDeque<Pair<TrieNode, String>>()
stack.addLast(current to prefix)
// 存储结果:(词, 词频)
val resultSet = linkedSetOf<String>()
while (stack.isNotEmpty() && results.size < max) {
val (node, word) = stack.removeLast()
// 优先队列:按 maxFreq 降序排列节点
val pq = PriorityQueue<TrieNode>(compareByDescending { it.maxFreq })
pq.add(current)
while (pq.isNotEmpty() && resultSet.size < max) {
val node = pq.poll()
// 如果当前节点是词尾,收集词
if (node.isEndOfWord) {
results.add(word)
if (results.size >= max) break
for (w in node.terminalWords) {
resultSet.add(w)
if (resultSet.size >= max) break
}
}
for ((char, child) in node.children) {
stack.addLast(child to (word + char))
if (resultSet.size >= max) break
// 将子节点加入优先队列(高词频的会优先被取出)
for ((_, child) in node.children) {
pq.add(child)
}
}
return results
return resultSet.toList()
}
}

View File

@@ -0,0 +1,349 @@
package com.example.myapplication.data
import android.content.Context
import com.example.myapplication.SuggestionStats
import com.example.myapplication.Trie
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
/**
* N-gram 语言模型,支持:
* 1. 下一词预测3-gram → 2-gram → 1-gram 回退)
* 2. 候选词排序(结合词频和用户个性化统计)
*/
class LanguageModel(
private val context: Context,
private val trie: Trie
) {
@Volatile
private var model: NgramModel? = null
private val loading = AtomicBoolean(false)
@Volatile
private var wordToId: Map<String, Int> = emptyMap()
private val stats = SuggestionStats(context)
fun preload() {
if (!loading.compareAndSet(false, true)) return
Thread {
try {
android.util.Log.d("LanguageModel", "开始加载语言模型...")
val m = LanguageModelLoader.load(context)
model = m
wordToId = m.vocab.withIndex().associate { it.value to it.index }
android.util.Log.d("LanguageModel", "语言模型加载成功,词表大小: ${m.vocabSize}")
} catch (e: Throwable) {
android.util.Log.e("LanguageModel", "语言模型加载失败: ${e.message}", e)
} finally {
loading.set(false)
}
}.start()
}
fun isReady(): Boolean = model != null
// ==================== 功能1: 下一词预测 ====================
/**
* 根据上下文预测下一个词
* 策略N-gram 模型获取候选词 → 用户点击记录排序
*
* @param contextWords 上下文词列表最多取最后2个
* @param topK 返回数量
*/
fun predictNext(contextWords: List<String>, topK: Int = 10): List<String> {
val m = model ?: return emptyList()
val lastTwo = contextWords.takeLast(2)
val ids = lastTwo.mapNotNull { wordToId[it.lowercase()] }
android.util.Log.d("LanguageModel", "predictNext: 上下文=$lastTwo, 映射ID=$ids, triCtxCount=${m.triCtxCount}")
if (ids.isEmpty()) return sortByUserClicks(getTopFrequentWords(m, topK * 3), topK)
// 获取更多候选词用于排序(取 3 倍数量)
val fetchCount = topK * 3
// 尝试 3-gram2词上下文
var candidates: List<String> = emptyList()
var usedModel = "1-gram"
if (ids.size >= 2) {
candidates = predictFromTrigram(m, ids[0], ids[1], fetchCount)
if (candidates.isNotEmpty()) usedModel = "3-gram"
android.util.Log.d("LanguageModel", "3-gram预测: ctx1=${ids[0]}, ctx2=${ids[1]}, 结果数=${candidates.size}")
}
// 尝试 2-gram1词上下文
if (candidates.isEmpty()) {
candidates = predictFromBigram(m, ids.last(), fetchCount)
if (candidates.isNotEmpty()) usedModel = "2-gram"
android.util.Log.d("LanguageModel", "2-gram预测: ctx=${ids.last()}, 结果数=${candidates.size}")
}
// 回退到 1-gram
if (candidates.isEmpty()) {
candidates = getTopFrequentWords(m, fetchCount)
android.util.Log.d("LanguageModel", "回退到1-gram")
}
android.util.Log.d("LanguageModel", "最终使用: $usedModel, 候选词前3: ${candidates.take(3)}")
// 用户点击记录排序
return sortByUserClicks(candidates, topK)
}
/**
* 按用户点击记录排序候选词
* 策略:有点击记录的优先,按点击次数降序,同次数保持原顺序(模型分数)
*/
private fun sortByUserClicks(candidates: List<String>, topK: Int): List<String> {
if (candidates.isEmpty()) return emptyList()
// 按用户点击次数降序排序,同次数保持原顺序(原顺序已是模型分数排序)
val sorted = candidates.sortedWith(
compareByDescending<String> { stats.getCount(it) }
)
return sorted.take(topK)
}
/**
* 根据输入文本预测下一个词
*/
fun predictNext(inputText: String, topK: Int = 10): List<String> {
val words = inputText.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
return predictNext(words, topK)
}
private fun predictFromBigram(m: NgramModel, ctxId: Int, topK: Int): List<String> {
if (ctxId >= m.vocabSize) return emptyList()
val indexPos = ctxId * 6
val offset = m.biRowptr.getInt(indexPos)
val length = m.biRowptr.getShort(indexPos + 4).toInt() and 0xFFFF
if (length == 0) return emptyList()
val results = ArrayList<String>(minOf(length, topK))
for (i in 0 until minOf(length, topK)) {
val dataPos = (offset + i) * 6
val nextId = m.biData.getInt(dataPos)
if (nextId in m.vocab.indices) {
results.add(m.vocab[nextId])
}
}
return results
}
private fun predictFromTrigram(m: NgramModel, ctx1Id: Int, ctx2Id: Int, topK: Int): List<String> {
val ctxIndex = binarySearchTrigramCtx(m, ctx1Id, ctx2Id)
if (ctxIndex < 0) return emptyList()
val indexPos = ctxIndex * 6
val offset = m.triRowptr.getInt(indexPos)
val length = m.triRowptr.getShort(indexPos + 4).toInt() and 0xFFFF
val results = ArrayList<String>(minOf(length, topK))
for (i in 0 until minOf(length, topK)) {
val dataPos = (offset + i) * 6
val nextId = m.triData.getInt(dataPos)
if (nextId in m.vocab.indices) {
results.add(m.vocab[nextId])
}
}
return results
}
private fun binarySearchTrigramCtx(m: NgramModel, ctx1: Int, ctx2: Int): Int {
var low = 0
var high = m.triCtxCount - 1
while (low <= high) {
val mid = (low + high) ushr 1
val pos = mid * 8
val midCtx1 = m.triCtx.getInt(pos)
val midCtx2 = m.triCtx.getInt(pos + 4)
val cmp = when {
ctx1 != midCtx1 -> ctx1.compareTo(midCtx1)
else -> ctx2.compareTo(midCtx2)
}
when {
cmp < 0 -> high = mid - 1
cmp > 0 -> low = mid + 1
else -> return mid
}
}
return -1
}
private fun getTopFrequentWords(m: NgramModel, topK: Int): List<String> {
// 词表已按频率降序排列,直接取前 topK
return m.vocab.take(topK)
}
// ==================== 功能2: 候选词排序 ====================
/**
* 获取前缀补全候选词
* Trie 遍历时已按(词频 + 用户点击)优先级排序,直接返回即可
*
* @param prefix 当前输入前缀
* @param lastWord 上一个词(暂未使用)
* @param topK 返回数量
*/
fun suggest(prefix: String, lastWord: String?, topK: Int = 10): List<String> {
val m = model
val pfx = prefix.trim()
if (pfx.isEmpty() && lastWord == null) {
return if (m != null) getTopFrequentWords(m, topK) else emptyList()
}
// 从 Trie 获取候选词(严格按原始大小写查询)
val candidates = if (pfx.isNotEmpty()) {
safeTriePrefix(pfx, topK)
} else {
emptyList()
}
if (candidates.isEmpty() && m != null && pfx.isEmpty()) {
return getTopFrequentWords(m, topK)
}
return candidates
}
/**
* 对候选词进行综合排序
* 排序规则:用户点击次数(降序)> bigram 分数(升序)> 词频分数(降序)
*/
private fun sortCandidates(candidates: List<String>, lastWord: String?): List<String> {
val m = model
// 获取用户点击统计
val clickCounts = candidates.associateWith { stats.getCount(it) }
// 获取 bigram 分数(如果有上下文)
val bigramScores: Map<String, Int> = if (lastWord != null && m != null) {
val lastId = wordToId[lastWord.lowercase()]
if (lastId != null) getBigramScoresMap(m, lastId) else emptyMap()
} else {
emptyMap()
}
return candidates.sortedWith(
// 1. 有点击记录的优先
compareByDescending<String> { if ((clickCounts[it] ?: 0) > 0) 1 else 0 }
// 2. 按点击次数降序
.thenByDescending { clickCounts[it] ?: 0 }
// 3. 有 bigram 关联的优先
.thenByDescending { if (bigramScores.containsKey(it)) 1 else 0 }
// 4. 按 bigram 分数升序(分数越小越可能)
.thenBy { bigramScores[it] ?: Int.MAX_VALUE }
// 5. 按词频分数降序
.thenByDescending { getUnigramScore(it) }
)
}
/**
* 仅按词频排序(无上下文场景)
*/
fun sortByFrequency(candidates: List<String>): List<String> {
// 先应用用户个性化排序
val userSorted = stats.sortByCount(candidates)
val m = model ?: return userSorted
// 对没有点击记录的词按词频排序
val clickCounts = userSorted.associateWith { stats.getCount(it) }
val hasClicks = userSorted.filter { (clickCounts[it] ?: 0) > 0 }
val noClicks = userSorted.filter { (clickCounts[it] ?: 0) == 0 }
val noClicksSorted = noClicks.sortedByDescending { getUnigramScore(it) }
return hasClicks + noClicksSorted
}
private fun getBigramCandidates(m: NgramModel, ctxId: Int, prefix: String, limit: Int): List<String> {
if (ctxId >= m.vocabSize) return emptyList()
val indexPos = ctxId * 6
val offset = m.biRowptr.getInt(indexPos)
val length = m.biRowptr.getShort(indexPos + 4).toInt() and 0xFFFF
if (length == 0) return emptyList()
val results = ArrayList<String>(minOf(length, limit))
for (i in 0 until length) {
if (results.size >= limit) break
val dataPos = (offset + i) * 6
val nextId = m.biData.getInt(dataPos)
if (nextId in m.vocab.indices) {
val word = m.vocab[nextId]
if (prefix.isEmpty() || word.startsWith(prefix, ignoreCase = true)) {
results.add(word)
}
}
}
return results
}
private fun getBigramScoresMap(m: NgramModel, ctxId: Int): Map<String, Int> {
if (ctxId >= m.vocabSize) return emptyMap()
val indexPos = ctxId * 6
val offset = m.biRowptr.getInt(indexPos)
val length = m.biRowptr.getShort(indexPos + 4).toInt() and 0xFFFF
val scores = HashMap<String, Int>(length)
for (i in 0 until length) {
val dataPos = (offset + i) * 6
val nextId = m.biData.getInt(dataPos)
val score = m.biData.getShort(dataPos + 4).toInt() and 0xFFFF
if (nextId in m.vocab.indices) {
scores[m.vocab[nextId]] = score
}
}
return scores
}
private fun getUnigramScore(word: String): Int {
val m = model ?: return 0
val id = wordToId[word.lowercase()] ?: return 0
if (id >= m.vocabSize) return 0
return m.unigramScores.getShort(id * 2).toInt() and 0xFFFF
}
private fun safeTriePrefix(prefix: String, limit: Int): List<String> {
if (prefix.isEmpty()) return emptyList()
return try {
trie.startsWith(prefix, limit)
} catch (_: Throwable) {
emptyList()
}
}
// ==================== 用户行为记录 ====================
/**
* 记录用户选择的词(用于个性化排序)
* 同时更新 Trie 中该词的优先级,使其在遍历时优先返回
*/
fun recordSelection(word: String) {
stats.incClick(word)
// 获取更新后的点击次数,同步更新 Trie
val newCount = stats.getCount(word)
trie.updateClickFreq(word, newCount)
}
/**
* 获取词的点击次数
*/
fun getClickCount(word: String): Int {
return stats.getCount(word)
}
}

View File

@@ -5,137 +5,98 @@ import java.io.BufferedReader
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.InputStreamReader
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.channels.Channels
import java.nio.channels.FileChannel
import kotlin.math.max
data class BigramModel(
val vocab: List<String>, // 保留全部词(含 <unk>, <s>, </s>),与二元矩阵索引对齐
val uniLogp: FloatArray, // 长度 = vocab.size
val biRowptr: IntArray, // 长度 = vocab.size + 1 (CSR)
val biCols: IntArray, // 长度 = nnz
val biLogp: FloatArray // 长度 = nnz
)
/**
* N-gram 语言模型数据结构
*
* 文件格式:
* - vocab.txt: 词表(每行一个词,行号=词ID按频率降序
* - uni_logp.bin: Unigram 分数 [u16 score, ...]0-1000越高越常用
* - bi_rowptr.bin: Bigram 索引 [u32 offset, u16 length, ...]
* - bi_data.bin: Bigram 数据 [u32 next_id, u16 score, ...]score 越小越可能)
* - tri_ctx.bin: Trigram 上下文 [u32 ctx1, u32 ctx2, ...]
* - tri_rowptr.bin: Trigram 索引
* - tri_data.bin: Trigram 数据
*/
data class NgramModel(
val vocab: List<String>,
val unigramScores: ByteBuffer, // u16 数组
val biRowptr: ByteBuffer, // [u32 offset, u16 length] 数组
val biData: ByteBuffer, // [u32 next_id, u16 score] 数组
val triCtx: ByteBuffer, // [u32, u32] 数组
val triRowptr: ByteBuffer,
val triData: ByteBuffer
) {
val vocabSize: Int get() = vocab.size
val triCtxCount: Int get() = triCtx.capacity() / 8 // 每条目 2 个 u32
}
object LanguageModelLoader {
fun load(context: Context): BigramModel {
fun load(context: Context): NgramModel {
val vocab = context.assets.open("vocab.txt").bufferedReader()
.use(BufferedReader::readLines)
val uniLogp = readFloat32(context, "uni_logp.bin")
val biRowptr = readInt32(context, "bi_rowptr.bin")
val biCols = readInt32(context, "bi_cols.bin")
val biLogp = readFloat32(context, "bi_logp.bin")
val unigramScores = loadBuffer(context, "uni_logp.bin")
val biRowptr = loadBuffer(context, "bi_rowptr.bin")
val biData = loadBuffer(context, "bi_data.bin")
val triCtx = loadBuffer(context, "tri_ctx.bin")
val triRowptr = loadBuffer(context, "tri_rowptr.bin")
val triData = loadBuffer(context, "tri_data.bin")
require(uniLogp.size == vocab.size) { "uni_logp length != vocab size" }
require(biRowptr.size == vocab.size + 1) { "bi_rowptr length invalid" }
require(biCols.size == biLogp.size) { "bi cols/logp nnz mismatch" }
// 基本校验
require(unigramScores.capacity() == vocab.size * 2) {
"uni_logp.bin 大小不匹配: 期望 ${vocab.size * 2}, 实际 ${unigramScores.capacity()}"
}
require(biRowptr.capacity() == vocab.size * 6) {
"bi_rowptr.bin 大小不匹配: 期望 ${vocab.size * 6}, 实际 ${biRowptr.capacity()}"
}
return BigramModel(vocab, uniLogp, biRowptr, biCols, biLogp)
return NgramModel(
vocab = vocab,
unigramScores = unigramScores,
biRowptr = biRowptr,
biData = biData,
triCtx = triCtx,
triRowptr = triRowptr,
triData = triData
)
}
private fun readInt32(context: Context, name: String): IntArray {
private fun loadBuffer(context: Context, name: String): ByteBuffer {
try {
context.assets.openFd(name).use { afd ->
FileInputStream(afd.fileDescriptor).channel.use { channel ->
return readInt32Channel(channel, afd.startOffset, afd.length)
return mapChannel(channel, afd.startOffset, afd.length)
}
}
} catch (e: FileNotFoundException) {
// Compressed assets do not support openFd; fall back to streaming.
// 压缩的 asset 不支持 openFd回退到流式读取
}
context.assets.open(name).use { input ->
return readInt32Stream(input)
return readStream(input)
}
}
private fun readFloat32(context: Context, name: String): FloatArray {
try {
context.assets.openFd(name).use { afd ->
FileInputStream(afd.fileDescriptor).channel.use { channel ->
return readFloat32Channel(channel, afd.startOffset, afd.length)
}
}
} catch (e: FileNotFoundException) {
// Compressed assets do not support openFd; fall back to streaming.
}
context.assets.open(name).use { input ->
return readFloat32Stream(input)
}
}
private fun readInt32Channel(channel: FileChannel, offset: Long, length: Long): IntArray {
require(length % 4L == 0L) { "int32 length invalid: $length" }
require(length <= Int.MAX_VALUE.toLong()) { "int32 asset too large: $length" }
val count = (length / 4L).toInt()
private fun mapChannel(channel: FileChannel, offset: Long, length: Long): ByteBuffer {
require(length <= Int.MAX_VALUE.toLong()) { "文件过大: $length" }
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, offset, length)
mapped.order(ByteOrder.LITTLE_ENDIAN)
val out = IntArray(count)
mapped.asIntBuffer().get(out)
return out
return mapped
}
private fun readFloat32Channel(channel: FileChannel, offset: Long, length: Long): FloatArray {
require(length % 4L == 0L) { "float32 length invalid: $length" }
require(length <= Int.MAX_VALUE.toLong()) { "float32 asset too large: $length" }
val count = (length / 4L).toInt()
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, offset, length)
mapped.order(ByteOrder.LITTLE_ENDIAN)
val out = FloatArray(count)
mapped.asFloatBuffer().get(out)
return out
}
private fun readInt32Stream(input: InputStream): IntArray {
val initialSize = max(1024, input.available() / 4)
var out = IntArray(initialSize)
var count = 0
val buffer = ByteBuffer.allocateDirect(64 * 1024)
private fun readStream(input: InputStream): ByteBuffer {
val bytes = input.readBytes()
val buffer = ByteBuffer.allocateDirect(bytes.size)
buffer.order(ByteOrder.LITTLE_ENDIAN)
Channels.newChannel(input).use { channel ->
while (true) {
val read = channel.read(buffer)
if (read == -1) break
if (read == 0) continue
buffer.flip()
while (buffer.remaining() >= 4) {
if (count == out.size) out = out.copyOf(out.size * 2)
out[count++] = buffer.getInt()
}
buffer.compact()
}
}
buffer.put(bytes)
buffer.flip()
check(buffer.remaining() == 0) { "truncated int32 stream" }
return out.copyOf(count)
}
private fun readFloat32Stream(input: InputStream): FloatArray {
val initialSize = max(1024, input.available() / 4)
var out = FloatArray(initialSize)
var count = 0
val buffer = ByteBuffer.allocateDirect(64 * 1024)
buffer.order(ByteOrder.LITTLE_ENDIAN)
Channels.newChannel(input).use { channel ->
while (true) {
val read = channel.read(buffer)
if (read == -1) break
if (read == 0) continue
buffer.flip()
while (buffer.remaining() >= 4) {
if (count == out.size) out = out.copyOf(out.size * 2)
out[count++] = buffer.getFloat()
}
buffer.compact()
}
}
buffer.flip()
check(buffer.remaining() == 0) { "truncated float32 stream" }
return out.copyOf(count)
return buffer
}
}

View File

@@ -5,6 +5,8 @@ import android.content.Context
import com.example.myapplication.Trie
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.atomic.AtomicBoolean
class WordDictionary(private val context: Context) {
@@ -18,15 +20,21 @@ class WordDictionary(private val context: Context) {
fun loadIfNeeded() {
if (!loaded.compareAndSet(false, true)) return
try {
// 加载词频分数
val freqScores = loadFrequencyScores()
context.assets.open("vocab.txt").use { input ->
BufferedReader(InputStreamReader(input)).useLines { lines ->
var idx = 0
lines.forEach { line ->
val currentIdx = idx
idx++
if (idx <= 3) return@forEach // 跳过 <unk>, <s>, </s>
if (currentIdx < 3) return@forEach // 跳过 <unk>, <s>, </s>
val w = line.trim()
if (w.isNotEmpty()) {
wordTrie.insert(w)
// 获取该词的词频分数0-1000越高越常用
val freq = if (currentIdx < freqScores.size) freqScores[currentIdx] else 0
wordTrie.insert(w, freq)
}
}
}
@@ -35,4 +43,25 @@ class WordDictionary(private val context: Context) {
// 失败也不抛,让输入法继续运行
}
}
/**
* 加载 uni_logp.bin 中的词频分数
* 格式u16 数组,每个词一个分数
*/
private fun loadFrequencyScores(): IntArray {
return try {
context.assets.open("uni_logp.bin").use { input ->
val bytes = input.readBytes()
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val count = bytes.size / 2 // u16 = 2 bytes
val scores = IntArray(count)
for (i in 0 until count) {
scores[i] = buffer.getShort(i * 2).toInt() and 0xFFFF
}
scores
}
} catch (_: Throwable) {
IntArray(0)
}
}
}

View File

@@ -65,8 +65,8 @@ abstract class BaseKeyboard(
protected fun applyBorderToAllKeyViews(root: View?) {
if (root == null) return
val keyMarginPx = 2.dpToPx()
val keyPaddingH = 8.dpToPx()
val keyMarginPx = env.ctx.resources.getDimensionPixelSize(com.example.myapplication.R.dimen.sw_3dp)
val keyPaddingH = env.ctx.resources.getDimensionPixelSize(com.example.myapplication.R.dimen.sw_8dp)
// 忽略 suggestion_0..20(联想栏)
val ignoredIds = HashSet<Int>().apply {

View File

@@ -88,6 +88,15 @@ class MainKeyboard(
others.forEach { applyKeyBackground(rootView, it) }
}
fun setShiftState(on: Boolean) {
if (isShiftOn == on) return
isShiftOn = on
val res = env.ctx.resources
val pkg = env.ctx.packageName
rootView.findViewById<View?>(res.getIdentifier("key_up", "id", pkg))?.isActivated = on
applyKeyBackgroundsForTheme()
}
// -------------------- 事件绑定 --------------------
private fun setupListenersForMain(view: View) {
val res = env.ctx.resources

View File

@@ -8,7 +8,7 @@ import retrofit2.http.*
interface ApiService {
// GET 示例/users/{id}
// GET 示例<EFBFBD>?users/{id}
// @GET("users/{id}")
// suspend fun getUser(
// @Path("id") id: String
@@ -27,12 +27,12 @@ interface ApiService {
@Body body: LoginRequest
): ApiResponse<LoginResponse>
//退出登录
//退出登录
@GET("user/logout")
suspend fun logout(
): ApiResponse<Boolean>
//发送验证
//发送验证邮件
@POST("user/sendVerifyMail")
suspend fun sendVerifyCode(
@Body body: SendVerifyCodeRequest
@@ -44,7 +44,7 @@ interface ApiService {
@Body body: RegisterRequest
): ApiResponse<Boolean>
//验证验证
//验证验证邮件
@POST("user/verifyMailCode")
suspend fun verifyCode(
@Body body: VerifyCodeRequest
@@ -114,7 +114,7 @@ interface ApiService {
@Query("tagId") tagId: Int
): ApiResponse<List<listByTagWithNotLogin>>
//登录用户按标签查询人设列表
//登录用户按标签查询人设列表
@GET("character/listByTag")
suspend fun loggedInPersonaListByTag(
@Query("tagId") tagId: Int
@@ -125,7 +125,7 @@ interface ApiService {
suspend fun personaByTag(
): ApiResponse<List<listByTagWithNotLogin>>
//未登录用户人设列表
//未登录用户人设列表
@GET("character/listWithNotLogin")
suspend fun personaListWithNotLogin(
): ApiResponse<List<listByTagWithNotLogin>>
@@ -149,12 +149,12 @@ interface ApiService {
suspend fun walletBalance(
): ApiResponse<Wallet>
//查询所有主题风格
//查询所有主题风格
@GET("themes/listAllStyles")
suspend fun themeList(
): ApiResponse<List<Theme>>
//按风格查询主题
//按风格查询主题
@GET("themes/listByStyle")
suspend fun themeListByStyle(
@Query("themeStyle") id: Int
@@ -199,8 +199,98 @@ interface ApiService {
suspend fun restoreTheme(
@Query("themeId") themeId: Int
): ApiResponse<Unit>
// =========================================圈子ai陪聊============================================
// 分页查询AI陪聊角色
@POST("ai-companion/page")
suspend fun aiCompanionPage(
@Body body: aiCompanionPageRequest
): ApiResponse<AiCompanionPageResponse>
// 分页查询聊天记录
@POST("chat/history")
suspend fun chatHistory(
@Body body: chatHistoryRequest
): ApiResponse<ChatHistoryResponse>
//点赞/取消点赞AI角色
@POST("ai-companion/like")
suspend fun aiCompanionLike(
@Body body: aiCompanionLikeRequest
): ApiResponse<Boolean>
// 同步对话
@POST("chat/message")
suspend fun chatMessage(
@Body body: chatMessageRequest
): ApiResponse<chatMessageResponse>
// 查询音频状态
@GET("chat/audio/{audioId}")
suspend fun chatAudioStatus(
@Path("audioId") chatId: String
): ApiResponse<ChatAudioStatusResponse>
//语音转文字
@Multipart
@POST("speech/transcribe")
suspend fun speechTranscribe(
@Part file: MultipartBody.Part
): ApiResponse<speechTranscribeResponse>
// 分页查询评论
@POST("ai-companion/comment/page")
suspend fun commentPage(
@Body body: commentPageRequest
): ApiResponse<CommentPageResponse>
// 发表评论
@POST("ai-companion/comment/add")
suspend fun addComment(
@Body body: addCommentRequest
): ApiResponse<Int>
// 点赞评论
@POST("ai-companion/comment/like")
suspend fun likeComment(
@Body body: likeCommentRequest
): ApiResponse<Boolean>
//根据ID获取AI角色详情
@GET("ai-companion/{companionId}")
suspend fun companionDetail(
@Path("companionId") chatId: String
): ApiResponse<CompanionDetailResponse>
//ai角色举报
@POST("ai-companion/report")
suspend fun report(
@Body body: reportRequest
): ApiResponse<Int>
//获取当前用户点赞过的AI角色列表
@GET("ai-companion/liked")
suspend fun companionLiked(
): ApiResponse<List<companionLikedResponse>>
//获取当前用户聊过天的AI角色列表
@GET("ai-companion/chatted")
suspend fun companionChatted(
): ApiResponse<List<companionChattedResponse>>
//重置会话
@POST("chat/session/reset")
suspend fun chatSessionReset(
@Body body: chatSessionResetRequest
): ApiResponse<chatSessionResetResponse>
// =========================================文件=============================================
// zip 文件下载(或其它大文件)——必须 @Streaming
// zip 文件下载(或其它大文件)——必须 @Streaming 注解
@Streaming
@GET("files/{fileName}")
suspend fun downloadZip(

View File

@@ -30,6 +30,10 @@ sealed class AuthEvent {
val bundle: Bundle? = null,
val clearGlobalBackStack: Boolean = false
) : AuthEvent()
data class OpenCirclePage(
val destinationId: Int,
val bundle: Bundle? = null
) : AuthEvent()
object UserUpdated : AuthEvent()
data class CharacterDeleted(val characterId: Int) : AuthEvent()
}

View File

@@ -12,7 +12,7 @@ object BehaviorHttpClient {
private const val TAG = "BehaviorHttp"
// TODO改成你的行为服务 baseUrl必须以 / 结尾)
private const val BASE_URL = "http://192.168.2.21:35310/api/"
private const val BASE_URL = "http://192.168.2.22:35310/api/"
/**
* 请求拦截器:打印请求信息

View File

@@ -1,4 +1,4 @@
// 定义请求 & 响应拦截器
// 定义请求 & 响应拦截器
package com.example.myapplication.network
import android.util.Log
@@ -119,11 +119,22 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain ->
val requestBody = request.body
if (requestBody != null) {
val buffer = Buffer()
requestBody.writeTo(buffer)
sb.append("Body:\n")
sb.append(buffer.readUtf8())
sb.append("\n")
if (shouldLogRequestBody(requestBody)) {
val buffer = Buffer()
requestBody.writeTo(buffer)
val charset = requestBody.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
sb.append("Body:\n")
sb.append(buffer.readString(charset))
sb.append("\n")
} else {
val contentType = requestBody.contentType()?.toString().orEmpty()
val length = try {
requestBody.contentLength()
} catch (_: Exception) {
-1L
}
sb.append("Body: [omitted, contentType=$contentType, contentLength=$length]\n")
}
}
sb.append("================================\n")
@@ -132,6 +143,22 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain ->
chain.proceed(request)
}
private fun shouldLogRequestBody(body: okhttp3.RequestBody): Boolean {
if (body.isDuplex() || body.isOneShot()) return false
val contentType = body.contentType() ?: return false
val type = contentType.type.lowercase()
val subtype = contentType.subtype.lowercase()
if (type == "multipart") return false
if (type == "audio" || type == "video" || type == "image") return false
if (subtype == "octet-stream" || subtype == "zip" || subtype == "gzip") return false
return type == "text" ||
subtype.contains("json") ||
subtype.contains("xml") ||
subtype.contains("x-www-form-urlencoded")
}
//
// ================== 签名工具(严格按你描述规则) ==================
//

View File

@@ -33,7 +33,7 @@ data class LoginResponse(
val token: String
)
//验证码发送邮
//验证码发送邮件请求
data class SendVerifyCodeRequest(
val mailAddress: String
)
@@ -48,7 +48,7 @@ data class RegisterRequest(
val inviteCode: String?
)
//验证验证
//验证验证邮件请求
data class VerifyCodeRequest(
val mailAddress: String,
val verifyCode: String,
@@ -249,3 +249,225 @@ data class deleteThemeRequest(
data class purchaseThemeRequest(
val themeId: Int,
)
// =========================================圈子ai陪聊)============================================
//分页查询AI陪聊角色
data class aiCompanionPageRequest(
val pageNum: Int,
val pageSize: Int,
)
data class AiCompanionPageResponse(
val records: List<AiCompanion>,
val total: Int,
val size: Int,
val current: Int,
val pages: Int
)
data class AiCompanion(
val id: Int,
val name: String,
val avatarUrl: String?,
val coverImageUrl: String?,
val gender: String,
val ageRange: String,
val shortDesc: String,
val introText: String,
val personalityTags: String?,
val speakingStyle: String,
val sortOrder: Int,
val popularityScore: Int,
val prologue: String?,
val prologueAudio: String?,
val likeCount: Int,
val commentCount: Int,
val liked: Boolean,
val createdAt: String,
)
//点赞/取消点赞AI角色
data class aiCompanionLikeRequest(
val companionId: Int,
)
//分页查询聊天记录
data class chatHistoryRequest(
val companionId: Int,
val pageNum: Int,
val pageSize: Int,
)
data class ChatHistoryResponse(
val records: List<ChatRecord>,
val total: Int,
val size: Int,
val current: Int,
val pages: Int
)
data class ChatRecord(
val id: Int,
val sender: Int,
val content: String,
val createdAt: String,
)
// 同步对话
data class chatMessageRequest(
val content: String,
val companionId: Int,
)
data class chatMessageResponse(
val aiResponse: String,
val audioId: String,
val llmDuration: Int,
)
// 查询音频状态
data class ChatAudioStatusResponse(
val audioId: String,
val status: String?,
val audioUrl: String?,
val errorMessage: String?,
)
//语音聊天
data class speechTranscribeResponse(
val transcript: String,
val confidence: Number,
val duration: Number,
val detectedLanguage: String,
)
//分页查询评论
data class commentPageRequest(
val companionId: Int,
val pageNum: Int,
val pageSize: Int,
)
data class CommentPageResponse(
val records: List<Comment>,
val total: Int,
val size: Int,
val current: Int,
val pages: Int,
)
data class Comment(
val id: Int,
val companionId: Int,
val userId: Int,
val userName: String?,
val userAvatar: String?,
val replyToUserName: String?,
val replyToUserId: Int?,
val parentId: Int?,
val rootId: Int?,
val content: String,
val likeCount: Int,
val liked: Boolean,
val createdAt: String,
val replies: List<Comment>?,
val replyCount: Int?,
)
//发表评论
data class addCommentRequest(
val companionId: Int,
val content: String,
val parentId: Int?,//父评论IDNULL表示一级评论
val rootId: Int?,//根评论IDNULL表示根评论
)
//点赞/取消点赞评论
data class likeCommentRequest(
val commentId: Int,//评论ID
)
//根据ID获取AI角色详情
data class CompanionDetailResponse(
val id: Int,
val name: String,
val avatarUrl: String?,
val coverImageUrl: String?,
val gender: String,
val ageRange: String,
val shortDesc: String,
val introText: String,
val personalityTags: String?,
val speakingStyle: String,
val sortOrder: Int,
val popularityScore: Int,
val prologue: String?,
val prologueAudio: String?,
val likeCount: Int,
val commentCount: Int,
val liked: Boolean,
val createdAt: String,
)
//ai角色举报
data class reportRequest(
val companionId: Int,
val reportTypes: List<Int>,
val reportDesc: String,
)
//获取当前用户点赞过的AI角色列表
data class companionLikedResponse(
val id: Int,
val name: String,
val avatarUrl: String?,
val coverImageUrl: String?,
val gender: String,
val ageRange: String,
val shortDesc: String,
val introText: String,
val personalityTags: String?,
val speakingStyle: String,
val sortOrder: Int,
val popularityScore: Int,
val prologue: String?,
val prologueAudio: String?,
val likeCount: Int,
val commentCount: Int,
val liked: Boolean,
val createdAt: String,
)
//获取当前用户聊过天的AI角色列表
data class companionChattedResponse(
val id: Int,
val name: String,
val avatarUrl: String?,
val coverImageUrl: String?,
val gender: String,
val ageRange: String,
val shortDesc: String,
val introText: String,
val personalityTags: String?,
val speakingStyle: String,
val sortOrder: Int,
val popularityScore: Int,
val prologue: String?,
val prologueAudio: String?,
val likeCount: Int,
val commentCount: Int,
val liked: Boolean,
val createdAt: String,
)
// 重置会话
data class chatSessionResetRequest(
val companionId: Int,
)
data class chatSessionResetResponse(
val sessionId: Int,
val companionId: Int,
val resetVersion: Int,
val createdAt: String,
)

View File

@@ -19,7 +19,7 @@ import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
object NetworkClient {
private const val BASE_URL = "http://192.168.2.21:7529/api"
private const val BASE_URL = "http://192.168.2.22:7529/api"
private const val TAG = "999-SSE_TALK"
// ====== 按你给的规则固定值 ======

View File

@@ -10,7 +10,7 @@ import com.example.myapplication.network.FileUploadService
object RetrofitClient {
private const val BASE_URL = "http://192.168.2.21:7529/api/"
private const val BASE_URL = "http://192.168.2.22:7529/api/"
// 保存 ApplicationContext
@Volatile
@@ -21,16 +21,17 @@ object RetrofitClient {
}
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
level = HttpLoggingInterceptor.Level.HEADERS
}
private val okHttpClient: OkHttpClient by lazy {
check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." }
// 创建 OkHttpClient.Builder 实例并设置连接、读取和写入超时时间
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS) // 设置连接超时时间为 15 秒
.readTimeout(30, TimeUnit.SECONDS) // 设置读取超时时间为 30 秒
.writeTimeout(30, TimeUnit.SECONDS) // 设置写入超时时间为 30 秒
// 顺序:请求拦截 -> logging -> 响应拦截
.addInterceptor(requestInterceptor(appContext))

View File

@@ -0,0 +1,285 @@
package com.example.myapplication.ui.circle
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
class ChatMessageAdapter : RecyclerView.Adapter<ChatMessageAdapter.MessageViewHolder>() {
private var items: MutableList<ChatMessage> = mutableListOf()
private var mediaPlayer: MediaPlayer? = null
private var playingMessageId: Long? = null
private var preparedMessageId: Long? = null
init {
setHasStableIds(true)
}
override fun getItemViewType(position: Int): Int {
return if (items[position].isMine) VIEW_TYPE_ME else VIEW_TYPE_BOT
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
val layout = if (viewType == VIEW_TYPE_ME) {
R.layout.item_chat_message_me
} else {
R.layout.item_chat_message_bot
}
val view = LayoutInflater.from(parent.context).inflate(layout, parent, false)
return MessageViewHolder(view)
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
holder.bind(items[position])
}
override fun onViewRecycled(holder: MessageViewHolder) {
holder.onRecycled()
super.onViewRecycled(holder)
}
override fun getItemCount(): Int = items.size
override fun getItemId(position: Int): Long = items[position].id
fun bindMessages(messages: MutableList<ChatMessage>) {
items = messages
notifyDataSetChanged()
}
fun notifyLastInserted() {
val index = items.size - 1
if (index >= 0) {
notifyItemInserted(index)
}
}
fun notifyMessageUpdated(messageId: Long) {
val index = items.indexOfFirst { it.id == messageId }
if (index >= 0) {
notifyItemChanged(index)
}
}
fun release() {
stopPlayback()
mediaPlayer?.release()
mediaPlayer = null
}
private fun stopPlayback() {
val previousId = playingMessageId
val player = mediaPlayer
if (player != null) {
if (player.isPlaying) {
player.stop()
}
player.reset()
}
playingMessageId = null
preparedMessageId = null
if (previousId != null) {
val index = items.indexOfFirst { it.id == previousId }
if (index >= 0) {
items[index].isAudioPlaying = false
notifyItemChanged(index)
}
}
}
private fun ensureMediaPlayer(): MediaPlayer {
val existing = mediaPlayer
if (existing != null) return existing
val created = MediaPlayer()
created.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
mediaPlayer = created
return created
}
private fun toggleAudio(message: ChatMessage) {
val url = message.audioUrl
if (url.isNullOrBlank()) return
val player = ensureMediaPlayer()
val currentId = playingMessageId
if (currentId == message.id && preparedMessageId == message.id) {
if (player.isPlaying) {
player.pause()
message.isAudioPlaying = false
} else {
player.start()
message.isAudioPlaying = true
}
notifyMessageUpdated(message.id)
return
}
if (currentId != null && currentId != message.id) {
stopPlayback()
}
playingMessageId = message.id
preparedMessageId = null
message.isAudioPlaying = false
notifyMessageUpdated(message.id)
try {
val targetId = message.id
player.reset()
player.setOnPreparedListener {
if (playingMessageId != targetId) return@setOnPreparedListener
preparedMessageId = targetId
it.start()
message.isAudioPlaying = true
notifyMessageUpdated(targetId)
}
player.setOnCompletionListener {
if (playingMessageId != targetId) return@setOnCompletionListener
message.isAudioPlaying = false
notifyMessageUpdated(targetId)
playingMessageId = null
preparedMessageId = null
it.reset()
}
player.setOnErrorListener { _, _, _ ->
if (playingMessageId == targetId) {
message.isAudioPlaying = false
notifyMessageUpdated(targetId)
playingMessageId = null
preparedMessageId = null
}
true
}
player.setDataSource(url)
player.prepareAsync()
} catch (_: Exception) {
message.isAudioPlaying = false
playingMessageId = null
preparedMessageId = null
notifyMessageUpdated(message.id)
}
}
inner class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val messageText: TextView = itemView.findViewById(R.id.messageText)
private val audioButton: ImageView? = itemView.findViewById(R.id.audioButton)
private val handler = Handler(Looper.getMainLooper())
private var typingRunnable: Runnable? = null
private var boundMessageId: Long = RecyclerView.NO_ID
private val loadingAnimation =
AnimationUtils.loadAnimation(itemView.context, R.anim.circle_audio_loading)
private val textLoadingAnimation =
AnimationUtils.loadAnimation(itemView.context, R.anim.circle_text_loading)
fun bind(message: ChatMessage) {
if (boundMessageId != message.id) {
stopTyping()
boundMessageId = message.id
}
bindAudioButton(message)
if (message.isLoading) {
stopTyping()
messageText.text = message.text
messageText.startAnimation(textLoadingAnimation)
return
} else {
messageText.clearAnimation()
}
if (message.isMine || message.hasAnimated) {
stopTyping()
messageText.text = message.text
} else if (typingRunnable == null) {
startTypewriter(message)
}
}
fun onRecycled() {
stopTyping()
audioButton?.clearAnimation()
messageText.clearAnimation()
}
private fun bindAudioButton(message: ChatMessage) {
val button = audioButton ?: return
if (message.isMine || message.audioId.isNullOrBlank()) {
button.visibility = View.GONE
button.clearAnimation()
button.setOnClickListener(null)
return
}
button.visibility = View.VISIBLE
if (message.audioUrl.isNullOrBlank()) {
button.setImageResource(android.R.drawable.ic_popup_sync)
button.contentDescription = button.context.getString(R.string.circle_audio_loading)
button.alpha = 0.7f
button.setOnClickListener(null)
button.startAnimation(loadingAnimation)
} else {
button.clearAnimation()
val isPlaying = message.isAudioPlaying
button.setImageResource(
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
)
button.contentDescription = button.context.getString(
if (isPlaying) R.string.circle_audio_pause else R.string.circle_audio_play
)
button.alpha = if (isPlaying) 1f else 0.7f
button.setOnClickListener { toggleAudio(message) }
}
}
private fun startTypewriter(message: ChatMessage) {
val fullText = message.text
if (fullText.isEmpty()) {
messageText.text = ""
message.hasAnimated = true
return
}
var index = 0
val runnable = object : Runnable {
override fun run() {
if (index <= fullText.length) {
messageText.text = fullText.substring(0, index)
index++
if (index <= fullText.length) {
handler.postDelayed(this, TYPE_DELAY_MS)
} else {
message.hasAnimated = true
typingRunnable = null
}
}
}
}
typingRunnable = runnable
handler.post(runnable)
}
private fun stopTyping() {
typingRunnable?.let { handler.removeCallbacks(it) }
typingRunnable = null
}
}
private companion object {
const val VIEW_TYPE_ME = 1
const val VIEW_TYPE_BOT = 2
const val TYPE_DELAY_MS = 28L
}
}

View File

@@ -0,0 +1,259 @@
package com.example.myapplication.ui.circle
import android.view.View
import android.widget.ImageView
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.myapplication.R
class ChatPageViewHolder(
itemView: View,
sharedPool: RecyclerView.RecycledViewPool
) : PageViewHolder(itemView) {
private val titleView: TextView = itemView.findViewById(R.id.pageTitle)
private val likeCountView: TextView = itemView.findViewById(R.id.likeCount)
private val commentCountView: TextView = itemView.findViewById(R.id.commentCount)
private val backgroundView: ImageView = itemView.findViewById(R.id.pageBackground)
private val likeView: ImageView = itemView.findViewById(R.id.like)
private val likeContainer: View = itemView.findViewById(R.id.likeContainer)
private val commentContainer: View = itemView.findViewById(R.id.commentContainer)
private val avatarView: ImageView = itemView.findViewById(R.id.avatar)
private val chatRv: EdgeAwareRecyclerView = itemView.findViewById(R.id.chatRv)
private val footerView: View = itemView.findViewById(R.id.chatFooter)
private val messageAdapter = ChatMessageAdapter()
private var footerBaseBottomMargin = 0
private val loadMoreThresholdPx =
itemView.resources.getDimensionPixelSize(R.dimen.circle_chat_load_more_threshold)
private var hasMoreHistory = true
private var isLoadingHistory = false
private var onLoadMore:
((position: Int, companionId: Int, onResult: (ChatHistoryLoadResult) -> Unit) -> Unit)? =
null
private var historyStateProvider: ((Int) -> ChatHistoryUiState)? = null
private var boundCompanionId: Int = -1
private var boundCommentCount: Int = 0
private var lastLoadRequestAt = 0L
private var onLikeClick: ((position: Int, companionId: Int) -> Unit)? = null
private var onCommentClick: ((companionId: Int, commentCount: Int) -> Unit)? = null
private var onAvatarClick: ((companionId: Int) -> Unit)? = null
private val loadMoreScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
maybeLoadMore(force = false)
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
maybeLoadMore(force = false)
}
}
}
private var boundPageId: Long = -1L
private var boundMessageVersion: Long = -1L
init {
chatRv.layoutManager = LinearLayoutManager(itemView.context).apply {
// 新消息在底部显示,符合聊天习?
stackFromEnd = true
}
chatRv.adapter = messageAdapter
chatRv.itemAnimator = null
chatRv.clipToPadding = false
chatRv.setItemViewCacheSize(20)
chatRv.setRecycledViewPool(sharedPool)
(footerView.layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
footerBaseBottomMargin = lp.bottomMargin
}
chatRv.allowParentInterceptAtTop = {
val state = resolveHistoryState()
!state.hasMore && !state.isLoading
}
chatRv.onTopPull = { maybeLoadMore(force = true) }
chatRv.addOnScrollListener(loadMoreScrollListener)
likeContainer.setOnClickListener {
val position = adapterPosition
if (position == RecyclerView.NO_POSITION || boundCompanionId <= 0) return@setOnClickListener
onLikeClick?.invoke(position, boundCompanionId)
}
commentContainer.setOnClickListener {
if (boundCompanionId <= 0) return@setOnClickListener
onCommentClick?.invoke(boundCompanionId, boundCommentCount)
}
avatarView.setOnClickListener {
if (boundCompanionId <= 0) return@setOnClickListener
onAvatarClick?.invoke(boundCompanionId)
}
}
fun bind(
data: ChatPageData,
inputOverlayHeight: Int,
bottomInset: Int,
historyState: ChatHistoryUiState,
historyStateProvider: (Int) -> ChatHistoryUiState,
onLoadMore: ((position: Int, companionId: Int, onResult: (ChatHistoryLoadResult) -> Unit) -> Unit)?,
onLikeClick: ((position: Int, companionId: Int) -> Unit)?,
onCommentClick: ((companionId: Int, commentCount: Int) -> Unit)?,
onAvatarClick: ((companionId: Int) -> Unit)?
) {
boundCompanionId = data.companionId
hasMoreHistory = historyState.hasMore
isLoadingHistory = historyState.isLoading
this.historyStateProvider = historyStateProvider
this.onLoadMore = onLoadMore
this.onLikeClick = onLikeClick
this.onCommentClick = onCommentClick
this.onAvatarClick = onAvatarClick
titleView.text = data.personaName
likeCountView.text = data.likeCount.toString()
commentCountView.text = data.commentCount.toString()
boundCommentCount = data.commentCount
Glide.with(backgroundView.context)
.load(data.backgroundColor)
.into(backgroundView)
Glide.with(avatarView.context)
.load(data.avatarUrl)
.into(avatarView)
likeView.setImageResource(
if (data. liked) R.drawable.like_select else R.drawable.like
)
val isNewPage = boundPageId != data.pageId
if (isNewPage) {
boundPageId = data.pageId
lastLoadRequestAt = 0L
}
val shouldRebindMessages = isNewPage || boundMessageVersion != data.messageVersion
if (shouldRebindMessages) {
messageAdapter.bindMessages(data.messages)
boundMessageVersion = data.messageVersion
if (isNewPage) {
scrollToBottom()
}
}
updateInsets(inputOverlayHeight, bottomInset)
}
fun updateInsets(inputOverlayHeight: Int, bottomInset: Int) {
// 固定底部信息区抬高,避免被输入框遮挡
val footerMargin = (footerBaseBottomMargin + inputOverlayHeight + bottomInset)
.coerceAtLeast(footerBaseBottomMargin)
val footerLp = footerView.layoutParams as? ViewGroup.MarginLayoutParams
if (footerLp != null && footerLp.bottomMargin != footerMargin) {
footerLp.bottomMargin = footerMargin
footerView.layoutParams = footerLp
}
// 列表只需要考虑系统栏高度即?
val paddingBottom = bottomInset.coerceAtLeast(0)
if (chatRv.paddingBottom != paddingBottom) {
val wasAtBottom = !chatRv.canScrollVertically(1)
chatRv.setPadding(
chatRv.paddingLeft,
chatRv.paddingTop,
chatRv.paddingRight,
paddingBottom
)
if (wasAtBottom) {
scrollToBottom()
}
}
}
private fun resolveHistoryState(): ChatHistoryUiState {
if (boundCompanionId <= 0) {
return ChatHistoryUiState(hasMore = false, isLoading = false)
}
return historyStateProvider?.invoke(boundCompanionId)
?: ChatHistoryUiState(hasMore = hasMoreHistory, isLoading = isLoadingHistory)
}
private fun maybeLoadMore(force: Boolean) {
val state = resolveHistoryState()
if (!state.hasMore || state.isLoading) return
val lm = chatRv.layoutManager as? LinearLayoutManager ?: return
val firstPos = lm.findFirstVisibleItemPosition()
if (firstPos == RecyclerView.NO_POSITION) {
if (force) {
requestLoadMore()
}
return
}
if (firstPos > 0) return
val firstView = lm.findViewByPosition(firstPos) ?: return
if (!force && firstView.top < -loadMoreThresholdPx) return
val now = System.currentTimeMillis()
if (now - lastLoadRequestAt < LOAD_MORE_DEBOUNCE_MS) return
lastLoadRequestAt = now
requestLoadMore()
}
private fun requestLoadMore() {
val callback = onLoadMore ?: return
val position = adapterPosition
if (position == RecyclerView.NO_POSITION) return
if (boundCompanionId <= 0) return
val requestedCompanionId = boundCompanionId
val requestedPageId = boundPageId
isLoadingHistory = true
callback(position, requestedCompanionId) { result ->
if (requestedCompanionId != boundCompanionId || requestedPageId != boundPageId) {
return@callback
}
isLoadingHistory = false
hasMoreHistory = result.hasMore
if (result.insertedCount > 0) {
notifyMessagesPrepended(result.insertedCount)
}
}
}
private fun notifyMessagesPrepended(insertedCount: Int) {
if (insertedCount <= 0) return
val lm = chatRv.layoutManager as? LinearLayoutManager ?: return
val firstPos = lm.findFirstVisibleItemPosition()
if (firstPos == RecyclerView.NO_POSITION) {
messageAdapter.notifyItemRangeInserted(0, insertedCount)
return
}
val firstView = lm.findViewByPosition(firstPos)
val offset = firstView?.top ?: 0
messageAdapter.notifyItemRangeInserted(0, insertedCount)
lm.scrollToPositionWithOffset(firstPos + insertedCount, offset)
}
fun notifyMessageAppended() {
messageAdapter.notifyLastInserted()
scrollToBottom()
}
fun notifyMessageUpdated(messageId: Long) {
messageAdapter.notifyMessageUpdated(messageId)
}
override fun onRecycled() {
messageAdapter.release()
chatRv.stopScroll()
}
private fun scrollToBottom() {
val lastIndex = messageAdapter.itemCount - 1
if (lastIndex >= 0) {
chatRv.scrollToPosition(lastIndex)
}
}
private companion object {
const val LOAD_MORE_DEBOUNCE_MS = 600L
}
}

View File

@@ -0,0 +1,36 @@
package com.example.myapplication.ui.circle
data class ChatMessage(
val id: Long,
var text: String,
val isMine: Boolean,
val timestamp: Long,
var audioId: String? = null,
var audioUrl: String? = null,
var isAudioPlaying: Boolean = false,
var hasAnimated: Boolean = true,
var isLoading: Boolean = false
)
data class ChatPageData(
val pageId: Long,
val companionId: Int,
val personaName: String,
val messages: MutableList<ChatMessage>,
val backgroundColor: String?,
val avatarUrl: String?,
var likeCount: Int,
var commentCount: Int,
var liked: Boolean,
var messageVersion: Long = 0
)
data class ChatHistoryUiState(
val hasMore: Boolean,
val isLoading: Boolean
)
data class ChatHistoryLoadResult(
val insertedCount: Int,
val hasMore: Boolean
)

View File

@@ -0,0 +1,658 @@
package com.example.myapplication.ui.circle
import android.app.ActivityManager
import android.content.Context
import android.graphics.Color
import android.util.LruCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import com.example.myapplication.network.ApiService
import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.AiCompanion
import com.example.myapplication.network.ChatHistoryResponse
import com.example.myapplication.network.ChatRecord
import com.example.myapplication.network.aiCompanionPageRequest
import com.example.myapplication.network.chatHistoryRequest
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
import java.util.concurrent.atomic.AtomicLong
import android.util.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class CircleChatRepository(
context: Context,
val totalPages: Int,
private val preloadCount: Int,
private val apiService: ApiService
) {
// LRU 缓存最近使用页面,避免内存无限增长。
private val cacheSize = computeCacheSize(context, totalPages)
private val cache = object : LruCache<Int, ChatPageData>(cacheSize) {}
private val companionCache = object : LruCache<Int, AiCompanion>(cacheSize) {}
private val pageFetchSize = computePageFetchSize(preloadCount)
.coerceAtMost(totalPages)
.coerceAtLeast(1)
private val lock = Any()
// 记录正在加载的页,避免重复加载。
private val inFlight = HashSet<Int>()
private val pageInFlight = HashSet<Int>()
private val pageFetched = HashSet<Int>()
private val historyStates = HashMap<Int, ChatHistoryState>()
@Volatile
private var knownTotalPages: Int? = null
@Volatile
private var availablePages: Int = totalPages
var onTotalPagesChanged: ((Int) -> Unit)? = null
// 后台协程用于预加载。
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val messageId = AtomicLong((totalPages.toLong() + 1) * 1_000_000L)
//获取指定位置的聊天页面数据
fun getPage(position: Int): ChatPageData {
if (position < 0 || position >= availablePages) {
return emptyPage(position)
}
val cached = synchronized(lock) { cache.get(position) }
if (cached != null) return cached
val page = createPage(position)
return synchronized(lock) {
val existing = cache.get(position)
if (existing != null) {
inFlight.remove(position)
existing
} else {
cache.put(position, page)
inFlight.remove(position)
page
}
}
}
//主要功能是根据用户当前查看的页面位置,预加载其前后若干页的数据,并将这些数据放入缓存中
fun preloadAround(position: Int) {
val maxPages = availablePages
if (maxPages <= 0) return
val start = max(0, position - preloadCount)//
val end = min(maxPages - 1, position + preloadCount)//
preloadRange(start, end, pageFetchSize, DEFAULT_CHAT_PAGE_SIZE)
}
fun preloadInitialPages() {
val maxPages = availablePages
if (maxPages <= 0) return
val end = min(maxPages - 1, pageFetchSize - 1)
preloadRange(0, end, pageFetchSize, DEFAULT_CHAT_PAGE_SIZE)
}
fun getAvailablePages(): Int = availablePages
fun getHistoryUiState(companionId: Int): ChatHistoryUiState {
if (companionId <= 0) {
return ChatHistoryUiState(hasMore = false, isLoading = false)
}
synchronized(lock) {
val state = historyStates[companionId]
return if (state != null) {
ChatHistoryUiState(hasMore = state.hasMore, isLoading = state.isLoading)
} else {
ChatHistoryUiState(hasMore = true, isLoading = false)
}
}
}
fun loadMoreHistory(
position: Int,
companionId: Int,
onResult: (ChatHistoryLoadResult) -> Unit
) {
if (companionId <= 0) return
var nextPage = 2
var hasMoreSnapshot = true
val shouldLoad = synchronized(lock) {
val state = historyStates[companionId]
hasMoreSnapshot = state?.hasMore ?: true
when {
state == null -> {
historyStates[companionId] = ChatHistoryState(
nextPage = nextPage,
hasMore = true,
isLoading = true
)
true
}
!state.hasMore || state.isLoading -> false
else -> {
state.isLoading = true
nextPage = state.nextPage
true
}
}
}
if (!shouldLoad) {
if (!hasMoreSnapshot) {
onResult(ChatHistoryLoadResult(insertedCount = 0, hasMore = false))
}
return
}
scope.launch(Dispatchers.IO) {
val response = fetchChatRecords(companionId, nextPage, DEFAULT_CHAT_PAGE_SIZE)
val data = response.data
val mapped = mapChatRecords(data?.records)
var insertedCount = 0
var hasMore = true
synchronized(lock) {
val state = historyStates[companionId]
val pageData = cache.get(position)
val filtered = if (pageData != null && mapped.isNotEmpty()) {
val existingIds = pageData.messages.asSequence()
.map { it.id }
.toHashSet()
mapped.filter { !existingIds.contains(it.id) }
} else {
mapped
}
if (pageData != null && filtered.isNotEmpty()) {
pageData.messages.addAll(0, filtered)
}
insertedCount = filtered.size
if (state != null) {
if (data != null) {
val computedHasMore = when {
data.pages > 0 -> data.current < data.pages
data.records.isNotEmpty() -> true
else -> false
}
if (state.hasMore) {
state.hasMore = computedHasMore
}
state.nextPage = max(state.nextPage, data.current + 1)
}
state.isLoading = false
hasMore = state.hasMore
} else {
hasMore = data?.let { it.current < it.pages } ?: true
}
}
withContext(Dispatchers.Main) {
onResult(ChatHistoryLoadResult(insertedCount = insertedCount, hasMore = hasMore))
}
}
}
// 指定位置的聊天页面添加用户消息
fun addUserMessage(position: Int, text: String, isLoading: Boolean = false): ChatMessage {
val message = ChatMessage(
id = messageId.getAndIncrement(),
text = text,
isMine = true,
timestamp = System.currentTimeMillis(),
hasAnimated = true,
isLoading = isLoading
)
synchronized(lock) {
// 消息存放在页面缓存中。
val page = getPage(position)
page.messages.add(message)
page.messageVersion++
}
return message
}
//在指定位置的聊天页面中添加一条由机器人发送的消息。
fun addBotMessage(
position: Int,
text: String,
audioId: String? = null,
animate: Boolean = false,
isLoading: Boolean = false
): ChatMessage {
val message = ChatMessage(
id = messageId.getAndIncrement(),
text = text,
isMine = false,
timestamp = System.currentTimeMillis(),
audioId = audioId,
hasAnimated = !animate,
isLoading = isLoading
)
synchronized(lock) {
val page = getPage(position)
page.messages.add(message)
page.messageVersion++
}
return message
}
fun touchMessages(position: Int) {
synchronized(lock) {
getPage(position).messageVersion++
}
}
fun updateLikeState(position: Int, companionId: Int, liked: Boolean, likeCount: Int): Boolean {
synchronized(lock) {
val page = cache.get(position)
if (page == null || page.companionId != companionId) return false
page.liked = liked
page.likeCount = likeCount
companionCache.get(position)?.let { companion ->
if (companion.id == companionId) {
companionCache.put(position, companion.copy(liked = liked, likeCount = likeCount))
}
}
return true
}
}
fun updateCommentCount(companionId: Int, newCount: Int): List<Int> {
if (companionId <= 0) return emptyList()
val sanitized = newCount.coerceAtLeast(0)
val updatedPositions = ArrayList<Int>()
synchronized(lock) {
for ((position, page) in cache.snapshot()) {
if (page.companionId == companionId) {
if (page.commentCount != sanitized) {
page.commentCount = sanitized
updatedPositions.add(position)
}
}
}
for ((position, companion) in companionCache.snapshot()) {
if (companion.id == companionId && companion.commentCount != sanitized) {
companionCache.put(position, companion.copy(commentCount = sanitized))
}
}
}
return updatedPositions
}
// fun buildBotReply(userText: String): String {
// val seed = userText.hashCode().toLong()
// val random = Random(seed)
// return sampleLines[random.nextInt(sampleLines.size)]
// }
fun close() {
scope.cancel()
}
/**
* 清除指定 companionId 对应页面的聊天消息。
* 返回被清除的页面 position 列表,用于通知 UI 刷新。
*/
fun clearMessagesForCompanion(companionId: Int): List<Int> {
if (companionId <= 0) return emptyList()
val clearedPositions = ArrayList<Int>()
synchronized(lock) {
for ((position, page) in cache.snapshot()) {
if (page.companionId == companionId) {
page.messages.clear()
page.messageVersion++
clearedPositions.add(position)
}
}
historyStates.remove(companionId)
}
return clearedPositions
}
//主要功能是确保指定位置的聊天页面数据已经存在于缓存中。如果指定位置的页面数据不存在,则生成该页面的数据并将其放入缓存中
private fun createPage(position: Int): ChatPageData {
val cachedCompanion = synchronized(lock) { companionCache.get(position) }
val companionInfo = cachedCompanion ?: run {
val pageNum = position / pageFetchSize + 1
val records = fetchCompanionPage(pageNum, pageFetchSize)
val index = position - (pageNum - 1) * pageFetchSize
records.getOrNull(index)
}
if (companionInfo == null) {
return emptyPage(position)
}
val historyResponse = fetchChatRecords(
companionInfo.id,
1,
DEFAULT_CHAT_PAGE_SIZE
).data
val messages = historyResponse?.records
updateHistoryState(companionInfo.id, historyResponse, 1)
Log.d("1314520-CircleChatRepository", "createPage: $position")
return buildPageData(position, companionInfo, messages)
}
private fun preloadRange(start: Int, end: Int, pageSize: Int, chatPageSize: Int) {
val maxPages = availablePages
if (maxPages <= 0) return
val safeStart = start.coerceAtLeast(0)
val safeEnd = end.coerceAtMost(maxPages - 1)
if (safeStart > safeEnd) return
val firstBatch = safeStart / pageSize
val lastBatch = safeEnd / pageSize
for (batchIndex in firstBatch..lastBatch) {
val batchStart = batchIndex * pageSize
val batchEnd = min(maxPages - 1, batchStart + pageSize - 1)
val targetPositions = ArrayList<Int>()
synchronized(lock) {
val rangeStart = max(safeStart, batchStart)
val rangeEnd = min(safeEnd, batchEnd)
for (pos in rangeStart..rangeEnd) {
if (cache.get(pos) == null && !inFlight.contains(pos)) {
inFlight.add(pos)
targetPositions.add(pos)
}
}
}
if (targetPositions.isEmpty()) continue
scope.launch {
preloadBatch(batchIndex, pageSize, chatPageSize, targetPositions)
}
}
}
private fun preloadBatch(
batchIndex: Int,
pageSize: Int,
chatPageSize: Int,
targetPositions: List<Int>
) {
val maxPages = availablePages
if (maxPages <= 0) {
clearInFlight(targetPositions.toHashSet())
return
}
val pageNum = batchIndex + 1
val records = fetchCompanionPage(pageNum, pageSize)
val base = batchIndex * pageSize
val targetSet = targetPositions.toHashSet()
if (records.isNotEmpty()) {
for ((index, record) in records.withIndex()) {
val position = base + index
if (position >= maxPages) break
if (!targetSet.contains(position)) continue
val historyResponse = fetchChatRecords(record.id, 1, chatPageSize).data
val messages = historyResponse?.records
updateHistoryState(record.id, historyResponse, 1)
val pageData = buildPageData(position, record, messages)
synchronized(lock) {
if (cache.get(position) == null) {
cache.put(position, pageData)
}
inFlight.remove(position)
}
}
}
clearInFlight(targetSet)
}
private fun clearInFlight(targetPositions: Set<Int>) {
synchronized(lock) {
for (pos in targetPositions) {
if (cache.get(pos) == null) {
inFlight.remove(pos)
}
}
}
}
private fun resolveAvailablePages(total: Int?, pages: Int?, size: Int?): Int? {
if (total != null && total >= 0) return total
if (pages != null && pages >= 0 && size != null && size > 0) return pages * size
return null
}
private fun updateAvailablePages(newValue: Int) {
val clamped = newValue.coerceAtLeast(0).coerceAtMost(totalPages)
if (clamped == availablePages) return
availablePages = clamped
onTotalPagesChanged?.invoke(clamped)
}
private fun collectCachedPage(pageNum: Int, pageSize: Int): List<AiCompanion> {
val maxPages = availablePages
if (maxPages <= 0) return emptyList()
val startPos = (pageNum - 1) * pageSize
val endPos = min(maxPages - 1, startPos + pageSize - 1)
val cached = ArrayList<AiCompanion>()
for (pos in startPos..endPos) {
companionCache.get(pos)?.let { cached.add(it) }
}
return cached
}
private fun fetchCompanionPage(pageNum: Int, pageSize: Int): List<AiCompanion> {
val maxPages = knownTotalPages
if (maxPages != null && pageNum > maxPages) {
synchronized(lock) {
pageFetched.add(pageNum)
}
return emptyList()
}
synchronized(lock) {
if (pageFetched.contains(pageNum)) {
return collectCachedPage(pageNum, pageSize)
}
while (pageInFlight.contains(pageNum)) {
try {
(lock as java.lang.Object).wait()
} catch (_: InterruptedException) {
return collectCachedPage(pageNum, pageSize)
}
if (pageFetched.contains(pageNum)) {
return collectCachedPage(pageNum, pageSize)
}
}
pageInFlight.add(pageNum)
}
var records: List<AiCompanion> = emptyList()
var shouldMarkFetched = false
try {
val response = fetchPageDataSync(pageNum, pageSize)
val data = response.data
records = data?.records.orEmpty()
if (data?.pages != null && data.pages > 0) {
knownTotalPages = data.pages
}
val available = resolveAvailablePages(data?.total, data?.pages, data?.size)
if (available != null) {
updateAvailablePages(available)
}
shouldMarkFetched = data != null
} finally {
val startPos = (pageNum - 1) * pageSize
synchronized(lock) {
for ((index, record) in records.withIndex()) {
val position = startPos + index
if (position in 0 until totalPages) {
companionCache.put(position, record)
}
}
if (shouldMarkFetched) {
pageFetched.add(pageNum)
}
pageInFlight.remove(pageNum)
(lock as java.lang.Object).notifyAll()
}
}
return records
}
private fun buildPageData(
position: Int,
companionInfo: AiCompanion,
records: List<ChatRecord>?
): ChatPageData {
val messages = mapChatRecords(records)
return ChatPageData(
pageId = position.toLong(),
companionId = companionInfo.id,
personaName = companionInfo.name,
messages = messages,
backgroundColor = companionInfo.coverImageUrl,
avatarUrl = companionInfo.avatarUrl,
likeCount = companionInfo.likeCount,
commentCount = companionInfo.commentCount,
liked = companionInfo.liked
)
}
private fun updateHistoryState(
companionId: Int,
response: ChatHistoryResponse?,
loadedPage: Int
) {
synchronized(lock) {
val current = response?.current ?: loadedPage
val computedHasMore = when {
response == null -> true
response.pages > 0 -> current < response.pages
response.records.isNotEmpty() -> true
else -> false
}
val nextPage = if (response == null) loadedPage else current + 1
val existing = historyStates[companionId]
if (existing == null) {
historyStates[companionId] = ChatHistoryState(
nextPage = nextPage,
hasMore = computedHasMore,
isLoading = false
)
} else {
existing.nextPage = max(existing.nextPage, nextPage)
if (response != null && existing.hasMore) {
existing.hasMore = computedHasMore
}
}
}
}
private fun mapChatRecords(records: List<ChatRecord>?): MutableList<ChatMessage> {
if (records.isNullOrEmpty()) return mutableListOf()
val reversedMessages = records.reversed()
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
val messages = ArrayList<ChatMessage>(reversedMessages.size)
for (record in reversedMessages) {
val isMine = record.sender == 1
val timestamp = try {
dateFormat.parse(record.createdAt)?.time ?: System.currentTimeMillis()
} catch (_: Exception) {
System.currentTimeMillis()
}
messages.add(
ChatMessage(
id = record.id.toLong(),
text = record.content,
isMine = isMine,
timestamp = timestamp,
hasAnimated = true
)
)
}
return messages
}
private fun emptyPage(position: Int): ChatPageData {
return ChatPageData(
pageId = position.toLong(),
companionId = -1,
personaName = "",
messages = mutableListOf(),
backgroundColor = "",
avatarUrl = "",
likeCount = 0,
commentCount = 0,
liked = false
)
}
private fun computeCacheSize(context: Context, totalPages: Int): Int {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
// 根据内存等级调整缓存大小。
val base = when {
am.isLowRamDevice -> 32
am.memoryClass >= 512 -> 120
am.memoryClass >= 384 -> 96
am.memoryClass >= 256 -> 72
am.memoryClass >= 192 -> 56
else -> 40
}
return base.coerceAtMost(totalPages).coerceAtLeast(24)
}
private data class ChatHistoryState(
var nextPage: Int,
var hasMore: Boolean,
var isLoading: Boolean
)
companion object {
const val DEFAULT_PAGE_COUNT = 200
const val DEFAULT_CHAT_PAGE_SIZE = 20
private const val MIN_FETCH_BATCH_SIZE = 4
private const val MAX_FETCH_BATCH_SIZE = 20
fun computePreloadCount(context: Context): Int {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
if (am.isLowRamDevice) return 4
return when {
am.memoryClass >= 512 -> 10
am.memoryClass >= 384 -> 8
am.memoryClass >= 256 -> 6
am.memoryClass >= 192 -> 5
else -> 4
}
}
fun computePageFetchSize(preloadCount: Int): Int {
val desired = preloadCount * 2 + 1
return desired.coerceAtLeast(MIN_FETCH_BATCH_SIZE)
.coerceAtMost(MAX_FETCH_BATCH_SIZE)
}
}
private fun fetchPageDataSync(pageNum: Int, pageSize: Int) =
runBlocking(Dispatchers.IO) {
try {
apiService.aiCompanionPage(
aiCompanionPageRequest(pageNum = pageNum, pageSize = pageSize)
)
} catch (e: Exception) {
Log.e("CircleChatRepository", "fetchPageDataSync failed: ${e.message}", e)
ApiResponse(-1, e.message ?: "Network error", null)
}
}
//分页查询聊天记录
fun fetchChatRecords(companionId: Int, pageNum: Int, pageSize: Int) =
runBlocking(Dispatchers.IO) {
try {
apiService.chatHistory(
chatHistoryRequest(companionId = companionId, pageNum = pageNum, pageSize = pageSize)
)
} catch (e: Exception) {
Log.e("CircleChatRepository", "fetchChatRecords failed: ${e.message}", e)
ApiResponse(-1, e.message ?: "Network error", null)
}
}
}

View File

@@ -0,0 +1,46 @@
package com.example.myapplication.ui.circle
import android.content.Context
import com.example.myapplication.network.ApiService
import java.util.concurrent.atomic.AtomicBoolean
object CircleChatRepositoryProvider {
@Volatile
private var repository: CircleChatRepository? = null
private val warmUpStarted = AtomicBoolean(false)
fun get(
context: Context,
apiService: ApiService,
totalPages: Int,
preloadCount: Int
): CircleChatRepository {
val appContext = context.applicationContext
return repository ?: synchronized(this) {
repository ?: CircleChatRepository(
context = appContext,
totalPages = totalPages,
preloadCount = preloadCount,
apiService = apiService
).also { repository = it }
}
}
fun warmUp(
context: Context,
apiService: ApiService,
totalPages: Int,
preloadCount: Int
) {
val repo = get(
context = context,
apiService = apiService,
totalPages = totalPages,
preloadCount = preloadCount
)
if (warmUpStarted.compareAndSet(false, true)) {
repo.preloadInitialPages()
}
}
}

View File

@@ -0,0 +1,273 @@
package com.example.myapplication.ui.circle
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.Comment
class CircleCommentAdapter(
private val sharedReplyPool: RecyclerView.RecycledViewPool,
private val isContentExpanded: (Int) -> Boolean,
private val onToggleContent: (Int) -> Unit,
private val onLoadMoreReplies: (Int) -> Unit,
private val onCollapseReplies: (Int) -> Unit,
private val onReplyClick: (Comment) -> Unit,
private val onLikeClick: (Comment) -> Unit
) : ListAdapter<CommentItem, CircleCommentAdapter.CommentViewHolder>(DiffCallback) {
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_circle_comment, parent, false)
return CommentViewHolder(view)
}
override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun onBindViewHolder(
holder: CommentViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
holder.bind(getItem(position))
return
}
val item = getItem(position)
var flags = 0
payloads.forEach { payload ->
if (payload is Int) {
flags = flags or payload
}
}
holder.bindPartial(item, flags)
}
override fun getItemId(position: Int): Long = getItem(position).comment.id.toLong()
inner class CommentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val avatarView: ImageView = itemView.findViewById(R.id.commentAvatar)
private val userNameView: TextView = itemView.findViewById(R.id.commentUserName)
private val timeView: TextView = itemView.findViewById(R.id.commentTime)
private val contentView: TextView = itemView.findViewById(R.id.commentContent)
private val contentToggle: TextView = itemView.findViewById(R.id.commentContentToggle)
private val replyButton: TextView = itemView.findViewById(R.id.commentReplyButton)
private val likeContainer: View = itemView.findViewById(R.id.commentLikeContainer)
private val likeIcon: ImageView = itemView.findViewById(R.id.commentLikeIcon)
private val likeCountView: TextView = itemView.findViewById(R.id.commentLikeCount)
private val repliesList: RecyclerView = itemView.findViewById(R.id.commentRepliesList)
private val repliesToggle: TextView = itemView.findViewById(R.id.commentRepliesToggle)
private val repliesCollapse: TextView = itemView.findViewById(R.id.commentRepliesCollapse)
private val replyAdapter = CircleCommentReplyAdapter(
onReplyClick = onReplyClick,
onLikeClick = onLikeClick,
onToggleContent = onToggleContent
)
private var boundId: Int = -1
init {
repliesList.layoutManager = LinearLayoutManager(itemView.context)
repliesList.adapter = replyAdapter
repliesList.itemAnimator = null
repliesList.setRecycledViewPool(sharedReplyPool)
}
fun bind(item: CommentItem) {
val comment = item.comment
boundId = comment.id
val context = itemView.context
val baseName = comment.userName?.takeIf { it.isNotBlank() }
?: context.getString(R.string.circle_comment_user_anonymous)
val displayName = buildDisplayName(baseName, comment.replyToUserName)
Glide.with(avatarView)
.load(comment.userAvatar)
.placeholder(R.drawable.default_avatar)
.error(R.drawable.default_avatar)
.into(avatarView)
userNameView.text = displayName
timeView.text = CommentTimeFormatter.format(context, comment.createdAt)
contentView.text = comment.content
bindContentToggle(item)
replyButton.setOnClickListener { onReplyClick(comment) }
likeContainer.setOnClickListener { onLikeClick(comment) }
contentToggle.setOnClickListener { onToggleContent(comment.id) }
repliesToggle.setOnClickListener { onLoadMoreReplies(comment.id) }
repliesCollapse.setOnClickListener { onCollapseReplies(comment.id) }
val liked = comment.liked
likeIcon.setImageResource(
if (liked) R.drawable.comment_has_been_liked else R.drawable.comment_likes
)
likeCountView.text = comment.likeCount.toString()
bindReplies(item)
}
fun bindPartial(item: CommentItem, flags: Int) {
boundId = item.comment.id
likeContainer.setOnClickListener { onLikeClick(item.comment) }
repliesToggle.setOnClickListener { onLoadMoreReplies(item.comment.id) }
repliesCollapse.setOnClickListener { onCollapseReplies(item.comment.id) }
if (flags and PAYLOAD_LIKE != 0) {
updateLike(item)
}
if (flags and PAYLOAD_REPLIES != 0) {
bindReplies(item)
}
if (flags and PAYLOAD_CONTENT != 0) {
contentView.text = item.comment.content
bindContentToggle(item)
}
}
private fun updateLike(item: CommentItem) {
val liked = item.comment.liked
likeIcon.setImageResource(
if (liked) R.drawable.comment_has_been_liked else R.drawable.comment_likes
)
likeCountView.text = item.comment.likeCount.toString()
}
private fun bindContentToggle(item: CommentItem) {
val isExpanded = item.isContentExpanded
contentView.maxLines = if (isExpanded) Int.MAX_VALUE else COLLAPSED_MAX_LINES
contentView.ellipsize = if (isExpanded) null else TextUtils.TruncateAt.END
contentToggle.text = itemView.context.getString(
if (isExpanded) R.string.circle_comment_collapse else R.string.circle_comment_expand
)
contentToggle.visibility = if (isExpanded) View.VISIBLE else View.GONE
contentView.post {
if (boundId != item.comment.id) return@post
if (isExpanded) {
contentToggle.visibility = View.VISIBLE
return@post
}
val layout = contentView.layout ?: return@post
val maxLine = COLLAPSED_MAX_LINES.coerceAtLeast(1)
val lastLine = minOf(layout.lineCount, maxLine) - 1
if (lastLine < 0) {
contentToggle.visibility = View.GONE
return@post
}
val textLength = contentView.text?.length ?: 0
val lineEnd = layout.getLineEnd(lastLine)
val hasEllipsis = layout.getEllipsisCount(lastLine) > 0
val hasOverflow = hasEllipsis || lineEnd < textLength
contentToggle.visibility = if (hasOverflow) View.VISIBLE else View.GONE
}
}
private fun bindReplies(item: CommentItem) {
val replies = item.comment.replies.orEmpty()
if (replies.isEmpty()) {
repliesList.visibility = View.GONE
repliesToggle.visibility = View.GONE
replyAdapter.submitList(emptyList())
return
}
val totalReplies = item.comment.replyCount ?: replies.size
val visibleCount = item.visibleReplyCount.coerceAtMost(totalReplies).coerceAtLeast(0)
val visibleReplies = if (visibleCount >= totalReplies) replies else replies.take(visibleCount)
val replyItems = visibleReplies.map { reply ->
ReplyItem(reply, isContentExpanded(reply.id))
}
repliesList.visibility = if (visibleReplies.isEmpty()) View.GONE else View.VISIBLE
replyAdapter.submitList(replyItems)
val shouldShowToggle = totalReplies > REPLY_INITIAL_COUNT
if (shouldShowToggle) {
val remaining = (totalReplies - visibleCount).coerceAtLeast(0)
repliesToggle.visibility = if (remaining > 0) View.VISIBLE else View.GONE
if (remaining > 0) {
repliesToggle.text = itemView.context.getString(
R.string.circle_comment_replies_load_more,
remaining
)
}
repliesCollapse.visibility = if (visibleCount > REPLY_INITIAL_COUNT) {
View.VISIBLE
} else {
View.GONE
}
} else {
repliesToggle.visibility = View.GONE
repliesCollapse.visibility = View.GONE
}
}
}
private fun buildDisplayName(baseName: String, replyToUserName: String?): String {
val target = replyToUserName?.takeIf { it.isNotBlank() } ?: return baseName
return "$baseName @$target"
}
companion object {
private const val COLLAPSED_MAX_LINES = 4
private const val REPLY_INITIAL_COUNT = 2
private const val REPLY_PAGE_SIZE = 5
private const val PAYLOAD_LIKE = 1
private const val PAYLOAD_REPLIES = 1 shl 1
private const val PAYLOAD_CONTENT = 1 shl 2
private val DiffCallback = object : DiffUtil.ItemCallback<CommentItem>() {
override fun areItemsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
return oldItem.comment.id == newItem.comment.id
}
override fun areContentsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: CommentItem, newItem: CommentItem): Any? {
val commentSame = oldItem.comment.content == newItem.comment.content &&
oldItem.comment.userName == newItem.comment.userName &&
oldItem.comment.userAvatar == newItem.comment.userAvatar &&
oldItem.comment.createdAt == newItem.comment.createdAt
if (!commentSame) return null
var flags = 0
if (oldItem.isContentExpanded != newItem.isContentExpanded) {
flags = flags or PAYLOAD_CONTENT
}
if (oldItem.comment.liked != newItem.comment.liked ||
oldItem.comment.likeCount != newItem.comment.likeCount
) {
flags = flags or PAYLOAD_LIKE
}
if (oldItem.visibleReplyCount != newItem.visibleReplyCount ||
oldItem.comment.replyCount != newItem.comment.replyCount ||
oldItem.comment.replies != newItem.comment.replies
) {
flags = flags or PAYLOAD_REPLIES
}
return if (flags == 0) null else flags
}
}
}
}
data class CommentItem(
val comment: Comment,
val isContentExpanded: Boolean,
val visibleReplyCount: Int
)

View File

@@ -0,0 +1,194 @@
package com.example.myapplication.ui.circle
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.Comment
class CircleCommentReplyAdapter(
private val onReplyClick: (Comment) -> Unit,
private val onLikeClick: (Comment) -> Unit,
private val onToggleContent: (Int) -> Unit
) : ListAdapter<ReplyItem, CircleCommentReplyAdapter.ReplyViewHolder>(DiffCallback) {
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReplyViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_circle_comment_reply, parent, false)
return ReplyViewHolder(view)
}
override fun onBindViewHolder(holder: ReplyViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun onBindViewHolder(
holder: ReplyViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
holder.bind(getItem(position))
return
}
val item = getItem(position)
var flags = 0
payloads.forEach { payload ->
if (payload is Int) {
flags = flags or payload
}
}
holder.bindPartial(item, flags)
}
override fun getItemId(position: Int): Long = getItem(position).comment.id.toLong()
inner class ReplyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val avatarView: ImageView = itemView.findViewById(R.id.replyAvatar)
private val userNameView: TextView = itemView.findViewById(R.id.replyUserName)
private val timeView: TextView = itemView.findViewById(R.id.replyTime)
private val contentView: TextView = itemView.findViewById(R.id.replyContent)
private val contentToggle: TextView = itemView.findViewById(R.id.replyContentToggle)
private val replyButton: TextView = itemView.findViewById(R.id.replyButton)
private val likeContainer: View = itemView.findViewById(R.id.replyLikeContainer)
private val likeIcon: ImageView = itemView.findViewById(R.id.replyLikeIcon)
private val likeCountView: TextView = itemView.findViewById(R.id.replyLikeCount)
private var boundId: Int = -1
fun bind(item: ReplyItem) {
val comment = item.comment
boundId = comment.id
val context = itemView.context
val baseName = comment.userName?.takeIf { it.isNotBlank() }
?: context.getString(R.string.circle_comment_user_anonymous)
val displayName = buildDisplayName(baseName, comment.replyToUserName)
Glide.with(avatarView)
.load(comment.userAvatar)
.placeholder(R.drawable.default_avatar)
.error(R.drawable.default_avatar)
.into(avatarView)
userNameView.text = displayName
timeView.text = CommentTimeFormatter.format(context, comment.createdAt)
contentView.text = comment.content
bindContentToggle(item)
replyButton.setOnClickListener { onReplyClick(comment) }
likeContainer.setOnClickListener { onLikeClick(comment) }
contentToggle.setOnClickListener { onToggleContent(comment.id) }
val liked = comment.liked
likeIcon.setImageResource(
if (liked) R.drawable.comment_has_been_liked else R.drawable.comment_likes
)
likeCountView.text = comment.likeCount.toString()
}
fun bindPartial(item: ReplyItem, flags: Int) {
boundId = item.comment.id
likeContainer.setOnClickListener { onLikeClick(item.comment) }
if (flags and PAYLOAD_LIKE != 0) {
updateLike(item)
}
if (flags and PAYLOAD_CONTENT != 0) {
contentView.text = item.comment.content
bindContentToggle(item)
}
}
private fun updateLike(item: ReplyItem) {
val liked = item.comment.liked
likeIcon.setImageResource(
if (liked) R.drawable.comment_has_been_liked else R.drawable.comment_likes
)
likeCountView.text = item.comment.likeCount.toString()
}
private fun bindContentToggle(item: ReplyItem) {
val isExpanded = item.isContentExpanded
contentView.maxLines = if (isExpanded) Int.MAX_VALUE else COLLAPSED_MAX_LINES
contentView.ellipsize = if (isExpanded) null else TextUtils.TruncateAt.END
contentToggle.text = itemView.context.getString(
if (isExpanded) R.string.circle_comment_collapse else R.string.circle_comment_expand
)
contentToggle.visibility = if (isExpanded) View.VISIBLE else View.GONE
contentView.post {
if (boundId != item.comment.id) return@post
if (isExpanded) {
contentToggle.visibility = View.VISIBLE
return@post
}
val layout = contentView.layout ?: return@post
val maxLine = COLLAPSED_MAX_LINES.coerceAtLeast(1)
val lastLine = minOf(layout.lineCount, maxLine) - 1
if (lastLine < 0) {
contentToggle.visibility = View.GONE
return@post
}
val textLength = contentView.text?.length ?: 0
val lineEnd = layout.getLineEnd(lastLine)
val hasEllipsis = layout.getEllipsisCount(lastLine) > 0
val hasOverflow = hasEllipsis || lineEnd < textLength
contentToggle.visibility = if (hasOverflow) View.VISIBLE else View.GONE
}
}
}
private fun buildDisplayName(baseName: String, replyToUserName: String?): String {
val target = replyToUserName?.takeIf { it.isNotBlank() } ?: return baseName
return "$baseName @$target"
}
companion object {
private const val COLLAPSED_MAX_LINES = 4
private const val PAYLOAD_LIKE = 1
private const val PAYLOAD_CONTENT = 1 shl 1
private val DiffCallback = object : DiffUtil.ItemCallback<ReplyItem>() {
override fun areItemsTheSame(oldItem: ReplyItem, newItem: ReplyItem): Boolean {
return oldItem.comment.id == newItem.comment.id
}
override fun areContentsTheSame(oldItem: ReplyItem, newItem: ReplyItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: ReplyItem, newItem: ReplyItem): Any? {
val commentSame = oldItem.comment.content == newItem.comment.content &&
oldItem.comment.userName == newItem.comment.userName &&
oldItem.comment.userAvatar == newItem.comment.userAvatar &&
oldItem.comment.createdAt == newItem.comment.createdAt
if (!commentSame) return null
var flags = 0
if (oldItem.isContentExpanded != newItem.isContentExpanded) {
flags = flags or PAYLOAD_CONTENT
}
if (oldItem.comment.liked != newItem.comment.liked ||
oldItem.comment.likeCount != newItem.comment.likeCount
) {
flags = flags or PAYLOAD_LIKE
}
return if (flags == 0) null else flags
}
}
}
}
data class ReplyItem(
val comment: Comment,
val isContentExpanded: Boolean
)

View File

@@ -0,0 +1,886 @@
package com.example.myapplication.ui.circle
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.Rect
import android.os.Bundle
import android.os.Build
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.ViewTreeObserver
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.view.WindowManager
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
import com.example.myapplication.network.Comment
import com.example.myapplication.network.LoginResponse
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.addCommentRequest
import com.example.myapplication.network.commentPageRequest
import com.example.myapplication.network.likeCommentRequest
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.card.MaterialCardView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import eightbitlab.com.blurview.BlurView
import eightbitlab.com.blurview.RenderEffectBlur
import eightbitlab.com.blurview.RenderScriptBlur
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class CircleCommentSheet : BottomSheetDialogFragment() {
private lateinit var commentContent: View
private lateinit var commentInputContainer: View
private lateinit var commentList: RecyclerView
private lateinit var commentEmpty: TextView
private lateinit var commentLoading: View
private lateinit var commentCard: MaterialCardView
private lateinit var commentTitle: TextView
private lateinit var commentInputMask: View
private lateinit var commentInput: EditText
private lateinit var commentSend: ImageView
private lateinit var commentClose: ImageView
private var commentBlur: BlurView? = null
private var originalSoftInputMode: Int? = null
private var contentPadStart = 0
private var contentPadTop = 0
private var contentPadEnd = 0
private var contentPadBottom = 0
private var sheetBaseHeight = 0
private var imeGapPx = 0
private var lastImeVisible = false
private var keyboardLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var keyboardDecorView: View? = null
private var sheetView: FrameLayout? = null
private val sharedReplyPool = RecyclerView.RecycledViewPool()
private lateinit var commentAdapter: CircleCommentAdapter
private val commentItems = mutableListOf<CommentItem>()
private val expandedContentIds = mutableSetOf<Int>()
private val replyVisibleCountMap = mutableMapOf<Int, Int>()
private val likeInFlight = mutableSetOf<Int>()
private var replyTarget: ReplyTarget? = null
private var companionId: Int = -1
private var commentCount: Int = 0
private var nextPage = 1
private var hasMore = true
private var isLoading = false
private var pendingRefresh = false
private var isSubmitting = false
override fun getTheme(): Int = R.style.CircleCommentSheetDialog
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.sheet_circle_comments, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
companionId = arguments?.getInt(ARG_COMPANION_ID, -1) ?: -1
commentCount = arguments?.getInt(ARG_COMMENT_COUNT, 0) ?: 0
if (companionId <= 0) {
return
}
bindViews(view)
setupList()
setupActions()
setupCommentBlur()
if (!restoreCachedState()) {
refreshComments()
}
}
override fun onStart() {
super.onStart()
val dialog = dialog as? BottomSheetDialog ?: return
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
activity?.window?.let { window ->
if (originalSoftInputMode == null) {
originalSoftInputMode = window.attributes.softInputMode
}
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
parentFragmentManager.setFragmentResult(
CircleFragment.RESULT_COMMENT_SHEET_VISIBILITY,
bundleOf(CircleFragment.KEY_COMMENT_SHEET_VISIBLE to true)
)
val sheet =
dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
?: return
sheetView = sheet
setupKeyboardVisibilityListener(dialog)
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
sheet.background = null
sheet.setBackgroundColor(Color.TRANSPARENT)
sheet.setBackgroundResource(android.R.color.transparent)
sheet.setPadding(0, 0, 0, 0)
ViewCompat.setBackgroundTintList(sheet, ColorStateList.valueOf(Color.TRANSPARENT))
val height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_RATIO).toInt()
sheetBaseHeight = height
val lp = sheet.layoutParams
if (lp != null && lp.height != height) {
lp.height = height
sheet.layoutParams = lp
}
val behavior = BottomSheetBehavior.from(sheet)
behavior.isFitToContents = true
behavior.skipCollapsed = true
behavior.state = BottomSheetBehavior.STATE_EXPANDED
dialog.window?.setDimAmount(0f)
}
private fun bindViews(view: View) {
commentContent = view.findViewById(R.id.commentContent)
contentPadStart = commentContent.paddingLeft
contentPadTop = commentContent.paddingTop
contentPadEnd = commentContent.paddingRight
contentPadBottom = commentContent.paddingBottom
commentInputContainer = view.findViewById(R.id.commentInputContainer)
commentList = view.findViewById(R.id.commentList)
commentEmpty = view.findViewById(R.id.commentEmpty)
commentLoading = view.findViewById(R.id.commentLoading)
commentCard = view.findViewById(R.id.commentCard)
commentTitle = view.findViewById(R.id.commentTitle)
commentInputMask = view.findViewById(R.id.commentInputMask)
commentInput = view.findViewById(R.id.commentInput)
commentSend = view.findViewById(R.id.commentSend)
commentClose = view.findViewById(R.id.commentClose)
commentBlur = view.findViewById(R.id.commentBlur)
imeGapPx = resources.getDimensionPixelSize(R.dimen.circle_comment_ime_gap)
}
private fun setupKeyboardVisibilityListener(dialog: BottomSheetDialog) {
if (keyboardLayoutListener != null) return
val decor = dialog.window?.decorView ?: return
keyboardDecorView = decor
val listener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
decor.getWindowVisibleDisplayFrame(rect)
val screenHeight = decor.rootView.height
val heightDiff = (screenHeight - rect.bottom).coerceAtLeast(0)
val threshold = (screenHeight * 0.15f).toInt()
val imeVisible = heightDiff > threshold
updateSheetHeight(rect.height(), imeVisible)
updateInputGap(imeVisible)
updateBlurForIme(imeVisible)
setMaskVisible(imeVisible)
if (lastImeVisible && !imeVisible) {
resetReplyTarget()
}
lastImeVisible = imeVisible
}
keyboardLayoutListener = listener
decor.viewTreeObserver.addOnGlobalLayoutListener(listener)
}
private fun setupList() {
commentAdapter = CircleCommentAdapter(
sharedReplyPool = sharedReplyPool,
isContentExpanded = { id -> expandedContentIds.contains(id) },
onToggleContent = { id -> toggleContent(id) },
onLoadMoreReplies = { id -> loadMoreReplies(id) },
onCollapseReplies = { id -> collapseReplies(id) },
onReplyClick = { comment -> prepareReply(comment) },
onLikeClick = { comment -> toggleLike(comment) }
)
commentList.layoutManager = LinearLayoutManager(requireContext())
commentList.adapter = commentAdapter
commentList.itemAnimator = null
commentList.setHasFixedSize(true)
commentList.setItemViewCacheSize(6)
commentList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0) return
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
val total = lm.itemCount
val lastVisible = lm.findLastVisibleItemPosition()
if (hasMore && !isLoading && lastVisible >= total - LOAD_MORE_THRESHOLD) {
loadComments(reset = false)
}
}
})
}
private fun setupActions() {
commentSend.setOnClickListener { submitComment() }
commentClose.setOnClickListener { dismiss() }
commentInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
resetReplyTarget()
setMaskVisible(false)
} else {
setMaskVisible(true)
showKeyboard(commentInput)
}
}
commentInput.setOnClickListener {
setMaskVisible(true)
showKeyboard(commentInput)
}
commentInputMask.setOnClickListener {
hideKeyboard(commentInput)
commentInput.clearFocus()
resetReplyTarget()
setMaskVisible(false)
}
commentInput.setOnEditorActionListener { _, actionId, event ->
val isSendAction = actionId == EditorInfo.IME_ACTION_SEND
val isEnterKey = actionId == EditorInfo.IME_ACTION_UNSPECIFIED &&
event?.keyCode == KeyEvent.KEYCODE_ENTER &&
event.action == KeyEvent.ACTION_DOWN
if (isSendAction || isEnterKey) {
submitComment()
true
} else {
false
}
}
val root = view ?: return
root.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
val insideInput = isTouchInsideView(event, commentInput) ||
isTouchInsideView(event, commentSend)
if (!insideInput) {
hideKeyboard(commentInput)
commentInput.clearFocus()
resetReplyTarget()
setMaskVisible(false)
}
}
false
}
}
private fun setupCommentBlur() {
val blurView = commentBlur ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
blurView.visibility = View.GONE
commentCard.setCardBackgroundColor(
ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay)
)
return
}
val rootView = activity?.findViewById<ViewGroup>(android.R.id.content)?.getChildAt(0) as? ViewGroup
?: return
val blurRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 14f else 10f
try {
val algorithm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RenderEffectBlur()
} else {
RenderScriptBlur(requireContext())
}
blurView.setupWith(rootView, algorithm)
.setFrameClearDrawable(requireActivity().window.decorView.background)
.setBlurRadius(blurRadius)
.setBlurAutoUpdate(true)
.setOverlayColor(
ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay)
)
} catch (_: Throwable) {
blurView.visibility = View.GONE
commentCard.setCardBackgroundColor(
ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay)
)
}
}
private fun restoreCachedState(): Boolean {
val cache = commentCache[companionId] ?: return false
commentItems.clear()
commentItems.addAll(cache.items)
expandedContentIds.clear()
expandedContentIds.addAll(cache.expandedIds)
replyVisibleCountMap.clear()
replyVisibleCountMap.putAll(cache.replyVisibleCounts)
nextPage = cache.nextPage
hasMore = cache.hasMore
if (cache.commentCount > commentCount) {
commentCount = cache.commentCount
} else {
cache.commentCount = commentCount
}
replyTarget = null
updateCommentTitle()
commentInput.hint = getString(R.string.circle_comment_input_hint)
commentAdapter.submitList(commentItems.toList())
commentEmpty.visibility = if (commentItems.isEmpty()) View.VISIBLE else View.GONE
commentLoading.visibility = View.GONE
return true
}
private fun updateCommentTitle() {
commentTitle.text = getString(R.string.circle_comments_title_with_count, commentCount)
}
private fun updateCommentCount(newCount: Int, notifyParent: Boolean = true) {
val sanitized = newCount.coerceAtLeast(0)
val changed = sanitized != commentCount
commentCount = sanitized
updateCommentTitle()
if (changed && notifyParent) {
dispatchCommentCountChanged()
}
updateCache()
}
private fun dispatchCommentCountChanged() {
if (companionId <= 0) return
parentFragmentManager.setFragmentResult(
CircleFragment.RESULT_COMMENT_COUNT_UPDATED,
bundleOf(
CircleFragment.KEY_COMMENT_COMPANION_ID to companionId,
CircleFragment.KEY_COMMENT_COUNT to commentCount
)
)
}
private fun updateCache() {
if (companionId <= 0) return
commentCache[companionId] = CommentCache(
items = commentItems.toMutableList(),
expandedIds = expandedContentIds.toMutableSet(),
replyVisibleCounts = replyVisibleCountMap.toMutableMap(),
nextPage = nextPage,
hasMore = hasMore,
commentCount = commentCount
)
}
private fun getLocalUser(): LoginResponse? {
val ctx = context ?: return null
return EncryptedSharedPreferencesUtil.get(ctx, "user", LoginResponse::class.java)
}
private fun buildLocalTimestamp(): String {
return localTimeFormat.format(Date())
}
private fun addLocalComment(content: String, target: ReplyTarget?, serverCommentId: Int?) {
val localUser = getLocalUser()
val userId = localUser?.uid?.toInt() ?: 0
val userName = localUser?.nickName
val userAvatar = localUser?.avatarUrl
val newId = serverCommentId?.takeIf { it > 0 } ?: nextTempCommentId()
val replyToName = target?.mentionName
val newComment = Comment(
id = newId,
companionId = companionId,
userId = userId,
userName = userName,
userAvatar = userAvatar,
replyToUserName = replyToName,
replyToUserId = null,
parentId = target?.parentId,
rootId = target?.rootId,
content = content,
likeCount = 0,
liked = false,
createdAt = buildLocalTimestamp(),
replies = emptyList(),
replyCount = 0
)
if (target == null) {
commentItems.add(
0,
CommentItem(
comment = newComment,
isContentExpanded = false,
visibleReplyCount = 0
)
)
val newList = commentItems.toList()
commentAdapter.submitList(newList) {
commentList.post { commentList.scrollToPosition(0) }
}
} else {
val parentIndex = findParentIndex(target.parentId)
if (parentIndex >= 0) {
val parentItem = commentItems[parentIndex]
val parentComment = parentItem.comment
val replies = parentComment.replies?.toMutableList() ?: mutableListOf()
replies.add(newComment)
val currentReplyCount = parentComment.replyCount ?: parentComment.replies?.size ?: 0
val updatedReplyCount = currentReplyCount + 1
val updatedParent = parentComment.copy(
replies = replies,
replyCount = updatedReplyCount
)
val currentVisible = replyVisibleCountMap[parentComment.id]
?: minOf(REPLY_INITIAL_COUNT, updatedReplyCount)
val nextVisible = (currentVisible + 1).coerceAtMost(updatedReplyCount)
replyVisibleCountMap[parentComment.id] = nextVisible
commentItems[parentIndex] = parentItem.copy(
comment = updatedParent,
visibleReplyCount = nextVisible
)
commentAdapter.submitList(commentItems.toList())
} else {
commentItems.add(
0,
CommentItem(
comment = newComment,
isContentExpanded = false,
visibleReplyCount = 0
)
)
val newList = commentItems.toList()
commentAdapter.submitList(newList) {
commentList.post { commentList.scrollToPosition(0) }
}
}
}
commentEmpty.visibility = if (commentItems.isEmpty()) View.VISIBLE else View.GONE
updateCache()
}
private fun refreshComments() {
expandedContentIds.clear()
replyVisibleCountMap.clear()
hasMore = true
nextPage = 1
replyTarget = null
updateCommentTitle()
commentInput.hint = getString(R.string.circle_comment_input_hint)
loadComments(reset = true)
}
private fun loadComments(reset: Boolean) {
if (isLoading) {
if (reset) {
pendingRefresh = true
}
return
}
if (hasMore.not() && !reset) return
isLoading = true
if (reset) {
pendingRefresh = false
commentLoading.visibility = View.VISIBLE
}
val pageToLoad = if (reset) 1 else nextPage
lifecycleScope.launch {
val response = withContext(Dispatchers.IO) {
runCatching {
RetrofitClient.apiService.commentPage(
commentPageRequest(
companionId = companionId,
pageNum = pageToLoad,
pageSize = PAGE_SIZE
)
)
}.onFailure {
Log.e(TAG, "commentPage failed", it)
}.getOrNull()
}
val data = response?.data
if (data != null) {
val existingIds = commentItems.asSequence().map { it.comment.id }.toHashSet()
val newRecords = data.records.filter { it.id !in existingIds }
val mapped = newRecords.map { mapToItem(it) }
commentItems.addAll(mapped)
hasMore = when {
data.pages > 0 -> data.current < data.pages
data.records.isNotEmpty() -> true
else -> false
}
nextPage = data.current + 1
}
commentAdapter.submitList(commentItems.toList())
commentEmpty.visibility = if (commentItems.isEmpty()) View.VISIBLE else View.GONE
commentLoading.visibility = View.GONE
isLoading = false
updateCache()
if (pendingRefresh) {
pendingRefresh = false
refreshComments()
}
}
}
private fun mapToItem(comment: Comment): CommentItem {
val totalReplies = comment.replyCount ?: comment.replies?.size ?: 0
val defaultVisible = minOf(REPLY_INITIAL_COUNT, totalReplies)
val visibleCount = replyVisibleCountMap[comment.id] ?: defaultVisible
return CommentItem(
comment = comment,
isContentExpanded = expandedContentIds.contains(comment.id),
visibleReplyCount = visibleCount
)
}
private fun toggleContent(commentId: Int) {
val isExpanded = !expandedContentIds.contains(commentId)
if (isExpanded) {
expandedContentIds.add(commentId)
} else {
expandedContentIds.remove(commentId)
}
val index = commentItems.indexOfFirst { it.comment.id == commentId }
if (index >= 0) {
commentItems[index] = commentItems[index].copy(isContentExpanded = isExpanded)
commentAdapter.submitList(commentItems.toList())
} else {
val parentIndex = findParentIndex(commentId)
if (parentIndex >= 0) {
commentAdapter.notifyItemChanged(parentIndex)
}
}
updateCache()
}
private fun loadMoreReplies(commentId: Int) {
val index = commentItems.indexOfFirst { it.comment.id == commentId }
if (index >= 0) {
val item = commentItems[index]
val totalReplies = item.comment.replyCount ?: item.comment.replies?.size ?: 0
if (totalReplies <= 0) return
val current = replyVisibleCountMap[commentId] ?: minOf(REPLY_INITIAL_COUNT, totalReplies)
val nextVisible = minOf(current + REPLY_PAGE_SIZE, totalReplies)
replyVisibleCountMap[commentId] = nextVisible
commentItems[index] = item.copy(visibleReplyCount = nextVisible)
commentAdapter.submitList(commentItems.toList())
updateCache()
}
}
private fun collapseReplies(commentId: Int) {
val index = commentItems.indexOfFirst { it.comment.id == commentId }
if (index >= 0) {
val item = commentItems[index]
val totalReplies = item.comment.replyCount ?: item.comment.replies?.size ?: 0
if (totalReplies <= 0) return
val nextVisible = minOf(REPLY_INITIAL_COUNT, totalReplies)
replyVisibleCountMap[commentId] = nextVisible
commentItems[index] = item.copy(visibleReplyCount = nextVisible)
val targetIndex = index
val newList = commentItems.toList()
commentAdapter.submitList(newList) {
commentList.post {
val lm = commentList.layoutManager as? LinearLayoutManager
if (lm != null) {
lm.scrollToPositionWithOffset(targetIndex, 0)
} else {
commentList.scrollToPosition(targetIndex)
}
}
}
updateCache()
}
}
private fun prepareReply(comment: Comment) {
if (comment.id <= 0) return
val rootId = comment.rootId ?: comment.parentId ?: comment.id
val mentionName = comment.userName?.takeIf { it.isNotBlank() }
?: getString(R.string.circle_comment_user_anonymous)
replyTarget = ReplyTarget(
parentId = comment.id,
rootId = rootId,
mentionName = mentionName
)
val hint = getString(R.string.circle_comment_reply_to, mentionName)
commentInput.hint = hint
commentInput.requestFocus()
showKeyboard(commentInput)
}
private fun submitComment() {
val rawContent = commentInput.text?.toString()?.trim().orEmpty()
if (rawContent.isBlank()) return
val target = replyTarget
val mentionName = target?.mentionName
val content = if (!mentionName.isNullOrBlank()) {
val prefix = "@$mentionName"
if (rawContent.startsWith(prefix)) {
rawContent.removePrefix(prefix).trimStart()
} else {
rawContent
}
} else {
rawContent
}
if (content.isBlank()) return
if (isSubmitting) return
isSubmitting = true
commentSend.isEnabled = false
lifecycleScope.launch {
try {
val response = withContext(Dispatchers.IO) {
runCatching {
RetrofitClient.apiService.addComment(
addCommentRequest(
companionId = companionId,
content = content,
parentId = target?.parentId,
rootId = target?.rootId
)
)
}.getOrNull()
}
val success = response?.code == 0
if (!success) {
val message = response?.message?.takeIf { it.isNotBlank() }
?: getString(R.string.circle_comment_send_failed)
showToast(message)
return@launch
}
val createdId = response?.data
updateCommentCount(commentCount + 1)
addLocalComment(content, target, createdId)
commentInput.setText("")
resetReplyTarget()
} finally {
isSubmitting = false
commentSend.isEnabled = true
}
}
}
private fun toggleLike(comment: Comment) {
if (!likeInFlight.add(comment.id)) return
val previousLiked = comment.liked
val previousCount = comment.likeCount
val targetLiked = !previousLiked
val targetCount = (previousCount + if (targetLiked) 1 else -1).coerceAtLeast(0)
updateLikeState(comment.id, targetLiked, targetCount)
lifecycleScope.launch {
try {
val response = withContext(Dispatchers.IO) {
runCatching {
RetrofitClient.apiService.likeComment(
likeCommentRequest(commentId = comment.id)
)
}.getOrNull()
}
val success = response?.code == 0
if (!success) {
updateLikeState(comment.id, previousLiked, previousCount)
}
} finally {
likeInFlight.remove(comment.id)
}
}
}
private fun updateLikeState(commentId: Int, liked: Boolean, likeCount: Int) {
var updated = false
val newItems = commentItems.map { item ->
when {
item.comment.id == commentId -> {
updated = true
item.copy(comment = item.comment.copy(liked = liked, likeCount = likeCount))
}
item.comment.replies?.any { it.id == commentId } == true -> {
val replies = item.comment.replies?.map { reply ->
if (reply.id == commentId) reply.copy(liked = liked, likeCount = likeCount) else reply
}
updated = true
item.copy(comment = item.comment.copy(replies = replies))
}
else -> item
}
}
if (updated) {
commentItems.clear()
commentItems.addAll(newItems)
commentAdapter.submitList(newItems)
updateCache()
}
}
private fun findParentIndex(commentId: Int): Int {
return commentItems.indexOfFirst { item ->
item.comment.id == commentId || item.comment.replies?.any { it.id == commentId } == true
}
}
private fun showToast(message: String) {
context?.let { Toast.makeText(it, message, Toast.LENGTH_SHORT).show() }
}
private fun resetReplyTarget() {
replyTarget = null
commentInput.hint = getString(R.string.circle_comment_input_hint)
}
private fun setMaskVisible(visible: Boolean) {
val target = if (visible) View.VISIBLE else View.GONE
if (commentInputMask.visibility != target) {
commentInputMask.visibility = target
}
}
private fun updateSheetHeight(visibleHeight: Int, imeVisible: Boolean) {
val sheet = sheetView ?: return
if (sheetBaseHeight <= 0) return
val targetHeight = if (imeVisible && visibleHeight > 0) {
minOf(sheetBaseHeight, visibleHeight)
} else {
sheetBaseHeight
}
val lp = sheet.layoutParams
if (lp != null && lp.height != targetHeight) {
lp.height = targetHeight
sheet.layoutParams = lp
}
}
private fun updateInputGap(imeVisible: Boolean) {
val targetPadding = if (imeVisible) contentPadBottom + imeGapPx else contentPadBottom
if (commentContent.paddingBottom != targetPadding) {
commentContent.setPadding(
contentPadStart,
contentPadTop,
contentPadEnd,
targetPadding
)
}
}
private fun showKeyboard(target: View) {
val imm = context?.getSystemService(InputMethodManager::class.java) ?: return
target.post { imm.showSoftInput(target, InputMethodManager.SHOW_IMPLICIT) }
}
private fun hideKeyboard(target: View) {
val imm = context?.getSystemService(InputMethodManager::class.java) ?: return
imm.hideSoftInputFromWindow(target.windowToken, 0)
}
private fun isTouchInsideView(event: MotionEvent, view: View): Boolean {
val rect = android.graphics.Rect()
view.getGlobalVisibleRect(rect)
return rect.contains(event.rawX.toInt(), event.rawY.toInt())
}
override fun onDestroyView() {
keyboardDecorView?.viewTreeObserver?.let { observer ->
keyboardLayoutListener?.let { listener ->
observer.removeOnGlobalLayoutListener(listener)
}
}
keyboardLayoutListener = null
keyboardDecorView = null
sheetView = null
parentFragmentManager.setFragmentResult(
CircleFragment.RESULT_COMMENT_SHEET_VISIBILITY,
bundleOf(CircleFragment.KEY_COMMENT_SHEET_VISIBLE to false)
)
activity?.window?.let { window ->
originalSoftInputMode?.let { window.setSoftInputMode(it) }
}
super.onDestroyView()
}
private data class ReplyTarget(
val parentId: Int,
val rootId: Int,
val mentionName: String?
)
private data class CommentCache(
val items: MutableList<CommentItem>,
val expandedIds: MutableSet<Int>,
val replyVisibleCounts: MutableMap<Int, Int>,
var nextPage: Int,
var hasMore: Boolean,
var commentCount: Int
)
companion object {
const val TAG = "CircleCommentSheet"
private const val ARG_COMPANION_ID = "arg_companion_id"
private const val ARG_COMMENT_COUNT = "arg_comment_count"
private const val SHEET_HEIGHT_RATIO = 0.7f
private const val PAGE_SIZE = 20
private const val LOAD_MORE_THRESHOLD = 2
private const val REPLY_INITIAL_COUNT = 2
private const val REPLY_PAGE_SIZE = 5
private val commentCache = mutableMapOf<Int, CommentCache>()
private val localTimeFormat = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
Locale.US
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private var tempCommentIdSeed = -1
fun clearCachedComments() {
commentCache.clear()
}
private fun nextTempCommentId(): Int {
val next = tempCommentIdSeed
tempCommentIdSeed -= 1
return next
}
fun newInstance(companionId: Int, commentCount: Int) = CircleCommentSheet().apply {
arguments = Bundle().apply {
putInt(ARG_COMPANION_ID, companionId)
putInt(ARG_COMMENT_COUNT, commentCount)
}
}
}
private fun updateBlurForIme(imeVisible: Boolean) {
val blurView = commentBlur ?: return
if (imeVisible) {
// 键盘出来:禁用毛玻璃,避免错位
blurView.visibility = View.GONE
commentCard.setCardBackgroundColor(
ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay)
)
} else {
// 键盘收起:恢复毛玻璃
blurView.visibility = View.VISIBLE
blurView.invalidate()
}
}
}

View File

@@ -0,0 +1,91 @@
package com.example.myapplication.ui.circle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.AiCompanion
class CircleDrawerMenuAdapter(
private val onItemClick: (position: Int, companion: AiCompanion) -> Unit
) : ListAdapter<AiCompanion, CircleDrawerMenuAdapter.ViewHolder>(DiffCallback()) {
private var selectedPosition: Int = RecyclerView.NO_POSITION
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_circle_drawer_menu, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item, position == selectedPosition)
holder.itemView.setOnClickListener {
val adapterPosition = holder.adapterPosition
if (adapterPosition != RecyclerView.NO_POSITION) {
onItemClick(adapterPosition, getItem(adapterPosition))
}
}
}
fun setSelectedPosition(position: Int) {
val oldPosition = selectedPosition
selectedPosition = position
if (oldPosition != RecyclerView.NO_POSITION) {
notifyItemChanged(oldPosition)
}
if (position != RecyclerView.NO_POSITION) {
notifyItemChanged(position)
}
}
fun getSelectedPosition(): Int = selectedPosition
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivAvatar: ImageView = itemView.findViewById(R.id.ivMenuAvatar)
private val tvName: TextView = itemView.findViewById(R.id.tvMenuName)
private val tvDesc: TextView = itemView.findViewById(R.id.tvMenuDesc)
private val ivArrow: ImageView = itemView.findViewById(R.id.ivMenuArrow)
fun bind(item: AiCompanion, isSelected: Boolean) {
tvName.text = item.name
tvDesc.text = item.shortDesc
Glide.with(itemView.context)
.load(item.avatarUrl)
.placeholder(R.drawable.a123123123)
.error(R.drawable.a123123123)
.into(ivAvatar)
// 选中状态显示不同图标和大小
ivArrow.setImageResource(
if (isSelected) R.drawable.menu_list_selected else R.drawable.menu_list_not_selected
)
val sizePx = if (isSelected) {
(16 * itemView.resources.displayMetrics.density).toInt()
} else {
(10 * itemView.resources.displayMetrics.density).toInt()
}
ivArrow.layoutParams.width = sizePx
ivArrow.layoutParams.height = sizePx
ivArrow.requestLayout()
}
}
private class DiffCallback : DiffUtil.ItemCallback<AiCompanion>() {
override fun areItemsTheSame(oldItem: AiCompanion, newItem: AiCompanion): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: AiCompanion, newItem: AiCompanion): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,367 @@
package com.example.myapplication.ui.circle
import android.app.Dialog
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.PopupWindow
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.myapplication.R
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.chatSessionResetRequest
import com.example.myapplication.network.companionChattedResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CircleMyAiCharacterFragment : Fragment() {
private lateinit var viewPager: ViewPager2
private lateinit var tabThumbsUp: TextView
private lateinit var tabChatting: TextView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_circle_my_ai_character, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewPager = view.findViewById(R.id.view_pager)
tabThumbsUp = view.findViewById(R.id.tab_thumbs_up)
tabChatting = view.findViewById(R.id.tab_chatting)
view.findViewById<View>(R.id.iv_close).setOnClickListener {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
setupViewPager()
setupTabs()
}
private fun setupViewPager() {
viewPager.adapter = object : FragmentStateAdapter(this) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> ThumbsUpFragment()
1 -> ChattingFragment()
else -> ThumbsUpFragment()
}
}
}
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
updateTabState(position)
}
})
}
private fun setupTabs() {
tabThumbsUp.setOnClickListener {
viewPager.currentItem = 0
}
tabChatting.setOnClickListener {
viewPager.currentItem = 1
}
}
private fun updateTabState(position: Int) {
when (position) {
0 -> {
tabThumbsUp.setTextColor(0xFF1B1F1A.toInt())
tabThumbsUp.setTypeface(null, Typeface.BOLD)
tabChatting.setTextColor(0xFF999999.toInt())
tabChatting.setTypeface(null, Typeface.NORMAL)
}
1 -> {
tabThumbsUp.setTextColor(0xFF999999.toInt())
tabThumbsUp.setTypeface(null, Typeface.NORMAL)
tabChatting.setTextColor(0xFF1B1F1A.toInt())
tabChatting.setTypeface(null, Typeface.BOLD)
}
}
}
/**
* 点赞过的AI角色列表页
*/
class ThumbsUpFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var tvEmpty: TextView
private lateinit var progressBar: ProgressBar
private lateinit var adapter: ThumbsUpAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_my_ai_character_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView = view.findViewById(R.id.recyclerView)
tvEmpty = view.findViewById(R.id.tvEmpty)
progressBar = view.findViewById(R.id.progressBar)
adapter = ThumbsUpAdapter { item ->
// 点击跳转到角色详情或聊天页
}
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
loadData()
}
private fun loadData() {
progressBar.visibility = View.VISIBLE
tvEmpty.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
try {
val response = RetrofitClient.apiService.companionLiked()
progressBar.visibility = View.GONE
if (response.code == 0 && response.data != null) {
val list = response.data
if (list.isEmpty()) {
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
} else {
tvEmpty.visibility = View.GONE
recyclerView.visibility = View.VISIBLE
adapter.submitList(list)
}
} else {
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
}
} catch (e: Exception) {
progressBar.visibility = View.GONE
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
}
}
}
}
/**
* 聊过天的AI角色列表页
*/
class ChattingFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var tvEmpty: TextView
private lateinit var progressBar: ProgressBar
private lateinit var adapter: ChattingAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_my_ai_character_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView = view.findViewById(R.id.recyclerView)
tvEmpty = view.findViewById(R.id.tvEmpty)
progressBar = view.findViewById(R.id.progressBar)
adapter = ChattingAdapter(
onItemClick = { item ->
// 点击跳转到聊天页
},
onItemLongClick = { _, rawX, rawY, item ->
showDeletePopup(rawX, rawY, item)
}
)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
loadData()
}
private fun loadData() {
progressBar.visibility = View.VISIBLE
tvEmpty.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
try {
val response = RetrofitClient.apiService.companionChatted()
progressBar.visibility = View.GONE
if (response.code == 0 && response.data != null) {
val list = response.data
if (list.isEmpty()) {
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
} else {
tvEmpty.visibility = View.GONE
recyclerView.visibility = View.VISIBLE
adapter.submitList(list)
}
} else {
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
}
} catch (e: Exception) {
progressBar.visibility = View.GONE
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
}
}
}
private fun showDeletePopup(rawX: Float, rawY: Float, item: companionChattedResponse) {
val popupView = LayoutInflater.from(requireContext())
.inflate(R.layout.popup_delete_action, null)
// 先测量弹窗实际尺寸
popupView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val popupW = popupView.measuredWidth
val popupH = popupView.measuredHeight
val popupWindow = PopupWindow(
popupView,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
true
)
popupWindow.elevation = 8f
popupWindow.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
popupView.findViewById<TextView>(R.id.tv_delete).setOnClickListener {
popupWindow.dismiss()
showConfirmDialog(item)
}
val screenWidth = resources.displayMetrics.widthPixels
val touchX = rawX.toInt()
val touchY = rawY.toInt()
val margin = (30 * resources.displayMetrics.density).toInt()
// 优先显示在手指左侧;空间不够则显示在右侧
val x = if (touchX - popupW - margin >= 0) {
touchX - popupW - margin
} else {
touchX + margin
}
// 垂直方向居中于手指位置
val y = touchY - popupH / 2
popupWindow.showAtLocation(recyclerView, Gravity.NO_GRAVITY, x, y)
}
private fun showConfirmDialog(item: companionChattedResponse) {
val dialog = Dialog(requireContext())
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(R.layout.dialog_confirm_reset_chat)
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialog.window?.setLayout(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
dialog.window?.setGravity(Gravity.CENTER)
dialog.setCancelable(true)
dialog.findViewById<TextView>(R.id.btn_cancel).setOnClickListener {
dialog.dismiss()
}
dialog.findViewById<TextView>(R.id.btn_confirm).setOnClickListener {
dialog.dismiss()
resetChatSession(item)
}
dialog.show()
}
private fun resetChatSession(item: companionChattedResponse) {
viewLifecycleOwner.lifecycleScope.launch {
try {
val response = withContext(Dispatchers.IO) {
RetrofitClient.apiService.chatSessionReset(
chatSessionResetRequest(companionId = item.id)
)
}
if (response.code == 0) {
Toast.makeText(
requireContext(),
R.string.circle_reset_chat_success,
Toast.LENGTH_SHORT
).show()
// 从列表中移除该项
adapter.removeItem(item.id)
// 列表为空时显示空状态
if (adapter.currentList.isEmpty()) {
tvEmpty.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
}
// 通知 CircleFragment 清除该角色的聊天消息
// ChattingFragment 在 ViewPager2 内parentFragmentManager 是
// CircleMyAiCharacterFragment 的 childFM需要再上跳一级
// 到 NavHostFragment 的 childFM才能被 CircleFragment 收到
val navFragmentManager = requireParentFragment()
.parentFragmentManager
navFragmentManager.setFragmentResult(
RESULT_CHAT_SESSION_RESET,
bundleOf(KEY_RESET_COMPANION_ID to item.id)
)
} else {
Toast.makeText(
requireContext(),
R.string.circle_reset_chat_failed,
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
Toast.makeText(
requireContext(),
R.string.circle_reset_chat_failed,
Toast.LENGTH_SHORT
).show()
}
}
}
}
companion object {
const val RESULT_CHAT_SESSION_RESET = "result_chat_session_reset"
const val KEY_RESET_COMPANION_ID = "key_reset_companion_id"
}
}

View File

@@ -0,0 +1,93 @@
package com.example.myapplication.ui.circle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
class CirclePageAdapter(
private val repository: CircleChatRepository,
private val sharedPool: RecyclerView.RecycledViewPool,
private val onLikeClick: ((position: Int, companionId: Int) -> Unit)? = null,
private val onCommentClick: ((companionId: Int, commentCount: Int) -> Unit)? = null,
private val onAvatarClick: ((companionId: Int) -> Unit)? = null
) : RecyclerView.Adapter<PageViewHolder>() {
// 每页固定为屏幕高度,配合 PagerSnapHelper 使用。
private var pageHeight: Int = 0
private var inputOverlayHeight: Int = 0
private var bottomInset: Int = 0
init {
// 稳定 ID 可减少切页时的重绘/闪动。
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_circle_chat_page, parent, false)
return ChatPageViewHolder(view, sharedPool)
}
//将数据绑定到 RecyclerView 的每一项视图上
override fun onBindViewHolder(holder: PageViewHolder, position: Int) {
val data = repository.getPage(position)
if (pageHeight > 0) {
// 强制全屏高度,保证每一项都能对齐到整页。
val lp = holder.itemView.layoutParams
if (lp != null && lp.height != pageHeight) {
lp.height = pageHeight
holder.itemView.layoutParams = lp
}
}
(holder as? ChatPageViewHolder)?.bind(
data,
inputOverlayHeight,
bottomInset,
repository.getHistoryUiState(data.companionId),
{ companionId -> repository.getHistoryUiState(companionId) },
{ pagePosition, companionId, onResult ->
repository.loadMoreHistory(pagePosition, companionId, onResult)
},
onLikeClick,
onCommentClick,
onAvatarClick
)
}
override fun getItemCount(): Int = repository.getAvailablePages()
override fun getItemId(position: Int): Long = position.toLong()
//当一个 ViewHolder 变得可见时,自动预加载其上下邻居的数据
override fun onViewAttachedToWindow(holder: PageViewHolder) {
super.onViewAttachedToWindow(holder)
val position = holder.adapterPosition
if (position != RecyclerView.NO_POSITION) {
// 页面进入可见时预加载上下邻居。
repository.preloadAround(position)
}
}
override fun onViewRecycled(holder: PageViewHolder) {
holder.onRecycled()
super.onViewRecycled(holder)
}
fun updatePageHeight(height: Int) {
if (height > 0 && pageHeight != height) {
pageHeight = height
notifyDataSetChanged()
}
}
fun updateInputOverlayHeight(height: Int) {
inputOverlayHeight = height
}
fun updateBottomInset(inset: Int) {
bottomInset = inset
}
}

View File

@@ -0,0 +1,49 @@
package com.example.myapplication.ui.circle
import android.content.Context
import com.example.myapplication.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
object CommentTimeFormatter {
private const val INPUT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
private val inputFormat = SimpleDateFormat(INPUT_PATTERN, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val dateFormatCurrentYear = SimpleDateFormat("MM-dd", Locale.getDefault())
private val dateFormatOtherYear = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
fun format(context: Context, raw: String): String {
return try {
val date = inputFormat.parse(raw) ?: return raw
val nowMillis = System.currentTimeMillis()
val diffMillis = (nowMillis - date.time).coerceAtLeast(0L)
val diffMinutes = diffMillis / 60_000L
val diffHours = diffMillis / 3_600_000L
val diffDays = diffMillis / 86_400_000L
when {
diffMinutes < 5 -> context.getString(R.string.circle_comment_time_just_now)
diffMinutes < 60 -> {
val rounded = ((diffMinutes + 9) / 10) * 10
context.getString(R.string.circle_comment_time_minutes_ago, rounded)
}
diffHours < 24 -> context.getString(R.string.circle_comment_time_hours_ago, diffHours)
diffDays < 5 -> context.getString(R.string.circle_comment_time_days_ago, diffDays)
else -> {
val calNow = Calendar.getInstance()
val calDate = Calendar.getInstance().apply { time = date }
if (calNow.get(Calendar.YEAR) == calDate.get(Calendar.YEAR)) {
dateFormatCurrentYear.format(date)
} else {
dateFormatOtherYear.format(date)
}
}
}
} catch (_: Exception) {
raw
}
}
}

View File

@@ -0,0 +1,60 @@
package com.example.myapplication.ui.circle
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.recyclerview.widget.RecyclerView
class EdgeAwareRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
private var lastY = 0f
private var topPullTriggered = false
var allowParentInterceptAtTop: (() -> Boolean)? = null
var onTopPull: (() -> Unit)? = null
override fun onTouchEvent(e: MotionEvent): Boolean {
when (e.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastY = e.y
topPullTriggered = false
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
val dy = e.y - lastY
lastY = e.y
val canScrollUp = canScrollVertically(-1)
val canScrollDown = canScrollVertically(1)
val scrollingDown = dy > 0
val disallow = if (scrollingDown) {
if (!canScrollUp) {
if (!topPullTriggered) {
topPullTriggered = true
onTopPull?.invoke()
}
val allowParent = allowParentInterceptAtTop?.invoke() ?: true
!allowParent
} else {
topPullTriggered = false
canScrollUp
}
} else {
canScrollDown
}
parent?.requestDisallowInterceptTouchEvent(disallow)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
topPullTriggered = false
}
}
return super.onTouchEvent(e)
}
}

View File

@@ -0,0 +1,71 @@
package com.example.myapplication.ui.circle
import android.content.Context
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.widget.FrameLayout
import android.graphics.BlendMode
class GradientMaskLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var maskShader: LinearGradient? = null
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateShader(w, h)
}
override fun dispatchDraw(canvas: Canvas) {
if (width == 0 || height == 0) {
super.dispatchDraw(canvas)
return
}
val saveCount = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
super.dispatchDraw(canvas)
if (maskShader == null) {
updateShader(width, height)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
maskPaint.blendMode = BlendMode.DST_IN
} else {
maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), maskPaint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
maskPaint.blendMode = null
} else {
maskPaint.xfermode = null
}
canvas.restoreToCount(saveCount)
}
private fun updateShader(w: Int, h: Int) {
if (w <= 0 || h <= 0) return
maskShader = LinearGradient(
0f,
h.toFloat(),
0f,
0f,
intArrayOf(0xFFFFFFFF.toInt(), 0x00FFFFFF),
floatArrayOf(0f, 1f),
Shader.TileMode.CLAMP
)
maskPaint.shader = maskShader
}
}

View File

@@ -0,0 +1,141 @@
package com.example.myapplication.ui.circle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import android.view.MotionEvent
import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.companionChattedResponse
import com.example.myapplication.network.companionLikedResponse
/**
* 点赞过的AI角色列表 Adapter
*/
class ThumbsUpAdapter(
private val onItemClick: (companionLikedResponse) -> Unit
) : ListAdapter<companionLikedResponse, ThumbsUpAdapter.ViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_my_ai_character, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivAvatar: ImageView = itemView.findViewById(R.id.ivAvatar)
private val tvName: TextView = itemView.findViewById(R.id.tvName)
private val tvShortDesc: TextView = itemView.findViewById(R.id.tvShortDesc)
private val tvTime: TextView = itemView.findViewById(R.id.tvTime)
fun bind(item: companionLikedResponse) {
Glide.with(ivAvatar)
.load(item.avatarUrl)
.placeholder(R.drawable.default_avatar)
.error(R.drawable.default_avatar)
.into(ivAvatar)
tvName.text = item.name
tvShortDesc.text = item.shortDesc
tvTime.text = MyAiCharacterTimeFormatter.format(itemView.context, item.createdAt)
itemView.setOnClickListener { onItemClick(item) }
}
}
companion object {
private val DiffCallback = object : DiffUtil.ItemCallback<companionLikedResponse>() {
override fun areItemsTheSame(
oldItem: companionLikedResponse,
newItem: companionLikedResponse
): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: companionLikedResponse,
newItem: companionLikedResponse
): Boolean = oldItem == newItem
}
}
}
/**
* 聊过天的AI角色列表 Adapter
*/
class ChattingAdapter(
private val onItemClick: (companionChattedResponse) -> Unit,
private val onItemLongClick: (View, Float, Float, companionChattedResponse) -> Unit
) : ListAdapter<companionChattedResponse, ChattingAdapter.ViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_my_ai_character, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivAvatar: ImageView = itemView.findViewById(R.id.ivAvatar)
private val tvName: TextView = itemView.findViewById(R.id.tvName)
private val tvShortDesc: TextView = itemView.findViewById(R.id.tvShortDesc)
private val tvTime: TextView = itemView.findViewById(R.id.tvTime)
private var lastTouchX = 0f
private var lastTouchY = 0f
fun bind(item: companionChattedResponse) {
Glide.with(ivAvatar)
.load(item.avatarUrl)
.placeholder(R.drawable.default_avatar)
.error(R.drawable.default_avatar)
.into(ivAvatar)
tvName.text = item.name
tvShortDesc.text = item.shortDesc
tvTime.text = MyAiCharacterTimeFormatter.format(itemView.context, item.createdAt)
itemView.setOnClickListener { onItemClick(item) }
itemView.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
lastTouchX = event.rawX
lastTouchY = event.rawY
}
false
}
itemView.setOnLongClickListener { view ->
onItemLongClick(view, lastTouchX, lastTouchY, item)
true
}
}
}
fun removeItem(companionId: Int) {
val newList = currentList.toMutableList()
newList.removeAll { it.id == companionId }
submitList(newList)
}
companion object {
private val DiffCallback = object : DiffUtil.ItemCallback<companionChattedResponse>() {
override fun areItemsTheSame(
oldItem: companionChattedResponse,
newItem: companionChattedResponse
): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: companionChattedResponse,
newItem: companionChattedResponse
): Boolean = oldItem == newItem
}
}
}

View File

@@ -0,0 +1,72 @@
package com.example.myapplication.ui.circle
import android.content.Context
import com.example.myapplication.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/**
* 我的AI角色列表时间格式化器
* 规则:
* - 5分钟内刚刚
* - 10分钟内X分钟
* - 1小时内以10为整的分钟10分钟、20分钟、30分钟...
* - 1天内X小时
* - 1个月内X天
* - 1年内MM-dd
* - 1年以上yyyy-MM-dd
*/
object MyAiCharacterTimeFormatter {
private const val INPUT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
private val inputFormat = SimpleDateFormat(INPUT_PATTERN, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val dateFormatCurrentYear = SimpleDateFormat("MM-dd", Locale.getDefault())
private val dateFormatOtherYear = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
fun format(context: Context, raw: String): String {
return try {
val date = inputFormat.parse(raw) ?: return raw
val nowMillis = System.currentTimeMillis()
val diffMillis = (nowMillis - date.time).coerceAtLeast(0L)
val diffMinutes = diffMillis / 60_000L
val diffHours = diffMillis / 3_600_000L
val diffDays = diffMillis / 86_400_000L
when {
// 5分钟内显示"刚刚"
diffMinutes < 5 -> context.getString(R.string.circle_comment_time_just_now)
// 10分钟内显示具体分钟数
diffMinutes < 10 -> context.getString(R.string.circle_comment_time_minutes_ago, diffMinutes)
// 1小时内显示以10为整的分钟数
diffMinutes < 60 -> {
val rounded = (diffMinutes / 10) * 10
context.getString(R.string.circle_comment_time_minutes_ago, rounded.coerceAtLeast(10))
}
// 1天内显示小时数
diffHours < 24 -> context.getString(R.string.circle_comment_time_hours_ago, diffHours)
// 1个月内约30天显示天数
diffDays < 30 -> context.getString(R.string.circle_comment_time_days_ago, diffDays)
// 超过30天根据年份显示日期
else -> {
val calNow = Calendar.getInstance()
val calDate = Calendar.getInstance().apply { time = date }
if (calNow.get(Calendar.YEAR) == calDate.get(Calendar.YEAR)) {
dateFormatCurrentYear.format(date)
} else {
dateFormatOtherYear.format(date)
}
}
}
} catch (_: Exception) {
raw
}
}
}

View File

@@ -0,0 +1,11 @@
package com.example.myapplication.ui.circle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
abstract class PageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
// 页面级生命周期回调(类似 Fragment
open fun onPageSelected() {}
open fun onPageUnSelected() {}
open fun onRecycled() {}
}

View File

@@ -0,0 +1,84 @@
package com.example.myapplication.ui.circle
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
import kotlin.random.Random
class SpectrumEqualizerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
}
private val random = Random.Default
private val barCount = 12
private val barLevels = FloatArray(barCount)
private val spacingPx = dpToPx(3f)
private val extraBarWidthPx = 4f
private val cornerRadiusPx = dpToPx(2f)
private val noiseGate = 0.02f
private val minBarHeightPx = max(dpToPx(4f), 24f)
fun setLevel(level: Float) {
val raw = level.coerceIn(0f, 1f)
val normalized = ((raw - noiseGate) / (1f - noiseGate)).coerceIn(0f, 1f)
val shaped = sqrt(normalized)
val clamped = (shaped * 2.6f).coerceIn(0f, 1f)
for (i in 0 until barCount) {
val target = clamped * (0.05f + 0.95f * random.nextFloat())
val smoothing = if (clamped == 0f) 0.5f else 0.8f
barLevels[i] = barLevels[i] + (target - barLevels[i]) * smoothing
}
invalidate()
}
fun reset() {
for (i in 0 until barCount) {
barLevels[i] = 0f
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val contentWidth = width - paddingLeft - paddingRight
val contentHeight = height - paddingTop - paddingBottom
if (contentWidth <= 0 || contentHeight <= 0) return
val maxGroupWidth = contentWidth * 0.8f
val totalSpacing = spacingPx * (barCount - 1)
val desiredBarWidth = ((maxGroupWidth - totalSpacing) / barCount.toFloat())
.coerceAtLeast(1f) + extraBarWidthPx
val desiredGroupWidth = desiredBarWidth * barCount + totalSpacing
val groupWidth = min(desiredGroupWidth, contentWidth.toFloat())
val barWidth = ((groupWidth - totalSpacing) / barCount.toFloat())
.coerceAtLeast(1f)
val baseX = paddingLeft + (contentWidth - groupWidth) / 2f
val centerY = paddingTop + contentHeight / 2f
val maxBarHeight = contentHeight.toFloat() * 1.3f
for (i in 0 until barCount) {
val level = barLevels[i]
val barHeight = max(minBarHeightPx, maxBarHeight * level)
val left = baseX + i * (barWidth + spacingPx)
val right = left + barWidth
val top = centerY - barHeight / 2f
val bottom = centerY + barHeight / 2f
canvas.drawRoundRect(left, top, right, bottom, cornerRadiusPx, cornerRadiusPx, paint)
}
}
private fun dpToPx(dp: Float): Float {
return dp * resources.displayMetrics.density
}
}

View File

@@ -0,0 +1,213 @@
// This is the code for the Circle AI Character Report Fragment
package com.example.myapplication.ui.circle
import android.os.Bundle
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.example.myapplication.R
import android.graphics.Typeface
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.reportRequest
import com.google.android.material.textfield.TextInputEditText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.lifecycle.lifecycleScope
class CircleAiCharacterReportFragment : Fragment() {
private var companionId: Int = -1
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_circle_ai_character_report, container, false)
}
private data class OptionItem(@StringRes val nameRes: Int, val value: Int)
private val reasonData = listOf(
OptionItem(R.string.circle_report_reason_1, 1),
OptionItem(R.string.circle_report_reason_2, 2),
OptionItem(R.string.circle_report_reason_3, 3),
OptionItem(R.string.circle_report_reason_4, 4),
OptionItem(R.string.circle_report_reason_5, 5),
OptionItem(R.string.circle_report_reason_6, 6),
OptionItem(R.string.circle_report_reason_7, 7),
OptionItem(R.string.circle_report_reason_8, 8),
OptionItem(R.string.circle_report_reason_9, 9),
)
private val contentData = listOf(
OptionItem(R.string.circle_report_content_1, 10),
OptionItem(R.string.circle_report_content_2, 11),
OptionItem(R.string.circle_report_content_3, 12),
)
private val selectedReasons = mutableSetOf<Int>()
private val selectedContents = mutableSetOf<Int>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
companionId = arguments?.getInt(ARG_COMPANION_ID, -1) ?: -1
Log.d("CircleAiCharacterReport", "companionId=$companionId")
view.findViewById<View>(R.id.iv_close).setOnClickListener {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
val reasonContainer = view.findViewById<LinearLayout>(R.id.reasonOptions)
val contentContainer = view.findViewById<LinearLayout>(R.id.contentOptions)
renderOptions(reasonContainer, reasonData, selectedReasons)
renderOptions(contentContainer, contentData, selectedContents)
val etFeedback = view.findViewById<TextInputEditText>(R.id.et_feedback)
view.findViewById<View>(R.id.btn_keyboard).setOnClickListener {
submitReport(etFeedback?.text?.toString().orEmpty())
}
}
private fun renderOptions(
container: LinearLayout,
items: List<OptionItem>,
selected: MutableSet<Int>
) {
container.removeAllViews()
val context = container.context
val rowPadding = dpToPx(8f)
val rowSpacing = dpToPx(20f)
val iconSize = dpToPx(18f)
items.forEach { item ->
val row = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
if (item != items.last()) {
bottomMargin = rowSpacing
}
}
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, rowPadding, 0, rowPadding)
}
val nameView = TextView(context).apply {
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
text = context.getString(item.nameRes)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(ContextCompat.getColor(context, android.R.color.black))
setTypeface(typeface, Typeface.BOLD)
}
val iconView = ImageView(context).apply {
layoutParams = LinearLayout.LayoutParams(iconSize, iconSize)
setImageResource(
if (selected.contains(item.value)) {
R.drawable.report_selection
} else {
R.drawable.report_not_selected
}
)
}
row.addView(nameView)
row.addView(iconView)
row.setOnClickListener {
if (selected.contains(item.value)) {
selected.remove(item.value)
} else {
selected.add(item.value)
}
iconView.setImageResource(
if (selected.contains(item.value)) {
R.drawable.report_selection
} else {
R.drawable.report_not_selected
}
)
}
container.addView(row)
}
}
private fun dpToPx(dp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
resources.displayMetrics
).toInt()
}
private fun submitReport(rawDesc: String) {
val reportDesc = rawDesc.trim()
val reportTypes = (selectedReasons + selectedContents).toList()
if (reportTypes.isEmpty() && reportDesc.isBlank()) {
Toast.makeText(
requireContext(),
getString(R.string.circle_report_empty_hint),
Toast.LENGTH_SHORT
).show()
return
}
if (companionId <= 0) {
Toast.makeText(
requireContext(),
getString(R.string.circle_report_submit_failed),
Toast.LENGTH_SHORT
).show()
return
}
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
RetrofitClient.apiService.report(
reportRequest(
companionId = companionId,
reportTypes = reportTypes,
reportDesc = reportDesc
)
)
}
}
val response = result.getOrNull()
val success = response != null &&
(response.code == 0 || response.message.equals("ok", ignoreCase = true))
if (success) {
Toast.makeText(
requireContext(),
getString(R.string.circle_report_submit_success),
Toast.LENGTH_SHORT
).show()
requireActivity().onBackPressedDispatcher.onBackPressed()
} else {
Toast.makeText(
requireContext(),
getString(R.string.circle_report_submit_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
companion object {
const val ARG_COMPANION_ID = "arg_companion_id"
}
}

View File

@@ -0,0 +1,274 @@
//角色详情页面
package com.example.myapplication.ui.circle
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.example.myapplication.R
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.RetrofitClient
import android.os.SystemClock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import android.graphics.drawable.Drawable
import android.os.Build
import eightbitlab.com.blurview.BlurView
import eightbitlab.com.blurview.RenderEffectBlur
import eightbitlab.com.blurview.RenderScriptBlur
class CircleCharacterDetailsFragment : Fragment() {
private lateinit var coverImageView: ImageView
private lateinit var nameTextView: TextView
private lateinit var introTextView: TextView
private lateinit var loadingOverlay: View
private lateinit var ageView: View
private var detailsBlur: BlurView? = null
private lateinit var morePopupView: View
private lateinit var moreButton: View
private var companionId: Int = -1
private var fetchJob: Job? = null
private val minLoadingDurationMs = 300L
private var loadingShownAtMs: Long = 0L
private val hideLoadingRunnable = Runnable { fadeOutLoadingOverlay() }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_circle_character_details, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
coverImageView = view.findViewById(R.id.coverImage)
nameTextView = view.findViewById(R.id.name)
introTextView = view.findViewById(R.id.introText)
ageView = view.findViewById(R.id.age)
loadingOverlay = view.findViewById(R.id.loadingOverlay)
detailsBlur = view.findViewById(R.id.detailsBlur)
val closeButton = view.findViewById<View>(R.id.iv_close)
val loadingCloseButton = view.findViewById<View>(R.id.loadingClose)
moreButton = view.findViewById(R.id.iv_more)
morePopupView = view.findViewById(R.id.morePopup)
val root = view.findViewById<View>(R.id.rootCoordinator)
val backAction = {
cancelLoadingAndRequest()
requireActivity().onBackPressedDispatcher.onBackPressed()
}
closeButton.setOnClickListener { backAction() }
loadingCloseButton.setOnClickListener { backAction() }
ageView.setOnClickListener { backAction() }
moreButton.setOnClickListener { toggleMorePopup() }
morePopupView.setOnClickListener { openReportPage() }
root.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN && morePopupView.visibility == View.VISIBLE) {
val insidePopup = isPointInsideView(event.rawX, event.rawY, morePopupView)
val insideMore = isPointInsideView(event.rawX, event.rawY, moreButton)
if (!insidePopup && !insideMore) {
hideMorePopup()
}
}
false
}
setupDetailsBlur()
companionId = arguments?.getInt(ARG_COMPANION_ID, -1) ?: -1
if (companionId > 0) {
fetchCompanionDetail(companionId)
}
}
private fun fetchCompanionDetail(companionId: Int) {
fetchJob?.cancel()
fetchJob = viewLifecycleOwner.lifecycleScope.launch {
showLoadingOverlay()
val response = withContext(Dispatchers.IO) {
try {
RetrofitClient.apiService.companionDetail(companionId.toString())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
Log.d("CircleCharacterDetails", "companionDetail id=$companionId response=$response")
val data = response?.data
if (response?.code == 0 && data != null) {
nameTextView.text = data.name
introTextView.text = data.introText
Glide.with(coverImageView)
.load(data.coverImageUrl)
.placeholder(R.drawable.bg)
.error(R.drawable.bg)
.transition(DrawableTransitionOptions.withCrossFade(180))
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
Log.e("CircleCharacterDetails", "cover image load failed", e)
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
hideLoadingOverlay()
return false
}
})
.into(coverImageView)
}
}
}
private fun showLoadingOverlay() {
loadingOverlay.removeCallbacks(hideLoadingRunnable)
loadingOverlay.animate().cancel()
loadingOverlay.alpha = 1f
loadingOverlay.visibility = View.VISIBLE
loadingShownAtMs = SystemClock.uptimeMillis()
}
private fun hideLoadingOverlay() {
if (loadingOverlay.visibility != View.VISIBLE) {
return
}
loadingOverlay.removeCallbacks(hideLoadingRunnable)
val elapsed = SystemClock.uptimeMillis() - loadingShownAtMs
val remaining = minLoadingDurationMs - elapsed
if (remaining > 0) {
loadingOverlay.postDelayed(hideLoadingRunnable, remaining)
} else {
fadeOutLoadingOverlay()
}
}
private fun fadeOutLoadingOverlay() {
loadingOverlay.animate().cancel()
loadingOverlay.animate()
.alpha(0f)
.setDuration(420)
.withEndAction {
loadingOverlay.visibility = View.GONE
loadingOverlay.alpha = 1f
}
.start()
}
private fun setupDetailsBlur() {
val blurView = detailsBlur ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
blurView.visibility = View.GONE
return
}
val rootView = activity?.findViewById<ViewGroup>(android.R.id.content)?.getChildAt(0) as? ViewGroup
?: return
val blurRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 14f else 10f
try {
val algorithm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RenderEffectBlur()
} else {
RenderScriptBlur(requireContext())
}
blurView.setupWith(rootView, algorithm)
.setFrameClearDrawable(requireActivity().window.decorView.background)
.setBlurRadius(blurRadius)
.setBlurAutoUpdate(true)
.setOverlayColor(ContextCompat.getColor(requireContext(), R.color.frosted_glass_bg_deep))
} catch (_: Throwable) {
blurView.visibility = View.GONE
}
}
private fun cancelLoadingAndRequest() {
fetchJob?.cancel()
fetchJob = null
loadingOverlay.removeCallbacks(hideLoadingRunnable)
loadingOverlay.animate().cancel()
loadingOverlay.visibility = View.GONE
loadingOverlay.alpha = 1f
}
private fun toggleMorePopup() {
if (morePopupView.visibility == View.VISIBLE) {
hideMorePopup()
} else {
morePopupView.visibility = View.VISIBLE
morePopupView.bringToFront()
}
}
private fun hideMorePopup() {
morePopupView.visibility = View.GONE
}
private fun openReportPage() {
if (companionId <= 0) {
return
}
hideMorePopup()
AuthEventBus.emit(
AuthEvent.OpenCirclePage(
R.id.circleAiCharacterReportFragment,
bundleOf(ARG_COMPANION_ID to companionId)
)
)
}
private fun isPointInsideView(rawX: Float, rawY: Float, view: View): Boolean {
val location = IntArray(2)
view.getLocationOnScreen(location)
val left = location[0]
val top = location[1]
val right = left + view.width
val bottom = top + view.height
return rawX >= left && rawX <= right && rawY >= top && rawY <= bottom
}
override fun onDestroyView() {
if (this::morePopupView.isInitialized) {
morePopupView.visibility = View.GONE
}
fetchJob?.cancel()
fetchJob = null
detailsBlur = null
super.onDestroyView()
}
companion object {
const val ARG_COMPANION_ID = "arg_companion_id"
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%"
android:duration="800"
android:repeatCount="infinite"
android:interpolator="@android:anim/linear_interpolator" />

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="220"
android:fromYDelta="100%"
android:toYDelta="0%"
android:interpolator="@android:anim/decelerate_interpolator" />
<alpha
android:duration="220"
android:fromAlpha="0.0"
android:toAlpha="1.0" />
</set>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="200"
android:fromYDelta="0%"
android:toYDelta="100%"
android:interpolator="@android:anim/accelerate_interpolator" />
<alpha
android:duration="200"
android:fromAlpha="1.0"
android:toAlpha="0.0" />
</set>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="0.4"
android:toAlpha="1.0"
android:duration="500"
android:repeatCount="infinite"
android:repeatMode="reverse" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F3F3F3"/>
<corners android:radius="4dp"/>
<corners android:radius="@dimen/sw_4dp"/>
</shape>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#f5f5f5"/>
<corners android:radius="5dp"/>
<corners android:radius="@dimen/sw_5dp"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#33000000" />
<corners android:radius="@dimen/sw_14dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 对方的消息气泡背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#94525252" />
<corners android:radius="@dimen/sw_14dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 自己的消息气泡背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#70FFFFFF" />
<corners android:radius="@dimen/sw_14dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#00FFFFFF" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#99000000"
android:endColor="#00000000"
android:angle="90" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 输入框整体背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 发送按钮背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#2563EB" />
<corners android:radius="@dimen/sw_10dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 输入框文本背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#7DFFFFFF" />
<corners android:radius="@dimen/sw_50dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 输入框文本背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#00000000" />
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#99FF5F5F" />
<corners android:radius="@dimen/sw_50dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#80000000" />
<corners android:radius="@dimen/sw_50dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#80000000" />
<corners android:radius="@dimen/sw_7dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#00FFFFFF" />
<corners
android:topLeftRadius="@dimen/sw_20dp"
android:topRightRadius="@dimen/sw_20dp"
android:bottomLeftRadius="0dp"
android:bottomRightRadius="0dp" />
</shape>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E53935"/>
<corners android:radius="18dp"/>
<corners android:radius="@dimen/sw_18dp"/>
</shape>

View File

@@ -2,5 +2,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#02BEAC" />
<corners android:radius="10dp" />
<corners android:radius="@dimen/sw_10dp" />
</shape>

View File

@@ -2,5 +2,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners android:radius="10dp" />
<corners android:radius="@dimen/sw_10dp" />
</shape>

View File

@@ -1,4 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/white"/>
<corners android:radius="16dp"/>
<corners android:radius="@dimen/sw_16dp"/>
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
<corners android:radius="@dimen/sw_23dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#00000000" />
<corners android:radius="@dimen/sw_50dp" />
<stroke android:width="@dimen/sw_1dp" android:color="#21FFFFFF" />
</shape>

View File

@@ -3,7 +3,7 @@
<!-- 选中 -->
<item android:state_selected="true">
<shape android:shape="rectangle">
<corners android:radius="14dp" />
<corners android:radius="@dimen/sw_14dp" />
<solid android:color="#1A000000" />
</shape>
</item>
@@ -11,7 +11,7 @@
<!-- 未选中 -->
<item>
<shape android:shape="rectangle">
<corners android:radius="14dp" />
<corners android:radius="@dimen/sw_14dp" />
<solid android:color="@android:color/transparent" />
</shape>
</item>

View File

@@ -3,7 +3,7 @@
<!-- 选中 -->
<item android:state_selected="true">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<corners android:radius="@dimen/sw_12dp" />
<solid android:color="#1A2563EB" />
</shape>
</item>
@@ -11,7 +11,7 @@
<!-- 未选中 -->
<item>
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<corners android:radius="@dimen/sw_12dp" />
<solid android:color="#0F000000" />
</shape>
</item>

View File

@@ -1,5 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="2dp"/>
<corners android:radius="@dimen/sw_2dp"/>
<solid android:color="#33000000"/>
</shape>

View File

@@ -3,14 +3,14 @@
<!-- 按下状态 -->
<item android:state_pressed="true">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<corners android:radius="@dimen/sw_4dp"/>
</shape>
</item>
<!-- 默认状态 -->
<item>
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<corners android:radius="@dimen/sw_4dp"/>
</shape>
</item>
</selector>

View File

@@ -4,8 +4,8 @@
<item android:state_activated="true">
<shape android:shape="rectangle">
<solid android:color="#4CAF50"/>
<corners android:radius="4dp"/>
<stroke android:width="1dp" android:color="#388E3C"/>
<corners android:radius="@dimen/sw_4dp"/>
<stroke android:width="@dimen/sw_1dp" android:color="#388E3C"/>
</shape>
</item>
@@ -13,8 +13,8 @@
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#9E9E9E"/>
<corners android:radius="4dp"/>
<stroke android:width="1dp" android:color="#757575"/>
<corners android:radius="@dimen/sw_4dp"/>
<stroke android:width="@dimen/sw_1dp" android:color="#757575"/>
</shape>
</item>
@@ -22,8 +22,8 @@
<item>
<shape android:shape="rectangle">
<solid android:color="#E0E0E0"/>
<corners android:radius="4dp"/>
<stroke android:width="1dp" android:color="#BDBDBD"/>
<corners android:radius="@dimen/sw_4dp"/>
<stroke android:width="@dimen/sw_1dp" android:color="#BDBDBD"/>
</shape>
</item>
</selector>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F1F1F1" />
<corners android:radius="24dp" />
<corners android:radius="@dimen/sw_24dp" />
</shape>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#02BEAC" />
<corners android:radius="24dp" />
<corners android:radius="@dimen/sw_24dp" />
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,4 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F4F8FB"/>
<corners android:radius="6dp"/>
<corners android:radius="@dimen/sw_6dp"/>
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 输入框文本背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#B3797979" />
<corners android:radius="50dp" />
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

View File

@@ -6,10 +6,10 @@
<!-- 圆角(玻璃一般有圆角) -->
<corners
android:radius="4dp" />
android:radius="@dimen/sw_4dp" />
<!-- 白色半透明描边,增加玻璃边缘感 -->
<stroke
android:width="1dp"
android:width="@dimen/sw_1dp"
android:color="#66FFFFFF" />
</shape>

View File

@@ -3,7 +3,7 @@
<solid android:color="#FFFFFF" /> <!-- 40% 透明白 -->
<corners
android:topLeftRadius="0dp"
android:topRightRadius="4dp"
android:topRightRadius="@dimen/sw_4dp"
android:bottomLeftRadius="0dp"
android:bottomRightRadius="4dp" />
android:bottomRightRadius="@dimen/sw_4dp" />
</shape>

View File

@@ -11,7 +11,7 @@
<!-- 底部边框:固定在底部,只画一条线 -->
<item android:gravity="bottom">
<shape android:shape="rectangle">
<size android:height="1dp" />
<size android:height="@dimen/sw_1dp" />
<solid android:color="#F3F3F3" />
</shape>
</item>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#DDF99E"
android:endColor="#BAF4D4"
android:angle="270" />
<corners android:radius="@dimen/sw_50dp" />
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
<corners android:radius="16dp" />
<corners android:radius="@dimen/sw_16dp" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:startColor="#DDF99E"
android:endColor="#BAF4D4"
android:angle="270" />
<corners android:radius="@dimen/sw_50dp"/>
</shape>

Some files were not shown because too many files have changed in this diff Show More