diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c7f8708..07ac1a0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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" ] } } diff --git a/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt b/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt index 3a0d839..1d2d3fa 100644 --- a/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt +++ b/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt @@ -13,6 +13,7 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.example.myapplication.network.BehaviorReporter +import com.example.myapplication.utils.ImeUtils class ImeGuideActivity : AppCompatActivity() { @@ -228,56 +229,7 @@ class ImeGuideActivity : AppCompatActivity() { } } - /** 是否启用了本输入法 */ - private fun isImeEnabled(): Boolean { - return try { - val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - val myComponent = ComponentName(this, MyInputMethodService::class.java) - - val result = imm.enabledInputMethodList.any { imeInfo -> - imeInfo.packageName == myComponent.packageName && - imeInfo.serviceName == myComponent.className - } - - Log.d(TAG, "isImeEnabled = $result") - result - } catch (e: Exception) { - Log.e(TAG, "isImeEnabled 出错", e) - false - } - } + private fun isImeEnabled(): Boolean = ImeUtils.isImeEnabled(this) - /** 是否已切换为当前输入法 */ - private fun isImeSelected(): Boolean { - return try { - val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - val myComponent = ComponentName(this, MyInputMethodService::class.java) - - val currentImeId = Settings.Secure.getString( - contentResolver, - Settings.Secure.DEFAULT_INPUT_METHOD - ) ?: return false - - Log.d(TAG, "DEFAULT_INPUT_METHOD = $currentImeId") - - // 找到“当前默认 IME”对应的 InputMethodInfo - val currentImeInfo = imm.enabledInputMethodList.firstOrNull { imeInfo -> - imeInfo.id == currentImeId - } - - if (currentImeInfo == null) { - Log.d(TAG, "currentImeInfo == null") - return false - } - - val isMine = currentImeInfo.packageName == myComponent.packageName && - currentImeInfo.serviceName == myComponent.className - - Log.d(TAG, "isImeSelected = $isMine") - isMine - } catch (e: Exception) { - Log.e(TAG, "isImeSelected 出错", e) - false - } - } + private fun isImeSelected(): Boolean = ImeUtils.isImeSelected(this) } diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index 50c2831..6ad695e 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -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(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) { diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt index ff26eb2..d5d2cde 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -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(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 @@ -548,40 +658,84 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun showMainKeyboard() { - clearEditorState() + clearEditorState() val kb = ensureMainKeyboard() currentKeyboardView = kb.rootView setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + updateAiRoleAvatar() } - + + private fun updateAiRoleAvatar() { + val view = currentKeyboardView ?: return + val avatarView = view.findViewById(R.id.key_airole) ?: return + val info = AiRolePreferences.getLastCompanion(this) + if (info != null && !info.avatarUrl.isNullOrBlank()) { + Glide.with(this) + .load(info.avatarUrl) + .circleCrop() + .placeholder(android.R.drawable.ic_menu_myplaces) + .into(avatarView) + } else { + avatarView.setImageResource(android.R.drawable.ic_menu_myplaces) + // 没有角色数据时,异步获取第一个角色作为默认值 + fetchDefaultCompanion() + } + } + + private fun fetchDefaultCompanion() { + CoroutineScope(Dispatchers.IO).launch { + try { + val response = RetrofitClient.apiService.aiCompanionPage( + aiCompanionPageRequest(pageNum = 1, pageSize = 1) + ) + val companion = response.data?.records?.firstOrNull() ?: return@launch + AiRolePreferences.saveLastCompanion( + this@MyInputMethodService, + companion.id, + companion.name, + companion.avatarUrl + ) + // 保存后刷新头像 + launch(Dispatchers.Main) { + updateAiRoleAvatar() + } + } catch (e: Exception) { + // 网络异常忽略,下次切换键盘时会重试 + } + } + } + override fun showNumberKeyboard() { - clearEditorState() + clearEditorState() val kb = ensureNumberKeyboard() currentKeyboardView = kb.rootView setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + updateAiRoleAvatar() } - + override fun showSymbolKeyboard() { - clearEditorState() + clearEditorState() val kb = ensureSymbolKeyboard() currentKeyboardView = kb.rootView setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + updateAiRoleAvatar() } - + override fun showAiKeyboard() { - clearEditorState() + clearEditorState() val kb = ensureAiKeyboard() currentKeyboardView = kb.rootView setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) kb.refreshPersonas() + updateAiRoleAvatar() } override fun showEmojiKeyboard() { - clearEditorState() + clearEditorState() val kb = ensureEmojiKeyboard() currentKeyboardView = kb.rootView setInputViewSafely(kb.rootView) @@ -589,27 +743,31 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun associateClose() { - clearEditorState() + clearEditorState() val kb = ensureEmojiKeyboard() } override fun onConfigurationChanged(newConfig: Configuration) { - // 先清理缓存,避免复用旧 View + dismissAiRolePanel() + aiRolePanelController?.destroy() + aiRolePanelController = null + aiRolePanelView = null + currentKeyboardView = null aiKeyboard?.cancelAiStream() - + mainKeyboardView = null numberKeyboardView = null symbolKeyboardView = null aiKeyboardView = null emojiKeyboardView = null - + mainKeyboard = null numberKeyboard = null symbolKeyboard = null aiKeyboard = null emojiKeyboard = null - + super.onConfigurationChanged(newConfig) } @@ -822,6 +980,26 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 发送(标准 SEND + 回车 fallback) override fun performSendAction() { + // AI 角色面板打开时,拦截发送操作,将文本发给 AI 角色 + if (isAiRolePanelShown) { + val ic = currentInputConnection ?: return + val et = try { + ic.getExtractedText(ExtractedTextRequest(), 0) + } catch (_: Throwable) { null } + val text = et?.text?.toString()?.trim().orEmpty() + if (text.isNotEmpty() && aiRolePanelController?.sendMessage(text) == true) { + // 清空输入框 + ic.beginBatchEdit() + try { + ic.performContextMenuAction(android.R.id.selectAll) + ic.commitText("", 1) + } finally { + ic.endBatchEdit() + } + } + return + } + val ic = currentInputConnection ?: return val info = currentInputEditorInfo diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt index 957a1bf..d29a4d0 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt @@ -484,6 +484,14 @@ class AiKeyboard( } } + // AI 角色面板 + val airoleId = res.getIdentifier("key_airole", "id", pkg) + if (airoleId != 0) { + rootView.findViewById(airoleId)?.setOnClickListener { + env.toggleAiRolePanel() + } + } + // VIP val vipButtonId = res.getIdentifier("key_vip", "id", pkg) if (vipButtonId != 0) { diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt b/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt new file mode 100644 index 0000000..6044549 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt @@ -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() + 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 + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt b/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt new file mode 100644 index 0000000..b0698ee --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt @@ -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() + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt index 0d1d25b..6c9ae66 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt @@ -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 diff --git a/app/src/main/java/com/example/myapplication/keyboard/FixedHeightFrameLayout.kt b/app/src/main/java/com/example/myapplication/keyboard/FixedHeightFrameLayout.kt new file mode 100644 index 0000000..4a7d090 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/FixedHeightFrameLayout.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt b/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt index df64ef9..a225823 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt @@ -50,4 +50,7 @@ interface KeyboardEnvironment { // 检查是否有可回填的文本 fun hasClearedText(): Boolean + + // AI 角色面板 + fun toggleAiRolePanel() } diff --git a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt index 5423dcc..9d15717 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt @@ -170,6 +170,13 @@ class MainKeyboard( view.findViewById(res.getIdentifier("key_emoji", "id", pkg))?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() } + + view.findViewById(res.getIdentifier("key_airole", "id", pkg))?.setOnClickListener { + vibrateKey(); env.toggleAiRolePanel() + } + + // 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键 + setupGapTouchForwardingForRows(view) } // 更新Revoke按钮的可见性 diff --git a/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt index d06a2a2..2fea035 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt @@ -120,6 +120,9 @@ class NumberKeyboard( numView.findViewById(res.getIdentifier("key_ai", "id", pkg)) ?.setOnClickListener { vibrateKey(); env.showAiKeyboard() } + numView.findViewById(res.getIdentifier("key_airole", "id", pkg)) + ?.setOnClickListener { vibrateKey(); env.toggleAiRolePanel() } + numView.findViewById(res.getIdentifier("key_space", "id", pkg)) ?.setOnClickListener { vibrateKey(); env.commitKey(' ') @@ -148,11 +151,14 @@ class NumberKeyboard( ?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() } numView.findViewById(res.getIdentifier("key_revoke", "id", pkg)) - ?.setOnClickListener { + ?.setOnClickListener { vibrateKey(); env.revokeLastClearedText() // 回填后更新按钮可见性 updateRevokeButtonVisibility(numView, res, pkg) } + + // 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键 + setupGapTouchForwardingForRows(numView) } // 更新Revoke按钮的可见性 diff --git a/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt index e2373c0..afbc5e8 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt @@ -140,6 +140,9 @@ class SymbolKeyboard( symView.findViewById(res.getIdentifier("key_ai", "id", pkg)) ?.setOnClickListener { vibrateKey(); env.showAiKeyboard() } + symView.findViewById(res.getIdentifier("key_airole", "id", pkg)) + ?.setOnClickListener { vibrateKey(); env.toggleAiRolePanel() } + symView.findViewById(res.getIdentifier("collapse_button", "id", pkg)) ?.setOnClickListener { vibrateKey(); env.hideKeyboard() } @@ -147,11 +150,14 @@ class SymbolKeyboard( ?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() } symView.findViewById(res.getIdentifier("key_revoke", "id", pkg)) - ?.setOnClickListener { + ?.setOnClickListener { vibrateKey(); env.revokeLastClearedText() // 回填后更新按钮可见性 updateRevokeButtonVisibility(symView, res, pkg) } + + // 为键盘行容器启用间隙触摸转发,点击按键间隙时触发最近按键 + setupGapTouchForwardingForRows(symView) } // 更新Revoke按钮的可见性 diff --git a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt index 4bed35c..bfb6041 100644 --- a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt +++ b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt @@ -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( diff --git a/app/src/main/java/com/example/myapplication/network/Models.kt b/app/src/main/java/com/example/myapplication/network/Models.kt index 838d4b9..cb2e1e4 100644 --- a/app/src/main/java/com/example/myapplication/network/Models.kt +++ b/app/src/main/java/com/example/myapplication/network/Models.kt @@ -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?, ) diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleChatRepository.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleChatRepository.kt index 5e547e9..a60e296 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleChatRepository.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CircleChatRepository.kt @@ -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 diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt index 588b68c..a66c07e 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt @@ -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) } diff --git a/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt b/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt index ade8ab4..6c6d125 100644 --- a/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt @@ -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 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 = emptyList() private val personaCache = mutableMapOf>() + 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(R.id.rootCoordinator) val floatingImage = view.findViewById(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 diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt index 65b1f02..57662b4 100644 --- a/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt @@ -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>? = runCatching { RetrofitClient.apiService.listByUser() }.getOrNull() diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt index a56823d..55ae60e 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt @@ -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() diff --git a/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt b/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt index 4d1b8dd..352750b 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt @@ -3,6 +3,7 @@ package com.example.myapplication.ui.mine import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -41,6 +42,7 @@ import com.example.myapplication.network.BehaviorReporter class MineFragment : Fragment() { private lateinit var nickname: TextView + private lateinit var vipIcon: ImageView private lateinit var time: TextView private lateinit var logout: TextView private lateinit var avatar: CircleImageView @@ -69,6 +71,7 @@ class MineFragment : Fragment() { super.onViewCreated(view, savedInstanceState) nickname = view.findViewById(R.id.nickname) + vipIcon = view.findViewById(R.id.vip_icon) time = view.findViewById(R.id.time) logout = view.findViewById(R.id.logout) avatar = view.findViewById(R.id.avatar) @@ -82,17 +85,25 @@ class MineFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { loadingOverlay.show() try { - val response = getinviteCode() - response?.data?.h5Link?.let { link -> - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("h5Link", link) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, getString(R.string.copy_invite_link_success), Toast.LENGTH_LONG).show() + val response = getinviteCode() + response?.data?.h5Link?.let { link -> BehaviorReporter.report( isNewUser = false, "page_id" to "my", "element_id" to "invite_copy", ) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + } + val chooser = Intent.createChooser(shareIntent, null) + if (chooser.resolveActivity(requireContext().packageManager) != null) { + startActivity(chooser) + } else { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("h5Link", link)) + Toast.makeText(context, getString(R.string.copy_invite_link_success), Toast.LENGTH_LONG).show() + } } } finally { loadingOverlay.hide() @@ -216,6 +227,7 @@ class MineFragment : Fragment() { ) nickname.text = cached?.nickName ?: "" time.text = cached?.vipExpiry?.let { getString(R.string.mine_vip_due_on, it) } ?: "" + renderVip(cached?.isVip, cached?.vipLevel) cached?.avatarUrl?.let { url -> Glide.with(requireContext()) .load(url) @@ -247,6 +259,8 @@ class MineFragment : Fragment() { time.text = u?.vipExpiry?.let { getString(R.string.mine_vip_due_on, it) } ?: "" + renderVip(u?.isVip, u?.vipLevel) + u?.avatarUrl?.let { url -> Glide.with(requireContext()) .load(url) @@ -277,6 +291,7 @@ class MineFragment : Fragment() { // 清空 UI nickname.text = "" time.text = "" + renderVip(false, null) Glide.with(requireContext()) .load(R.drawable.default_avatar) .into(avatar) @@ -292,6 +307,22 @@ class MineFragment : Fragment() { } } + + private fun renderVip(isVip: Boolean?, vipLevel: Int?) { + val show = isVip == true && (vipLevel == 1 || vipLevel == 2) + if (!show) { + vipIcon.visibility = View.GONE + return + } + vipIcon.visibility = View.VISIBLE + val iconRes = if (vipLevel == 2) { + R.drawable.mine_svip_icon + } else { + R.drawable.mine_vip_icon + } + vipIcon.setImageResource(iconRes) + } + private fun isLoggedIn(): Boolean { val ctx = context ?: return false return EncryptedSharedPreferencesUtil.contains(ctx, "user") diff --git a/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt index 0f6c51b..f20a90f 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt @@ -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, - private val onCloseClick: () -> Unit, - private val onRechargeClick: () -> Unit + private val data: MutableList ) : RecyclerView.Adapter() { 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) { 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(R.id.iv_close).setOnClickListener { onCloseClick() } - itemView.findViewById(R.id.rechargeButton).setOnClickListener { onRechargeClick() } - } - - fun bind(balanceText: String) { - balance.text = balanceText - adjustBalanceTextSize(balance, balanceText) - } - - private fun adjustBalanceTextSize(tv: TextView, text: String) { - tv.textSize = when (text.length) { - in 0..3 -> 40f - 4 -> 36f - 5 -> 32f - 6 -> 28f - 7 -> 24f - 8 -> 22f - 9 -> 20f - else -> 16f - } + is ItemVH -> holder.bind(data[position]) } } @@ -146,12 +94,11 @@ class TransactionAdapter( tvTime.text = item.createdAt tvDesc.text = item.description tvAmount.text = "${item.amount}" - - // 根据type设置字体颜色 + val color = when (item.type) { - 1 -> Color.parseColor("#CD2853") // 收入 - 红色 - 2 -> Color.parseColor("#66CD7C") // 支出 - 绿色 - else -> tvAmount.currentTextColor // 保持当前颜色 + 1 -> Color.parseColor("#CD2853") + 2 -> Color.parseColor("#66CD7C") + else -> tvAmount.currentTextColor } tvAmount.setTextColor(color) } diff --git a/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt index d429110..18c463f 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt @@ -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() @@ -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(R.id.iv_close).setOnClickListener { closeByNav() } + view.findViewById(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? = 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 + } } diff --git a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt index e9e7770..991da16 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt @@ -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(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 + } } diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt index ac1333e..1d5f469 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt @@ -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 = emptyList() private var styleIds: List = emptyList() + private var themeListLoaded = false // ===== ViewModel ===== private lateinit var vm: ShopViewModel @@ -72,6 +75,14 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.styleData.collect { + updateNoResultOverlay() + } + } + } + viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { ShopEventBus.events.collect { event -> @@ -142,16 +153,24 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { updateBalance(getwalletBalance()) // 主题 + themeListLoaded = false + + setNoResultVisible(false) + val themeResp = getThemeList() tabTitles = themeResp?.data ?: emptyList() styleIds = tabTitles.map { it.id } + themeListLoaded = true + // Fragment 可能在语言切换重建时被销毁/未附着,避免在未附着状态下创建子 Fragment if (!isAdded) return@launch setupViewPagerOnce() setupTagsOnce() + updateNoResultOverlay(0) + styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) } } } @@ -168,20 +187,31 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { try { updateBalance(getwalletBalance()) + themeListLoaded = false + + setNoResultVisible(false) + val newThemes = getThemeList()?.data ?: emptyList() if (!isAdded) return@launch if (newThemes != tabTitles) { tabTitles = newThemes styleIds = tabTitles.map { it.id } + themeListLoaded = true + setupViewPagerOnce(force = true) setupTagsOnce(force = true) + updateNoResultOverlay(0) + vm.clearCache() styleIds.forEach { vm.forceLoadStyle(it) } } else { + themeListLoaded = true styleIds.getOrNull(viewPager.currentItem) - ?.let { vm.forceLoadStyle(it) } + ?.let { vm.forceLoadStyle(it) } + + updateNoResultOverlay(viewPager.currentItem) } } catch (e: Exception) { Log.e("ShopFragment", "refresh error", e) @@ -207,6 +237,7 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { override fun onPageSelected(position: Int) { updateTagState(position) styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) } + updateNoResultOverlay(position) } } viewPager.registerOnPageChangeCallback(pageCallback!!) @@ -268,6 +299,34 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { } } + + private fun updateNoResultOverlay(position: Int = viewPager.currentItem) { + if (!::noResultOverlay.isInitialized) return + if (!::viewPager.isInitialized) return + if (!themeListLoaded) { + setNoResultVisible(false) + return + } + if (styleIds.isEmpty()) { + setNoResultVisible(true) + return + } + val styleId = styleIds.getOrNull(position) ?: run { + setNoResultVisible(false) + return + } + val cached = vm.styleData.value[styleId] ?: run { + setNoResultVisible(false) + return + } + setNoResultVisible(cached.isEmpty()) + } + + private fun setNoResultVisible(visible: Boolean) { + if (!::noResultOverlay.isInitialized) return + noResultOverlay.visibility = if (visible) View.VISIBLE else View.GONE + } + // ========================== UI helpers ========================== private fun updateBalance(walletResp: ApiResponse?) { @@ -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( diff --git a/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkin.kt b/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkin.kt index 2f185e5..2cc883d 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkin.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkin.kt @@ -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(R.id.tvEditor) + tvEditor = view.findViewById(R.id.tvEditor) + tvTitle = view.findViewById(R.id.tvTitle) val bottomBar = view.findViewById(R.id.bottomEditBar) val tvSelectedCount = view.findViewById(R.id.tvSelectedCount) val btnDelete = view.findViewById(R.id.btnDelete) swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) + noResultOverlay = view.findViewById(R.id.noResultOverlay) // 设置下拉刷新监听器 swipeRefreshLayout.setOnRefreshListener { @@ -141,6 +146,7 @@ class MySkin : Fragment() { adapter.exitEditMode() tvEditor.text = "Editor" hideBottomBar() + updateNoResultOverlay() } } } @@ -153,6 +159,7 @@ class MySkin : Fragment() { viewLifecycleOwner.lifecycleScope.launch { val resp = getPurchasedThemeList() adapter.submitList(resp?.data ?: emptyList()) + updateNoResultOverlay() } } @@ -161,9 +168,11 @@ class MySkin : Fragment() { try { val resp = getPurchasedThemeList() adapter.submitList(resp?.data ?: emptyList()) + updateNoResultOverlay() Log.d("1314520-MySkin", "下拉刷新完成") } catch (e: Exception) { Log.e("1314520-MySkin", "下拉刷新失败", e) + updateNoResultOverlay() } finally { // 停止刷新动画 swipeRefreshLayout.isRefreshing = false @@ -171,7 +180,29 @@ class MySkin : Fragment() { } } - private suspend fun getPurchasedThemeList(): ApiResponse>? { + + 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>? { return try { RetrofitClient.apiService.purchasedThemeList() } catch (e: Exception) { Log.e("MySkin", "获取已购买主题失败", e); null } } diff --git a/app/src/main/java/com/example/myapplication/utils/ImeUtils.kt b/app/src/main/java/com/example/myapplication/utils/ImeUtils.kt new file mode 100644 index 0000000..8d67089 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/utils/ImeUtils.kt @@ -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) + } +} diff --git a/app/src/main/res/drawable/ic_language.png b/app/src/main/res/drawable/ic_language.png new file mode 100644 index 0000000..3708dda Binary files /dev/null and b/app/src/main/res/drawable/ic_language.png differ diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml deleted file mode 100644 index cbff913..0000000 --- a/app/src/main/res/drawable/ic_language.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/mine_svip_icon.png b/app/src/main/res/drawable/mine_svip_icon.png new file mode 100644 index 0000000..6cb5eb5 Binary files /dev/null and b/app/src/main/res/drawable/mine_svip_icon.png differ diff --git a/app/src/main/res/drawable/mine_vip_icon.png b/app/src/main/res/drawable/mine_vip_icon.png new file mode 100644 index 0000000..c2e2a99 Binary files /dev/null and b/app/src/main/res/drawable/mine_vip_icon.png differ diff --git a/app/src/main/res/layout/activity_ime_guide.xml b/app/src/main/res/layout/activity_ime_guide.xml index ef4436a..8ea9b54 100644 --- a/app/src/main/res/layout/activity_ime_guide.xml +++ b/app/src/main/res/layout/activity_ime_guide.xml @@ -39,7 +39,7 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_page_list1.xml b/app/src/main/res/layout/bottom_page_list1.xml index b701ab8..62e9e24 100644 --- a/app/src/main/res/layout/bottom_page_list1.xml +++ b/app/src/main/res/layout/bottom_page_list1.xml @@ -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"> + + + + + + + + + + + + - + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index f51bccf..7868141 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -305,6 +305,62 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_mine.xml b/app/src/main/res/layout/fragment_mine.xml index 9438565..e2c3c11 100644 --- a/app/src/main/res/layout/fragment_mine.xml +++ b/app/src/main/res/layout/fragment_mine.xml @@ -99,18 +99,32 @@ android:layout_weight="1" android:layout_marginStart="@dimen/sw_10dp" android:orientation="vertical"> - + android:gravity="center_vertical" + orientation="horizontal"> + + + + diff --git a/app/src/main/res/layout/fragment_shop.xml b/app/src/main/res/layout/fragment_shop.xml index e048abd..3145c6c 100644 --- a/app/src/main/res/layout/fragment_shop.xml +++ b/app/src/main/res/layout/fragment_shop.xml @@ -264,5 +264,42 @@ android:layout_marginBottom="@dimen/sw_30dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + + + + + + + + + + diff --git a/app/src/main/res/layout/keyboard.xml b/app/src/main/res/layout/keyboard.xml index 31dae5e..98510b4 100644 --- a/app/src/main/res/layout/keyboard.xml +++ b/app/src/main/res/layout/keyboard.xml @@ -20,14 +20,22 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_weight="1" android:gravity="center_vertical" android:orientation="horizontal"> + + + - + android:minHeight="@dimen/sw_200dp"> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/my_skin.xml b/app/src/main/res/layout/my_skin.xml index a40396e..6e16e45 100644 --- a/app/src/main/res/layout/my_skin.xml +++ b/app/src/main/res/layout/my_skin.xml @@ -13,70 +13,130 @@ android:layout_height="match_parent" android:background="#F6F7FB" tools:context=".ui.shop.myskin.MySkin"> - + android:orientation="vertical"> + - - - - - - + android:orientation="horizontal" + android:padding="@dimen/sw_16dp" + android:gravity="center_vertical"> + + + + - + + + + + + + + + + + + + + + + + + + + android:layout_gravity="center" + android:orientation="vertical" + android:gravity="center_horizontal"> - - + - - - - + + + + + - + + + - - - + + + - - - - - - - + android:layout_height="wrap_content" + android:padding="@dimen/sw_16dp" + android:background="#F8F8F8" + android:orientation="horizontal" + android:gravity="center_vertical" + android:clickable="true" + android:focusable="true"> - - - - - - - + - - - + android:layout_width="@dimen/sw_13dp" + android:layout_height="@dimen/sw_13dp" + android:layout_gravity="center" + android:src="@drawable/more_icons" + android:rotation="180" + android:scaleType="fitCenter" /> + + + android:textSize="@dimen/sw_16sp" /> + - - + + - - + + - + + + android:padding="@dimen/sw_16dp" + android:background="#F8F8F8" + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:layout_gravity="center" + android:orientation="vertical" + android:gravity="center_horizontal"> - - + 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" /> - - - - - - - + android:includeFontPadding="false" /> - - - - - - - - - - - - - - - - - - - + + + + diff --git a/app/src/main/res/layout/symbol_keyboard.xml b/app/src/main/res/layout/symbol_keyboard.xml index aa77c8d..06714e4 100644 --- a/app/src/main/res/layout/symbol_keyboard.xml +++ b/app/src/main/res/layout/symbol_keyboard.xml @@ -23,6 +23,12 @@ + diff --git a/app/src/main/res/values-zh-rCN/strings_i18n.xml b/app/src/main/res/values-zh-rCN/strings_i18n.xml index 94dbb3f..587b249 100644 --- a/app/src/main/res/values-zh-rCN/strings_i18n.xml +++ b/app/src/main/res/values-zh-rCN/strings_i18n.xml @@ -113,7 +113,7 @@ 请输入你要搜索的内容 搜索 - 目前暂无相关数据。 + 目前暂无相关数据 历史搜索