键盘ai角色,无数据显示2

This commit is contained in:
pengxiaolong
2026-02-12 19:40:32 +08:00
parent ea2ec19dc8
commit acf4d39892
47 changed files with 1923 additions and 472 deletions

View File

@@ -3,7 +3,8 @@
"allow": [
"Bash(dir:*)",
"Bash(powershell:*)",
"Bash(find /d/Test/MyApplication/app/src/main/assets -name \"vocab.txt\" -exec wc -l {} ;)"
"Bash(find /d/Test/MyApplication/app/src/main/assets -name \"vocab.txt\" -exec wc -l {} ;)",
"WebSearch"
]
}
}

View File

@@ -13,6 +13,7 @@ import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.utils.ImeUtils
class ImeGuideActivity : AppCompatActivity() {
@@ -228,56 +229,7 @@ class ImeGuideActivity : AppCompatActivity() {
}
}
/** 是否启用了本输入法 */
private fun isImeEnabled(): Boolean {
return try {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
val myComponent = ComponentName(this, MyInputMethodService::class.java)
val result = imm.enabledInputMethodList.any { imeInfo ->
imeInfo.packageName == myComponent.packageName &&
imeInfo.serviceName == myComponent.className
}
Log.d(TAG, "isImeEnabled = $result")
result
} catch (e: Exception) {
Log.e(TAG, "isImeEnabled 出错", e)
false
}
}
private fun isImeEnabled(): Boolean = ImeUtils.isImeEnabled(this)
/** 是否已切换为当前输入法 */
private fun isImeSelected(): Boolean {
return try {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
val myComponent = ComponentName(this, MyInputMethodService::class.java)
val currentImeId = Settings.Secure.getString(
contentResolver,
Settings.Secure.DEFAULT_INPUT_METHOD
) ?: return false
Log.d(TAG, "DEFAULT_INPUT_METHOD = $currentImeId")
// 找到“当前默认 IME”对应的 InputMethodInfo
val currentImeInfo = imm.enabledInputMethodList.firstOrNull { imeInfo ->
imeInfo.id == currentImeId
}
if (currentImeInfo == null) {
Log.d(TAG, "currentImeInfo == null")
return false
}
val isMine = currentImeInfo.packageName == myComponent.packageName &&
currentImeInfo.serviceName == myComponent.className
Log.d(TAG, "isImeSelected = $isMine")
isMine
} catch (e: Exception) {
Log.e(TAG, "isImeSelected 出错", e)
false
}
}
private fun isImeSelected(): Boolean = ImeUtils.isImeSelected(this)
}

View File

