diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 07ac1a0..2e2e2e0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(dir:*)", "Bash(powershell:*)", "Bash(find /d/Test/MyApplication/app/src/main/assets -name \"vocab.txt\" -exec wc -l {} ;)", - "WebSearch" + "WebSearch", + "Bash(grep:*)", + "Bash(find:*)" ] } } diff --git a/_nul b/_nul new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/example/myapplication/GuideActivity.kt b/app/src/main/java/com/example/myapplication/GuideActivity.kt index e9a4513..91cb9d4 100644 --- a/app/src/main/java/com/example/myapplication/GuideActivity.kt +++ b/app/src/main/java/com/example/myapplication/GuideActivity.kt @@ -21,9 +21,13 @@ import android.graphics.Rect import android.view.inputmethod.EditorInfo import android.view.KeyEvent import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsAnimationCompat import android.widget.ImageView import android.text.TextWatcher import android.text.Editable +import kotlin.math.max import com.example.myapplication.network.BehaviorReporter class GuideActivity : AppCompatActivity() { @@ -36,6 +40,8 @@ class GuideActivity : AppCompatActivity() { private lateinit var bottomPanel: LinearLayout private lateinit var hintLayout: LinearLayout private lateinit var titleTextView: TextView + private var lastImeBottom = 0 + private var lastSystemBottom = 0 // 我方的预设回复 @@ -117,45 +123,46 @@ class GuideActivity : AppCompatActivity() { findViewById(R.id.iv_close).setOnClickListener { finish() } - //输入框上移 + //输入框上移(参考 CircleFragment,使用 WindowInsetsCompat 精确区分 IME 和导航栏高度) + // 1. WindowInsetsCompat 监听:处理初始状态和配置变化 + ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets -> + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + applyImeInsets(imeBottom, systemBottom) + insets + } + // 2. WindowInsetsAnimationCompat:跟随键盘动画平滑移动 + ViewCompat.setWindowInsetsAnimationCallback( + rootView, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + applyImeInsets(imeBottom, systemBottom) + return insets + } + } + ) + // 3. ViewTreeObserver 兼容方案:部分旧机型 WindowInsets 不触发,通过布局变化兜底 rootView.viewTreeObserver.addOnGlobalLayoutListener { val r = Rect() - // 获取窗口可见区域 rootView.getWindowVisibleDisplayFrame(r) - + val screenHeight = rootView.rootView.height - val visibleBottom = r.bottom - val keyboardHeight = screenHeight - visibleBottom - - // 这个阈值防止“状态栏/导航栏变化”被误认为键盘 - val isKeyboardVisible = keyboardHeight > screenHeight * 0.15 - - if (isKeyboardVisible) { - // 键盘高度为正,把 bottomPanel 抬上去,但不要抬太高 - // 只上移键盘高度减去底部面板高度,让输入框刚好在键盘上方 - val adjustedTranslation = -(keyboardHeight - bottomPanel.height) - bottomPanel.translationY = adjustedTranslation.toFloat() - - // 为了让最后一条消息不被挡住,可以给 scrollView 加个 paddingBottom - scrollView.setPadding( - scrollView.paddingLeft, - scrollView.paddingTop, - scrollView.paddingRight, - keyboardHeight + bottomPanel.height - ) - - // 再滚到底,保证能看到最新消息 - scrollToBottom() - } else { - // 键盘收起,复位 - bottomPanel.translationY = 0f - scrollView.setPadding( - scrollView.paddingLeft, - scrollView.paddingTop, - scrollView.paddingRight, - bottomPanel.height // 保持底部有一点空隙也可以按你需求调 - ) - } + val heightDiff = (screenHeight - r.bottom).coerceAtLeast(0) + val threshold = (screenHeight * 0.15f).toInt() + val heightIme = if (heightDiff > threshold) heightDiff else 0 + + val rootInsets = ViewCompat.getRootWindowInsets(rootView) + val insetIme = rootInsets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 + val imeBottom = max(heightIme, insetIme) + val systemBottom = rootInsets + ?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom + ?: lastSystemBottom + applyImeInsets(imeBottom, systemBottom) } // 键盘发送 @@ -293,4 +300,33 @@ class GuideActivity : AppCompatActivity() { scrollView.fullScroll(View.FOCUS_DOWN) } } + + // ======== 键盘弹起/收起时调整 bottom_panel 位置 ======== + private fun applyImeInsets(imeBottom: Int, systemBottom: Int) { + if (lastImeBottom == imeBottom && lastSystemBottom == systemBottom) return + lastImeBottom = imeBottom + lastSystemBottom = systemBottom + + if (imeBottom > 0) { + // 键盘弹起:上移偏移量 = IME高度 - 导航栏高度(避免重复计算导航栏区域) + val offset = (imeBottom - systemBottom).coerceAtLeast(0) + bottomPanel.translationY = -offset.toFloat() + scrollView.setPadding( + scrollView.paddingLeft, + scrollView.paddingTop, + scrollView.paddingRight, + offset + bottomPanel.height + ) + scrollToBottom() + } else { + // 键盘收起:复位 + bottomPanel.translationY = 0f + scrollView.setPadding( + scrollView.paddingLeft, + scrollView.paddingTop, + scrollView.paddingRight, + bottomPanel.height + ) + } + } } diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index 6ad695e..09ea8cd 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -2,6 +2,7 @@ import android.app.AlertDialog import android.content.Context +import android.content.Intent import android.graphics.drawable.ColorDrawable import android.net.ConnectivityManager import android.net.Network @@ -10,8 +11,10 @@ import android.net.NetworkRequest import android.os.Build import android.os.Bundle import android.util.Log +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity @@ -261,6 +264,14 @@ class MainActivity : AppCompatActivity() { is AuthEvent.CharacterAdded -> { // 不需要处理,由HomeFragment处理 } + + is AuthEvent.ShowChatErrorRecharge -> { + showChatErrorRechargeDialog(event.errorMessage) + } + + is AuthEvent.KeyboardChatUpdated -> { + // 由 CircleFragment 自行处理 + } } } } @@ -287,10 +298,25 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + // 处理来自键盘的跳转请求 + handleRechargeIntent(intent) // ✅ 最终兜底:从后台回来 / 某些场景没触发 listener,也能恢复底栏 bottomNav.post { updateBottomNavVisibility() } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + handleRechargeIntent(intent) + } + + private fun handleRechargeIntent(intent: Intent?) { + if (intent?.action == "ACTION_OPEN_RECHARGE") { + intent.action = null + openGlobal(R.id.rechargeFragment) + } + } + override fun onDestroy() { // ✅ 防泄漏:移除路由监听(Activity 销毁时) runCatching { @@ -584,6 +610,29 @@ class MainActivity : AppCompatActivity() { return globalNavController.currentDestination?.id != R.id.globalEmptyFragment } + private fun showChatErrorRechargeDialog(errorMessage: String? = null) { + val dialogView = LayoutInflater.from(this) + .inflate(R.layout.dialog_chat_error_recharge, null) + val dialog = AlertDialog.Builder(this) + .setView(dialogView) + .setCancelable(true) + .create() + dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) + dialog.window?.addFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND) + dialog.window?.setDimAmount(0.5f) + if (!errorMessage.isNullOrEmpty()) { + dialogView.findViewById(R.id.hintText).text = errorMessage + } + dialogView.findViewById(R.id.btnClose).setOnClickListener { + dialog.dismiss() + } + dialogView.findViewById(R.id.btnRecharge).setOnClickListener { + dialog.dismiss() + openGlobal(R.id.rechargeFragment) + } + dialog.show() + } + private fun setupBackPress() { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt index d5d2cde..c78a902 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -674,7 +674,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { Glide.with(this) .load(info.avatarUrl) .circleCrop() - .placeholder(android.R.drawable.ic_menu_myplaces) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatarView) } else { avatarView.setImageResource(android.R.drawable.ic_menu_myplaces) diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt b/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt index 6044549..5315723 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/AiRolePanelController.kt @@ -1,12 +1,16 @@ package com.example.myapplication.keyboard import android.content.Context +import android.content.Intent import android.util.Log +import android.view.LayoutInflater 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.AuthEvent +import com.example.myapplication.network.AuthEventBus import com.example.myapplication.network.RetrofitClient import com.example.myapplication.network.chatMessageRequest import com.example.myapplication.ui.circle.ChatMessage @@ -114,20 +118,15 @@ class AiRolePanelController(private val context: Context) { } } 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) + removePlaceholder(placeholder) 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) + removePlaceholder(placeholder) + showChatErrorRechargeDialog(response.message) return@launch } @@ -138,6 +137,11 @@ class AiRolePanelController(private val context: Context) { adapter?.notifyMessageUpdated(placeholder.id) scrollToBottom() + // 通知 CircleFragment 刷新对应角色的聊天记录 + AuthEventBus.emit(AuthEvent.KeyboardChatUpdated(currentCompanionId)) + // 持久化脏标记,确保应用从后台恢复时也能刷新 + AiRolePreferences.markCompanionDirty(context, currentCompanionId) + // 轮询音频 URL val audioUrl = fetchAudioUrl(data.audioId) if (!audioUrl.isNullOrBlank()) { @@ -169,6 +173,46 @@ class AiRolePanelController(private val context: Context) { } } + private fun removePlaceholder(placeholder: ChatMessage) { + val index = messages.indexOfFirst { it.id == placeholder.id } + if (index >= 0) { + messages.removeAt(index) + adapter?.notifyMessageRemoved(index) + } + } + + private fun showChatErrorRechargeDialog(errorMessage: String? = null) { + val windowToken = recyclerView?.windowToken ?: return + val dialogView = LayoutInflater.from(context) + .inflate(R.layout.dialog_chat_error_recharge, null) + val dialog = android.app.AlertDialog.Builder(context) + .setView(dialogView) + .setCancelable(true) + .create() + dialog.window?.let { window -> + window.setBackgroundDrawableResource(android.R.color.transparent) + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND) + window.setDimAmount(0.5f) + window.setType(android.view.WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG) + window.attributes = window.attributes.also { it.token = windowToken } + } + if (!errorMessage.isNullOrEmpty()) { + dialogView.findViewById(R.id.hintText).text = errorMessage + } + dialogView.findViewById(R.id.btnClose).setOnClickListener { + dialog.dismiss() + } + dialogView.findViewById(R.id.btnRecharge).setOnClickListener { + dialog.dismiss() + val intent = Intent(context, com.example.myapplication.MainActivity::class.java).apply { + action = "ACTION_OPEN_RECHARGE" + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + context.startActivity(intent) + } + dialog.show() + } + private fun nextId(): Long = messageIdCounter++ fun clearMessages() { diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt b/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt index b0698ee..eaf440d 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/AiRolePreferences.kt @@ -11,6 +11,7 @@ object AiRolePreferences { private const val KEY_COMPANION_ID = "companion_id" private const val KEY_PERSONA_NAME = "persona_name" private const val KEY_AVATAR_URL = "avatar_url" + private const val KEY_DIRTY_COMPANIONS = "dirty_companion_ids" data class CompanionInfo( val companionId: Int, @@ -49,4 +50,26 @@ object AiRolePreferences { .clear() .apply() } + + /** + * 标记某个 companionId 的聊天记录需要刷新(键盘聊天成功后调用) + */ + fun markCompanionDirty(context: Context, companionId: Int) { + val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_MULTI_PROCESS) + val existing = sp.getStringSet(KEY_DIRTY_COMPANIONS, emptySet()) ?: emptySet() + sp.edit() + .putStringSet(KEY_DIRTY_COMPANIONS, existing + companionId.toString()) + .apply() + } + + /** + * 消费所有待刷新的 companionId,返回后自动清除标记 + */ + fun consumeDirtyCompanions(context: Context): Set { + val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_MULTI_PROCESS) + val raw = sp.getStringSet(KEY_DIRTY_COMPANIONS, null) + if (raw.isNullOrEmpty()) return emptySet() + sp.edit().remove(KEY_DIRTY_COMPANIONS).apply() + return raw.mapNotNull { it.toIntOrNull() }.toSet() + } } diff --git a/app/src/main/java/com/example/myapplication/network/ApiService.kt b/app/src/main/java/com/example/myapplication/network/ApiService.kt index 9d29de2..b838bdf 100644 --- a/app/src/main/java/com/example/myapplication/network/ApiService.kt +++ b/app/src/main/java/com/example/myapplication/network/ApiService.kt @@ -102,6 +102,11 @@ interface ApiService { @GET("user/inviteCode") suspend fun inviteCode( ): ApiResponse + + // 获取客服邮箱 + @GET("user/customerMail") + suspend fun delUserCharacter( + ): ApiResponse //===========================================首页================================= // 标签列表 @GET("tag/list") @@ -197,7 +202,7 @@ interface ApiService { //恢复已删除的主题 @POST("themes/restore") suspend fun restoreTheme( - @Query("themeId") themeId: Int + @Body body: restoreThemeRequest ): ApiResponse // =========================================圈子(ai陪聊)============================================ // 分页查询AI陪聊角色 @@ -285,6 +290,12 @@ interface ApiService { @Body body: chatSessionResetRequest ): ApiResponse + //删除聊天记录 + @POST("chat/history/delete") + suspend fun chatDelete( + @Body body: chatDeleteRequest + ): ApiResponse + diff --git a/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt index 2ef19ad..639a554 100644 --- a/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt +++ b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt @@ -36,4 +36,6 @@ sealed class AuthEvent { ) : AuthEvent() object UserUpdated : AuthEvent() data class CharacterDeleted(val characterId: Int) : AuthEvent() + data class ShowChatErrorRecharge(val errorMessage: String? = null) : AuthEvent() + data class KeyboardChatUpdated(val companionId: Int) : AuthEvent() } diff --git a/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt b/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt index 7b951dd..26b69c5 100644 --- a/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt +++ b/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt @@ -11,7 +11,7 @@ object BehaviorHttpClient { private const val TAG = "BehaviorHttp" - // TODO:改成你的行为服务 baseUrl(必须以 / 结尾) + // TODO:改成你的行为服务 baseUrl(必须以 / 结尾)(上报接口) private const val BASE_URL = "http://192.168.2.22:35310/api/" /** 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 bfb6041..65ffb13 100644 --- a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt +++ b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt @@ -258,14 +258,14 @@ private fun bodyToString(body: okhttp3.RequestBody): String { } /** - * JSON 扁平化规则: - * object: a.b.c - * array : items[0].id + * JSON 扁平化规则(与 iOS KBSignUtils 对齐): + * 仅展开顶层 object 的 key-value + * 数组和嵌套对象直接转为 JSON 字符串作为 value(不递归展开) */ private fun flattenJson(elem: JsonElement, prefix: String, out: MutableMap) { when { elem.isJsonNull -> { - // null 不参与签名(服务端也要一致) + // null 不参与签名 } elem.isJsonPrimitive -> { if (prefix.isNotBlank()) out[prefix] = elem.asJsonPrimitive.toString().trim('"') @@ -274,14 +274,20 @@ private fun flattenJson(elem: JsonElement, prefix: String, out: MutableMap { - val arr = elem.asJsonArray - for (i in 0 until arr.size()) { - val newKey = "$prefix[$i]" - flattenJson(arr[i], newKey, out) + // 顶层数组场景(极少见),直接转 JSON 字符串 + if (prefix.isNotBlank()) { + val jsonStr = Gson().toJson(elem) + if (jsonStr.isNotBlank()) out[prefix] = jsonStr } } } 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 cb2e1e4..797149c 100644 --- a/app/src/main/java/com/example/myapplication/network/Models.kt +++ b/app/src/main/java/com/example/myapplication/network/Models.kt @@ -252,6 +252,11 @@ data class purchaseThemeRequest( val themeId: Int, ) +//恢复主题 +data class restoreThemeRequest( + val themeId: Int, +) + // =========================================圈子(ai陪聊)============================================ //分页查询AI陪聊角色 @@ -472,4 +477,8 @@ data class chatSessionResetResponse( val companionId: Int, val resetVersion: Int, val createdAt: String, +) + +data class chatDeleteRequest( + val id: Int, ) \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt index ca8d0b5..a5dd99e 100644 --- a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt +++ b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt @@ -19,7 +19,8 @@ import com.example.myapplication.utils.EncryptedSharedPreferencesUtil object NetworkClient { - private const val BASE_URL = "http://192.168.2.22:7529/api" + // private const val BASE_URL = "http://192.168.2.22:7529/api" + private const val BASE_URL = "https://devcallback.loveamorkey.com/api" private const val TAG = "999-SSE_TALK" // ====== 按你给的规则固定值 ====== diff --git a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt index 57b8259..bc4ff70 100644 --- a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt +++ b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt @@ -10,7 +10,8 @@ import com.example.myapplication.network.FileUploadService object RetrofitClient { - private const val BASE_URL = "http://192.168.2.22:7529/api/" + // private const val BASE_URL = "http://192.168.2.22:7529/api/" + private const val BASE_URL = "https://devcallback.loveamorkey.com/api/" // 保存 ApplicationContext @Volatile diff --git a/app/src/main/java/com/example/myapplication/ui/circle/ChatMessageAdapter.kt b/app/src/main/java/com/example/myapplication/ui/circle/ChatMessageAdapter.kt index 8d69723..bc239d9 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/ChatMessageAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/ChatMessageAdapter.kt @@ -5,6 +5,7 @@ import android.media.MediaPlayer import android.os.Handler import android.os.Looper import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.AnimationUtils @@ -13,9 +14,20 @@ import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.example.myapplication.R -class ChatMessageAdapter : RecyclerView.Adapter() { +class ChatMessageAdapter : RecyclerView.Adapter() { private var items: MutableList = mutableListOf() + var onMessageLongClick: ((message: ChatMessage, anchorView: View, rawX: Float, rawY: Float) -> Unit)? = null + var showLoading: Boolean = false + set(value) { + if (field == value) return + field = value + if (value) { + notifyItemInserted(0) + } else { + notifyItemRemoved(0) + } + } private var mediaPlayer: MediaPlayer? = null private var playingMessageId: Long? = null @@ -25,11 +37,21 @@ class ChatMessageAdapter : RecyclerView.Adapter holder.onRecycled() + is LoadingViewHolder -> holder.onRecycled() + } super.onViewRecycled(holder) } - override fun getItemCount(): Int = items.size + override fun getItemCount(): Int = items.size + if (showLoading) 1 else 0 - override fun getItemId(position: Int): Long = items[position].id + override fun getItemId(position: Int): Long { + if (showLoading && position == 0) return Long.MIN_VALUE + return items[toDataIndex(position)].id + } + + private fun toAdapterIndex(dataIndex: Int): Int = + if (showLoading) dataIndex + 1 else dataIndex fun bindMessages(messages: MutableList) { items = messages @@ -60,14 +95,20 @@ class ChatMessageAdapter : RecyclerView.Adapter= 0) { - notifyItemInserted(index) + notifyItemInserted(toAdapterIndex(index)) } } fun notifyMessageUpdated(messageId: Long) { val index = items.indexOfFirst { it.id == messageId } if (index >= 0) { - notifyItemChanged(index) + notifyItemChanged(toAdapterIndex(index)) + } + } + + fun notifyMessageRemoved(position: Int) { + if (position >= 0) { + notifyItemRemoved(toAdapterIndex(position)) } } @@ -186,6 +227,28 @@ class ChatMessageAdapter : RecyclerView.Adapter + if (event.action == MotionEvent.ACTION_DOWN) { + lastTouchRawX = event.rawX + lastTouchRawY = event.rawY + } + false + } + itemView.setOnLongClickListener { + val pos = adapterPosition + if (pos != RecyclerView.NO_POSITION && pos < items.size) { + val msg = items[pos] + if (!msg.isLoading) { + onMessageLongClick?.invoke(msg, itemView, lastTouchRawX, lastTouchRawY) + } + } + true + } + } fun bind(message: ChatMessage) { if (boundMessageId != message.id) { @@ -280,6 +343,21 @@ class ChatMessageAdapter : RecyclerView.Adapter Unit) -> Unit)?, onLikeClick: ((position: Int, companionId: Int) -> Unit)?, onCommentClick: ((companionId: Int, commentCount: Int) -> Unit)?, - onAvatarClick: ((companionId: Int) -> Unit)? + onAvatarClick: ((companionId: Int) -> Unit)?, + onMessageLongClick: ((message: ChatMessage, anchorView: View, rawX: Float, rawY: Float) -> Unit)? = null ) { boundCompanionId = data.companionId + messageAdapter.onMessageLongClick = onMessageLongClick hasMoreHistory = historyState.hasMore isLoadingHistory = historyState.isLoading this.historyStateProvider = historyStateProvider @@ -114,10 +116,14 @@ class ChatPageViewHolder( boundCommentCount = data.commentCount Glide.with(backgroundView.context) .load(data.backgroundColor) + .placeholder(R.drawable.component_loading) + .error(R.drawable.circle_not_data_bg) .into(backgroundView) Glide.with(avatarView.context) .load(data.avatarUrl) + .placeholder(R.drawable.component_loading) + .error(R.drawable.default_avatar) .into(avatarView) likeView.setImageResource( @@ -203,12 +209,15 @@ class ChatPageViewHolder( val requestedCompanionId = boundCompanionId val requestedPageId = boundPageId isLoadingHistory = true + messageAdapter.showLoading = true callback(position, requestedCompanionId) { result -> if (requestedCompanionId != boundCompanionId || requestedPageId != boundPageId) { + messageAdapter.showLoading = false return@callback } isLoadingHistory = false hasMoreHistory = result.hasMore + messageAdapter.showLoading = false if (result.insertedCount > 0) { notifyMessagesPrepended(result.insertedCount) } @@ -238,7 +247,12 @@ class ChatPageViewHolder( messageAdapter.notifyMessageUpdated(messageId) } + fun notifyMessageRemoved(position: Int) { + messageAdapter.notifyMessageRemoved(position) + } + override fun onRecycled() { + messageAdapter.showLoading = false messageAdapter.release() chatRv.stopScroll() } 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 a60e296..30a4b62 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 @@ -48,13 +48,17 @@ class CircleChatRepository( private var knownTotalPages: Int? = null @Volatile private var availablePages: Int = totalPages + @Volatile + private var hasLoadFailure = false var onTotalPagesChanged: ((Int) -> Unit)? = null + @Volatile + var onPageLoaded: ((position: Int) -> Unit)? = null // 后台协程用于预加载。 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val messageId = AtomicLong((totalPages.toLong() + 1) * 1_000_000L) - //获取指定位置的聊天页面数据 + //获取指定位置的聊天页面数据(非阻塞,缓存未命中时触发后台加载) fun getPage(position: Int): ChatPageData { if (position < 0 || position >= availablePages) { return emptyPage(position) @@ -62,18 +66,9 @@ class CircleChatRepository( val cached = synchronized(lock) { cache.get(position) } if (cached != null) return cached - val page = createPage(position) - return synchronized(lock) { - val existing = cache.get(position) - if (existing != null) { - inFlight.remove(position) - existing - } else { - cache.put(position, page) - inFlight.remove(position) - page - } - } + // 缓存未命中:触发后台预加载,立即返回空占位页避免阻塞调用线程 + preloadAround(position) + return emptyPage(position) } //主要功能是根据用户当前查看的页面位置,预加载其前后若干页的数据,并将这些数据放入缓存中 @@ -88,6 +83,7 @@ class CircleChatRepository( // 清除加载失败的缓存页面(companionId <= 0),使后续 preloadAround 能重新加载 fun invalidateFailedPages() { synchronized(lock) { + hasLoadFailure = false val snapshot = cache.snapshot() for ((position, page) in snapshot) { if (page.companionId <= 0) { @@ -97,8 +93,24 @@ class CircleChatRepository( } } - // 检查缓存中是否存在加载失败的页面 + // 清除全部缓存和状态,用于下拉刷新等场景强制重新加载所有数据 + fun invalidateAll() { + synchronized(lock) { + cache.evictAll() + companionCache.evictAll() + inFlight.clear() + pageInFlight.clear() + pageFetched.clear() + historyStates.clear() + hasLoadFailure = false + knownTotalPages = null + availablePages = totalPages + } + } + + // 检查是否存在加载失败的页面 fun hasFailedPages(): Boolean { + if (hasLoadFailure) return true synchronized(lock) { for ((_, page) in cache.snapshot()) { if (page.companionId <= 0) return true @@ -264,6 +276,18 @@ class CircleChatRepository( } } + fun removeMessage(position: Int, messageId: Long): Int { + synchronized(lock) { + val page = getPage(position) + val index = page.messages.indexOfFirst { it.id == messageId } + if (index >= 0) { + page.messages.removeAt(index) + page.messageVersion++ + } + return index + } + } + fun updateLikeState(position: Int, companionId: Int, liked: Boolean, likeCount: Int): Boolean { synchronized(lock) { val page = cache.get(position) @@ -307,6 +331,39 @@ class CircleChatRepository( // return sampleLines[random.nextInt(sampleLines.size)] // } + /** + * 刷新指定 companionId 对应页面的聊天记录(从服务端重新获取第 1 页)。 + * 返回被更新的 position 列表,用于通知 UI 刷新。 + * 注意:此方法包含网络 IO,必须在后台线程调用。 + */ + fun refreshCompanionMessages(companionId: Int): List { + if (companionId <= 0) return emptyList() + val updatedPositions = ArrayList() + val matchedPositions = synchronized(lock) { + cache.snapshot().filter { it.value.companionId == companionId }.keys.toList() + } + if (matchedPositions.isEmpty()) return emptyList() + + val response = fetchChatRecords(companionId, 1, DEFAULT_CHAT_PAGE_SIZE) + val freshMessages = mapChatRecords(response.data?.records) + + synchronized(lock) { + for (position in matchedPositions) { + val page = cache.get(position) ?: continue + if (page.companionId != companionId) continue + page.messages.clear() + page.messages.addAll(freshMessages) + page.messageVersion++ + updatedPositions.add(position) + } + historyStates.remove(companionId) + if (response.data != null) { + updateHistoryState(companionId, response.data, 1) + } + } + return updatedPositions + } + fun close() { scope.cancel() } @@ -332,32 +389,6 @@ class CircleChatRepository( } - //主要功能是确保指定位置的聊天页面数据已经存在于缓存中。如果指定位置的页面数据不存在,则生成该页面的数据并将其放入缓存中 - private fun createPage(position: Int): ChatPageData { - val cachedCompanion = synchronized(lock) { companionCache.get(position) } - val companionInfo = cachedCompanion ?: run { - val pageNum = position / pageFetchSize + 1 - val records = fetchCompanionPage(pageNum, pageFetchSize) - val index = position - (pageNum - 1) * pageFetchSize - records.getOrNull(index) - } - - if (companionInfo == null) { - return emptyPage(position) - } - - val historyResponse = fetchChatRecords( - companionInfo.id, - 1, - DEFAULT_CHAT_PAGE_SIZE - ).data - val messages = historyResponse?.records - updateHistoryState(companionInfo.id, historyResponse, 1) - Log.d("1314520-CircleChatRepository", "createPage: $position") - - return buildPageData(position, companionInfo, messages) - } - private fun preloadRange(start: Int, end: Int, pageSize: Int, chatPageSize: Int) { val maxPages = availablePages if (maxPages <= 0) return @@ -413,11 +444,20 @@ class CircleChatRepository( val messages = historyResponse?.records updateHistoryState(record.id, historyResponse, 1) val pageData = buildPageData(position, record, messages) - synchronized(lock) { + val wasInserted = synchronized(lock) { if (cache.get(position) == null) { cache.put(position, pageData) + inFlight.remove(position) + true + } else { + inFlight.remove(position) + false + } + } + if (wasInserted) { + onPageLoaded?.let { callback -> + scope.launch(Dispatchers.Main) { callback(position) } } - inFlight.remove(position) } } } @@ -499,6 +539,9 @@ class CircleChatRepository( updateAvailablePages(available) } shouldMarkFetched = data != null + if (!shouldMarkFetched) { + hasLoadFailure = true + } } finally { val startPos = (pageNum - 1) * pageSize synchronized(lock) { @@ -603,7 +646,8 @@ class CircleChatRepository( avatarUrl = "", likeCount = 0, commentCount = 0, - liked = false + liked = false, + messageVersion = -1 ) } diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentAdapter.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentAdapter.kt index d38475f..a55284e 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentAdapter.kt @@ -96,8 +96,8 @@ class CircleCommentAdapter( Glide.with(avatarView) .load(comment.userAvatar) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatarView) userNameView.text = displayName diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentReplyAdapter.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentReplyAdapter.kt index fe4ca77..c52ba67 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentReplyAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentReplyAdapter.kt @@ -76,8 +76,8 @@ class CircleCommentReplyAdapter( Glide.with(avatarView) .load(comment.userAvatar) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatarView) userNameView.text = displayName diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentSheet.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentSheet.kt index 4bacd70..0561334 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentSheet.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CircleCommentSheet.kt @@ -158,6 +158,16 @@ class CircleCommentSheet : BottomSheetDialogFragment() { behavior.state = BottomSheetBehavior.STATE_EXPANDED dialog.window?.setDimAmount(0f) + + // 给原始 BlurView 设置固定高度,防止 sheet 缩放时触发 onSizeChanged 导致冻结的内部位图失效 + commentBlur?.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + height + ) + // 延迟冻结原始 BlurView:等 sheet 展开动画完成 + 渲染几帧后再冻结 + commentBlur?.postDelayed({ + commentBlur?.setBlurAutoUpdate(false) + }, 600) } private fun bindViews(view: View) { @@ -297,6 +307,7 @@ class CircleCommentSheet : BottomSheetDialogFragment() { ?: return val blurRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 14f else 10f + val overlayColor = ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay) try { val algorithm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { RenderEffectBlur() @@ -307,14 +318,10 @@ class CircleCommentSheet : BottomSheetDialogFragment() { .setFrameClearDrawable(requireActivity().window.decorView.background) .setBlurRadius(blurRadius) .setBlurAutoUpdate(true) - .setOverlayColor( - ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay) - ) + .setOverlayColor(overlayColor) } catch (_: Throwable) { blurView.visibility = View.GONE - commentCard.setCardBackgroundColor( - ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay) - ) + commentCard.setCardBackgroundColor(overlayColor) } } @@ -869,18 +876,6 @@ class CircleCommentSheet : BottomSheetDialogFragment() { } private fun updateBlurForIme(imeVisible: Boolean) { - val blurView = commentBlur ?: return - - if (imeVisible) { - // 键盘出来:禁用毛玻璃,避免错位 - blurView.visibility = View.GONE - commentCard.setCardBackgroundColor( - ContextCompat.getColor(requireContext(), R.color.circle_drawer_blur_overlay) - ) - } else { - // 键盘收起:恢复毛玻璃 - blurView.visibility = View.VISIBLE - blurView.invalidate() - } + // 毛玻璃始终冻结显示,不做切换 } } diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleDrawerMenuAdapter.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleDrawerMenuAdapter.kt index 522cafc..f819083 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleDrawerMenuAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CircleDrawerMenuAdapter.kt @@ -60,8 +60,8 @@ class CircleDrawerMenuAdapter( Glide.with(itemView.context) .load(item.avatarUrl) - .placeholder(R.drawable.a123123123) - .error(R.drawable.a123123123) + .placeholder(R.drawable.component_loading) + .error(R.drawable.default_avatar) .into(ivAvatar) // 选中状态显示不同图标和大小 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 a66c07e..fc58ac8 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 @@ -33,6 +33,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.example.myapplication.R import com.example.myapplication.network.AuthEvent import com.example.myapplication.network.AuthEventBus @@ -41,6 +42,7 @@ import com.example.myapplication.network.NetworkEventBus import com.example.myapplication.network.chatMessageRequest import com.example.myapplication.network.aiCompanionLikeRequest import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.max @@ -56,7 +58,14 @@ import android.os.Build import androidx.core.content.ContextCompat import android.widget.ImageView import android.view.inputmethod.InputMethodManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.widget.PopupWindow import com.example.myapplication.network.AiCompanion +import com.example.myapplication.network.chatDeleteRequest import com.example.myapplication.keyboard.AiRolePreferences import java.io.File @@ -65,6 +74,7 @@ class CircleFragment : Fragment() { private lateinit var pageRv: RecyclerView private lateinit var inputOverlay: View private lateinit var noResultOverlay: View + private lateinit var noResultSwipeRefresh: SwipeRefreshLayout private lateinit var imeDismissOverlay: View private lateinit var inputContainerText: View private lateinit var inputContainerVoice: View @@ -195,6 +205,13 @@ class CircleFragment : Fragment() { pageRv = view.findViewById(R.id.pageRv) inputOverlay = view.findViewById(R.id.inputOverlay) noResultOverlay = view.findViewById(R.id.noResultOverlay) + noResultSwipeRefresh = view.findViewById(R.id.noResultSwipeRefresh) + noResultSwipeRefresh.setColorSchemeColors( + Color.parseColor("#02BEAC"), + Color.parseColor("#1B1F1A"), + Color.parseColor("#9F9F9F") + ) + noResultSwipeRefresh.setOnRefreshListener { refreshAllCircleData() } imeDismissOverlay = view.findViewById(R.id.imeDismissOverlay) inputContainerText = view.findViewById(R.id.inputContainerText) inputContainerVoice = view.findViewById(R.id.inputContainerVoice) @@ -307,8 +324,22 @@ class CircleFragment : Fragment() { sharedPool = sharedChatPool, onLikeClick = { position, companionId -> handleLikeClick(position, companionId) }, onCommentClick = { companionId, commentCount -> showCommentSheet(companionId, commentCount) }, - onAvatarClick = { companionId -> openCharacterDetails(companionId) } + onAvatarClick = { companionId -> openCharacterDetails(companionId) }, + onMessageLongClick = { message, anchorView, rawX, rawY -> + showChatMessagePopup(message, anchorView, rawX, rawY) + } ) + // 后台预加载完成时刷新对应的列表项 + repository.onPageLoaded = { position -> + if (isAdded && view != null) { + pageAdapter.notifyItemChanged(position) + val page = repository.getPage(position) + if (page.companionId > 0) { + setNoResultVisible(false) + noResultSwipeRefresh.isRefreshing = false + } + } + } parentFragmentManager.setFragmentResultListener( RESULT_COMMENT_COUNT_UPDATED, viewLifecycleOwner @@ -418,6 +449,17 @@ class CircleFragment : Fragment() { } } } + + // 监听输入法聊天更新事件,刷新对应角色的聊天记录 + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + AuthEventBus.events.collect { event -> + if (event is AuthEvent.KeyboardChatUpdated) { + handleKeyboardChatUpdated(event.companionId) + } + } + } + } } override fun onResume() { @@ -425,6 +467,8 @@ class CircleFragment : Fragment() { view?.post { requestOverlayUpdate() } // 检查是否有加载失败的页面,如果有则重新加载 retryFailedPages() + // 消费后台期间积累的脏标记,刷新对应角色聊天记录 + consumePendingDirtyCompanions() } // 清除失败缓存并重新加载 @@ -439,8 +483,52 @@ class CircleFragment : Fragment() { updateNoResultOverlayFromFirstPage() } + // 输入法聊天更新后,刷新对应角色在缓存中的聊天记录 + private fun handleKeyboardChatUpdated(companionId: Int) { + if (!::repository.isInitialized) return + viewLifecycleOwner.lifecycleScope.launch { + val updatedPositions = withContext(Dispatchers.IO) { + repository.refreshCompanionMessages(companionId) + } + for (position in updatedPositions) { + pageAdapter.notifyItemChanged(position) + } + } + } + + // 消费后台期间通过 SharedPreferences 持久化的脏标记 + private fun consumePendingDirtyCompanions() { + if (!::repository.isInitialized) return + val dirtyIds = AiRolePreferences.consumeDirtyCompanions(requireContext()) + if (dirtyIds.isEmpty()) return + for (companionId in dirtyIds) { + handleKeyboardChatUpdated(companionId) + } + } + + // 下拉刷新:清除全部缓存,重新加载所有接口数据 + private fun refreshAllCircleData() { + repository.invalidateAll() + currentPage = RecyclerView.NO_POSITION + pageAdapter.notifyDataSetChanged() + loadDrawerMenuData() + repository.preloadInitialPages() + updateNoResultOverlayFromFirstPage() + + // 超时保护:10秒后无论如何停止刷新动画 + viewLifecycleOwner.lifecycleScope.launch { + delay(10_000) + if (::noResultSwipeRefresh.isInitialized) { + noResultSwipeRefresh.isRefreshing = false + } + } + } + //清理和恢复输入框的高CircleFragment 在生命周期结束时的状态 override fun onDestroyView() { + if (::repository.isInitialized) { + repository.onPageLoaded = null + } view?.let { root -> ViewCompat.setWindowInsetsAnimationCallback(root, null) root.viewTreeObserver.removeOnGlobalLayoutListener(keyboardLayoutListener) @@ -823,7 +911,8 @@ class CircleFragment : Fragment() { } } catch (e: Exception) { Log.e("1314520-Circle", "chatMessage request failed: ${e.message}", e) - markBotPlaceholderFailed(page, placeholder, requestFailedText) + val removedIndex = repository.removeMessage(page, placeholder.id) + notifyMessageRemoved(page, removedIndex) return@launch } val data = response.data @@ -832,7 +921,9 @@ class CircleFragment : Fragment() { "1314520-Circle", "chatMessage failed code=${response.code} message=${response.message}" ) - markBotPlaceholderFailed(page, placeholder, requestFailedText) + val removedIndex = repository.removeMessage(page, placeholder.id) + notifyMessageRemoved(page, removedIndex) + showChatErrorRechargeDialog(response.message) return@launch } @@ -862,6 +953,29 @@ class CircleFragment : Fragment() { notifyMessageUpdated(page, placeholder.id) } + private fun showChatErrorRechargeDialog(errorMessage: String? = null) { + val dialogView = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_chat_error_recharge, null) + val dialog = android.app.AlertDialog.Builder(requireContext()) + .setView(dialogView) + .setCancelable(true) + .create() + dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) + dialog.window?.addFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND) + dialog.window?.setDimAmount(0.5f) + if (!errorMessage.isNullOrEmpty()) { + dialogView.findViewById(R.id.hintText).text = errorMessage + } + dialogView.findViewById(R.id.btnClose).setOnClickListener { + dialog.dismiss() + } + dialogView.findViewById(R.id.btnRecharge).setOnClickListener { + dialog.dismiss() + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.rechargeFragment)) + } + dialog.show() + } + //确定 RecyclerView 中当前选中的页面位置 private fun resolveCurrentPage(): Int { if (currentPage != RecyclerView.NO_POSITION) return currentPage @@ -897,6 +1011,15 @@ class CircleFragment : Fragment() { } } + private fun notifyMessageRemoved(pagePosition: Int, messageIndex: Int) { + val holder = pageRv.findViewHolderForAdapterPosition(pagePosition) as? ChatPageViewHolder + if (holder != null) { + holder.notifyMessageRemoved(messageIndex) + } else { + pageAdapter.notifyItemChanged(pagePosition) + } + } + private fun handleLikeClick(pagePosition: Int, companionId: Int) { if (pagePosition == RecyclerView.NO_POSITION || companionId <= 0) return if (!likeInFlight.add(companionId)) return @@ -979,6 +1102,104 @@ class CircleFragment : Fragment() { .show(fm, CircleCommentSheet.TAG) } + private fun showChatMessagePopup( + message: ChatMessage, + anchorView: View, + rawX: Float, + rawY: Float + ) { + val ctx = context ?: return + val popupView = LayoutInflater.from(ctx) + .inflate(R.layout.popup_chat_message_menu, null) + + // AI 消息显示:复制、删除、举报;用户消息显示:复制、删除 + if (message.isMine) { + popupView.findViewById(R.id.menuReport).visibility = View.GONE + popupView.findViewById(R.id.divider2).visibility = View.GONE + } + + popupView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + val popupW = popupView.measuredWidth + val popupH = popupView.measuredHeight + + val popupWindow = PopupWindow( + popupView, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + true + ) + popupWindow.elevation = 8f + popupWindow.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + // 复制 + popupView.findViewById(R.id.menuCopy).setOnClickListener { + popupWindow.dismiss() + val clipboard = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("chat_message", message.text)) + Toast.makeText(ctx, R.string.chat_copy_success, Toast.LENGTH_SHORT).show() + } + + // 删除 + popupView.findViewById(R.id.menuDelete).setOnClickListener { + popupWindow.dismiss() + handleDeleteMessage(message) + } + + // 举报 + popupView.findViewById(R.id.menuReport).setOnClickListener { + popupWindow.dismiss() + val page = resolveCurrentPage() + if (page == RecyclerView.NO_POSITION) return@setOnClickListener + val companionId = repository.getPage(page).companionId + if (companionId <= 0) return@setOnClickListener + AuthEventBus.emit( + AuthEvent.OpenCirclePage( + R.id.circleAiCharacterReportFragment, + bundleOf(ARG_COMPANION_ID to companionId) + ) + ) + } + + // 智能定位:优先显示在触摸点上方,空间不够则显示在下方 + val screenWidth = resources.displayMetrics.widthPixels + val screenHeight = resources.displayMetrics.heightPixels + val touchX = rawX.toInt() + val touchY = rawY.toInt() + val margin = (8 * resources.displayMetrics.density).toInt() + + val x = (touchX - popupW / 2).coerceIn(margin, screenWidth - popupW - margin) + val y = if (touchY - popupH - margin > 0) { + touchY - popupH - margin + } else { + touchY + margin + } + + popupWindow.showAtLocation(pageRv, Gravity.NO_GRAVITY, x, y) + } + + private fun handleDeleteMessage(message: ChatMessage) { + val page = resolveCurrentPage() + if (page == RecyclerView.NO_POSITION) return + + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + RetrofitClient.apiService.chatDelete(chatDeleteRequest(id = message.id.toInt())) + } + } catch (e: Exception) { + Log.e("1314520-Circle", "chatDelete failed: ${e.message}", e) + return@launch + } + val removedIndex = repository.removeMessage(page, message.id) + if (removedIndex >= 0) { + notifyMessageRemoved(page, removedIndex) + } + } + } + //同步当前页面的选中状态 private fun syncCurrentPage() { val lm = pageRv.layoutManager as? LinearLayoutManager ?: return @@ -1140,6 +1361,9 @@ class CircleFragment : Fragment() { blur.visibility = View.GONE } } else { + // global 页面可见时不恢复底栏 + val globalContainer = activity?.findViewById(R.id.global_container) + if (globalContainer != null && globalContainer.visibility == View.VISIBLE) return // 恢复时确保底栏可见,不依赖之前保存的状态(可能保存了 GONE) nav.visibility = View.VISIBLE prevBottomNavVisibility = null diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CirclePageAdapter.kt b/app/src/main/java/com/example/myapplication/ui/circle/CirclePageAdapter.kt index 0abdcea..b855d31 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/CirclePageAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/CirclePageAdapter.kt @@ -10,7 +10,8 @@ class CirclePageAdapter( private val sharedPool: RecyclerView.RecycledViewPool, private val onLikeClick: ((position: Int, companionId: Int) -> Unit)? = null, private val onCommentClick: ((companionId: Int, commentCount: Int) -> Unit)? = null, - private val onAvatarClick: ((companionId: Int) -> Unit)? = null + private val onAvatarClick: ((companionId: Int) -> Unit)? = null, + private val onMessageLongClick: ((message: ChatMessage, anchorView: android.view.View, rawX: Float, rawY: Float) -> Unit)? = null ) : RecyclerView.Adapter() { // 每页固定为屏幕高度,配合 PagerSnapHelper 使用。 @@ -53,7 +54,8 @@ class CirclePageAdapter( }, onLikeClick, onCommentClick, - onAvatarClick + onAvatarClick, + onMessageLongClick ) } diff --git a/app/src/main/java/com/example/myapplication/ui/circle/EdgeAwareRecyclerView.kt b/app/src/main/java/com/example/myapplication/ui/circle/EdgeAwareRecyclerView.kt index 9620213..a74bb5d 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/EdgeAwareRecyclerView.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/EdgeAwareRecyclerView.kt @@ -17,11 +17,12 @@ class EdgeAwareRecyclerView @JvmOverloads constructor( var allowParentInterceptAtTop: (() -> Boolean)? = null var onTopPull: (() -> Unit)? = null - override fun onTouchEvent(e: MotionEvent): Boolean { + override fun dispatchTouchEvent(e: MotionEvent): Boolean { when (e.actionMasked) { MotionEvent.ACTION_DOWN -> { lastY = e.y topPullTriggered = false + // 在分发阶段就抢占触摸,防止外层 pageRv 拦截 parent?.requestDisallowInterceptTouchEvent(true) } MotionEvent.ACTION_MOVE -> { @@ -32,22 +33,31 @@ class EdgeAwareRecyclerView @JvmOverloads constructor( val canScrollDown = canScrollVertically(1) val scrollingDown = dy > 0 - val disallow = if (scrollingDown) { - if (!canScrollUp) { - if (!topPullTriggered) { - topPullTriggered = true - onTopPull?.invoke() - } - val allowParent = allowParentInterceptAtTop?.invoke() ?: true - !allowParent - } else { - topPullTriggered = false - canScrollUp + if (!canScrollUp && !canScrollDown) { + // 列表内容不足以滚动,放行给父级翻页 + if (scrollingDown && !topPullTriggered) { + topPullTriggered = true + onTopPull?.invoke() } + parent?.requestDisallowInterceptTouchEvent(false) } else { - canScrollDown + val disallow = if (scrollingDown) { + if (!canScrollUp) { + if (!topPullTriggered) { + topPullTriggered = true + onTopPull?.invoke() + } + val allowParent = allowParentInterceptAtTop?.invoke() ?: true + !allowParent + } else { + topPullTriggered = false + true + } + } else { + canScrollDown + } + parent?.requestDisallowInterceptTouchEvent(disallow) } - parent?.requestDisallowInterceptTouchEvent(disallow) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { @@ -55,6 +65,6 @@ class EdgeAwareRecyclerView @JvmOverloads constructor( topPullTriggered = false } } - return super.onTouchEvent(e) + return super.dispatchTouchEvent(e) } } diff --git a/app/src/main/java/com/example/myapplication/ui/circle/MyAiCharacterAdapters.kt b/app/src/main/java/com/example/myapplication/ui/circle/MyAiCharacterAdapters.kt index e56d279..c1dbe23 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/MyAiCharacterAdapters.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/MyAiCharacterAdapters.kt @@ -40,8 +40,8 @@ class ThumbsUpAdapter( fun bind(item: companionLikedResponse) { Glide.with(ivAvatar) .load(item.avatarUrl) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(ivAvatar) tvName.text = item.name @@ -96,8 +96,8 @@ class ChattingAdapter( fun bind(item: companionChattedResponse) { Glide.with(ivAvatar) .load(item.avatarUrl) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(ivAvatar) tvName.text = item.name diff --git a/app/src/main/java/com/example/myapplication/ui/circle/circleCharacterDetailsFragment.kt b/app/src/main/java/com/example/myapplication/ui/circle/circleCharacterDetailsFragment.kt index 826ee7a..80b80d9 100644 --- a/app/src/main/java/com/example/myapplication/ui/circle/circleCharacterDetailsFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/circle/circleCharacterDetailsFragment.kt @@ -123,8 +123,8 @@ class CircleCharacterDetailsFragment : Fragment() { introTextView.text = data.introText Glide.with(coverImageView) .load(data.coverImageUrl) - .placeholder(R.drawable.bg) - .error(R.drawable.bg) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .transition(DrawableTransitionOptions.withCrossFade(180)) .listener(object : RequestListener { override fun onLoadFailed( 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 6c6d125..538fed1 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 @@ -1,6 +1,7 @@ package com.example.myapplication.ui.home import android.content.Intent +import android.graphics.Color import android.graphics.drawable.TransitionDrawable import android.os.Bundle import android.util.Log @@ -23,6 +24,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.viewpager2.widget.ViewPager2 import com.example.myapplication.ImeGuideActivity import com.example.myapplication.ui.common.LoadingOverlay @@ -63,6 +65,7 @@ class HomeFragment : Fragment() { private lateinit var tabList2: TextView private lateinit var backgroundImage: ImageView private lateinit var noResultOverlay: View + private lateinit var swipeRefreshLayout: SwipeRefreshLayout private var lastList1RenderKey: String? = null private lateinit var loadingOverlay: LoadingOverlay @@ -265,6 +268,7 @@ class HomeFragment : Fragment() { viewPager = view.findViewById(R.id.viewPager) viewPager.isSaveEnabled = false viewPager.offscreenPageLimit = 2 + swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) backgroundImage = bottomSheet.findViewById(R.id.backgroundImage) noResultOverlay = bottomSheet.findViewById(R.id.noResultOverlay) @@ -296,6 +300,20 @@ class HomeFragment : Fragment() { // ✅ setupViewPager 只初始化一次 setupViewPagerOnce() + // 下拉刷新 + swipeRefreshLayout.setColorSchemeColors( + Color.parseColor("#02BEAC"), + Color.parseColor("#1B1F1A"), + Color.parseColor("#9F9F9F") + ) + swipeRefreshLayout.setOnRefreshListener { + refreshAllData { swipeRefreshLayout.isRefreshing = false } + } + swipeRefreshLayout.setOnChildScrollUpCallback { _, _ -> + // BottomSheet 未折叠时,让 BottomSheet 自己处理拖拽,不触发刷新 + bottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED + } + // 标签 UI 初始为空 setupTags() @@ -708,25 +726,29 @@ class HomeFragment : Fragment() { } private fun refreshHomeAfterNetwork() { + loadingOverlay.show() + refreshAllData { loadingOverlay.hide() } + } + + private fun refreshAllData(onFinish: () -> Unit) { networkRefreshJob?.cancel() networkRefreshJob = viewLifecycleOwner.lifecycleScope.launch { preloadJob?.cancel() - loadingOverlay.show() try { - list1Loaded = false - setNoResultVisible(false) - val list = fetchAllPersonaList() + // 先获取所有数据,不动 UI + val newPersonaList = fetchAllPersonaList() if (!isAdded) return@launch - allPersonaCache = list + val response = RetrofitClient.apiService.tagList() + if (!isAdded) return@launch + + // 数据全部返回后,一次性更新 UI + allPersonaCache = newPersonaList list1Loaded = true lastList1RenderKey = null personaCache.clear() notifyPageChangedOnMain(0) updateNoResultOverlay(0) - val response = RetrofitClient.apiService.tagList() - if (!isAdded) return@launch - tags.clear() response.data?.let { networkTags -> tags.addAll(networkTags.map { Tag(it.id, it.tagName) }) @@ -735,9 +757,9 @@ class HomeFragment : Fragment() { setupTags() startPreloadAllTagsFillCacheOnly() } catch (e: Exception) { - Log.e("HomeFragment", "refresh after network fail", e) + Log.e("HomeFragment", "refresh data fail", e) } finally { - loadingOverlay.hide() + onFinish() } } } @@ -952,7 +974,11 @@ class HomeFragment : Fragment() { itemView.findViewById(R.id.tv_desc).text = p.characterBackground ?: "" val iv = itemView.findViewById(R.id.iv_avatar) - com.bumptech.glide.Glide.with(iv).load(p.avatarUrl).into(iv) + com.bumptech.glide.Glide.with(iv) + .load(p.avatarUrl) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) + .into(iv) // ---------------- add 按钮(失败回滚 + 防连点) ---------------- val addBtn = itemView.findViewById(R.id.btn_add) @@ -1046,7 +1072,11 @@ class HomeFragment : Fragment() { addBtn.isVisible = true name.text = item.characterName ?: "" - com.bumptech.glide.Glide.with(avatar).load(item.avatarUrl).into(avatar) + com.bumptech.glide.Glide.with(avatar) + .load(item.avatarUrl) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) + .into(avatar) // ✅ 记录“原始背景/原始icon”,用于 added=false 时恢复 val originBg = addBtn.background diff --git a/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt b/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt index d2a0f2e..56cdb55 100644 --- a/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt @@ -41,8 +41,8 @@ class PersonaAdapter( Glide.with(itemView.context) .load(item.avatarUrl) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(ivAvatar) val isAdded = item.added diff --git a/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt b/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt index 7fcd93b..2fdf48f 100644 --- a/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt @@ -68,8 +68,8 @@ class PersonaDetailDialogFragment : DialogFragment() { Glide.with(requireContext()) .load(data.avatarUrl) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(ivAvatar) btnAdd.setOnClickListener { diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt index 0978331..9b52aa8 100644 --- a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt @@ -27,6 +27,7 @@ import com.example.myapplication.network.RetrofitClient import com.example.myapplication.network.SubjectTag import com.example.myapplication.network.themeDetail import com.example.myapplication.network.purchaseThemeRequest +import com.example.myapplication.network.restoreThemeRequest import com.example.myapplication.ui.shop.ThemeCardAdapter import com.google.android.material.imageview.ShapeableImageView import kotlinx.coroutines.launch @@ -38,6 +39,7 @@ import com.example.myapplication.ui.shop.ShopEventBus import com.example.myapplication.network.BehaviorReporter import com.example.myapplication.network.AuthEvent import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.ui.common.LoadingOverlay class KeyboardDetailFragment : Fragment() { @@ -53,6 +55,7 @@ class KeyboardDetailFragment : Fragment() { private lateinit var enabledButtonText: TextView private lateinit var progressBar: android.widget.ProgressBar private lateinit var swipeRefreshLayout: SwipeRefreshLayout + private lateinit var loadingOverlay: LoadingOverlay private var themeDetailResp: themeDetail? = null override fun onCreateView( @@ -77,21 +80,22 @@ class KeyboardDetailFragment : Fragment() { enabledButtonText = view.findViewById(R.id.enabledButtonText) progressBar = view.findViewById(R.id.progressBar) swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) + loadingOverlay = LoadingOverlay.attach(view.findViewById(R.id.rootCoordinator)) - // 设置按钮始终防止事件穿透的触摸监听器 + // 设置按钮始终防止事件穿透的触摸监听�? enabledButton.setOnTouchListener { _, event -> - // 如果按钮被禁用,消耗所有触摸事件防止穿透 + // 如果按钮被禁用,消耗所有触摸事件防止穿�? if (!enabledButton.isEnabled) { return@setOnTouchListener true } - // 如果按钮启用,不消耗事件,让按钮正常处理点击 + // 如果按钮启用,不消耗事件,让按钮正常处理点�? return@setOnTouchListener false } // 初始化RecyclerView setupRecyclerView() - // 设置下拉刷新监听器 + // 设置下拉刷新监听�? swipeRefreshLayout.setOnRefreshListener { loadData() } @@ -109,7 +113,7 @@ class KeyboardDetailFragment : Fragment() { requireActivity().onBackPressedDispatcher.onBackPressed() } - //充值主题 + //充值主�? rechargeButton.setOnClickListener { showPurchaseConfirmationDialog(themeId) } @@ -136,13 +140,18 @@ class KeyboardDetailFragment : Fragment() { } viewLifecycleOwner.lifecycleScope.launch { + // 下拉刷新时已有自带动画,仅首次加载显�?overlay + if (!swipeRefreshLayout.isRefreshing) { + loadingOverlay.show() + } try { themeDetailResp = getThemeDetail(themeId)?.data val recommendThemeListResp = getrecommendThemeList()?.data Glide.with(requireView().context) .load(themeDetailResp?.themePreviewImageUrl) - .placeholder(R.drawable.bg) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(shapeableImageView) tvKeyboardName.text = themeDetailResp?.themeName @@ -162,7 +171,7 @@ class KeyboardDetailFragment : Fragment() { renderTags(tags) } - // 渲染推荐主题列表(剔除当前themeId) + // 渲染推荐主题列表(剔除当前themeId�? recommendThemeListResp?.let { themes -> val filteredThemes = themes.filter { it.id != themeId } themeCardAdapter.submitList(filteredThemes) @@ -173,6 +182,7 @@ class KeyboardDetailFragment : Fragment() { } finally { // 停止刷新动画 swipeRefreshLayout.isRefreshing = false + loadingOverlay.hide() } } } @@ -183,9 +193,9 @@ class KeyboardDetailFragment : Fragment() { if (tags.isEmpty()) return val context = layoutTagsContainer.context - val tagsPerRow = 5 // 每行固定显示5个标签 + val tagsPerRow = 5 // 每行固定显示5个标�? - // 将标签分组,每行6个 + // 将标签分组,每行6�? val rows = tags.chunked(tagsPerRow) rows.forEach { rowTags -> @@ -212,7 +222,7 @@ class KeyboardDetailFragment : Fragment() { setTextColor(ContextCompat.getColor(context, android.R.color.white)) gravity = Gravity.CENTER - // 设置内边距:左右12dp,上下5dp + // 设置内边距:左右12dp,上�?dp val horizontalPadding = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 12f, resources.displayMetrics ).toInt() @@ -221,14 +231,14 @@ class KeyboardDetailFragment : Fragment() { ).toInt() setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) - // 设置背景(50dp圆角) + // 设置背景�?0dp圆角�? background = ContextCompat.getDrawable(context, R.drawable.tag_background)?.mutate() background?.setTint(android.graphics.Color.parseColor(tag.color)) } // 使用权重布局,让标签自适应间距 val layoutParams = LinearLayout.LayoutParams( - 0, // 宽度设为0,使用权重 + 0, // 宽度设为0,使用权�? LinearLayout.LayoutParams.WRAP_CONTENT, tagWeight ).apply { @@ -241,13 +251,13 @@ class KeyboardDetailFragment : Fragment() { rowLayout.addView(tagView, layoutParams) } - // 如果当前行标签数量不足6个,添加空View填充剩余空间 + // 如果当前行标签数量不�?个,添加空View填充剩余空间 val remainingTags = tagsPerRow - rowTags.size if (remainingTags > 0) { repeat(remainingTags) { val emptyView = View(context) val layoutParams = LinearLayout.LayoutParams( - 0, // 宽度设为0,使用权重 + 0, // 宽度设为0,使用权�? LinearLayout.LayoutParams.WRAP_CONTENT, tagWeight ).apply { @@ -274,7 +284,8 @@ class KeyboardDetailFragment : Fragment() { private suspend fun setrestoreTheme(themeId: Int): ApiResponse? { return try { - RetrofitClient.apiService.restoreTheme(themeId) + val restoreThemeRequest = restoreThemeRequest(themeId = themeId) + RetrofitClient.apiService.restoreTheme(restoreThemeRequest) } catch (e: Exception) { Log.e("KeyboardDetailFragment", "恢复已删除的主题失败", e) null @@ -294,7 +305,7 @@ class KeyboardDetailFragment : Fragment() { val purchaseThemeRequest = purchaseThemeRequest(themeId = purchaseId) val response = RetrofitClient.apiService.purchaseTheme(purchaseThemeRequest) - // 购买成功后触发刷新(成功状态码为0) + // 购买成功后触发刷新(成功状态码�?�? if (response?.code == 0) { loadData() } @@ -308,7 +319,7 @@ class KeyboardDetailFragment : Fragment() { //=============================RecyclerView=================================== private fun setupRecyclerView() { - // 设置GridLayoutManager,每行显示2个item + // 设置GridLayoutManager,每行显�?个item val layoutManager = GridLayoutManager(requireContext(), 2) recyclerRecommendList.layoutManager = layoutManager @@ -325,7 +336,7 @@ class KeyboardDetailFragment : Fragment() { val dialog = Dialog(requireContext()) dialog.setContentView(R.layout.dialog_purchase_confirmation) - // 设置弹窗属性 + // 设置弹窗属�? dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) dialog.window?.setLayout( android.view.WindowManager.LayoutParams.WRAP_CONTENT, @@ -357,7 +368,7 @@ class KeyboardDetailFragment : Fragment() { } /** - * 从 URL 中提取 zip 包名(去掉路径和查询参数,去掉 .zip 扩展名) + * �?URL 中提�?zip 包名(去掉路径和查询参数,去�?.zip 扩展名) */ private fun extractZipNameFromUrl(url: String): String { // 提取文件名部分(去掉路径和查询参数) @@ -367,7 +378,7 @@ class KeyboardDetailFragment : Fragment() { url.substring(url.lastIndexOf('/') + 1) } - // 去掉 .zip 扩展名 + // 去掉 .zip 扩展�? return if (fileName.endsWith(".zip")) { fileName.substring(0, fileName.length - 4) } else { @@ -425,7 +436,7 @@ class KeyboardDetailFragment : Fragment() { enabledButton.isEnabled = false enabledButton.isClickable = false enabledButton.isFocusable = false - // 防止点击事件穿透 - 消耗所有触摸事件 + // 防止点击事件穿�?- 消耗所有触摸事�? enabledButton.setOnTouchListener { _, _ -> true } // 添加视觉上的禁用效果 enabledButton.alpha = 0.6f @@ -446,7 +457,7 @@ class KeyboardDetailFragment : Fragment() { enabledButton.isFocusable = true // 移除触摸监听器,恢复正常触摸事件处理 enabledButton.setOnTouchListener(null) - // 恢复正常的视觉效果 + // 恢复正常的视觉效�? enabledButton.alpha = 1.0f } @@ -464,3 +475,5 @@ class KeyboardDetailFragment : Fragment() { Log.e("1314520-KeyboardDetailFragment", "Error: $message") } } + + 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 352750b..5bbca75 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 @@ -4,6 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -21,6 +22,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.bumptech.glide.Glide import com.example.myapplication.R import com.example.myapplication.network.AuthEvent @@ -48,6 +50,7 @@ class MineFragment : Fragment() { private lateinit var avatar: CircleImageView private lateinit var share: LinearLayout private lateinit var loadingOverlay: LoadingOverlay + private lateinit var swipeRefreshLayout: SwipeRefreshLayout private var loadUserJob: Job? = null @@ -77,6 +80,15 @@ class MineFragment : Fragment() { avatar = view.findViewById(R.id.avatar) share = view.findViewById(R.id.click_Share) loadingOverlay = LoadingOverlay.attach(view.findViewById(R.id.rootCoordinator)) + swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) + swipeRefreshLayout.setColorSchemeColors( + Color.parseColor("#02BEAC"), + Color.parseColor("#1B1F1A"), + Color.parseColor("#9F9F9F") + ) + swipeRefreshLayout.setOnRefreshListener { + refreshUser(force = true, showToast = true) + } // 1) 先用本地缓存秒出首屏 renderFromCache() @@ -178,6 +190,30 @@ class MineFragment : Fragment() { ) } + view.findViewById(R.id.click_Email).setOnClickListener { + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + val response = RetrofitClient.apiService.delUserCharacter() + val email = response.data + if (!isAdded) return@launch + if (email.isNullOrBlank()) { + Toast.makeText(requireContext(), getString(R.string.refresh_failed), Toast.LENGTH_SHORT).show() + return@launch + } + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("email", email)) + Toast.makeText(requireContext(), getString(R.string.email_copy_success), Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) return@launch + Log.e(TAG, "customerMail failed", e) + if (isAdded) Toast.makeText(requireContext(), getString(R.string.refresh_failed), Toast.LENGTH_SHORT).show() + } finally { + loadingOverlay.hide() + } + } + } + // ✅ 监听登录成功/登出事件(跨 NavHost 可靠) @@ -231,6 +267,8 @@ class MineFragment : Fragment() { cached?.avatarUrl?.let { url -> Glide.with(requireContext()) .load(url) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatar) } } @@ -242,6 +280,7 @@ class MineFragment : Fragment() { */ private fun refreshUser(force: Boolean, showToast: Boolean = false) { if (!isLoggedIn()) { + swipeRefreshLayout.isRefreshing = false if (showToast && isAdded) Toast.makeText(requireContext(), getString(R.string.not_logged_in_toast), Toast.LENGTH_SHORT).show() return } @@ -264,6 +303,8 @@ class MineFragment : Fragment() { u?.avatarUrl?.let { url -> Glide.with(requireContext()) .load(url) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatar) } @@ -274,6 +315,8 @@ class MineFragment : Fragment() { if (e is kotlinx.coroutines.CancellationException) return@launch Log.e(TAG, "getUser failed", e) if (showToast && isAdded) Toast.makeText(requireContext(), getString(R.string.refresh_failed), Toast.LENGTH_SHORT).show() + } finally { + swipeRefreshLayout.isRefreshing = false } } } @@ -294,6 +337,8 @@ class MineFragment : Fragment() { renderVip(false, null) Glide.with(requireContext()) .load(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatar) // 触发登出事件,让MainActivity打开登录页面 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 991da16..80701b5 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 @@ -212,8 +212,8 @@ class PersonalSettings : BottomSheetDialogFragment() { Glide.with(this) .load(u.avatarUrl) - .placeholder(R.drawable.default_avatar) - .error(R.drawable.default_avatar) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatar) } @@ -287,6 +287,8 @@ class PersonalSettings : BottomSheetDialogFragment() { private fun handleImageResult(uri: Uri) { Glide.with(this) .load(uri) + .placeholder(R.drawable.component_loading) + .error(R.drawable.no_search_result) .into(avatar) lifecycleScope.launch { diff --git a/app/src/main/java/com/example/myapplication/ui/recharge/RechargeFragment.kt b/app/src/main/java/com/example/myapplication/ui/recharge/RechargeFragment.kt index 48db972..0461c37 100644 --- a/app/src/main/java/com/example/myapplication/ui/recharge/RechargeFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/recharge/RechargeFragment.kt @@ -1,18 +1,29 @@ package com.example.myapplication.ui.recharge -import android.graphics.Paint +import android.animation.AnimatorSet +import android.animation.ObjectAnimator import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.FrameLayout import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 import com.example.myapplication.R import com.example.myapplication.network.AuthEvent import com.example.myapplication.network.AuthEventBus +import com.google.android.material.appbar.AppBarLayout +import kotlin.math.abs class RechargeFragment : Fragment() { + + private var currentTab = 0 + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -24,15 +35,129 @@ class RechargeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // 找到旧价格 TextView - val tvOldPrice = view.findViewById(R.id.tvOldPrice) - // 旧价格加删除线 - tvOldPrice.paintFlags = tvOldPrice.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - - // 设置关闭按钮点击事件 - view.findViewById(R.id.iv_close).setOnClickListener { + // 收起输入法,避免从聊天页跳转过来时键盘残留 + val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + + val appBar = view.findViewById(R.id.appBarLayout) + val headerContainer = view.findViewById(R.id.headerContainer) + val stickyTitleBar = view.findViewById(R.id.stickyTitleBar) + val stickyTitle = view.findViewById(R.id.stickyTitle) + val ivStickyClose = view.findViewById(R.id.iv_sticky_close) + val ivClose = view.findViewById(R.id.iv_close) + val viewPager = view.findViewById(R.id.viewPager) + val tabVip = view.findViewById(R.id.tabVip) + val tabSvip = view.findViewById(R.id.tabSvip) + val ivVipTab = view.findViewById(R.id.ivVipTab) + val ivSvipTab = view.findViewById(R.id.ivSvipTab) + + val titleBarMaxHeight = resources.getDimensionPixelSize(R.dimen.sw_46dp) + + // 显式计算 headerContainer 高度 + // 原始布局流式位置:bg(224) + close(-198+46) + vip(269) + equity(-198+391) + collapse(-380) + spacing(16) = 170dp + // LinearLayout.Math.max 阻止负 margin 缩减测量高度,因此必须在代码中显式设置 + val headerHeight = + resources.getDimensionPixelSize(R.dimen.sw_224dp) + + resources.getDimensionPixelSize(R.dimen._sw_198dp) + + resources.getDimensionPixelSize(R.dimen.sw_46dp) + + resources.getDimensionPixelSize(R.dimen.sw_269dp) + + resources.getDimensionPixelSize(R.dimen._sw_198dp) + + resources.getDimensionPixelSize(R.dimen.sw_391dp) + + resources.getDimensionPixelSize(R.dimen._sw_380dp) + + resources.getDimensionPixelSize(R.dimen.sw_16dp) + headerContainer.layoutParams.height = headerHeight + + // 关闭按钮(头部原位 + 标题栏共用同一逻辑) + val closeAction = View.OnClickListener { AuthEventBus.emit(AuthEvent.UserUpdated) requireActivity().onBackPressedDispatcher.onBackPressed() } + ivClose.setOnClickListener(closeAction) + ivStickyClose.setOnClickListener(closeAction) + + // ViewPager2 适配器 + val pageLayouts = intArrayOf(R.layout.page_recharge_vip, R.layout.page_recharge_svip) + viewPager.adapter = object : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val itemView = LayoutInflater.from(parent.context) + .inflate(pageLayouts[viewType], parent, false) + return object : RecyclerView.ViewHolder(itemView) {} + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {} + override fun getItemViewType(position: Int) = position + override fun getItemCount() = 2 + } + + // 页面切换回调 + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + switchTab(position, ivVipTab, ivSvipTab) + } + }) + + // Tab 点击 + tabVip.setOnClickListener { viewPager.setCurrentItem(0, true) } + tabSvip.setOnClickListener { viewPager.setCurrentItem(1, true) } + + // AppBarLayout 滚动监听:渐进展开标题栏(仿 Shop 页面) + appBar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + val totalRange = appBarLayout.totalScrollRange + if (totalRange == 0) return@OnOffsetChangedListener + + val ratio = abs(verticalOffset).toFloat() / totalRange + + // 最后 20% 渐进展开标题栏 + val progress = ((ratio - 0.8f) / 0.2f).coerceIn(0f, 1f) + + // 标题栏高度 0 → titleBarMaxHeight,防抖优化 + val newHeight = (progress * titleBarMaxHeight).toInt() + val lp = stickyTitleBar.layoutParams + if (lp.height != newHeight) { + lp.height = newHeight + stickyTitleBar.layoutParams = lp + } + + // 标题文字和关闭按钮渐显 + stickyTitle.alpha = progress + ivStickyClose.alpha = progress + }) + } + + private fun switchTab(position: Int, ivVipTab: ImageView, ivSvipTab: ImageView) { + if (currentTab == position) return + currentTab = position + + val selectedView = if (position == 0) ivVipTab else ivSvipTab + val unselectedView = if (position == 0) ivSvipTab else ivVipTab + + AnimatorSet().apply { + playTogether( + ObjectAnimator.ofFloat(selectedView, "scaleX", 0.85f, 1f), + ObjectAnimator.ofFloat(selectedView, "scaleY", 0.85f, 1f), + ObjectAnimator.ofFloat(selectedView, "alpha", 0.7f, 1f), + ObjectAnimator.ofFloat(unselectedView, "scaleX", 1f, 0.85f), + ObjectAnimator.ofFloat(unselectedView, "scaleY", 1f, 0.85f), + ObjectAnimator.ofFloat(unselectedView, "alpha", 1f, 0.7f) + ) + duration = 250 + interpolator = AccelerateDecelerateInterpolator() + start() + } + + if (position == 0) { + ivVipTab.setImageResource(R.drawable.vip_select) + ivSvipTab.setImageResource(R.drawable.svip_not_selected) + } else { + ivVipTab.setImageResource(R.drawable.vip_not_selected) + ivSvipTab.setImageResource(R.drawable.svip_select) + } + + unselectedView.scaleX = 1f + unselectedView.scaleY = 1f + unselectedView.alpha = 1f + selectedView.scaleX = 1f + selectedView.scaleY = 1f + selectedView.alpha = 1f } } 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 1d5f469..562fe01 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 @@ -189,10 +189,11 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { themeListLoaded = false - setNoResultVisible(false) - val newThemes = getThemeList()?.data ?: emptyList() if (!isAdded) return@launch + + setNoResultVisible(false) + if (newThemes != tabTitles) { tabTitles = newThemes styleIds = tabTitles.map { it.id } diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt index 4b5ba6d..0125d1b 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt @@ -42,7 +42,8 @@ class ThemeCardAdapter : ListAdapter + + + + diff --git a/app/src/main/res/drawable/bg_recharge_svip.xml b/app/src/main/res/drawable/bg_recharge_svip.xml new file mode 100644 index 0000000..4759648 --- /dev/null +++ b/app/src/main/res/drawable/bg_recharge_svip.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_recharge_svip_benefit_desc.xml b/app/src/main/res/drawable/bg_recharge_svip_benefit_desc.xml new file mode 100644 index 0000000..643977a --- /dev/null +++ b/app/src/main/res/drawable/bg_recharge_svip_benefit_desc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_recharge_svip_benefits.xml b/app/src/main/res/drawable/bg_recharge_svip_benefits.xml new file mode 100644 index 0000000..b45c64b --- /dev/null +++ b/app/src/main/res/drawable/bg_recharge_svip_benefits.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_recharge_svip_card.png b/app/src/main/res/drawable/bg_recharge_svip_card.png new file mode 100644 index 0000000..c352a6c Binary files /dev/null and b/app/src/main/res/drawable/bg_recharge_svip_card.png differ diff --git a/app/src/main/res/drawable/bg_recharge_svip_card_price.xml b/app/src/main/res/drawable/bg_recharge_svip_card_price.xml new file mode 100644 index 0000000..9eb125b --- /dev/null +++ b/app/src/main/res/drawable/bg_recharge_svip_card_price.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/chat_copy.png b/app/src/main/res/drawable/chat_copy.png new file mode 100644 index 0000000..314f4aa Binary files /dev/null and b/app/src/main/res/drawable/chat_copy.png differ diff --git a/app/src/main/res/drawable/chat_delete.png b/app/src/main/res/drawable/chat_delete.png new file mode 100644 index 0000000..3a81f9d Binary files /dev/null and b/app/src/main/res/drawable/chat_delete.png differ diff --git a/app/src/main/res/drawable/chat_history_loading.png b/app/src/main/res/drawable/chat_history_loading.png new file mode 100644 index 0000000..79e3637 Binary files /dev/null and b/app/src/main/res/drawable/chat_history_loading.png differ diff --git a/app/src/main/res/drawable/chat_report.png b/app/src/main/res/drawable/chat_report.png new file mode 100644 index 0000000..41fb130 Binary files /dev/null and b/app/src/main/res/drawable/chat_report.png differ diff --git a/app/src/main/res/drawable/circle_not_data_bg.png b/app/src/main/res/drawable/circle_not_data_bg.png new file mode 100644 index 0000000..ab8285f Binary files /dev/null and b/app/src/main/res/drawable/circle_not_data_bg.png differ diff --git a/app/src/main/res/drawable/component_loading.png b/app/src/main/res/drawable/component_loading.png new file mode 100644 index 0000000..931c3be Binary files /dev/null and b/app/src/main/res/drawable/component_loading.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1.png new file mode 100644 index 0000000..1958de8 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_1.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_1.png new file mode 100644 index 0000000..db68ad2 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_1.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_2.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_2.png new file mode 100644 index 0000000..5e37255 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_2.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_3.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_3.png new file mode 100644 index 0000000..e7897d0 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_3.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_4.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_4.png new file mode 100644 index 0000000..19315dc Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_4.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_5.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_5.png new file mode 100644 index 0000000..5878644 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_5.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_6.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_6.png new file mode 100644 index 0000000..17b167d Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_6.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_7.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_7.png new file mode 100644 index 0000000..264e184 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_7.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_8.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_8.png new file mode 100644 index 0000000..60c6e45 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_desc_8.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_1_icon.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_icon.png new file mode 100644 index 0000000..49efc91 Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_1_icon.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_benefit_2.png b/app/src/main/res/drawable/ic_recharge_svip_benefit_2.png new file mode 100644 index 0000000..efa294b Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_benefit_2.png differ diff --git a/app/src/main/res/drawable/ic_recharge_svip_hook.png b/app/src/main/res/drawable/ic_recharge_svip_hook.png new file mode 100644 index 0000000..df691bc Binary files /dev/null and b/app/src/main/res/drawable/ic_recharge_svip_hook.png differ diff --git a/app/src/main/res/drawable/recharge_now_bg.xml b/app/src/main/res/drawable/recharge_now_bg.xml new file mode 100644 index 0000000..6aa60fa --- /dev/null +++ b/app/src/main/res/drawable/recharge_now_bg.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recharge_tab_bg.xml b/app/src/main/res/drawable/recharge_tab_bg.xml new file mode 100644 index 0000000..b31bd48 --- /dev/null +++ b/app/src/main/res/drawable/recharge_tab_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/svip_not_selected.png b/app/src/main/res/drawable/svip_not_selected.png new file mode 100644 index 0000000..b4ed8f9 Binary files /dev/null and b/app/src/main/res/drawable/svip_not_selected.png differ diff --git a/app/src/main/res/drawable/svip_select.png b/app/src/main/res/drawable/svip_select.png new file mode 100644 index 0000000..d5fa96f Binary files /dev/null and b/app/src/main/res/drawable/svip_select.png differ diff --git a/app/src/main/res/drawable/vip_not_selected.png b/app/src/main/res/drawable/vip_not_selected.png new file mode 100644 index 0000000..b745c32 Binary files /dev/null and b/app/src/main/res/drawable/vip_not_selected.png differ diff --git a/app/src/main/res/drawable/vip_select.png b/app/src/main/res/drawable/vip_select.png new file mode 100644 index 0000000..b2054a5 Binary files /dev/null and b/app/src/main/res/drawable/vip_select.png differ diff --git a/app/src/main/res/layout/activity_recharge.xml b/app/src/main/res/layout/activity_recharge.xml index fe2088c..1020548 100644 --- a/app/src/main/res/layout/activity_recharge.xml +++ b/app/src/main/res/layout/activity_recharge.xml @@ -7,16 +7,27 @@ android:layout_height="match_parent" android:background="#F6F7FB" tools:context=".ui.home.HomeFragment"> - - + android:layout_height="wrap_content" + android:background="#F6F7FB" + android:elevation="0dp" + android:stateListAnimator="@null" + android:clipChildren="false" + android:clipToPadding="false"> + + + android:orientation="vertical" + android:clipChildren="false" + android:clipToPadding="false" + app:layout_scrollFlags="scroll|exitUntilCollapsed"> + + @@ -38,268 +51,186 @@ android:rotation="180" android:scaleType="fitCenter" /> + + android:src="@drawable/vip_two" /> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:src="@drawable/recharge_equity_bg" /> - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_chat_error_recharge.xml b/app/src/main/res/layout/dialog_chat_error_recharge.xml new file mode 100644 index 0000000..3cbeb2c --- /dev/null +++ b/app/src/main/res/layout/dialog_chat_error_recharge.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_circle.xml b/app/src/main/res/layout/fragment_circle.xml index 1caf030..acf9773 100644 --- a/app/src/main/res/layout/fragment_circle.xml +++ b/app/src/main/res/layout/fragment_circle.xml @@ -244,12 +244,16 @@ android:importantForAccessibility="no" android:elevation="@dimen/sw_10dp"> + + + android:gravity="center"> + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 7868141..ae478ed 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,8 +1,13 @@ - + + + + diff --git a/app/src/main/res/layout/fragment_mine.xml b/app/src/main/res/layout/fragment_mine.xml index e2c3c11..1128adc 100644 --- a/app/src/main/res/layout/fragment_mine.xml +++ b/app/src/main/res/layout/fragment_mine.xml @@ -14,6 +14,11 @@ android:layout_height="match_parent" android:background="#F6F7FB"/> + + + + diff --git a/app/src/main/res/layout/item_chat_loading.xml b/app/src/main/res/layout/item_chat_loading.xml new file mode 100644 index 0000000..12d30a1 --- /dev/null +++ b/app/src/main/res/layout/item_chat_loading.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/layout/item_circle_drawer_menu.xml b/app/src/main/res/layout/item_circle_drawer_menu.xml index cc95673..a124b45 100644 --- a/app/src/main/res/layout/item_circle_drawer_menu.xml +++ b/app/src/main/res/layout/item_circle_drawer_menu.xml @@ -11,8 +11,7 @@ + android:layout_height="@dimen/sw_40dp"/> diff --git a/app/src/main/res/layout/my_skin.xml b/app/src/main/res/layout/my_skin.xml index 6e16e45..9339e97 100644 --- a/app/src/main/res/layout/my_skin.xml +++ b/app/src/main/res/layout/my_skin.xml @@ -125,9 +125,11 @@ android:id="@+id/noResultText" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingStart="@dimen/sw_50dp" + android:paddingEnd="@dimen/sw_50dp" android:gravity="center" android:textAlignment="center" - android:text="@string/search_not_data" + android:text="@string/skin_select_none" android:textSize="@dimen/sw_13sp" android:textColor="#1B1F1A" android:includeFontPadding="false" /> diff --git a/app/src/main/res/layout/page_recharge_svip.xml b/app/src/main/res/layout/page_recharge_svip.xml new file mode 100644 index 0000000..35ed210 --- /dev/null +++ b/app/src/main/res/layout/page_recharge_svip.xml @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/page_recharge_vip.xml b/app/src/main/res/layout/page_recharge_vip.xml new file mode 100644 index 0000000..52039b1 --- /dev/null +++ b/app/src/main/res/layout/page_recharge_vip.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/popup_chat_message_menu.xml b/app/src/main/res/layout/popup_chat_message_menu.xml new file mode 100644 index 0000000..145a398 --- /dev/null +++ b/app/src/main/res/layout/popup_chat_message_menu.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 587b249..91c9d65 100644 --- a/app/src/main/res/values-zh-rCN/strings_i18n.xml +++ b/app/src/main/res/values-zh-rCN/strings_i18n.xml @@ -109,6 +109,8 @@ 编辑 退出编辑 个被选中 + 您目前还没有任何皮肤,快去下载吧 + 请输入你要搜索的内容 @@ -218,6 +220,10 @@ 第三性别 跳过 删除 + 复制 + 举报 + 已复制 + 邮箱已复制 下一步 对方正在输入... @@ -249,5 +255,19 @@ 请输入搜索关键词。 皮肤应用成功 + + 会员充值 + 立即充值 + 点击\"支付\"即表示您同意 + 《会员协议》 + 月度订阅 + + + 1周 + 会员权益 + 更长的聊天记录 + 无限畅聊 + 聊天不限速 + 敬请期待 diff --git a/app/src/main/res/values/strings_i18n.xml b/app/src/main/res/values/strings_i18n.xml index 8b0161d..dc4711c 100644 --- a/app/src/main/res/values/strings_i18n.xml +++ b/app/src/main/res/values/strings_i18n.xml @@ -113,6 +113,7 @@ Editor Exit editing items selected + You currently don\'t have any skin. Hurry up and download it! Please enter the content you want to search for. @@ -223,6 +224,10 @@ The third gender Skip Delete + Copy + Report + Copied + Email copied Next step The other party is currently inputting... @@ -254,4 +259,20 @@ Please enter the search term. Skin application was successful. + + Member recharge + Recharge now + By clicking \"pay\", you indicate your agreement to the + 《Membership Agreement》 + Monthly Subscription + + + 1 Week + Membership Benefits + Longer chat history + Unlimited chatting + Chat without speed limits + Coming soon + +