ai角色聊天板块

This commit is contained in:
pengxiaolong
2026-01-30 21:54:00 +08:00
parent 63415e1fde
commit e3367d1943
53 changed files with 145346 additions and 149 deletions

View File

@@ -51,7 +51,8 @@
<activity
android:name=".MainActivity"
android:screenOrientation="portrait"
android:exported="true">
android:exported="true"
android:windowSoftInputMode="stateHidden|adjustNothing">
</activity>
<!-- 输入法服务 -->

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

View File

@@ -17,8 +17,6 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import eightbitlab.com.blurview.BlurView
import eightbitlab.com.blurview.RenderEffectBlur
import eightbitlab.com.blurview.RenderScriptBlur
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
@@ -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等 */

View File

@@ -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()

View File

@@ -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)
}
}

View File

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

View File

@@ -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

View File

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

View File

@@ -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,
)

View File

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

View File

@@ -10,7 +10,7 @@ import com.example.myapplication.network.FileUploadService
object RetrofitClient {
private const val BASE_URL = "http://192.168.2.21:7529/api/"
private const val BASE_URL = "http://192.168.2.22:7529/api/"
// 保存 ApplicationContext
@Volatile
@@ -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))

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
// )

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -0,0 +1,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() {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 底部额外间距,防止导航栏遮挡输入框 -->
<dimen name="circle_input_bottom_spacing">50dp</dimen>
</resources>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>