@@ -27,6 +27,7 @@ import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.network.NetworkEvent
import com.example.myapplication.network.NetworkEventBus
import com.example.myapplication.keyboard.AiRolePreferences
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.button.MaterialButton
@@ -50,7 +51,8 @@ class MainActivity : AppCompatActivity() {
private val protectedTabs = setOf(
R.id.shop_graph,
R.id.mine_graph
R.id.mine_graph,
R.id.circle_graph
)
private val tabMap by lazy {
@@ -73,6 +75,7 @@ class MainActivity : AppCompatActivity() {
private var connectivityManager: ConnectivityManager? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var hasNetworkConnection: Boolean = true
private var wasNetworkValidated: Boolean = true
private var noNetworkDialog: AlertDialog? = null
private val currentTabHost: NavHostFragment
@@ -192,34 +195,37 @@ class MainActivity : AppCompatActivity() {
// 登录成功事件处理
is AuthEvent.LoginSuccess -> {
// 关闭 global overlay回到 empty
globalNavController.popBackStack(R.id.globalEmptyFragment, false)
// 捕获待切换的 tab立即清除
val targetTab = pendingTabAfterLogin
pendingTabAfterLogin = null
// 如果之前想去商城/我的,登录成功后自动切过去
pendingTabAfterLogin?.let { tag ->
switchTab(tag)
bottomNav.selectedItemId = when (tag) {
// 先切 tab在 global overlay 关闭之前),确保 currentTabTag 已更新
if (targetTab != null) {
switchTab(targetTab)
bottomNav.selectedItemId = when (targetTab) {
TAB_SHOP -> R.id.shop_graph
TAB_CIRCLE -> R.id.circle_graph
TAB_MINE -> R.id.mine_graph
else -> R.id.home_graph
}
supportFragmentManager.executePendingTransactions()
}
pendingTabAfterLogin = null
// 再关闭 global overlaypopBackStack 会触发 bindGlobalVisibility
// 此时 tab 已切换完成updateBottomNavVisibility 能正确判断
globalNavController.popBackStack(R.id.globalEmptyFragment, false)
// 处理intent跳转目标页
if (pendingNavigationAfterLogin == "recharge_fragment") {
openGlobal(R.id.rechargeFragment)
pendingNavigationAfterLogin = null
}
// ✅ 登录成功后也刷新一次
bottomNav.post { updateBottomNavVisibility() }
}
// 登出事件处理
is AuthEvent.Logout -> {
pendingTabAfterLogin = event.returnTabTag
AiRolePreferences.clear(this@MainActivity)
// ✅ 用户没登录按返回,应回首页,所以先切到首页
switchTab(TAB_HOME, force = true)
@@ -393,9 +399,6 @@ class MainActivity : AppCompatActivity() {
findViewById<View>(R.id.global_container).visibility =
if (isEmpty) View.GONE else View.VISIBLE
// ✅ 底栏统一走 update
updateBottomNavVisibility()
val justClosedOverlay =
(dest.id == R.id.globalEmptyFragment && lastGlobalDestId != R.id.globalEmptyFragment)
lastGlobalDestId = dest.id
@@ -404,6 +407,7 @@ class MainActivity : AppCompatActivity() {
val currentTabGraphId = when (currentTabTag) {
TAB_SHOP -> R.id.shop_graph
TAB_MINE -> R.id.mine_graph
TAB_CIRCLE -> R.id.circle_graph
else -> R.id.home_graph
}
@@ -415,9 +419,10 @@ class MainActivity : AppCompatActivity() {
if (!isLoggedIn()) {
pendingTabAfterLogin = null
}
bottomNav.post { updateBottomNavVisibility() }
}
// 无论何种情况global 目的地变化后都刷新底栏
bottomNav.post { updateBottomNavVisibility() }
}
}
@@ -705,6 +710,7 @@ class MainActivity : AppCompatActivity() {
if (cm == null) return
hasNetworkConnection = isNetworkConnected(cm)
wasNetworkValidated = hasNetworkConnection
if (!hasNetworkConnection) {
showNoNetworkDialog()
NetworkEventBus.emit(NetworkEvent.NetworkLost)
@@ -718,6 +724,24 @@ class MainActivity : AppCompatActivity() {
override fun onLost(network: Network) {
handleNetworkStateChange(isNetworkConnected(cm))
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities
) {
val validated = capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
)
if (validated && !wasNetworkValidated) {
// 网络从未验证(慢/超时)变为已验证(可用),触发刷新
wasNetworkValidated = true
runOnUiThread {
NetworkEventBus.emit(NetworkEvent.NetworkAvailable)
}
} else if (!validated && wasNetworkValidated) {
wasNetworkValidated = false
}
}
}
networkCallback = callback
@@ -735,6 +759,7 @@ class MainActivity : AppCompatActivity() {
private fun handleNetworkStateChange(connected: Boolean) {
if (connected == hasNetworkConnection) return
hasNetworkConnection = connected
wasNetworkValidated = connected
runOnUiThread {
if (connected) {

View File

@@ -43,7 +43,15 @@ import kotlin.math.abs
import java.text.BreakIterator
import android.widget.EditText
import android.content.res.Configuration
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import com.bumptech.glide.Glide
import com.example.myapplication.keyboard.AiRolePreferences
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.aiCompanionPageRequest
import com.example.myapplication.keyboard.AiRolePanelController
import com.example.myapplication.keyboard.FixedHeightFrameLayout
import de.hdodenhof.circleimageview.CircleImageView
class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
@@ -88,6 +96,11 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// ================= 表情 =================
private var emojiKeyboardView: View? = null
private var emojiKeyboard: com.example.myapplication.keyboard.EmojiKeyboard? = null
// ================= AI 角色面板(通过 CandidatesView 实现) =================
private var aiRolePanelView: View? = null
private var isAiRolePanelShown = false
private var aiRolePanelController: AiRolePanelController? = null
// =================上滑清空==================
private var swipeHintPopup: PopupWindow? = null
private var swipeClearPopup: PopupWindow? = null
@@ -242,8 +255,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
}
// 输入法状态变化
// 创建输入视图:此处只负责选择主键盘
// 创建输入视图:直接返回键盘 rootView
override fun onCreateInputView(): View {
val keyboard = ensureMainKeyboard()
currentKeyboardView = keyboard.rootView
@@ -252,6 +264,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
return keyboard.rootView
}
// 创建候选视图:用作 AI 角色面板的容器Android 官方 API
override fun onCreateCandidatesView(): View {
val panel = ensureAiRolePanelView()
(panel.parent as? ViewGroup)?.removeView(panel)
return panel
}
// 关键:让系统把 CandidatesView 区域也算作输入法内容区域,正确推上输入框
// 这是解决 Android 13+ setCandidatesViewShown 不生效的官方 workaround
override fun onComputeInsets(outInsets: Insets) {
super.onComputeInsets(outInsets)
if (!isFullscreenMode()) {
outInsets.contentTopInsets = outInsets.visibleTopInsets
}
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
isInputViewShownFlag = true
@@ -303,6 +331,10 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 清理本次输入状态
clearEditorState()
// 键盘收起时清理聊天数据
dismissAiRolePanel()
aiRolePanelController?.clearMessages()
mainHandler.postDelayed({
if (!isInputViewShownFlag) {
try {
@@ -318,6 +350,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
ThemeManager.removeThemeChangeListener(themeListener)
stopRepeatDelete()
aiKeyboard?.cancelAiStream()
aiRolePanelController?.destroy()
aiRolePanelController = null
super.onDestroy()
}
@@ -432,6 +466,82 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
return lastClearedText != null
}
// ================= AI 角色面板展开/关闭 =================
override fun toggleAiRolePanel() {
if (isAiRolePanelShown) {
dismissAiRolePanel()
} else {
showAiRolePanel()
}
}
private fun ensureAiRolePanelView(): View {
if (aiRolePanelView == null) {
aiRolePanelView = layoutInflater.inflate(R.layout.airole_panel, null)
// CandidatesView 父容器mCandidatesFrame用 MeasureSpec.UNSPECIFIED 测量子 View
// 任何 XML/LayoutParams 声明的固定高度都会被忽略RecyclerView 会展开所有 item。
// 通过自定义 FixedHeightFrameLayout.maxHeightPx 在 onMeasure 中强制钳制高度。
val panelHeightPx = resources.getDimensionPixelSize(R.dimen.sw_200dp)
(aiRolePanelView as? FixedHeightFrameLayout)?.maxHeightPx = panelHeightPx
aiRolePanelView?.findViewById<View>(R.id.airole_panel_close)?.setOnClickListener {
dismissAiRolePanel()
}
// 创建 Controller 并绑定视图
aiRolePanelController = AiRolePanelController(this)
aiRolePanelController?.bindView(aiRolePanelView!!)
}
return aiRolePanelView!!
}
private fun showAiRolePanel() {
if (isAiRolePanelShown) return
// 复制当前键盘根视图的背景到面板
val keyboardView = currentKeyboardView
val panel = ensureAiRolePanelView()
if (keyboardView != null) {
val bgDrawable = keyboardView.background?.constantState?.newDrawable()?.mutate()
if (bgDrawable != null) {
panel.background = bgDrawable
}
}
isAiRolePanelShown = true
aiRolePanelController?.onPanelShow()
setCandidatesViewShown(true)
// Android 13+ bug workaround系统内部可能把候选容器设为 INVISIBLE
// 需要主动遍历找到面板的父容器链,强制设为 VISIBLE
mainHandler.post {
forceCandidatesFrameVisible()
}
}
private fun dismissAiRolePanel() {
if (!isAiRolePanelShown) return
isAiRolePanelShown = false
setCandidatesViewShown(false)
}
/**
* Android 13+ workaroundsetCandidatesViewShown(true) 后,
* 系统内部的 mCandidatesFrame 可能被设为 INVISIBLE。
* 从面板 View 向上遍历父容器,强制所有不可见的父容器设为 VISIBLE。
*/
private fun forceCandidatesFrameVisible() {
var v: View? = aiRolePanelView ?: return
while (v != null) {
if (v.visibility != View.VISIBLE) {
v.visibility = View.VISIBLE
}
val parent = v.parent
v = if (parent is View) parent else null
}
}
private fun showSwipeClearHint(anchor: View, text: String = "Clear") {
mainHandler.post {
if (swipeClearPopupShown) return@post
@@ -548,40 +658,84 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
}
override fun showMainKeyboard() {
clearEditorState()
clearEditorState()
val kb = ensureMainKeyboard()
currentKeyboardView = kb.rootView
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
updateAiRoleAvatar()
}
private fun updateAiRoleAvatar() {
val view = currentKeyboardView ?: return
val avatarView = view.findViewById<CircleImageView>(R.id.key_airole) ?: return
val info = AiRolePreferences.getLastCompanion(this)
if (info != null && !info.avatarUrl.isNullOrBlank()) {
Glide.with(this)
.load(info.avatarUrl)
.circleCrop()
.placeholder(android.R.drawable.ic_menu_myplaces)
.into(avatarView)
} else {
avatarView.setImageResource(android.R.drawable.ic_menu_myplaces)
// 没有角色数据时,异步获取第一个角色作为默认值
fetchDefaultCompanion()
}
}
private fun fetchDefaultCompanion() {
CoroutineScope(Dispatchers.IO).launch {
try {
val response = RetrofitClient.apiService.aiCompanionPage(
aiCompanionPageRequest(pageNum = 1, pageSize = 1)
)
val companion = response.data?.records?.firstOrNull() ?: return@launch
AiRolePreferences.saveLastCompanion(
this@MyInputMethodService,
companion.id,
companion.name,
companion.avatarUrl
)
// 保存后刷新头像
launch(Dispatchers.Main) {
updateAiRoleAvatar()
}
} catch (e: Exception) {
// 网络异常忽略,下次切换键盘时会重试
}
}
}
override fun showNumberKeyboard() {
clearEditorState()
clearEditorState()
val kb = ensureNumberKeyboard()
currentKeyboardView = kb.rootView
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
updateAiRoleAvatar()
}
override fun showSymbolKeyboard() {
clearEditorState()
clearEditorState()
val kb = ensureSymbolKeyboard()
currentKeyboardView = kb.rootView
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
updateAiRoleAvatar()
}
override fun showAiKeyboard() {
clearEditorState()
clearEditorState()
val kb = ensureAiKeyboard()
currentKeyboardView = kb.rootView
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
kb.refreshPersonas()
updateAiRoleAvatar()
}
override fun showEmojiKeyboard() {
clearEditorState()
clearEditorState()
val kb = ensureEmojiKeyboard()
currentKeyboardView = kb.rootView
setInputViewSafely(kb.rootView)
@@ -589,27 +743,31 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
}
override fun associateClose() {
clearEditorState()
clearEditorState()
val kb = ensureEmojiKeyboard()
}
override fun onConfigurationChanged(newConfig: Configuration) {
// 先清理缓存,避免复用旧 View
dismissAiRolePanel()
aiRolePanelController?.destroy()
aiRolePanelController = null
aiRolePanelView = null
currentKeyboardView = null
aiKeyboard?.cancelAiStream()
mainKeyboardView = null
numberKeyboardView = null
symbolKeyboardView = null
aiKeyboardView = null
emojiKeyboardView = null
mainKeyboard = null
numberKeyboard = null
symbolKeyboard = null
aiKeyboard = null
emojiKeyboard = null
super.onConfigurationChanged(newConfig)
}
@@ -822,6 +980,26 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 发送(标准 SEND + 回车 fallback
override fun performSendAction() {
// AI 角色面板打开时,拦截发送操作,将文本发给 AI 角色
if (isAiRolePanelShown) {
val ic = currentInputConnection ?: return
val et = try {
ic.getExtractedText(ExtractedTextRequest(), 0)
} catch (_: Throwable) { null }
val text = et?.text?.toString()?.trim().orEmpty()
if (text.isNotEmpty() && aiRolePanelController?.sendMessage(text) == true) {
// 清空输入框
ic.beginBatchEdit()
try {
ic.performContextMenuAction(android.R.id.selectAll)
ic.commitText("", 1)
} finally {
ic.endBatchEdit()
}
}
return
}
val ic = currentInputConnection ?: return
val info = currentInputEditorInfo

View File

@@ -484,6 +484,14 @@ class AiKeyboard(
}
}
// AI 角色面板
val airoleId = res.getIdentifier("key_airole", "id", pkg)
if (airoleId != 0) {
rootView.findViewById<View?>(airoleId)?.setOnClickListener {
env.toggleAiRolePanel()
}
}
// VIP
val vipButtonId = res.getIdentifier("key_vip", "id", pkg)
if (vipButtonId != 0) {

View File

@@ -0,0 +1,195 @@
package com.example.myapplication.keyboard
import android.content.Context
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.chatMessageRequest
import com.example.myapplication.ui.circle.ChatMessage
import com.example.myapplication.ui.circle.ChatMessageAdapter
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* AI 角色面板控制器,封装面板内聊天的全部业务逻辑
*/
class AiRolePanelController(private val context: Context) {
companion object {
private const val TAG = "AiRolePanel"
}
private var recyclerView: RecyclerView? = null
private var loginHint: TextView? = null
private var adapter: ChatMessageAdapter? = null
private val messages = mutableListOf<ChatMessage>()
private var currentCompanionId: Int = 0
private var messageIdCounter = System.currentTimeMillis()
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun bindView(panelView: View) {
recyclerView = panelView.findViewById(R.id.airole_panel_messages)
loginHint = panelView.findViewById(R.id.airole_panel_login_hint)
adapter = ChatMessageAdapter()
recyclerView?.apply {
layoutManager = LinearLayoutManager(context).apply {
stackFromEnd = true
}
adapter = this@AiRolePanelController.adapter
}
adapter?.bindMessages(messages)
}
private fun isLoggedIn(): Boolean {
return EncryptedSharedPreferencesUtil.contains(context, "user")
}
private fun updateLoginState() {
val loggedIn = isLoggedIn()
recyclerView?.visibility = if (loggedIn) View.VISIBLE else View.GONE
loginHint?.visibility = if (loggedIn) View.GONE else View.VISIBLE
}
fun onPanelShow() {
updateLoginState()
if (!isLoggedIn()) return
val info = AiRolePreferences.getLastCompanion(context)
val newId = info?.companionId ?: 0
if (newId != currentCompanionId) {
currentCompanionId = newId
messages.clear()
adapter?.bindMessages(messages)
}
}
/**
* 发送用户消息到 AI 角色
* @return true 表示消息已处理面板打开且有角色false 表示不处理
*/
fun sendMessage(text: String): Boolean {
if (!isLoggedIn()) return false
if (text.isBlank() || currentCompanionId <= 0) return false
// 添加用户消息
val userMsg = ChatMessage(
id = nextId(),
text = text,
isMine = true,
timestamp = System.currentTimeMillis()
)
messages.add(userMsg)
adapter?.notifyLastInserted()
scrollToBottom()
// 添加占位消息
val placeholder = ChatMessage(
id = nextId(),
text = "...",
isMine = false,
timestamp = System.currentTimeMillis(),
isLoading = true,
hasAnimated = false
)
messages.add(placeholder)
adapter?.notifyLastInserted()
scrollToBottom()
// 请求 AI 回复
scope.launch {
val response = try {
withContext(Dispatchers.IO) {
RetrofitClient.apiService.chatMessage(
chatMessageRequest(content = text, companionId = currentCompanionId)
)
}
} catch (e: Exception) {
Log.e(TAG, "chatMessage failed: ${e.message}", e)
placeholder.text = context.getString(R.string.refresh_failed)
placeholder.isLoading = false
placeholder.hasAnimated = true
adapter?.notifyMessageUpdated(placeholder.id)
return@launch
}
val data = response.data
if (data == null) {
Log.e(TAG, "chatMessage no data, code=${response.code}")
placeholder.text = context.getString(R.string.refresh_failed)
placeholder.isLoading = false
placeholder.hasAnimated = true
adapter?.notifyMessageUpdated(placeholder.id)
return@launch
}
placeholder.text = data.aiResponse
placeholder.audioId = data.audioId
placeholder.isLoading = false
placeholder.hasAnimated = false
adapter?.notifyMessageUpdated(placeholder.id)
scrollToBottom()
// 轮询音频 URL
val audioUrl = fetchAudioUrl(data.audioId)
if (!audioUrl.isNullOrBlank()) {
placeholder.audioUrl = audioUrl
adapter?.notifyMessageUpdated(placeholder.id)
}
}
return true
}
private suspend fun fetchAudioUrl(audioId: String): String? {
if (audioId.isBlank()) return null
return withContext(Dispatchers.IO) {
try {
RetrofitClient.apiService.chatAudioStatus(audioId).data?.audioUrl
} catch (e: Exception) {
Log.e(TAG, "chatAudioStatus failed: ${e.message}", e)
null
}
}
}
private fun scrollToBottom() {
val rv = recyclerView ?: return
val count = adapter?.itemCount ?: return
if (count > 0) {
rv.post { rv.smoothScrollToPosition(count - 1) }
}
}
private fun nextId(): Long = messageIdCounter++
fun clearMessages() {
scope.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
adapter?.release()
messages.clear()
adapter = ChatMessageAdapter()
recyclerView?.adapter = adapter
adapter?.bindMessages(messages)
}
fun destroy() {
scope.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
adapter?.release()
adapter = null
recyclerView?.adapter = null
recyclerView = null
loginHint = null
messages.clear()
currentCompanionId = 0
}
}

View File

@@ -0,0 +1,52 @@
package com.example.myapplication.keyboard
import android.content.Context
/**
* 持久化最后停留的 AI 角色信息,供输入法服务跨进程读取
*/
object AiRolePreferences {
private const val PREFS_NAME = "ai_role_prefs"
private const val KEY_COMPANION_ID = "companion_id"
private const val KEY_PERSONA_NAME = "persona_name"
private const val KEY_AVATAR_URL = "avatar_url"
data class CompanionInfo(
val companionId: Int,
val personaName: String,
val avatarUrl: String?
)
fun saveLastCompanion(
context: Context,
companionId: Int,
personaName: String,
avatarUrl: String?
) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_MULTI_PROCESS)
.edit()
.putInt(KEY_COMPANION_ID, companionId)
.putString(KEY_PERSONA_NAME, personaName)
.putString(KEY_AVATAR_URL, avatarUrl)
.apply()
}
fun getLastCompanion(context: Context): CompanionInfo? {
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_MULTI_PROCESS)
val id = sp.getInt(KEY_COMPANION_ID, 0)
if (id <= 0) return null
return CompanionInfo(
companionId = id,
personaName = sp.getString(KEY_PERSONA_NAME, "") ?: "",
avatarUrl = sp.getString(KEY_AVATAR_URL, null)
)
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_MULTI_PROCESS)
.edit()
.clear()
.apply()
}
}

View File

@@ -5,6 +5,7 @@ import android.content.res.ColorStateList
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
@@ -118,6 +119,125 @@ abstract class BaseKeyboard(
dfs(root)
}
/**
* 为指定的行容器启用间隙触摸转发:
* 当触摸点落在子 View 之间的间隙时,找到最近的可点击子 View 并转发事件
*/
protected fun enableGapTouchForwarding(row: ViewGroup) {
var forwardTarget: View? = null
row.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// 查找触摸点命中的子 View如果已经命中了则不拦截
val touchX = event.x
val touchY = event.y
val hitChild = findChildAt(row, touchX, touchY)
if (hitChild != null) {
// 触摸直接落在按键上,走正常分发流程
forwardTarget = null
false
} else {
// 触摸落在间隙,找到最近的可点击子 View
val nearest = findNearestChild(row, touchX, touchY)
if (nearest != null) {
forwardTarget = nearest
// 将坐标转换到目标 View 的局部坐标系并转发
val forwarded = forwardEventToChild(row, nearest, event)
forwarded
} else {
false
}
}
}
MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> {
val target = forwardTarget
if (target != null) {
val result = forwardEventToChild(row, target, event)
if (event.actionMasked == MotionEvent.ACTION_UP ||
event.actionMasked == MotionEvent.ACTION_CANCEL) {
forwardTarget = null
}
result
} else {
false
}
}
else -> false
}
}
}
/** 判断触摸坐标是否直接落在某个子 View 上 */
private fun findChildAt(parent: ViewGroup, x: Float, y: Float): View? {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
if (child.visibility != View.VISIBLE) continue
if (x >= child.left && x <= child.right && y >= child.top && y <= child.bottom) {
return child
}
}
return null
}
/** 找到距离触摸点最近的可点击子 View */
private fun findNearestChild(parent: ViewGroup, x: Float, y: Float): View? {
var nearest: View? = null
var minDist = Float.MAX_VALUE
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
if (child.visibility != View.VISIBLE) continue
if (!child.isClickable && !child.hasOnClickListeners()) continue
// 计算触摸点到子 View 边界的最短距离
val cx = x.coerceIn(child.left.toFloat(), child.right.toFloat())
val cy = y.coerceIn(child.top.toFloat(), child.bottom.toFloat())
val dx = x - cx
val dy = y - cy
val dist = dx * dx + dy * dy
if (dist < minDist) {
minDist = dist
nearest = child
}
}
return nearest
}
/** 将触摸事件的坐标从父容器转换到目标子 View 并分发 */
private fun forwardEventToChild(parent: ViewGroup, child: View, event: MotionEvent): Boolean {
val offsetX = parent.scrollX - child.left
val offsetY = parent.scrollY - child.top
val childEvent = MotionEvent.obtain(event)
childEvent.offsetLocation(offsetX.toFloat(), offsetY.toFloat())
val handled = child.dispatchTouchEvent(childEvent)
childEvent.recycle()
return handled
}
/**
* 为根布局中所有包含按键的水平行容器启用间隙触摸转发。
* 排除控制栏和补全建议区(它们有独立的交互逻辑)。
*/
protected fun setupGapTouchForwardingForRows(root: View) {
if (root !is ViewGroup) return
val res = env.ctx.resources
val pkg = env.ctx.packageName
val excludedIds = setOf(
res.getIdentifier("control_layout", "id", pkg),
res.getIdentifier("completion_scroll", "id", pkg)
)
for (i in 0 until root.childCount) {
val child = root.getChildAt(i)
if (child is LinearLayout
&& child.orientation == LinearLayout.HORIZONTAL
&& child.id !in excludedIds
) {
enableGapTouchForwarding(child)
}
}
}
/** dp -> px */
protected fun Int.dpToPx(): Int {
val density = env.ctx.resources.displayMetrics.density

View File

@@ -0,0 +1,37 @@
package com.example.myapplication.keyboard
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
/**
* 固定最大高度的 FrameLayout。
*
* Android InputMethodService 的 CandidatesView 父容器mCandidatesFrame
* 使用 MeasureSpec.UNSPECIFIED 测量子 View导致子 View 声明的任何固定高度
* 都会被忽略RecyclerView 会展开为所有 item 的总高度。
*
* 本类在 onMeasure 中将 UNSPECIFIED/过大的高度钳制为 [maxHeightPx]
* 确保内部 RecyclerView 在固定区域内滚动。
*/
class FixedHeightFrameLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
/** 最大高度(像素),由外部设置 */
var maxHeightPx: Int = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val constrainedHeight = if (maxHeightPx > 0) {
MeasureSpec.makeMeasureSpec(maxHeightPx, MeasureSpec.AT_MOST)
} else {
heightMeasureSpec
}
super.onMeasure(widthMeasureSpec, constrainedHeight)
if (maxHeightPx > 0 && measuredHeight > maxHeightPx) {
setMeasuredDimension(measuredWidth, maxHeightPx)
}
}
}

View File

@@ -50,4 +50,7 @@ interface KeyboardEnvironment {
// 检查是否有可回填的文本
fun hasClearedText(): Boolean
// AI 角色面板
fun toggleAiRolePanel()
}

View File

@@ -170,6 +170,13 @@ class MainKeyboard(
view.findViewById<View?>(res.getIdentifier("key_emoji", "id", pkg))?.setOnClickListener {
vibrateKey(); env.showEmojiKeyboard()
}
view.findViewById<View?>(res.getIdentifier("key_airole", "id", pkg))?.setOnClickListener {
vibrateKey(); env.toggleAiRolePanel()
}
// 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键
setupGapTouchForwardingForRows(view)
}
// 更新Revoke按钮的可见性

View File

@@ -120,6 +120,9 @@ class NumberKeyboard(
numView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
?.setOnClickListener { vibrateKey(); env.showAiKeyboard() }
numView.findViewById<View?>(res.getIdentifier("key_airole", "id", pkg))
?.setOnClickListener { vibrateKey(); env.toggleAiRolePanel() }
numView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))
?.setOnClickListener {
vibrateKey(); env.commitKey(' ')
@@ -148,11 +151,14 @@ class NumberKeyboard(
?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() }
numView.findViewById<View?>(res.getIdentifier("key_revoke", "id", pkg))
?.setOnClickListener {
?.setOnClickListener {
vibrateKey(); env.revokeLastClearedText()
// 回填后更新按钮可见性
updateRevokeButtonVisibility(numView, res, pkg)
}
// 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键
setupGapTouchForwardingForRows(numView)
}
// 更新Revoke按钮的可见性

View File

@@ -140,6 +140,9 @@ class SymbolKeyboard(
symView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
?.setOnClickListener { vibrateKey(); env.showAiKeyboard() }
symView.findViewById<View?>(res.getIdentifier("key_airole", "id", pkg))
?.setOnClickListener { vibrateKey(); env.toggleAiRolePanel() }
symView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
?.setOnClickListener { vibrateKey(); env.hideKeyboard() }
@@ -147,11 +150,14 @@ class SymbolKeyboard(
?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() }
symView.findViewById<View?>(res.getIdentifier("key_revoke", "id", pkg))
?.setOnClickListener {
?.setOnClickListener {
vibrateKey(); env.revokeLastClearedText()
// 回填后更新按钮可见性
updateRevokeButtonVisibility(symView, res, pkg)
}
// 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键
setupGapTouchForwardingForRows(symView)
}
// 更新Revoke按钮的可见性

View File

@@ -32,6 +32,8 @@ private val NO_LOGIN_REQUIRED_PATHS = setOf(
"/user/detail",
"/character/listByTag",
"/character/list",
"/themes/listAllStyles",
"/ai-companion/page"
)
private val NO_SIGN_REQUIRED_PATHS = setOf(

View File

@@ -30,6 +30,7 @@ data class LoginResponse(
val emailVerified: Boolean,
val isVip: Boolean,
val vipExpiry: String,
val vipLevel: Int?,
val token: String
)
@@ -72,6 +73,7 @@ data class User(
val emailVerified: Boolean,
val isVip: Boolean,
val vipExpiry: String?,
val vipLevel: Int?,
val token: String?,
)

View File

@@ -85,6 +85,28 @@ class CircleChatRepository(
preloadRange(start, end, pageFetchSize, DEFAULT_CHAT_PAGE_SIZE)
}
// 清除加载失败的缓存页面companionId <= 0使后续 preloadAround 能重新加载
fun invalidateFailedPages() {
synchronized(lock) {
val snapshot = cache.snapshot()
for ((position, page) in snapshot) {
if (page.companionId <= 0) {
cache.remove(position)
}
}
}
}
// 检查缓存中是否存在加载失败的页面
fun hasFailedPages(): Boolean {
synchronized(lock) {
for ((_, page) in cache.snapshot()) {
if (page.companionId <= 0) return true
}
}
return false
}
fun preloadInitialPages() {
val maxPages = availablePages
if (maxPages <= 0) return

View File

@@ -27,13 +27,17 @@ import androidx.core.widget.doAfterTextChanged
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
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.chatMessageRequest
import com.example.myapplication.network.aiCompanionLikeRequest
import kotlinx.coroutines.Dispatchers
@@ -53,12 +57,15 @@ import androidx.core.content.ContextCompat
import android.widget.ImageView
import android.view.inputmethod.InputMethodManager
import com.example.myapplication.network.AiCompanion
import com.example.myapplication.keyboard.AiRolePreferences
import java.io.File
class CircleFragment : Fragment() {
private lateinit var pageRv: RecyclerView
private lateinit var inputOverlay: View
private lateinit var noResultOverlay: View
private lateinit var imeDismissOverlay: View
private lateinit var inputContainerText: View
private lateinit var inputContainerVoice: View
private lateinit var inputEdit: EditText
@@ -187,6 +194,8 @@ class CircleFragment : Fragment() {
pageRv = view.findViewById(R.id.pageRv)
inputOverlay = view.findViewById(R.id.inputOverlay)
noResultOverlay = view.findViewById(R.id.noResultOverlay)
imeDismissOverlay = view.findViewById(R.id.imeDismissOverlay)
inputContainerText = view.findViewById(R.id.inputContainerText)
inputContainerVoice = view.findViewById(R.id.inputContainerVoice)
inputEdit = view.findViewById(R.id.inputEdit)
@@ -213,6 +222,7 @@ class CircleFragment : Fragment() {
drawerMenuRv = view.findViewById(R.id.rvDrawerMenu)
searchEdit = view.findViewById(R.id.etCircleSearch)
searchIcon = view.findViewById(R.id.ivSearchIcon)
imeDismissOverlay.setOnClickListener { hideImeFromOverlay() }
setupDrawerMenu()
setupDrawerBlur()
if (drawerListener == null) {
@@ -224,6 +234,14 @@ class CircleFragment : Fragment() {
}
}
override fun onDrawerStateChanged(newState: Int) {
if (newState == DrawerLayout.STATE_DRAGGING || newState == DrawerLayout.STATE_SETTLING) {
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
hideImeFromDrawer()
}
}
}
override fun onDrawerClosed(drawerView: View) {
if (drawerView.id == R.id.circleSideDrawer) {
lockOverlayForSheet(false)
@@ -310,6 +328,7 @@ class CircleFragment : Fragment() {
repository.onTotalPagesChanged = { newTotal ->
pageRv.post {
pageAdapter.notifyDataSetChanged()
updateNoResultOverlay(newTotal)
if (newTotal <= 0) {
currentPage = RecyclerView.NO_POSITION
} else if (currentPage >= newTotal) {
@@ -325,6 +344,7 @@ class CircleFragment : Fragment() {
pageRv.setHasFixedSize(true)
pageRv.itemAnimator = null
pageRv.setItemViewCacheSize(computeViewCache(preloadCount))
updateNoResultOverlayFromFirstPage()
//设置滑动辅助器
snapHelper = PagerSnapHelper()
@@ -386,11 +406,37 @@ class CircleFragment : Fragment() {
false
}
}
// 监听网络恢复事件,重新加载数据
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
NetworkEventBus.events.collect { event ->
if (event is NetworkEvent.NetworkAvailable) {
loadDrawerMenuData()
retryFailedPages()
}
}
}
}
}
override fun onResume() {
super.onResume()
view?.post { requestOverlayUpdate() }
// 检查是否有加载失败的页面,如果有则重新加载
retryFailedPages()
}
// 清除失败缓存并重新加载
private fun retryFailedPages() {
if (!::repository.isInitialized) return
if (!repository.hasFailedPages()) return
repository.invalidateFailedPages()
if (currentPage != RecyclerView.NO_POSITION) {
repository.preloadAround(currentPage)
}
pageAdapter.notifyDataSetChanged()
updateNoResultOverlayFromFirstPage()
}
//清理和恢复输入框的高CircleFragment 在生命周期结束时的状态
@@ -439,6 +485,44 @@ class CircleFragment : Fragment() {
sendButton.visibility = if (visible) View.VISIBLE else View.GONE
}
private fun updateNoResultOverlay(pageCount: Int = repository.getAvailablePages()) {
if (!::noResultOverlay.isInitialized) return
setNoResultVisible(pageCount <= 0)
}
private fun updateNoResultOverlayFromFirstPage() {
if (!::repository.isInitialized) return
viewLifecycleOwner.lifecycleScope.launch {
val pageCount = repository.getAvailablePages()
if (pageCount <= 0) {
setNoResultVisible(true)
return@launch
}
val page = withContext(Dispatchers.IO) { repository.getPage(0) }
if (!isAdded) return@launch
setNoResultVisible(page.companionId <= 0)
}
}
private fun setNoResultVisible(visible: Boolean) {
if (!::noResultOverlay.isInitialized) return
noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
private fun hideImeFromOverlay() {
if (!isImeVisible) return
val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(inputEdit.windowToken, 0)
inputEdit.clearFocus()
setImeDismissOverlayVisible(false)
}
private fun hideImeFromDrawer() {
val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(searchEdit.windowToken, 0)
searchEdit.clearFocus()
}
private fun handleVoiceTouch(event: MotionEvent): Boolean {
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
@@ -906,6 +990,22 @@ class CircleFragment : Fragment() {
currentPage = position
CircleCommentSheet.clearCachedComments()
// 持久化当前角色信息到 SharedPreferences供输入法读取
val pageData = repository.getPage(position)
if (pageData.companionId > 0) {
AiRolePreferences.saveLastCompanion(
requireContext(), pageData.companionId,
pageData.personaName, pageData.avatarUrl
)
} else if (position < allCompanions.size) {
val comp = allCompanions[position]
if (comp.id > 0) {
AiRolePreferences.saveLastCompanion(
requireContext(), comp.id, comp.name, comp.avatarUrl
)
}
}
repository.preloadAround(position)
val oldHolder = pageRv.findViewHolderForAdapterPosition(oldPos) as? PageViewHolder
@@ -1014,6 +1114,7 @@ class CircleFragment : Fragment() {
val imeVisible = imeBottom > 0
isImeVisible = imeVisible
setImeDismissOverlayVisible(imeVisible)
bottomInset = if (imeVisible) 0 else systemBottom
inputOverlay.translationY = 0f
@@ -1039,7 +1140,9 @@ class CircleFragment : Fragment() {
blur.visibility = View.GONE
}
} else {
restoreBottomNavVisibility()
// 恢复时确保底栏可见,不依赖之前保存的状态(可能保存了 GONE
nav.visibility = View.VISIBLE
prevBottomNavVisibility = null
if (forceHideBottomNavBlur) {
blur?.visibility = View.GONE
}
@@ -1130,11 +1233,19 @@ class CircleFragment : Fragment() {
private fun setImeHandlingEnabled(enabled: Boolean) {
if (imeHandlingEnabled == enabled) return
imeHandlingEnabled = enabled
if (!enabled) {
setImeDismissOverlayVisible(false)
}
if (enabled) {
view?.let { ViewCompat.requestApplyInsets(it) }
}
}
private fun setImeDismissOverlayVisible(visible: Boolean) {
if (!::imeDismissOverlay.isInitialized) return
imeDismissOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
private fun lockOverlayForSheet(locked: Boolean) {
if (isOverlayLocked == locked) return
isOverlayLocked = locked
@@ -1245,6 +1356,13 @@ class CircleFragment : Fragment() {
// 同步当前选中状态
if (currentPage != RecyclerView.NO_POSITION && currentPage < allCompanions.size) {
drawerMenuAdapter.setSelectedPosition(currentPage)
// 初始加载完成后,持久化当前页角色信息供输入法读取
val comp = allCompanions[currentPage]
if (comp.id > 0) {
AiRolePreferences.saveLastCompanion(
requireContext(), comp.id, comp.name, comp.avatarUrl
)
}
}
}
}
@@ -1303,19 +1421,23 @@ class CircleFragment : Fragment() {
// 更新菜单选中状态
drawerMenuAdapter.setSelectedPosition(targetPosition)
// 关闭抽屉
drawerLayout.closeDrawer(GravityCompat.START)
// 隐藏键盘
val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(searchEdit.windowToken, 0)
searchEdit.clearFocus()
drawerLayout.closeDrawer(GravityCompat.START)
// 滚动到目标页面
pageRv.scrollToPosition(targetPosition)
currentPage = targetPosition
// 持久化当前角色信息到 SharedPreferences供输入法读取
AiRolePreferences.saveLastCompanion(
requireContext(), companion.id,
companion.name, companion.avatarUrl
)
// 预加载前后页面
repository.preloadAround(targetPosition)
}

View File

@@ -27,6 +27,7 @@ import androidx.viewpager2.widget.ViewPager2
import com.example.myapplication.ImeGuideActivity
import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.R
import com.example.myapplication.utils.ImeUtils
import com.example.myapplication.network.AddPersonaClick
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
@@ -46,6 +47,11 @@ import kotlin.math.abs
class HomeFragment : Fragment() {
companion object {
// 本次进程生命周期内是否已弹过输入法引导,进程重启后自动重置
private var hasPromptedImeGuide = false
}
private lateinit var bottomSheet: MaterialCardView
private lateinit var bottomSheetBehavior: BottomSheetBehavior<MaterialCardView>
private lateinit var scrim: View
@@ -56,6 +62,7 @@ class HomeFragment : Fragment() {
private lateinit var tabList1: TextView
private lateinit var tabList2: TextView
private lateinit var backgroundImage: ImageView
private lateinit var noResultOverlay: View
private var lastList1RenderKey: String? = null
private lateinit var loadingOverlay: LoadingOverlay
@@ -63,6 +70,7 @@ class HomeFragment : Fragment() {
private var networkRefreshJob: Job? = null
private var allPersonaCache: List<listByTagWithNotLogin> = emptyList()
private val personaCache = mutableMapOf<Int, List<listByTagWithNotLogin>>()
private var list1Loaded = false
private var parentWidth = 0
private var parentHeight = 0
@@ -80,6 +88,11 @@ class HomeFragment : Fragment() {
private var sheetAdapter: SheetPagerAdapter? = null
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
override fun onResume() {
super.onResume()
checkAndPromptImeGuide()
}
override fun onDestroyView() {
preloadJob?.cancel()
networkRefreshJob?.cancel()
@@ -109,11 +122,15 @@ class HomeFragment : Fragment() {
personaCache.clear()
allPersonaCache = emptyList()
lastList1RenderKey = null
list1Loaded = false
setNoResultVisible(false)
// 2) 重新拉列表1登录态接口会变
viewLifecycleOwner.lifecycleScope.launch {
allPersonaCache = fetchAllPersonaList()
list1Loaded = true
notifyPageChangedOnMain(0)
updateNoResultOverlay(0)
}
// 3) 如果当前在某个 tag 页,也建议重新拉当前页数据
@@ -124,6 +141,7 @@ class HomeFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
personaCache[tagId] = fetchPersonaByTag(tagId)
notifyPageChangedOnMain(pos)
updateNoResultOverlay(pos)
}
}
}
@@ -141,8 +159,10 @@ class HomeFragment : Fragment() {
try {
// 1) 列表一:重新拉
allPersonaCache = fetchAllPersonaList()
list1Loaded = true
lastList1RenderKey = null
notifyPageChangedOnMain(0)
updateNoResultOverlay(0)
// 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”)
personaCache.clear()
@@ -154,11 +174,12 @@ class HomeFragment : Fragment() {
if (tagId != null) {
// 先刷新一次,让页面进入 loading因为缓存被清了
notifyPageChangedOnMain(pos)
updateNoResultOverlay(pos)
// 再拉当前 tag 的新数据
val list = fetchPersonaByTag(tagId)
personaCache[tagId] = list
notifyPageChangedOnMain(pos)
updateNoResultOverlay(pos)
}
}
@@ -176,8 +197,10 @@ class HomeFragment : Fragment() {
try {
// 1) 列表一:重新拉
allPersonaCache = fetchAllPersonaList()
list1Loaded = true
lastList1RenderKey = null
notifyPageChangedOnMain(0)
updateNoResultOverlay(0)
// 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”)
personaCache.clear()
@@ -189,11 +212,12 @@ class HomeFragment : Fragment() {
if (tagId != null) {
// 先刷新一次,让页面进入 loading因为缓存被清了
notifyPageChangedOnMain(pos)
updateNoResultOverlay(pos)
// 再拉当前 tag 的新数据
val list = fetchPersonaByTag(tagId)
personaCache[tagId] = list
notifyPageChangedOnMain(pos)
updateNoResultOverlay(pos)
}
}
@@ -242,6 +266,7 @@ class HomeFragment : Fragment() {
viewPager.isSaveEnabled = false
viewPager.offscreenPageLimit = 2
backgroundImage = bottomSheet.findViewById(R.id.backgroundImage)
noResultOverlay = bottomSheet.findViewById(R.id.noResultOverlay)
val root = view.findViewById<CoordinatorLayout>(R.id.rootCoordinator)
val floatingImage = view.findViewById<ImageView>(R.id.floatingImage)
@@ -282,14 +307,18 @@ class HomeFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
list1Loaded = false
setNoResultVisible(false)
val list = fetchAllPersonaList()
if (!isAdded) return@launch
allPersonaCache = list
list1Loaded = true
// ✅ 关键:数据变了就清 renderKey允许重建一次 UI
lastList1RenderKey = null
notifyPageChangedOnMain(0)
updateNoResultOverlay(0)
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "获取列表一失败", e)
} finally {
@@ -340,6 +369,7 @@ class HomeFragment : Fragment() {
override fun onPageSelected(position: Int) {
if (!isAdded) return
updateTabsAndTags(position)
updateNoResultOverlay(position)
// ✅ 修复当切换到标签页且缓存已有数据时强制刷新UI
if (position > 0) {
@@ -362,6 +392,27 @@ class HomeFragment : Fragment() {
}
}
private fun updateNoResultOverlay(position: Int = viewPager.currentItem) {
if (!::noResultOverlay.isInitialized) return
if (!::viewPager.isInitialized) return
if (position != viewPager.currentItem) return
val show = when (position) {
0 -> list1Loaded && allPersonaCache.isEmpty()
else -> {
val tagId = tags.getOrNull(position - 1)?.id ?: return setNoResultVisible(false)
val cached = personaCache[tagId] ?: return setNoResultVisible(false)
cached.isEmpty()
}
}
setNoResultVisible(show)
}
private fun setNoResultVisible(visible: Boolean) {
if (!::noResultOverlay.isInitialized) return
if (!::noResultOverlay.isInitialized) return
noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
// ---------------- 方案A成功后“造新数据(copy)替换缓存”并刷新 ----------------
private fun applyAddedToggle(personaId: Int, newAdded: Boolean) {
@@ -378,6 +429,7 @@ class HomeFragment : Fragment() {
// renderList1 有 renderKey必须清一下
lastList1RenderKey = null
notifyPageChangedOnMain(0)
updateNoResultOverlay(0)
}
}
@@ -405,6 +457,7 @@ class HomeFragment : Fragment() {
if (changedCurrentTagPage) {
notifyPageChangedOnMain(viewPager.currentItem)
updateNoResultOverlay(viewPager.currentItem)
}
}
@@ -660,12 +713,16 @@ class HomeFragment : Fragment() {
preloadJob?.cancel()
loadingOverlay.show()
try {
list1Loaded = false
setNoResultVisible(false)
val list = fetchAllPersonaList()
if (!isAdded) return@launch
allPersonaCache = list
list1Loaded = true
lastList1RenderKey = null
personaCache.clear()
notifyPageChangedOnMain(0)
updateNoResultOverlay(0)
val response = RetrofitClient.apiService.tagList()
if (!isAdded) return@launch
@@ -709,6 +766,7 @@ class HomeFragment : Fragment() {
val thisPos = 1 + idx
if (idx >= 0 && viewPager.currentItem == thisPos) {
notifyPageChangedOnMain(thisPos)
updateNoResultOverlay(thisPos)
}
} catch (e: Exception) {
@@ -1092,6 +1150,18 @@ class HomeFragment : Fragment() {
return EncryptedSharedPreferencesUtil.contains(ctx, "user")
}
/**
* 检查输入法是否已激活并选中,未就绪则跳转引导页。
* 本次 App 进程内只弹一次,用户返回后不再弹,下次启动 App 再检查。
*/
private fun checkAndPromptImeGuide() {
if (hasPromptedImeGuide) return
val ctx = context ?: return
if (ImeUtils.isImeReady(ctx)) return
hasPromptedImeGuide = true
startActivity(Intent(ctx, ImeGuideActivity::class.java))
}
// ✅ 统一安全导航stateSaved 防护(切很快/后台回来时很重要)
private fun safeNavigate(actionId: Int) {
if (!isAdded) return

View File

@@ -32,6 +32,7 @@ class MyKeyboard : Fragment() {
private lateinit var adapter: KeyboardAdapter
private lateinit var loadingOverlay: LoadingOverlay
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var noResultOverlay: View
override fun onCreateView(
inflater: LayoutInflater,
@@ -49,6 +50,7 @@ class MyKeyboard : Fragment() {
rv = view.findViewById(R.id.rv_keyboard)
btnSave = view.findViewById(R.id.btn_keyboard)
swipeRefresh = view.findViewById(R.id.swipeRefresh)
noResultOverlay = view.findViewById(R.id.noResultOverlay)
swipeRefresh.setOnRefreshListener { loadList() }
adapter = KeyboardAdapter(
@@ -122,18 +124,34 @@ class MyKeyboard : Fragment() {
val resp = getlistByUser()
if (resp?.code == 0 && resp.data != null) {
adapter.submitList(resp.data)
updateNoResultOverlay(resp.data.size)
Log.d("1314520-list", resp.data.toString())
} else {
if (adapter.itemCount == 0) updateNoResultOverlay(0)
Toast.makeText(requireContext(), resp?.message ?: getString(R.string.Pop_up_window_my_keyboard_3), Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.d("MyKeyboard-loadList", e.message.toString())
if (adapter.itemCount == 0) updateNoResultOverlay(0)
} finally {
swipeRefresh.isRefreshing = false
}
}
}
private fun updateNoResultOverlay(count: Int = adapter.itemCount) {
if (!::noResultOverlay.isInitialized) return
setNoResultVisible(count <= 0)
}
private fun setNoResultVisible(visible: Boolean) {
if (!::noResultOverlay.isInitialized) return
noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE
if (::btnSave.isInitialized) {
btnSave.visibility = if (visible) View.GONE else View.VISIBLE
}
}
// 获取用户人设列表
private suspend fun getlistByUser(): ApiResponse<List<ListByUserWithNot>>? =
runCatching { RetrofitClient.apiService.listByUser() }.getOrNull()

View File

@@ -6,6 +6,7 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageView
import androidx.fragment.app.Fragment
@@ -91,6 +92,25 @@ class LoginFragment : Fragment() {
emailEditText.setText(email)
}
// 邮箱输入框:禁止换行,回车跳转到密码框
emailEditText.setSingleLine(true)
emailEditText.imeOptions = EditorInfo.IME_ACTION_NEXT
emailEditText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
passwordEditText.requestFocus()
true
} else false
}
// 密码输入框:回车直接触发登录
passwordEditText.imeOptions = EditorInfo.IME_ACTION_GO
passwordEditText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
loginButton.performClick()
true
} else false
}
// 初始是隐藏密码状态
passwordEditText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
@@ -117,7 +137,7 @@ class LoginFragment : Fragment() {
// // 登录按钮逻辑
loginButton.setOnClickListener {
val pwd = passwordEditText.text?.toString().orEmpty()
val email = emailEditText.text?.toString().orEmpty()
val email = emailEditText.text?.toString().orEmpty().trim()
if (pwd.isEmpty() || email.isEmpty()) {
// 输入框不能为空
Toast.makeText(requireContext(), getString(R.string.Pop_up_window_LoginFragment_1), Toast.LENGTH_SHORT).show()

View File

@@ -3,6 +3,7 @@ package com.example.myapplication.ui.mine
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@@ -41,6 +42,7 @@ import com.example.myapplication.network.BehaviorReporter
class MineFragment : Fragment() {
private lateinit var nickname: TextView
private lateinit var vipIcon: ImageView
private lateinit var time: TextView
private lateinit var logout: TextView
private lateinit var avatar: CircleImageView
@@ -69,6 +71,7 @@ class MineFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
nickname = view.findViewById(R.id.nickname)
vipIcon = view.findViewById(R.id.vip_icon)
time = view.findViewById(R.id.time)
logout = view.findViewById(R.id.logout)
avatar = view.findViewById(R.id.avatar)
@@ -82,17 +85,25 @@ class MineFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val response = getinviteCode()
response?.data?.h5Link?.let { link ->
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("h5Link", link)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, getString(R.string.copy_invite_link_success), Toast.LENGTH_LONG).show()
val response = getinviteCode()
response?.data?.h5Link?.let { link ->
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my",
"element_id" to "invite_copy",
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, link)
}
val chooser = Intent.createChooser(shareIntent, null)
if (chooser.resolveActivity(requireContext().packageManager) != null) {
startActivity(chooser)
} else {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("h5Link", link))
Toast.makeText(context, getString(R.string.copy_invite_link_success), Toast.LENGTH_LONG).show()
}
}
} finally {
loadingOverlay.hide()
@@ -216,6 +227,7 @@ class MineFragment : Fragment() {
)
nickname.text = cached?.nickName ?: ""
time.text = cached?.vipExpiry?.let { getString(R.string.mine_vip_due_on, it) } ?: ""
renderVip(cached?.isVip, cached?.vipLevel)
cached?.avatarUrl?.let { url ->
Glide.with(requireContext())
.load(url)
@@ -247,6 +259,8 @@ class MineFragment : Fragment() {
time.text = u?.vipExpiry?.let { getString(R.string.mine_vip_due_on, it) } ?: ""
renderVip(u?.isVip, u?.vipLevel)
u?.avatarUrl?.let { url ->
Glide.with(requireContext())
.load(url)
@@ -277,6 +291,7 @@ class MineFragment : Fragment() {
// 清空 UI
nickname.text = ""
time.text = ""
renderVip(false, null)
Glide.with(requireContext())
.load(R.drawable.default_avatar)
.into(avatar)
@@ -292,6 +307,22 @@ class MineFragment : Fragment() {
}
}
private fun renderVip(isVip: Boolean?, vipLevel: Int?) {
val show = isVip == true && (vipLevel == 1 || vipLevel == 2)
if (!show) {
vipIcon.visibility = View.GONE
return
}
vipIcon.visibility = View.VISIBLE
val iconRes = if (vipLevel == 2) {
R.drawable.mine_svip_icon
} else {
R.drawable.mine_vip_icon
}
vipIcon.setImageResource(iconRes)
}
private fun isLoggedIn(): Boolean {
val ctx = context ?: return false
return EncryptedSharedPreferencesUtil.contains(ctx, "user")

View File

@@ -4,7 +4,6 @@ import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
@@ -12,29 +11,17 @@ import com.example.myapplication.R
import com.example.myapplication.network.TransactionRecord
class TransactionAdapter(
private val data: MutableList<TransactionRecord>,
private val onCloseClick: () -> Unit,
private val onRechargeClick: () -> Unit
private val data: MutableList<TransactionRecord>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ITEM = 1
private const val TYPE_FOOTER = 2
}
// Header: balance
private var headerBalanceText: String = "0.00"
// Footer state
private var showFooter: Boolean = false
private var footerNoMore: Boolean = false
fun updateHeaderBalance(text: Any?) {
headerBalanceText = (text ?: "0.00").toString()
notifyItemChanged(0)
}
fun setFooterLoading() {
showFooter = true
footerNoMore = false
@@ -61,19 +48,17 @@ class TransactionAdapter(
fun append(list: List<TransactionRecord>) {
if (list.isEmpty()) return
val start = 1 + data.size // header占1
val start = data.size
data.addAll(list)
notifyItemRangeInserted(start, list.size)
}
override fun getItemCount(): Int {
// header + items + optional footer
return 1 + data.size + if (showFooter) 1 else 0
return data.size + if (showFooter) 1 else 0
}
override fun getItemViewType(position: Int): Int {
return when {
position == 0 -> TYPE_HEADER
showFooter && position == itemCount - 1 -> TYPE_FOOTER
else -> TYPE_ITEM
}
@@ -82,10 +67,6 @@ class TransactionAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> {
val v = inflater.inflate(R.layout.layout_consumption_record_header, parent, false)
HeaderVH(v, onCloseClick, onRechargeClick)
}
TYPE_FOOTER -> {
val v = inflater.inflate(R.layout.item_loading_footer, parent, false)
FooterVH(v)
@@ -99,41 +80,8 @@ class TransactionAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind(headerBalanceText)
is FooterVH -> holder.bind(footerNoMore)
is ItemVH -> holder.bind(data[position - 1]) // position-1 because header
}
}
class HeaderVH(
itemView: View,
onCloseClick: () -> Unit,
onRechargeClick: () -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val balance: TextView = itemView.findViewById(R.id.balance)
init {
itemView.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener { onCloseClick() }
itemView.findViewById<View>(R.id.rechargeButton).setOnClickListener { onRechargeClick() }
}
fun bind(balanceText: String) {
balance.text = balanceText
adjustBalanceTextSize(balance, balanceText)
}
private fun adjustBalanceTextSize(tv: TextView, text: String) {
tv.textSize = when (text.length) {
in 0..3 -> 40f
4 -> 36f
5 -> 32f
6 -> 28f
7 -> 24f
8 -> 22f
9 -> 20f
else -> 16f
}
is ItemVH -> holder.bind(data[position])
}
}
@@ -146,12 +94,11 @@ class TransactionAdapter(
tvTime.text = item.createdAt
tvDesc.text = item.description
tvAmount.text = "${item.amount}"
// 根据type设置字体颜色
val color = when (item.type) {
1 -> Color.parseColor("#CD2853") // 收入 - 红色
2 -> Color.parseColor("#66CD7C") // 支出 - 绿色
else -> tvAmount.currentTextColor // 保持当前颜色
1 -> Color.parseColor("#CD2853")
2 -> Color.parseColor("#66CD7C")
else -> tvAmount.currentTextColor
}
tvAmount.setTextColor(color)
}

View File

@@ -6,6 +6,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
@@ -28,6 +30,8 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var rv: RecyclerView
private lateinit var adapter: TransactionAdapter
private lateinit var noResultOverlay: View
private lateinit var balance: TextView
private val listData = arrayListOf<TransactionRecord>()
@@ -49,6 +53,12 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
swipeRefresh = view.findViewById(R.id.swipeRefresh)
rv = view.findViewById(R.id.rvTransactions)
noResultOverlay = view.findViewById(R.id.noResultOverlay)
balance = view.findViewById(R.id.balance)
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener { closeByNav() }
view.findViewById<View>(R.id.rechargeButton).setOnClickListener {
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment))
}
setupRecycler()
setupRefresh()
@@ -83,11 +93,7 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
private fun setupRecycler() {
adapter = TransactionAdapter(
data = listData,
onCloseClick = { closeByNav() }, // ✅ 改这里:不要 dismiss()
onRechargeClick = {
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment))
}
data = listData
)
rv.layoutManager = LinearLayoutManager(requireContext())
@@ -122,12 +128,14 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
totalPages = Int.MAX_VALUE
isLoading = false
val hadData = listData.isNotEmpty()
adapter.hideFooter()
adapter.replaceAll(emptyList())
if (hadData) setNoResultVisible(false)
val walletResp = getwalletBalance()
val balanceText = walletResp?.data?.balanceDisplay ?: "0.00"
adapter.updateHeaderBalance(balanceText)
updateHeaderBalance(balanceText)
loadPage(targetPage = 1, isRefresh = true)
@@ -156,6 +164,7 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
val records = data.records
if (isRefresh) adapter.replaceAll(records) else adapter.append(records)
updateNoResultOverlay(records.isEmpty() && listData.isEmpty())
if (pageNum >= totalPages) adapter.setFooterNoMore() else adapter.hideFooter()
@@ -167,6 +176,7 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
}
} else {
adapter.hideFooter()
if (listData.isEmpty()) updateNoResultOverlay(true)
}
isLoading = false
@@ -178,4 +188,34 @@ class ConsumptionRecordFragment : BottomSheetDialogFragment() {
private suspend fun gettransactions(body: transactionsRequest): ApiResponse<transactionsResponse>? =
runCatching { RetrofitClient.apiService.transactions(body) }.getOrNull()
private fun updateHeaderBalance(text: Any?) {
val value = (text ?: "0.00").toString()
balance.text = value
adjustBalanceTextSize(balance, value)
}
private fun adjustBalanceTextSize(tv: TextView, text: String) {
tv.textSize = when (text.length) {
in 0..3 -> 40f
4 -> 36f
5 -> 32f
6 -> 28f
7 -> 24f
8 -> 22f
9 -> 20f
else -> 16f
}
}
private fun updateNoResultOverlay(show: Boolean) {
if (!::noResultOverlay.isInitialized) return
setNoResultVisible(show)
}
private fun setNoResultVisible(visible: Boolean) {
if (!::noResultOverlay.isInitialized) return
noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
}

View File

@@ -53,6 +53,7 @@ class PersonalSettings : BottomSheetDialogFragment() {
private lateinit var tvUserId: TextView
private lateinit var loadingOverlay: LoadingOverlay
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var noResultOverlay: View
/**
* ✅ Android Photo Picker
@@ -87,6 +88,7 @@ class PersonalSettings : BottomSheetDialogFragment() {
loadingOverlay = LoadingOverlay.attach(requireView() as ViewGroup)
swipeRefresh = view.findViewById(R.id.swipeRefresh)
swipeRefresh.setOnRefreshListener { loadUser() }
noResultOverlay = view.findViewById(R.id.noResultOverlay)
// 关闭
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
@@ -186,14 +188,17 @@ class PersonalSettings : BottomSheetDialogFragment() {
viewLifecycleOwner.lifecycleScope.launch {
swipeRefresh.isRefreshing = true
try {
if (user != null) setNoResultVisible(false)
val resp = getUserdata()
val u = resp?.data // ???????????? ApiResponse ???????????? data??????????????????????????????
if (u == null) {
Toast.makeText(requireContext(), getString(R.string.Pop_up_window_my_keyboard_3), Toast.LENGTH_SHORT).show()
setNoResultVisible(true)
return@launch
}
user = u
renderUser(u)
setNoResultVisible(false)
} finally {
swipeRefresh.isRefreshing = false
}
@@ -417,4 +422,9 @@ class PersonalSettings : BottomSheetDialogFragment() {
loadingOverlay.remove()
super.onDestroyView()
}
private fun setNoResultVisible(visible: Boolean) {
if (!::noResultOverlay.isInitialized) return
noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
}

View File

@@ -27,6 +27,7 @@ import com.example.myapplication.network.*
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import com.example.myapplication.network.BehaviorReporter
@@ -42,10 +43,12 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
private lateinit var balance: TextView
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var shopTitle: TextView
private lateinit var noResultOverlay: View
// ===== Data =====
private var tabTitles: List<Theme> = emptyList()
private var styleIds: List<Int> = emptyList()
private var themeListLoaded = false
// ===== ViewModel =====
private lateinit var vm: ShopViewModel
@@ -72,6 +75,14 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.styleData.collect {
updateNoResultOverlay()
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
ShopEventBus.events.collect { event ->
@@ -142,16 +153,24 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
updateBalance(getwalletBalance())
// 主题
themeListLoaded = false
setNoResultVisible(false)
val themeResp = getThemeList()
tabTitles = themeResp?.data ?: emptyList()
styleIds = tabTitles.map { it.id }
themeListLoaded = true
// Fragment 可能在语言切换重建时被销毁/未附着,避免在未附着状态下创建子 Fragment
if (!isAdded) return@launch
setupViewPagerOnce()
setupTagsOnce()
updateNoResultOverlay(0)
styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) }
}
}
@@ -168,20 +187,31 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
try {
updateBalance(getwalletBalance())
themeListLoaded = false
setNoResultVisible(false)
val newThemes = getThemeList()?.data ?: emptyList()
if (!isAdded) return@launch
if (newThemes != tabTitles) {
tabTitles = newThemes
styleIds = tabTitles.map { it.id }
themeListLoaded = true
setupViewPagerOnce(force = true)
setupTagsOnce(force = true)
updateNoResultOverlay(0)
vm.clearCache()
styleIds.forEach { vm.forceLoadStyle(it) }
} else {
themeListLoaded = true
styleIds.getOrNull(viewPager.currentItem)
?.let { vm.forceLoadStyle(it) }
?.let { vm.forceLoadStyle(it) }
updateNoResultOverlay(viewPager.currentItem)
}
} catch (e: Exception) {
Log.e("ShopFragment", "refresh error", e)
@@ -207,6 +237,7 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
override fun onPageSelected(position: Int) {
updateTagState(position)
styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) }
updateNoResultOverlay(position)
}
}
viewPager.registerOnPageChangeCallback(pageCallback!!)
@@ -268,6 +299,34 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
}
}
private fun updateNoResultOverlay(position: Int = viewPager.currentItem) {
if (!::noResultOverlay.isInitialized) return
if (!::viewPager.isInitialized) return
if (!themeListLoaded) {
setNoResultVisible(false)
return
}
if (styleIds.isEmpty()) {
setNoResultVisible(true)
return
}
val styleId = styleIds.getOrNull(position) ?: run {
setNoResultVisible(false)
return
}
val cached = vm.styleData.value[styleId] ?: run {
setNoResultVisible(false)
return
}
setNoResultVisible(cached.isEmpty())
}
private fun setNoResultVisible(visible: Boolean) {
if (!::noResultOverlay.isInitialized) return
noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE
}
// ========================== UI helpers ==========================
private fun updateBalance(walletResp: ApiResponse<Wallet>?) {
@@ -427,6 +486,7 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
tagContainer = view.findViewById(R.id.tagContainer)
balance = view.findViewById(R.id.balance)
swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout)
noResultOverlay = view.findViewById(R.id.noResultOverlay)
swipeRefreshLayout.isEnabled = true
swipeRefreshLayout.setColorSchemeColors(

View File

@@ -27,6 +27,9 @@ class MySkin : Fragment() {
private lateinit var adapter: MySkinAdapter
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var noResultOverlay: View
private lateinit var tvEditor: TextView
private lateinit var tvTitle: TextView
override fun onCreateView(
inflater: LayoutInflater,
@@ -39,11 +42,13 @@ class MySkin : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val tvEditor = view.findViewById<TextView>(R.id.tvEditor)
tvEditor = view.findViewById<TextView>(R.id.tvEditor)
tvTitle = view.findViewById<TextView>(R.id.tvTitle)
val bottomBar = view.findViewById<View>(R.id.bottomEditBar)
val tvSelectedCount = view.findViewById<TextView>(R.id.tvSelectedCount)
val btnDelete = view.findViewById<TextView>(R.id.btnDelete)
swipeRefreshLayout = view.findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
noResultOverlay = view.findViewById(R.id.noResultOverlay)
// 设置下拉刷新监听器
swipeRefreshLayout.setOnRefreshListener {
@@ -141,6 +146,7 @@ class MySkin : Fragment() {
adapter.exitEditMode()
tvEditor.text = "Editor"
hideBottomBar()
updateNoResultOverlay()
}
}
}
@@ -153,6 +159,7 @@ class MySkin : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val resp = getPurchasedThemeList()
adapter.submitList(resp?.data ?: emptyList())
updateNoResultOverlay()
}
}
@@ -161,9 +168,11 @@ class MySkin : Fragment() {
try {
val resp = getPurchasedThemeList()
adapter.submitList(resp?.data ?: emptyList())
updateNoResultOverlay()
Log.d("1314520-MySkin", "下拉刷新完成")
} catch (e: Exception) {
Log.e("1314520-MySkin", "下拉刷新失败", e)
updateNoResultOverlay()
} finally {
// 停止刷新动画
swipeRefreshLayout.isRefreshing = false
@@ -171,7 +180,29 @@ class MySkin : Fragment() {
}
}
private suspend fun getPurchasedThemeList(): ApiResponse<List<themeStyle>>? {
private fun updateNoResultOverlay() {
if (!::noResultOverlay.isInitialized) return
val empty = adapter.itemCount <= 0
noResultOverlay.visibility = if (empty) View.VISIBLE else View.GONE
if (::tvEditor.isInitialized) {
tvEditor.visibility = if (empty) View.GONE else View.VISIBLE
}
if (::tvTitle.isInitialized) {
val endMargin = if (empty) {
resources.getDimensionPixelSize(R.dimen.sw_49dp)
} else {
0
}
val params = tvTitle.layoutParams as? ViewGroup.MarginLayoutParams
if (params != null && params.marginEnd != endMargin) {
params.marginEnd = endMargin
tvTitle.layoutParams = params
}
}
}
private suspend fun getPurchasedThemeList(): ApiResponse<List<themeStyle>>? {
return try { RetrofitClient.apiService.purchasedThemeList() }
catch (e: Exception) { Log.e("MySkin", "获取已购买主题失败", e); null }
}

View File

@@ -0,0 +1,58 @@
package com.example.myapplication.utils
import android.content.ComponentName
import android.content.Context
import android.provider.Settings
import android.util.Log
import android.view.inputmethod.InputMethodManager
import com.example.myapplication.MyInputMethodService
/**
* 输入法状态检查工具
*/
object ImeUtils {
private const val TAG = "ImeUtils"
/** 本应用输入法是否已在系统中启用 */
fun isImeEnabled(context: Context): Boolean {
return try {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val myComponent = ComponentName(context, MyInputMethodService::class.java)
imm.enabledInputMethodList.any { imeInfo ->
imeInfo.packageName == myComponent.packageName &&
imeInfo.serviceName == myComponent.className
}
} catch (e: Exception) {
Log.e(TAG, "isImeEnabled 出错", e)
false
}
}
/** 本应用输入法是否已被选为当前默认输入法 */
fun isImeSelected(context: Context): Boolean {
return try {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val myComponent = ComponentName(context, MyInputMethodService::class.java)
val currentImeId = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.DEFAULT_INPUT_METHOD
) ?: return false
val currentImeInfo = imm.enabledInputMethodList.firstOrNull { imeInfo ->
imeInfo.id == currentImeId
} ?: return false
currentImeInfo.packageName == myComponent.packageName &&
currentImeInfo.serviceName == myComponent.className
} catch (e: Exception) {
Log.e(TAG, "isImeSelected 出错", e)
false
}
}
/** 输入法是否已完全就绪(启用且选中) */
fun isImeReady(context: Context): Boolean {
return isImeEnabled(context) && isImeSelected(context)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="@dimen/sw_24dp"
android:height="@dimen/sw_24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#1B1F1A"
android:pathData="M11.99 2c-5.52 0-10 4.48-10 10s4.48 10 10 10 10-4.48 10-10-4.48-10-10-10zm6.93 6h-2.95c-.19-1.26-.54-2.45-1.03-3.5 1.84.74 3.27 2.27 3.98 4.1zm-6.93-4.9c.86 1.25 1.45 2.7 1.73 4.28h-3.46c.28-1.57.87-3.02 1.73-4.28zm-7.46 9.4c-.2-.63-.33-1.3-.33-2 0-.7.12-1.37.33-2h2.71c-.06.66-.1 1.32-.1 2 0 .68.04 1.34.1 2h-2.71zm.53 2h2.95c.19 1.26.54 2.45 1.03 3.5-1.84-.74-3.27-2.27-3.98-4.1zm2.95-6h-2.95c.71-1.83 2.14-3.36 3.98-4.1-.49 1.05-.84 2.24-1.03 3.5zm4.98 9.9c-.86-1.25-1.45-2.7-1.73-4.28h3.46c-.28 1.57-.87 3.02-1.73 4.28zm2.1-6.28h-4.2c-.07-.66-.11-1.32-.11-2 0-.68.04-1.34.11-2h4.19c.07.66.11 1.32.11 2 0 .68-.04 1.34-.1 2zm.26 2h2.95c-.71 1.83-2.14 3.36-3.98 4.1.49-1.05.84-2.24 1.03-3.5zm0-4h2.95c.2.63.33 1.3.33 2 0 .7-.12 1.37-.33 2h-2.95c.06-.66.1-1.32.1-2 0-.68-.04-1.34-.1-2z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -39,7 +39,7 @@
<TextView
android:id="@+id/stepTips"
android:textSize="@dimen/sw_4sp"
android:textSize="@dimen/sw_14sp"
android:layout_width="@dimen/sw_175dp"
android:layout_marginTop="@dimen/sw_18dp"
android:textColor="#A1A1A1"

View File

@@ -23,6 +23,16 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/key_airole"
android:layout_width="@dimen/sw_34dp"
android:layout_height="@dimen/sw_34dp"
android:layout_marginStart="@dimen/sw_10dp"
android:clickable="true"
app:layout_constraintStart_toEndOf="@id/key_abc"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/key_vip"
android:layout_width="@dimen/sw_115dp"

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<com.example.myapplication.keyboard.FixedHeightFrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/airole_panel_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true">
<!-- 消息列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/airole_panel_messages"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="@dimen/sw_8dp"
android:paddingBottom="@dimen/sw_8dp"
android:overScrollMode="always" />
<!-- 未登录提示 -->
<TextView
android:id="@+id/airole_panel_login_hint"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_200dp"
android:gravity="center"
android:text="@string/Pop_up_window_ai_1"
android:textColor="#999999"
android:textSize="@dimen/sw_14sp"
android:visibility="gone" />
<!-- 关闭按钮(右上角,叠在消息列表上方) -->
<ImageView
android:id="@+id/airole_panel_close"
android:layout_width="@dimen/sw_30dp"
android:layout_height="@dimen/sw_30dp"
android:layout_gravity="top|end"
android:layout_marginTop="@dimen/sw_8dp"
android:layout_marginEnd="@dimen/sw_8dp"
android:padding="@dimen/sw_4dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="close"
android:clickable="true"
android:focusable="true" />
</com.example.myapplication.keyboard.FixedHeightFrameLayout>

View File

@@ -124,7 +124,7 @@
android:gravity="center"
android:layout_width="@dimen/sw_60dp"
android:layout_marginTop="@dimen/sw_50dp"
android:layout_height="@dimen/sw_8dp"
android:layout_height="@dimen/sw_28dp"
android:background="@drawable/round_bg_one">
<ImageView
android:id="@+id/add_first_icon"

View File

@@ -61,6 +61,18 @@
android:focusable="false" />
</com.example.myapplication.ui.circle.GradientMaskLayout>
<View
android:id="@+id/imeDismissOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="fill"
android:background="@android:color/transparent"
android:clickable="true"
android:focusable="true"
android:elevation="@dimen/sw_2dp"
android:visibility="gone"
android:importantForAccessibility="no" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/inputOverlay"
android:layout_width="match_parent"
@@ -220,6 +232,46 @@
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:importantForAccessibility="no"
android:elevation="@dimen/sw_10dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:includeFontPadding="false" />
</LinearLayout>
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<!-- 抽屉 -->
<FrameLayout

View File

@@ -16,13 +16,65 @@
android:layout_height="match_parent"
android:background="@drawable/login_bg">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTransactions"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:clipToPadding="false"
android:paddingBottom="@dimen/sw_16dp" />
android:orientation="vertical">
<include layout="@layout/layout_consumption_record_header" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTransactions"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:clipToPadding="false"
android:paddingBottom="@dimen/sw_16dp" />
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:importantForAccessibility="no">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:includeFontPadding="false" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -305,6 +305,62 @@
</LinearLayout>
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:importantForAccessibility="no">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:orientation="vertical"
android:gravity="center_horizontal">
<!-- 顶部“可点击的头部区域”,点击可展开/收起 -->
<LinearLayout
android:id="@+id/bottomSheetHeader"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_48dp"
android:gravity="center"
android:paddingTop="@dimen/sw_8dp"
android:paddingBottom="@dimen/sw_8dp"
android:orientation="vertical">
<!-- 小横条指示器 -->
<View
android:layout_width="@dimen/sw_40dp"
android:layout_height="@dimen/sw_4dp"
android:background="@drawable/bs_handle_bg" />
</LinearLayout>
<ImageView
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:includeFontPadding="false" />
</LinearLayout>
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 浮动按钮 -->

View File

@@ -99,18 +99,32 @@
android:layout_weight="1"
android:layout_marginStart="@dimen/sw_10dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/nickname"
android:text="@string/mine_username"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:ellipsize="end"
android:singleLine="true"
android:maxLines="1"
android:layout_weight="1"
android:textSize="@dimen/sw_20sp" />
android:gravity="center_vertical"
orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/nickname"
android:text="@string/mine_username"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:ellipsize="end"
android:singleLine="true"
android:maxLines="1"
android:layout_weight="1"
android:textSize="@dimen/sw_20sp" />
<ImageView
android:id="@+id/vip_icon"
android:layout_width="@dimen/sw_55dp"
android:layout_height="@dimen/sw_19dp"
android:layout_marginStart="@dimen/sw_5dp"
android:layout_marginEnd="@dimen/sw_5dp"/>
</LinearLayout>
<TextView
android:layout_width="match_parent"
@@ -253,8 +267,8 @@
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="@dimen/sw_20dp"
android:layout_height="@dimen/sw_24dp"
android:layout_width="@dimen/sw_25dp"
android:layout_height="@dimen/sw_29dp"
android:layout_marginStart="@dimen/sw_10dp"
android:layout_marginEnd="@dimen/sw_10dp"
android:src="@drawable/ic_language" />

View File

@@ -264,5 +264,42 @@
android:layout_marginBottom="@dimen/sw_30dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:importantForAccessibility="no"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:includeFontPadding="false" />
</LinearLayout>
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</com.example.myapplication.widget.NoHorizontalInterceptSwipeRefreshLayout>

View File

@@ -20,14 +20,22 @@ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/key_ai"
android:layout_width="@dimen/sw_34dp"
android:layout_height="@dimen/sw_34dp"
android:textSize="12sp"
android:textSize="@dimen/sw_12sp"
android:textColor="#A9A9A9"
android:clickable="true"
android:gravity="center"/>
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/key_airole"
android:layout_marginStart="@dimen/sw_10dp"
android:layout_width="@dimen/sw_34dp"
android:layout_height="@dimen/sw_34dp"
android:clickable="true" />
</LinearLayout>
<LinearLayout

View File

@@ -69,12 +69,57 @@
android:background="@drawable/mykeyboard_bg"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_keyboard"
<FrameLayout
android:id="@+id/keyboardListContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never"
tools:listitem="@layout/item_keyboard_character" />
android:minHeight="@dimen/sw_200dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_keyboard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never"
tools:listitem="@layout/item_keyboard_character" />
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:importantForAccessibility="no">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:includeFontPadding="false" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -13,70 +13,130 @@
android:layout_height="match_parent"
android:background="#F6F7FB"
tools:context=".ui.shop.myskin.MySkin">
<androidx.core.widget.NestedScrollView
<LinearLayout
android:id="@+id/contentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
android:orientation="vertical">
<!-- 标题和返-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<!-- 标题和返回 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="@dimen/sw_16dp"
android:gravity="center_vertical">
<!-- 返回按钮 -->
<FrameLayout
android:id="@+id/iv_close"
android:layout_width="@dimen/sw_46dp"
android:layout_height="@dimen/sw_46dp">
<ImageView
android:layout_width="@dimen/sw_13dp"
android:layout_height="@dimen/sw_13dp"
android:layout_gravity="center"
android:src="@drawable/more_icons"
android:rotation="180"
android:scaleType="fitCenter" />
</FrameLayout>
android:orientation="horizontal"
android:padding="@dimen/sw_16dp"
android:gravity="center_vertical">
<!-- 返回按钮 -->
<FrameLayout
android:id="@+id/iv_close"
android:layout_width="@dimen/sw_46dp"
android:layout_height="@dimen/sw_46dp">
<ImageView
android:layout_width="@dimen/sw_13dp"
android:layout_height="@dimen/sw_13dp"
android:layout_gravity="center"
android:src="@drawable/more_icons"
android:rotation="180"
android:scaleType="fitCenter" />
</FrameLayout>
<TextView
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textStyle="bold"
android:text="@string/skin_title"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_16sp" />
<TextView
android:id="@+id/tvEditor"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/sw_4dp"
android:gravity="center"
android:textStyle="bold"
android:text="@string/skin_editor"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_13sp" />
</LinearLayout>
<!-- 内容 -->
<FrameLayout
android:id="@+id/contentFrame"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/sw_200dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvThemes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_myskin_theme"/>
</FrameLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:importantForAccessibility="no">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textStyle="bold"
android:text="@string/skin_title"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_16sp" />
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<TextView
android:id="@+id/tvEditor"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/sw_4dp"
android:gravity="center"
android:textStyle="bold"
android:text="@string/skin_editor"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_13sp" />
</LinearLayout>
<ImageView
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<!-- 内容 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvThemes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_myskin_theme"/>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<TextView
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:includeFontPadding="false" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
</LinearLayout>
<!-- 底部编辑-->
<!-- 底部编辑-->
<LinearLayout
android:id="@+id/bottomEditBar"
android:layout_width="match_parent"

View File

@@ -28,6 +28,13 @@
android:textColor="#A9A9A9"
android:gravity="center"
android:clickable="true"/>
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/key_airole"
android:layout_marginStart="@dimen/sw_10dp"
android:layout_width="@dimen/sw_34dp"
android:layout_height="@dimen/sw_34dp"
android:clickable="true" />
</LinearLayout>
<LinearLayout

View File

@@ -7,243 +7,288 @@
android:layout_height="match_parent"
android:background="#F6F7FB"
tools:context=".ui.home.myotherpages.PersonalSettings">
<!-- 内容 -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:layout_height="match_parent"
android:orientation="vertical">
<!-- -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/sw_16dp"
android:background="#F8F8F8"
android:orientation="vertical">
<!-- 标题和返回 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- 返回按钮 -->
<FrameLayout
android:id="@+id/iv_close"
android:layout_width="@dimen/sw_46dp"
android:layout_height="@dimen/sw_46dp">
<ImageView
android:layout_width="@dimen/sw_13dp"
android:layout_height="@dimen/sw_13dp"
android:layout_gravity="center"
android:src="@drawable/more_icons"
android:rotation="180"
android:scaleType="fitCenter" />
</FrameLayout>
android:layout_height="wrap_content"
android:padding="@dimen/sw_16dp"
android:background="#F8F8F8"
android:orientation="horizontal"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="@dimen/sw_49dp"
android:gravity="center"
android:textStyle="bold"
android:text="@string/personal_settings_title"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_16sp" />
</LinearLayout>
<!-- 头像 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="@dimen/sw_130dp"
android:padding="@dimen/sw_16dp"
android:gravity="center"
android:orientation="horizontal">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/avatar"
android:layout_width="@dimen/sw_88dp"
android:layout_height="@dimen/sw_88dp"
android:src="@drawable/default_avatar"
android:elevation="@dimen/sw_1dp"
android:clickable="true"
android:focusable="true"/>
<FrameLayout
android:id="@+id/iv_close"
android:layout_width="@dimen/sw_46dp"
android:layout_height="@dimen/sw_46dp">
<ImageView
android:layout_width="@dimen/sw_18dp"
android:layout_height="@dimen/sw_18dp"
android:layout_marginStart="@dimen/_sw_22dp"
android:layout_marginTop="@dimen/sw_34dp"
android:elevation="@dimen/sw_2dp"
android:src="@drawable/avatar_modification"
android:scaleType="centerCrop"/>
</LinearLayout>
<!-- 修改 -->
android:layout_width="@dimen/sw_13dp"
android:layout_height="@dimen/sw_13dp"
android:layout_gravity="center"
android:src="@drawable/more_icons"
android:rotation="180"
android:scaleType="fitCenter" />
</FrameLayout>
<TextView
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="@dimen/sw_49dp"
android:gravity="center"
android:textStyle="bold"
android:text="Modify"
android:text="@string/personal_settings_title"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_18sp" />
android:textSize="@dimen/sw_16sp" />
</LinearLayout>
<!-- 其他设置 -->
<LinearLayout
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/sw_24dp"
android:background="@drawable/settings"
android:orientation="vertical">
<!-- Nickname -->
<LinearLayout
android:id="@+id/row_nickname"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="@dimen/sw_64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_16dp"
android:text="@string/personal_settings_nickname"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:textSize="@dimen/sw_16sp" />
android:padding="@dimen/sw_16dp"
android:background="#F8F8F8"
android:orientation="vertical">
<!-- ?? -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="@dimen/sw_130dp"
android:padding="@dimen/sw_16dp"
android:gravity="center"
android:orientation="horizontal">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/avatar"
android:layout_width="@dimen/sw_88dp"
android:layout_height="@dimen/sw_88dp"
android:src="@drawable/default_avatar"
android:elevation="@dimen/sw_1dp"
android:clickable="true"
android:focusable="true"/>
<ImageView
android:layout_width="@dimen/sw_18dp"
android:layout_height="@dimen/sw_18dp"
android:layout_marginStart="@dimen/_sw_22dp"
android:layout_marginTop="@dimen/sw_34dp"
android:elevation="@dimen/sw_2dp"
android:src="@drawable/avatar_modification"
android:scaleType="centerCrop"/>
</LinearLayout>
<!-- ?? -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:text="Modify"
android:textColor="#1B1F1A"
android:textSize="@dimen/sw_18sp" />
<!-- ???? -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/sw_24dp"
android:background="@drawable/settings"
android:orientation="vertical">
<!-- Nickname -->
<LinearLayout
android:id="@+id/row_nickname"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_16dp"
android:text="@string/personal_settings_nickname"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:textSize="@dimen/sw_16sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_nickname_value"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Nickname"
android:gravity="end"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:layout_weight="1"
android:textSize="@dimen/sw_16sp" />
<ImageView
android:layout_width="@dimen/sw_9dp"
android:layout_height="@dimen/sw_13dp"
android:tint="#AFAFAF"
android:layout_marginStart="@dimen/sw_12dp"
android:layout_marginEnd="@dimen/sw_16dp"
android:src="@drawable/more_icons" />
</LinearLayout>
</LinearLayout>
<!-- Gender -->
<LinearLayout
android:id="@+id/row_gender"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_16dp"
android:text="@string/personal_settings_gender"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:textSize="@dimen/sw_16sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_gender_value"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Gender"
android:gravity="end"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:layout_weight="1"
android:textSize="@dimen/sw_16sp" />
<ImageView
android:layout_width="@dimen/sw_9dp"
android:layout_height="@dimen/sw_13dp"
android:tint="#AFAFAF"
android:layout_marginStart="@dimen/sw_12dp"
android:layout_marginEnd="@dimen/sw_16dp"
android:src="@drawable/more_icons" />
</LinearLayout>
</LinearLayout>
<!-- User ID -->
<LinearLayout
android:id="@+id/row_userid"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_16dp"
android:text="UID"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:textSize="@dimen/sw_16sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_userid_value"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="88888888"
android:gravity="end"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:layout_weight="1"
android:textSize="@dimen/sw_16sp" />
<ImageView
android:layout_width="@dimen/sw_14dp"
android:layout_height="@dimen/sw_14dp"
android:tint="#AFAFAF"
android:layout_marginStart="@dimen/sw_12dp"
android:layout_marginEnd="@dimen/sw_16dp"
android:src="@drawable/copy" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<FrameLayout
android:id="@+id/noResultOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:importantForAccessibility="no">
<LinearLayout
android:layout_width="wrap_content"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_nickname_value"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Nickname"
android:gravity="end"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:layout_weight="1"
android:textSize="@dimen/sw_16sp" />
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:layout_width="@dimen/sw_9dp"
android:layout_height="@dimen/sw_13dp"
android:tint="#AFAFAF"
android:layout_marginStart="@dimen/sw_12dp"
android:layout_marginEnd="@dimen/sw_16dp"
android:src="@drawable/more_icons" />
</LinearLayout>
</LinearLayout>
android:id="@+id/noResultImage"
android:layout_width="@dimen/sw_175dp"
android:layout_height="@dimen/sw_177dp"
android:src="@drawable/no_search_result"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<!-- Gender -->
<LinearLayout
android:id="@+id/row_gender"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_16dp"
android:text="@string/personal_settings_gender"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:textSize="@dimen/sw_16sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_gender_value"
android:layout_width="0dp"
android:id="@+id/noResultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Gender"
android:gravity="end"
android:gravity="center"
android:textAlignment="center"
android:text="@string/search_not_data"
android:textSize="@dimen/sw_13sp"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:layout_weight="1"
android:textSize="@dimen/sw_16sp" />
<ImageView
android:layout_width="@dimen/sw_9dp"
android:layout_height="@dimen/sw_13dp"
android:tint="#AFAFAF"
android:layout_marginStart="@dimen/sw_12dp"
android:layout_marginEnd="@dimen/sw_16dp"
android:src="@drawable/more_icons" />
android:includeFontPadding="false" />
</LinearLayout>
</LinearLayout>
<!-- User ID -->
<LinearLayout
android:id="@+id/row_userid"
android:layout_width="match_parent"
android:layout_height="@dimen/sw_64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_16dp"
android:text="UID"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:textSize="@dimen/sw_16sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/sw_10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_userid_value"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="88888888"
android:gravity="end"
android:textColor="#1B1F1A"
android:textStyle="bold"
android:layout_weight="1"
android:textSize="@dimen/sw_16sp" />
<ImageView
android:layout_width="@dimen/sw_14dp"
android:layout_height="@dimen/sw_14dp"
android:tint="#AFAFAF"
android:layout_marginStart="@dimen/sw_12dp"
android:layout_marginEnd="@dimen/sw_16dp"
android:src="@drawable/copy" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="@dimen/sw_63dp"
android:layout_marginTop="@dimen/sw_273dp"
android:layout_marginBottom="@dimen/sw_20dp"
android:gravity="center"
android:text="Log Out"
android:textColor="#FF0000"
android:textSize="@dimen/sw_16sp"
android:textStyle="bold"
android:background="@drawable/settings"/>
<!-- ````````` -->
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -23,6 +23,12 @@
<!-- ai -->
<TextView android:id="@+id/key_ai" android:layout_width="@dimen/sw_34dp" android:layout_height="@dimen/sw_34dp" android:textSize="@dimen/sw_12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/key_airole"
android:layout_marginStart="@dimen/sw_10dp"
android:layout_width="@dimen/sw_34dp"
android:layout_height="@dimen/sw_34dp"
android:clickable="true" />
</LinearLayout>

View File

@@ -113,7 +113,7 @@
<!-- 搜索 -->
<string name="search_hint">请输入你要搜索的内容</string>
<string name="search_search">搜索</string>
<string name="search_not_data">目前暂无相关数据</string>
<string name="search_not_data">目前暂无相关数据</string>
<string name="search_historical">历史搜索</string>
<!-- 详情 -->