键盘ai角色,无数据显示2
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
private fun isImeEnabled(): Boolean = ImeUtils.isImeEnabled(this)
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -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 overlay:popBackStack 会触发 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) {
|
||||
|
||||
@@ -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+ workaround:setCandidatesViewShown(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
|
||||
@@ -553,6 +663,47 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
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() {
|
||||
@@ -561,6 +712,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
currentKeyboardView = kb.rootView
|
||||
setInputViewSafely(kb.rootView)
|
||||
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
updateAiRoleAvatar()
|
||||
}
|
||||
|
||||
override fun showSymbolKeyboard() {
|
||||
@@ -569,6 +721,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
currentKeyboardView = kb.rootView
|
||||
setInputViewSafely(kb.rootView)
|
||||
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
updateAiRoleAvatar()
|
||||
}
|
||||
|
||||
override fun showAiKeyboard() {
|
||||
@@ -578,6 +731,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
setInputViewSafely(kb.rootView)
|
||||
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
kb.refreshPersonas()
|
||||
updateAiRoleAvatar()
|
||||
}
|
||||
|
||||
override fun showEmojiKeyboard() {
|
||||
@@ -594,7 +748,11 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
// 先清理缓存,避免复用旧 View
|
||||
dismissAiRolePanel()
|
||||
aiRolePanelController?.destroy()
|
||||
aiRolePanelController = null
|
||||
aiRolePanelView = null
|
||||
|
||||
currentKeyboardView = null
|
||||
aiKeyboard?.cancelAiStream()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,7 @@ interface KeyboardEnvironment {
|
||||
|
||||
// 检查是否有可回填的文本
|
||||
fun hasClearedText(): Boolean
|
||||
|
||||
// AI 角色面板
|
||||
fun toggleAiRolePanel()
|
||||
}
|
||||
|
||||
@@ -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按钮的可见性
|
||||
|
||||
@@ -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(' ')
|
||||
@@ -153,6 +156,9 @@ class NumberKeyboard(
|
||||
// 回填后更新按钮可见性
|
||||
updateRevokeButtonVisibility(numView, res, pkg)
|
||||
}
|
||||
|
||||
// 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键
|
||||
setupGapTouchForwardingForRows(numView)
|
||||
}
|
||||
|
||||
// 更新Revoke按钮的可见性
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -152,6 +155,9 @@ class SymbolKeyboard(
|
||||
// 回填后更新按钮可见性
|
||||
updateRevokeButtonVisibility(symView, res, pkg)
|
||||
}
|
||||
|
||||
// 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键
|
||||
setupGapTouchForwardingForRows(symView)
|
||||
}
|
||||
|
||||
// 更新Revoke按钮的可见性
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -84,15 +87,23 @@ class MineFragment : Fragment() {
|
||||
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()
|
||||
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")
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +95,10 @@ class TransactionAdapter(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
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(
|
||||
|
||||
@@ -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,6 +180,28 @@ class MySkin : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable/ic_language.png
Normal file
BIN
app/src/main/res/drawable/ic_language.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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>
|
||||
BIN
app/src/main/res/drawable/mine_svip_icon.png
Normal file
BIN
app/src/main/res/drawable/mine_svip_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/main/res/drawable/mine_vip_icon.png
Normal file
BIN
app/src/main/res/drawable/mine_vip_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
45
app/src/main/res/layout/airole_panel.xml
Normal file
45
app/src/main/res/layout/airole_panel.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/login_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
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"
|
||||
@@ -23,6 +35,46 @@
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 浮动按钮 -->
|
||||
|
||||
@@ -99,6 +99,12 @@
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="@dimen/sw_10dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
orientation="horizontal">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -112,6 +118,14 @@
|
||||
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"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
android:background="@drawable/mykeyboard_bg"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/keyboardListContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/sw_200dp">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_keyboard"
|
||||
android:layout_width="match_parent"
|
||||
@@ -76,6 +82,45 @@
|
||||
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>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
@@ -13,17 +13,13 @@
|
||||
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">
|
||||
<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"
|
||||
@@ -45,6 +41,7 @@
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
@@ -67,16 +64,79 @@
|
||||
</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_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
|
||||
android:id="@+id/bottomEditBar"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,29 +7,23 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F6F7FB"
|
||||
tools:context=".ui.home.myotherpages.PersonalSettings">
|
||||
<!-- 内容 -->
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<androidx.core.widget.NestedScrollView
|
||||
|
||||
<LinearLayout
|
||||
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: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">
|
||||
<!-- 返回按钮 -->
|
||||
android:gravity="center_vertical"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/iv_close"
|
||||
android:layout_width="@dimen/sw_46dp"
|
||||
@@ -55,7 +49,30 @@
|
||||
android:textSize="@dimen/sw_16sp" />
|
||||
</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="match_parent">
|
||||
|
||||
<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:padding="@dimen/sw_16dp"
|
||||
android:background="#F8F8F8"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- ?? -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/sw_130dp"
|
||||
@@ -81,7 +98,7 @@
|
||||
android:scaleType="centerCrop"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 修改 -->
|
||||
<!-- ?? -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -91,7 +108,7 @@
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="@dimen/sw_18sp" />
|
||||
|
||||
<!-- 其他设置 -->
|
||||
<!-- ???? -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -230,20 +247,48 @@
|
||||
</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>
|
||||
|
||||
<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="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>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 详情 -->
|
||||
|
||||
Reference in New Issue
Block a user