手机适配
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(powershell:*)",
|
||||||
|
"Bash(find /d/Test/MyApplication/app/src/main/assets -name \"vocab.txt\" -exec wc -l {} ;)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
128
CLAUDE.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
Android 输入法应用(IME),包含自定义键盘和社交圈(Circle)功能模块。Kotlin 开发,Jetpack Compose + XML 混合 UI。
|
||||||
|
|
||||||
|
- **包名**: `com.boshan.key.of.love`
|
||||||
|
- **命名空间**: `com.example.myapplication`
|
||||||
|
- **编译配置**: compileSdk 34, minSdk 21, targetSdk 34
|
||||||
|
|
||||||
|
## 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradlew assembleDebug # 构建 Debug APK
|
||||||
|
gradlew assembleRelease # 构建 Release APK
|
||||||
|
gradlew installDebug # 安装到设备
|
||||||
|
gradlew clean # 清理
|
||||||
|
gradlew lint # Lint 检查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心架构
|
||||||
|
|
||||||
|
### 应用入口流程
|
||||||
|
```
|
||||||
|
SplashActivity → GuideActivity/ImeGuideActivity → OnboardingActivity → MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
### 键盘模块 (keyboard/)
|
||||||
|
|
||||||
|
**继承体系**:
|
||||||
|
```
|
||||||
|
BaseKeyboard (抽象基类,提供震动、主题应用、递归设置文字颜色)
|
||||||
|
├── MainKeyboard (主键盘,拼音/英文输入)
|
||||||
|
├── AiKeyboard (AI 辅助键盘)
|
||||||
|
├── EmojiKeyboard (表情/颜文字键盘)
|
||||||
|
├── NumberKeyboard (数字键盘)
|
||||||
|
└── SymbolKeyboard (符号键盘)
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心服务 `MyInputMethodService.kt`**:
|
||||||
|
- 键盘懒加载 + 缓存机制(`ensureMainKeyboard()` 等)
|
||||||
|
- 智能联想流程:`commitKey()` → `updateCompletionsAndRender()` → 后台计算候选词 → `showCompletionSuggestions()`
|
||||||
|
- 特殊交互:Emoji 按 Unicode 代码点删除、长按连删 + 上滑清空、回填功能
|
||||||
|
|
||||||
|
### 语言模型 (data/)
|
||||||
|
|
||||||
|
**N-gram 模型架构**(`LanguageModel.kt` + `LanguageModelLoader.kt`):
|
||||||
|
- 词表:`assets/vocab.txt`(每行一词,行号=词ID,按频率降序)
|
||||||
|
- Unigram:`assets/uni_logp.bin`(u16 分数数组,0-1000,越高越常用)
|
||||||
|
- Bigram:`assets/bi_rowptr.bin`([u32 offset, u16 length]) + `bi_data.bin`([u32 next_id, u16 score])
|
||||||
|
- Trigram:`assets/tri_ctx.bin`([u32 ctx1, u32 ctx2]) + `tri_rowptr.bin` + `tri_data.bin`
|
||||||
|
|
||||||
|
**预测算法**:3-gram → 2-gram → 1-gram 回退机制,结合用户点击学习排序
|
||||||
|
|
||||||
|
**Trie 优化**(`Trie.kt`):
|
||||||
|
- 每个节点记录 `maxFreq`(子树最高词频)
|
||||||
|
- 优先级队列遍历快速获取 top-K
|
||||||
|
- 用户点击权重 1000,远超静态词频
|
||||||
|
|
||||||
|
### 网络层 (network/)
|
||||||
|
|
||||||
|
**双客户端架构**:
|
||||||
|
- `RetrofitClient.kt`:常规 HTTP(30s 超时)
|
||||||
|
- `NetworkClient.kt`:SSE 流式响应(无超时,用于 AI 聊天)
|
||||||
|
|
||||||
|
**请求签名机制**(`HttpInterceptors.kt`):
|
||||||
|
1. 生成 timestamp + nonce(UUID 前 16 位)
|
||||||
|
2. 合并参数(appId + timestamp + nonce + query + body 扁平化)
|
||||||
|
3. 按 key 字典序拼接,HMAC-SHA256 签名
|
||||||
|
4. 添加 Header:X-App-Id, X-Timestamp, X-Nonce, X-Sign
|
||||||
|
|
||||||
|
**SSE 解析**(`NetworkClient.kt`):支持 JSON 和纯文本 chunk,自动识别 `[done]` 结束标记
|
||||||
|
|
||||||
|
**Token 过期处理**:响应拦截器检测 code=40102/40103 → 清除本地 token → `AuthEventBus.emit(TokenExpired)`
|
||||||
|
|
||||||
|
### 社交圈 Repository (ui/circle/)
|
||||||
|
|
||||||
|
**缓存策略**(`CircleChatRepository.kt`):
|
||||||
|
- 自适应 LRU 缓存大小(根据设备内存 32-120 页)
|
||||||
|
- 预加载当前页前后 N 页
|
||||||
|
- 防重加载机制(`inFlight`、`pageInFlight` HashSet)
|
||||||
|
|
||||||
|
### 主题系统 (theme/)
|
||||||
|
|
||||||
|
- `ThemeManager.kt`:观察者模式通知主题变更
|
||||||
|
- 各键盘实现 `applyKeyBackgroundsForTheme()` 应用主题
|
||||||
|
- `ThemeDownloadWorker.kt`:WorkManager 后台下载
|
||||||
|
|
||||||
|
### UI 模块 (ui/)
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `circle/` | 社交圈:AI 角色聊天、评论系统 |
|
||||||
|
| `shop/` | 主题商店 |
|
||||||
|
| `mine/` | 个人中心 |
|
||||||
|
| `login/` | 登录注册 |
|
||||||
|
| `recharge/` | 充值 |
|
||||||
|
| `home/` | 首页 |
|
||||||
|
|
||||||
|
导航图:`res/navigation/circle_graph.xml`
|
||||||
|
|
||||||
|
## 关键配置
|
||||||
|
|
||||||
|
**build.gradle.kts**:
|
||||||
|
```kotlin
|
||||||
|
androidResources {
|
||||||
|
noCompress += listOf("bin") // 禁止压缩 .bin,允许 FileChannel 内存映射
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**输入法配置**:`res/xml/method.xml`
|
||||||
|
**网络安全**:`res/xml/network_security_config.xml`
|
||||||
|
|
||||||
|
## 辅助脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/clean_vocab.py <输入文件> <输出文件> # 词表清洗(过滤无效词)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发注意事项
|
||||||
|
|
||||||
|
- 输入法服务需用户在系统设置中手动启用
|
||||||
|
- 语言模型使用 `FileChannel.map()` 内存映射加载,修改 .bin 文件需重新生成
|
||||||
|
- 网络请求签名使用 Body 扁平化(支持嵌套 JSON 和数组)
|
||||||
|
- `NetworkClient.init(context)` 必须在使用 SSE 前调用(通常在 Application.onCreate)
|
||||||
|
- 项目当前无单元测试配置
|
||||||
@@ -8,6 +8,11 @@ android {
|
|||||||
namespace = "com.example.myapplication"
|
namespace = "com.example.myapplication"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
|
|
||||||
|
// 禁止压缩 .bin 文件,允许内存映射加载
|
||||||
|
androidResources {
|
||||||
|
noCompress += listOf("bin")
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.boshan.key.of.love"
|
applicationId = "com.boshan.key.of.love"
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="stateHidden|adjustResize">
|
||||||
</activity>
|
</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,8 +159,14 @@ class GuideActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 键盘发送
|
// 键盘发送
|
||||||
inputMessage.setOnEditorActionListener { _, actionId, _ ->
|
inputMessage.setOnEditorActionListener { _, actionId, event ->
|
||||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
if (event != null) {
|
||||||
|
return@setOnEditorActionListener false
|
||||||
|
}
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND ||
|
||||||
|
actionId == EditorInfo.IME_ACTION_DONE ||
|
||||||
|
actionId == EditorInfo.IME_ACTION_UNSPECIFIED
|
||||||
|
) {
|
||||||
// 走同一套发送逻辑
|
// 走同一套发送逻辑
|
||||||
sendMessage()
|
sendMessage()
|
||||||
true
|
true
|
||||||
@@ -168,6 +174,14 @@ class GuideActivity : AppCompatActivity() {
|
|||||||
false
|
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.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import eightbitlab.com.blurview.BlurView
|
import eightbitlab.com.blurview.BlurView
|
||||||
import eightbitlab.com.blurview.RenderEffectBlur
|
|
||||||
import eightbitlab.com.blurview.RenderScriptBlur
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -236,6 +234,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
is AuthEvent.OpenGlobalPage -> {
|
is AuthEvent.OpenGlobalPage -> {
|
||||||
openGlobal(event.destinationId, event.bundle, event.clearGlobalBackStack)
|
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 -> {
|
is AuthEvent.UserUpdated -> {
|
||||||
// 不需要处理
|
// 不需要处理
|
||||||
@@ -357,6 +364,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
R.id.feedbackFragment,
|
R.id.feedbackFragment,
|
||||||
R.id.MyKeyboard,
|
R.id.MyKeyboard,
|
||||||
R.id.PersonalSettings,
|
R.id.PersonalSettings,
|
||||||
|
R.id.circleCharacterDetailsFragment,
|
||||||
|
R.id.circleAiCharacterReportFragment,
|
||||||
|
R.id.CircleMyAiCharacterFragment
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,13 +486,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun applyCircleTabBackground() {
|
private fun applyCircleTabBackground() {
|
||||||
bottomNav.itemBackground = null
|
bottomNav.itemBackground = null
|
||||||
if (blurReady) {
|
bottomNav.backgroundTintList = null
|
||||||
bottomNavBlur.visibility = View.VISIBLE
|
// Circle 页底栏保持完全透明
|
||||||
bottomNav.background = ColorDrawable(android.graphics.Color.TRANSPARENT)
|
|
||||||
} else {
|
|
||||||
bottomNavBlur.visibility = View.GONE
|
bottomNavBlur.visibility = View.GONE
|
||||||
bottomNav.background = ColorDrawable(ContextCompat.getColor(this, R.color.black_30_percent))
|
bottomNav.background = ColorDrawable(android.graphics.Color.TRANSPARENT)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetBottomNavBackground() {
|
private fun resetBottomNavBackground() {
|
||||||
@@ -492,35 +499,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupBottomNavBlur() {
|
private fun setupBottomNavBlur() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
// 全局移除底栏毛玻璃效果
|
||||||
blurReady = false
|
blurReady = false
|
||||||
bottomNavBlur.visibility = View.GONE
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开全局页(login/recharge等) */
|
/** 打开全局页(login/recharge等) */
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import android.widget.TextView
|
|||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import com.example.myapplication.data.WordDictionary
|
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.theme.ThemeManager
|
||||||
import com.example.myapplication.keyboard.KeyboardEnvironment
|
import com.example.myapplication.keyboard.KeyboardEnvironment
|
||||||
import com.example.myapplication.keyboard.MainKeyboard
|
import com.example.myapplication.keyboard.MainKeyboard
|
||||||
@@ -70,19 +70,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
private var currentInput = StringBuilder() // 当前输入前缀
|
private var currentInput = StringBuilder() // 当前输入前缀
|
||||||
private var completionSuggestions = emptyList<String>() // 自动完成建议
|
private var completionSuggestions = emptyList<String>() // 自动完成建议
|
||||||
private val suggestionViews = mutableListOf<TextView>() // 缓存动态创建的候选视图
|
private val suggestionViews = mutableListOf<TextView>() // 缓存动态创建的候选视图
|
||||||
private var suggestionSlotCount: Int = 21 // 包含前缀位,调这里可修改渲染数量
|
private var suggestionSlotCount: Int = 10 // 包含前缀位,调这里可修改渲染数量
|
||||||
private val completionCapacity: Int
|
private val completionCapacity: Int
|
||||||
get() = (suggestionSlotCount - 1).coerceAtLeast(0)
|
get() = (suggestionSlotCount - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
@Volatile private var isSpecialToken: BooleanArray = BooleanArray(0)
|
@Volatile private var isSpecialToken: BooleanArray = BooleanArray(0)
|
||||||
|
|
||||||
private val suggestionStats by lazy { SuggestionStats(applicationContext) }
|
private val languageModel by lazy { LanguageModel(applicationContext, wordDictionary.wordTrie) }
|
||||||
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
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MyIME"
|
private const val TAG = "MyIME"
|
||||||
@@ -124,6 +118,12 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
// Shift 状态
|
// Shift 状态
|
||||||
private var isShiftOn = false
|
private var isShiftOn = false
|
||||||
|
|
||||||
|
private fun setShiftState(on: Boolean) {
|
||||||
|
if (isShiftOn == on) return
|
||||||
|
isShiftOn = on
|
||||||
|
mainKeyboard?.setShiftState(on)
|
||||||
|
}
|
||||||
|
|
||||||
// 删除长按
|
// 删除长按
|
||||||
private var isDeleting = false
|
private var isDeleting = false
|
||||||
private val repeatDelInitialDelay = 350L
|
private val repeatDelInitialDelay = 350L
|
||||||
@@ -223,7 +223,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
ThemeManager.init(this)
|
ThemeManager.init(this)
|
||||||
}.start()
|
}.start()
|
||||||
|
|
||||||
// 异步加载词典与 bigram 模型
|
// 异步加载词典与语言模型
|
||||||
Thread {
|
Thread {
|
||||||
// 1) Trie 词典
|
// 1) Trie 词典
|
||||||
try {
|
try {
|
||||||
@@ -232,35 +232,11 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
Log.w(TAG, "Trie load failed: ${e.message}", e)
|
Log.w(TAG, "Trie load failed: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Bigram 模型
|
// 2) N-gram 语言模型
|
||||||
try {
|
try {
|
||||||
val m = LanguageModelLoader.load(this)
|
languageModel.preload()
|
||||||
|
|
||||||
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
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
bigramReady = false
|
Log.w(TAG, "Language model load failed: ${e.message}", e)
|
||||||
Log.w(TAG, "Bigram load failed: ${e.message}", e)
|
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
|
|
||||||
@@ -371,6 +347,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
// 删除键长按连删
|
// 删除键长按连删
|
||||||
val delId = resources.getIdentifier("key_del", "id", packageName)
|
val delId = resources.getIdentifier("key_del", "id", packageName)
|
||||||
mainKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
|
mainKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
|
||||||
|
|
||||||
|
// 同步当前 Shift 状态到主键盘 UI
|
||||||
|
mainKeyboard?.setShiftState(isShiftOn)
|
||||||
}
|
}
|
||||||
return mainKeyboard!!
|
return mainKeyboard!!
|
||||||
}
|
}
|
||||||
@@ -388,7 +367,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
val full = et.text?.toString().orEmpty()
|
val full = et.text?.toString().orEmpty()
|
||||||
if (full.isEmpty()) {
|
if (full.isEmpty()) {
|
||||||
// 已经空了就不做
|
// 已经空了就不做
|
||||||
clearEditorState()
|
clearEditorState(resetShift = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +385,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
ic.endBatchEdit()
|
ic.endBatchEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEditorState()
|
clearEditorState(resetShift = false)
|
||||||
|
|
||||||
// 清空后立即更新所有键盘的按钮可见性
|
// 清空后立即更新所有键盘的按钮可见性
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
@@ -678,6 +657,28 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
return word
|
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)
|
// 提交一个字符(原 sendKey)
|
||||||
override fun commitKey(c: Char) {
|
override fun commitKey(c: Char) {
|
||||||
val ic = currentInputConnection ?: return
|
val ic = currentInputConnection ?: return
|
||||||
@@ -700,6 +701,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
c == ' ' || !c.isLetter() -> {
|
c == ' ' || !c.isLetter() -> {
|
||||||
updateCompletionsAndRender(prefix = "")
|
updateCompletionsAndRender(prefix = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playKeyClick()
|
playKeyClick()
|
||||||
@@ -803,7 +805,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
val editorReallyEmpty = before1.isEmpty() && after1.isEmpty()
|
val editorReallyEmpty = before1.isEmpty() && after1.isEmpty()
|
||||||
|
|
||||||
if (editorReallyEmpty) {
|
if (editorReallyEmpty) {
|
||||||
clearEditorState()
|
clearEditorState(resetShift = false)
|
||||||
} else {
|
} else {
|
||||||
// prefix 也不要取太长
|
// prefix 也不要取太长
|
||||||
val prefix = getCurrentWordPrefix(maxLen = 64)
|
val prefix = getCurrentWordPrefix(maxLen = 64)
|
||||||
@@ -823,23 +825,50 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
val ic = currentInputConnection ?: return
|
val ic = currentInputConnection ?: return
|
||||||
val info = currentInputEditorInfo
|
val info = currentInputEditorInfo
|
||||||
|
|
||||||
var handled = false
|
var actionId = EditorInfo.IME_ACTION_UNSPECIFIED
|
||||||
|
var imeOptions = 0
|
||||||
|
var isMultiLine = false
|
||||||
|
var noEnterAction = false
|
||||||
|
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
// 取出当前 EditText 声明的 action
|
// 取出当前 EditText 声明的 action
|
||||||
val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用
|
if (isMultiLine && (noEnterAction ||
|
||||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
actionId == EditorInfo.IME_ACTION_UNSPECIFIED ||
|
||||||
handled = ic.performEditorAction(actionId)
|
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
|
// 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false
|
||||||
// 就降级为“标准回车”
|
// 就降级为“标准回车”
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
|
if (isMultiLine) {
|
||||||
|
ic.commitText("\n", 1)
|
||||||
|
} else {
|
||||||
sendEnterKey(ic)
|
sendEnterKey(ic)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
playKeyClick()
|
playKeyClick()
|
||||||
clearEditorState()
|
clearEditorState()
|
||||||
@@ -864,7 +893,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
override fun getCurrentWordPrefix(maxLen: Int): String {
|
override fun getCurrentWordPrefix(maxLen: Int): String {
|
||||||
val before = currentInputConnection?.getTextBeforeCursor(maxLen, 0)?.toString() ?: ""
|
val before = currentInputConnection?.getTextBeforeCursor(maxLen, 0)?.toString() ?: ""
|
||||||
val match = Regex("[A-Za-z]+$").find(before)
|
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 afterAll = ic?.getTextAfterCursor(256, 0)?.toString().orEmpty()
|
||||||
val editorReallyEmpty = beforeAll.isEmpty() && afterAll.isEmpty()
|
val editorReallyEmpty = beforeAll.isEmpty() && afterAll.isEmpty()
|
||||||
|
|
||||||
|
val rawPrefix = prefix
|
||||||
|
val lookupPrefix = prefix // 严格按原始大小写查询
|
||||||
|
|
||||||
currentInput.clear()
|
currentInput.clear()
|
||||||
currentInput.append(prefix)
|
currentInput.append(rawPrefix)
|
||||||
|
|
||||||
if (editorReallyEmpty) {
|
if (editorReallyEmpty) {
|
||||||
clearEditorState()
|
clearEditorState(resetShift = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val lastWord = getPrevWordBeforeCursor()
|
val lastWord = getPrevWordBeforeCursor()
|
||||||
|
val prevWords = getPrevWordsBeforeCursor(2) // 获取最后2个词用于 trigram
|
||||||
val maxCompletions = completionCapacity
|
val maxCompletions = completionCapacity
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
@@ -892,25 +925,30 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
if (prefix.isEmpty()) {
|
val modelResult = if (lookupPrefix.isEmpty()) {
|
||||||
if (lastWord == null) {
|
if (prevWords.isEmpty()) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
suggestWithBigram("", lastWord, topK = maxCompletions)
|
// 无前缀但有上文:预测下一词(传入最后2个词以支持 trigram)
|
||||||
|
languageModel.predictNext(prevWords, maxCompletions)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val fromBi = suggestWithBigram(prefix, lastWord, topK = maxCompletions)
|
// 有前缀:严格按原始大小写查询
|
||||||
if (fromBi.isNotEmpty()) {
|
languageModel.suggest(lookupPrefix, lastWord, maxCompletions)
|
||||||
fromBi.filter { it != prefix }
|
.filter { it != lookupPrefix }
|
||||||
} else {
|
|
||||||
wordDictionary.wordTrie.startsWith(prefix, maxCompletions)
|
|
||||||
.filter { it != prefix }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果语言模型返回空结果,回退到 Trie(严格按原始大小写)
|
||||||
|
if (modelResult.isEmpty() && lookupPrefix.isNotEmpty()) {
|
||||||
|
wordDictionary.wordTrie.startsWith(lookupPrefix, maxCompletions)
|
||||||
|
.filterNot { it == lookupPrefix }
|
||||||
|
} else {
|
||||||
|
modelResult
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
if (prefix.isNotEmpty()) {
|
if (lookupPrefix.isNotEmpty()) {
|
||||||
wordDictionary.wordTrie.startsWith(prefix, maxCompletions)
|
wordDictionary.wordTrie.startsWith(lookupPrefix, maxCompletions)
|
||||||
.filterNot { it == prefix }
|
.filterNot { it == lookupPrefix }
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
@@ -919,7 +957,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
|
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
val limited = if (maxCompletions > 0) list.distinct().take(maxCompletions) else emptyList()
|
val limited = if (maxCompletions > 0) list.distinct().take(maxCompletions) else emptyList()
|
||||||
completionSuggestions = suggestionStats.sortByCount(limited)
|
completionSuggestions = limited
|
||||||
showCompletionSuggestions()
|
showCompletionSuggestions()
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
@@ -1022,7 +1060,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
textView.text = word
|
textView.text = word
|
||||||
textView.visibility = View.VISIBLE
|
textView.visibility = View.VISIBLE
|
||||||
textView.setOnClickListener {
|
textView.setOnClickListener {
|
||||||
suggestionStats.incClick(word)
|
languageModel.recordSelection(word)
|
||||||
insertCompletion(word)
|
insertCompletion(word)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1313,107 +1351,6 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
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(
|
override fun onUpdateSelection(
|
||||||
oldSelStart: Int,
|
oldSelStart: Int,
|
||||||
oldSelEnd: Int,
|
oldSelEnd: Int,
|
||||||
@@ -1439,20 +1376,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
|
|
||||||
// 当编辑框光标前后都没有任何字符,说明真的完全空了
|
// 当编辑框光标前后都没有任何字符,说明真的完全空了
|
||||||
if (before.isEmpty() && after.isEmpty()) {
|
if (before.isEmpty() && after.isEmpty()) {
|
||||||
clearEditorState()
|
clearEditorState(resetShift = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 清理本次编辑框相关的状态(光标、联想、长按等)
|
// 清理本次编辑框相关的状态(光标、联想、长按等)
|
||||||
private fun clearEditorState() {
|
private fun clearEditorState(resetShift: Boolean = true) {
|
||||||
// 1. 文本联想/补全相关
|
// 1. 文本联想/补全相关
|
||||||
currentInput.clear()
|
currentInput.clear()
|
||||||
completionSuggestions = emptyList()
|
completionSuggestions = emptyList()
|
||||||
lastWordForLM = null
|
lastWordForLM = null
|
||||||
|
|
||||||
// 2. Shift 状态
|
// 2. Shift 状态
|
||||||
isShiftOn = false
|
if (resetShift) {
|
||||||
|
setShiftState(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 停止长按删除
|
// 3. 停止长按删除
|
||||||
stopRepeatDelete()
|
stopRepeatDelete()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import android.os.Looper
|
|||||||
// import android.widget.ProgressBar
|
// import android.widget.ProgressBar
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.example.myapplication.network.BehaviorReporter
|
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() {
|
class SplashActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -19,6 +22,13 @@ class SplashActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.activity_splash)
|
setContentView(R.layout.activity_splash)
|
||||||
|
|
||||||
// progressBar = findViewById(R.id.progressBar)
|
// 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 prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
|
||||||
val isFirstLaunch = prefs.getBoolean("is_first_launch", true)
|
val isFirstLaunch = prefs.getBoolean("is_first_launch", true)
|
||||||
|
|||||||
@@ -1,71 +1,156 @@
|
|||||||
package com.example.myapplication
|
package com.example.myapplication
|
||||||
|
|
||||||
import java.util.ArrayDeque
|
import java.util.PriorityQueue
|
||||||
|
|
||||||
class Trie {
|
class Trie {
|
||||||
//表示Trie数据结构中的一个节点,该节点可以存储其子节点,并且可以标记是否是一个完整单词的结尾
|
// Trie 节点,包含子节点、终结词集合、是否是词尾,以及该子树的最大词频
|
||||||
private data class TrieNode(
|
private data class TrieNode(
|
||||||
val children: MutableMap<Char, TrieNode> = mutableMapOf(),
|
val children: MutableMap<Char, TrieNode> = mutableMapOf(),
|
||||||
|
val terminalWords: LinkedHashSet<String> = linkedSetOf(),
|
||||||
var isEndOfWord: Boolean = false
|
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
|
var current = root
|
||||||
|
|
||||||
for (char in word.lowercase()) {
|
for (char in word) {
|
||||||
current = current.children.getOrPut(char) { TrieNode() }
|
current = current.children.getOrPut(char) { TrieNode() }
|
||||||
|
// 沿路径更新每个节点的最大词频
|
||||||
|
if (freq > current.maxFreq) {
|
||||||
|
current.maxFreq = freq
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
current.isEndOfWord = true
|
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 {
|
fun search(word: String): Boolean {
|
||||||
var current = root
|
var current = root
|
||||||
|
|
||||||
for (char in word.lowercase()) {
|
for (char in word) {
|
||||||
current = current.children[char] ?: return false
|
current = current.children[char] ?: return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return current.isEndOfWord
|
return current.isEndOfWord
|
||||||
}
|
}
|
||||||
|
|
||||||
//查找以prefix为前缀的所有单词。通过遍历prefix的每个字符,找到相应的节点,然后从该节点开始迭代搜索所有以该节点为起点的单词。
|
/**
|
||||||
|
* 查找以 prefix 为前缀的所有单词(不限数量)
|
||||||
|
*/
|
||||||
fun startsWith(prefix: String): List<String> {
|
fun startsWith(prefix: String): List<String> {
|
||||||
return startsWith(prefix, Int.MAX_VALUE)
|
return startsWith(prefix, Int.MAX_VALUE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找以 prefix 为前缀的单词,按词频降序返回
|
||||||
|
* 使用优先队列,优先遍历高词频分支
|
||||||
|
*
|
||||||
|
* @param prefix 前缀
|
||||||
|
* @param limit 最大返回数量
|
||||||
|
*/
|
||||||
fun startsWith(prefix: String, limit: Int): List<String> {
|
fun startsWith(prefix: String, limit: Int): List<String> {
|
||||||
var current = root
|
var current = root
|
||||||
|
|
||||||
val normalized = prefix.lowercase()
|
for (char in prefix) {
|
||||||
for (char in normalized) {
|
|
||||||
current = current.children[char] ?: return emptyList()
|
current = current.children[char] ?: return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val max = if (limit < 0) 0 else limit
|
val max = if (limit < 0) 0 else limit
|
||||||
if (max == 0) return emptyList()
|
if (max == 0) return emptyList()
|
||||||
|
|
||||||
val results = ArrayList<String>(minOf(max, 16))
|
// 存储结果:(词, 词频)
|
||||||
val stack = ArrayDeque<Pair<TrieNode, String>>()
|
val resultSet = linkedSetOf<String>()
|
||||||
stack.addLast(current to prefix)
|
|
||||||
|
|
||||||
while (stack.isNotEmpty() && results.size < max) {
|
// 优先队列:按 maxFreq 降序排列节点
|
||||||
val (node, word) = stack.removeLast()
|
val pq = PriorityQueue<TrieNode>(compareByDescending { it.maxFreq })
|
||||||
|
pq.add(current)
|
||||||
|
|
||||||
|
while (pq.isNotEmpty() && resultSet.size < max) {
|
||||||
|
val node = pq.poll()
|
||||||
|
|
||||||
|
// 如果当前节点是词尾,收集词
|
||||||
if (node.isEndOfWord) {
|
if (node.isEndOfWord) {
|
||||||
results.add(word)
|
for (w in node.terminalWords) {
|
||||||
if (results.size >= max) break
|
resultSet.add(w)
|
||||||
}
|
if (resultSet.size >= max) break
|
||||||
|
|
||||||
for ((char, child) in node.children) {
|
|
||||||
stack.addLast(child to (word + char))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
if (resultSet.size >= max) break
|
||||||
|
|
||||||
|
// 将子节点加入优先队列(高词频的会优先被取出)
|
||||||
|
for ((_, child) in node.children) {
|
||||||
|
pq.add(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.FileInputStream
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.nio.channels.Channels
|
import java.nio.channels.Channels
|
||||||
import java.nio.channels.FileChannel
|
import java.nio.channels.FileChannel
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
data class BigramModel(
|
/**
|
||||||
val vocab: List<String>, // 保留全部词(含 <unk>, <s>, </s>),与二元矩阵索引对齐
|
* N-gram 语言模型数据结构
|
||||||
val uniLogp: FloatArray, // 长度 = vocab.size
|
*
|
||||||
val biRowptr: IntArray, // 长度 = vocab.size + 1 (CSR)
|
* 文件格式:
|
||||||
val biCols: IntArray, // 长度 = nnz
|
* - vocab.txt: 词表(每行一个词,行号=词ID,按频率降序)
|
||||||
val biLogp: FloatArray // 长度 = nnz
|
* - 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 {
|
object LanguageModelLoader {
|
||||||
fun load(context: Context): BigramModel {
|
|
||||||
|
fun load(context: Context): NgramModel {
|
||||||
val vocab = context.assets.open("vocab.txt").bufferedReader()
|
val vocab = context.assets.open("vocab.txt").bufferedReader()
|
||||||
.use(BufferedReader::readLines)
|
.use(BufferedReader::readLines)
|
||||||
|
|
||||||
val uniLogp = readFloat32(context, "uni_logp.bin")
|
val unigramScores = loadBuffer(context, "uni_logp.bin")
|
||||||
val biRowptr = readInt32(context, "bi_rowptr.bin")
|
val biRowptr = loadBuffer(context, "bi_rowptr.bin")
|
||||||
val biCols = readInt32(context, "bi_cols.bin")
|
val biData = loadBuffer(context, "bi_data.bin")
|
||||||
val biLogp = readFloat32(context, "bi_logp.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(unigramScores.capacity() == vocab.size * 2) {
|
||||||
require(biCols.size == biLogp.size) { "bi cols/logp nnz mismatch" }
|
"uni_logp.bin 大小不匹配: 期望 ${vocab.size * 2}, 实际 ${unigramScores.capacity()}"
|
||||||
|
}
|
||||||
return BigramModel(vocab, uniLogp, biRowptr, biCols, biLogp)
|
require(biRowptr.capacity() == vocab.size * 6) {
|
||||||
|
"bi_rowptr.bin 大小不匹配: 期望 ${vocab.size * 6}, 实际 ${biRowptr.capacity()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readInt32(context: Context, name: String): IntArray {
|
return NgramModel(
|
||||||
|
vocab = vocab,
|
||||||
|
unigramScores = unigramScores,
|
||||||
|
biRowptr = biRowptr,
|
||||||
|
biData = biData,
|
||||||
|
triCtx = triCtx,
|
||||||
|
triRowptr = triRowptr,
|
||||||
|
triData = triData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBuffer(context: Context, name: String): ByteBuffer {
|
||||||
try {
|
try {
|
||||||
context.assets.openFd(name).use { afd ->
|
context.assets.openFd(name).use { afd ->
|
||||||
FileInputStream(afd.fileDescriptor).channel.use { channel ->
|
FileInputStream(afd.fileDescriptor).channel.use { channel ->
|
||||||
return readInt32Channel(channel, afd.startOffset, afd.length)
|
return mapChannel(channel, afd.startOffset, afd.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: FileNotFoundException) {
|
} catch (e: FileNotFoundException) {
|
||||||
// Compressed assets do not support openFd; fall back to streaming.
|
// 压缩的 asset 不支持 openFd,回退到流式读取
|
||||||
}
|
}
|
||||||
|
|
||||||
context.assets.open(name).use { input ->
|
context.assets.open(name).use { input ->
|
||||||
return readInt32Stream(input)
|
return readStream(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readFloat32(context: Context, name: String): FloatArray {
|
private fun mapChannel(channel: FileChannel, offset: Long, length: Long): ByteBuffer {
|
||||||
try {
|
require(length <= Int.MAX_VALUE.toLong()) { "文件过大: $length" }
|
||||||
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()
|
|
||||||
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, offset, length)
|
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, offset, length)
|
||||||
mapped.order(ByteOrder.LITTLE_ENDIAN)
|
mapped.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
val out = IntArray(count)
|
return mapped
|
||||||
mapped.asIntBuffer().get(out)
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readFloat32Channel(channel: FileChannel, offset: Long, length: Long): FloatArray {
|
private fun readStream(input: InputStream): ByteBuffer {
|
||||||
require(length % 4L == 0L) { "float32 length invalid: $length" }
|
val bytes = input.readBytes()
|
||||||
require(length <= Int.MAX_VALUE.toLong()) { "float32 asset too large: $length" }
|
val buffer = ByteBuffer.allocateDirect(bytes.size)
|
||||||
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)
|
|
||||||
buffer.order(ByteOrder.LITTLE_ENDIAN)
|
buffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
Channels.newChannel(input).use { channel ->
|
buffer.put(bytes)
|
||||||
while (true) {
|
|
||||||
val read = channel.read(buffer)
|
|
||||||
if (read == -1) break
|
|
||||||
if (read == 0) continue
|
|
||||||
buffer.flip()
|
buffer.flip()
|
||||||
while (buffer.remaining() >= 4) {
|
return buffer
|
||||||
if (count == out.size) out = out.copyOf(out.size * 2)
|
|
||||||
out[count++] = buffer.getInt()
|
|
||||||
}
|
|
||||||
buffer.compact()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.content.Context
|
|||||||
import com.example.myapplication.Trie
|
import com.example.myapplication.Trie
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class WordDictionary(private val context: Context) {
|
class WordDictionary(private val context: Context) {
|
||||||
@@ -18,15 +20,21 @@ class WordDictionary(private val context: Context) {
|
|||||||
fun loadIfNeeded() {
|
fun loadIfNeeded() {
|
||||||
if (!loaded.compareAndSet(false, true)) return
|
if (!loaded.compareAndSet(false, true)) return
|
||||||
try {
|
try {
|
||||||
|
// 加载词频分数
|
||||||
|
val freqScores = loadFrequencyScores()
|
||||||
|
|
||||||
context.assets.open("vocab.txt").use { input ->
|
context.assets.open("vocab.txt").use { input ->
|
||||||
BufferedReader(InputStreamReader(input)).useLines { lines ->
|
BufferedReader(InputStreamReader(input)).useLines { lines ->
|
||||||
var idx = 0
|
var idx = 0
|
||||||
lines.forEach { line ->
|
lines.forEach { line ->
|
||||||
|
val currentIdx = idx
|
||||||
idx++
|
idx++
|
||||||
if (idx <= 3) return@forEach // 跳过 <unk>, <s>, </s>
|
if (currentIdx < 3) return@forEach // 跳过 <unk>, <s>, </s>
|
||||||
val w = line.trim()
|
val w = line.trim()
|
||||||
if (w.isNotEmpty()) {
|
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?) {
|
protected fun applyBorderToAllKeyViews(root: View?) {
|
||||||
if (root == null) return
|
if (root == null) return
|
||||||
|
|
||||||
val keyMarginPx = 2.dpToPx()
|
val keyMarginPx = env.ctx.resources.getDimensionPixelSize(com.example.myapplication.R.dimen.sw_3dp)
|
||||||
val keyPaddingH = 8.dpToPx()
|
val keyPaddingH = env.ctx.resources.getDimensionPixelSize(com.example.myapplication.R.dimen.sw_8dp)
|
||||||
|
|
||||||
// 忽略 suggestion_0..20(联想栏)
|
// 忽略 suggestion_0..20(联想栏)
|
||||||
val ignoredIds = HashSet<Int>().apply {
|
val ignoredIds = HashSet<Int>().apply {
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ class MainKeyboard(
|
|||||||
others.forEach { applyKeyBackground(rootView, it) }
|
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) {
|
private fun setupListenersForMain(view: View) {
|
||||||
val res = env.ctx.resources
|
val res = env.ctx.resources
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import retrofit2.http.*
|
|||||||
|
|
||||||
interface ApiService {
|
interface ApiService {
|
||||||
|
|
||||||
// GET 示例:/users/{id}
|
// GET 示例<EFBFBD>?users/{id}
|
||||||
// @GET("users/{id}")
|
// @GET("users/{id}")
|
||||||
// suspend fun getUser(
|
// suspend fun getUser(
|
||||||
// @Path("id") id: String
|
// @Path("id") id: String
|
||||||
@@ -32,7 +32,7 @@ interface ApiService {
|
|||||||
suspend fun logout(
|
suspend fun logout(
|
||||||
): ApiResponse<Boolean>
|
): ApiResponse<Boolean>
|
||||||
|
|
||||||
//发送验证嘛
|
//发送验证邮件
|
||||||
@POST("user/sendVerifyMail")
|
@POST("user/sendVerifyMail")
|
||||||
suspend fun sendVerifyCode(
|
suspend fun sendVerifyCode(
|
||||||
@Body body: SendVerifyCodeRequest
|
@Body body: SendVerifyCodeRequest
|
||||||
@@ -44,7 +44,7 @@ interface ApiService {
|
|||||||
@Body body: RegisterRequest
|
@Body body: RegisterRequest
|
||||||
): ApiResponse<Boolean>
|
): ApiResponse<Boolean>
|
||||||
|
|
||||||
//验证验证码
|
//验证验证邮件
|
||||||
@POST("user/verifyMailCode")
|
@POST("user/verifyMailCode")
|
||||||
suspend fun verifyCode(
|
suspend fun verifyCode(
|
||||||
@Body body: VerifyCodeRequest
|
@Body body: VerifyCodeRequest
|
||||||
@@ -199,8 +199,98 @@ interface ApiService {
|
|||||||
suspend fun restoreTheme(
|
suspend fun restoreTheme(
|
||||||
@Query("themeId") themeId: Int
|
@Query("themeId") themeId: Int
|
||||||
): ApiResponse<Unit>
|
): 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
|
@Streaming
|
||||||
@GET("files/{fileName}")
|
@GET("files/{fileName}")
|
||||||
suspend fun downloadZip(
|
suspend fun downloadZip(
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ sealed class AuthEvent {
|
|||||||
val bundle: Bundle? = null,
|
val bundle: Bundle? = null,
|
||||||
val clearGlobalBackStack: Boolean = false
|
val clearGlobalBackStack: Boolean = false
|
||||||
) : AuthEvent()
|
) : AuthEvent()
|
||||||
|
data class OpenCirclePage(
|
||||||
|
val destinationId: Int,
|
||||||
|
val bundle: Bundle? = null
|
||||||
|
) : AuthEvent()
|
||||||
object UserUpdated : AuthEvent()
|
object UserUpdated : AuthEvent()
|
||||||
data class CharacterDeleted(val characterId: Int) : AuthEvent()
|
data class CharacterDeleted(val characterId: Int) : AuthEvent()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ object BehaviorHttpClient {
|
|||||||
private const val TAG = "BehaviorHttp"
|
private const val TAG = "BehaviorHttp"
|
||||||
|
|
||||||
// TODO:改成你的行为服务 baseUrl(必须以 / 结尾)
|
// 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
|
package com.example.myapplication.network
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -119,11 +119,22 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain ->
|
|||||||
|
|
||||||
val requestBody = request.body
|
val requestBody = request.body
|
||||||
if (requestBody != null) {
|
if (requestBody != null) {
|
||||||
|
if (shouldLogRequestBody(requestBody)) {
|
||||||
val buffer = Buffer()
|
val buffer = Buffer()
|
||||||
requestBody.writeTo(buffer)
|
requestBody.writeTo(buffer)
|
||||||
|
val charset = requestBody.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
|
||||||
sb.append("Body:\n")
|
sb.append("Body:\n")
|
||||||
sb.append(buffer.readUtf8())
|
sb.append(buffer.readString(charset))
|
||||||
sb.append("\n")
|
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")
|
sb.append("================================\n")
|
||||||
@@ -132,6 +143,22 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain ->
|
|||||||
chain.proceed(request)
|
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
|
val token: String
|
||||||
)
|
)
|
||||||
|
|
||||||
//验证码发送邮箱
|
//验证码发送邮件请求
|
||||||
data class SendVerifyCodeRequest(
|
data class SendVerifyCodeRequest(
|
||||||
val mailAddress: String
|
val mailAddress: String
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ data class RegisterRequest(
|
|||||||
val inviteCode: String?
|
val inviteCode: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
//验证验证码
|
//验证验证邮件请求
|
||||||
data class VerifyCodeRequest(
|
data class VerifyCodeRequest(
|
||||||
val mailAddress: String,
|
val mailAddress: String,
|
||||||
val verifyCode: String,
|
val verifyCode: String,
|
||||||
@@ -249,3 +249,225 @@ data class deleteThemeRequest(
|
|||||||
data class purchaseThemeRequest(
|
data class purchaseThemeRequest(
|
||||||
val themeId: Int,
|
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 {
|
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"
|
private const val TAG = "999-SSE_TALK"
|
||||||
|
|
||||||
// ====== 按你给的规则固定值 ======
|
// ====== 按你给的规则固定值 ======
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.example.myapplication.network.FileUploadService
|
|||||||
|
|
||||||
object RetrofitClient {
|
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
|
// 保存 ApplicationContext
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -21,16 +21,17 @@ object RetrofitClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
level = HttpLoggingInterceptor.Level.HEADERS
|
||||||
}
|
}
|
||||||
|
|
||||||
private val okHttpClient: OkHttpClient by lazy {
|
private val okHttpClient: OkHttpClient by lazy {
|
||||||
check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." }
|
check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." }
|
||||||
|
|
||||||
|
// 创建 OkHttpClient.Builder 实例并设置连接、读取和写入超时时间
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS) // 设置连接超时时间为 15 秒
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS) // 设置读取超时时间为 30 秒
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS) // 设置写入超时时间为 30 秒
|
||||||
|
|
||||||
// 顺序:请求拦截 -> logging -> 响应拦截
|
// 顺序:请求拦截 -> logging -> 响应拦截
|
||||||
.addInterceptor(requestInterceptor(appContext))
|
.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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
<solid android:color="#F3F3F3"/>
|
<solid android:color="#F3F3F3"/>
|
||||||
<corners android:radius="4dp"/>
|
<corners android:radius="@dimen/sw_4dp"/>
|
||||||
</shape>
|
</shape>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
<solid android:color="#f5f5f5"/>
|
<solid android:color="#f5f5f5"/>
|
||||||
<corners android:radius="5dp"/>
|
<corners android:radius="@dimen/sw_5dp"/>
|
||||||
</shape>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#E53935"/>
|
<solid android:color="#E53935"/>
|
||||||
<corners android:radius="18dp"/>
|
<corners android:radius="@dimen/sw_18dp"/>
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
<solid android:color="#02BEAC" />
|
<solid android:color="#02BEAC" />
|
||||||
<corners android:radius="10dp" />
|
<corners android:radius="@dimen/sw_10dp" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
<solid android:color="@android:color/white" />
|
<solid android:color="@android:color/white" />
|
||||||
<corners android:radius="10dp" />
|
<corners android:radius="@dimen/sw_10dp" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="@android:color/white"/>
|
<solid android:color="@android:color/white"/>
|
||||||
<corners android:radius="16dp"/>
|
<corners android:radius="@dimen/sw_16dp"/>
|
||||||
</shape>
|
</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">
|
<item android:state_selected="true">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="14dp" />
|
<corners android:radius="@dimen/sw_14dp" />
|
||||||
<solid android:color="#1A000000" />
|
<solid android:color="#1A000000" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<!-- 未选中 -->
|
<!-- 未选中 -->
|
||||||
<item>
|
<item>
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="14dp" />
|
<corners android:radius="@dimen/sw_14dp" />
|
||||||
<solid android:color="@android:color/transparent" />
|
<solid android:color="@android:color/transparent" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 选中 -->
|
<!-- 选中 -->
|
||||||
<item android:state_selected="true">
|
<item android:state_selected="true">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="12dp" />
|
<corners android:radius="@dimen/sw_12dp" />
|
||||||
<solid android:color="#1A2563EB" />
|
<solid android:color="#1A2563EB" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<!-- 未选中 -->
|
<!-- 未选中 -->
|
||||||
<item>
|
<item>
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="12dp" />
|
<corners android:radius="@dimen/sw_12dp" />
|
||||||
<solid android:color="#0F000000" />
|
<solid android:color="#0F000000" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
<corners android:radius="2dp"/>
|
<corners android:radius="@dimen/sw_2dp"/>
|
||||||
<solid android:color="#33000000"/>
|
<solid android:color="#33000000"/>
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<!-- 按下状态 -->
|
<!-- 按下状态 -->
|
||||||
<item android:state_pressed="true">
|
<item android:state_pressed="true">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="4dp"/>
|
<corners android:radius="@dimen/sw_4dp"/>
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
|
|
||||||
<!-- 默认状态 -->
|
<!-- 默认状态 -->
|
||||||
<item>
|
<item>
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="4dp"/>
|
<corners android:radius="@dimen/sw_4dp"/>
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
</selector>
|
</selector>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<item android:state_activated="true">
|
<item android:state_activated="true">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<solid android:color="#4CAF50"/>
|
<solid android:color="#4CAF50"/>
|
||||||
<corners android:radius="4dp"/>
|
<corners android:radius="@dimen/sw_4dp"/>
|
||||||
<stroke android:width="1dp" android:color="#388E3C"/>
|
<stroke android:width="@dimen/sw_1dp" android:color="#388E3C"/>
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<item android:state_pressed="true">
|
<item android:state_pressed="true">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<solid android:color="#9E9E9E"/>
|
<solid android:color="#9E9E9E"/>
|
||||||
<corners android:radius="4dp"/>
|
<corners android:radius="@dimen/sw_4dp"/>
|
||||||
<stroke android:width="1dp" android:color="#757575"/>
|
<stroke android:width="@dimen/sw_1dp" android:color="#757575"/>
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
<item>
|
<item>
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<solid android:color="#E0E0E0"/>
|
<solid android:color="#E0E0E0"/>
|
||||||
<corners android:radius="4dp"/>
|
<corners android:radius="@dimen/sw_4dp"/>
|
||||||
<stroke android:width="1dp" android:color="#BDBDBD"/>
|
<stroke android:width="@dimen/sw_1dp" android:color="#BDBDBD"/>
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
</selector>
|
</selector>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#F1F1F1" />
|
<solid android:color="#F1F1F1" />
|
||||||
<corners android:radius="24dp" />
|
<corners android:radius="@dimen/sw_24dp" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#02BEAC" />
|
<solid android:color="#02BEAC" />
|
||||||
<corners android:radius="24dp" />
|
<corners android:radius="@dimen/sw_24dp" />
|
||||||
</shape>
|
</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">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#F4F8FB"/>
|
<solid android:color="#F4F8FB"/>
|
||||||
<corners android:radius="6dp"/>
|
<corners android:radius="@dimen/sw_6dp"/>
|
||||||
</shape>
|
</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
|
<corners
|
||||||
android:radius="4dp" />
|
android:radius="@dimen/sw_4dp" />
|
||||||
|
|
||||||
<!-- 白色半透明描边,增加玻璃边缘感 -->
|
<!-- 白色半透明描边,增加玻璃边缘感 -->
|
||||||
<stroke
|
<stroke
|
||||||
android:width="1dp"
|
android:width="@dimen/sw_1dp"
|
||||||
android:color="#66FFFFFF" />
|
android:color="#66FFFFFF" />
|
||||||
</shape>
|
</shape>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<solid android:color="#FFFFFF" /> <!-- 40% 透明白 -->
|
<solid android:color="#FFFFFF" /> <!-- 40% 透明白 -->
|
||||||
<corners
|
<corners
|
||||||
android:topLeftRadius="0dp"
|
android:topLeftRadius="0dp"
|
||||||
android:topRightRadius="4dp"
|
android:topRightRadius="@dimen/sw_4dp"
|
||||||
android:bottomLeftRadius="0dp"
|
android:bottomLeftRadius="0dp"
|
||||||
android:bottomRightRadius="4dp" />
|
android:bottomRightRadius="@dimen/sw_4dp" />
|
||||||
</shape>
|
</shape>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<!-- 底部边框:固定在底部,只画一条线 -->
|
<!-- 底部边框:固定在底部,只画一条线 -->
|
||||||
<item android:gravity="bottom">
|
<item android:gravity="bottom">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<size android:height="1dp" />
|
<size android:height="@dimen/sw_1dp" />
|
||||||
<solid android:color="#F3F3F3" />
|
<solid android:color="#F3F3F3" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</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 |