手机适配
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 输入法服务 -->
|
||||
|
||||
BIN
app/src/main/assets/bi_data.bin
Normal file
BIN
app/src/main/assets/tri_data.bin
Normal file
BIN
app/src/main/assets/tri_rowptr.bin
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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等) */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-gram(2词上下文)
|
||||
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-gram(1词上下文)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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/"
|
||||
|
||||
/**
|
||||
* 请求拦截器:打印请求信息
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
//
|
||||
// ================== 签名工具(严格按你描述规则) ==================
|
||||
//
|
||||
|
||||
@@ -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?,//父评论ID,NULL表示一级评论
|
||||
val rootId: Int?,//根评论ID,NULL表示根评论
|
||||
)
|
||||
//点赞/取消点赞评论
|
||||
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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
// ====== 按你给的规则固定值 ======
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/anim/circle_audio_loading.xml
Normal 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" />
|
||||
12
app/src/main/res/anim/circle_sheet_enter.xml
Normal 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>
|
||||
12
app/src/main/res/anim/circle_sheet_exit.xml
Normal 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>
|
||||
7
app/src/main/res/anim/circle_text_loading.xml
Normal 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" />
|
||||
|
Before Width: | Height: | Size: 2.2 MiB |
BIN
app/src/main/res/drawable/a123123123.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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>
|
||||
@@ -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>
|
||||
5
app/src/main/res/drawable/bg_chat_audio_button.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/bg_chat_bubble_bot.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/bg_chat_bubble_me.xml
Normal 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>
|
||||
4
app/src/main/res/drawable/bg_chat_footer_placeholder.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/bg_chat_frosted_gradient.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/bg_chat_input.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/bg_chat_send.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/bg_chat_text_box.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/bg_chat_text_box_edit_text.xml
Normal 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>
|
||||
4
app/src/main/res/drawable/bg_chat_voice_cancel_input.xml
Normal 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>
|
||||
4
app/src/main/res/drawable/bg_chat_voice_input.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
4
app/src/main/res/drawable/bg_report_reason.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/bg_search_box.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
app/src/main/res/drawable/circle_display.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/drawable/close_the_comment_box.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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>
|
||||
BIN
app/src/main/res/drawable/collect.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
app/src/main/res/drawable/comment.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable/comment_has_been_liked.png
Normal file
|
After Width: | Height: | Size: 882 B |
6
app/src/main/res/drawable/comment_input_bg.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable/comment_likes.png
Normal file
|
After Width: | Height: | Size: 929 B |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
BIN
app/src/main/res/drawable/details_of_ai_character_close.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/drawable/details_of_ai_character_close_more.png
Normal file
|
After Width: | Height: | Size: 361 B |
BIN
app/src/main/res/drawable/details_of_ai_character_more_icon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||