diff --git a/.kotlin/errors/errors-1766641600959.log b/.kotlin/errors/errors-1766641600959.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/.kotlin/errors/errors-1766641600959.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9c54e85..c0ee4b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,4 +74,15 @@ dependencies { implementation("com.squareup.retrofit2:converter-gson:2.11.0") // 协程(如果还没加) implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + // lifecycle + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0") + // 加密 SharedPreferences + implementation("androidx.security:security-crypto:1.1.0-alpha06") + // Glide for image loading + implementation("com.github.bumptech.glide:glide:4.16.0") + annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") + + // SwipeRefreshLayout for pull-to-refresh + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a2f524c..0ced4fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,17 +1,20 @@ + + android:theme="@style/Theme.MyApplication" + android:networkSecurityConfig="@xml/network_security_config"> (R.id.rootCoordinator) + + inputMessage.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // 不需要实现 + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + // 不需要实现 + } + + override fun afterTextChanged(s: Editable?) { + hintLayout.visibility = + if (s.isNullOrEmpty()) View.VISIBLE else View.GONE + } + }) // 动画 itemAnim = AnimationUtils.loadAnimation(this, R.anim.item_slide_in_up) //自动聚焦 @@ -96,8 +120,10 @@ class GuideActivity : AppCompatActivity() { val isKeyboardVisible = keyboardHeight > screenHeight * 0.15 if (isKeyboardVisible) { - // 键盘高度为正,仅仅把 bottomPanel 抬上去 - bottomPanel.translationY = -keyboardHeight.toFloat() + // 键盘高度为正,把 bottomPanel 抬上去,但不要抬太高 + // 只上移键盘高度减去底部面板高度,让输入框刚好在键盘上方 + val adjustedTranslation = -(keyboardHeight - bottomPanel.height) + bottomPanel.translationY = adjustedTranslation.toFloat() // 为了让最后一条消息不被挡住,可以给 scrollView 加个 paddingBottom scrollView.setPadding( @@ -139,6 +165,10 @@ class GuideActivity : AppCompatActivity() { sendMessage() } } + + fun dp2px(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } // 发送消息 private fun sendMessage() { val text = inputMessage.text.toString().trim() @@ -155,12 +185,21 @@ class GuideActivity : AppCompatActivity() { inputMessage.setText("") val replyText = replyData.random() - + + // 保存原来的标题文本 + val originalTitle = titleTextView.text.toString() + // 延迟执行我方回复 scrollView.postDelayed({ + // 先恢复标题文本 + titleTextView.text = originalTitle + // 然后添加我方回复 addOurMessage(replyText) - }, 500) + }, 1500) + scrollView.postDelayed({ + titleTextView.text = "The other party is typing..." + }, 500) inputMessage.isFocusable = true inputMessage.isFocusableInTouchMode = true diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index a19e4dc..6d27984 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -3,11 +3,16 @@ package com.example.myapplication import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import com.google.android.material.bottomnavigation.BottomNavigationView import androidx.navigation.NavController import androidx.navigation.NavDestination +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.network.AuthEvent +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { @@ -18,6 +23,23 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + lifecycleScope.launch { + AuthEventBus.events.collectLatest { event -> + if (event is AuthEvent.TokenExpired) { + val navController = (supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment) + .navController + + // 避免重复跳转(比如已经在登录页) + if (navController.currentDestination?.id != R.id.loginFragment) { + navController.navigate(R.id.action_global_loginFragment) + } + } else if (event is AuthEvent.GenericError) { + android.widget.Toast.makeText(this@MainActivity, "${event.message}", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + // 1. 找到 NavHostFragment val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment diff --git a/app/src/main/java/com/example/myapplication/MyApp.kt b/app/src/main/java/com/example/myapplication/MyApp.kt new file mode 100644 index 0000000..8df53e8 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/MyApp.kt @@ -0,0 +1,14 @@ +package com.example.myapplication + +import android.app.Application +import com.example.myapplication.network.RetrofitClient + +class MyApp : Application() { + + override fun onCreate() { + super.onCreate() + + // 初始化 RetrofitClient,传入 ApplicationContext + RetrofitClient.init(this) + } +} diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt index 56f9786..05781d7 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -34,6 +34,16 @@ import com.example.myapplication.keyboard.AiKeyboard import android.text.InputType import android.view.KeyEvent import android.os.SystemClock +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.network.AuthEvent +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import android.content.Intent +import android.view.inputmethod.ExtractedTextRequest +import android.graphics.drawable.GradientDrawable +import kotlin.math.abs @@ -76,6 +86,17 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { private const val NOTIFICATION_CHANNEL_ID = "input_method_channel" private const val NOTIFICATION_ID = 1 } + // ================= 表情 ================= + private var emojiKeyboardView: View? = null + private var emojiKeyboard: com.example.myapplication.keyboard.EmojiKeyboard? = null + // =================上滑清空================== + private var swipeHintPopup: PopupWindow? = null + private var swipeClearPopup: PopupWindow? = null + private var swipeClearPopupShown = false + // 备份:上次“清空”前的全文 + @Volatile private var lastClearedText: String? = null + @Volatile private var lastClearedSelStart: Int = 0 + @Volatile private var lastClearedSelEnd: Int = 0 // ===== KeyboardEnvironment 实现所需属性 ===== @@ -102,6 +123,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { private var isDeleting = false private val repeatDelInitialDelay = 350L private val repeatDelInterval = 50L + private val refreshAfterEditDelayMs = 16L // 1 帧 + private val refreshAfterEditRunnable = Runnable { refreshSuggestionsAfterEdit() } + private val repeatDelRunnable = object : Runnable { override fun run() { @@ -124,6 +148,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { ColorStateList.valueOf(Color.TRANSPARENT) private set + //主题更新 + private val themeListener: () -> Unit = { + applyThemeAfterThemeChanged() + } + private fun applyThemeAfterThemeChanged() { + mainKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + numberKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + symbolKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + aiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + + currentKeyboardView?.apply { + requestLayout() + invalidate() + } + } // 键盘关闭 override fun getInputConnection(): InputConnection? { return currentInputConnection @@ -173,6 +213,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { ThemeManager.ensureBuiltInThemesInstalled(this) ThemeManager.init(this) + + ThemeManager.addThemeChangeListener(themeListener) // 异步加载词典与 bigram 模型 Thread { @@ -217,10 +259,26 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { createNotificationChannelIfNeeded() tryStartForegroundSafe() + + // 监听认证事件 + // CoroutineScope(Dispatchers.Main).launch { + // AuthEventBus.events.collectLatest { event -> + // if (event is AuthEvent.TokenExpired) { + // // 启动 MainActivity 并跳转到登录页面 + // val intent = Intent(this@MyInputMethodService, MainActivity::class.java).apply { + // flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + // putExtra("navigate_to", "loginFragment") + // } + // startActivity(intent) + // } else if (event is AuthEvent.GenericError) { + // // 显示错误提示 + // android.widget.Toast.makeText(this@MyInputMethodService, "请求失败: ${event.message}", android.widget.Toast.LENGTH_SHORT).show() + // } + // } + // } } - - + // 输入法状态变化 private fun createNotificationChannelIfNeeded() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( @@ -321,8 +379,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun onDestroy() { - super.onDestroy() + ThemeManager.removeThemeChangeListener(themeListener) stopRepeatDelete() + super.onDestroy() } // ================= KeyboardEnvironment:键盘切换 ================= @@ -354,6 +413,162 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } return mainKeyboard!! } + + // 上滑清空 + private fun clearAllAndBackup() { + val ic = currentInputConnection ?: return + + val et = try { + ic.getExtractedText(ExtractedTextRequest(), 0) + } catch (_: Throwable) { + null + } ?: return + + val full = et.text?.toString().orEmpty() + if (full.isEmpty()) { + // 已经空了就不做 + clearEditorState() + return + } + + // 备份 + lastClearedText = full + lastClearedSelStart = et.selectionStart.coerceIn(0, full.length) + lastClearedSelEnd = et.selectionEnd.coerceIn(0, full.length) + + // 清空:全选 -> 用空串替换 + ic.beginBatchEdit() + try { + ic.setSelection(0, full.length) + ic.commitText("", 1) + } finally { + ic.endBatchEdit() + } + + clearEditorState() + + // 清空后立即更新所有键盘的按钮可见性 + mainHandler.post { + mainKeyboard?.updateRevokeButtonVisibility() + numberKeyboard?.updateRevokeButtonVisibility() + symbolKeyboard?.updateRevokeButtonVisibility() + } + } + + // 回填上次清空的文本 + override fun revokeLastClearedText() { + val ic = currentInputConnection ?: return + val text = lastClearedText ?: return + + // 回填文本并恢复光标位置 + ic.beginBatchEdit() + try { + // 先清空当前内容 + val currentText = ic.getTextBeforeCursor(1000, 0)?.toString().orEmpty() + if (currentText.isNotEmpty()) { + ic.setSelection(0, currentText.length) + ic.commitText("", 1) + } + + // 回填备份的文本 + ic.commitText(text, 1) + + // 恢复光标位置 + val selStart = lastClearedSelStart.coerceIn(0, text.length) + val selEnd = lastClearedSelEnd.coerceIn(0, text.length) + ic.setSelection(selStart, selEnd) + + // 清空备份,避免重复回填 + lastClearedText = null + lastClearedSelStart = 0 + lastClearedSelEnd = 0 + } finally { + ic.endBatchEdit() + } + } + + // 检查是否有可回填的文本 + override fun hasClearedText(): Boolean { + return lastClearedText != null + } + + private fun showSwipeClearHint(anchor: View, text: String = "Clear") { + mainHandler.post { + if (swipeClearPopupShown) return@post + swipeClearPopupShown = true + + // 先关旧的 + swipeClearPopup?.dismiss() + swipeClearPopup = null + + val dp = resources.displayMetrics.density + + // ✅ 这里“对标按键预览气泡”:优先用你项目里可能已有的 preview 背景 drawable + // 你如果确定资源名,就把 getIdentifier 换成 R.drawable.xxx + val previewBgId = resources.getIdentifier("key_preview_bg", "drawable", packageName) + .takeIf { it != 0 } + ?: resources.getIdentifier("popup_preview_bg", "drawable", packageName) + .takeIf { it != 0 } + + val tv = TextView(this).apply { + this.text = text + textSize = 16f + setTextColor(Color.BLACK) + setPadding(20, 10, 20, 10) + background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 16f + setColor(Color.WHITE) + setStroke(1, Color.parseColor("#33000000")) + } + gravity = Gravity.CENTER + } + + tv.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + val w = tv.measuredWidth + val h = tv.measuredHeight + + val popup = PopupWindow( + tv, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + false + ).apply { + isClippingEnabled = false + elevation = 10f + } + swipeClearPopup = popup + + // ✅ 用 IME 自己的 decorView 做 parent(输入法里最稳) + val parent = window?.window?.decorView ?: anchor.rootView + + // ✅ 坐标用 inWindow,跟 decorView 同坐标系 + val loc = IntArray(2) + anchor.getLocationInWindow(loc) + + val x = loc[0] + anchor.width / 2 - w / 2 + val y = loc[1] - h - (10 * dp).toInt() + + try { + popup.showAtLocation(parent, Gravity.NO_GRAVITY, x, y) + } catch (t: Throwable) { + swipeClearPopupShown = false + Log.w(TAG, "showSwipeClearHint failed: ${t.message}", t) + } + } + } + + //松手关闭气泡 + private fun dismissSwipeClearHint() { + mainHandler.post { + swipeClearPopup?.dismiss() + swipeClearPopup = null + swipeClearPopupShown = false + } + } private fun ensureNumberKeyboard(): NumberKeyboard { @@ -373,8 +588,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { symbolKeyboard = SymbolKeyboard(this) symbolKeyboardView = symbolKeyboard!!.rootView - // 符号键盘删除键 key_backspace - val delId = resources.getIdentifier("key_backspace", "id", packageName) + // 符号键盘删除键 key_del + val delId = resources.getIdentifier("key_del", "id", packageName) symbolKeyboardView?.findViewById(delId)?.let { attachRepeatDeleteInternal(it) } } return symbolKeyboard!! @@ -415,6 +630,27 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { setInputView(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } + + override fun showEmojiKeyboard() { + val kb = ensureEmojiKeyboard() + currentKeyboardView = kb.rootView + setInputView(kb.rootView) + kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + } + + // Emoji 键盘 + private fun ensureEmojiKeyboard(): com.example.myapplication.keyboard.EmojiKeyboard { + if (emojiKeyboard == null) { + emojiKeyboard = com.example.myapplication.keyboard.EmojiKeyboard(this) + emojiKeyboardView = emojiKeyboard!!.rootView + + // Emoji 页面删除键也支持长按连删(复用你现有 attachRepeatDeleteInternal) + val delId = resources.getIdentifier("key_del", "id", packageName) + emojiKeyboardView?.findViewById(delId)?.let { attachRepeatDeleteInternal(it) } + } + return emojiKeyboard!! + } + // ================== 文本输入核心逻辑 ================== @@ -445,6 +681,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { override fun commitKey(c: Char) { val ic = currentInputConnection ?: return + // 如果有清空过的文本,用户开始输入新内容时清空备份 + if (lastClearedText != null) { + lastClearedText = null + lastClearedSelStart = 0 + lastClearedSelEnd = 0 + } + val toSend = if (isShiftOn && c in 'a'..'z') c.uppercaseChar() else c ic.commitText(toSend.toString(), 1) @@ -465,37 +708,77 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { override fun deleteOne() { val ic = currentInputConnection ?: return - // 1️⃣ 发送一个 DEL 按键(DOWN + UP),让客户端有机会拦截 - ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) - ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) + // 删除时少做 IPC:selectedText 也可能慢,所以只在需要时取 + val selected = ic.getSelectedText(0) + if (!selected.isNullOrEmpty()) { + // 删选区 + ic.commitText("", 1) + } else { + // 删光标前一个字符(更同步) + ic.deleteSurroundingText(1, 0) + } - // 如果你担心有些 EditText 不处理 DEL,可以加一个兜底: - // ic.deleteSurroundingText(1, 0) - - // 2️⃣ 你原来的逻辑可以继续保留 - val prefix = getCurrentWordPrefix() - updateCompletionsAndRender(prefix) + scheduleRefreshSuggestions() playKeyClick() } + + private fun refreshSuggestionsAfterEdit() { + val ic = currentInputConnection ?: return + + // ✅ 判空只取 1 个字符,避免 256/256 的 IPC 开销 + val before1 = ic.getTextBeforeCursor(1, 0)?.toString().orEmpty() + val after1 = ic.getTextAfterCursor(1, 0)?.toString().orEmpty() + val editorReallyEmpty = before1.isEmpty() && after1.isEmpty() + + if (editorReallyEmpty) { + clearEditorState() + } else { + // prefix 也不要取太长 + val prefix = getCurrentWordPrefix(maxLen = 64) + updateCompletionsAndRender(prefix) + } + } + + private fun scheduleRefreshSuggestions() { + mainHandler.removeCallbacks(refreshAfterEditRunnable) + mainHandler.postDelayed(refreshAfterEditRunnable, refreshAfterEditDelayMs) + } + // 发送(标准 SEND + 回车 fallback) override fun performSendAction() { val ic = currentInputConnection ?: return - - // 1. 尝试执行标准发送动作(IME_ACTION_SEND) - val handled = ic.performEditorAction(EditorInfo.IME_ACTION_SEND) - - if (!handled) { - // 2. 如果输入框不支持 SEND,则退回到插入换行 - ic.commitText("\n", 1) + val info = currentInputEditorInfo + + var handled = false + + if (info != null) { + // 取出当前 EditText 声明的 action + val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION + + // 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用 + if (actionId == EditorInfo.IME_ACTION_SEND) { + handled = ic.performEditorAction(actionId) + } } - + + // 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false + // 就降级为“标准回车” + if (!handled) { + sendEnterKey(ic) + } + playKeyClick() clearEditorState() } + private fun sendEnterKey(ic: InputConnection) { + // 按下+抬起 KEYCODE_ENTER + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } // 按键音效 override fun playKeyClick() { @@ -666,26 +949,119 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } // ================== 长按删除 ================== - - // 真正实现逻辑(基本照搬你原来的 attachRepeatDelete) private fun attachRepeatDeleteInternal(view: View) { - view.setOnLongClickListener { + val dp = resources.displayMetrics.density + val triggerUp = 48f * dp // 触发“准备清空”的上滑距离 + val cancelBack = 48f * dp // 回滑取消阈值(小于 triggerUp,防抖) + val maxDx = 48f * dp + + var downX = 0f + var downY = 0f + + var pendingSwipeClear = false // 是否处于“准备清空” + var resumeDeletingAfterCancel = false // 取消后是否要恢复连删 + + fun startRepeatDeleteNow() { if (!isDeleting) { isDeleting = true mainHandler.postDelayed(repeatDelRunnable, repeatDelInitialDelay) - deleteOne() // 首次立刻删一次 + deleteOne() + } + } + + view.setOnLongClickListener { + // 只要不是准备清空,就允许长按连删 + if (!pendingSwipeClear) { + startRepeatDeleteNow() } true } + view.setOnTouchListener { _, event -> when (event.actionMasked) { + + android.view.MotionEvent.ACTION_DOWN -> { + downX = event.x + downY = event.y + pendingSwipeClear = false + resumeDeletingAfterCancel = false + dismissSwipeClearHint() + false + } + + android.view.MotionEvent.ACTION_MOVE -> { + val dy = event.y - downY // 上滑 dy < 0 + val dx = abs(event.x - downX) + if (dx > maxDx) return@setOnTouchListener pendingSwipeClear + + // 1) 还没进入准备清空:检测上滑触发 + if (!pendingSwipeClear) { + if (-dy >= triggerUp) { + pendingSwipeClear = true + + // 如果此时正在连删(长按已触发),记录一下,方便取消时恢复 + resumeDeletingAfterCancel = isDeleting + stopRepeatDelete() + + showSwipeClearHint(view, "Clear") + return@setOnTouchListener true + } + return@setOnTouchListener false + } + + // 2) 已经进入准备清空:允许“回滑取消” + // 当你往下滑回来,dy 变大(不那么负),达到 cancelBack 就取消 + if (-dy <= cancelBack) { + pendingSwipeClear = false + dismissSwipeClearHint() + + // 如果之前是长按连删途中进入的准备清空,那取消后恢复连删 + if (resumeDeletingAfterCancel) { + resumeDeletingAfterCancel = false + startRepeatDeleteNow() + } + return@setOnTouchListener false + } + + // 仍处于准备清空:持续消费,保证能收到 UP 来决定是否清空 + true + } + android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL, - android.view.MotionEvent.ACTION_OUTSIDE -> stopRepeatDelete() + android.view.MotionEvent.ACTION_OUTSIDE -> { + + // 如果我们处于“准备清空”,才由我们接管结束逻辑 + if (pendingSwipeClear) { + stopRepeatDelete() + + if (event.actionMasked == android.view.MotionEvent.ACTION_UP) { + clearAllAndBackup() + } + + pendingSwipeClear = false + resumeDeletingAfterCancel = false + dismissSwipeClearHint() + + // 消费 UP,避免 click/longclick 再触发 + return@setOnTouchListener true + } + + // 不在准备清空:不要吃掉 UP/CANCEL,让 View 收到 UP 取消长按检测 + stopRepeatDelete() // 可留可不留;一般点按不会进入 isDeleting + pendingSwipeClear = false + resumeDeletingAfterCancel = false + dismissSwipeClearHint() + + return@setOnTouchListener false + } + + else -> false } - false } } + + private fun stopRepeatDelete() { if (isDeleting) { @@ -771,6 +1147,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { numberKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) symbolKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) aiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) + emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } // ================== bigram & 联想实现 ================== 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 b25efb9..fa80317 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt @@ -13,11 +13,173 @@ import android.widget.LinearLayout import android.widget.TextView import com.example.myapplication.MainActivity import com.example.myapplication.theme.ThemeManager +import android.os.Handler +import android.os.Looper +import android.widget.ScrollView +import com.example.myapplication.network.NetworkClient +import com.example.myapplication.network.LlmStreamCallback +import okhttp3.Call class AiKeyboard( env: KeyboardEnvironment ) : BaseKeyboard(env) { + private var currentStreamCall: Call? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + private val messagesContainer: LinearLayout by lazy { + val res = env.ctx.resources + val id = res.getIdentifier("container_messages", "id", env.ctx.packageName) + rootView.findViewById(id) + } + + private val messagesScrollView: ScrollView by lazy { + val res = env.ctx.resources + val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName) + rootView.findViewById(id) + } + + // 当前正在流式更新的那一个 AI 文本 + private var currentAssistantTextView: TextView? = null + + // 用来处理 的缓冲 + private val streamBuffer = StringBuilder() + + + //新建一条 AI 消息(空内容),返回里面的 TextView 用来后续流式更新 + + private fun addAssistantMessage(initialText: String = ""): TextView { + val inflater = env.layoutInflater + val res = env.ctx.resources + val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName) + + val itemView = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout + val tv = itemView.findViewById( + res.getIdentifier("tv_content", "id", env.ctx.packageName) + ) + tv.text = initialText + messagesContainer.addView(itemView) + + scrollToBottom() + return tv + } + + /** + * (可选)如果你也想显示用户提问 + */ + private fun addUserMessage(text: String) { + // 简单写:复用同一个 item 布局 + val tv = addAssistantMessage(text) + // 这里可以改成设置 gravity、背景区分用户/AI 等 + } + + private fun scrollToBottom() { + // 延迟一点点执行,保证 addView 完成后再滚动 + messagesScrollView.post { + messagesScrollView.fullScroll(View.FOCUS_DOWN) + } + } + + //后端每来一个 llm_chunk 的 data,就调用一次这个方法 + private fun onLlmChunk(data: String) { + // 丢掉 data=":\n\n" 这条 + if (data == ":\n\n") return + + // 确保在主线程更新 UI + mainHandler.post { + // 如果还没有正在流式的 TextView,就新建一条 AI 消息 + if (currentAssistantTextView == null) { + currentAssistantTextView = addAssistantMessage("") + streamBuffer.clear() + } + + // 累积到缓冲区 + streamBuffer.append(data) + + // 先整体把 ":\n\n" 删掉(以防万一有别的地方混进来) + var text = streamBuffer.toString().replace(":\n\n", "") + + // 处理 :代表下一句/下一条消息 + val splitTag = "" + var index = text.indexOf(splitTag) + + while (index != -1) { + // split 前面这一段是上一条消息的最终内容 + val before = text.substring(0, index) + currentAssistantTextView?.text = before + scrollToBottom() + + // 开启下一条 AI 消息 + currentAssistantTextView = addAssistantMessage("") + + // 剩下的留给下一轮 + text = text.substring(index + splitTag.length) + index = text.indexOf(splitTag) + } + + // 循环结束后 text 就是「当前这条消息的未完成尾巴」 + currentAssistantTextView?.text = text + scrollToBottom() + + // 缓冲区只保留尾巴(避免无限变长) + streamBuffer.clear() + streamBuffer.append(text) + } + } + + + // 收到 type="done" 时调用,表示这一轮回答结束 + private fun onLlmDone() { + mainHandler.post { + // 这里目前不需要做太多事,必要的话可以清掉 buffer + streamBuffer.clear() + currentAssistantTextView = null + } + } + + + // 开始一次新的 AI 回答流式请求 + fun startAiStream(userQuestion: String) { + // 可选:先把用户问题显示出来 + addUserMessage(userQuestion) + + // 如果之前有没结束的流,先取消 + currentStreamCall?.cancel() + + currentStreamCall = NetworkClient.startLlmStream( + question = userQuestion, + callback = object : LlmStreamCallback { + override fun onEvent(type: String, data: String?) { + when (type) { + "llm_chunk" -> { + if (data != null) { + onLlmChunk(data) // 这里就是之前写的流式 UI 更新 + } + } + "done" -> { + onLlmDone() // 一轮结束 + } + "search_result" -> { + } + } + } + + override fun onError(t: Throwable) { + addAssistantMessage("出错了:${t.message}") + } + } + ) + } + + // 比如键盘关闭时可以调用一次,避免内存泄漏 / 多余请求 + fun cancelAiStream() { + currentStreamCall?.cancel() + currentStreamCall = null + } + + + + // 以下是 BaseKeyboard 的实现 override val rootView: View = run { val res = env.ctx.resources val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName) @@ -93,6 +255,17 @@ class AiKeyboard( val res = env.ctx.resources val pkg = env.ctx.packageName + // 获取ai_persona和ai_output视图引用 + val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg) + val aiOutputId = res.getIdentifier("ai_output", "id", pkg) + + val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById(aiPersonaId) else null + val aiOutputView = if (aiOutputId != 0) rootView.findViewById(aiOutputId) else null + + // 初始化显示状态:显示ai_persona,隐藏ai_output + aiPersonaView?.visibility = View.VISIBLE + aiOutputView?.visibility = View.GONE + // 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc,就绑定一下 val backId = res.getIdentifier("key_abc", "id", pkg) if (backId != 0) { @@ -108,6 +281,59 @@ class AiKeyboard( navigateToRechargeFragment() } } + + //显示切换 + val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg) + if (returnButtonId != 0) { + rootView.findViewById(returnButtonId)?.let { returnButton -> + // 确保按钮可点击且可获得焦点,防止事件穿透 + returnButton.isClickable = true + returnButton.isFocusable = true + returnButton.setOnClickListener { + // 点击Return_keyboard:先隐藏ai_output,再显示ai_persona(顺序动画) + aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction { + aiOutputView?.visibility = View.GONE + // 等ai_output完全隐藏后再显示ai_persona + aiPersonaView?.visibility = View.VISIBLE + aiPersonaView?.alpha = 0f + aiPersonaView?.animate()?.alpha(1f)?.setDuration(150) + } + } + } + } + + val cardButtonId = res.getIdentifier("card", "id", pkg) + if (cardButtonId != 0) { + rootView.findViewById(cardButtonId)?.setOnClickListener { + // 点击card:先隐藏ai_persona,再显示ai_output(顺序动画) + aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction { + aiPersonaView?.visibility = View.GONE + // 等ai_persona完全隐藏后再显示ai_output + aiOutputView?.visibility = View.VISIBLE + aiOutputView?.alpha = 0f + aiOutputView?.animate()?.alpha(1f)?.setDuration(150) + } + } + } + + + + + + // // 假设 ai_keyboard.xml 里有一个发送按钮 key_send + // val sendId = res.getIdentifier("key_send", "id", pkg) + // val inputId = res.getIdentifier("et_prompt", "id", pkg) // 假设这是你的输入框 id + + // if (sendId != 0 && inputId != 0) { + // val inputView = rootView.findViewById(inputId) + + // rootView.findViewById(sendId)?.setOnClickListener { + // val question = inputView?.text?.toString()?.trim().orEmpty() + // if (question.isNotEmpty()) { + // startAiStream(question) + // } + // } + // } } private fun navigateToRechargeFragment() { @@ -128,6 +354,21 @@ class AiKeyboard( borderColor: ColorStateList, backgroundColor: ColorStateList ) { - + applyKeyBackgroundsForTheme() } -} + // ==============================刷新主题================================== + override fun applyKeyBackgroundsForTheme() { + // 背景 + applyKeyBackground(rootView, "background") + + // // AI 键盘上的功能键(按你现有 layout 里出现过的 id 来列) + // val others = listOf( + // "key_abc", // 返回主键盘 + // "key_vip", // VIP + // "Return_keyboard", // 返回 persona 页 + // "card" // 切换到 output 页 + // // 如果后续 ai_keyboard.xml 里还有其它需要换肤的 key id,继续往这里加 + // ) + // others.forEach { applyKeyBackground(rootView, it) } + } +} \ No newline at end of file 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 3a0ba1a..174ba82 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt @@ -1,33 +1,47 @@ package com.example.myapplication.keyboard +import android.content.Context import android.content.res.ColorStateList +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView -/** - * 所有键盘的基础类:只处理文本颜色、边距 / 内边距 这些通用样式。 - * 不再直接访问 resources,统一走 env.ctx.resources。 - */ abstract class BaseKeyboard( protected val env: KeyboardEnvironment ) { + protected val vibrator: Vibrator? = env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + /** 根布局 */ abstract val rootView: View /** - * 应用主题:文字颜色 + 边框(只调 margin/padding,不动你每个键的图片背景) + * 应用主题:文字颜色 + 边框 + 按键背景 + * 调用后文字颜色、边框立即生效,子类刷新按键背景 */ open fun applyTheme( textColor: ColorStateList, borderColor: ColorStateList, backgroundColor: ColorStateList ) { + // 文字颜色递归设置 applyTextColorToAllTextViews(rootView, textColor) + + // 边框(margin / padding)递归设置 applyBorderToAllKeyViews(rootView) + + // 子类刷新按键背景(如 ThemeManager 提供的图片) + applyKeyBackgroundsForTheme() } - // 文字颜色递归设置 + /** 子类实现:刷新按键背景 */ + abstract fun applyKeyBackgroundsForTheme() + + // ------------------- 工具方法 ------------------- + + /** 递归设置 TextView 文字颜色 */ protected fun applyTextColorToAllTextViews(root: View?, color: ColorStateList) { if (root == null) return @@ -35,11 +49,8 @@ abstract class BaseKeyboard( when (v) { is TextView -> v.setTextColor(color) is ViewGroup -> { - val childCount = v.childCount - var i = 0 - while (i < childCount) { + for (i in 0 until v.childCount) { dfs(v.getChildAt(i)) - i++ } } } @@ -49,35 +60,29 @@ abstract class BaseKeyboard( } /** - * 只设置 margin / padding,不统一改背景,避免覆盖你用 ThemeManager 设置的按键图。 - * 跟你原来 MyInputMethodService.applyBorderColorToAllKeys 的逻辑保持一致。 + * 只设置 margin / padding,不改背景,避免覆盖 ThemeManager 设置的按键图 */ protected fun applyBorderToAllKeyViews(root: View?) { if (root == null) return - val res = env.ctx.resources - val pkg = env.ctx.packageName - val keyMarginPx = 1.dpToPx() val keyPaddingH = 6.dpToPx() - // 忽略 suggestion_0..20(联想栏),不改它们背景 + // 忽略 suggestion_0..20(联想栏) val ignoredIds = HashSet().apply { - var i = 0 - while (i <= 20) { + val res = env.ctx.resources + val pkg = env.ctx.packageName + for (i in 0..20) { val id = res.getIdentifier("suggestion_$i", "id", pkg) if (id != 0) add(id) - i++ } } fun dfs(v: View?) { when (v) { is TextView -> { - if (ignoredIds.contains(v.id)) { - // 联想词不加边距 - return - } + if (ignoredIds.contains(v.id)) return + val lp = v.layoutParams if (lp is LinearLayout.LayoutParams) { lp.setMargins(keyMarginPx, keyMarginPx, keyMarginPx, keyMarginPx) @@ -91,11 +96,8 @@ abstract class BaseKeyboard( ) } is ViewGroup -> { - val childCount = v.childCount - var i = 0 - while (i < childCount) { + for (i in 0 until v.childCount) { dfs(v.getChildAt(i)) - i++ } } } @@ -104,9 +106,19 @@ abstract class BaseKeyboard( dfs(root) } - // dp -> px 工具 + /** dp -> px */ protected fun Int.dpToPx(): Int { val density = env.ctx.resources.displayMetrics.density return (this * density + 0.5f).toInt() } + + /** 按键震动 */ + protected fun vibrateKey() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(20) + } + } } diff --git a/app/src/main/java/com/example/myapplication/keyboard/EmojiKaomojiData.kt b/app/src/main/java/com/example/myapplication/keyboard/EmojiKaomojiData.kt new file mode 100644 index 0000000..2b0f584 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/EmojiKaomojiData.kt @@ -0,0 +1,60 @@ +package com.example.myapplication.keyboard + +data class SubCategory(val label: String, val items: List) + +object EmojiKaomojiData { + + fun emojiCategories(recents: List): List { + val recentItems = recents.ifEmpty { + // 没有历史时给一些默认(系统也是这样) + listOf("😀","😂","😍","😭","👍","🙏","🎉","❤️") + } + return listOf( + SubCategory("最近", listOf("😀","😂","😍","😭","👍","🙏","🎉","❤️","🔥","✨")), + SubCategory("表情", listOf("😀","😁","😂","🤣","😃","😄","😅","😆","😉","😊","😍","😘","😎","😭","😡","🤯","🥳","😴","🤔","🙃","😬","😇","😵‍💫","😮‍💨","🤥","🤫","😶‍🌫️","😐","😑","😒","😓","😕","🙄","😮","😯","😲","😴","🤤","🤒","🤕","🤧","🥴","🤮","🤢","😷","🤠")), + SubCategory("手势", listOf("👍","👎","👌","✌️","🤞","🤟","🤘","👏","🙌","🙏","👋","🤝","💪","✋","🖐️")), + SubCategory("爱情", listOf("❤️","🧡","💛","💚","💙","💜","🖤","🤍","💔","🤎","💞","💕","💓","💗","💖","💘","💝","💟","💑","👩‍❤️‍👩","👨‍❤️‍👨")), + SubCategory("符号", listOf("✨","🔥","💯","🎉","✅","❌","⚠️","⭐","🌟")), + SubCategory("庆祝", listOf("🎉","🎊","🥳","🎂","🎁","🏆","🥇","🥈","🥉","🎯","🚩","✨","🌟","💯","👏","🙌","🥂","🍾")), + SubCategory("日常", listOf("⏰","⌛","⏳","📅","📆","🕒","🕕","🌅","🌄","🌇","🌆","🌃","🌌","☕","🍵","🧃","🛏️","🛋️","🪑","🧸","🕯️")), + SubCategory("动物", listOf("🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵","🐧","🐦","🐤")), + SubCategory("工作", listOf("💼","📊","📈","📉","📋","📝","📌","📍","📎","💰","💵","💴","💶","💷","💳","🧾","🏦","🏢","🏬")), + SubCategory("食物", listOf("🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🍒","🍑","🥭","🍍","🥝","🍅","🥑","🥦","🥕","🌽","🥔","🍠","🍞","🥐","🥖","🥨","🧀","🥚","🍗","🍖","🌭","🍔","🍟","🍕","🥪","🌮","🌯","🍜","🍣","🍤","🦀","🍰","🧁","🍩","🍪","🍫","🍿","🍦","🍧","🍭","☕","🥤","🍺","🍷")), + SubCategory("交通", listOf("🚗","🚕","🚙","🚌","🚎","🏎️","🚓","🚑","🚒","🚐","🚚","🚛","🚲","🛵","🏍️","🛴","🚂","🚆","🚄","🚅","✈️","🛫","🛬","🛩️","🚀","🛰️","⛵","🛶","🚤","🛳️","🚢")), + SubCategory("天气", listOf("☀️","🌤️","⛅","🌥️","☁️","🌦️","🌧️","⛈️","🌩️","❄️","🌨️","💨","🌪️","🌈","🌙","⭐","🌟","⚡","🔥","💧","🌊","🌫️","🍃","🌸","🍀","🌵")), + SubCategory("物品", listOf("📱","💻","⌨️","🖥️","🖨️","🎧","🎮","📷","🎥","📺","🔋","💡","🔦","🔑","🧰","🛠️","🔧","🔨","🧲","📌","📎","✂️","🖊️","📝","📚","📦")), + SubCategory("运动", listOf("⚽","🏀","🏈","⚾","🎾","🏐","🏉","🥏","🎳","🏓","🏸","🥊","🥋","⛳","🛹","⛷️","🏂","🏋️","🤸","🏃","🚴","🏊","🧘","🧗")), + SubCategory("标志", listOf("⭕","❌","❗","❓","‼️","⁉️","🔺","🔻","🔸","🔹","🔶","🔷","🟢","🟡","🟠","🔴","⚫","⚪","🟣","🟤","✔️","☑️","🔘","🔲","🔳","⬆️","⬇️","⬅️","➡️","↩️","↪️")), + SubCategory("箭头", listOf("⬆️","⬇️","⬅️","➡️","↗️","↘️","↙️","↖️","⏫","⏬","⏩","⏪","🔼","🔽","▶️","⏸️","⏹️","⏺️","🔁","🔂")), + SubCategory("按键", listOf("🅰️","🅱️","🅾️","🆎","🆑","🆒","🆓","🆔","🆕","🆖","🆗","🆘","🆙","🆚","🔠","🔡","🔢","🔣","🔤","🔞")), + SubCategory("国家", listOf("🇦🇨","🇦🇩","🇦🇪","🇦🇫","🇦🇬","🇦🇮","🇦🇱","🇦🇲","🇦🇴","🇦🇶","🇦🇷","🇦🇸","🇦🇹","🇦🇺","🇦🇼","🇦🇽","🇦🇿","🇧🇦","🇧🇧","🇧🇩","🇧🇪","🇧🇫","🇧🇬","🇧🇭","🇧🇮","🇧🇯","🇧🇱","🇧🇲","🇧🇳","🇧🇴","🇧🇶","🇧🇷","🇧🇸","🇧🇹","🇧🇻","🇧🇼","🇧🇾","🇧🇿","🇨🇦","🇨🇨","🇨🇩","🇨🇫","🇨🇬","🇨🇭","🇨🇮","🇨🇰","🇨🇱","🇨🇲","🇨🇳","🇨🇴","🇨🇵","🇨🇷","🇨🇺","🇨🇻","🇨🇼","🇨🇽","🇨🇾","🇨🇿","🇩🇪","🇩🇬","🇩🇯","🇩🇰","🇩🇲","🇩🇴","🇩🇿","🇪🇦","🇪🇨","🇪🇪","🇪🇬","🇪🇭","🇪🇷","🇪🇸","🇪🇹","🇪🇺","🇫🇮","🇫🇯","🇫🇰","🇫🇲","🇫🇴","🇫🇷","🇬🇦","🇬🇧","🇬🇩","🇬🇪","🇬🇫","🇬🇬","🇬🇭","🇬🇮","🇬🇱","🇬🇲","🇬🇳","🇬🇵","🇬🇶","🇬🇷","🇬🇸","🇬🇹","🇬🇺","🇬🇼","🇬🇾","🇭🇰","🇭🇲","🇭🇳","🇭🇷","🇭🇹","🇭🇺","🇮🇨","🇮🇩","🇮🇪","🇮🇱","🇮🇲","🇮🇳","🇮🇴","🇮🇶","🇮🇷","🇮🇸","🇮🇹","🇯🇪","🇯🇲","🇯🇴","🇯🇵","🇰🇪","🇰🇬","🇰🇭","🇰🇮","🇰🇲","🇰🇳","🇰🇵","🇰🇷","🇰🇼","🇰🇾","🇰🇿","🇱🇦","🇱🇧","🇱🇨","🇱🇮","🇱🇰","🇱🇷","🇱🇸","🇱🇹","🇱🇺","🇱🇻","🇱🇾","🇲🇦","🇲🇨","🇲🇩","🇲🇪","🇲🇫","🇲🇬","🇲🇭","🇲🇰","🇲🇱","🇲🇲","🇲🇳","🇲🇴","🇲🇵","🇲🇶","🇲🇷","🇲🇸","🇲🇹","🇲🇺","🇲🇻","🇲🇼","🇲🇽","🇲🇾","🇲🇿","🇳🇦","🇳🇨","🇳🇪","🇳🇫","🇳🇬","🇳🇮","🇳🇱","🇳🇴","🇳🇵","🇳🇷","🇳🇺","🇳🇿","🇴🇲","🇵🇦","🇵🇪","🇵🇫","🇵🇬","🇵🇭","🇵🇰","🇵🇱","🇵🇲","🇵🇳","🇵🇷","🇵🇸","🇵🇹","🇵🇼","🇵🇾","🇶🇦","🇷🇪","🇷🇴","🇷🇸","🇷🇺","🇷🇼","🇸🇦","🇸🇧","🇸🇨","🇸🇩","🇸🇪","🇸🇬","🇸🇭","🇸🇮","🇸🇯","🇸🇰","🇸🇱","🇸🇲","🇸🇳","🇸🇴","🇸🇷","🇸🇸","🇸🇹","🇸🇻","🇸🇽","🇸🇾","🇸🇿","🇹🇦","🇹🇨","🇹🇩","🇹🇫","🇹🇬","🇹🇭","🇹🇯","🇹🇰","🇹🇱","🇹🇲","🇹🇳","🇹🇴","🇹🇷","🇹🇹","🇹🇻","🇹🇼","🇹🇿","🇺🇦","🇺🇬","🇺🇲","🇺🇳","🇺🇸","🇺🇾","🇺🇿","🇻🇦","🇻🇨","🇻🇪","🇻🇬","🇻🇮","🇻🇳","🇻🇺","🇼🇫","🇼🇸","🇽🇰","🇾🇪","🇾🇹","🇿🇦","🇿🇲","🇿🇼")), + SubCategory("地点", listOf("🏠","🏡","🏢","🏣","🏥","🏦","🏫","🏬","🏭","🏨","🏪","🏩","🏛️","⛪","🕌","🛕","🕍","🗼","🗽","⛩️","🗿","🎡","🎢","🎠","🏖️","🏕️","⛰️","🏔️")) + ) + } + + fun kaomojiCategories(recents: List): List { + val recentItems = recents.ifEmpty { + listOf("(。・ω・。)","(๑•̀ㅂ•́)و✧","(T_T)","(╯°□°)╯︵ ┻━┻") + } + + return listOf( + SubCategory("常用", listOf("(。・ω・。)","(๑•̀ㅂ•́)و✧","( ̄▽ ̄)","(╯°□°)╯︵ ┻━┻","┬─┬ノ( º _ ºノ)","(T_T)","(ಥ_ಥ)","(ಡωಡ)","(ง •_•)ง","(づ。◕‿‿◕。)づ")), + SubCategory("开心", listOf("(*^▽^*)","(≧▽≦)","(๑>◡<๑)","ヽ(•‿•)ノ","(ノ◕ヮ◕)ノ*:・゚✧","(´▽`)","(•̀ᴗ•́)و")), + SubCategory("可爱", listOf("(。♥‿♥。)","(•ө•)♡","(≧ω≦)","(灬º‿º灬)♡","(づ ̄ ³ ̄)づ","(。•ㅅ•。)♡")), + SubCategory("生气", listOf("(#`皿´)","(╬ ̄皿 ̄)","(¬_¬)","(`Д´)","(ง'̀-'́)ง")), + SubCategory("哭泣", listOf("(T_T)","(ಥ_ಥ)","(;_;)","(╥﹏╥)","(。•́︿•̀。)","(இдஇ; )")), + SubCategory("害羞", listOf("(*/ω\*)","(///▽///)","(⁄ ⁄•⁄ω⁄•⁄ ⁄)","(。>﹏<。)","(๑•﹏•)","(〃∀〃)","(⁄ ⁄>⁄ ▽ ⁄<⁄ ⁄)","(;´Д`)","(〃ω〃)","(*/▽\*)")), + SubCategory("惊讶", listOf("Σ(°△°|||)︴","(⊙_⊙)","(°ロ°) !","(゚д゚)","(ಠ_ಠ)","(╯°□°)╯︵ ┻━┻","(ʘᗩʘ')","(;゚Д゚)","(゜ロ゜)","(; ̄Д ̄)")), + SubCategory("无语", listOf("(¬_¬)","(눈_눈)","(;一_一)","( ̄□ ̄;)","(。-_-。)","(=_=)","(-_-) zzZ","(・_・;)","(ಠ‿ಠ)","(;¬_¬)")), + SubCategory("加油", listOf("(๑•̀ㅂ•́)و✧","(ง •_•)ง","(ง'̀-'́)ง","( •̀ᴗ•́ )و","(๑•̀ᴗ•́)و","٩(ˊᗜˋ*)و","ᕦ(ò_óˇ)ᕤ","(ノ≧ڡ≦)","(๑و•̀Δ•́)و")), + SubCategory("爱心", listOf("(。♥‿♥。)","(づ ̄ ³ ̄)づ","(づ。◕‿‿◕。)づ","(っ˘з(˘⌣˘ )","( ˘ ³˘)♥","(♡˙︶˙♡)","(❤ω❤)","(ღ˘⌣˘ღ)","(っ´▽`)っ","(づ◡﹏◡)づ")), + SubCategory("困", listOf("(-_-) zzZ","( ̄o ̄) . z Z","(_ _*) Z z z","(¦3[▓▓]","(つω-。)","(。-ω-。)","( ̄ρ ̄)..zzZZ","( ˘ω˘ )スヤァ")), + SubCategory("调皮", listOf("(๑˃̵ᴗ˂̵)و","(≖‿≖)✧","( ̄y▽ ̄)╭","(๑>؂<๑)","(`∀´)","(^▽^)","(๑¯∀¯๑)","(๑˘︶˘๑)","(。•̀ᴗ-)✧")), + SubCategory("思考", listOf("(・_・?)","(・・;)","( ̄へ ̄)","(¬‿¬)","(¬_¬)","(一_一)","(ಠ_ಠ)","(눈_눈)","( ̄~ ̄;)")), + SubCategory("崩溃", listOf("(;´Д`)","(╥﹏╥)","(ಥ﹏ಥ)","(T_T)","(つД`)","(இдஇ; )","(ノД`)・゜・。","(;_;)","(;ω;)")), + SubCategory("佛系", listOf("( ̄ー ̄)","( ˘_˘ )","( ̄ω ̄)","( -_-)","(´ー`)","( ̄~ ̄)","(=_=)","( ̄ρ ̄)..zzZZ","( ˘ω˘ )")), + SubCategory("回应", listOf("( ̄▽ ̄)ゞ","( ´ ▽ ` )ノ","(`・ω・´)","(。•̀ᴗ-)✧","(×_×)","(>﹏<)","(; ̄Д ̄)","( ̄□ ̄;)")), + SubCategory("拒绝", listOf("(>﹏<)","(×_×)","( ̄□ ̄;)","(; ̄Д ̄)","(╯︵╰,)","(╥﹏╥)","(ಠ_ಠ)")) + ) + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/EmojiKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/EmojiKeyboard.kt new file mode 100644 index 0000000..f97d397 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/EmojiKeyboard.kt @@ -0,0 +1,240 @@ +package com.example.myapplication.keyboard + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.View +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.viewpager2.widget.ViewPager2 +import com.example.myapplication.R + +class EmojiKeyboard(private val env: KeyboardEnvironment) { + + val rootView: View = LayoutInflater.from(env.ctx).inflate(R.layout.keyboard_emoji, null, false) + + private val tabEmoji: TextView = rootView.findViewById(R.id.tab_emoji) + private val tabKaomoji: TextView = rootView.findViewById(R.id.tab_kaomoji) + + private val subBar: LinearLayout = rootView.findViewById(R.id.subcategory_bar) + private val subcategoryScrollView: HorizontalScrollView = rootView.findViewById(R.id.subcategory_scroll) + private val pager: ViewPager2 = rootView.findViewById(R.id.pager) + // private val indicatorLayout: LinearLayout = rootView.findViewById(R.id.page_indicator) + // private val indicator = PageIndicator(indicatorLayout) + + private val backspace: View = rootView.findViewById(R.id.key_del) + private val toABC: View = rootView.findViewById(R.id.key_abc) + + private enum class Mode { EMOJI, KAOMOJI } + private var mode: Mode = Mode.EMOJI + private val recentStore = RecentStore(env.ctx) + + // 你按键格子大小决定 + private val emojiSpan = 6 + private val emojiPageSize = 6 * 4 + + private val kaomojiSpan = 2 + private val kaomojiPageSize = 2 * 4 + + private val pagerAdapter = InfiniteFlatPagerAdapter( + spanCount = emojiSpan, + onItemClick = { s -> + val isEmojiNow = (mode == Mode.EMOJI) + + env.getInputConnection()?.commitText(s, 1) + env.playKeyClick() + + // ✅ 写入最近 + recentStore.push(s, isEmojiNow) + + // ✅ 立刻刷新“最近分类”的数据并更新 pager(无论当前在哪个分类) + refreshRecentsAndRebuildPages(isEmojiNow) + } + ) + + private fun refreshRecentsAndRebuildPages(isEmojiNow: Boolean) { + // 1) 用最新 recents 覆盖 categories 的“最近”项 + val newRecents = recentStore.get(isEmoji = isEmojiNow) + + if (categories.isNotEmpty()) { + val first = categories[0] + // 假设 categories[0] 就是“最近”(你的数据就是这么组织的) + categories = categories.toMutableList().apply { + this[0] = first.copy(items = newRecents) + } + } + + // 2) 用当前点击类型对应的 pageSize 重建 flatPages + val (_, pageSize) = if (isEmojiNow) + emojiSpan to emojiPageSize + else + kaomojiSpan to kaomojiPageSize + + flatPages = FlatPageBuilder.buildFlatPages(categories, pageSize) + + // 3) 记录当前页的 realIndex,避免刷新后把用户甩回“最近” + val oldAdapterPos = pager.currentItem + val oldRealIndex = pagerAdapter.getRealIndex(oldAdapterPos) + + // 4) 重新提交 pages(span 不变) + val span = if (isEmojiNow) emojiSpan else kaomojiSpan + val itemLayoutRes = if (isEmojiNow) { + R.layout.item_emoji + } else { + R.layout.item_kaomoji + } + pagerAdapter.submit(flatPages, span, itemLayoutRes) + + // 5) 回到原来的 realIndex(对应同一位置) + val base = pagerAdapter.getBasePosition() + pager.setCurrentItem(base + oldRealIndex, false) + pager.post { onForceSyncUI(base + oldRealIndex) } + } + + + // 当前模式下的数据 + private var categories: List = emptyList() + private var flatPages: List = emptyList() + + init { + pager.adapter = pagerAdapter + + toABC.setOnClickListener { env.showMainKeyboard() } + backspace.setOnClickListener { env.deleteOne() } + + tabEmoji.setOnClickListener { switchMode(Mode.EMOJI) } + tabKaomoji.setOnClickListener { switchMode(Mode.KAOMOJI) } + + pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + val page = pagerAdapter.getPageByAdapterPos(position) ?: return + // 1) 高亮子分类 + highlightSubTabs(page.catIndex) + + // // 2) dots:该分类有多少页,当前第几页 + // indicator.setPageCount(page.catPageCount) + // indicator.setSelected(page.pageInCat) + } + }) + + // 默认 Emoji + switchMode(Mode.EMOJI) + } + + private fun switchMode(m: Mode) { + mode = m + tabEmoji.isSelected = (mode == Mode.EMOJI) + tabKaomoji.isSelected = (mode == Mode.KAOMOJI) + rebuildForMode() + } + + private fun rebuildForMode() { + val recents = recentStore.get(isEmoji = (mode == Mode.EMOJI)) + + categories = if (mode == Mode.EMOJI) + EmojiKaomojiData.emojiCategories(recents) + else + EmojiKaomojiData.kaomojiCategories(recents) + + val (span, pageSize) = if (mode == Mode.EMOJI) + emojiSpan to emojiPageSize + else + kaomojiSpan to kaomojiPageSize + + flatPages = FlatPageBuilder.buildFlatPages(categories, pageSize) + + // 重建子分类栏 + rebuildSubCategoryBar() + + // 更新 pager 数据(无限循环) + val itemLayoutRes = if (mode == Mode.EMOJI) { + R.layout.item_emoji + } else { + R.layout.item_kaomoji + } + pagerAdapter.submit(flatPages, span, itemLayoutRes) + + // 跳到 base(对齐 realIndex=0) + val base = pagerAdapter.getBasePosition() + if (pagerAdapter.itemCount > 0) { + pager.setCurrentItem(base, false) + // 主动刷新一次 UI(某些机型第一次不触发 onPageSelected) + pager.post { onForceSyncUI(base) } + } else { + // indicator.clear() + } + } + + private fun onForceSyncUI(adapterPos: Int) { + val page = pagerAdapter.getPageByAdapterPos(adapterPos) ?: return + highlightSubTabs(page.catIndex) + // indicator.setPageCount(page.catPageCount) + // indicator.setSelected(page.pageInCat) + } + + private fun rebuildSubCategoryBar() { + subBar.removeAllViews() + + categories.forEachIndexed { idx, cat -> + val tv = LayoutInflater.from(env.ctx) + .inflate(R.layout.item_emoji_tab, subBar, false) as TextView + + val tabText = if (mode == Mode.EMOJI) { + when (cat.label) { + "最近" -> "🕘" + else -> cat.items.firstOrNull().orEmpty().ifBlank { cat.label } + } + } else { + when (cat.label) { + "常用" -> "🕘" + else -> cat.items.firstOrNull().orEmpty().ifBlank { cat.label } + } + // cat.label + } + + tv.text = tabText + tv.isSelected = (idx == 0) + tv.textSize = if (mode == Mode.EMOJI) 18f else 14f + + tv.setOnClickListener { jumpToCategory(idx) } + subBar.addView(tv) + } + } + private fun highlightSubTabs(catIndex: Int) { + for (i in 0 until subBar.childCount) { + (subBar.getChildAt(i) as? TextView)?.isSelected = (i == catIndex) + } + + // 自动滚动到当前选中的子分类标签 + if (catIndex < subBar.childCount) { + val selectedView = subBar.getChildAt(catIndex) + subcategoryScrollView.post { + val scrollTo = selectedView.left - (subcategoryScrollView.width - selectedView.width) / 2 + subcategoryScrollView.smoothScrollTo(scrollTo, 0) + } + } + } + + /** + * 跳到某个子分类的“第 1 页” + * 本质:找到 flatPages 里该分类的第一个 page 的 realIndex,然后 setCurrentItem(base + realIndex) + */ + private fun jumpToCategory(catIndex: Int) { + if (flatPages.isEmpty()) return + val realIndex = flatPages.indexOfFirst { it.catIndex == catIndex && it.pageInCat == 0 } + .takeIf { it >= 0 } ?: return + + val base = pagerAdapter.getBasePosition() + pager.setCurrentItem(base + realIndex, false) + pager.post { onForceSyncUI(base + realIndex) } + } + + fun applyTheme(text: ColorStateList, border: ColorStateList, bg: ColorStateList) { + tabEmoji.setTextColor(text) + tabKaomoji.setTextColor(text) + for (i in 0 until subBar.childCount) { + (subBar.getChildAt(i) as? TextView)?.setTextColor(text) + } + // dot 如果要跟主题走:可以把 item_dot 改成 View,然后 setBackgroundTintList + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/FlatPage.kt b/app/src/main/java/com/example/myapplication/keyboard/FlatPage.kt new file mode 100644 index 0000000..ebf5fb1 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/FlatPage.kt @@ -0,0 +1,9 @@ +package com.example.myapplication.keyboard + +data class FlatPage( + val catIndex: Int, + val catLabel: String, + val pageInCat: Int, // 0-based + val catPageCount: Int, + val items: List +) diff --git a/app/src/main/java/com/example/myapplication/keyboard/FlatPageBuilder.kt b/app/src/main/java/com/example/myapplication/keyboard/FlatPageBuilder.kt new file mode 100644 index 0000000..382bc27 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/FlatPageBuilder.kt @@ -0,0 +1,41 @@ +package com.example.myapplication.keyboard + +object FlatPageBuilder { + + fun buildFlatPages( + categories: List, + pageSize: Int + ): List { + val out = ArrayList() + categories.forEachIndexed { catIdx, cat -> + val chunks = if (cat.items.isEmpty()) emptyList() else cat.items.chunked(pageSize) + val total = chunks.size.coerceAtLeast(1) + + if (chunks.isEmpty()) { + // 没数据也给一个空页,避免 pager 0 页 + out.add( + FlatPage( + catIndex = catIdx, + catLabel = cat.label, + pageInCat = 0, + catPageCount = 1, + items = emptyList() + ) + ) + } else { + chunks.forEachIndexed { pageIdx, items -> + out.add( + FlatPage( + catIndex = catIdx, + catLabel = cat.label, + pageInCat = pageIdx, + catPageCount = total, + items = items + ) + ) + } + } + } + return out + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/InfiniteFlatPagerAdapter.kt b/app/src/main/java/com/example/myapplication/keyboard/InfiniteFlatPagerAdapter.kt new file mode 100644 index 0000000..fd53c6b --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/InfiniteFlatPagerAdapter.kt @@ -0,0 +1,109 @@ +package com.example.myapplication.keyboard + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R + +class InfiniteFlatPagerAdapter( + private var spanCount: Int, + private val onItemClick: (String) -> Unit +) : RecyclerView.Adapter() { + + private var flatPages: List = emptyList() + private var basePosition: Int = 0 + private var itemLayoutRes: Int = R.layout.item_emoji + + fun submit(pages: List, newSpan: Int, newItemLayoutRes: Int) { + flatPages = pages + spanCount = newSpan + itemLayoutRes = newItemLayoutRes + basePosition = computeBasePosition() + notifyDataSetChanged() + } + + fun getBasePosition(): Int = basePosition + + fun realCount(): Int = flatPages.size + + fun getRealIndex(adapterPosition: Int): Int { + val n = flatPages.size + if (n == 0) return 0 + val x = adapterPosition - basePosition + val m = x % n + return if (m >= 0) m else (m + n) + } + + fun getPageByAdapterPos(adapterPosition: Int): FlatPage? { + if (flatPages.isEmpty()) return null + return flatPages[getRealIndex(adapterPosition)] + } + + override fun getItemCount(): Int { + return if (flatPages.isEmpty()) 0 else Int.MAX_VALUE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageVH { + val v = LayoutInflater.from(parent.context).inflate(R.layout.pager_page_grid, parent, false) + return PageVH(v, spanCount, itemLayoutRes, onItemClick) + } + + override fun onBindViewHolder(holder: PageVH, position: Int) { + // 每次 bind 都把“当前 adapter 的配置”同步进去,避免复用交叉 + holder.updateConfig(spanCount, itemLayoutRes, onItemClick) + + val page = getPageByAdapterPos(position) ?: return + holder.bind(page.items) + } + + private fun computeBasePosition(): Int { + val n = flatPages.size + if (n == 0) return 0 + val mid = Int.MAX_VALUE / 2 + // 对齐到 realIndex=0 + return mid - (mid % n) + } + + class PageVH( + itemView: View, + spanCount: Int, + itemLayoutRes: Int, + onItemClick: (String) -> Unit + ) : RecyclerView.ViewHolder(itemView) { + + private val grid: RecyclerView = itemView.findViewById(R.id.page_grid) + + private var curSpan = spanCount + private var curItemLayoutRes = itemLayoutRes + + private var adapter = SimpleStringGridAdapter(curItemLayoutRes, onItemClick) + + init { + grid.layoutManager = GridLayoutManager(itemView.context, curSpan) + grid.adapter = adapter + } + + fun updateConfig(spanCount: Int, itemLayoutRes: Int, onItemClick: (String) -> Unit) { + // 1) 更新 span(layoutManager 存在则改 spanCount) + val lm = (grid.layoutManager as? GridLayoutManager) + if (lm != null && curSpan != spanCount) { + lm.spanCount = spanCount + curSpan = spanCount + } + + // 2) 更新 item 布局(需要换 adapter 或让 adapter 支持 setLayoutRes) + if (curItemLayoutRes != itemLayoutRes) { + curItemLayoutRes = itemLayoutRes + adapter = SimpleStringGridAdapter(curItemLayoutRes, onItemClick) + grid.adapter = adapter + } + } + + fun bind(items: List) { + adapter.submit(items) + } + } + +} 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 2ce828b..cd6f98c 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt @@ -37,7 +37,15 @@ interface KeyboardEnvironment { fun showNumberKeyboard() fun showSymbolKeyboard() fun showAiKeyboard() + //emoji键盘 + fun showEmojiKeyboard() // 音效 fun playKeyClick() + + // 回填上次清空的文本 + fun revokeLastClearedText() + + // 检查是否有可回填的文本 + fun hasClearedText(): Boolean } 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 41f9cc0..74d2be0 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt @@ -20,9 +20,6 @@ import com.example.myapplication.theme.ThemeManager class MainKeyboard( env: KeyboardEnvironment, private val swipeAltMap: Map, - /** - * 交给 MyInputMethodService 切换 Shift 状态,并返回最新状态 - */ private val onToggleShift: () -> Boolean ) : BaseKeyboard(env) { @@ -34,211 +31,153 @@ class MainKeyboard( private var isShiftOn: Boolean = false private var keyPreviewPopup: PopupWindow? = null - // ======================== 震动相关 ======================== - - private val vibrator: Vibrator? = - env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - - private fun vibrateKey( - duration: Long = 30L, // 时间:10~40 推荐 - amplitude: Int = 255 // 1~255,100~150 最舒服 - ) { - val v = vibrator ?: return - if (!v.hasVibrator()) return - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - v.vibrate( - VibrationEffect.createOneShot(duration, amplitude) - ) - } else { - @Suppress("DEPRECATION") - v.vibrate(duration) - } - } catch (_: SecurityException) { - // 没权限就自动静音,不崩溃 - } - } - init { applyPerKeyBackgroundForMainKeyboard(rootView) - - applyTheme( - env.currentTextColor, - env.currentBorderColor, - env.currentBackgroundColor - ) - + applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor) setupListenersForMain(rootView) } - // ======================== 背景图 ======================== - + // -------------------- 背景 -------------------- private fun applyPerKeyBackgroundForMainKeyboard(root: View) { - // a..z 小写 var c = 'a' while (c <= 'z') { - val idName = "key_$c" - applyKeyBackground(root, idName) + applyKeyBackground(root, "key_$c") c++ } - // 键盘背景 applyKeyBackground(root, "background") - // 其他功能键 val others = listOf( - "key_space", - "key_send", - "key_del", - "key_up", - "key_123", - "key_ai", - "Key_collapse" + "key_space", "key_send", "key_del", "key_up", + "key_123", "key_ai", "Key_collapse","key_emoji","key_revoke" ) - for (idName in others) { - applyKeyBackground(root, idName) - } + others.forEach { applyKeyBackground(root, it) } } - private fun applyKeyBackground( - root: View, - viewIdName: String, - drawableName: String? = null - ) { + private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) { val res = env.ctx.resources val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) - if (viewId == 0) return val v = root.findViewById(viewId) ?: return - val keyName = drawableName ?: viewIdName val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return if (viewIdName == "background") { - val scaled = scaleDrawableToHeight(rawDrawable, 243f) - v.background = scaled - return + v.background = scaleDrawableToHeight(rawDrawable, 243f) + } else { + v.background = rawDrawable } - v.background = rawDrawable } private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable { val res = env.ctx.resources val dm = res.displayMetrics - val targetHeightPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - targetDp, - dm - ).toInt() - + val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt() val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src - val w = bitmap.width - val h = bitmap.height - - val ratio = targetHeightPx.toFloat() / h - val targetWidthPx = (w * ratio).toInt() - + val ratio = targetHeightPx.toFloat() / bitmap.height + val targetWidthPx = (bitmap.width * ratio).toInt() val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true) - return BitmapDrawable(res, scaled).apply { - setBounds(0, 0, targetWidthPx, targetHeightPx) - } + return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) } } - // ======================== 事件绑定 ======================== + // -------------------- 实现主题刷新 -------------------- + override fun applyKeyBackgroundsForTheme() { + // 刷新字母背景 + var c = 'a' + while (c <= 'z') { + val drawableName = if (isShiftOn) "key_${c}_up" else "key_$c" + applyKeyBackground(rootView, "key_$c", drawableName) + c++ + } + // 刷新 Shift 键 + val upDrawableName = if (isShiftOn) "key_up_upper" else "key_up" + applyKeyBackground(rootView, "key_up", upDrawableName) + // 刷新其他功能键 + val others = listOf("key_space", "key_send", "key_del", "key_123", "key_ai","key_emoji", "Key_collapse", "background","key_revoke") + others.forEach { applyKeyBackground(rootView, it) } + } + + // -------------------- 事件绑定 -------------------- private fun setupListenersForMain(view: View) { val res = env.ctx.resources val pkg = env.ctx.packageName - // a..z:支持上滑副字符 + // 初始化时设置Revoke按钮的可见性 + updateRevokeButtonVisibility(view, res, pkg) + var c = 'a' while (c <= 'z') { - val id = res.getIdentifier("key_$c", "id", pkg) - val tv = view.findViewById(id) + val tv = view.findViewById(res.getIdentifier("key_$c", "id", pkg)) if (tv != null) { val baseChar = c val altChar = swipeAltMap[baseChar] - - attachKeyTouchWithSwipe( - tv, - normalCharProvider = { baseChar }, - altCharProvider = altChar?.let { ac -> - { ac } - } - ) + attachKeyTouchWithSwipe(tv, { baseChar }, altChar?.let { { it } }) } c++ } - // space - view.findViewById(res.getIdentifier("key_space", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.commitKey(' ') - } + view.findViewById(res.getIdentifier("key_space", "id", pkg))?.setOnClickListener { + vibrateKey(); env.commitKey(' ') + // 输入新内容后更新按钮可见性 + updateRevokeButtonVisibility(view, res, pkg) + } - // Shift - val shiftId = res.getIdentifier("key_up", "id", pkg) - view.findViewById(shiftId)?.setOnClickListener { + view.findViewById(res.getIdentifier("key_up", "id", pkg))?.setOnClickListener { vibrateKey() isShiftOn = onToggleShift() it.isActivated = isShiftOn - updateKeyBackgroundsForLetters(view) + applyKeyBackgroundsForTheme() // 立即刷新主题背景 } - // 删除(单击;长按由 MyInputMethodService 挂) - view.findViewById(res.getIdentifier("key_del", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.deleteOne() - } - - //关闭键盘 - rootView.findViewById(res.getIdentifier("collapse_button", "id", pkg)) - ?.setOnClickListener { - vibrateKey() // 如果这个方法在当前类里有 - env.hideKeyboard() + view.findViewById(res.getIdentifier("key_del", "id", pkg))?.setOnClickListener { + vibrateKey(); env.deleteOne() + // 删除内容后更新按钮可见性 + updateRevokeButtonVisibility(view, res, pkg) } + + view.findViewById(res.getIdentifier("collapse_button", "id", pkg))?.setOnClickListener { + vibrateKey(); env.hideKeyboard() + } + + view.findViewById(res.getIdentifier("key_123", "id", pkg))?.setOnClickListener { + vibrateKey(); env.showNumberKeyboard() + } + + view.findViewById(res.getIdentifier("key_ai", "id", pkg))?.setOnClickListener { + vibrateKey(); env.showAiKeyboard() + } + + view.findViewById(res.getIdentifier("key_send", "id", pkg))?.setOnClickListener { + vibrateKey(); env.performSendAction() + // 发送后更新按钮可见性 + updateRevokeButtonVisibility(view, res, pkg) + } + + view.findViewById(res.getIdentifier("key_revoke", "id", pkg))?.setOnClickListener { + vibrateKey(); env.revokeLastClearedText() + // 回填后更新按钮可见性 + updateRevokeButtonVisibility(view, res, pkg) + } + + view.findViewById(res.getIdentifier("key_emoji", "id", pkg))?.setOnClickListener { + vibrateKey(); env.showEmojiKeyboard() + } + } + + // 更新Revoke按钮的可见性 + private fun updateRevokeButtonVisibility(view: View, res: android.content.res.Resources, pkg: String) { + val revokeButton = view.findViewById(res.getIdentifier("key_revoke", "id", pkg)) + revokeButton?.visibility = if (env.hasClearedText()) View.VISIBLE else View.GONE + } - // 切换数字键盘 - view.findViewById(res.getIdentifier("key_123", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showNumberKeyboard() - } - - // 跳 AI - view.findViewById(res.getIdentifier("key_ai", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showAiKeyboard() - } - - // 发送 - view.findViewById(res.getIdentifier("key_send", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.performSendAction() - } + // 公共方法:更新Revoke按钮的可见性(供外部调用) + fun updateRevokeButtonVisibility() { + val res = env.ctx.resources + val pkg = env.ctx.packageName + updateRevokeButtonVisibility(rootView, res, pkg) } - // Shift 后更新字母按键背景(key_a vs key_a_up) - private fun updateKeyBackgroundsForLetters(root: View) { - var c = 'a' - while (c <= 'z') { - val idName = "key_$c" - val drawableName = if (isShiftOn) "${idName}_up" else idName - applyKeyBackground(root, idName, drawableName) - c++ - } - - val upKeyIdName = "key_up" - val upDrawableName = if (isShiftOn) "key_up_upper" else "key_up" - applyKeyBackground(root, upKeyIdName, upDrawableName) - } - - // ======================== 触摸 + 预览 ======================== - + // -------------------- 触摸 + 预览 -------------------- private fun attachKeyTouchWithSwipe( view: View, normalCharProvider: () -> Char, @@ -252,10 +191,9 @@ class MainKeyboard( view.setOnTouchListener { v, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - downY = event.rawY - isAlt = false + downY = event.rawY; isAlt = false currentChar = normalCharProvider() - vibrateKey() // 按下就震 + vibrateKey() showKeyPreview(v, currentChar.toString()) v.isPressed = true true @@ -278,8 +216,7 @@ class MainKeyboard( v.isPressed = false true } - MotionEvent.ACTION_CANCEL, - MotionEvent.ACTION_OUTSIDE -> { + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> { keyPreviewPopup?.dismiss() v.isPressed = false true @@ -291,7 +228,6 @@ class MainKeyboard( private fun showKeyPreview(anchor: View, text: String) { keyPreviewPopup?.dismiss() - val tv = TextView(env.ctx).apply { this.text = text textSize = 26f @@ -309,14 +245,7 @@ class MainKeyboard( val w = (anchor.width * 1.2f).toInt() val h = (anchor.height * 1.2f).toInt() - keyPreviewPopup = PopupWindow(tv, w, h, false).apply { - isClippingEnabled = false - } - - keyPreviewPopup?.showAsDropDown( - anchor, - -(w - anchor.width) / 2, - -(h + anchor.height * 1.1f).toInt() - ) + keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false } + keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt()) } } 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 f5f3a35..1e78b33 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/NumberKeyboard.kt @@ -15,7 +15,6 @@ import android.view.MotionEvent import android.view.View import android.widget.PopupWindow import android.widget.TextView -import com.example.myapplication.R import com.example.myapplication.theme.ThemeManager class NumberKeyboard( @@ -29,241 +28,164 @@ class NumberKeyboard( private var keyPreviewPopup: PopupWindow? = null - // ================= 震动相关 ================= - - private val vibrator: Vibrator? = - env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - - private fun vibrateKey( - duration: Long = 30L, // 时间:10~40 推荐 - amplitude: Int = 255 // 1~255,100~150 最舒服 - ) { - val v = vibrator ?: return - if (!v.hasVibrator()) return - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - v.vibrate( - VibrationEffect.createOneShot(duration, amplitude) - ) - } else { - @Suppress("DEPRECATION") - v.vibrate(duration) - } - } catch (_: SecurityException) { - // 没权限就自动静音,不崩溃 - } - } - init { applyPerKeyBackgroundForNumberKeyboard(rootView) - - // 初次创建立刻应用当前主题 - applyTheme( - env.currentTextColor, - env.currentBorderColor, - env.currentBackgroundColor - ) - + applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor) setupListenersForNumberView(rootView) } - // ================= 背景(完全拷贝原逻辑,只是换成 env.ctx.resources) ================= - + // -------------------- 背景 -------------------- private fun applyPerKeyBackgroundForNumberKeyboard(root: View) { - val res = env.ctx.resources - - // 0..9 - for (i in 0..9) { - val idName = "key_$i" - applyKeyBackground(root, idName) - } - - // 背景 + for (i in 0..9) applyKeyBackground(root, "key_$i") applyKeyBackground(root, "background") - // 符号键 val symbolKeys = listOf( - "key_comma", - "key_dot", - "key_minus", - "key_slash", - "key_colon", - "key_semicolon", - "key_paren_l", - "key_paren_r", - "key_dollar", - "key_amp", - "key_at", - "key_question", - "key_exclam", - "key_quote", - "key_quote_d" + "key_comma","key_dot","key_minus","key_slash","key_colon","key_semicolon", + "key_paren_l","key_paren_r","key_dollar","key_amp","key_at","key_question", + "key_exclam","key_quote","key_quote_d" ) - symbolKeys.forEach { idName -> - applyKeyBackground(root, idName) - } + symbolKeys.forEach { applyKeyBackground(root, it) } - // 功能键 val others = listOf( - "key_symbols_more", - "key_abc", - "key_ai", - "key_space", - "key_send", - "key_del", - "Key_collapse" + "key_symbols_more","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","key_emoji","key_revoke" ) - others.forEach { idName -> - applyKeyBackground(root, idName) - } + others.forEach { applyKeyBackground(root, it) } } - private fun applyKeyBackground( - root: View, - viewIdName: String, - drawableName: String? = null - ) { + private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) { val res = env.ctx.resources val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) - if (viewId == 0) return val v = root.findViewById(viewId) ?: return - val keyName = drawableName ?: viewIdName val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return if (viewIdName == "background") { - val scaled = scaleDrawableToHeight(rawDrawable, 243f) - v.background = scaled - return + v.background = scaleDrawableToHeight(rawDrawable, 243f) + } else { + v.background = rawDrawable } - v.background = rawDrawable } private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable { val res = env.ctx.resources val dm = res.displayMetrics - val targetHeightPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - targetDp, - dm - ).toInt() - + val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt() val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src - val w = bitmap.width - val h = bitmap.height - - val ratio = targetHeightPx.toFloat() / h - val targetWidthPx = (w * ratio).toInt() - - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - targetWidthPx, - targetHeightPx, - true - ) - return BitmapDrawable(res, scaledBitmap).apply { - setBounds(0, 0, targetWidthPx, targetHeightPx) - } + val ratio = targetHeightPx.toFloat() / bitmap.height + val targetWidthPx = (bitmap.width * ratio).toInt() + val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true) + return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) } } - // ================= 按键事件 ================= + // -------------------- 实现主题刷新 -------------------- + override fun applyKeyBackgroundsForTheme() { + // 刷新数字键 + for (i in 0..9) applyKeyBackground(rootView, "key_$i") + // 刷新符号键 + val symbolKeys = listOf( + "key_comma","key_dot","key_minus","key_slash","key_colon","key_semicolon", + "key_paren_l","key_paren_r","key_dollar","key_amp","key_at","key_question", + "key_exclam","key_quote","key_quote_d" + ) + symbolKeys.forEach { applyKeyBackground(rootView, it) } + + // 刷新功能键和背景 + val others = listOf( + "key_symbols_more","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","background","key_emoji","key_revoke" + ) + others.forEach { applyKeyBackground(rootView, it) } + } + + // -------------------- 按键事件 -------------------- private fun setupListenersForNumberView(numView: View) { val res = env.ctx.resources val pkg = env.ctx.packageName - // 0~9 + // 初始化时设置Revoke按钮的可见性 + updateRevokeButtonVisibility(numView, res, pkg) + for (i in 0..9) { val id = res.getIdentifier("key_$i", "id", pkg) - numView.findViewById(id)?.let { v -> - attachKeyTouch(v) { i.toString()[0] } - } + numView.findViewById(id)?.let { attachKeyTouch(it) { i.toString()[0] } } } - // 符号键 val symbolMap: List> = listOf( - "key_comma" to ',', - "key_dot" to '.', - "key_minus" to '-', - "key_slash" to '/', - "key_colon" to ':', - "key_semicolon" to ';', - "key_paren_l" to '(', - "key_paren_r" to ')', - "key_dollar" to '$', - "key_amp" to '&', - "key_at" to '@', - "key_question" to '?', - "key_exclam" to '!', - "key_quote" to '\'', - "key_quote_d" to '”' + "key_comma" to ',', "key_dot" to '.', "key_minus" to '-', "key_slash" to '/', + "key_colon" to ':', "key_semicolon" to ';', "key_paren_l" to '(', "key_paren_r" to ')', + "key_dollar" to '$', "key_amp" to '&', "key_at" to '@', "key_question" to '?', + "key_exclam" to '!', "key_quote" to '\'', "key_quote_d" to '”' ) symbolMap.forEach { (name, ch) -> val id = res.getIdentifier(name, "id", pkg) - numView.findViewById(id)?.let { v -> - attachKeyTouch(v) { ch } - } + numView.findViewById(id)?.let { attachKeyTouch(it) { ch } } } - // 切换:符号层 + // 功能键 numView.findViewById(res.getIdentifier("key_symbols_more", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showSymbolKeyboard() - } + ?.setOnClickListener { vibrateKey(); env.showSymbolKeyboard() } - // 切回字母 numView.findViewById(res.getIdentifier("key_abc", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showMainKeyboard() - } + ?.setOnClickListener { vibrateKey(); env.showMainKeyboard() } - // 跳 AI numView.findViewById(res.getIdentifier("key_ai", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showAiKeyboard() - } + ?.setOnClickListener { vibrateKey(); env.showAiKeyboard() } - // 空格 numView.findViewById(res.getIdentifier("key_space", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.commitKey(' ') + ?.setOnClickListener { + vibrateKey(); env.commitKey(' ') + // 输入新内容后更新按钮可见性 + updateRevokeButtonVisibility(numView, res, pkg) } - // 发送 numView.findViewById(res.getIdentifier("key_send", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.performSendAction() + ?.setOnClickListener { + vibrateKey(); env.performSendAction() + // 发送后更新按钮可见性 + updateRevokeButtonVisibility(numView, res, pkg) } - // 删除(单击;长按连删在 MyInputMethodService 里挂) numView.findViewById(res.getIdentifier("key_del", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.deleteOne() + ?.setOnClickListener { + vibrateKey(); env.deleteOne() + // 删除内容后更新按钮可见性 + updateRevokeButtonVisibility(numView, res, pkg) } + + numView.findViewById(res.getIdentifier("collapse_button", "id", pkg)) + ?.setOnClickListener { vibrateKey(); env.hideKeyboard() } - //关闭键盘 - rootView.findViewById(res.getIdentifier("collapse_button", "id", pkg)) - ?.setOnClickListener { - vibrateKey() // 如果这个方法在当前类里有 - env.hideKeyboard() - } + numView.findViewById(res.getIdentifier("key_emoji", "id", pkg)) + ?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() } + + numView.findViewById(res.getIdentifier("key_revoke", "id", pkg)) + ?.setOnClickListener { + vibrateKey(); env.revokeLastClearedText() + // 回填后更新按钮可见性 + updateRevokeButtonVisibility(numView, res, pkg) + } } - // ================= 按键触摸 & 预览 ================= + // 更新Revoke按钮的可见性 + private fun updateRevokeButtonVisibility(view: View, res: android.content.res.Resources, pkg: String) { + val revokeButton = view.findViewById(res.getIdentifier("key_revoke", "id", pkg)) + revokeButton?.visibility = if (env.hasClearedText()) View.VISIBLE else View.GONE + } + + // 公共方法:更新Revoke按钮的可见性(供外部调用) + fun updateRevokeButtonVisibility() { + val res = env.ctx.resources + val pkg = env.ctx.packageName + updateRevokeButtonVisibility(rootView, res, pkg) + } + // -------------------- 按键触摸 & 预览 -------------------- private fun attachKeyTouch(view: View, charProvider: () -> Char) { view.setOnTouchListener { v, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { val ch = charProvider() - vibrateKey() // 按下就震一下 + vibrateKey() showKeyPreview(v, ch.toString()) v.isPressed = true true @@ -288,7 +210,6 @@ class NumberKeyboard( private fun showKeyPreview(anchor: View, text: String) { keyPreviewPopup?.dismiss() - val tv = TextView(env.ctx).apply { this.text = text textSize = 26f @@ -306,14 +227,7 @@ class NumberKeyboard( val w = (anchor.width * 1.2f).toInt() val h = (anchor.height * 1.2f).toInt() - keyPreviewPopup = PopupWindow(tv, w, h, false).apply { - isClippingEnabled = false - } - - keyPreviewPopup?.showAsDropDown( - anchor, - -(w - anchor.width) / 2, - -(h + anchor.height * 1.1f).toInt() - ) + keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false } + keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt()) } } diff --git a/app/src/main/java/com/example/myapplication/keyboard/PageIndicator.kt b/app/src/main/java/com/example/myapplication/keyboard/PageIndicator.kt new file mode 100644 index 0000000..0cd7fe6 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/PageIndicator.kt @@ -0,0 +1,29 @@ +package com.example.myapplication.keyboard + +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import com.example.myapplication.R + +class PageIndicator( + private val container: LinearLayout +) { + fun setPageCount(count: Int) { + container.removeAllViews() + val inflater = LayoutInflater.from(container.context) + repeat(count) { + container.addView(inflater.inflate(R.layout.item_dot, container, false)) + } + setSelected(0) + } + + fun setSelected(index: Int) { + for (i in 0 until container.childCount) { + container.getChildAt(i).isSelected = (i == index) + } + } + + fun clear() { + container.removeAllViews() + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/RecentStore.kt b/app/src/main/java/com/example/myapplication/keyboard/RecentStore.kt new file mode 100644 index 0000000..3727778 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/RecentStore.kt @@ -0,0 +1,43 @@ +package com.example.myapplication.keyboard + +import android.content.Context + +class RecentStore(ctx: Context) { + + private val sp = ctx.getSharedPreferences("emoji_recent_store", Context.MODE_PRIVATE) + + private val keyEmoji = "recent_emoji" + private val keyKaomoji = "recent_kaomoji" + + // 最近最多保存多少个(系统一般 30~50) + private val maxSize = 40 + + fun get(isEmoji: Boolean): List { + val key = if (isEmoji) keyEmoji else keyKaomoji + val raw = sp.getString(key, "") ?: "" + if (raw.isBlank()) return emptyList() + // 用 \u0001 作为分隔,避免和颜文字里的空格/逗号冲突 + return raw.split('\u0001').filter { it.isNotBlank() } + } + + fun push(item: String, isEmoji: Boolean) { + if (item.isBlank()) return + val key = if (isEmoji) keyEmoji else keyKaomoji + + val list = get(isEmoji).toMutableList() + // 去重:把旧的删掉再插到最前 + list.removeAll { it == item } + list.add(0, item) + + if (list.size > maxSize) { + list.subList(maxSize, list.size).clear() + } + + sp.edit().putString(key, list.joinToString(separator = "\u0001")).apply() + } + + fun clear(isEmoji: Boolean) { + val key = if (isEmoji) keyEmoji else keyKaomoji + sp.edit().remove(key).apply() + } +} diff --git a/app/src/main/java/com/example/myapplication/keyboard/SimpleStringGridAdapter.kt b/app/src/main/java/com/example/myapplication/keyboard/SimpleStringGridAdapter.kt new file mode 100644 index 0000000..ff0d26d --- /dev/null +++ b/app/src/main/java/com/example/myapplication/keyboard/SimpleStringGridAdapter.kt @@ -0,0 +1,38 @@ +package com.example.myapplication.keyboard + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R + +class SimpleStringGridAdapter( + private val itemLayoutRes: Int, + private val onClick: (String) -> Unit +) : RecyclerView.Adapter() { + + private val data = ArrayList() + + fun submit(list: List) { + data.clear() + data.addAll(list) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val tv = LayoutInflater.from(parent.context) + .inflate(itemLayoutRes, parent, false) as TextView + return VH(tv) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val s = data[position] + holder.tv.text = s + holder.tv.setOnClickListener { onClick(s) } + } + + override fun getItemCount(): Int = data.size + + class VH(val tv: TextView) : RecyclerView.ViewHolder(tv) +} + 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 8e26cac..6697ca5 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/SymbolKeyboard.kt @@ -15,7 +15,6 @@ import android.view.MotionEvent import android.view.View import android.widget.PopupWindow import android.widget.TextView -import com.example.myapplication.R import com.example.myapplication.theme.ThemeManager class SymbolKeyboard( @@ -29,259 +28,163 @@ class SymbolKeyboard( private var keyPreviewPopup: PopupWindow? = null - // ================== 震动相关 ================== - - private val vibrator: Vibrator? = - env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - - private fun vibrateKey( - duration: Long = 30L, // 时间:10~40 推荐 - amplitude: Int = 255 // 1~255,100~150 最舒服 - ) { - val v = vibrator ?: return - if (!v.hasVibrator()) return - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - v.vibrate( - VibrationEffect.createOneShot(duration, amplitude) - ) - } else { - @Suppress("DEPRECATION") - v.vibrate(duration) - } - } catch (_: SecurityException) { - // 没权限就自动静音,不崩溃 - } - } - init { - // 按键背景图片(跟你原来 applyPerKeyBackgroundForSymbolKeyboard 一样) applyPerKeyBackgroundForSymbolKeyboard(rootView) - - // 初次创建立刻应用当前主题色 - applyTheme( - env.currentTextColor, - env.currentBorderColor, - env.currentBackgroundColor - ) - + applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor) setupListenersForSymbolView(rootView) } - // ================== 背景(完全按你原来的 key 列表) ================== - + // -------------------- 背景 -------------------- private fun applyPerKeyBackgroundForSymbolKeyboard(root: View) { - val res = env.ctx.resources - val symbolKeys = listOf( - // 第一行 - "key_bracket_l", - "key_bracket_r", - "key_brace_l", - "key_brace_r", - "key_hash", - "key_percent", - "key_caret", - "key_asterisk", - "key_plus", - "key_equal", - - // 第二行 - "key_underscore", - "key_backslash", - "key_pipe", - "key_tilde", - "key_lt", - "key_gt", - "key_euro", - "key_pound", - "key_money", - "key_bullet", - - // 第三行 - "key_dot", - "key_comma", - "key_question", - "key_exclam", - "key_quote" + "key_bracket_l","key_bracket_r","key_brace_l","key_brace_r","key_hash","key_percent", + "key_caret","key_asterisk","key_plus","key_equal", + "key_underscore","key_backslash","key_pipe","key_tilde","key_lt","key_gt", + "key_euro","key_pound","key_money","key_bullet", + "key_dot","key_comma","key_question","key_exclam","key_quote" ) + symbolKeys.forEach { applyKeyBackground(root, it) } - symbolKeys.forEach { idName -> - applyKeyBackground(root, idName) - } - - // 背景整体 applyKeyBackground(root, "background") - // 功能键 val others = listOf( - "key_symbols_123", - "key_backspace", - "key_abc", - "key_ai", - "key_space", - "key_send", - "Key_collapse" + "key_symbols_123","key_emoji","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","key_revoke" ) - - others.forEach { idName -> - applyKeyBackground(root, idName) - } + others.forEach { applyKeyBackground(root, it) } } - private fun applyKeyBackground( - root: View, - viewIdName: String, - drawableName: String? = null - ) { + private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) { val res = env.ctx.resources val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) - if (viewId == 0) return val v = root.findViewById(viewId) ?: return - val keyName = drawableName ?: viewIdName val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return if (viewIdName == "background") { - val scaled = scaleDrawableToHeight(rawDrawable, 243f) - v.background = scaled - return + v.background = scaleDrawableToHeight(rawDrawable, 243f) + } else { + v.background = rawDrawable } - v.background = rawDrawable } private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable { val res = env.ctx.resources val dm = res.displayMetrics - val targetHeightPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - targetDp, - dm - ).toInt() - + val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt() val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src - val w = bitmap.width - val h = bitmap.height - - val ratio = targetHeightPx.toFloat() / h - val targetWidthPx = (w * ratio).toInt() - - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - targetWidthPx, - targetHeightPx, - true - ) - return BitmapDrawable(res, scaledBitmap).apply { - setBounds(0, 0, targetWidthPx, targetHeightPx) - } + val ratio = targetHeightPx.toFloat() / bitmap.height + val targetWidthPx = (bitmap.width * ratio).toInt() + val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true) + return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) } } - // ================== 符号键盘事件 ================== + // -------------------- 实现主题刷新 -------------------- + override fun applyKeyBackgroundsForTheme() { + // 刷新符号键 + val symbolKeys = listOf( + "key_bracket_l","key_bracket_r","key_brace_l","key_brace_r","key_hash","key_percent", + "key_caret","key_asterisk","key_plus","key_equal", + "key_underscore","key_backslash","key_pipe","key_tilde","key_lt","key_gt", + "key_euro","key_pound","key_money","key_bullet", + "key_dot","key_comma","key_question","key_exclam","key_quote" + ) + symbolKeys.forEach { applyKeyBackground(rootView, it) } + // 刷新功能键和背景 + val others = listOf( + "key_symbols_123","key_emoji","key_abc","key_ai","key_space","key_send","Key_collapse","key_del","background","key_revoke" + ) + others.forEach { applyKeyBackground(rootView, it) } + } + + // -------------------- 符号键盘事件 -------------------- private fun setupListenersForSymbolView(symView: View) { val res = env.ctx.resources val pkg = env.ctx.packageName - val pairs = listOf( - // 第一行 - "key_bracket_l" to '[', - "key_bracket_r" to ']', - "key_brace_l" to '{', - "key_brace_r" to '}', - "key_hash" to '#', - "key_percent" to '%', - "key_caret" to '^', - "key_asterisk" to '*', - "key_plus" to '+', - "key_equal" to '=', + // 初始化时设置Revoke按钮的可见性 + updateRevokeButtonVisibility(symView, res, pkg) - // 第二行 - "key_underscore" to '_', - "key_backslash" to '\\', - "key_pipe" to '|', - "key_tilde" to '~', - "key_lt" to '<', - "key_gt" to '>', - "key_euro" to '€', - "key_pound" to '£', - "key_money" to '¥', - "key_bullet" to '•', - - // 第三行 - "key_dot" to '.', - "key_comma" to ',', - "key_question" to '?', - "key_exclam" to '!', - "key_quote" to '\'' + val pairs: List> = listOf( + "key_bracket_l" to '[',"key_bracket_r" to ']',"key_brace_l" to '{',"key_brace_r" to '}', + "key_hash" to '#',"key_percent" to '%',"key_caret" to '^',"key_asterisk" to '*', + "key_plus" to '+',"key_equal" to '=', + "key_underscore" to '_',"key_backslash" to '\\',"key_pipe" to '|',"key_tilde" to '~', + "key_lt" to '<',"key_gt" to '>',"key_euro" to '€',"key_pound" to '£', + "key_money" to '¥',"key_bullet" to '•', + "key_dot" to '.',"key_comma" to ',',"key_question" to '?',"key_exclam" to '!',"key_quote" to '\'' ) pairs.forEach { (name, ch) -> val id = res.getIdentifier(name, "id", pkg) - symView.findViewById(id)?.let { v -> - attachKeyTouch(v) { ch } - } + symView.findViewById(id)?.let { attachKeyTouch(it) { ch } } } - // 切换回数字 + // 功能键 symView.findViewById(res.getIdentifier("key_symbols_123", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showNumberKeyboard() - } + ?.setOnClickListener { vibrateKey(); env.showNumberKeyboard() } - // 切回字母 symView.findViewById(res.getIdentifier("key_abc", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showMainKeyboard() - } + ?.setOnClickListener { vibrateKey(); env.showMainKeyboard() } - // 空格 symView.findViewById(res.getIdentifier("key_space", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.commitKey(' ') + ?.setOnClickListener { + vibrateKey(); env.commitKey(' ') + // 输入新内容后更新按钮可见性 + updateRevokeButtonVisibility(symView, res, pkg) } - // 发送 symView.findViewById(res.getIdentifier("key_send", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.performSendAction() + ?.setOnClickListener { + vibrateKey(); env.performSendAction() + // 发送后更新按钮可见性 + updateRevokeButtonVisibility(symView, res, pkg) } - // 删除(单击;长按连删在 MyInputMethodService 里统一挂) - symView.findViewById(res.getIdentifier("key_backspace", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.deleteOne() + symView.findViewById(res.getIdentifier("key_del", "id", pkg)) + ?.setOnClickListener { + vibrateKey(); env.deleteOne() + // 删除内容后更新按钮可见性 + updateRevokeButtonVisibility(symView, res, pkg) } - // 跳 AI 键盘 symView.findViewById(res.getIdentifier("key_ai", "id", pkg)) - ?.setOnClickListener { - vibrateKey() - env.showAiKeyboard() - } + ?.setOnClickListener { vibrateKey(); env.showAiKeyboard() } - //关闭键盘 - rootView.findViewById(res.getIdentifier("collapse_button", "id", pkg)) - ?.setOnClickListener { - vibrateKey() // 如果这个方法在当前类里有 - env.hideKeyboard() - } + symView.findViewById(res.getIdentifier("collapse_button", "id", pkg)) + ?.setOnClickListener { vibrateKey(); env.hideKeyboard() } + + symView.findViewById(res.getIdentifier("key_emoji", "id", pkg)) + ?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() } + + symView.findViewById(res.getIdentifier("key_revoke", "id", pkg)) + ?.setOnClickListener { + vibrateKey(); env.revokeLastClearedText() + // 回填后更新按钮可见性 + updateRevokeButtonVisibility(symView, res, pkg) + } } - // ================== 触摸 + 预览 ================== + // 更新Revoke按钮的可见性 + private fun updateRevokeButtonVisibility(view: View, res: android.content.res.Resources, pkg: String) { + val revokeButton = view.findViewById(res.getIdentifier("key_revoke", "id", pkg)) + revokeButton?.visibility = if (env.hasClearedText()) View.VISIBLE else View.GONE + } + + // 公共方法:更新Revoke按钮的可见性(供外部调用) + fun updateRevokeButtonVisibility() { + val res = env.ctx.resources + val pkg = env.ctx.packageName + updateRevokeButtonVisibility(rootView, res, pkg) + } + // -------------------- 触摸 + 预览 -------------------- private fun attachKeyTouch(view: View, charProvider: () -> Char) { view.setOnTouchListener { v, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { val ch = charProvider() - vibrateKey() // 按下震动 + vibrateKey() showKeyPreview(v, ch.toString()) v.isPressed = true true @@ -293,8 +196,7 @@ class SymbolKeyboard( v.isPressed = false true } - MotionEvent.ACTION_CANCEL, - MotionEvent.ACTION_OUTSIDE -> { + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> { keyPreviewPopup?.dismiss() v.isPressed = false true @@ -306,7 +208,6 @@ class SymbolKeyboard( private fun showKeyPreview(anchor: View, text: String) { keyPreviewPopup?.dismiss() - val tv = TextView(env.ctx).apply { this.text = text textSize = 26f @@ -324,14 +225,7 @@ class SymbolKeyboard( val w = (anchor.width * 1.2f).toInt() val h = (anchor.height * 1.2f).toInt() - keyPreviewPopup = PopupWindow(tv, w, h, false).apply { - isClippingEnabled = false - } - - keyPreviewPopup?.showAsDropDown( - anchor, - -(w - anchor.width) / 2, - -(h + anchor.height * 1.1f).toInt() - ) + keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false } + keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt()) } } diff --git a/app/src/main/java/com/example/myapplication/network/ApiResponse.kt b/app/src/main/java/com/example/myapplication/network/ApiResponse.kt deleted file mode 100644 index 3530708..0000000 --- a/app/src/main/java/com/example/myapplication/network/ApiResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -// ApiResponse.kt -package com.example.network - -data class ApiResponse( - val code: Int, - val msg: String, - val data: T? -) 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 bd8e792..72993bb 100644 --- a/app/src/main/java/com/example/myapplication/network/ApiService.kt +++ b/app/src/main/java/com/example/myapplication/network/ApiService.kt @@ -1,5 +1,5 @@ // 请求方法 -package com.example.network +package com.example.myapplication.network import okhttp3.ResponseBody import retrofit2.Response @@ -8,36 +8,155 @@ import retrofit2.http.* interface ApiService { // GET 示例:/users/{id} - @GET("users/{id}") - suspend fun getUser( - @Path("id") id: String - ): ApiResponse + // @GET("users/{id}") + // suspend fun getUser( + // @Path("id") id: String + // ): ApiResponse // GET 示例:带查询参数 /users?page=1&pageSize=20 - @GET("users") - suspend fun getUsers( - @Query("page") page: Int, - @Query("pageSize") pageSize: Int - ): ApiResponse> + // @GET("users") + // suspend fun getUsers( + // @Query("page") page: Int, + // @Query("pageSize") pageSize: Int + // ): ApiResponse> - // POST JSON 示例:Body 为 JSON:{"username": "...", "password": "..."} - @POST("auth/login") + //登录 + @POST("user/login") suspend fun login( @Body body: LoginRequest ): ApiResponse - // POST 表单示例:x-www-form-urlencoded - @FormUrlEncoded - @POST("auth/loginForm") - suspend fun loginForm( - @Field("username") username: String, - @Field("password") password: String - ): ApiResponse + // =========================================用户================================= + //获取用户详情 + @GET("user/detail") + suspend fun getUser( + ): ApiResponse + //更新用户信息 + @POST("user/updateInfo") + suspend fun updateUserInfo( + @Body body: updateInfoRequest + ): ApiResponse + + + //===========================================首页================================= + // 标签列表 + @GET("tag/list") + suspend fun tagList( + ): ApiResponse> + + //未登录用户按标签查询人设列表 + @GET("character/listByTagWithNotLogin") + suspend fun personaListByTag( + @Query("tagId") tagId: Int + ): ApiResponse> + + //登录用户按标签查询人设列表 + @GET("character/listByTag") + suspend fun loggedInPersonaListByTag( + @Query("tagId") tagId: Int + ): ApiResponse> + + // 人设列表 + @GET("character/list") + suspend fun personaByTag( + ): ApiResponse> + + //未登录用户人设列表 + @GET("character/listWithNotLogin") + suspend fun personaListWithNotLogin( + ): ApiResponse> + + // 人设详情 + @GET("character/detail") + suspend fun characterDetail( + @Query("id") id: Int + ): ApiResponse + + //删除用户人设 + @GET("character/delUserCharacter") + suspend fun delUserCharacter( + @Query("id") id: Int + ): ApiResponse + + //添加用户人设 + @POST("character/addUserCharacter") + suspend fun addUserCharacter( + @Body body: AddPersonaClick + ): ApiResponse + + //==========================================商城=========================================== + + //查询钱包余额 + @GET("wallet/balance") + suspend fun walletBalance( + ): ApiResponse + + //查询所有主题风格 + @GET("themes/listAllStyles") + suspend fun themeList( + ): ApiResponse> + + //按风格查询主题 + @GET("themes/listByStyle") + suspend fun themeListByStyle( + @Query("themeStyle") id: Int + ): ApiResponse> + + //查询主题详情 + @GET("themes/detail") + suspend fun themeDetail( + @Query("themeId") id: Int + ): ApiResponse + + //推荐主题列表 + @GET("themes/recommended") + suspend fun recommendThemeList( + ): ApiResponse> + + //搜索主题 + @GET("themes/search") + suspend fun searchTheme( + @Query("themeName") keyword: String + ): ApiResponse> + + //查询已购买的主题 + @GET("themes/purchased") + suspend fun purchasedThemeList( + ): ApiResponse> + + // 批量删除用户主题 + @POST("user-themes/batch-delete") + suspend fun batchDeleteUserTheme( + @Body body: deleteThemeRequest + ): ApiResponse + + // 购买主题 + @POST("themes/purchase") + suspend fun purchaseTheme( + @Body body: purchaseThemeRequest + ): ApiResponse + + //恢复已删除的主题 + @POST("themes/restore") + suspend fun restoreTheme( + @Query("themeId") themeId: Int + ): ApiResponse + // =========================================文件============================================= // zip 文件下载(或其它大文件)——必须 @Streaming @Streaming @GET("files/{fileName}") suspend fun downloadZip( @Path("fileName") fileName: String // 比如 "xxx.zip" ): Response + + // 完整 URL 下载 + @Streaming + @GET + @Headers( + "Accept-Encoding: identity" + ) + suspend fun downloadZipFromUrl( + @Url url: String // 完整的下载 URL + ): Response } diff --git a/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt new file mode 100644 index 0000000..4e1fdbb --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt @@ -0,0 +1,24 @@ +package com.example.myapplication.network + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +object AuthEventBus { + + // replay=0:不缓存历史事件;extraBufferCapacity:避免瞬时丢事件 + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + + val events: SharedFlow = _events + + fun emit(event: AuthEvent) { + _events.tryEmit(event) + } +} + +sealed class AuthEvent { + data class TokenExpired(val message: String? = null) : AuthEvent() + data class GenericError(val message: String) : AuthEvent() +} diff --git a/app/src/main/java/com/example/myapplication/network/FileDownloader.kt b/app/src/main/java/com/example/myapplication/network/FileDownloader.kt index a6400ed..9145e3b 100644 --- a/app/src/main/java/com/example/myapplication/network/FileDownloader.kt +++ b/app/src/main/java/com/example/myapplication/network/FileDownloader.kt @@ -1,5 +1,5 @@ // zip 文件下载器 -package com.example.network +package com.example.myapplication.network import android.content.Context import android.util.Log @@ -16,7 +16,7 @@ object FileDownloader { /** * 下载 zip 文件并保存到 app 专属目录 * @param context 用来获取文件目录 - * @param remoteFileName 服务器上的文件名,比如 "test.zip" + * @param remoteFileName 服务器上的文件名或完整URL,比如 "test.zip" 或 "https://example.com/files/test.zip" * @param localFileName 本地保存名字,比如 "test_local.zip" * @return 保存成功后返回 File,失败返回 null */ @@ -27,7 +27,13 @@ object FileDownloader { ): File? = withContext(Dispatchers.IO) { val api = RetrofitClient.apiService try { - val response = api.downloadZip(remoteFileName) + val response = if (remoteFileName.startsWith("http")) { + // 完整 URL 下载 - 使用 @Url 注解,Retrofit 会忽略 base URL + api.downloadZipFromUrl(remoteFileName) + } else { + // 文件名下载 + api.downloadZip(remoteFileName) + } if (!response.isSuccessful) { Log.e("Downloader", "download failed: code=${response.code()}") return@withContext null 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 fdb2a49..62b3cf5 100644 --- a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt +++ b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt @@ -1,27 +1,69 @@ // 定义请求 & 响应拦截器 -package com.example.network +package com.example.myapplication.network import android.util.Log +import com.google.gson.Gson import okhttp3.Interceptor import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import android.content.Context /** * 请求拦截器:统一加 Header、token 等 */ -val requestInterceptor = Interceptor { chain -> +fun requestInterceptor(appContext: Context) = Interceptor { chain -> val original = chain.request() - val token = "your_token" // 这里换成你自己的 token + + val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java) + val token = user?.token.orEmpty() + val newRequest = original.newBuilder() - .addHeader("Accept", "application/json") - .addHeader("Content-Type", "application/json") - // 这里加你自己的 token,如果没有就注释掉 - .addHeader("Authorization", "Bearer $token") + .apply { + if (token.isNotBlank()) { + addHeader("auth-token", "$token") + } + } + .addHeader("Accept-Language", "lang") .build() - chain.proceed(newRequest) + // ===== 打印请求信息 ===== + val request = newRequest + val url = request.url + + val sb = StringBuilder() + sb.append("\n======== HTTP Request ========\n") + sb.append("Method: ${request.method}\n") + sb.append("URL: $url\n") + + sb.append("Headers:\n") + for (name in request.headers.names()) { + sb.append(" $name: ${request.header(name)}\n") + } + + if (url.querySize > 0) { + sb.append("Query Params:\n") + for (i in 0 until url.querySize) { + sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n") + } + } + + val requestBody = request.body + if (requestBody != null) { + val buffer = okio.Buffer() + requestBody.writeTo(buffer) + sb.append("Body:\n") + sb.append(buffer.readUtf8()) + sb.append("\n") + } + + sb.append("================================\n") + Log.d("1314520-OkHttp-Request", sb.toString()) + + chain.proceed(request) } + /** * 响应拦截器:统一打印日志、做一些简单的错误处理 */ @@ -33,10 +75,18 @@ val responseInterceptor = Interceptor { chain -> val rawBody = response.body val mediaType = rawBody?.contentType() + + if ( + mediaType?.subtype == "zip" || + request.url.toString().endsWith(".zip") + ) { + return@Interceptor response + } + val bodyString = rawBody?.string() ?: "" Log.d( - "HTTP", + "1314520-HTTP", "⬇⬇⬇\n" + "URL : ${request.url}\n" + "Method: ${request.method}\n" + @@ -45,6 +95,33 @@ val responseInterceptor = Interceptor { chain -> "Body : $bodyString\n" + "⬆⬆⬆" ) + + // 尝试解析响应体,检查是否为token过期错误 + try { + val gson = Gson() + val errorResponse = gson.fromJson(bodyString, ErrorResponse::class.java) + + if (errorResponse.code == 40102) { + Log.w("1314520-HTTP", "token 过期: ${errorResponse.message}") + + // 只发事件,UI 层去跳转 + AuthEventBus.emit(AuthEvent.TokenExpired(errorResponse.message)) + + return@Interceptor response.newBuilder() + .code(401) + .message("Login expired: ${errorResponse.message}") + .body(bodyString.toResponseBody(mediaType)) + .build() + } + // 其他非0的错误码,通过事件总线发送错误信息 + else if (errorResponse.code!= 0) { + AuthEventBus.emit(AuthEvent.GenericError(errorResponse.message ?: "未知错误")) + } + + } catch (e: Exception) { + // 如果解析失败,忽略错误继续正常处理 + Log.d("1314520-HTTP", "解析JSON失败: ${e.message}") + } // body 只能读一次,这里读完后再重新构建一个 response.newBuilder() diff --git a/app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt b/app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt new file mode 100644 index 0000000..7665eb4 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/LlmStreamCallback.kt @@ -0,0 +1,6 @@ +package com.example.myapplication.network + +interface LlmStreamCallback { + fun onEvent(type: String, data: String?) + fun onError(t: Throwable) +} \ No newline at end of file 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 1701394..52b9e08 100644 --- a/app/src/main/java/com/example/myapplication/network/Models.kt +++ b/app/src/main/java/com/example/myapplication/network/Models.kt @@ -1,18 +1,164 @@ // Models.kt -package com.example.network +package com.example.myapplication.network -data class User( - val id: String, - val name: String, - val age: Int +// 通用API响应模型 +data class ApiResponse( + val code: Int, + val message: String, + val data: T? ) +// 错误响应 +data class ErrorResponse( + val code: Int, + val message: String +) + +// ======================================登录================================ +// 登录 data class LoginRequest( - val username: String, + val mail: String, val password: String ) - +// 登录响应 data class LoginResponse( - val token: String, - val user: User + val uid: Long, + val nickName: String, + val gender: Int, + val avatarUrl: String?, + val email: String, + val emailVerified: Boolean, + val isVip: Boolean, + val vipExpiry: String, + val token: String ) + +// ======================================用户=================================== +//获取用户详情 +data class User( + val uid: Long, + val nickName: String, + val gender: Int, + val avatarUrl: String?, + val email: String, + val emailVerified: Boolean, + val isVip: Boolean, + val vipExpiry: String, + val token: String +) + +//更新用户 +data class updateInfoRequest( + val uid: Long, + val nickName: String, + val gender: Int, + val avatarUrl: String?, +) + +// =======================================首页====================================== +//标签列表 +data class Tag( + val id: Int, + val tagName: String +) + +data class TagList( + val data: List +) + +// 人设详情点击事件 +sealed class PersonaClick { + data class Item(val persona: listByTagWithNotLogin) : PersonaClick() + data class Add(val persona: listByTagWithNotLogin) : PersonaClick() +} + + +data class listByTagWithNotLogin( + val id: Int, + val characterName: String, + val characterBackground: String , + val avatarUrl: String , + val download: String , + val tag: Int , + val rank: Int , + val added: Boolean , + val emoji: String +) + +// 人设详情响应 +data class CharacterDetailResponse( + val id: Long? = null, + val characterName: String? = null, + val characterBackground: String? = null, + val avatarUrl: String? = null, + val download: String? = null, + val tag: Long? = null, + val rank: Int? = null, + val added: Boolean? = null, + val emoji: String? = null +) + +//添加用户人设点击事件 +data class AddPersonaClick( + val characterId: Int, + val emoji: String +) + +// ============================================商城====================================== +//查询所有主题风格 +data class Theme( + val id: Int, + val styleName: String +) + +data class Wallet( + val balance: Number, + val balanceDisplay: String +) + +data class SubjectTag( + val label: String, + val color: String +) + +//按风格查询主题 +data class themeStyle( + val id: Int, + val themeName: String, + val themePrice: Number, + val themeTag: List?, + val themeDownload: String, + val themeStyle: Int, + val themePreviewImageUrl: String, + val themeDownloadUrl: String, + val themePurchasesNumber: Int, + val sort: Int, + val isFree: Boolean, + val isPurchased: Boolean +) + +//查询主题详情 +data class themeDetail( + val id: Int, + val themeName: String, + val themePrice: Number, + val themeTag: List?, + val themeDownload: String, + val themeStyle: Int, + val themePreviewImageUrl: String, + val themeDownloadUrl: String, + val themePurchasesNumber: Int, + val sort: Int, + val isFree: Boolean, + val isPurchased: Boolean, +) + +// 批量删除主题 +data class deleteThemeRequest( + val themeIds: List +) + +//购买主题 +data class purchaseThemeRequest( + val themeId: 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 new file mode 100644 index 0000000..97fae86 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt @@ -0,0 +1,116 @@ +package com.example.myapplication.network + +import okhttp3.* +import okio.Buffer +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +object NetworkClient { + + // 你自己后端的 base url + private const val BASE_URL = "http://192.168.2.21:7529/api" + + // 专门用于 SSE 的 OkHttpClient:readTimeout = 0 代表不超时,一直保持连接 + private val sseClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须不能有读超时 + .build() + } + + /** + * 启动一次 SSE 流式请求 + * @param question 用户问题(你要传给后端的) + * @return Call,可用于取消(比如用户关闭键盘时) + */ + fun startLlmStream( + question: String, + callback: LlmStreamCallback + ): Call { + // 根据你后端的接口改:是 POST 还是 GET,参数格式是什么 + val json = JSONObject().apply { + put("query", question) // 假设你后端字段叫 query + } + + val requestBody = json.toString() + .toRequestBody("application/json; charset=utf-8".toMediaType()) + + val request = Request.Builder() + .url("$BASE_URL/llm/stream") // TODO: 换成你真实的 SSE 路径 + .post(requestBody) + // 有些 SSE 接口会要求 Accept + .addHeader("Accept", "text/event-stream") + .build() + + val call = sseClient.newCall(request) + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (call.isCanceled()) return // 被主动取消就不用回调错误了 + callback.onError(e) + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + callback.onError(IOException("SSE failed: ${response.code}")) + response.close() + return + } + + val body = response.body ?: run { + callback.onError(IOException("Empty body")) + return + } + + // 长连接读取:一行一行读,直到服务器关闭或我们取消 + body.use { b -> + val source = b.source() + try { + while (!source.exhausted() && !call.isCanceled()) { + val line = source.readUtf8Line() ?: break + if (line.isBlank()) { + // SSE 中空行代表一个 event 结束,这里可以忽略 + continue + } + + // 兼容两种格式: + // 1) 标准 SSE: "data: { ... }" + // 2) 服务器直接一行一个 JSON: "{ ... }" + val payload = if (line.startsWith("data:")) { + line.substringAfter("data:").trim() + } else { + line.trim() + } + + // 你日志里是: + // {"type":"llm_chunk","data":"Her"} + // {"type":"done","data":null} + try { + val jsonObj = JSONObject(payload) + val type = jsonObj.optString("type") + val data = + if (jsonObj.has("data") && !jsonObj.isNull("data")) + jsonObj.getString("data") + else + null + + callback.onEvent(type, data) + } catch (e: Exception) { + // 解析失败就忽略这一行(或者你可以打印下日志) + // Log.e("NetworkClient", "Bad SSE line: $payload", e) + } + } + } catch (ioe: IOException) { + if (!call.isCanceled()) { + callback.onError(ioe) + } + } + } + } + }) + + return call + } +} 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 af5d3a3..dc599bd 100644 --- a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt +++ b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt @@ -1,6 +1,6 @@ -// RetrofitClient.kt -package com.example.network +package com.example.myapplication.network +import android.content.Context import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -9,22 +9,30 @@ import java.util.concurrent.TimeUnit object RetrofitClient { - private const val BASE_URL = "https://api.example.com/" // 换成你的地址 + private const val BASE_URL = "http://192.168.2.21:7529/api/" + + // 保存 ApplicationContext + @Volatile + private lateinit var appContext: Context + + fun init(context: Context) { + appContext = context.applicationContext + } - // 日志拦截器(可选) private val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } private val okHttpClient: OkHttpClient by lazy { + check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." } + OkHttpClient.Builder() - // 超时时间自己看需求改 .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) // 顺序:请求拦截 -> logging -> 响应拦截 - .addInterceptor(requestInterceptor) + .addInterceptor(requestInterceptor(appContext)) .addInterceptor(loggingInterceptor) .addInterceptor(responseInterceptor) .build() @@ -41,4 +49,16 @@ object RetrofitClient { val apiService: ApiService by lazy { retrofit.create(ApiService::class.java) } + + /** + * 创建支持完整 URL 下载的 Retrofit 实例 + * @param baseUrl 完整的下载 URL + */ + fun createRetrofitForUrl(baseUrl: String): Retrofit { + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } } diff --git a/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt b/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt index 815c0f4..e03c6ca 100644 --- a/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt +++ b/app/src/main/java/com/example/myapplication/theme/ThemeManager.kt @@ -21,27 +21,26 @@ object ThemeManager { private var drawableCache: MutableMap = mutableMapOf() // ==================== 外部目录相关 ==================== + //通知主题更新 + private val listeners = mutableSetOf<() -> Unit>() + + fun addThemeChangeListener(listener: () -> Unit) { + listeners.add(listener) + } + + fun removeThemeChangeListener(listener: () -> Unit) { + listeners.remove(listener) + } + /** 主题根目录:/Android/data//files/keyboard_themes */ private fun getThemeRootDir(context: Context): File = - File(context.getExternalFilesDir(null), "keyboard_themes") + File(context.filesDir, "keyboard_themes") /** 某个具体主题目录:/Android/.../keyboard_themes/ */ private fun getThemeDir(context: Context, themeName: String): File = File(getThemeRootDir(context), themeName) - // ==================== 内置主题拷贝(assets -> 外部目录) ==================== - - /** - * 确保 APK 自带的主题(assets/keyboard_themes/...) 已经复制到 - * /Android/data/.../files/keyboard_themes 目录下。 - * - * 行为: - * - 如果主题目录不存在:整套拷贝过去。 - * - 如果主题目录已经存在:只复制“新增文件”,不会覆盖已有文件。 - * - * 建议在 IME 的 onCreate() 里调用一次。 - */ fun ensureBuiltInThemesInstalled(context: Context) { val am = context.assets val rootName = "keyboard_themes" @@ -126,6 +125,8 @@ object ThemeManager { .apply() drawableCache = loadThemeDrawables(context, themeName) + + listeners.forEach { it.invoke() } } fun getCurrentThemeName(): String? = currentThemeName diff --git a/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt b/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt new file mode 100644 index 0000000..c5f562f --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt @@ -0,0 +1,34 @@ +package com.example.myapplication.ui.common + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.example.myapplication.R + +class LoadingOverlay private constructor( + private val parent: ViewGroup, + private val overlay: View +) { + companion object { + fun attach(parent: ViewGroup): LoadingOverlay { + val overlay = LayoutInflater.from(parent.context) + .inflate(R.layout.view_fullscreen_loading, parent, false) + + overlay.visibility = View.GONE + parent.addView(overlay) // 加到最上层(最后添加的在最上面) + return LoadingOverlay(parent, overlay) + } + } + + fun show() { + overlay.visibility = View.VISIBLE + } + + fun hide() { + overlay.visibility = View.GONE + } + + fun remove() { + parent.removeView(overlay) + } +} 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 6cb72e5..09415f4 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,30 +1,41 @@ package com.example.myapplication.ui.home +import android.content.Intent +import android.graphics.drawable.TransitionDrawable import android.os.Bundle +import android.util.Log import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup +import android.widget.HorizontalScrollView import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat +import androidx.core.widget.NestedScrollView import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 +import com.example.myapplication.ImeGuideActivity import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.listByTagWithNotLogin +import com.example.myapplication.network.PersonaClick +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.card.MaterialCardView -import android.graphics.drawable.TransitionDrawable -import android.view.MotionEvent -import android.view.ViewConfiguration -import android.widget.HorizontalScrollView -import androidx.coordinatorlayout.widget.CoordinatorLayout +import kotlinx.coroutines.launch import kotlin.math.abs -import android.content.Intent -import com.example.myapplication.ImeGuideActivity +import com.example.myapplication.network.AddPersonaClick class HomeFragment : Fragment() { @@ -38,16 +49,41 @@ class HomeFragment : Fragment() { private lateinit var tabList1: TextView private lateinit var tabList2: TextView private lateinit var backgroundImage: ImageView + private var preloadJob: kotlinx.coroutines.Job? = null + private var allPersonaCache: List = emptyList() + private val sharedPool = RecyclerView.RecycledViewPool() + private var parentWidth = 0 private var parentHeight = 0 + // 你点了哪个 tag(列表二) + private var clickedTagId: Int? = null + + // ✅ 列表二:每个 tagId 对应一份 persona 数据,避免串页 + private val personaCache = mutableMapOf>() + + data class Tag(val id: Int, val tagName: String) + + private val tags = mutableListOf() + private val dragToCloseThreshold by lazy { val dp = 40f (dp * resources.displayMetrics.density) } - // 第二个列表的“标签页”,数量不固定,可以从服务端/本地配置来 - private val tags = listOf("标签一", "标签二", "标签三", "标签四", "标签五", "标签六", "标签七", "标签八", "标签九", "标签十") + private val list1Adapter: List1Adapter by lazy { + List1Adapter { item: String -> + Log.d("HomeFragment", "list1 click: $item") + } + } + + override fun onDestroyView() { + preloadJob?.cancel() + pageChangeCallback?.let { viewPager.unregisterOnPageChangeCallback(it) } + pageChangeCallback = null + sheetAdapter = null + super.onDestroyView() + } override fun onCreateView( inflater: LayoutInflater, @@ -59,12 +95,13 @@ class HomeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + // 充值按钮点击 view.findViewById(R.id.rechargeButton).setOnClickListener { findNavController().navigate(R.id.action_global_rechargeFragment) } - //输入法激活跳转 + + // 输入法激活跳转 view.findViewById(R.id.floatingImage).setOnClickListener { if (isAdded) { startActivity(Intent(requireActivity(), ImeGuideActivity::class.java)) @@ -79,10 +116,11 @@ class HomeFragment : Fragment() { tabList1 = view.findViewById(R.id.tab_list1) tabList2 = view.findViewById(R.id.tab_list2) viewPager = view.findViewById(R.id.viewPager) + viewPager.isSaveEnabled = false backgroundImage = bottomSheet.findViewById(R.id.backgroundImage) val root = view.findViewById(R.id.rootCoordinator) val floatingImage = view.findViewById(R.id.floatingImage) - // 拿到父布局的宽高(需要等布局完成) + root.post { parentWidth = root.width parentHeight = root.height @@ -90,10 +128,44 @@ class HomeFragment : Fragment() { initDrag(floatingImage, root) setupBottomSheet(view) - setupViewPager() setupTopTabs() + + // 先把 ViewPager / Tags 初始化为空(避免你下面网络回来前被调用多次) + setupViewPager() setupTags() + + //刚进来强制显示列表1 + viewPager.setCurrentItem(0, false) + updateTabsAndTags(0) + + // 加载标签列表(列表一) + viewLifecycleOwner.lifecycleScope.launch { + try { + val list = fetchAllPersonaList() + allPersonaCache = list + viewPager.adapter?.notifyItemChanged(0) // 只刷新第一页 + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "获取列表一失败", e) + } + } + // 拉标签 + 默认加载第一个 tag 的 persona(列表二第一个页) + viewLifecycleOwner.lifecycleScope.launch { + try { + val response = RetrofitClient.apiService.tagList() + tags.clear() + response.data?.let { networkTags -> + tags.addAll(networkTags.map { Tag(it.id, it.tagName) }) + } + // 刷新:页数和标签栏 + setupViewPager() + setupTags() + startPreloadAllTags() + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "获取标签失败", e) + } + } } + // ---------------- 拖拽效果 ---------------- private fun initDrag(target: View, parent: ViewGroup) { var dX = 0f @@ -101,20 +173,15 @@ class HomeFragment : Fragment() { var lastRawX = 0f var lastRawY = 0f var isDragging = false - + val touchSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop - + target.setOnTouchListener { v, event -> when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - // 告诉 CoordinatorLayout:别拦截这次事件 parent.requestDisallowInterceptTouchEvent(true) - // 暂时禁止 BottomSheet 拖动 - if (::bottomSheetBehavior.isInitialized) { - bottomSheetBehavior.isDraggable = false - } - + if (::bottomSheetBehavior.isInitialized) bottomSheetBehavior.isDraggable = false + dX = v.x - event.rawX dY = v.y - event.rawY lastRawX = event.rawX @@ -122,61 +189,47 @@ class HomeFragment : Fragment() { isDragging = false true } - + MotionEvent.ACTION_MOVE -> { val dxMove = event.rawX - lastRawX val dyMove = event.rawY - lastRawY if (!isDragging && (abs(dxMove) > touchSlop || abs(dyMove) > touchSlop)) { isDragging = true } - + if (isDragging) { var newX = event.rawX + dX var newY = event.rawY + dY - - // 限制在父布局范围内 + val maxX = parentWidth - v.width val maxY = parentHeight - v.height - + newX = newX.coerceIn(0f, maxX.toFloat()) newY = newY.coerceIn(0f, maxY.toFloat()) - + v.x = newX v.y = newY } - + lastRawX = event.rawX lastRawY = event.rawY true } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - // 允许父布局继续拦截之后的事件 + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { parent.requestDisallowInterceptTouchEvent(false) - // 恢复 BottomSheet 可拖动 - if (::bottomSheetBehavior.isInitialized) { - bottomSheetBehavior.isDraggable = true - } - - if (!isDragging) { - v.performClick() - } - - // 手指抬起:吸边 + if (::bottomSheetBehavior.isInitialized) bottomSheetBehavior.isDraggable = true + + if (!isDragging) v.performClick() snapToEdge(v) true } - + else -> false } } } - - /** - * 吸边逻辑:左右贴边(需要上下也吸边可以再扩展) - */ private fun snapToEdge(v: View) { if (parentWidth == 0 || parentHeight == 0) return @@ -184,248 +237,240 @@ class HomeFragment : Fragment() { val toLeft = centerX < parentWidth / 2f val targetX = if (toLeft) 0f else (parentWidth - v.width).toFloat() - - // 如果你还想限制上下边距,比如离底部留 80dp 不遮挡 BottomSheet,可以再处理 y val minTop = 0f val maxBottom = (parentHeight - v.height).toFloat() val targetY = v.y.coerceIn(minTop, maxBottom) - v.animate() - .x(targetX) - .y(targetY) - .setDuration(200) - .start() + v.animate().x(targetX).y(targetY).setDuration(200).start() } - // ---------------- BottomSheet 行为 ---------------- + // ---------------- BottomSheet 行为 ---------------- private fun setupBottomSheet(root: View) { bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) - // 允许拖拽,允许嵌套滚动控制 bottomSheetBehavior.isDraggable = true bottomSheetBehavior.isHideable = false bottomSheetBehavior.isFitToContents = false - // 展开时高度占屏幕 70% bottomSheetBehavior.halfExpandedRatio = 0.7f - // 先等布局完成之后,计算“按钮下面剩余空间”作为 peekHeight root.post { - val coordinatorHeight = root.height-40 + val coordinatorHeight = root.height - 40 val button = root.findViewById(R.id.rechargeButton) - val buttonBottom = button.bottom - val peek = (coordinatorHeight - buttonBottom).coerceAtLeast(200) + val peek = (coordinatorHeight - button.bottom).coerceAtLeast(200) bottomSheetBehavior.peekHeight = peek bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } - // 监听状态变化,用来控制遮罩显示/隐藏 - bottomSheetBehavior.addBottomSheetCallback(object : - BottomSheetBehavior.BottomSheetCallback() { + bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { - when (newState) { - BottomSheetBehavior.STATE_COLLAPSED -> { - scrim.isVisible = false - } - BottomSheetBehavior.STATE_DRAGGING, - BottomSheetBehavior.STATE_EXPANDED, - BottomSheetBehavior.STATE_HALF_EXPANDED -> { - scrim.isVisible = true - } - else -> {} - } + scrim.isVisible = newState != BottomSheetBehavior.STATE_COLLAPSED } override fun onSlide(bottomSheet: View, slideOffset: Float) { - // 跟随滑动渐变遮罩透明度 - if (slideOffset >= 0f) { - scrim.alpha = slideOffset.coerceIn(0f, 1f) - } + if (slideOffset >= 0f) scrim.alpha = slideOffset.coerceIn(0f, 1f) } }) - // 点击遮罩,关闭回原位 scrim.setOnClickListener { bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } - // 简单的“空白区域下滑”关闭:在遮罩上响应手势(简单版,只要 move 就关) scrim.setOnTouchListener { _, event -> - // 这里可以更精细地判断手势方向,这里简单处理为:有滑动就关闭 - // 如果你想更准,可以根据 down / move 的 dy 判断 - // 为了示例就写得简单一点 - // MotionEvent.ACTION_MOVE = 2 - if (event.action == 2) { + if (event.action == MotionEvent.ACTION_MOVE) { bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED true - } else { - false - } + } else false } - // 点击底部盒子的“头部”,在折叠 / 半展开之间切换 header.setOnClickListener { when (bottomSheetBehavior.state) { - BottomSheetBehavior.STATE_COLLAPSED -> { + BottomSheetBehavior.STATE_COLLAPSED -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED - } + BottomSheetBehavior.STATE_HALF_EXPANDED, - BottomSheetBehavior.STATE_EXPANDED -> { + BottomSheetBehavior.STATE_EXPANDED -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } + else -> {} } } } - // ---------------- ViewPager2 + 列表 ---------------- + // ---------------- ViewPager2 + Tabs ---------------- + private var sheetAdapter: SheetPagerAdapter? = null + private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null private fun setupViewPager() { - val pageCount = 1 + tags.size // 1 = 第一个列表,剩下的是第二个列表的标签页 - viewPager.adapter = SheetPagerAdapter(pageCount) + if (sheetAdapter == null) { + sheetAdapter = SheetPagerAdapter(1 + tags.size) + viewPager.adapter = sheetAdapter - viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - updateTabsAndTags(position) // 里面会调用 highlightTag,把标签高亮并滚动 + pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + updateTabsAndTags(position) + } } - }) + viewPager.registerOnPageChangeCallback(pageChangeCallback!!) + } else { + // tags 数量变了,只更新 pageCount 并刷新一次即可 + sheetAdapter!!.updatePageCount(1 + tags.size) + } } + + + private fun startPreloadAllTags() { + preloadJob?.cancel() + + // 限制并发,避免一下子打爆网络/主线程调度抖动 + val semaphore = kotlinx.coroutines.sync.Semaphore(permits = 2) + + preloadJob = viewLifecycleOwner.lifecycleScope.launch { + // tags 还没拿到就别跑 + if (tags.isEmpty()) return@launch + + // 逐个 tag 预拉取(并发=2) + tags.forEachIndexed { index, tag -> + // 已经有缓存就跳过 + if (personaCache.containsKey(tag.id)) return@forEachIndexed + launch { + semaphore.acquire() + try { + val list = fetchPersonaByTag(tag.id) + personaCache[tag.id] = list + + val pagePos = 1 + index + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + viewPager.adapter?.notifyItemChanged(pagePos) + } + } finally { + semaphore.release() + } + } + } + } + } + - // 顶部“列表一 / 列表二”选项栏点击 private fun setupTopTabs() { - tabList1.setOnClickListener { - viewPager.currentItem = 0 // 列表一 - } + tabList1.setOnClickListener { viewPager.currentItem = 0 } tabList2.setOnClickListener { - viewPager.currentItem = 1 // 列表二的第一个标签页 + // 没有标签就别切 + if (tags.isNotEmpty()) viewPager.currentItem = 1 } } - // 顶部标签行(只在第二个列表时可见) private fun setupTags() { tagContainer.removeAllViews() + tags.forEachIndexed { index, tag -> - val tv = layoutInflater.inflate( - R.layout.item_tag, - tagContainer, - false - ) as TextView - tv.text = tag + val tv = layoutInflater.inflate(R.layout.item_tag, tagContainer, false) as TextView + tv.text = tag.tagName + tv.setOnClickListener { - // 当前位置 = 1 + 标签下标 - viewPager.currentItem = 1 + index + clickedTagId = tag.id + val pagePos = 1 + index + + // ✅ 先切页:用户体感立刻响应 + viewPager.setCurrentItem(pagePos, true) + + // ✅ 有缓存就不阻塞(可选:同时后台刷新) + val cached = personaCache[tag.id] + if (cached != null) { + viewPager.adapter?.notifyItemChanged(pagePos) + return@setOnClickListener + } + + // ✅ 没缓存:页内显示 loading(你 onBind 已经处理 cached==null 的 loading) + viewPager.adapter?.notifyItemChanged(pagePos) + + // 后台拉取,回来只刷新这一页 + viewLifecycleOwner.lifecycleScope.launch { + val list = fetchPersonaByTag(tag.id) + personaCache[tag.id] = list + viewPager.adapter?.notifyItemChanged(pagePos) // ✅ 只刷新这一页 + } } + + tagContainer.addView(tv) } - // 默认选中列表一,所以标签行默认隐藏 + tagScroll.isVisible = false } - // 根据当前 page 更新上方两个选项 & 标签高亮/显隐 private fun updateTabsAndTags(position: Int) { - if (position == 0) { tabList1.setTextColor(requireContext().getColor(R.color.black)) tabList2.setTextColor(requireContext().getColor(R.color.light_black)) tagScroll.isVisible = false - fadeImage(backgroundImage, R.drawable.option_background) - } else { - tabList1.setTextColor(requireContext().getColor(R.color.light_black)) tabList2.setTextColor(requireContext().getColor(R.color.black)) tagScroll.isVisible = true - fadeImage(backgroundImage, R.drawable.option_background_two) - + val tagIndex = position - 1 highlightTag(tagIndex) } } - //背景淡入淡出 + private fun fadeImage(imageView: ImageView, newImageRes: Int) { val oldDrawable = imageView.drawable - val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes) - - if (newDrawable == null) { - return - } - - // 第一次还没有旧图,直接设置就好 + val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes) ?: return + if (oldDrawable == null) { imageView.setImageDrawable(newDrawable) return } - + val transitionDrawable = TransitionDrawable(arrayOf(oldDrawable, newDrawable)).apply { - // 关键:启用交叉淡入淡出,旧图才会一起淡出 isCrossFadeEnabled = true } - imageView.setImageDrawable(transitionDrawable) - transitionDrawable.startTransition(300) // 300ms 淡入淡出 + transitionDrawable.startTransition(300) } - private fun highlightTag(index: Int) { for (i in 0 until tagContainer.childCount) { val child = tagContainer.getChildAt(i) as TextView if (i == index) { child.setBackgroundResource(R.drawable.tag_selected_bg) child.setTextColor(requireContext().getColor(android.R.color.white)) - - // 关键:把选中的标签滚动到可见(这里我用“居中”效果) tagScroll.post { val scrollViewWidth = tagScroll.width val childCenter = child.left + child.width / 2 val targetScrollX = childCenter - scrollViewWidth / 2 tagScroll.smoothScrollTo(targetScrollX.coerceAtLeast(0), 0) } - } else { child.setBackgroundResource(R.drawable.tag_unselected_bg) child.setTextColor(requireContext().getColor(R.color.light_black)) } } } - - // ---------------- 共享的 ViewHolder 类 ---------------- - - inner class PageViewHolder(val recyclerView: RecyclerView) : - RecyclerView.ViewHolder(recyclerView) - - // ---------------- ViewPager2 的 Adapter ---------------- - - /** - * 每一页都是一个 RecyclerView 卡片列表: - * - position = 0:列表一(数据 A) - * - position >= 1:列表二的第 index 个标签页(数据 B[index]) - */ + // ---------------- ViewPager Adapter ---------------- inner class SheetPagerAdapter( - private val pageCount: Int + private var pageCount: Int ) : RecyclerView.Adapter() { inner class PageViewHolder(val root: View) : RecyclerView.ViewHolder(root) - override fun getItemViewType(position: Int): Int { - // 0:第一个列表页,>0:第二个列表的各标签页 - return if (position == 0) 0 else 1 + fun updatePageCount(newCount: Int) { + pageCount = newCount + notifyDataSetChanged() } + override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { - val layoutId = when (viewType) { - 0 -> R.layout.bottom_page_list1 // 第一个列表的自定义内容 - else -> R.layout.bottom_page_list2 // 第二个列表各标签页的自定义内容 + val layoutId = if (viewType == 0) { + R.layout.bottom_page_list1 + } else { + R.layout.bottom_page_list2 } - - val root = LayoutInflater.from(parent.context) - .inflate(layoutId, parent, false) - - // 如果需要,禁用嵌套滚动(对 NestedScrollView 一般问题不大,可以不写) - // root.findViewById(R.id.scrollContent)?.isNestedScrollingEnabled = false - + val root = LayoutInflater.from(parent.context).inflate(layoutId, parent, false) return PageViewHolder(root) } @@ -433,73 +478,291 @@ class HomeFragment : Fragment() { val root = holder.root if (position == 0) { - // 这里可以拿到 bottom_page_list1 中的控件,做一些初始化 - // val someView = root.findViewById(R.id.xxx) - // someView.text = "xxx" - + renderList1(root, allPersonaCache) } else { - // // 第二个列表对应的标签页 - // val tagIndex = position - 1 - // val tagName = tags[tagIndex] + val rv2 = root.findViewById(R.id.recyclerView) + val loadingView = root.findViewById(R.id.loadingView) - // // 示例:把标题改成“标签一的内容 / 标签二的内容 ……” - // val titleView = root.findViewById(R.id.pageTitle) - // titleView?.text = "$tagName 的自定义内容" + rv2.setHasFixedSize(true) + rv2.itemAnimator = null + rv2.isNestedScrollingEnabled = false - // // 你也可以根据 tagIndex,显示/隐藏不同区域 - } + var adapter = rv2.adapter as? PersonaAdapter + if (adapter == null) { + adapter = PersonaAdapter { click -> + when (click) { + is PersonaClick.Item -> { + val id = click.persona.id + PersonaDetailDialogFragment + .newInstance(id) + .show(childFragmentManager, "persona_detail") + } + is PersonaClick.Add -> { + lifecycleScope.launch { + if (click.persona.added == true) { + click.persona.id?.let { id -> + RetrofitClient.apiService.delUserCharacter(id.toInt()) + } + } else { + val req = AddPersonaClick( + characterId = click.persona.id?.toInt() ?: 0, + emoji = click.persona.emoji ?: "" + ) + RetrofitClient.apiService.addUserCharacter(req) + } + } + } + } + } + rv2.layoutManager = GridLayoutManager(root.context, 2) + rv2.adapter = adapter + } - //让当前页里的滚动容器具备“下拉关闭 BottomSheet”的能力 - val scrollContent = root.findViewById(R.id.scrollContent) - if (scrollContent != null) { - setupPullToClose(scrollContent) + val tagIndex = position - 1 + if (tagIndex !in tags.indices) { + loadingView.isVisible = false + adapter.submitList(emptyList()) + return + } + + val tagId = tags[tagIndex].id + val cached = personaCache[tagId] + + if (cached == null) { + loadingView.isVisible = true + adapter.submitList(emptyList()) + } else { + loadingView.isVisible = false + adapter.submitList(cached) + } } } override fun getItemCount(): Int = pageCount } + + // 通过 tagIndex 取出该页要显示的数据 + private fun getPersonaListByTagIndex(tagIndex: Int): List { + if (tagIndex !in tags.indices) return emptyList() + val tagId = tags[tagIndex].id + return personaCache[tagId] ?: emptyList() + } + + private fun renderList1(root: View, list: List) { + // 1) 排序:rank 小的排前面 + val sorted = list.sortedBy { it.rank ?: Int.MAX_VALUE } + val top3 = sorted.take(3) + val others = if (sorted.size > 3) sorted.drop(3) else emptyList() + + // 2) 绑定前三名(注意:你的 UI 排列是:第二/第一/第三) + bindTopItem(root, + avatarId = R.id.avatar_first, + nameId = R.id.name_first, + addBtnId = R.id.btn_add_first, + container = R.id.container_first, + item = top3.getOrNull(0) // rank 最小 = 第一名 + ) + + bindTopItem(root, + avatarId = R.id.avatar_second, + nameId = R.id.name_second, + addBtnId = R.id.btn_add_second, + container = R.id.container_second, + item = top3.getOrNull(1) // 第二名 + ) + + bindTopItem(root, + avatarId = R.id.avatar_third, + nameId = R.id.name_third, + addBtnId = R.id.btn_add_third, + container = R.id.container_third, + item = top3.getOrNull(2) // 第三名 + ) + + // 3) 渲染后面的内容卡片 + val container = root.findViewById(R.id.container_others) + container.removeAllViews() + + val inflater = LayoutInflater.from(root.context) + others.forEach { p -> + val itemView = inflater.inflate(R.layout.item_rank_other, container, false) - private fun setupPullToClose(scrollable: View) { - var downY = 0f - var isDraggingToClose = false - scrollable.setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - downY = event.rawY - isDraggingToClose = false - } + itemView.findViewById(R.id.tv_rank).text = (p.rank ?: "--").toString() + itemView.findViewById(R.id.tv_name).text = p.characterName ?: "" + itemView.findViewById(R.id.tv_desc).text = p.characterBackground ?: "" - MotionEvent.ACTION_MOVE -> { - // 已经是折叠状态,不拦截,交给内容自己滚(其实也滚不动多少) - if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { - return@setOnTouchListener false + // 头像 + val iv = itemView.findViewById(R.id.iv_avatar) + // Glide 示例 + com.bumptech.glide.Glide.with(iv).load(p.avatarUrl).into(iv) + + itemView.setOnClickListener { + val id = p.id + Log.d("HomeFragment", "list1 others click id=$id") + PersonaDetailDialogFragment + .newInstance(id) + .show(childFragmentManager, "persona_detail") + } + + // 只点“添加”按钮 + itemView.findViewById(R.id.btn_add).setOnClickListener { + val id = p.id + lifecycleScope.launch { + if(p.added == true){ + //取消收藏 + p.id?.let { id -> + try { + RetrofitClient.apiService.delUserCharacter(id.toInt()) + } catch (e: Exception) { + // 处理错误 + } + } + }else{ + val addPersonaRequest = AddPersonaClick( + characterId = p.id?.toInt() ?: 0, + emoji = p.emoji ?: "" + ) + try { + RetrofitClient.apiService.addUserCharacter(addPersonaRequest) + } catch (e: Exception) { + // 处理错误 + } } - - val dy = event.rawY - downY - - if (!scrollable.canScrollVertically(-1) && // 已在顶部 - dy > dragToCloseThreshold && // 向下拉超过阈值 - (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - bottomSheetBehavior.state == BottomSheetBehavior.STATE_HALF_EXPANDED) - ) { - isDraggingToClose = true - bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - return@setOnTouchListener true - } - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - isDraggingToClose = false } } - isDraggingToClose + container.addView(itemView) } } + private fun bindTopItem( + root: View, + avatarId: Int, + nameId: Int, + addBtnId: Int, + container: Int, + item: listByTagWithNotLogin? + ) { + val avatar = root.findViewById(avatarId) + val name = root.findViewById(nameId) + val addBtn = root.findViewById(addBtnId) + val container = root.findViewById(container) + + if (item == null) { + // 没数据就隐藏(或者显示占位) + // avatar.isVisible = false + name.isVisible = false + addBtn.isVisible = false + return + } + + avatar.isVisible = true + name.isVisible = true + addBtn.isVisible = true + + name.text = item.characterName ?: "" + + // 头像 + com.bumptech.glide.Glide.with(avatar).load(item.avatarUrl).into(avatar) + + addBtn.setOnClickListener { + val id = item.id + lifecycleScope.launch { + if(item.added == true){ + //取消收藏 + item.id?.let { id -> + try { + RetrofitClient.apiService.delUserCharacter(id.toInt()) + } catch (e: Exception) { + // 处理错误 + } + } + }else{ + val addPersonaRequest = AddPersonaClick( + characterId = item.id?.toInt() ?: 0, + emoji = item.emoji ?: "" + ) + try { + RetrofitClient.apiService.addUserCharacter(addPersonaRequest) + } catch (e: Exception) { + // 处理错误 + } + } + } + } + + container.setOnClickListener { + val id = item.id + Log.d("HomeFragment", "list1 top click id=$id rank=${item.rank}") + PersonaDetailDialogFragment + .newInstance(id) + .show(childFragmentManager, "persona_detail") + } + } + + + // ---------------- 网络请求 ---------------- + private suspend fun fetchPersonaByTag(tagId: Int): List { + return try { + val resp = if (!isLoggedIn()) { + RetrofitClient.apiService.personaListByTag(tagId) + } else { + RetrofitClient.apiService.loggedInPersonaListByTag(tagId) + } + resp.data ?: emptyList() + } catch (e: Exception) { + if(!isLoggedIn()){ + //未登录用户获取人设列表 + Log.e("1314520-HomeFragment", "未登录根据标签获取人设列表", e) + }else{ + Log.e("1314520-HomeFragment", "登录根据标签获取人设列表", e) + } + emptyList() + } + } + + private suspend fun fetchAllPersonaList(): List { + return try { + val personaData = if (!isLoggedIn()) { + RetrofitClient.apiService.personaListWithNotLogin() + } else { + RetrofitClient.apiService.personaByTag() + } + personaData.data ?: emptyList() + } catch (e: Exception) { + if(!isLoggedIn()){ + //未登录用户获取人设列表 + Log.e("1314520-HomeFragment", "未登录用户人设列表", e) + }else{ + Log.e("1314520-HomeFragment", "登录用户人设列表", e) + } + emptyList() + } + } + + suspend fun getpersonaLis(id: Int): ApiResponse>? { + return try { + RetrofitClient.apiService.personaListByTag(id) + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "未登录用户按标签查询人设列表", e) + null + } + } + + suspend fun loggedInGetpersonaLis(id: Int): ApiResponse>? { + return try { + RetrofitClient.apiService.loggedInPersonaListByTag(id) + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "登录用户按标签查询人设列表", e) + null + } + } + + private fun isLoggedIn(): Boolean { + return EncryptedSharedPreferencesUtil.contains(requireContext(), "user") + } } diff --git a/app/src/main/java/com/example/myapplication/ui/home/List1Adapter.kt b/app/src/main/java/com/example/myapplication/ui/home/List1Adapter.kt new file mode 100644 index 0000000..18399e2 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/home/List1Adapter.kt @@ -0,0 +1,38 @@ +package com.example.myapplication.ui.home + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class List1Adapter( + private val onClick: (String) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + + fun submitList(list: List) { + items.clear() + items.addAll(list) + notifyDataSetChanged() + } + + inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + val tv: TextView = itemView.findViewById(android.R.id.text1) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val view = LayoutInflater.from(parent.context) + .inflate(android.R.layout.simple_list_item_1, parent, false) + return VH(view) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val item = items[position] + holder.tv.text = item + holder.itemView.setOnClickListener { onClick(item) } + } + + override fun getItemCount(): Int = items.size +} 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 new file mode 100644 index 0000000..1a479f6 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt @@ -0,0 +1,72 @@ +package com.example.myapplication.ui.home + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.example.myapplication.R +import com.example.myapplication.network.listByTagWithNotLogin +import com.example.myapplication.network.PersonaClick +import de.hdodenhof.circleimageview.CircleImageView +import android.util.Log + +class PersonaAdapter( + private val onClick: (PersonaClick) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + + fun submitList(list: List) { + items.clear() + items.addAll(list) + notifyDataSetChanged() + } + + inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + + val ivAvatar: CircleImageView = itemView.findViewById(R.id.ivAvatar) + val tvName: TextView = itemView.findViewById(R.id.tvName) + val characterBackground: TextView = + itemView.findViewById(R.id.characterBackground) + val download: TextView = itemView.findViewById(R.id.download) + val operation: TextView = itemView.findViewById(R.id.operation) + + /** ✅ 统一绑定 + 点击逻辑 */ + fun bind(item: listByTagWithNotLogin) { + + tvName.text = item.characterName + characterBackground.text = item.characterBackground + download.text = item.download + + Glide.with(itemView.context) + .load(item.avatarUrl) + .placeholder(R.drawable.default_avatar) + .error(R.drawable.default_avatar) + .into(ivAvatar) + + // ✅ 整个 item:跳详情 + itemView.setOnClickListener { + onClick(PersonaClick.Item(item)) + } + + // ✅ 添加 / 下载按钮 + operation.setOnClickListener { + onClick(PersonaClick.Add(item)) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_persona, parent, false) + return VH(view) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size +} 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 new file mode 100644 index 0000000..b8a2663 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt @@ -0,0 +1,111 @@ +package com.example.myapplication.ui.home + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.WindowManager +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.example.myapplication.R +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.CharacterDetailResponse +import kotlinx.coroutines.launch +import com.example.myapplication.network.AddPersonaClick + +class PersonaDetailDialogFragment : DialogFragment() { + + companion object { + private const val ARG_ID = "arg_persona_id" + + fun newInstance(personaId: Int): PersonaDetailDialogFragment { + return PersonaDetailDialogFragment().apply { + arguments = Bundle().apply { putInt(ARG_ID, personaId) } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = Dialog(requireContext(), R.style.PersonaDetailDialog) // 下面会给 style + val view = LayoutInflater.from(context).inflate(R.layout.dialog_persona_detail, null, false) + dialog.setContentView(view) + dialog.setCanceledOnTouchOutside(true) + + val personaId = requireArguments().getInt(ARG_ID) + + val btnClose = view.findViewById(R.id.btnClose) + val ivAvatar = view.findViewById(R.id.ivAvatar) + val tvName = view.findViewById(R.id.tvName) + val tvBackground = view.findViewById(R.id.tvBackground) + val btnAdd = view.findViewById(R.id.btnAdd) + val download = view.findViewById(R.id.download) + + btnClose.setOnClickListener { dismissAllowingStateLoss() } + + // ✅ 拉详情 - 使用lifecycleScope而不是viewLifecycleOwner + lifecycleScope.launch { + try { + val resp = RetrofitClient.apiService.characterDetail(personaId) + val data = resp.data + + + if (data == null) { + return@launch + } + + tvName.text = data.characterName ?: "" + download.text = data.download ?: "" + tvBackground.text = data.characterBackground ?: "" + btnAdd.text = data.added?.let { "Added" } ?: "Add" + btnAdd.setBackgroundResource(data.added?.let { R.drawable.ic_added } ?: R.drawable.keyboard_ettings) + + Glide.with(requireContext()) + .load(data.avatarUrl) + .placeholder(R.drawable.default_avatar) + .error(R.drawable.default_avatar) + .into(ivAvatar) + + btnAdd.setOnClickListener { + lifecycleScope.launch { + if(data.added == true){ + //取消收藏 + data.id?.let { id -> + try { + RetrofitClient.apiService.delUserCharacter(id.toInt()) + } catch (e: Exception) { + // 处理错误 + } + } + }else{ + val addPersonaRequest = AddPersonaClick( + characterId = data.id?.toInt() ?: 0, + emoji = data.emoji ?: "" + ) + try { + RetrofitClient.apiService.addUserCharacter(addPersonaRequest) + } catch (e: Exception) { + // 处理错误 + } + } + dismissAllowingStateLoss() + } + } + } catch (e: Exception) { + } + } + + return dialog + } + + override fun onStart() { + super.onStart() + // 让弹窗宽度接近屏幕 + dialog?.window?.setLayout( + (resources.displayMetrics.widthPixels * 0.92f).toInt(), + WindowManager.LayoutParams.WRAP_CONTENT + ) + } +} 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 4e51c72..1d5b1e7 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 @@ -1,14 +1,60 @@ package com.example.myapplication.ui.keyboard +import android.app.Dialog +import android.content.Intent import android.os.Bundle +import android.util.Log +import android.util.TypedValue +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment +import android.widget.Button import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.bumptech.glide.Glide import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +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.ui.shop.ThemeCardAdapter +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.imageview.ShapeableImageView +import kotlinx.coroutines.launch +import com.example.myapplication.GuideActivity +import com.example.myapplication.network.themeStyle +import com.example.myapplication.network.FileDownloader +import com.example.myapplication.theme.ThemeManager +import com.example.myapplication.utils.unzipThemeSmart +import com.example.myapplication.utils.logZipEntries +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.FileInputStream class KeyboardDetailFragment : Fragment() { + + private lateinit var shapeableImageView: ShapeableImageView + private lateinit var tvKeyboardName: TextView + private lateinit var tvDownloadCount: TextView + private lateinit var layoutTagsContainer: LinearLayout + private lateinit var recyclerRecommendList: RecyclerView + private lateinit var themeCardAdapter: ThemeCardAdapter + private lateinit var tvPrice: TextView + private lateinit var rechargeButton: LinearLayout + private lateinit var enabledButton: LinearLayout + private lateinit var enabledButtonText: TextView + private lateinit var progressBar: android.widget.ProgressBar + private lateinit var swipeRefreshLayout: SwipeRefreshLayout override fun onCreateView( inflater: LayoutInflater, @@ -21,8 +67,454 @@ class KeyboardDetailFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + shapeableImageView = view.findViewById(R.id.iv_keyboard) + tvKeyboardName = view.findViewById(R.id.tv_keyboard_name) + tvDownloadCount = view.findViewById(R.id.tv_download_count) + layoutTagsContainer = view.findViewById(R.id.layout_tags_container) + recyclerRecommendList = view.findViewById(R.id.recycler_recommend_list) + tvPrice = view.findViewById(R.id.tv_price) + rechargeButton = view.findViewById(R.id.rechargeButton) + enabledButton = view.findViewById(R.id.enabledButton) + enabledButtonText = view.findViewById(R.id.enabledButtonText) + progressBar = view.findViewById(R.id.progressBar) + swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) + + // 设置按钮始终防止事件穿透的触摸监听器 + enabledButton.setOnTouchListener { _, event -> + // 如果按钮被禁用,消耗所有触摸事件防止穿透 + if (!enabledButton.isEnabled) { + return@setOnTouchListener true + } + // 如果按钮启用,不消耗事件,让按钮正常处理点击 + return@setOnTouchListener false + } + + // 初始化RecyclerView + setupRecyclerView() + + // 设置下拉刷新监听器 + swipeRefreshLayout.setOnRefreshListener { + loadData() + } + + // 获取传递的参数 + val themeId = arguments?.getInt("themeId", 0) ?: 0 + + // 根据themeId加载主题详情 + if (themeId != 0) { + loadData() + } + view.findViewById(R.id.iv_close).setOnClickListener { parentFragmentManager.popBackStack() } + + //充值主题 + rechargeButton.setOnClickListener { + showPurchaseConfirmationDialog(themeId) + } + //启动主题 + enabledButton.setOnClickListener { + viewLifecycleOwner.lifecycleScope.launch { + enableTheme() + } + } + } + + private fun loadData() { + val themeId = arguments?.getInt("themeId", 0) ?: 0 + if (themeId == 0) { + swipeRefreshLayout.isRefreshing = false + return + } + + viewLifecycleOwner.lifecycleScope.launch { + try { + val themeDetailResp = getThemeDetail(themeId)?.data + val recommendThemeListResp = getrecommendThemeList()?.data + + Glide.with(requireView().context) + .load(themeDetailResp?.themePreviewImageUrl) + .placeholder(R.drawable.bg) + .into(shapeableImageView) + + tvKeyboardName.text = themeDetailResp?.themeName + tvDownloadCount.text = "Download:${themeDetailResp?.themeDownload}" + tvPrice.text = "${themeDetailResp?.themePrice}" + + if (themeDetailResp?.isPurchased ?: false) { + rechargeButton.visibility = View.GONE + enabledButton.visibility = View.VISIBLE + } else { + rechargeButton.visibility = View.VISIBLE + enabledButton.visibility = View.GONE + } + + // 渲染标签 + themeDetailResp?.themeTag?.let { tags -> + renderTags(tags) + } + + // 渲染推荐主题列表(剔除当前themeId) + recommendThemeListResp?.let { themes -> + val filteredThemes = themes.filter { it.id != themeId } + themeCardAdapter.submitList(filteredThemes) + } + + } catch (e: Exception) { + Log.e("KeyboardDetailFragment", "获取主题详情异常", e) + } finally { + // 停止刷新动画 + swipeRefreshLayout.isRefreshing = false + } + } + } + + private fun renderTags(tags: List) { + layoutTagsContainer.removeAllViews() + + if (tags.isEmpty()) return + + val context = layoutTagsContainer.context + val tagsPerRow = 5 // 每行固定显示5个标签 + + // 将标签分组,每行6个 + val rows = tags.chunked(tagsPerRow) + + rows.forEach { rowTags -> + val rowLayout = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + // 添加行间距:上下相隔5dp + bottomMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics + ).toInt() + } + } + + // 计算每个标签的权重(等间距分布) + val tagWeight = 1f / tagsPerRow + + rowTags.forEach { tag -> + val tagView = TextView(context).apply { + text = tag.label + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setTextColor(ContextCompat.getColor(context, android.R.color.white)) + gravity = Gravity.CENTER + + // 设置内边距:左右12dp,上下5dp + val horizontalPadding = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 12f, resources.displayMetrics + ).toInt() + val verticalPadding = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics + ).toInt() + setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) + + // 设置背景(50dp圆角) + background = ContextCompat.getDrawable(context, R.drawable.tag_background)?.mutate() + background?.setTint(android.graphics.Color.parseColor(tag.color)) + } + + // 使用权重布局,让标签自适应间距 + val layoutParams = LinearLayout.LayoutParams( + 0, // 宽度设为0,使用权重 + LinearLayout.LayoutParams.WRAP_CONTENT, + tagWeight + ).apply { + // 添加标签间距 + marginEnd = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics + ).toInt() + } + + rowLayout.addView(tagView, layoutParams) + } + + // 如果当前行标签数量不足6个,添加空View填充剩余空间 + val remainingTags = tagsPerRow - rowTags.size + if (remainingTags > 0) { + repeat(remainingTags) { + val emptyView = View(context) + val layoutParams = LinearLayout.LayoutParams( + 0, // 宽度设为0,使用权重 + LinearLayout.LayoutParams.WRAP_CONTENT, + tagWeight + ).apply { + marginEnd = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics + ).toInt() + } + rowLayout.addView(emptyView, layoutParams) + } + } + + layoutTagsContainer.addView(rowLayout) + } + } + //=============================网络请求=================================== + private suspend fun getThemeDetail(themeId: Int): ApiResponse? { + return try { + RetrofitClient.apiService.themeDetail(themeId) + } catch (e: Exception) { + Log.e("KeyboardDetailFragment", "获取主题详情失败", e) + null + } + } + + private suspend fun setrestoreTheme(themeId: Int): ApiResponse? { + return try { + RetrofitClient.apiService.restoreTheme(themeId) + } catch (e: Exception) { + Log.e("KeyboardDetailFragment", "恢复已删除的主题失败", e) + null + } + } + + private suspend fun getrecommendThemeList(): ApiResponse>? { + return try { + RetrofitClient.apiService.recommendThemeList() + } catch (e: Exception) { + Log.e("KeyboardDetailFragment", "获取推荐列表失败", e) + null + } + } + private suspend fun setpurchaseTheme(purchaseId: Int) { + try { + val purchaseThemeRequest = purchaseThemeRequest(themeId = purchaseId) + val response = RetrofitClient.apiService.purchaseTheme(purchaseThemeRequest) + + // 购买成功后触发刷新(成功状态码为0) + if (response?.code == 0) { + loadData() + } + } catch (e: Exception) { + Log.e("KeyboardDetailFragment", "购买主题失败", e) + } + } + + + //=============================RecyclerView=================================== + private fun setupRecyclerView() { + // 设置GridLayoutManager,每行显示2个item + val layoutManager = GridLayoutManager(requireContext(), 2) + recyclerRecommendList.layoutManager = layoutManager + + // 初始化ThemeCardAdapter + themeCardAdapter = ThemeCardAdapter() + recyclerRecommendList.adapter = themeCardAdapter + + // 设置item间距(可选) + recyclerRecommendList.setPadding(0, 0, 0, 0) + } + + //=============================弹窗=================================== + private fun showPurchaseConfirmationDialog(themeId: Int) { + 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, + android.view.WindowManager.LayoutParams.WRAP_CONTENT + ) + + // 设置按钮点击事件 + dialog.findViewById(R.id.btn_confirm).setOnClickListener { + // 确认购买逻辑 + viewLifecycleOwner.lifecycleScope.launch { + setpurchaseTheme(themeId) + dialog.dismiss() + } + } + + dialog.findViewById(R.id.btn_cancel).setOnClickListener { + dialog.dismiss() + } + + // 显示弹窗 + dialog.show() + } + + /** + * 从 URL 中提取 zip 包名(去掉路径和查询参数,去掉 .zip 扩展名) + */ + private fun extractZipNameFromUrl(url: String): String { + // 提取文件名部分(去掉路径和查询参数) + val fileName = if (url.contains('?')) { + url.substring(url.lastIndexOf('/') + 1, url.indexOf('?')) + } else { + url.substring(url.lastIndexOf('/') + 1) + } + + // 去掉 .zip 扩展名 + return if (fileName.endsWith(".zip")) { + fileName.substring(0, fileName.length - 4) + } else { + fileName + } + } + + /** + * 启用主题:下载、解压并设置主题 + */ + private suspend fun enableTheme() { + val themeId = arguments?.getInt("themeId", 0) ?: 0 + if (themeId == 0) { + return + } + + // 恢复已删除的主题 + val restoreResponse = setrestoreTheme(themeId) + if (restoreResponse?.code != 0) { + // 恢复失败,显示错误信息并返回 + Log.e("1314520-KeyboardDetailFragment", "恢复主题失败: ${restoreResponse?.message ?: "未知错误"}") + return + } + + // 显示下载进度 + showDownloadProgress() + + try { + // 获取主题详情 + val themeDetailResp = getThemeDetail(themeId)?.data + if (themeDetailResp == null) { + hideDownloadProgress() + return + } + + val downloadUrl = themeDetailResp.themeDownloadUrl + + if (downloadUrl.isNullOrEmpty()) { + hideDownloadProgress() + return + } + + // 从下载 URL 中提取 zip 包名作为主题名称 + val themeName = extractZipNameFromUrl(downloadUrl) + + val context = requireContext() + + // 检查主题是否已存在 + val availableThemes = ThemeManager.listAvailableThemes(context) + if (availableThemes.contains(themeId.toString())) { + ThemeManager.setCurrentTheme(context, themeId.toString()) + showSuccessMessage("主题已启用") + hideDownloadProgress() + // 跳转到GuideActivity + val intent = Intent(requireContext(), GuideActivity::class.java) + startActivity(intent) + return + } + + // 主动下载主题 + Log.d("1314520-KeyboardDetailFragment", "Downloading theme $themeName from $downloadUrl") + + // 下载 zip 文件 + val downloadedFile = FileDownloader.downloadZipFile( + context = context, + remoteFileName = downloadUrl, + localFileName = "$themeName.zip" + ) + + if (downloadedFile == null) { + showErrorMessage("下载主题失败") + hideDownloadProgress() + return + } + Log.d("1314520-zip", "path=${downloadedFile.absolutePath}") + Log.d("1314520-zip", "size=${downloadedFile.length()} bytes") + + // 打印前16字节(确认PK头/或者错误文本) + FileInputStream(downloadedFile).use { fis -> + val head = ByteArray(16) + val n = fis.read(head) + Log.d("1314520-zip", "head16=${head.take(n).joinToString { b -> "%02X".format(b) }}") + } + + // 解压到主题目录 + try { + val installedThemeName: String = withContext(Dispatchers.IO) { + unzipThemeSmart( + context = context, + zipFile = downloadedFile, + themeId = themeId + ) + } + ThemeManager.setCurrentTheme(context, installedThemeName) + + // 删除临时下载文件 + downloadedFile.delete() + showSuccessMessage("主题启用成功") + // 跳转到GuideActivity + val intent = Intent(requireContext(), GuideActivity::class.java) + startActivity(intent) + + } catch (e: Exception) { + showErrorMessage("解压主题失败:${e.message}") + // 清理临时文件 + downloadedFile.delete() + } + + } catch (e: Exception) { + showErrorMessage("启用主题失败") + } finally { + hideDownloadProgress() + } + } + + /** + * 显示下载进度 + */ + private fun showDownloadProgress() { + // 在主线程中更新UI + view?.post { + progressBar.visibility = View.VISIBLE + enabledButtonText.text = "Loading..." + // 完全禁用按钮交互 + enabledButton.isEnabled = false + enabledButton.isClickable = false + enabledButton.isFocusable = false + // 防止点击事件穿透 - 消耗所有触摸事件 + enabledButton.setOnTouchListener { _, _ -> true } + // 添加视觉上的禁用效果 + enabledButton.alpha = 0.6f + } + } + + /** + * 隐藏下载进度 + */ + private fun hideDownloadProgress() { + // 在主线程中更新UI + view?.post { + progressBar.visibility = View.GONE + enabledButtonText.text = "Enabled" + // 恢复按钮交互 + enabledButton.isEnabled = true + enabledButton.isClickable = true + enabledButton.isFocusable = true + // 移除触摸监听器,恢复正常触摸事件处理 + enabledButton.setOnTouchListener(null) + // 恢复正常的视觉效果 + enabledButton.alpha = 1.0f + } + + } + + private fun showSuccessMessage(message: String) { + // 使用 Toast 显示成功消息 + android.widget.Toast.makeText(requireContext(), message, android.widget.Toast.LENGTH_SHORT).show() + Log.d("1314520-KeyboardDetailFragment", "Success: $message") + } + + private fun showErrorMessage(message: String) { + // 使用 Toast 显示错误消息 + android.widget.Toast.makeText(requireContext(), message, android.widget.Toast.LENGTH_SHORT).show() + Log.e("1314520-KeyboardDetailFragment", "Error: $message") } } 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 a325829..9d8a473 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 @@ -2,25 +2,41 @@ package com.example.myapplication.ui.login import android.os.Bundle import android.text.InputType +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.ImageView import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import android.widget.FrameLayout import android.widget.TextView import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.LoginRequest +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import com.google.android.material.button.MaterialButton +import android.widget.Toast +import kotlinx.coroutines.launch class LoginFragment : Fragment() { private lateinit var passwordEditText: EditText private lateinit var toggleImageView: ImageView - private lateinit var loginButton: MaterialButton // 如果你 XML 里有这个按钮 id: btn_login + private lateinit var loginButton: TextView + private lateinit var emailEditText: EditText + private var loadingOverlay: com.example.myapplication.ui.common.LoadingOverlay? = null//加载遮罩层 private var isPasswordVisible = false + + override fun onDestroyView() { + loadingOverlay?.remove() + loadingOverlay = null + super.onDestroyView() + } override fun onCreateView( inflater: LayoutInflater, @@ -32,6 +48,7 @@ class LoginFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + loadingOverlay = com.example.myapplication.ui.common.LoadingOverlay.attach(view as ViewGroup) // 注册 view.findViewById(R.id.tv_signup).setOnClickListener { @@ -43,12 +60,23 @@ class LoginFragment : Fragment() { } // 返回按钮 view.findViewById(R.id.iv_close).setOnClickListener { - parentFragmentManager.popBackStack() + findNavController().previousBackStackEntry + ?.savedStateHandle + ?.set("from_login", true) + + findNavController().popBackStack() } // 绑定控件(id 必须和 xml 里的一样) passwordEditText = view.findViewById(R.id.et_password) + emailEditText = view.findViewById(R.id.et_email) toggleImageView = view.findViewById(R.id.iv_toggle) - // loginButton = view.findViewById(R.id.btn_login) // 如果没有这个按钮就把这一行和变量删了 + loginButton = view.findViewById(R.id.btn_login) + + // 账号回填 + val savedEmail = EncryptedSharedPreferencesUtil.get(requireContext(), "email", String::class.java) + savedEmail?.let { email -> + emailEditText.setText(email) + } // 初始是隐藏密码状态 passwordEditText.inputType = @@ -73,10 +101,39 @@ class LoginFragment : Fragment() { passwordEditText.setSelection(passwordEditText.text?.length ?: 0) } - // // 登录按钮逻辑你自己填 - // loginButton.setOnClickListener { - // val pwd = passwordEditText.text?.toString().orEmpty() - // // TODO: 登录处理 - // } + // // 登录按钮逻辑 + loginButton.setOnClickListener { + val pwd = passwordEditText.text?.toString().orEmpty() + val email = emailEditText.text?.toString().orEmpty() + if (pwd.isEmpty() || email.isEmpty()) { + // 输入框不能为空 + Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show() + } else { + loadingOverlay?.show() + // 调用登录API + lifecycleScope.launch { + try { + val loginRequest = LoginRequest( + mail = email, // 使用email作为username + password = pwd + ) + val response = RetrofitClient.apiService.login(loginRequest) + // 存储登录响应 + if (response.code == 0) { + EncryptedSharedPreferencesUtil.save(requireContext(), "user", response.data) + EncryptedSharedPreferencesUtil.save(requireContext(), "email",email) + findNavController().popBackStack() + } else { + Toast.makeText(requireContext(), "Login failed: ${response.message}", Toast.LENGTH_SHORT).show() + } + loadingOverlay?.hide() + } catch (e: Exception) { + Log.e("1314520-LoginFragment", "登录请求失败: ${e.message}", e) + Toast.makeText(requireContext(), "Login failed: ${e.message}", Toast.LENGTH_SHORT).show() + loadingOverlay?.hide() + } + } + } + } } } diff --git a/app/src/main/java/com/example/myapplication/ui/mine/LogoutDialogFragment.kt b/app/src/main/java/com/example/myapplication/ui/mine/LogoutDialogFragment.kt new file mode 100644 index 0000000..325c849 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/mine/LogoutDialogFragment.kt @@ -0,0 +1,40 @@ +//退出弹窗 +package com.example.myapplication.ui.mine + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import com.example.myapplication.R + +class LogoutDialogFragment( + private val onConfirm: () -> Unit +) : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = Dialog(requireContext()) + dialog.setContentView(R.layout.dialog_logout) + dialog.setCancelable(true) + + dialog.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + dialog.findViewById(R.id.btn_cancel).setOnClickListener { + dismiss() + } + dialog.findViewById(R.id.btn_logout).setOnClickListener { + dismiss() + onConfirm() + } + + return dialog + } +} 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 9214004..171078e 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 @@ -6,13 +6,27 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.example.myapplication.R import android.widget.LinearLayout +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.LoginResponse import de.hdodenhof.circleimageview.CircleImageView +import android.util.Log +import kotlinx.coroutines.launch +import android.widget.TextView +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import androidx.navigation.navOptions class MineFragment : Fragment() { + private lateinit var nickname: TextView + private lateinit var time: TextView + private lateinit var logout: TextView + + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -24,6 +38,78 @@ class MineFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + // 判断是否登录(门禁) + if (!isLoggedIn()) { + val nav = findNavController() + + // 改用 savedStateHandle 的标记:LoginFragment 返回时写入 + val fromLogin = nav.currentBackStackEntry + ?.savedStateHandle + ?.get("from_login") == true + + // 用完就清掉 + nav.currentBackStackEntry?.savedStateHandle?.remove("from_login") + + view?.post { + try { + if (fromLogin) { + // 从登录页回来仍未登录:跳首页 + nav.navigate(R.id.action_global_homeFragment) + } else { + // 不是从登录页来:跳登录 + nav.navigate(R.id.action_mineFragment_to_loginFragment) + } + } catch (e: IllegalArgumentException) { + // 万一你的导航框架在当前时机解析 action 有问题,兜底:直接去目标 Fragment id + if (fromLogin) { + nav.navigate(R.id.homeFragment) + } else { + nav.navigate(R.id.loginFragment) + } + } + } + + return + } + + + nickname = view.findViewById(R.id.nickname) + time = view.findViewById(R.id.time) + logout = view.findViewById(R.id.logout) + + + // 获取用户信息, 并显示 + val user = EncryptedSharedPreferencesUtil.get(requireContext(), "Personal_information", LoginResponse::class.java) + nickname.text = user?.nickName ?: "" + time.text = user?.vipExpiry?.let { "Due on November $it" } ?: "" + + // 2) 下一帧再请求网络(让首帧先出来) + view.post { + viewLifecycleOwner.lifecycleScope.launch { + try { + val response = RetrofitClient.apiService.getUser() + nickname.text = response.data?.nickName ?: "" + time.text = response.data?.vipExpiry?.let { "Due on November $it" } ?: "" + EncryptedSharedPreferencesUtil.save(requireContext(), "Personal_information", response.data) + } catch (e: Exception) { + Log.e("1314520-MineFragment", "获取失败", e) + } + } + } + + // 退出登录(先确认) + logout.setOnClickListener { + LogoutDialogFragment { + // ✅ 用户确认后才执行 + EncryptedSharedPreferencesUtil.remove(requireContext(), "Personal_information") + EncryptedSharedPreferencesUtil.remove(requireContext(), "user") + + // ⚠️ 建议用 popUpTo 清栈,避免按返回回到已登录页面 + findNavController().navigate(R.id.action_mineFragment_to_loginFragment) + }.show(parentFragmentManager, "logout_dialog") + } + // 会员充值按钮点击 view.findViewById(R.id.imgLeft).setOnClickListener { @@ -56,9 +142,12 @@ class MineFragment : Fragment() { } //隐私政策 - view.findViewById(R.id.click_Privacy).setOnClickListener { - findNavController().navigate(R.id.action_mineFragment_to_loginFragment) - } - + // view.findViewById(R.id.click_Privacy).setOnClickListener { + // findNavController().navigate(R.id.action_mineFragment_to_loginFragment) + // } + + } + private fun isLoggedIn(): Boolean { + return EncryptedSharedPreferencesUtil.contains(requireContext(), "user") } } diff --git a/app/src/main/java/com/example/myapplication/ui/shop/NoHorizontalInterceptSwipeRefreshLayout.kt b/app/src/main/java/com/example/myapplication/ui/shop/NoHorizontalInterceptSwipeRefreshLayout.kt new file mode 100644 index 0000000..b429c29 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/shop/NoHorizontalInterceptSwipeRefreshLayout.kt @@ -0,0 +1,46 @@ +package com.example.myapplication.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlin.math.abs + +class NoHorizontalInterceptSwipeRefreshLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : SwipeRefreshLayout(context, attrs) { + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var startX = 0f + private var startY = 0f + private var isDragging = false + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + startX = ev.x + startY = ev.y + isDragging = false + // 让 SwipeRefreshLayout 记录好初始状态 + return super.onInterceptTouchEvent(ev) + } + + MotionEvent.ACTION_MOVE -> { + val dx = ev.x - startX + val dy = ev.y - startY + + if (!isDragging && (abs(dx) > touchSlop || abs(dy) > touchSlop)) { + isDragging = true + } + + // ✅ 横向为主:不拦截,把事件留给 ViewPager2 + if (isDragging && abs(dx) > abs(dy)) { + return false + } + } + } + return super.onInterceptTouchEvent(ev) + } +} 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 d12b69e..45be2a1 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 @@ -1,168 +1,261 @@ package com.example.myapplication.ui.shop -import android.content.Intent +import android.annotation.SuppressLint +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.graphics.Color import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable import android.os.Bundle -import android.view.LayoutInflater +import android.util.Log +import android.view.MotionEvent import android.view.View -import android.view.ViewGroup import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView -import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.example.myapplication.R -import androidx.fragment.app.FragmentActivity -import androidx.viewpager2.adapter.FragmentStateAdapter -import android.graphics.Color -import android.graphics.drawable.GradientDrawable -import android.animation.ValueAnimator -import android.animation.ArgbEvaluator +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.Theme +import com.example.myapplication.network.Wallet +import com.example.myapplication.network.themeStyle +import kotlinx.coroutines.launch -class ShopFragment : Fragment() { +class ShopFragment : Fragment(R.layout.fragment_shop) { private lateinit var viewPager: ViewPager2 private lateinit var tagScroll: HorizontalScrollView private lateinit var tagContainer: LinearLayout + private lateinit var balance: TextView + private lateinit var swipeRefreshLayout: SwipeRefreshLayout - // 标签标题,可以根据需要修改 - private val tabTitles = listOf("全部", "数码", "服饰", "家居", "美食","数码", "服饰", "家居", "美食") + // 风格 tabs + private var tabTitles: List = emptyList() + private var styleIds: List = emptyList() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_shop, container, false) - } + // ✅ 共享数据/缓存/加载都交给 VM + private val vm: ShopViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - - // 金币充值按钮点击 + view.findViewById(R.id.rechargeButton).setOnClickListener { findNavController().navigate(R.id.action_global_goldCoinRechargeFragment) } - // 我的皮肤按钮点击 view.findViewById(R.id.skinButton).setOnClickListener { findNavController().navigate(R.id.action_shopfragment_to_myskin) } - // 搜索按钮点击 view.findViewById(R.id.searchButton).setOnClickListener { findNavController().navigate(R.id.action_shopfragment_to_searchfragment) } - - tagScroll = view.findViewById(R.id.tagScroll) tagContainer = view.findViewById(R.id.tagContainer) viewPager = view.findViewById(R.id.viewPager) - val rechargeButton = view.findViewById(R.id.rechargeButton) - rechargeButton.setOnClickListener { - findNavController().navigate(R.id.action_global_goldCoinRechargeFragment) + balance = view.findViewById(R.id.balance) + swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) + + // 设置下拉刷新监听器 + swipeRefreshLayout.setOnRefreshListener { + refreshData() } - // 1. 设置 ViewPager2 的 Adapter - viewPager.adapter = ShopPagerAdapter(this, tabTitles.size) - // 2. 创建顶部标签 - setupTags() + // 设置刷新指示器颜色 + swipeRefreshLayout.setColorSchemeColors( + Color.parseColor("#02BEAC"), + Color.parseColor("#1B1F1A"), + Color.parseColor("#9F9F9F") + ) - // 3. 绑定 ViewPager2 滑动 & 标签联动 - setupViewPager() + // 禁用默认的刷新行为,使用自定义逻辑 + swipeRefreshLayout.isEnabled = false + + // 设置 ViewPager 的子页面滚动监听 + setupViewPagerScrollListener() + + loadInitialData() + + // 修复 ViewPager2 和 SwipeRefreshLayout 的手势冲突 + fixViewPager2SwipeConflict() } -/** 动态创建标签 TextView */ -private fun setupTags() { - tagContainer.removeAllViews() + private fun loadInitialData() { + viewLifecycleOwner.lifecycleScope.launch { + val walletResp = getwalletBalance() + val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString() + balance.text = balanceText + adjustBalanceTextSize(balanceText) - val context = requireContext() - val density = context.resources.displayMetrics.density + val themeListResp = getThemeList() + tabTitles = themeListResp?.data ?: emptyList() + Log.d("1314520-Shop", "风格列表: $tabTitles") - // ⬇⬇⬇ 你要求的 padding 值(已适配 dp) - val paddingHorizontal = (16 * density).toInt() // 左右 16dp - val paddingVertical = (6 * density).toInt() // 上下 6dp - val marginEnd = (8 * density).toInt() // 标签之间 8dp 间距 + styleIds = tabTitles.map { it.id } - tabTitles.forEachIndexed { index, title -> - val tv = TextView(context).apply { - text = title - textSize = 12f // 字体大小 12sp + viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds) - // ✅ 设置内边距(左右16dp,上下6dp) - setPadding( - paddingHorizontal, - paddingVertical, - paddingHorizontal, - paddingVertical - ) + setupTags() + setupViewPager() - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.MATCH_PARENT - ).apply { - setMargins(0, 0, marginEnd, 0) // 右侧 8dp 间距 + // ✅ 默认加载第一个(交给 VM) + viewPager.post { + styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) } } + } + } - gravity = android.view.Gravity.CENTER + /** + * 根据字符数量调整余额文本的字体大小 + * 字符数量越多,字体越小 + */ + private fun adjustBalanceTextSize(text: String) { + val maxFontSize = 40f // 最大字体大小(sp) + val minFontSize = 16f // 最小字体大小(sp) + + // 根据字符数量计算字体大小 + val fontSize = when (text.length) { + 0, 1, 2, 3 -> maxFontSize // 0-3个字符使用最大字体 + 4 -> 36f + 5 -> 32f + 6 -> 28f + 7 -> 24f + 8 -> 22f + 9 -> 20f + else -> minFontSize // 10个字符及以上使用最小字体 + } + + balance.textSize = fontSize + } - // 胶囊大圆角背景 - background = createCapsuleBackground() + private fun refreshData() { + viewLifecycleOwner.lifecycleScope.launch { + try { + // 重新获取钱包余额 + val walletResp = getwalletBalance() + val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString() + balance.text = balanceText + adjustBalanceTextSize(balanceText) - // 初始化选中状态 - isSelected = index == 0 - updateTagStyleNoAnim(this, isSelected) // 初始化不用动画,避免闪烁 + // 重新获取主题列表 + val themeListResp = getThemeList() + val newTabTitles = themeListResp?.data ?: emptyList() + + // 检查主题列表是否有变化 + if (newTabTitles != tabTitles) { + tabTitles = newTabTitles + styleIds = tabTitles.map { it.id } + + // 重新设置适配器 + viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds) + + // 重新设置标签 + setupTags() + + // 通知 ViewModel 清除缓存 + vm.clearCache() + + // 强制重新加载所有页面的数据 + styleIds.forEach { styleId -> + // 强制重新加载,即使有缓存也要重新获取 + vm.forceLoadStyle(styleId) + } + } else { + // 主题列表没有变化,强制重新加载当前页面的数据 + val currentPosition = viewPager.currentItem + styleIds.getOrNull(currentPosition)?.let { vm.forceLoadStyle(it) } + } + + Log.d("1314520-Shop", "下拉刷新完成") + } catch (e: Exception) { + Log.e("1314520-Shop", "下拉刷新失败", e) + } finally { + // 停止刷新动画 + swipeRefreshLayout.isRefreshing = false + } + } + } - // 点击切换页面 - setOnClickListener { - if (viewPager.currentItem != index) { - viewPager.currentItem = index + /** 子页读取缓存(从 VM 读) */ + fun getCachedList(styleId: Int): List = vm.getCached(styleId) + + /** 动态创建标签 */ + private fun setupTags() { + tagContainer.removeAllViews() + + val context = requireContext() + val density = context.resources.displayMetrics.density + val paddingHorizontal = (16 * density).toInt() + val paddingVertical = (6 * density).toInt() + val marginEnd = (8 * density).toInt() + + tabTitles.forEachIndexed { index, title -> + val tv = TextView(context).apply { + text = title.styleName + textSize = 12f + setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical) + + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT + ).apply { setMargins(0, 0, marginEnd, 0) } + + gravity = android.view.Gravity.CENTER + background = createCapsuleBackground() + + isSelected = index == 0 + updateTagStyleNoAnim(this, isSelected) + + setOnClickListener { + if (viewPager.currentItem != index) viewPager.currentItem = index } } + tagContainer.addView(tv) } - - tagContainer.addView(tv) } -} - - private fun createCapsuleBackground(): GradientDrawable { val density = resources.displayMetrics.density return GradientDrawable().apply { shape = GradientDrawable.RECTANGLE - cornerRadius = 50f * density // 大圆角 - setColor(Color.parseColor("#F1F1F1")) // 默认未选中背景 + cornerRadius = 50f * density + setColor(Color.parseColor("#F1F1F1")) setStroke((2 * density).toInt(), Color.parseColor("#F1F1F1")) } } - - /** 设置 ViewPager2 的监听,实现滑动联动标签 */ private fun setupViewPager() { + // ✅ 只设置一次 + viewPager.offscreenPageLimit = 1 + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) updateTagState(position) + + // ✅ 切换到某页就按需加载(交给 VM) + styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) } } }) } - /** 根据当前位置更新所有标签的选中状态 */ private fun updateTagState(position: Int) { for (i in 0 until tagContainer.childCount) { val child = tagContainer.getChildAt(i) as TextView val newSelected = i == position - - // ✅ 如果这个标签的选中状态没有变化,就不要动它,避免“闪一下” if (child.isSelected == newSelected) continue - + child.isSelected = newSelected updateTagStyleWithAnim(child, newSelected) - + if (newSelected) { - // 让选中项尽量居中显示 child.post { val scrollX = child.left - (tagScroll.width - child.width) / 2 tagScroll.smoothScrollTo(scrollX, 0) @@ -170,84 +263,25 @@ private fun setupTags() { } } } - - /** 统一控制标签样式,可根据自己项目主题改颜色/大小 **/ - private fun updateTagStyle(textView: TextView, selected: Boolean) { - val context = textView.context - val density = context.resources.displayMetrics.density - - // 确保背景是 GradientDrawable,方便改边框和背景色 - val bg = (textView.background as? GradientDrawable) - ?: createCapsuleBackground().also { textView.background = it } - - // 颜色配置(按你要求) - val selectedTextColor = Color.parseColor("#1B1F1A") - val unselectedTextColor = Color.parseColor("#9F9F9F") - - val selectedStrokeColor = Color.parseColor("#02BEAC") - val unselectedStrokeColor = Color.parseColor("#F1F1F1") - - val selectedBgColor = Color.parseColor("#FFFFFF") - val unselectedBgColor = Color.parseColor("#F1F1F1") - - // 当前颜色作为起点 - val startTextColor = textView.currentTextColor - val startStrokeColor = try { - // 没有方便的 getter,这里通过 isSelected 反推一个“起点” - if (selected) unselectedStrokeColor else selectedStrokeColor - } catch (e: Exception) { - if (selected) unselectedStrokeColor else selectedStrokeColor + private fun setupViewPagerScrollListener() { + // 监听 AppBarLayout 的展开状态来判断是否在顶部 + view?.findViewById(R.id.appBar)?.addOnOffsetChangedListener { appBarLayout, verticalOffset -> + val isAtTop = verticalOffset == 0 + swipeRefreshLayout.isEnabled = isAtTop } - val startBgColor = if (selected) unselectedBgColor else selectedBgColor - - // 目标颜色 - val endTextColor = if (selected) selectedTextColor else unselectedTextColor - val endStrokeColor = if (selected) selectedStrokeColor else unselectedStrokeColor - val endBgColor = if (selected) selectedBgColor else unselectedBgColor - - val strokeWidth = (2 * density).toInt() - - val animator = ValueAnimator.ofFloat(0f, 1f).apply { - duration = 200L // 动画时长可以自己调 - addUpdateListener { va -> - val fraction = va.animatedFraction - val evaluator = ArgbEvaluator() - - val currentTextColor = - evaluator.evaluate(fraction, startTextColor, endTextColor) as Int - val currentStrokeColor = - evaluator.evaluate(fraction, startStrokeColor, endStrokeColor) as Int - val currentBgColor = - evaluator.evaluate(fraction, startBgColor, endBgColor) as Int - - textView.setTextColor(currentTextColor) - bg.setStroke(strokeWidth, currentStrokeColor) - bg.setColor(currentBgColor) - } - } - animator.start() - - // 字重变化 - textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) } - - - - /** ViewPager2 的 Adapter,可以替换成你的真实 Fragment */ - private class ShopPagerAdapter( - fragment: Fragment, - private val pageCount: Int - ) : FragmentStateAdapter(fragment) { - - override fun getItemCount(): Int = pageCount - - override fun createFragment(position: Int): Fragment { - // 根据 position 返回不同的页面 Fragment - - // 这里先用一个简单的占位示例 - return SimplePageFragment.newInstance("当前页:${position + 1}") + @SuppressLint("ClickableViewAccessibility") + private fun fixViewPager2SwipeConflict() { + val rv = viewPager.getChildAt(0) as? RecyclerView ?: return + rv.setOnTouchListener { v, ev -> + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> v.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + v.parent?.requestDisallowInterceptTouchEvent(false) + } + false } } @@ -255,18 +289,17 @@ private fun setupTags() { val density = resources.displayMetrics.density val bg = (textView.background as? GradientDrawable) ?: createCapsuleBackground().also { textView.background = it } - val strokeWidth = (2 * density).toInt() - + if (selected) { - bg.setColor(Color.parseColor("#FFFFFF")) // 背景白色 - bg.setStroke(strokeWidth, Color.parseColor("#02BEAC")) // 边框 #02BEAC - textView.setTextColor(Color.parseColor("#1B1F1A")) // 字体 #1B1F1A + bg.setColor(Color.parseColor("#FFFFFF")) + bg.setStroke(strokeWidth, Color.parseColor("#02BEAC")) + textView.setTextColor(Color.parseColor("#1B1F1A")) textView.setTypeface(null, Typeface.BOLD) } else { - bg.setColor(Color.parseColor("#F1F1F1")) // 背景 #F1F1F1 - bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1")) // 边框 #F1F1F1 - textView.setTextColor(Color.parseColor("#9F9F9F")) // 字体 #9F9F9F + bg.setColor(Color.parseColor("#F1F1F1")) + bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1")) + textView.setTextColor(Color.parseColor("#9F9F9F")) textView.setTypeface(null, Typeface.NORMAL) } } @@ -275,71 +308,114 @@ private fun setupTags() { val density = resources.displayMetrics.density val bg = (textView.background as? GradientDrawable) ?: createCapsuleBackground().also { textView.background = it } - val strokeWidth = (2 * density).toInt() - - // 颜色配置 + val selectedTextColor = Color.parseColor("#1B1F1A") val unselectedTextColor = Color.parseColor("#9F9F9F") - val selectedStrokeColor = Color.parseColor("#02BEAC") val unselectedStrokeColor = Color.parseColor("#F1F1F1") - val selectedBgColor = Color.parseColor("#FFFFFF") val unselectedBgColor = Color.parseColor("#F1F1F1") - - // 起点、终点颜色我们自己定义,而不是乱读当前值,避免抖动 - val startTextColor: Int - val endTextColor: Int - val startStrokeColor: Int - val endStrokeColor: Int - val startBgColor: Int - val endBgColor: Int - - if (selected) { - // 未选中 -> 选中 - startTextColor = unselectedTextColor - endTextColor = selectedTextColor - - startStrokeColor = unselectedStrokeColor - endStrokeColor = selectedStrokeColor - - startBgColor = unselectedBgColor - endBgColor = selectedBgColor + + val colorsArray = if (selected) { + arrayOf( + unselectedTextColor, selectedTextColor, + unselectedStrokeColor, selectedStrokeColor, + unselectedBgColor, selectedBgColor + ) } else { - // 选中 -> 未选中 - startTextColor = selectedTextColor - endTextColor = unselectedTextColor - - startStrokeColor = selectedStrokeColor - endStrokeColor = unselectedStrokeColor - - startBgColor = selectedBgColor - endBgColor = unselectedBgColor + arrayOf( + selectedTextColor, unselectedTextColor, + selectedStrokeColor, unselectedStrokeColor, + selectedBgColor, unselectedBgColor + ) } - + + val startTextColor = colorsArray[0] + val endTextColor = colorsArray[1] + val startStrokeColor = colorsArray[2] + val endStrokeColor = colorsArray[3] + val startBgColor = colorsArray[4] + val endBgColor = colorsArray[5] + val evaluator = ArgbEvaluator() - - val animator = ValueAnimator.ofFloat(0f, 1f).apply { + ValueAnimator.ofFloat(0f, 1f).apply { duration = 200L addUpdateListener { va -> - val fraction = va.animatedFraction - - val currentTextColor = - evaluator.evaluate(fraction, startTextColor, endTextColor) as Int - val currentStrokeColor = - evaluator.evaluate(fraction, startStrokeColor, endStrokeColor) as Int - val currentBgColor = - evaluator.evaluate(fraction, startBgColor, endBgColor) as Int - - textView.setTextColor(currentTextColor) - bg.setStroke(strokeWidth, currentStrokeColor) - bg.setColor(currentBgColor) + val f = va.animatedFraction + textView.setTextColor(evaluator.evaluate(f, startTextColor, endTextColor) as Int) + bg.setStroke(strokeWidth, evaluator.evaluate(f, startStrokeColor, endStrokeColor) as Int) + bg.setColor(evaluator.evaluate(f, startBgColor, endBgColor) as Int) } + start() } - animator.start() - + textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) } - + + private inner class ShopPagerAdapter( + fragment: Fragment, + private val styleIds: List + ) : FragmentStateAdapter(fragment) { + + override fun getItemCount(): Int = styleIds.size + + override fun createFragment(position: Int): Fragment { + val styleId = styleIds[position] + return ShopStylePageFragment.newInstance(styleId) + } + } + + // ============================ 网络请求 ============================ + + private suspend fun getwalletBalance(): ApiResponse? { + return try { + RetrofitClient.apiService.walletBalance() + } catch (e: Exception) { + Log.e("1314520-ShopFragment", "获取钱包余额失败", e) + null + } + } + + private suspend fun getThemeList(): ApiResponse>? { + return try { + RetrofitClient.apiService.themeList() + } catch (e: Exception) { + Log.e("1314520-ShopFragment", "获取主题风格失败", e) + null + } + } + /** + * 根据余额值计算字体大小 + * 基础字体大小16sp,数字越大字体越小 + */ + // private fun calculateFontSize(balance: Double): Float { + // val baseSize = 40f // 基础字体大小 + // val minSize = 5f // 最小字体大小 + // val maxSize = 40f // 最大字体大小 + + // // 使用对数函数实现平滑的字体大小变化 + // // 当余额为0时使用最大字体,余额越大字体越小 + // val scaleFactor = when { + // balance <= 0 -> 1.0 + // balance < 10 -> 0.93 + // balance < 100 -> 0.86 + // balance < 1000 -> 0.79 + // balance < 10000 -> 0.72 + // balance < 100000 -> 0.65 + // balance < 1000000 -> 0.58 + // balance < 10000000 -> 0.51 + // balance < 100000000 -> 0.44 + // balance < 1000000000 -> 0.37 + // balance < 10000000000 -> 0.3 + // balance < 100000000000 -> 0.23 + // balance < 1000000000000 -> 0.16 + // else -> 0.09 + // } + + // val calculatedSize = baseSize * scaleFactor.toFloat() + + // // 确保字体大小在最小和最大限制范围内 + // return calculatedSize.coerceIn(minSize, maxSize) + // } } diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ShopStyleFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/ShopStyleFragment.kt new file mode 100644 index 0000000..b1f89b0 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/shop/ShopStyleFragment.kt @@ -0,0 +1,55 @@ +package com.example.myapplication.ui.shop + +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R +import kotlinx.coroutines.launch + +class ShopStylePageFragment : Fragment(R.layout.fragment_shop_style_page) { + + private var styleId: Int = 0 + private lateinit var rv: RecyclerView + private val adapter = ThemeCardAdapter() + + // ✅ 拿到父 ShopFragment 的同一个 VM(关键) + private val vm: ShopViewModel by viewModels({ requireParentFragment() }) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + styleId = arguments?.getInt(ARG_STYLE_ID) ?: 0 + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + rv = view.findViewById(R.id.recyclerView) + rv.layoutManager = GridLayoutManager(requireContext(), 2) + rv.adapter = adapter + + // 1) 进来就请求一次(有缓存会自动跳过) + vm.loadStyleIfNeeded(styleId) + + // 2) 观察数据:数据一更新就刷新,永远不会漏 + viewLifecycleOwner.lifecycleScope.launch { + vm.styleData.collect { map -> + val list = map[styleId].orEmpty() + Log.d("1314520-StylePage", "collect styleId=$styleId size=${list.size}") + adapter.submitList(list) + } + } + } + + companion object { + private const val ARG_STYLE_ID = "style_id" + + fun newInstance(styleId: Int) = ShopStylePageFragment().apply { + arguments = Bundle().apply { putInt(ARG_STYLE_ID, styleId) } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ShopViewModel.kt b/app/src/main/java/com/example/myapplication/ui/shop/ShopViewModel.kt new file mode 100644 index 0000000..ba0acd4 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/shop/ShopViewModel.kt @@ -0,0 +1,71 @@ +package com.example.myapplication.ui.shop + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.themeStyle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ShopViewModel : ViewModel() { + + // styleId -> list + private val _styleData = MutableStateFlow>>(emptyMap()) + val styleData: StateFlow>> = _styleData + + private val inFlight = mutableSetOf() + + fun getCached(styleId: Int): List = _styleData.value[styleId].orEmpty() + + fun loadStyleIfNeeded(styleId: Int) { + if (_styleData.value.containsKey(styleId)) return + if (!inFlight.add(styleId)) return + + viewModelScope.launch { + try { + val resp = RetrofitClient.apiService.themeListByStyle(styleId) + val list = resp.data ?: emptyList() + _styleData.update { old -> old + (styleId to list) } + Log.d("1314520-ShopVM", "style=$styleId size=${list.size}") + } catch (e: Exception) { + Log.e("1314520-ShopVM", "按风格查询主题失败", e) + } finally { + inFlight.remove(styleId) + } + } + } + + /** + * 清除缓存数据,用于下拉刷新 + */ + fun clearCache() { + // 使用 update 方法确保触发数据流更新 + _styleData.update { emptyMap() } + inFlight.clear() + Log.d("1314520-ShopVM", "缓存已清除") + } + + /** + * 强制重新加载指定风格的数据,忽略缓存 + */ + fun forceLoadStyle(styleId: Int) { + // 清除该 styleId 的 inFlight 状态,确保可以重新加载 + inFlight.remove(styleId) + + viewModelScope.launch { + try { + val resp = RetrofitClient.apiService.themeListByStyle(styleId) + val list = resp.data ?: emptyList() + _styleData.update { old -> old + (styleId to list) } + Log.d("1314520-ShopVM", "强制重新加载 style=$styleId size=${list.size}") + } catch (e: Exception) { + Log.e("1314520-ShopVM", "强制重新加载主题失败", e) + } finally { + inFlight.remove(styleId) + } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/shop/SimplePageFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/SimplePageFragment.kt deleted file mode 100644 index 79c7f5b..0000000 --- a/app/src/main/java/com/example/myapplication/ui/shop/SimplePageFragment.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.example.myapplication.ui.shop - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.fragment.app.Fragment -import com.example.myapplication.R - -class SimplePageFragment : Fragment() { - - companion object { - fun newInstance(text: String): SimplePageFragment { - val fragment = SimplePageFragment() - val args = Bundle() - args.putString("text", text) - fragment.arguments = args - return fragment - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.simple_page_layout, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // val text = arguments?.getString("text") ?: "" - // view.findViewById(R.id.textView).text = text - } -} 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 new file mode 100644 index 0000000..360362d --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt @@ -0,0 +1,73 @@ +package com.example.myapplication.ui.shop + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.navigation.findNavController +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.example.myapplication.R +import com.example.myapplication.network.themeStyle +import com.google.android.material.card.MaterialCardView + +class ThemeCardAdapter : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThemeCardViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_theme_card, parent, false) + return ThemeCardViewHolder(view) + } + + override fun onBindViewHolder(holder: ThemeCardViewHolder, position: Int) { + val theme = getItem(position) + holder.bind(theme) + } + + inner class ThemeCardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val themeImage: ImageView = itemView.findViewById(R.id.theme_image) + private val themeName: TextView = itemView.findViewById(R.id.theme_name) + private val themePrice: TextView = itemView.findViewById(R.id.theme_price) + private val themeCard: CardView = itemView.findViewById(R.id.theme_card) + + fun bind(theme: themeStyle) { + // 加载主题图片 + Glide.with(itemView.context) + .load(theme.themePreviewImageUrl) + .placeholder(R.drawable.bg) + .into(themeImage) + + // 设置主题名称 + themeName.text = theme.themeName + + // 设置主题价格 + themePrice.text = theme.themePrice.toString() + + // 设置主题卡片点击事件 + themeCard.setOnClickListener { + // 跳转到主题详情页并传递参数 + val bundle = Bundle().apply { + putInt("themeId", theme.id) + } + itemView.findNavController().navigate(R.id.action_global_keyboardDetailFragment, bundle) + } + } + } + + companion object { + private val DiffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: themeStyle, newItem: themeStyle): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: themeStyle, newItem: themeStyle): Boolean { + return oldItem == newItem + } + } + } +} 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 104d72f..2fee536 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 @@ -1,15 +1,30 @@ package com.example.myapplication.ui.shop.myskin +import android.graphics.Color import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import android.widget.FrameLayout +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.themeStyle +import com.example.myapplication.network.deleteThemeRequest +import kotlinx.coroutines.launch class MySkin : Fragment() { - + + private lateinit var adapter: MySkinAdapter + private lateinit var swipeRefreshLayout: SwipeRefreshLayout + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -20,9 +35,151 @@ class MySkin : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + + val tvEditor = view.findViewById(R.id.tvEditor) + 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) + + // 设置下拉刷新监听器 + swipeRefreshLayout.setOnRefreshListener { + refreshData() + } + + // 设置刷新指示器颜色 + swipeRefreshLayout.setColorSchemeColors( + Color.parseColor("#02BEAC"), + Color.parseColor("#1B1F1A"), + Color.parseColor("#9F9F9F") + ) + + // 返回 view.findViewById(R.id.iv_close).setOnClickListener { parentFragmentManager.popBackStack() } + + val rv = view.findViewById(R.id.rvThemes) + rv.layoutManager = GridLayoutManager(requireContext(), 2) + + adapter = MySkinAdapter( + onItemClick = { /* 非编辑模式点击:进详情等 */ }, + onSelectionChanged = { count -> + tvSelectedCount.text = "$count themes selected" + btnDelete.isEnabled = count > 0 + btnDelete.alpha = if (count > 0) 1f else 0.4f + } + ) + rv.adapter = adapter + + fun showBottomBar() { + bottomBar.visibility = View.VISIBLE + bottomBar.translationY = bottomBar.height.toFloat() + bottomBar.animate().translationY(0f).setDuration(160).start() + } + + fun hideBottomBar() { + bottomBar.animate() + .translationY(bottomBar.height.toFloat()) + .setDuration(160) + .withEndAction { bottomBar.visibility = View.GONE } + .start() + } + + // Editor:进/退编辑 + tvEditor.setOnClickListener { + if (!adapter.editMode) { + adapter.enterEditMode() + tvEditor.text = "Exit editing" + bottomBar.post { showBottomBar() } // post 确保有 height + } else { + adapter.exitEditMode() + tvEditor.text = "Editor" + hideBottomBar() + } + } + + // 删除按钮 + btnDelete.setOnClickListener { + val ids = adapter.getSelectedIds() + if (ids.isEmpty()) return@setOnClickListener + + viewLifecycleOwner.lifecycleScope.launch { + val resp = batchDeleteThemes(ids) + if (resp?.code == 0) { + // 删除本地主题文件 + deleteLocalThemeFiles(ids) + + // 如果当前主题是被删除的主题之一,切换到默认主题 + val currentTheme = com.example.myapplication.theme.ThemeManager.getCurrentThemeName() + if (currentTheme != null && ids.any { it.toString() == currentTheme }) { + com.example.myapplication.theme.ThemeManager.setCurrentTheme(requireContext(), "default") + } + + adapter.removeByIds(ids.toSet()) + adapter.exitEditMode() + tvEditor.text = "Editor" + hideBottomBar() + } + } + } + + // 初始加载数据 + loadInitialData() } -} \ No newline at end of file + + private fun loadInitialData() { + viewLifecycleOwner.lifecycleScope.launch { + val resp = getPurchasedThemeList() + adapter.submitList(resp?.data ?: emptyList()) + } + } + + private fun refreshData() { + viewLifecycleOwner.lifecycleScope.launch { + try { + val resp = getPurchasedThemeList() + adapter.submitList(resp?.data ?: emptyList()) + Log.d("1314520-MySkin", "下拉刷新完成") + } catch (e: Exception) { + Log.e("1314520-MySkin", "下拉刷新失败", e) + } finally { + // 停止刷新动画 + swipeRefreshLayout.isRefreshing = false + } + } + } + + private suspend fun getPurchasedThemeList(): ApiResponse>? { + return try { RetrofitClient.apiService.purchasedThemeList() } + catch (e: Exception) { Log.e("MySkin", "获取已购买主题失败", e); null } + } + + private suspend fun batchDeleteThemes(themeIds: List): ApiResponse? { + val request = deleteThemeRequest( + themeIds = themeIds + ) + return try { RetrofitClient.apiService.batchDeleteUserTheme(request) } + catch (e: Exception) { Log.e("MySkin", "批量删除主题失败", e); null } + } + + /** + * 删除本地主题文件 + */ + private fun deleteLocalThemeFiles(themeIds: List) { + try { + val themeRootDir = java.io.File(requireContext().filesDir, "keyboard_themes") + if (!themeRootDir.exists() || !themeRootDir.isDirectory) return + + themeIds.forEach { themeId -> + val themeDir = java.io.File(themeRootDir, themeId.toString()) + if (themeDir.exists() && themeDir.isDirectory) { + themeDir.deleteRecursively() + Log.d("MySkin", "删除本地主题文件: ${themeDir.absolutePath}") + } + } + } catch (e: Exception) { + Log.e("MySkin", "删除本地主题文件失败", e) + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkinAdapter.kt b/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkinAdapter.kt new file mode 100644 index 0000000..fa93db7 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/shop/myskin/MySkinAdapter.kt @@ -0,0 +1,108 @@ +package com.example.myapplication.ui.shop.myskin + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.navigation.findNavController +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.example.myapplication.R +import com.example.myapplication.network.themeStyle + +class MySkinAdapter( + private val onItemClick: (themeStyle) -> Unit, + private val onSelectionChanged: (count: Int) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + private val selectedIds = mutableSetOf() + + var editMode: Boolean = false + private set + + fun submitList(list: List) { + items.clear() + items.addAll(list) + selectedIds.clear() + onSelectionChanged(0) + notifyDataSetChanged() + } + + fun enterEditMode() { + if (editMode) return + editMode = true + selectedIds.clear() + onSelectionChanged(0) + notifyDataSetChanged() + } + + fun exitEditMode() { + if (!editMode) return + editMode = false + selectedIds.clear() + onSelectionChanged(0) + notifyDataSetChanged() + } + + fun getSelectedIds(): List = selectedIds.toList() + + fun removeByIds(ids: Set) { + items.removeAll { ids.contains(it.id) } + selectedIds.removeAll(ids) + onSelectionChanged(selectedIds.size) + notifyDataSetChanged() + } + + inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + val ivPreview: ImageView = itemView.findViewById(R.id.ivPreview) + val tvName: TextView = itemView.findViewById(R.id.tvName) + val overlay: View = itemView.findViewById(R.id.overlay) + val ivCheck: ImageView = itemView.findViewById(R.id.ivCheck) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val v = LayoutInflater.from(parent.context).inflate(R.layout.item_myskin_theme, parent, false) + return VH(v) + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val item = items[position] + + holder.tvName.text = item.themeName + + Glide.with(holder.itemView) + .load(item.themePreviewImageUrl) + .placeholder(R.drawable.default_avatar) + .into(holder.ivPreview) + + val selected = selectedIds.contains(item.id) + + if (editMode) { + holder.ivCheck.visibility = View.VISIBLE + holder.overlay.visibility = if (selected) View.VISIBLE else View.GONE + holder.ivCheck.alpha = if (selected) 1f else 0.0f + } else { + holder.ivCheck.visibility = View.GONE + holder.overlay.visibility = View.GONE + } + + holder.itemView.setOnClickListener { + if (editMode) { + if (selected) selectedIds.remove(item.id) else selectedIds.add(item.id) + onSelectionChanged(selectedIds.size) + notifyItemChanged(position) + } else { + // 跳转到主题详情页面 + val bundle = Bundle().apply { + putInt("themeId", item.id) + } + holder.itemView.findNavController().navigate(R.id.action_global_keyboardDetailFragment, bundle) + } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt index 09d27f2..00f6959 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt @@ -3,18 +3,26 @@ package com.example.myapplication.ui.shop.search import android.content.Context import android.graphics.Color import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.cardview.widget.CardView import androidx.fragment.app.Fragment -import com.example.myapplication.R -import com.google.android.flexbox.FlexboxLayout -import com.google.android.flexbox.FlexboxLayout.LayoutParams +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.core.os.bundleOf -import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.themeStyle +import com.example.myapplication.ui.shop.ThemeCardAdapter +import com.google.android.flexbox.FlexboxLayout +import com.google.android.flexbox.FlexboxLayout.LayoutParams +import kotlinx.coroutines.launch @@ -24,6 +32,8 @@ class SearchFragment : Fragment() { private lateinit var etInput: EditText private val prefsName = "search_history" private lateinit var historySection: LinearLayout + private lateinit var recyclerRecommendList: RecyclerView + private lateinit var themeCardAdapter: ThemeCardAdapter override fun onCreateView( inflater: LayoutInflater, @@ -35,20 +45,34 @@ class SearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + // 推荐主题列表 + viewLifecycleOwner.lifecycleScope.launch { + try { + val recommendThemeListResp = getrecommendThemeList()?.data + // 渲染推荐主题列表 + recommendThemeListResp?.let { themes -> + themeCardAdapter.submitList(themes) + } + } catch (e: Exception) { + Log.e("SearchFragment", "获取推荐主题列表异常", e) + } + } + // 返回 view.findViewById(R.id.iv_close).setOnClickListener { parentFragmentManager.popBackStack() } - // 详情跳转 - view.findViewById(R.id.card_view).setOnClickListener { - findNavController().navigate(R.id.action_global_keyboardDetailFragment) - } historySection = view.findViewById(R.id.layout_history_section) historyLayout = view.findViewById(R.id.layout_history_list) etInput = view.findViewById(R.id.et_input) + recyclerRecommendList = view.findViewById(R.id.recycler_recommend_list) + + // 初始化RecyclerView + setupRecyclerView() // 加载历史记录 loadHistory() @@ -145,5 +169,27 @@ class SearchFragment : Fragment() { historyLayout.removeAllViews() historySection.visibility = View.GONE } + + private fun setupRecyclerView() { + // 设置GridLayoutManager,每行显示2个item + val layoutManager = GridLayoutManager(requireContext(), 2) + recyclerRecommendList.layoutManager = layoutManager + + // 初始化ThemeCardAdapter + themeCardAdapter = ThemeCardAdapter() + recyclerRecommendList.adapter = themeCardAdapter + + // 设置item间距(可选) + recyclerRecommendList.setPadding(0, 0, 0, 0) + } + + private suspend fun getrecommendThemeList(): ApiResponse>? { + return try { + RetrofitClient.apiService.recommendThemeList() + } catch (e: Exception) { + Log.e("SearchFragment", "获取推荐列表失败", e) + null + } + } } diff --git a/app/src/main/java/com/example/myapplication/ui/shop/search/SearchResultFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/search/SearchResultFragment.kt index e5b8dcc..efd29cb 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/search/SearchResultFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/search/SearchResultFragment.kt @@ -1,17 +1,34 @@ package com.example.myapplication.ui.shop.search import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Button import android.widget.EditText import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.themeStyle +import com.example.myapplication.ui.shop.ThemeCardAdapter +import kotlinx.coroutines.launch class SearchResultFragment : Fragment() { private lateinit var etInput: EditText + private lateinit var recyclerSearchResults: RecyclerView + private lateinit var themeCardAdapter: ThemeCardAdapter + private lateinit var tvSearch: TextView + private lateinit var llNoSearchResult: LinearLayout override fun onCreateView( inflater: LayoutInflater, @@ -30,10 +47,79 @@ class SearchResultFragment : Fragment() { } etInput = view.findViewById(R.id.et_input) + recyclerSearchResults = view.findViewById(R.id.recycler_search_results) + tvSearch = view.findViewById(R.id.tv_search) + llNoSearchResult = view.findViewById(R.id.ll_no_search_result) + // 初始化RecyclerView + setupRecyclerView() - // ⭐ 接收从上一个页面传来的搜索词 + // 设置搜索按钮点击事件 + tvSearch.setOnClickListener { + val keyword = etInput.text.toString() + if (keyword.isEmpty()) { + Toast.makeText(requireContext(), "请输入搜索词", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + viewLifecycleOwner.lifecycleScope.launch { + try { + val searchResults = getSearchTheme(keyword)?.data + // 渲染搜索结果列表 + handleSearchResults(searchResults) + } catch (e: Exception) { + Log.e("SearchResultFragment", "搜索主题失败", e) + } + } + } + + + // 接收从上一个页面传来的搜索词 val keyword = arguments?.getString("search_keyword") ?: "" etInput.setText(keyword) - // etInput.setSelection(keyword.length) // 光标移动到最后 + etInput.setSelection(keyword.length) + + viewLifecycleOwner.lifecycleScope.launch { + try { + val searchResults = getSearchTheme(keyword)?.data + // 渲染搜索结果列表 + handleSearchResults(searchResults) + } catch (e: Exception) { + Log.e("SearchResultFragment", "搜索主题失败", e) + } + } + } + + private fun handleSearchResults(themes: List?) { + if (themes.isNullOrEmpty()) { + // 显示无结果提示,隐藏列表 + llNoSearchResult.visibility = View.VISIBLE + recyclerSearchResults.visibility = View.GONE + } else { + // 显示列表,隐藏无结果提示 + llNoSearchResult.visibility = View.GONE + recyclerSearchResults.visibility = View.VISIBLE + themeCardAdapter.submitList(themes) + } + } + + private suspend fun getSearchTheme(keyword: String): ApiResponse>? { + return try { + RetrofitClient.apiService.searchTheme(keyword) + } catch (e: Exception) { + Log.e("SearchResultFragment", "搜索主题失败", e) + null + } + } + + private fun setupRecyclerView() { + // 设置GridLayoutManager,每行显示2个item + val layoutManager = GridLayoutManager(requireContext(), 2) + recyclerSearchResults.layoutManager = layoutManager + + // 初始化ThemeCardAdapter + themeCardAdapter = ThemeCardAdapter() + recyclerSearchResults.adapter = themeCardAdapter + + // 设置item间距(可选) + recyclerSearchResults.setPadding(0, 0, 0, 0) } } diff --git a/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt b/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt new file mode 100644 index 0000000..2e32039 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/utils/EncryptedSharedPreferences.kt @@ -0,0 +1,94 @@ +package com.example.myapplication.utils + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.google.gson.Gson +import java.lang.reflect.Type +import android.util.Log + +/** + * 加密 SharedPreferences 工具类 + * 用于安全地存储敏感数据(支持任意对象) + */ +object EncryptedSharedPreferencesUtil { + + private const val SHARED_PREFS_NAME = "secure_prefs" + private val gson by lazy { Gson() } + + /** + * 获取加密的 SharedPreferences(实际类型是 SharedPreferences) + */ + private fun prefs(context: Context) = + EncryptedSharedPreferences.create( + context, + SHARED_PREFS_NAME, + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + /** + * 存储任意对象(会转为 JSON 字符串保存) + */ + fun save(context: Context, key: String, value: Any?) { + val sp = prefs(context) + if (value == null) { + sp.edit().remove(key).apply() + return + } + sp.edit().putString(key, gson.toJson(value)).apply() + Log.d("1314520-EncryptedSharedPreferencesUtil", "储存成功: $value") + } + + /** + * 获取对象(适用于非泛型:User、Config、String 等) + */ + fun get(context: Context, key: String, clazz: Class): T? { + val sp = prefs(context) + val json = sp.getString(key, null) ?: return null + Log.d("1314520-EncryptedSharedPreferencesUtil", "获取成功: $json") + return try { + gson.fromJson(json, clazz) + } catch (e: Exception) { + null + } + } + + fun contains(context: Context, key: String): Boolean { + return prefs(context).contains(key) + } + + /** + * 获取对象(适用于泛型:List、Map 等) + * 用法:object : TypeToken>() {}.type + */ + fun get(context: Context, key: String, type: Type): T? { + val sp = prefs(context) + val json = sp.getString(key, null) ?: return null + Log.d("1314520-EncryptedSharedPreferencesUtil", "获取成功: $json") + return try { + gson.fromJson(json, type) + } catch (e: Exception) { + null + } + } + + /** + * 删除单个 key + */ + fun remove(context: Context, key: String) { + prefs(context).edit().remove(key).apply() + Log.d("1314520-EncryptedSharedPreferencesUtil", "删除成功: $key") + } + + /** + * 删除全部 + */ + fun clearAll(context: Context) { + prefs(context).edit().clear().apply() + Log.d("1314520-EncryptedSharedPreferencesUtil", "全部清除成功") + } +} diff --git a/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt b/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt index 53ca7b1..dd9f8de 100644 --- a/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt +++ b/app/src/main/java/com/example/myapplication/utils/unzipToDir.kt @@ -1,31 +1,276 @@ package com.example.myapplication.utils -import java.io.* +import android.content.Context +import android.util.Log +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.util.zip.ZipEntry +import java.util.zip.ZipFile import java.util.zip.ZipInputStream -fun unzipToDir(zipInputStream: InputStream, targetDir: File) { - ZipInputStream(BufferedInputStream(zipInputStream)).use { zis -> - var entry: ZipEntry? = zis.nextEntry - val buffer = ByteArray(4096) +private const val TAG_UNZIP = "1314520-unzip" +private const val TAG_ZIPLIST = "1314520-ziplist" - while (entry != null) { - val file = File(targetDir, entry.name) +/* ========================= + * 1️⃣ 打印 zip 内容(调试用) + * ========================= */ +fun logZipEntries(zipFile: File) { + Log.e(TAG_ZIPLIST, "========== ZIP CONTENT START ==========") + Log.e(TAG_ZIPLIST, "zipPath=${zipFile.absolutePath} size=${zipFile.length()}") - if (entry.isDirectory) { - file.mkdirs() - } else { - file.parentFile?.mkdirs() - FileOutputStream(file).use { fos -> - var count: Int - while (zis.read(buffer).also { count = it } != -1) { - fos.write(buffer, 0, count) - } + try { + FileInputStream(zipFile).use { fis -> + ZipInputStream(BufferedInputStream(fis)).use { zis -> + var count = 0 + while (true) { + val entry = zis.nextEntry ?: break + count++ + Log.e( + TAG_ZIPLIST, + "[$count] ${entry.name} dir=${entry.isDirectory}" + ) + zis.closeEntry() } + Log.e(TAG_ZIPLIST, "total entries=$count") } + } + } catch (e: Exception) { + Log.e(TAG_ZIPLIST, "read zip failed: ${e.message}", e) + } - zis.closeEntry() - entry = zis.nextEntry + Log.e(TAG_ZIPLIST, "========== ZIP CONTENT END ==========") +} + +fun detectArchiveType(file: File): String { + FileInputStream(file).use { fis -> + val header = ByteArray(8) + val read = fis.read(header) + if (read < 4) return "UNKNOWN" + + fun hex(vararg b: Int) = + b.map { it.toByte() }.toByteArray().contentEquals(header.copyOf(b.size)) + + return when { + hex(0x50, 0x4B, 0x03, 0x04) -> "ZIP" + hex(0x50, 0x4B, 0x05, 0x06) -> "ZIP_EMPTY" + hex(0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7Z" + hex(0x52, 0x61, 0x72, 0x21) -> "RAR" + else -> "UNKNOWN" } } } + +fun validateZipWithZipFile(zip: File) { + try { + ZipFile(zip).use { zf -> + val e = zf.entries() + var count = 0 + while (e.hasMoreElements()) { + val entry = e.nextElement() + Log.e("1314520-zipfile", "entry=${entry.name}") + count++ + } + Log.e("1314520-zipfile", "ZipFile entries=$count") + } + } catch (e: Exception) { + Log.e("1314520-zipfile", "ZipFile FAILED: ${e.message}", e) + } +} + +/* ========================= + * 2️⃣ 是否是垃圾文件 + * ========================= */ +private fun isJunkEntry(name: String): Boolean { + if (name.startsWith("__MACOSX/")) return true + val last = name.substringAfterLast('/') + if (last.startsWith("._")) return true + if (last == ".DS_Store") return true + return false +} + +/* ========================= + * 3️⃣ 是否是「zip 里套 zip」 + * ========================= */ +private fun findSingleInnerZip(zipFile: File): String? { + ZipFile(zipFile).use { zip -> + val entries = zip.entries().toList() + if (entries.size == 1) { + val e = entries.first() + if (!e.isDirectory && e.name.endsWith(".zip", ignoreCase = true)) { + return e.name + } + } + } + return null +} + +/* ========================= + * 4️⃣ 解出内层 zip + * ========================= */ +private fun extractInnerZip( + zipFile: File, + entryName: String, + outFile: File +) { + ZipFile(zipFile).use { zip -> + val entry = zip.getEntry(entryName) + ?: error("Inner zip not found: $entryName") + + zip.getInputStream(entry).use { input -> + FileOutputStream(outFile).use { output -> + input.copyTo(output) + } + } + } +} + +/* ========================= + * 5️⃣ 智能入口(唯一对外) + * ========================= */ +fun unzipThemeSmart( + context: Context, + zipFile: File, + themeId: Int, + targetBaseDir: File = File(context.filesDir, "keyboard_themes") +): String { + // 👉 检测嵌套 zip + val innerZipName = findSingleInnerZip(zipFile) + if (innerZipName != null) { + + val tempInnerZip = File( + context.cacheDir, + "inner_${System.currentTimeMillis()}.zip" + ) + + extractInnerZip(zipFile, innerZipName, tempInnerZip) + + val result = unzipThemeSmart( + context = context, + zipFile = tempInnerZip, + themeId = themeId, + targetBaseDir = targetBaseDir + ) + + tempInnerZip.delete() + return result + } + + // 👉 普通主题 zip + return unzipThemeFromFileOverwrite_ZIS( + context = context, + zipFile = zipFile, + themeId = themeId, + targetBaseDir = targetBaseDir + ) +} + +/* ========================= + * 6️⃣ 真正的主题解压(你原逻辑) + * ========================= */ +fun unzipThemeFromFileOverwrite_ZIS( + context: Context, + zipFile: File, + themeId: Int, + targetBaseDir: File +): String { + + val tempOut = File(context.cacheDir, "tmp_theme_out").apply { + if (exists()) deleteRecursively() + mkdirs() + } + val canonicalTempOut = tempOut.canonicalFile.path + File.separator + + fun findIconsRelativePath(entryName: String): String? { + val n = entryName.replace('\\', '/') + val lower = n.lowercase() + + val idxIcons = lower.indexOf("/icons/") + val idxIcon = lower.indexOf("/icon/") + + val idx = when { + idxIcons >= 0 -> idxIcons + 7 + idxIcon >= 0 -> idxIcon + 6 + lower.startsWith("icons/") -> 6 + lower.startsWith("icon/") -> 5 + else -> return null + } + return n.substring(idx) + } + + fun isBackground(entryName: String): Boolean = + entryName.substringAfterLast('/') + .equals("background.png", ignoreCase = true) + + var sawAnyEntry = false + var extractedAnyIcons = false + + try { + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> + val buffer = ByteArray(8192) + + while (true) { + val entry: ZipEntry = zis.nextEntry ?: break + sawAnyEntry = true + val name = entry.name + + if (isJunkEntry(name)) { + zis.closeEntry(); continue + } + + val iconsRel = findIconsRelativePath(name) + val isIcons = iconsRel != null + val isBg = isBackground(name) + + if (!isIcons && !isBg) { + zis.closeEntry(); continue + } + + val relativeOut = if (isIcons) iconsRel!! else "background.png" + val outFile = File(tempOut, relativeOut) + + val canonicalOut = outFile.canonicalFile.path + if (!canonicalOut.startsWith(canonicalTempOut)) { + zis.closeEntry(); continue + } + + if (!entry.isDirectory) { + outFile.parentFile?.mkdirs() + FileOutputStream(outFile).use { fos -> + while (true) { + val c = zis.read(buffer) + if (c == -1) break + fos.write(buffer, 0, c) + } + } + if (isIcons) extractedAnyIcons = true + } + zis.closeEntry() + } + } + + if (!sawAnyEntry) + throw IllegalStateException("zip 为空或损坏") + if (!extractedAnyIcons) + throw IllegalStateException("未找到 icons/icon 文件(请看 ziplist 日志)") + + val finalDir = File(targetBaseDir, themeId.toString()) + if (finalDir.exists()) finalDir.deleteRecursively() + finalDir.parentFile?.mkdirs() + + if (!tempOut.renameTo(finalDir)) { + finalDir.mkdirs() + tempOut.copyRecursively(finalDir, overwrite = true) + tempOut.deleteRecursively() + } + return themeId.toString() + + } catch (e: Exception) { + logZipEntries(zipFile) + Log.e(TAG_UNZIP, "解压失败: ${e.message}", e) + throw e + } finally { + if (tempOut.exists()) tempOut.deleteRecursively() + } +} diff --git a/app/src/main/res/anim/item_slide_in_up.xml b/app/src/main/res/anim/item_slide_in_up.xml index a11766b..1dbc9e1 100644 --- a/app/src/main/res/anim/item_slide_in_up.xml +++ b/app/src/main/res/anim/item_slide_in_up.xml @@ -6,12 +6,12 @@ + android:duration="300" /> + android:duration="300" /> diff --git a/app/src/main/res/drawable/bg_delete_btn.xml b/app/src/main/res/drawable/bg_delete_btn.xml new file mode 100644 index 0000000..524113d --- /dev/null +++ b/app/src/main/res/drawable/bg_delete_btn.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_dialog_round.xml b/app/src/main/res/drawable/bg_dialog_round.xml new file mode 100644 index 0000000..18d14f5 --- /dev/null +++ b/app/src/main/res/drawable/bg_dialog_round.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/bg_sub_tab.xml b/app/src/main/res/drawable/bg_sub_tab.xml new file mode 100644 index 0000000..0f9e474 --- /dev/null +++ b/app/src/main/res/drawable/bg_sub_tab.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_top_tab.xml b/app/src/main/res/drawable/bg_top_tab.xml new file mode 100644 index 0000000..9b8ff42 --- /dev/null +++ b/app/src/main/res/drawable/bg_top_tab.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/button_cancel_background.xml b/app/src/main/res/drawable/button_cancel_background.xml new file mode 100644 index 0000000..7d2cfe8 --- /dev/null +++ b/app/src/main/res/drawable/button_cancel_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/button_confirm_background.xml b/app/src/main/res/drawable/button_confirm_background.xml new file mode 100644 index 0000000..e7ef7c8 --- /dev/null +++ b/app/src/main/res/drawable/button_confirm_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/complete_bg.xml b/app/src/main/res/drawable/complete_bg.xml new file mode 100644 index 0000000..0cd9d1c --- /dev/null +++ b/app/src/main/res/drawable/complete_bg.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 0000000..ca7d6bf --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/dialog_persona_detail_bg.xml b/app/src/main/res/drawable/dialog_persona_detail_bg.xml new file mode 100644 index 0000000..8cceef2 --- /dev/null +++ b/app/src/main/res/drawable/dialog_persona_detail_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_bg.xml b/app/src/main/res/drawable/dot_bg.xml new file mode 100644 index 0000000..9fe2543 --- /dev/null +++ b/app/src/main/res/drawable/dot_bg.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_added.xml b/app/src/main/res/drawable/ic_added.xml new file mode 100644 index 0000000..54ffb2c --- /dev/null +++ b/app/src/main/res/drawable/ic_added.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/input_icon.png b/app/src/main/res/drawable/input_icon.png new file mode 100644 index 0000000..2e0a0cb Binary files /dev/null and b/app/src/main/res/drawable/input_icon.png differ diff --git a/app/src/main/res/drawable/input_message_bg.xml b/app/src/main/res/drawable/input_message_bg.xml new file mode 100644 index 0000000..fe29d2d --- /dev/null +++ b/app/src/main/res/drawable/input_message_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/keyboard_button_bg4.xml b/app/src/main/res/drawable/keyboard_button_bg4.xml new file mode 100644 index 0000000..235ad94 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_button_bg4.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/no_search_result.png b/app/src/main/res/drawable/no_search_result.png new file mode 100644 index 0000000..e0a8808 Binary files /dev/null and b/app/src/main/res/drawable/no_search_result.png differ diff --git a/app/src/main/res/drawable/selected.png b/app/src/main/res/drawable/selected.png new file mode 100644 index 0000000..630181e Binary files /dev/null and b/app/src/main/res/drawable/selected.png differ diff --git a/app/src/main/res/drawable/send_icon.png b/app/src/main/res/drawable/send_icon.png new file mode 100644 index 0000000..f86a284 Binary files /dev/null and b/app/src/main/res/drawable/send_icon.png differ diff --git a/app/src/main/res/drawable/shut.png b/app/src/main/res/drawable/shut.png new file mode 100644 index 0000000..b61d931 Binary files /dev/null and b/app/src/main/res/drawable/shut.png differ diff --git a/app/src/main/res/drawable/tag_background.xml b/app/src/main/res/drawable/tag_background.xml new file mode 100644 index 0000000..b4838ba --- /dev/null +++ b/app/src/main/res/drawable/tag_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/tv_background_bg.xml b/app/src/main/res/drawable/tv_background_bg.xml new file mode 100644 index 0000000..c13b660 --- /dev/null +++ b/app/src/main/res/drawable/tv_background_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_guide.xml b/app/src/main/res/layout/activity_guide.xml index d2488af..54d24e8 100644 --- a/app/src/main/res/layout/activity_guide.xml +++ b/app/src/main/res/layout/activity_guide.xml @@ -33,6 +33,17 @@ android:rotation="180" android:scaleType="fitCenter" /> + @@ -127,32 +139,61 @@ android:id="@+id/bottom_panel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:gravity="center_horizontal" + android:gravity="center_vertical" + android:background="@drawable/input_message_bg" + android:padding="5dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="16dp" + android:paddingStart="16dp" android:orientation="horizontal" - android:padding="16dp" android:layout_gravity="bottom"> - -