ai角色聊天板块
@@ -51,7 +51,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="stateHidden|adjustNothing">
|
||||
</activity>
|
||||
|
||||
<!-- 输入法服务 -->
|
||||
|
||||
@@ -159,8 +159,14 @@ class GuideActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// 键盘发送
|
||||
inputMessage.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||
inputMessage.setOnEditorActionListener { _, actionId, event ->
|
||||
if (event != null) {
|
||||
return@setOnEditorActionListener false
|
||||
}
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND ||
|
||||
actionId == EditorInfo.IME_ACTION_DONE ||
|
||||
actionId == EditorInfo.IME_ACTION_UNSPECIFIED
|
||||
) {
|
||||
// 走同一套发送逻辑
|
||||
sendMessage()
|
||||
true
|
||||
@@ -168,6 +174,14 @@ class GuideActivity : AppCompatActivity() {
|
||||
false
|
||||
}
|
||||
}
|
||||
inputMessage.setOnKeyListener { _, keyCode, event ->
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
|
||||
sendMessage()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import eightbitlab.com.blurview.BlurView
|
||||
import eightbitlab.com.blurview.RenderEffectBlur
|
||||
import eightbitlab.com.blurview.RenderScriptBlur
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
@@ -476,13 +474,10 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun applyCircleTabBackground() {
|
||||
bottomNav.itemBackground = null
|
||||
if (blurReady) {
|
||||
bottomNavBlur.visibility = View.VISIBLE
|
||||
bottomNav.background = ColorDrawable(android.graphics.Color.TRANSPARENT)
|
||||
} else {
|
||||
bottomNavBlur.visibility = View.GONE
|
||||
bottomNav.background = ColorDrawable(ContextCompat.getColor(this, R.color.black_30_percent))
|
||||
}
|
||||
bottomNav.backgroundTintList = null
|
||||
// Circle 页底栏保持完全透明
|
||||
bottomNavBlur.visibility = View.GONE
|
||||
bottomNav.background = ColorDrawable(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
private fun resetBottomNavBackground() {
|
||||
@@ -492,35 +487,9 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun setupBottomNavBlur() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
blurReady = false
|
||||
bottomNavBlur.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
|
||||
val rootView = findViewById<ViewGroup>(android.R.id.content)?.getChildAt(0) as? ViewGroup
|
||||
?: run { blurReady = false; bottomNavBlur.visibility = View.GONE; return }
|
||||
|
||||
// Lighter blur for higher transparency
|
||||
val blurRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 8f else 6f
|
||||
try {
|
||||
val algorithm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
RenderEffectBlur()
|
||||
} else {
|
||||
RenderScriptBlur(this)
|
||||
}
|
||||
|
||||
bottomNavBlur.setupWith(rootView, algorithm)
|
||||
.setFrameClearDrawable(window.decorView.background)
|
||||
.setBlurRadius(blurRadius)
|
||||
.setBlurAutoUpdate(true)
|
||||
.setOverlayColor(ContextCompat.getColor(this, R.color.frosted_glass_bg))
|
||||
|
||||
blurReady = true
|
||||
} catch (e: Exception) {
|
||||
blurReady = false
|
||||
bottomNavBlur.visibility = View.GONE
|
||||
}
|
||||
// 全局移除底栏毛玻璃效果
|
||||
blurReady = false
|
||||
bottomNavBlur.visibility = View.GONE
|
||||
}
|
||||
|
||||
/** 打开全局页(login/recharge等) */
|
||||
|
||||
@@ -70,7 +70,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
private var currentInput = StringBuilder() // 当前输入前缀
|
||||
private var completionSuggestions = emptyList<String>() // 自动完成建议
|
||||
private val suggestionViews = mutableListOf<TextView>() // 缓存动态创建的候选视图
|
||||
private var suggestionSlotCount: Int = 21 // 包含前缀位,调这里可修改渲染数量
|
||||
private var suggestionSlotCount: Int = 8 // 包含前缀位,调这里可修改渲染数量
|
||||
private val completionCapacity: Int
|
||||
get() = (suggestionSlotCount - 1).coerceAtLeast(0)
|
||||
|
||||
@@ -124,6 +124,12 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
// Shift 状态
|
||||
private var isShiftOn = false
|
||||
|
||||
private fun setShiftState(on: Boolean) {
|
||||
if (isShiftOn == on) return
|
||||
isShiftOn = on
|
||||
mainKeyboard?.setShiftState(on)
|
||||
}
|
||||
|
||||
// 删除长按
|
||||
private var isDeleting = false
|
||||
private val repeatDelInitialDelay = 350L
|
||||
@@ -371,6 +377,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
// 删除键长按连删
|
||||
val delId = resources.getIdentifier("key_del", "id", packageName)
|
||||
mainKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
|
||||
|
||||
// 同步当前 Shift 状态到主键盘 UI
|
||||
mainKeyboard?.setShiftState(isShiftOn)
|
||||
}
|
||||
return mainKeyboard!!
|
||||
}
|
||||
@@ -388,7 +397,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
val full = et.text?.toString().orEmpty()
|
||||
if (full.isEmpty()) {
|
||||
// 已经空了就不做
|
||||
clearEditorState()
|
||||
clearEditorState(resetShift = false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -406,7 +415,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
ic.endBatchEdit()
|
||||
}
|
||||
|
||||
clearEditorState()
|
||||
clearEditorState(resetShift = false)
|
||||
|
||||
// 清空后立即更新所有键盘的按钮可见性
|
||||
mainHandler.post {
|
||||
@@ -803,7 +812,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
val editorReallyEmpty = before1.isEmpty() && after1.isEmpty()
|
||||
|
||||
if (editorReallyEmpty) {
|
||||
clearEditorState()
|
||||
clearEditorState(resetShift = false)
|
||||
} else {
|
||||
// prefix 也不要取太长
|
||||
val prefix = getCurrentWordPrefix(maxLen = 64)
|
||||
@@ -824,16 +833,28 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
val info = currentInputEditorInfo
|
||||
|
||||
var handled = false
|
||||
var actionId = EditorInfo.IME_ACTION_UNSPECIFIED
|
||||
var imeOptions = 0
|
||||
|
||||
if (info != null) {
|
||||
// 取出当前 EditText 声明的 action
|
||||
val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION
|
||||
imeOptions = info.imeOptions
|
||||
actionId = imeOptions and EditorInfo.IME_MASK_ACTION
|
||||
|
||||
// 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||
handled = ic.performEditorAction(actionId)
|
||||
// 兼容 SEND / DONE / GO / NEXT / PREVIOUS / SEARCH / NONE / UNSPECIFIED
|
||||
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,
|
||||
EditorInfo.IME_ACTION_NONE -> false
|
||||
EditorInfo.IME_ACTION_UNSPECIFIED -> false
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
Log.d("1314520-IME", "performSendAction actionId=$actionId imeOptions=$imeOptions handled=$handled")
|
||||
|
||||
// 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false
|
||||
// 就降级为“标准回车”
|
||||
@@ -864,7 +885,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
override fun getCurrentWordPrefix(maxLen: Int): String {
|
||||
val before = currentInputConnection?.getTextBeforeCursor(maxLen, 0)?.toString() ?: ""
|
||||
val match = Regex("[A-Za-z]+$").find(before)
|
||||
return (match?.value ?: "").lowercase()
|
||||
return (match?.value ?: "")
|
||||
}
|
||||
|
||||
// 统一处理补全/联想
|
||||
@@ -875,11 +896,14 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
val afterAll = ic?.getTextAfterCursor(256, 0)?.toString().orEmpty()
|
||||
val editorReallyEmpty = beforeAll.isEmpty() && afterAll.isEmpty()
|
||||
|
||||
val rawPrefix = prefix
|
||||
val lookupPrefix = prefix
|
||||
|
||||
currentInput.clear()
|
||||
currentInput.append(prefix)
|
||||
currentInput.append(rawPrefix)
|
||||
|
||||
if (editorReallyEmpty) {
|
||||
clearEditorState()
|
||||
clearEditorState(resetShift = false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -892,25 +916,25 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
emptyList()
|
||||
} else {
|
||||
try {
|
||||
if (prefix.isEmpty()) {
|
||||
if (lookupPrefix.isEmpty()) {
|
||||
if (lastWord == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
suggestWithBigram("", lastWord, topK = maxCompletions)
|
||||
}
|
||||
} else {
|
||||
val fromBi = suggestWithBigram(prefix, lastWord, topK = maxCompletions)
|
||||
val fromBi = suggestWithBigram(lookupPrefix, lastWord, topK = maxCompletions)
|
||||
if (fromBi.isNotEmpty()) {
|
||||
fromBi.filter { it != prefix }
|
||||
fromBi.filter { it != lookupPrefix }
|
||||
} else {
|
||||
wordDictionary.wordTrie.startsWith(prefix, maxCompletions)
|
||||
.filter { it != prefix }
|
||||
wordDictionary.wordTrie.startsWith(lookupPrefix, maxCompletions)
|
||||
.filter { it != lookupPrefix }
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
if (prefix.isNotEmpty()) {
|
||||
wordDictionary.wordTrie.startsWith(prefix, maxCompletions)
|
||||
.filterNot { it == prefix }
|
||||
if (lookupPrefix.isNotEmpty()) {
|
||||
wordDictionary.wordTrie.startsWith(lookupPrefix, maxCompletions)
|
||||
.filterNot { it == lookupPrefix }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1327,7 +1351,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
val pf = prefix.lowercase()
|
||||
val pf = prefix
|
||||
val last = lastWord?.lowercase()
|
||||
val lastId = last?.let { word2id[it] }
|
||||
|
||||
@@ -1439,20 +1463,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
|
||||
// 当编辑框光标前后都没有任何字符,说明真的完全空了
|
||||
if (before.isEmpty() && after.isEmpty()) {
|
||||
clearEditorState()
|
||||
clearEditorState(resetShift = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 清理本次编辑框相关的状态(光标、联想、长按等)
|
||||
private fun clearEditorState() {
|
||||
private fun clearEditorState(resetShift: Boolean = true) {
|
||||
// 1. 文本联想/补全相关
|
||||
currentInput.clear()
|
||||
completionSuggestions = emptyList()
|
||||
lastWordForLM = null
|
||||
|
||||
// 2. Shift 状态
|
||||
isShiftOn = false
|
||||
if (resetShift) {
|
||||
setShiftState(false)
|
||||
}
|
||||
|
||||
// 3. 停止长按删除
|
||||
stopRepeatDelete()
|
||||
|
||||
@@ -6,7 +6,7 @@ class Trie {
|
||||
//表示Trie数据结构中的一个节点,该节点可以存储其子节点,并且可以标记是否是一个完整单词的结尾
|
||||
private data class TrieNode(
|
||||
val children: MutableMap<Char, TrieNode> = mutableMapOf(),
|
||||
|
||||
val terminalWords: LinkedHashSet<String> = linkedSetOf(),
|
||||
var isEndOfWord: Boolean = false
|
||||
)
|
||||
|
||||
@@ -16,18 +16,19 @@ class Trie {
|
||||
fun insert(word: String) {
|
||||
var current = root
|
||||
|
||||
for (char in word.lowercase()) {
|
||||
for (char in word) {
|
||||
current = current.children.getOrPut(char) { TrieNode() }
|
||||
}
|
||||
|
||||
current.isEndOfWord = true
|
||||
current.terminalWords.add(word)
|
||||
}
|
||||
|
||||
//在Trie数据结构中查找指定的单词是否存在。
|
||||
fun search(word: String): Boolean {
|
||||
var current = root
|
||||
|
||||
for (char in word.lowercase()) {
|
||||
for (char in word) {
|
||||
current = current.children[char] ?: return false
|
||||
}
|
||||
|
||||
@@ -42,8 +43,7 @@ class Trie {
|
||||
fun startsWith(prefix: String, limit: Int): List<String> {
|
||||
var current = root
|
||||
|
||||
val normalized = prefix.lowercase()
|
||||
for (char in normalized) {
|
||||
for (char in prefix) {
|
||||
current = current.children[char] ?: return emptyList()
|
||||
}
|
||||
|
||||
@@ -51,18 +51,22 @@ class Trie {
|
||||
if (max == 0) return emptyList()
|
||||
|
||||
val results = ArrayList<String>(minOf(max, 16))
|
||||
val stack = ArrayDeque<Pair<TrieNode, String>>()
|
||||
stack.addLast(current to prefix)
|
||||
val stack = ArrayDeque<TrieNode>()
|
||||
stack.addLast(current)
|
||||
|
||||
while (stack.isNotEmpty() && results.size < max) {
|
||||
val (node, word) = stack.removeLast()
|
||||
val node = stack.removeLast()
|
||||
if (node.isEndOfWord) {
|
||||
results.add(word)
|
||||
if (results.size >= max) break
|
||||
for (w in node.terminalWords) {
|
||||
results.add(w)
|
||||
if (results.size >= max) break
|
||||
}
|
||||
}
|
||||
|
||||
for ((char, child) in node.children) {
|
||||
stack.addLast(child to (word + char))
|
||||
if (results.size >= max) break
|
||||
|
||||
for ((_, child) in node.children) {
|
||||
stack.addLast(child)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@ class MainKeyboard(
|
||||
others.forEach { applyKeyBackground(rootView, it) }
|
||||
}
|
||||
|
||||
fun setShiftState(on: Boolean) {
|
||||
if (isShiftOn == on) return
|
||||
isShiftOn = on
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
rootView.findViewById<View?>(res.getIdentifier("key_up", "id", pkg))?.isActivated = on
|
||||
applyKeyBackgroundsForTheme()
|
||||
}
|
||||
|
||||
// -------------------- 事件绑定 --------------------
|
||||
private fun setupListenersForMain(view: View) {
|
||||
val res = env.ctx.resources
|
||||
|
||||
@@ -199,6 +199,32 @@ interface ApiService {
|
||||
suspend fun restoreTheme(
|
||||
@Query("themeId") themeId: Int
|
||||
): ApiResponse<Unit>
|
||||
// =========================================圈子(ai陪聊)=============================================
|
||||
// 分页查询AI陪聊角色
|
||||
@POST("ai-companion/page")
|
||||
suspend fun aiCompanionPage(
|
||||
@Body body: aiCompanionPageRequest
|
||||
): ApiResponse<AiCompanionPageResponse>
|
||||
|
||||
// 分页查询聊天记录
|
||||
@POST("chat/history")
|
||||
suspend fun chatHistory(
|
||||
@Body body: chatHistoryRequest
|
||||
): ApiResponse<ChatHistoryResponse>
|
||||
|
||||
//点赞/取消点赞AI角色
|
||||
@POST("ai-companion/like")
|
||||
suspend fun aiCompanionLike(
|
||||
@Body body: aiCompanionLikeRequest
|
||||
): ApiResponse<Boolean>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// =========================================文件=============================================
|
||||
// zip 文件下载(或其它大文件)——必须 @Streaming
|
||||
@Streaming
|
||||
|
||||
@@ -12,7 +12,7 @@ object BehaviorHttpClient {
|
||||
private const val TAG = "BehaviorHttp"
|
||||
|
||||
// TODO:改成你的行为服务 baseUrl(必须以 / 结尾)
|
||||
private const val BASE_URL = "http://192.168.2.21:35310/api/"
|
||||
private const val BASE_URL = "http://192.168.2.22:35310/api/"
|
||||
|
||||
/**
|
||||
* 请求拦截器:打印请求信息
|
||||
|
||||
@@ -249,3 +249,69 @@ data class deleteThemeRequest(
|
||||
data class purchaseThemeRequest(
|
||||
val themeId: Int,
|
||||
)
|
||||
|
||||
// =========================================圈子(ai陪聊)=============================================
|
||||
|
||||
//分页查询AI陪聊角色
|
||||
data class aiCompanionPageRequest(
|
||||
val pageNum: Int,
|
||||
val pageSize: Int,
|
||||
)
|
||||
|
||||
data class AiCompanionPageResponse(
|
||||
val records: List<AiCompanion>,
|
||||
val total: Int,
|
||||
val size: Int,
|
||||
val current: Int,
|
||||
val pages: Int
|
||||
)
|
||||
|
||||
data class AiCompanion(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val avatarUrl: String,
|
||||
val coverImageUrl: String,
|
||||
val gender: String,
|
||||
val ageRange: String,
|
||||
val shortDesc: String,
|
||||
val introText: String,
|
||||
val personalityTags: String?,
|
||||
val speakingStyle: String,
|
||||
val sortOrder: Int,
|
||||
val popularityScore: Int,
|
||||
val prologue: String?,
|
||||
val prologueAudio: String?,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val liked: Boolean,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
//点赞/取消点赞AI角色
|
||||
data class aiCompanionLikeRequest(
|
||||
val companionId: Int,
|
||||
)
|
||||
|
||||
|
||||
//分页查询聊天记录
|
||||
data class chatHistoryRequest(
|
||||
val companionId: Int,
|
||||
val pageNum: Int,
|
||||
val pageSize: Int,
|
||||
)
|
||||
|
||||
data class ChatHistoryResponse(
|
||||
val records: List<ChatRecord>,
|
||||
val total: Int,
|
||||
val size: Int,
|
||||
val current: Int,
|
||||
val pages: Int
|
||||
)
|
||||
|
||||
data class ChatRecord(
|
||||
val id: Int,
|
||||
val sender: Int,
|
||||
val content: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
|
||||
|
||||
object NetworkClient {
|
||||
|
||||
private const val BASE_URL = "http://192.168.2.21:7529/api"
|
||||
private const val BASE_URL = "http://192.168.2.22:7529/api"
|
||||
private const val TAG = "999-SSE_TALK"
|
||||
|
||||
// ====== 按你给的规则固定值 ======
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.example.myapplication.network.FileUploadService
|
||||
|
||||
object RetrofitClient {
|
||||
|
||||
private const val BASE_URL = "http://192.168.2.21:7529/api/"
|
||||
private const val BASE_URL = "http://192.168.2.22:7529/api/"
|
||||
|
||||
// 保存 ApplicationContext
|
||||
@Volatile
|
||||
@@ -27,10 +27,11 @@ object RetrofitClient {
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." }
|
||||
|
||||
// 创建 OkHttpClient.Builder 实例并设置连接、读取和写入超时时间
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.connectTimeout(15, TimeUnit.SECONDS) // 设置连接超时时间为 15 秒
|
||||
.readTimeout(30, TimeUnit.SECONDS) // 设置读取超时时间为 30 秒
|
||||
.writeTimeout(30, TimeUnit.SECONDS) // 设置写入超时时间为 30 秒
|
||||
|
||||
// 顺序:请求拦截 -> logging -> 响应拦截
|
||||
.addInterceptor(requestInterceptor(appContext))
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.myapplication.ui.circle
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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()
|
||||
|
||||
init {
|
||||
// 稳定 ID 有助于 RecyclerView 保持动画一致。
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val messageText: TextView = itemView.findViewById(R.id.messageText)
|
||||
|
||||
fun bind(message: ChatMessage) {
|
||||
messageText.text = message.text
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VIEW_TYPE_ME = 1
|
||||
const val VIEW_TYPE_BOT = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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 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 var boundPageId: 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
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(data: ChatPageData, inputOverlayHeight: Int, bottomInset: Int) {
|
||||
titleView.text = data.personaName
|
||||
likeCountView.text = data.likeCount.toString()
|
||||
commentCountView.text = data.commentCount.toString()
|
||||
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
|
||||
)
|
||||
|
||||
if (boundPageId != data.pageId) {
|
||||
// 只有页面切换时才重绑,避免滚动跳动。
|
||||
boundPageId = data.pageId
|
||||
messageAdapter.bindMessages(data.messages)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyMessageAppended() {
|
||||
messageAdapter.notifyLastInserted()
|
||||
// 追加消息后滚动到底部。
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
chatRv.stopScroll()
|
||||
}
|
||||
|
||||
private fun scrollToBottom() {
|
||||
val lastIndex = messageAdapter.itemCount - 1
|
||||
if (lastIndex >= 0) {
|
||||
chatRv.scrollToPosition(lastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.myapplication.ui.circle
|
||||
|
||||
// 聊天消息模型。
|
||||
data class ChatMessage(
|
||||
val id: Long,
|
||||
val text: String,
|
||||
val isMine: Boolean,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
// 一页 = 一个角色的聊天线程。
|
||||
data class ChatPageData(
|
||||
val pageId: Long,
|
||||
val personaName: String,
|
||||
val messages: MutableList<ChatMessage>,
|
||||
val backgroundColor: String,
|
||||
val avatarUrl: String,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val liked: Boolean
|
||||
)
|
||||
|
||||
// data class ChatPageData(
|
||||
// val pageId: Long,
|
||||
// val companionId: Int?,
|
||||
// val personaName: String,
|
||||
// val avatarUrl: String?,
|
||||
// val coverImageUrl: String?,
|
||||
// val likeCount: Int,
|
||||
// val commentCount: Int,
|
||||
// val liked: Boolean,
|
||||
// val messages: MutableList<ChatMessage>,
|
||||
// val backgroundColor: Int
|
||||
// )
|
||||
@@ -0,0 +1,266 @@
|
||||
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 kotlinx.coroutines.withContext
|
||||
import com.example.myapplication.network.ApiService
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
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 kotlin.collections.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
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 lock = Any()
|
||||
// 记录正在加载的页,避免重复加载。
|
||||
private val inFlight = HashSet<Int>()
|
||||
|
||||
// 后台协程用于预加载。
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val messageId = AtomicLong((totalPages.toLong() + 1) * 1_000_000L)
|
||||
|
||||
// private val personas = listOf(
|
||||
// "Ari", "Bex", "Cato", "Dara", "Eli", "Faye", "Gio", "Hale",
|
||||
// "Ira", "Juno", "Kian", "Luna", "Mira", "Noa", "Oren", "Pia",
|
||||
// "Quin", "Rae", "Sage", "Tala", "Uma", "Vera", "Wren", "Zane"
|
||||
// )
|
||||
private val backgroundColors = listOf(
|
||||
Color.parseColor("#F6F7FB"),
|
||||
Color.parseColor("#F3F7F4"),
|
||||
Color.parseColor("#F7F4FB"),
|
||||
Color.parseColor("#F6F1EC"),
|
||||
Color.parseColor("#F2F5F7"),
|
||||
Color.parseColor("#F7F7F2")
|
||||
)
|
||||
|
||||
private val sampleLines = listOf(
|
||||
"How is your day going?",
|
||||
"Tell me more about that.",
|
||||
"That sounds interesting.",
|
||||
"I am here to help.",
|
||||
"Let us break it down step by step.",
|
||||
"What would you like to do next?",
|
||||
"I can keep track of that for you.",
|
||||
"That makes sense.",
|
||||
"Do you want a quick summary?",
|
||||
"Let us try a different angle.",
|
||||
"We can keep it simple.",
|
||||
"Got it, I will remember that.",
|
||||
"What is the most important part?",
|
||||
"I can draft a reply if you want.",
|
||||
"We can do a quick checklist.",
|
||||
"Would you like another example?"
|
||||
)
|
||||
|
||||
//获取指定位置的聊天页面数据
|
||||
fun getPage(position: Int): ChatPageData {
|
||||
return synchronized(lock) {
|
||||
cache.get(position) ?: createPage(position).also { cache.put(position, it) }
|
||||
}
|
||||
}
|
||||
|
||||
//主要功能是根据用户当前查看的页面位置,预加载其前后若干页的数据,并将这些数据放入缓存中
|
||||
fun preloadAround(position: Int) {
|
||||
val start = max(0, position - preloadCount)//预加载的起始页码
|
||||
val end = min(totalPages - 1, position + preloadCount)//预加载的结束页码
|
||||
for (i in start..end) {
|
||||
if (markInFlightIfNeeded(i)) {
|
||||
// 后台预加载,UI 不阻塞。
|
||||
scope.launch {
|
||||
ensurePage(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 指定位置的聊天页面添加用户消息
|
||||
fun addUserMessage(position: Int, text: String): ChatMessage {
|
||||
val message = ChatMessage(
|
||||
id = messageId.getAndIncrement(),
|
||||
text = text,
|
||||
isMine = true,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
synchronized(lock) {
|
||||
// 消息存放在页面缓存中。
|
||||
getPage(position).messages.add(message)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
//在指定位置的聊天页面中添加一条由机器人发送的消息。
|
||||
fun addBotMessage(position: Int, text: String): ChatMessage {
|
||||
val message = ChatMessage(
|
||||
id = messageId.getAndIncrement(),
|
||||
text = text,
|
||||
isMine = false,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
synchronized(lock) {
|
||||
getPage(position).messages.add(message)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
fun buildBotReply(userText: String): String {
|
||||
val seed = userText.hashCode().toLong()
|
||||
val random = Random(seed)
|
||||
return sampleLines[random.nextInt(sampleLines.size)]
|
||||
}
|
||||
|
||||
fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
|
||||
//主要功能是确保指定位置的聊天页面数据已经存在于缓存中。如果指定位置的页面数据不存在,则生成该页面的数据并将其放入缓存中
|
||||
private fun ensurePage(position: Int) {
|
||||
synchronized(lock) {
|
||||
if (cache.get(position) != null) {
|
||||
inFlight.remove(position)
|
||||
return
|
||||
}
|
||||
}
|
||||
val page = createPage(position)
|
||||
synchronized(lock) {
|
||||
if (cache.get(position) == null) {
|
||||
cache.put(position, page)
|
||||
}
|
||||
inFlight.remove(position)
|
||||
}
|
||||
}
|
||||
|
||||
//检查并记录某个特定聊天页面是否正在被加载
|
||||
private fun markInFlightIfNeeded(position: Int): Boolean {
|
||||
return synchronized(lock) {
|
||||
if (cache.get(position) != null) return@synchronized false
|
||||
if (inFlight.contains(position)) return@synchronized false
|
||||
inFlight.add(position)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// 生成一个虚拟的聊天页面数据,包括聊天对象的名字、消息列表和背景颜色
|
||||
private fun createPage(position: Int): ChatPageData {
|
||||
val personadata = fetchPageDataSync(position+1, 1)
|
||||
val personaName = personadata.data?.records?.get(0)?.name?: ""
|
||||
val backgroundColor = personadata.data?.records?.get(0)?.coverImageUrl?: ""
|
||||
val avatarUrl = personadata.data?.records?.get(0)?.avatarUrl?: ""
|
||||
val likeCount = personadata.data?.records?.get(0)?.likeCount?: 0
|
||||
val commentCount = personadata.data?.records?.get(0)?.commentCount?: 0
|
||||
val liked = personadata.data?.records?.get(0)?.liked?: false
|
||||
val id = personadata.data?.records?.get(0)?.id?: 0
|
||||
|
||||
val messagesdata = fetchChatRecords(id,1,20).data?.records
|
||||
|
||||
|
||||
|
||||
Log.d("1314520-CircleChatRepository", "createPage: $position")
|
||||
val messages = mutableListOf<ChatMessage>()//消息列表
|
||||
|
||||
// 处理真实聊天记录数据
|
||||
if (!messagesdata.isNullOrEmpty()) {
|
||||
// 将数组倒转
|
||||
val reversedMessages = messagesdata.reversed()
|
||||
|
||||
// 解析ISO时间格式
|
||||
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
|
||||
|
||||
for (record in reversedMessages) {
|
||||
val isMine = record.sender == 1 // sender=1表示是我发的消息
|
||||
val timestamp = try {
|
||||
dateFormat.parse(record.createdAt)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
messages.add(
|
||||
ChatMessage(
|
||||
id = record.id.toLong(),
|
||||
text = record.content,
|
||||
isMine = isMine,
|
||||
timestamp = timestamp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 如果没有数据,messages保持为空列表
|
||||
|
||||
return ChatPageData(
|
||||
pageId = position.toLong(),
|
||||
personaName = personaName,
|
||||
messages = messages,
|
||||
backgroundColor = backgroundColor,
|
||||
avatarUrl = avatarUrl,
|
||||
likeCount = likeCount,
|
||||
commentCount = commentCount,
|
||||
liked = liked
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// 接真实数据后再调整默认页数。
|
||||
const val DEFAULT_PAGE_COUNT = 200
|
||||
}
|
||||
|
||||
// 聊天页面数据请求
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,109 @@
|
||||
package com.example.myapplication.ui.circle
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.graphics.Rect
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.bumptech.glide.Glide
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.PagerSnapHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.AuthEvent
|
||||
import com.example.myapplication.network.AuthEventBus
|
||||
import com.example.myapplication.network.NetworkEvent
|
||||
import com.example.myapplication.network.NetworkEventBus
|
||||
import com.example.myapplication.network.LoginResponse
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.ShareResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.ui.common.LoadingOverlay
|
||||
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
|
||||
import de.hdodenhof.circleimageview.CircleImageView
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.example.myapplication.network.BehaviorReporter
|
||||
import kotlin.math.max
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import android.view.WindowManager
|
||||
import eightbitlab.com.blurview.BlurView
|
||||
import eightbitlab.com.blurview.RenderEffectBlur
|
||||
import eightbitlab.com.blurview.RenderScriptBlur
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.widget.ImageView
|
||||
|
||||
class CircleFragment : Fragment() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
private lateinit var pageRv: RecyclerView
|
||||
private lateinit var inputOverlay: View
|
||||
private lateinit var inputEdit: EditText
|
||||
private lateinit var sendButton: ImageView
|
||||
private var inputBlur: BlurView? = null
|
||||
private var inputBlurMask: View? = null
|
||||
|
||||
private lateinit var pageAdapter: CirclePageAdapter
|
||||
private lateinit var snapHelper: PagerSnapHelper
|
||||
private lateinit var repository: CircleChatRepository
|
||||
|
||||
private val sharedChatPool = RecyclerView.RecycledViewPool()
|
||||
|
||||
private var currentPage = RecyclerView.NO_POSITION
|
||||
private var inputOverlayHeight = 0
|
||||
private var bottomInset = 0
|
||||
private var listBottomInset = 0
|
||||
private var bottomNavHeight = 0
|
||||
private var overlayBottomSpacingPx = 0
|
||||
private val overlayBottomSpacingMinPx by lazy {
|
||||
resources.getDimensionPixelSize(R.dimen.circle_input_bottom_spacing)
|
||||
}
|
||||
private var bottomNav: View? = null
|
||||
private var bottomNavBlur: View? = null
|
||||
private var prevBottomNavVisibility: Int? = null
|
||||
private var prevBottomNavBlurVisibility: Int? = null
|
||||
private var forceHideBottomNavBlur: Boolean = false
|
||||
private var originalSoftInputMode: Int? = null
|
||||
private var lastImeBottom = 0
|
||||
private var lastSystemBottom = 0
|
||||
private var isImeVisible = false
|
||||
private var overlayPadStart = 0
|
||||
private var overlayPadTop = 0
|
||||
private var overlayPadEnd = 0
|
||||
private var overlayPadBottom = 0
|
||||
|
||||
// 处理软键盘(IME)的动画变化,以便根据软键盘的显示和隐藏状态动态调整应用中的其他视图的布局
|
||||
private val imeAnimationCallback =
|
||||
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: MutableList<WindowInsetsAnimationCompat>
|
||||
): WindowInsetsCompat {
|
||||
val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||
applyImeInsets(imeBottom, systemBottom)
|
||||
return insets
|
||||
}
|
||||
}
|
||||
// 实现带有多个聊天页面和输入区域的界面
|
||||
private val keyboardLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val decor = activity?.window?.decorView ?: view ?: return@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 heightIme = if (heightDiff > threshold) heightDiff else 0
|
||||
|
||||
val rootInsets = ViewCompat.getRootWindowInsets(decor)
|
||||
val insetIme = rootInsets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
|
||||
val imeBottom = max(heightIme, insetIme)
|
||||
val systemBottom = rootInsets
|
||||
?.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
?.bottom
|
||||
?: lastSystemBottom
|
||||
applyImeInsets(imeBottom, systemBottom)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@@ -50,18 +112,379 @@ class CircleFragment : Fragment() {
|
||||
savedInstanceState: Bundle?
|
||||
): View = inflater.inflate(R.layout.fragment_circle, container, false)
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
NetworkEventBus.events.collect { event ->
|
||||
if (event is NetworkEvent.NetworkAvailable) {
|
||||
Log.d("CircleFragment", "Network restored, refresh circle content if needed")
|
||||
}
|
||||
}
|
||||
pageRv = view.findViewById(R.id.pageRv)
|
||||
inputOverlay = view.findViewById(R.id.inputOverlay)
|
||||
inputEdit = view.findViewById(R.id.inputEdit)
|
||||
sendButton = view.findViewById(R.id.btnSend)
|
||||
inputBlurMask = view.findViewById(R.id.inputBlurMask)
|
||||
inputBlur = view.findViewById(R.id.inputBlur)
|
||||
|
||||
overlayPadStart = inputOverlay.paddingStart
|
||||
overlayPadTop = inputOverlay.paddingTop
|
||||
overlayPadEnd = inputOverlay.paddingEnd
|
||||
overlayPadBottom = inputOverlay.paddingBottom
|
||||
|
||||
//保存Activity的原始软键盘行为模式,并将其设置为在软键盘弹出时调整主窗口大小
|
||||
activity?.window?.let { window ->
|
||||
originalSoftInputMode = window.attributes.softInputMode
|
||||
window.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or
|
||||
WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN
|
||||
)
|
||||
}
|
||||
|
||||
bottomNav = activity?.findViewById(R.id.bottom_nav)
|
||||
bottomNavBlur = activity?.findViewById(R.id.bottom_nav_blur)
|
||||
bottomNav?.doOnLayout {
|
||||
bottomNavHeight = it.height
|
||||
updateOverlaySpacing()
|
||||
}
|
||||
bottomNavBlur?.let { blur ->
|
||||
if (prevBottomNavBlurVisibility == null) {
|
||||
prevBottomNavBlurVisibility = blur.visibility
|
||||
}
|
||||
blur.visibility = View.GONE
|
||||
forceHideBottomNavBlur = true
|
||||
}
|
||||
|
||||
setupInputBlur()
|
||||
|
||||
//初始化聊天页面的 RecyclerView 和数据仓库
|
||||
val preloadCount = computePreloadCount(requireContext())
|
||||
repository = CircleChatRepository(
|
||||
context = requireContext(),
|
||||
totalPages = CircleChatRepository.DEFAULT_PAGE_COUNT,
|
||||
preloadCount = preloadCount,
|
||||
apiService = RetrofitClient.apiService
|
||||
)
|
||||
|
||||
//初始化聊天页面的 RecyclerView 的适配器
|
||||
pageAdapter = CirclePageAdapter(
|
||||
repository = repository,
|
||||
sharedPool = sharedChatPool
|
||||
)
|
||||
|
||||
//设置 RecyclerView 的布局管理器、适配器、滑动辅助器、缓存数量
|
||||
pageRv.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
|
||||
pageRv.adapter = pageAdapter
|
||||
pageRv.setHasFixedSize(true)
|
||||
pageRv.itemAnimator = null
|
||||
pageRv.setItemViewCacheSize(computeViewCache(preloadCount))
|
||||
|
||||
//设置滑动辅助器
|
||||
snapHelper = PagerSnapHelper()
|
||||
snapHelper.attachToRecyclerView(pageRv)
|
||||
|
||||
//设置 RecyclerView 的滑动监听器
|
||||
pageRv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
if (newState != RecyclerView.SCROLL_STATE_IDLE) return
|
||||
syncCurrentPage()
|
||||
}
|
||||
})
|
||||
|
||||
//设置一个窗口插入监听器,以便在软键盘弹出时动态调整输入区域的布局
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
|
||||
val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||
applyImeInsets(imeBottom, systemBottom)
|
||||
insets
|
||||
}
|
||||
//设置一个窗口插入动画回调,以便在软键盘弹出时动态调整输入区域的布局
|
||||
ViewCompat.setWindowInsetsAnimationCallback(view, imeAnimationCallback)
|
||||
view.doOnLayout { ViewCompat.requestApplyInsets(it) }
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(keyboardLayoutListener)
|
||||
|
||||
//设置输入区域的高度
|
||||
inputOverlay.doOnLayout {
|
||||
inputOverlayHeight = it.height
|
||||
pageAdapter.updateInputOverlayHeight(inputOverlayHeight)
|
||||
updateOverlaySpacing()
|
||||
updateVisibleInsets()
|
||||
}
|
||||
|
||||
//设置 RecyclerView 的高度
|
||||
pageRv.post {
|
||||
pageAdapter.updatePageHeight(pageRv.height)
|
||||
syncCurrentPage()
|
||||
}
|
||||
|
||||
//设置输入框的监听器
|
||||
sendButton.setOnClickListener { sendMessage() }
|
||||
//监听输入框的编辑操作事件
|
||||
inputEdit.setOnEditorActionListener { _, actionId, event ->
|
||||
if (event != null) {
|
||||
return@setOnEditorActionListener false
|
||||
}
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND ||
|
||||
actionId == EditorInfo.IME_ACTION_DONE ||
|
||||
actionId == EditorInfo.IME_ACTION_UNSPECIFIED
|
||||
) {
|
||||
sendMessage()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
//用户于输入框 inputEdit 中释放 Enter 键时发送消息
|
||||
inputEdit.setOnKeyListener { _, keyCode, event ->
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
|
||||
sendMessage()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//清理和恢复 CircleFragment 在生命周期结束时的状态
|
||||
override fun onDestroyView() {
|
||||
view?.let { root ->
|
||||
ViewCompat.setWindowInsetsAnimationCallback(root, null)
|
||||
root.viewTreeObserver.removeOnGlobalLayoutListener(keyboardLayoutListener)
|
||||
}
|
||||
|
||||
bottomNav?.translationY = 0f
|
||||
bottomNavBlur?.translationY = 0f
|
||||
|
||||
forceHideBottomNavBlur = false
|
||||
restoreBottomNavVisibility()
|
||||
bottomNav = null
|
||||
bottomNavBlur = null
|
||||
inputBlur = null
|
||||
inputBlurMask = null
|
||||
activity?.window?.let { window ->
|
||||
originalSoftInputMode?.let { window.setSoftInputMode(it) }
|
||||
}
|
||||
originalSoftInputMode = null
|
||||
repository.close()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
//从输入框中获取用户输入的消息,将其添加到当前页面的消息列表中
|
||||
private fun sendMessage() {
|
||||
val text = inputEdit.text?.toString()?.trim().orEmpty()
|
||||
Log.d(
|
||||
"1314520-Circle",
|
||||
"sendMessage textLen=${text.length} currentPage=$currentPage"
|
||||
)
|
||||
if (text.isEmpty()) return
|
||||
val page = resolveCurrentPage()
|
||||
Log.d("1314520-Circle", "sendMessage resolvedPage=$page")
|
||||
if (page == RecyclerView.NO_POSITION) return
|
||||
repository.addUserMessage(page, text)
|
||||
inputEdit.setText("")
|
||||
notifyMessageAppended(page)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
|
||||
delay(450)
|
||||
val reply = repository.buildBotReply(text)
|
||||
repository.addBotMessage(page, reply)
|
||||
notifyMessageAppended(page)
|
||||
}
|
||||
}
|
||||
|
||||
//确定 RecyclerView 中当前选中的页面位置
|
||||
private fun resolveCurrentPage(): Int {
|
||||
if (currentPage != RecyclerView.NO_POSITION) return currentPage
|
||||
val lm = pageRv.layoutManager as? LinearLayoutManager ?: return currentPage
|
||||
val snapView = snapHelper.findSnapView(lm)
|
||||
val position = when {
|
||||
snapView != null -> lm.getPosition(snapView)
|
||||
else -> lm.findFirstVisibleItemPosition()
|
||||
}
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
currentPage = position
|
||||
}
|
||||
return currentPage
|
||||
}
|
||||
|
||||
//通知 RecyclerView 中的页面 ViewHolder 刷新消息列表
|
||||
private fun notifyMessageAppended(position: Int) {
|
||||
val holder = pageRv.findViewHolderForAdapterPosition(position) as? ChatPageViewHolder
|
||||
holder?.notifyMessageAppended()
|
||||
}
|
||||
|
||||
//同步当前页面的选中状态
|
||||
private fun syncCurrentPage() {
|
||||
val lm = pageRv.layoutManager as? LinearLayoutManager ?: return
|
||||
val snapView = snapHelper.findSnapView(lm) ?: return
|
||||
val position = lm.getPosition(snapView)
|
||||
if (position == RecyclerView.NO_POSITION || position == currentPage) return
|
||||
|
||||
val oldPos = currentPage
|
||||
currentPage = position
|
||||
|
||||
repository.preloadAround(position)
|
||||
|
||||
val oldHolder = pageRv.findViewHolderForAdapterPosition(oldPos) as? PageViewHolder
|
||||
val newHolder = pageRv.findViewHolderForAdapterPosition(position) as? PageViewHolder
|
||||
oldHolder?.onPageUnSelected()
|
||||
newHolder?.onPageSelected()
|
||||
}
|
||||
|
||||
//根据当前可见的聊天页面来动态调整这些页面的内边距,以适应软键盘的弹出和隐藏状态
|
||||
private fun updateVisibleInsets() {
|
||||
val lm = pageRv.layoutManager as? LinearLayoutManager ?: return
|
||||
val first = lm.findFirstVisibleItemPosition()
|
||||
val last = lm.findLastVisibleItemPosition()
|
||||
if (first == RecyclerView.NO_POSITION || last == RecyclerView.NO_POSITION) {
|
||||
|
||||
pageRv.post { updateVisibleInsets() }
|
||||
return
|
||||
}
|
||||
|
||||
for (i in first..last) {
|
||||
val holder = pageRv.findViewHolderForAdapterPosition(i) as? ChatPageViewHolder
|
||||
|
||||
holder?.updateInsets(inputOverlayHeight, listBottomInset)
|
||||
}
|
||||
}
|
||||
//根据软键盘的显示和隐藏状态来调整输入区域的布局
|
||||
private fun updateOverlaySpacing() {
|
||||
val fallbackNavHeight = if (bottomNavHeight > 0) {
|
||||
bottomNavHeight
|
||||
} else {
|
||||
resources.getDimensionPixelSize(
|
||||
com.google.android.material.R.dimen.design_bottom_navigation_height
|
||||
)
|
||||
}
|
||||
val navHeight = if (isImeVisible) 0 else fallbackNavHeight
|
||||
if (overlayBottomSpacingPx != navHeight) {
|
||||
overlayBottomSpacingPx = navHeight
|
||||
val lp = inputOverlay.layoutParams as? ViewGroup.MarginLayoutParams
|
||||
if (lp != null && lp.bottomMargin != navHeight) {
|
||||
lp.bottomMargin = navHeight
|
||||
inputOverlay.layoutParams = lp
|
||||
}
|
||||
val maskLp = inputBlurMask?.layoutParams as? ViewGroup.MarginLayoutParams
|
||||
if (maskLp != null && maskLp.bottomMargin != 0) {
|
||||
maskLp.bottomMargin = 0
|
||||
inputBlurMask?.layoutParams = maskLp
|
||||
}
|
||||
}
|
||||
val overlayHeight = if (inputOverlayHeight > 0) {
|
||||
inputOverlayHeight
|
||||
} else {
|
||||
resources.getDimensionPixelSize(R.dimen.circle_input_overlay_height)
|
||||
}
|
||||
val maskHeight = overlayHeight + navHeight +
|
||||
resources.getDimensionPixelSize(R.dimen.circle_frosted_gradient_extra)
|
||||
inputBlurMask?.let { mask ->
|
||||
val params = mask.layoutParams
|
||||
if (params.height != maskHeight) {
|
||||
params.height = maskHeight
|
||||
mask.layoutParams = params
|
||||
}
|
||||
}
|
||||
listBottomInset = bottomInset
|
||||
pageAdapter.updateBottomInset(listBottomInset)
|
||||
updateVisibleInsets()
|
||||
}
|
||||
|
||||
//根据软键盘的显示和隐藏状态来调整底部导航栏的可见性和透明度
|
||||
private fun applyImeInsets(imeBottom: Int, systemBottom: Int) {
|
||||
if (lastImeBottom == imeBottom && lastSystemBottom == systemBottom) return
|
||||
lastImeBottom = imeBottom
|
||||
lastSystemBottom = systemBottom
|
||||
|
||||
val imeVisible = imeBottom > 0
|
||||
isImeVisible = imeVisible
|
||||
|
||||
bottomInset = if (imeVisible) 0 else systemBottom
|
||||
inputOverlay.translationY = 0f
|
||||
inputOverlay.setPadding(
|
||||
overlayPadStart,
|
||||
overlayPadTop,
|
||||
overlayPadEnd,
|
||||
overlayPadBottom
|
||||
)
|
||||
updateOverlaySpacing()
|
||||
setBottomNavVisible(!imeVisible)
|
||||
}
|
||||
|
||||
//根据当前的输入状态来设置底部导航栏的可见性和透明度
|
||||
private fun setBottomNavVisible(visible: Boolean) {
|
||||
val nav = bottomNav ?: return
|
||||
val blur = bottomNavBlur
|
||||
if (!visible) {
|
||||
if (prevBottomNavVisibility == null) prevBottomNavVisibility = nav.visibility
|
||||
nav.visibility = View.GONE
|
||||
if (blur != null) {
|
||||
if (prevBottomNavBlurVisibility == null) prevBottomNavBlurVisibility = blur.visibility
|
||||
blur.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
restoreBottomNavVisibility()
|
||||
if (forceHideBottomNavBlur) {
|
||||
blur?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//恢复底部导航栏的可见性和透明度
|
||||
private fun restoreBottomNavVisibility() {
|
||||
bottomNav?.let { nav ->
|
||||
prevBottomNavVisibility?.let { nav.visibility = it }
|
||||
}
|
||||
bottomNavBlur?.let { blur ->
|
||||
if (!forceHideBottomNavBlur) {
|
||||
prevBottomNavBlurVisibility?.let { blur.visibility = it }
|
||||
}
|
||||
}
|
||||
prevBottomNavVisibility = null
|
||||
if (!forceHideBottomNavBlur) {
|
||||
prevBottomNavBlurVisibility = null
|
||||
}
|
||||
}
|
||||
|
||||
//设置输入区域的模糊效果
|
||||
private fun setupInputBlur() {
|
||||
val blurView = inputBlur ?: 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) 8f else 6f
|
||||
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))
|
||||
} catch (_: Throwable) {
|
||||
blurView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
//计算 RecyclerView 的缓存数量,以便在页面切换时尽可能减少页面切换时的闪烁
|
||||
private fun computeViewCache(preloadCount: Int): Int {
|
||||
return (preloadCount * 2 + 1).coerceAtLeast(3).coerceAtMost(7)
|
||||
}
|
||||
|
||||
//计算 RecyclerView 的预加载数量,以便在页面切换时尽可能减少页面切换时的闪烁
|
||||
private 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
) : 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)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = repository.totalPages
|
||||
|
||||
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,46 @@
|
||||
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
|
||||
|
||||
override fun onTouchEvent(e: MotionEvent): Boolean {
|
||||
when (e.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastY = e.y
|
||||
// 默认由子 RecyclerView 处理滚动,直到到达边界。
|
||||
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) {
|
||||
canScrollUp
|
||||
} else {
|
||||
canScrollDown
|
||||
}
|
||||
parent?.requestDisallowInterceptTouchEvent(disallow)
|
||||
}
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
parent?.requestDisallowInterceptTouchEvent(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,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() {}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.2 MiB |
BIN
app/src/main/res/drawable/a123123123.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
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="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="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="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="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>
|
||||
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/language_input.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/drawable/like.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/drawable/like_select.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.0 KiB |
BIN
app/src/main/res/drawable/send_input.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/src/main/res/drawable/text_input.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
@@ -8,34 +8,165 @@
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.circle.CircleFragment">
|
||||
|
||||
<!-- 背景图片 -->
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:src="@drawable/a123123123"
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="true" />
|
||||
<FrameLayout
|
||||
android:id="@+id/chatContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:elevation="2dp">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
<ImageView
|
||||
android:id="@+id/chatAvatar"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:padding="6dp"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
android:layout_marginEnd="26dp"
|
||||
android:src="@drawable/collect"
|
||||
android:scaleType="centerCrop"/>
|
||||
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<!-- 全屏竖向分页:每个 item 是一个聊天页 -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/pageRv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
android:overScrollMode="never" />
|
||||
|
||||
<LinearLayout
|
||||
<com.example.myapplication.ui.circle.GradientMaskLayout
|
||||
android:id="@+id/inputBlurMask"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:elevation="0dp">
|
||||
|
||||
<eightbitlab.com.blurview.BlurView
|
||||
android:id="@+id/inputBlur"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:layout_height="match_parent"
|
||||
android:clickable="false"
|
||||
android:focusable="false" />
|
||||
</com.example.myapplication.ui.circle.GradientMaskLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:textSize="24sp"
|
||||
android:text="布局"
|
||||
<!-- 固定输入框,IME 弹出时用 translation 上移 -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/inputOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="68dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:elevation="2dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<!-- 文字输入框 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/inputContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="@drawable/bg_chat_text_box"
|
||||
android:paddingEnd="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/btnlanguage"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="52dp"
|
||||
android:padding="12dp"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"/>
|
||||
</LinearLayout>
|
||||
android:src="@drawable/language_input"
|
||||
android:includeFontPadding="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
<EditText
|
||||
android:id="@+id/inputEdit"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_gravity="bottom"
|
||||
android:hint="@string/circle_input_hint"
|
||||
android:background="@drawable/bg_chat_text_box_edit_text"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="textCapSentences"
|
||||
android:maxLines="1"
|
||||
android:minLines="1"
|
||||
android:singleLine="true"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingBottom="0dp"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="#111111"
|
||||
android:textColorHint="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnlanguage"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="52dp"
|
||||
android:padding="12dp"
|
||||
android:gravity="center"
|
||||
android:src="@drawable/send_input"
|
||||
android:includeFontPadding="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 语音输入框 -->
|
||||
<!-- <LinearLayout
|
||||
android:id="@+id/inputContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="@drawable/bg_chat_text_box"
|
||||
android:paddingEnd="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="52dp"
|
||||
android:padding="12dp"
|
||||
android:gravity="center"
|
||||
android:src="@drawable/text_input"
|
||||
android:includeFontPadding="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/inputEdit"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_gravity="bottom"
|
||||
android:hint="@string/circle_input_hint"
|
||||
android:background="@drawable/bg_chat_text_box_edit_text"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="textCapSentences"
|
||||
android:maxLines="1"
|
||||
android:minLines="1"
|
||||
android:singleLine="true"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingBottom="0dp"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="#111111"
|
||||
android:textColorHint="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnSend"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</LinearLayout> -->
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
24
app/src/main/res/layout/item_chat_message_bot.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 对方的消息气泡 -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start"
|
||||
android:maxWidth="250dp"
|
||||
android:background="@drawable/bg_chat_bubble_bot"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
</FrameLayout>
|
||||
24
app/src/main/res/layout/item_chat_message_me.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 自己的消息气泡 -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:maxWidth="250dp"
|
||||
android:background="@drawable/bg_chat_bubble_me"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:textColor="#000000"
|
||||
android:textSize="14sp" />
|
||||
</FrameLayout>
|
||||
134
app/src/main/res/layout/item_circle_chat_page.xml
Normal file
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- circle每个页面的布局文件 -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pageBackground"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layerType="hardware"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/page"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="60dp"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:textColor="#111111"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.example.myapplication.ui.circle.EdgeAwareRecyclerView
|
||||
android:id="@+id/chatRv"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:overScrollMode="never"
|
||||
app:layout_constraintBottom_toTopOf="@+id/chatFooter"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/page" />
|
||||
|
||||
<!-- ai角色详情 -->
|
||||
<FrameLayout
|
||||
android:id="@+id/chatFooter"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="90dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@android:color/transparent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="54dp"
|
||||
android:layout_height="54dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:src="@drawable/default_avatar"
|
||||
android:clickable="true"
|
||||
android:focusable="true"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pageTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:text=""
|
||||
android:textSize="14sp"
|
||||
android:textColor="#FFFFFF" />
|
||||
|
||||
<!-- 点赞 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/like"
|
||||
android:layout_width="21dp"
|
||||
android:layout_height="19dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:src="@drawable/like" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/likeCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#FFFFFF" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 评论 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/comment"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:src="@drawable/comment" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/commentCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#FFFFFF" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -126,6 +126,9 @@
|
||||
<!-- 商城其他 -->
|
||||
<string name="recommended">推荐皮肤</string>
|
||||
|
||||
<!-- ai角色聊天 -->
|
||||
<string name="circle_input_hint">给他/她发送一条消息</string>
|
||||
|
||||
<!-- 输入法权限和体验页 -->
|
||||
<string name="guide_chat_1">欢迎使用【key of love】键盘!</string>
|
||||
<string name="guide_chat_2">点击“复制”任意对话内容,然后“粘贴”并尝试使用键盘[人物角色]方式回复。</string>
|
||||
|
||||
7
app/src/main/res/values/circle_dimens.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 输入框固定高度 -->
|
||||
<dimen name="circle_input_overlay_height">56dp</dimen>
|
||||
<!-- 毛玻璃向上渐变的扩展高度 -->
|
||||
<dimen name="circle_frosted_gradient_extra">100dp</dimen>
|
||||
</resources>
|
||||
5
app/src/main/res/values/dimens.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 底部额外间距,防止导航栏遮挡输入框 -->
|
||||
<dimen name="circle_input_bottom_spacing">50dp</dimen>
|
||||
</resources>
|
||||
@@ -17,4 +17,5 @@
|
||||
<string name="key_comma">,</string>
|
||||
<string name="key_period">.</string>
|
||||
<string name="key_enter">Enter</string>
|
||||
<string name="circle_send">Send</string>
|
||||
</resources>
|
||||
|
||||
@@ -131,6 +131,8 @@
|
||||
<!-- 商城其他 -->
|
||||
<string name="recommended">Recommended skins</string>
|
||||
|
||||
<!-- ai角色聊天 -->
|
||||
<string name="circle_input_hint">Send a message to him/her</string>
|
||||
|
||||
<!-- 输入法权限和体验页 -->
|
||||
<string name="guide_chat_1">Welcome to use the [key of love] keyboard!</string>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<!-- 警告:cleartextTrafficPermitted="true" 会允许所有 HTTP 和 HTTPS 通信,不安全,请谨慎使用! -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">192.168.2.21</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||