Compare commits

...

2 Commits

Author SHA1 Message Date
pengxiaolong
70e727fdb7 Merge branch 'main' of https://git.hanxiaokj.cn/pxl/Android-key-of-love 2025-12-29 13:16:24 +08:00
pengxiaolong
7814a10815 完善 2025-12-26 22:01:04 +08:00
96 changed files with 5852 additions and 1913 deletions

View File

@@ -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

View File

@@ -74,4 +74,15 @@ dependencies {
implementation("com.squareup.retrofit2:converter-gson:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.11.0")
// 协程(如果还没加) // 协程(如果还没加)
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.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")
} }

View File

@@ -8,6 +8,7 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:name=".MyApp"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -120,8 +120,10 @@ class GuideActivity : AppCompatActivity() {
val isKeyboardVisible = keyboardHeight > screenHeight * 0.15 val isKeyboardVisible = keyboardHeight > screenHeight * 0.15
if (isKeyboardVisible) { if (isKeyboardVisible) {
// 键盘高度为正,仅仅把 bottomPanel 抬上去 // 键盘高度为正,把 bottomPanel 抬上去,但不要抬太高
bottomPanel.translationY = -keyboardHeight.toFloat() // 只上移键盘高度减去底部面板高度,让输入框刚好在键盘上方
val adjustedTranslation = -(keyboardHeight - bottomPanel.height)
bottomPanel.translationY = adjustedTranslation.toFloat()
// 为了让最后一条消息不被挡住,可以给 scrollView 加个 paddingBottom // 为了让最后一条消息不被挡住,可以给 scrollView 加个 paddingBottom
scrollView.setPadding( scrollView.setPadding(

View File

@@ -3,11 +3,16 @@ package com.example.myapplication
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination 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() { class MainActivity : AppCompatActivity() {
@@ -18,6 +23,23 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) 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 // 1. 找到 NavHostFragment
val navHostFragment = val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment

View File

@@ -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)
}
}

View File

@@ -34,6 +34,16 @@ import com.example.myapplication.keyboard.AiKeyboard
import android.text.InputType import android.text.InputType
import android.view.KeyEvent import android.view.KeyEvent
import android.os.SystemClock 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_CHANNEL_ID = "input_method_channel"
private const val NOTIFICATION_ID = 1 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 实现所需属性 ===== // ===== KeyboardEnvironment 实现所需属性 =====
@@ -102,6 +123,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
private var isDeleting = false private var isDeleting = false
private val repeatDelInitialDelay = 350L private val repeatDelInitialDelay = 350L
private val repeatDelInterval = 50L private val repeatDelInterval = 50L
private val refreshAfterEditDelayMs = 16L // 1 帧
private val refreshAfterEditRunnable = Runnable { refreshSuggestionsAfterEdit() }
private val repeatDelRunnable = object : Runnable { private val repeatDelRunnable = object : Runnable {
override fun run() { override fun run() {
@@ -124,6 +148,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
ColorStateList.valueOf(Color.TRANSPARENT) ColorStateList.valueOf(Color.TRANSPARENT)
private set 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? { override fun getInputConnection(): InputConnection? {
return currentInputConnection return currentInputConnection
@@ -174,6 +214,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
ThemeManager.ensureBuiltInThemesInstalled(this) ThemeManager.ensureBuiltInThemesInstalled(this)
ThemeManager.init(this) ThemeManager.init(this)
ThemeManager.addThemeChangeListener(themeListener)
// 异步加载词典与 bigram 模型 // 异步加载词典与 bigram 模型
Thread { Thread {
// 1) Trie 词典 // 1) Trie 词典
@@ -217,10 +259,26 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
createNotificationChannelIfNeeded() createNotificationChannelIfNeeded()
tryStartForegroundSafe() 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() { private fun createNotificationChannelIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
@@ -321,8 +379,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() ThemeManager.removeThemeChangeListener(themeListener)
stopRepeatDelete() stopRepeatDelete()
super.onDestroy()
} }
// ================= KeyboardEnvironment键盘切换 ================= // ================= KeyboardEnvironment键盘切换 =================
@@ -355,6 +414,162 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
return mainKeyboard!! 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 { private fun ensureNumberKeyboard(): NumberKeyboard {
if (numberKeyboard == null) { if (numberKeyboard == null) {
@@ -373,8 +588,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
symbolKeyboard = SymbolKeyboard(this) symbolKeyboard = SymbolKeyboard(this)
symbolKeyboardView = symbolKeyboard!!.rootView symbolKeyboardView = symbolKeyboard!!.rootView
// 符号键盘删除键 key_backspace // 符号键盘删除键 key_del
val delId = resources.getIdentifier("key_backspace", "id", packageName) val delId = resources.getIdentifier("key_del", "id", packageName)
symbolKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) } symbolKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
} }
return symbolKeyboard!! return symbolKeyboard!!
@@ -416,6 +631,27 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) 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<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
}
return emojiKeyboard!!
}
// ================== 文本输入核心逻辑 ================== // ================== 文本输入核心逻辑 ==================
@@ -445,6 +681,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
override fun commitKey(c: Char) { override fun commitKey(c: Char) {
val ic = currentInputConnection ?: return 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 val toSend = if (isShiftOn && c in 'a'..'z') c.uppercaseChar() else c
ic.commitText(toSend.toString(), 1) ic.commitText(toSend.toString(), 1)
@@ -465,20 +708,44 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
override fun deleteOne() { override fun deleteOne() {
val ic = currentInputConnection ?: return val ic = currentInputConnection ?: return
// 1⃣ 发送一个 DEL 按键DOWN + UP让客户端有机会拦截 // 删除时少做 IPCselectedText 也可能慢,所以只在需要时取
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) val selected = ic.getSelectedText(0)
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) if (!selected.isNullOrEmpty()) {
// 删选区
ic.commitText("", 1)
} else {
// 删光标前一个字符(更同步)
ic.deleteSurroundingText(1, 0)
}
// 如果你担心有些 EditText 不处理 DEL可以加一个兜底 scheduleRefreshSuggestions()
// ic.deleteSurroundingText(1, 0)
// 2⃣ 你原来的逻辑可以继续保留
val prefix = getCurrentWordPrefix()
updateCompletionsAndRender(prefix)
playKeyClick() 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 // 发送(标准 SEND + 回车 fallback
override fun performSendAction() { override fun performSendAction() {
@@ -682,26 +949,119 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
} }
// ================== 长按删除 ================== // ================== 长按删除 ==================
// 真正实现逻辑(基本照搬你原来的 attachRepeatDelete
private fun attachRepeatDeleteInternal(view: View) { 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) { if (!isDeleting) {
isDeleting = true isDeleting = true
mainHandler.postDelayed(repeatDelRunnable, repeatDelInitialDelay) mainHandler.postDelayed(repeatDelRunnable, repeatDelInitialDelay)
deleteOne() // 首次立刻删一次 deleteOne()
}
}
view.setOnLongClickListener {
// 只要不是准备清空,就允许长按连删
if (!pendingSwipeClear) {
startRepeatDeleteNow()
} }
true true
} }
view.setOnTouchListener { _, event -> view.setOnTouchListener { _, event ->
when (event.actionMasked) { when (event.actionMasked) {
android.view.MotionEvent.ACTION_UP,
android.view.MotionEvent.ACTION_CANCEL, android.view.MotionEvent.ACTION_DOWN -> {
android.view.MotionEvent.ACTION_OUTSIDE -> stopRepeatDelete() downX = event.x
} downY = event.y
pendingSwipeClear = false
resumeDeletingAfterCancel = false
dismissSwipeClearHint()
false 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 -> {
// 如果我们处于“准备清空”,才由我们接管结束逻辑
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
}
}
}
private fun stopRepeatDelete() { private fun stopRepeatDelete() {
if (isDeleting) { if (isDeleting) {
@@ -787,6 +1147,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
numberKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) numberKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
symbolKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) symbolKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
aiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) aiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
} }
// ================== bigram & 联想实现 ================== // ================== bigram & 联想实现 ==================

View File

@@ -354,6 +354,21 @@ class AiKeyboard(
borderColor: ColorStateList, borderColor: ColorStateList,
backgroundColor: 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) }
} }
} }

View File

@@ -1,33 +1,47 @@
package com.example.myapplication.keyboard package com.example.myapplication.keyboard
import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
/**
* 所有键盘的基础类:只处理文本颜色、边距 / 内边距 这些通用样式。
* 不再直接访问 resources统一走 env.ctx.resources。
*/
abstract class BaseKeyboard( abstract class BaseKeyboard(
protected val env: KeyboardEnvironment protected val env: KeyboardEnvironment
) { ) {
protected val vibrator: Vibrator? = env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
/** 根布局 */
abstract val rootView: View abstract val rootView: View
/** /**
* 应用主题:文字颜色 + 边框(只调 margin/padding不动你每个键的图片背景 * 应用主题:文字颜色 + 边框 + 按键背景
* 调用后文字颜色、边框立即生效,子类刷新按键背景
*/ */
open fun applyTheme( open fun applyTheme(
textColor: ColorStateList, textColor: ColorStateList,
borderColor: ColorStateList, borderColor: ColorStateList,
backgroundColor: ColorStateList backgroundColor: ColorStateList
) { ) {
// 文字颜色递归设置
applyTextColorToAllTextViews(rootView, textColor) applyTextColorToAllTextViews(rootView, textColor)
// 边框margin / padding递归设置
applyBorderToAllKeyViews(rootView) applyBorderToAllKeyViews(rootView)
// 子类刷新按键背景(如 ThemeManager 提供的图片)
applyKeyBackgroundsForTheme()
} }
// 文字颜色递归设置 /** 子类实现:刷新按键背景 */
abstract fun applyKeyBackgroundsForTheme()
// ------------------- 工具方法 -------------------
/** 递归设置 TextView 文字颜色 */
protected fun applyTextColorToAllTextViews(root: View?, color: ColorStateList) { protected fun applyTextColorToAllTextViews(root: View?, color: ColorStateList) {
if (root == null) return if (root == null) return
@@ -35,11 +49,8 @@ abstract class BaseKeyboard(
when (v) { when (v) {
is TextView -> v.setTextColor(color) is TextView -> v.setTextColor(color)
is ViewGroup -> { is ViewGroup -> {
val childCount = v.childCount for (i in 0 until v.childCount) {
var i = 0
while (i < childCount) {
dfs(v.getChildAt(i)) dfs(v.getChildAt(i))
i++
} }
} }
} }
@@ -49,35 +60,29 @@ abstract class BaseKeyboard(
} }
/** /**
* 只设置 margin / padding统一改背景,避免覆盖你用 ThemeManager 设置的按键图 * 只设置 margin / padding不改背景避免覆盖 ThemeManager 设置的按键图
* 跟你原来 MyInputMethodService.applyBorderColorToAllKeys 的逻辑保持一致。
*/ */
protected fun applyBorderToAllKeyViews(root: View?) { protected fun applyBorderToAllKeyViews(root: View?) {
if (root == null) return if (root == null) return
val res = env.ctx.resources
val pkg = env.ctx.packageName
val keyMarginPx = 1.dpToPx() val keyMarginPx = 1.dpToPx()
val keyPaddingH = 6.dpToPx() val keyPaddingH = 6.dpToPx()
// 忽略 suggestion_0..20(联想栏),不改它们背景 // 忽略 suggestion_0..20(联想栏)
val ignoredIds = HashSet<Int>().apply { val ignoredIds = HashSet<Int>().apply {
var i = 0 val res = env.ctx.resources
while (i <= 20) { val pkg = env.ctx.packageName
for (i in 0..20) {
val id = res.getIdentifier("suggestion_$i", "id", pkg) val id = res.getIdentifier("suggestion_$i", "id", pkg)
if (id != 0) add(id) if (id != 0) add(id)
i++
} }
} }
fun dfs(v: View?) { fun dfs(v: View?) {
when (v) { when (v) {
is TextView -> { is TextView -> {
if (ignoredIds.contains(v.id)) { if (ignoredIds.contains(v.id)) return
// 联想词不加边距
return
}
val lp = v.layoutParams val lp = v.layoutParams
if (lp is LinearLayout.LayoutParams) { if (lp is LinearLayout.LayoutParams) {
lp.setMargins(keyMarginPx, keyMarginPx, keyMarginPx, keyMarginPx) lp.setMargins(keyMarginPx, keyMarginPx, keyMarginPx, keyMarginPx)
@@ -91,11 +96,8 @@ abstract class BaseKeyboard(
) )
} }
is ViewGroup -> { is ViewGroup -> {
val childCount = v.childCount for (i in 0 until v.childCount) {
var i = 0
while (i < childCount) {
dfs(v.getChildAt(i)) dfs(v.getChildAt(i))
i++
} }
} }
} }
@@ -104,9 +106,19 @@ abstract class BaseKeyboard(
dfs(root) dfs(root)
} }
// dp -> px 工具 /** dp -> px */
protected fun Int.dpToPx(): Int { protected fun Int.dpToPx(): Int {
val density = env.ctx.resources.displayMetrics.density val density = env.ctx.resources.displayMetrics.density
return (this * density + 0.5f).toInt() 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)
}
}
} }

View File

@@ -0,0 +1,60 @@
package com.example.myapplication.keyboard
data class SubCategory(val label: String, val items: List<String>)
object EmojiKaomojiData {
fun emojiCategories(recents: List<String>): List<SubCategory> {
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<String>): List<SubCategory> {
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("()","(×_×)","( ̄□ ̄;)","(; ̄Д ̄)","(╯︵╰,)","(╥﹏╥)","(ಠ_ಠ)"))
)
}
}

View File

@@ -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<HorizontalScrollView>(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) 重新提交 pagesspan 不变)
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<SubCategory> = emptyList()
private var flatPages: List<FlatPage> = 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
}
}

View File

@@ -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<String>
)

View File

@@ -0,0 +1,41 @@
package com.example.myapplication.keyboard
object FlatPageBuilder {
fun buildFlatPages(
categories: List<SubCategory>,
pageSize: Int
): List<FlatPage> {
val out = ArrayList<FlatPage>()
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
}
}

View File

@@ -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<InfiniteFlatPagerAdapter.PageVH>() {
private var flatPages: List<FlatPage> = emptyList()
private var basePosition: Int = 0
private var itemLayoutRes: Int = R.layout.item_emoji
fun submit(pages: List<FlatPage>, 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) 更新 spanlayoutManager 存在则改 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<String>) {
adapter.submit(items)
}
}
}

View File

@@ -37,7 +37,15 @@ interface KeyboardEnvironment {
fun showNumberKeyboard() fun showNumberKeyboard()
fun showSymbolKeyboard() fun showSymbolKeyboard()
fun showAiKeyboard() fun showAiKeyboard()
//emoji键盘
fun showEmojiKeyboard()
// 音效 // 音效
fun playKeyClick() fun playKeyClick()
// 回填上次清空的文本
fun revokeLastClearedText()
// 检查是否有可回填的文本
fun hasClearedText(): Boolean
} }

View File

@@ -20,9 +20,6 @@ import com.example.myapplication.theme.ThemeManager
class MainKeyboard( class MainKeyboard(
env: KeyboardEnvironment, env: KeyboardEnvironment,
private val swipeAltMap: Map<Char, Char>, private val swipeAltMap: Map<Char, Char>,
/**
* 交给 MyInputMethodService 切换 Shift 状态,并返回最新状态
*/
private val onToggleShift: () -> Boolean private val onToggleShift: () -> Boolean
) : BaseKeyboard(env) { ) : BaseKeyboard(env) {
@@ -34,211 +31,153 @@ class MainKeyboard(
private var isShiftOn: Boolean = false private var isShiftOn: Boolean = false
private var keyPreviewPopup: PopupWindow? = null 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~255100~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 { init {
applyPerKeyBackgroundForMainKeyboard(rootView) applyPerKeyBackgroundForMainKeyboard(rootView)
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
applyTheme(
env.currentTextColor,
env.currentBorderColor,
env.currentBackgroundColor
)
setupListenersForMain(rootView) setupListenersForMain(rootView)
} }
// ======================== 背景图 ======================== // -------------------- 背景 --------------------
private fun applyPerKeyBackgroundForMainKeyboard(root: View) { private fun applyPerKeyBackgroundForMainKeyboard(root: View) {
// a..z 小写
var c = 'a' var c = 'a'
while (c <= 'z') { while (c <= 'z') {
val idName = "key_$c" applyKeyBackground(root, "key_$c")
applyKeyBackground(root, idName)
c++ c++
} }
// 键盘背景
applyKeyBackground(root, "background") applyKeyBackground(root, "background")
// 其他功能键
val others = listOf( val others = listOf(
"key_space", "key_space", "key_send", "key_del", "key_up",
"key_send", "key_123", "key_ai", "Key_collapse","key_emoji","key_revoke"
"key_del",
"key_up",
"key_123",
"key_ai",
"Key_collapse"
) )
for (idName in others) { others.forEach { applyKeyBackground(root, it) }
applyKeyBackground(root, idName)
}
} }
private fun applyKeyBackground( private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
root: View,
viewIdName: String,
drawableName: String? = null
) {
val res = env.ctx.resources val res = env.ctx.resources
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
if (viewId == 0) return
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
if (viewIdName == "background") { if (viewIdName == "background") {
val scaled = scaleDrawableToHeight(rawDrawable, 243f) v.background = scaleDrawableToHeight(rawDrawable, 243f)
v.background = scaled } else {
return
}
v.background = rawDrawable v.background = rawDrawable
} }
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable { private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources val res = env.ctx.resources
val dm = res.displayMetrics val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension( val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
TypedValue.COMPLEX_UNIT_DIP,
targetDp,
dm
).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val w = bitmap.width val ratio = targetHeightPx.toFloat() / bitmap.height
val h = bitmap.height val targetWidthPx = (bitmap.width * ratio).toInt()
val ratio = targetHeightPx.toFloat() / h
val targetWidthPx = (w * ratio).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true) val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
return BitmapDrawable(res, scaled).apply { return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
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) { private fun setupListenersForMain(view: View) {
val res = env.ctx.resources val res = env.ctx.resources
val pkg = env.ctx.packageName val pkg = env.ctx.packageName
// a..z支持上滑副字符 // 初始化时设置Revoke按钮的可见性
updateRevokeButtonVisibility(view, res, pkg)
var c = 'a' var c = 'a'
while (c <= 'z') { while (c <= 'z') {
val id = res.getIdentifier("key_$c", "id", pkg) val tv = view.findViewById<TextView?>(res.getIdentifier("key_$c", "id", pkg))
val tv = view.findViewById<TextView?>(id)
if (tv != null) { if (tv != null) {
val baseChar = c val baseChar = c
val altChar = swipeAltMap[baseChar] val altChar = swipeAltMap[baseChar]
attachKeyTouchWithSwipe(tv, { baseChar }, altChar?.let { { it } })
attachKeyTouchWithSwipe(
tv,
normalCharProvider = { baseChar },
altCharProvider = altChar?.let { ac ->
{ ac }
}
)
} }
c++ c++
} }
// space view.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))?.setOnClickListener {
view.findViewById<View?>(res.getIdentifier("key_space", "id", pkg)) vibrateKey(); env.commitKey(' ')
?.setOnClickListener { // 输入新内容后更新按钮可见性
vibrateKey() updateRevokeButtonVisibility(view, res, pkg)
env.commitKey(' ')
} }
// Shift view.findViewById<View?>(res.getIdentifier("key_up", "id", pkg))?.setOnClickListener {
val shiftId = res.getIdentifier("key_up", "id", pkg)
view.findViewById<View?>(shiftId)?.setOnClickListener {
vibrateKey() vibrateKey()
isShiftOn = onToggleShift() isShiftOn = onToggleShift()
it.isActivated = isShiftOn it.isActivated = isShiftOn
updateKeyBackgroundsForLetters(view) applyKeyBackgroundsForTheme() // 立即刷新主题背景
} }
// 删除(单击;长按由 MyInputMethodService 挂) view.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))?.setOnClickListener {
view.findViewById<View?>(res.getIdentifier("key_del", "id", pkg)) vibrateKey(); env.deleteOne()
?.setOnClickListener { // 删除内容后更新按钮可见性
vibrateKey() updateRevokeButtonVisibility(view, res, pkg)
env.deleteOne()
} }
//关闭键盘 view.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))?.setOnClickListener {
rootView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg)) vibrateKey(); env.hideKeyboard()
?.setOnClickListener {
vibrateKey() // 如果这个方法在当前类里有
env.hideKeyboard()
} }
// 切换数字键盘 view.findViewById<View?>(res.getIdentifier("key_123", "id", pkg))?.setOnClickListener {
view.findViewById<View?>(res.getIdentifier("key_123", "id", pkg)) vibrateKey(); env.showNumberKeyboard()
?.setOnClickListener {
vibrateKey()
env.showNumberKeyboard()
} }
// 跳 AI view.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))?.setOnClickListener {
view.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg)) vibrateKey(); env.showAiKeyboard()
?.setOnClickListener {
vibrateKey()
env.showAiKeyboard()
} }
// 发送 view.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))?.setOnClickListener {
view.findViewById<View?>(res.getIdentifier("key_send", "id", pkg)) vibrateKey(); env.performSendAction()
?.setOnClickListener { // 发送后更新按钮可见性
vibrateKey() updateRevokeButtonVisibility(view, res, pkg)
env.performSendAction() }
view.findViewById<View?>(res.getIdentifier("key_revoke", "id", pkg))?.setOnClickListener {
vibrateKey(); env.revokeLastClearedText()
// 回填后更新按钮可见性
updateRevokeButtonVisibility(view, res, pkg)
}
view.findViewById<View?>(res.getIdentifier("key_emoji", "id", pkg))?.setOnClickListener {
vibrateKey(); env.showEmojiKeyboard()
} }
} }
// Shift 后更新字母按键背景key_a vs key_a_up // 更新Revoke按钮的可见性
private fun updateKeyBackgroundsForLetters(root: View) { private fun updateRevokeButtonVisibility(view: View, res: android.content.res.Resources, pkg: String) {
var c = 'a' val revokeButton = view.findViewById<View?>(res.getIdentifier("key_revoke", "id", pkg))
while (c <= 'z') { revokeButton?.visibility = if (env.hasClearedText()) View.VISIBLE else View.GONE
val idName = "key_$c"
val drawableName = if (isShiftOn) "${idName}_up" else idName
applyKeyBackground(root, idName, drawableName)
c++
} }
val upKeyIdName = "key_up" // 公共方法更新Revoke按钮的可见性供外部调用
val upDrawableName = if (isShiftOn) "key_up_upper" else "key_up" fun updateRevokeButtonVisibility() {
applyKeyBackground(root, upKeyIdName, upDrawableName) val res = env.ctx.resources
val pkg = env.ctx.packageName
updateRevokeButtonVisibility(rootView, res, pkg)
} }
// ======================== 触摸 + 预览 ======================== // -------------------- 触摸 + 预览 --------------------
private fun attachKeyTouchWithSwipe( private fun attachKeyTouchWithSwipe(
view: View, view: View,
normalCharProvider: () -> Char, normalCharProvider: () -> Char,
@@ -252,10 +191,9 @@ class MainKeyboard(
view.setOnTouchListener { v, event -> view.setOnTouchListener { v, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
downY = event.rawY downY = event.rawY; isAlt = false
isAlt = false
currentChar = normalCharProvider() currentChar = normalCharProvider()
vibrateKey() // 按下就震 vibrateKey()
showKeyPreview(v, currentChar.toString()) showKeyPreview(v, currentChar.toString())
v.isPressed = true v.isPressed = true
true true
@@ -278,8 +216,7 @@ class MainKeyboard(
v.isPressed = false v.isPressed = false
true true
} }
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> {
MotionEvent.ACTION_OUTSIDE -> {
keyPreviewPopup?.dismiss() keyPreviewPopup?.dismiss()
v.isPressed = false v.isPressed = false
true true
@@ -291,7 +228,6 @@ class MainKeyboard(
private fun showKeyPreview(anchor: View, text: String) { private fun showKeyPreview(anchor: View, text: String) {
keyPreviewPopup?.dismiss() keyPreviewPopup?.dismiss()
val tv = TextView(env.ctx).apply { val tv = TextView(env.ctx).apply {
this.text = text this.text = text
textSize = 26f textSize = 26f
@@ -309,14 +245,7 @@ class MainKeyboard(
val w = (anchor.width * 1.2f).toInt() val w = (anchor.width * 1.2f).toInt()
val h = (anchor.height * 1.2f).toInt() val h = (anchor.height * 1.2f).toInt()
keyPreviewPopup = PopupWindow(tv, w, h, false).apply { keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false }
isClippingEnabled = false keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt())
}
keyPreviewPopup?.showAsDropDown(
anchor,
-(w - anchor.width) / 2,
-(h + anchor.height * 1.1f).toInt()
)
} }
} }

View File

@@ -15,7 +15,6 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.PopupWindow import android.widget.PopupWindow
import android.widget.TextView import android.widget.TextView
import com.example.myapplication.R
import com.example.myapplication.theme.ThemeManager import com.example.myapplication.theme.ThemeManager
class NumberKeyboard( class NumberKeyboard(
@@ -29,241 +28,164 @@ class NumberKeyboard(
private var keyPreviewPopup: PopupWindow? = null 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~255100~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 { init {
applyPerKeyBackgroundForNumberKeyboard(rootView) applyPerKeyBackgroundForNumberKeyboard(rootView)
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
// 初次创建立刻应用当前主题
applyTheme(
env.currentTextColor,
env.currentBorderColor,
env.currentBackgroundColor
)
setupListenersForNumberView(rootView) setupListenersForNumberView(rootView)
} }
// ================= 背景(完全拷贝原逻辑,只是换成 env.ctx.resources ================= // -------------------- 背景 --------------------
private fun applyPerKeyBackgroundForNumberKeyboard(root: View) { private fun applyPerKeyBackgroundForNumberKeyboard(root: View) {
val res = env.ctx.resources for (i in 0..9) applyKeyBackground(root, "key_$i")
// 0..9
for (i in 0..9) {
val idName = "key_$i"
applyKeyBackground(root, idName)
}
// 背景
applyKeyBackground(root, "background") applyKeyBackground(root, "background")
// 符号键
val symbolKeys = listOf( val symbolKeys = listOf(
"key_comma", "key_comma","key_dot","key_minus","key_slash","key_colon","key_semicolon",
"key_dot", "key_paren_l","key_paren_r","key_dollar","key_amp","key_at","key_question",
"key_minus", "key_exclam","key_quote","key_quote_d"
"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 -> symbolKeys.forEach { applyKeyBackground(root, it) }
applyKeyBackground(root, idName)
}
// 功能键
val others = listOf( val others = listOf(
"key_symbols_more", "key_symbols_more","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","key_emoji","key_revoke"
"key_abc",
"key_ai",
"key_space",
"key_send",
"key_del",
"Key_collapse"
) )
others.forEach { idName -> others.forEach { applyKeyBackground(root, it) }
applyKeyBackground(root, idName)
}
} }
private fun applyKeyBackground( private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
root: View,
viewIdName: String,
drawableName: String? = null
) {
val res = env.ctx.resources val res = env.ctx.resources
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
if (viewId == 0) return
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
if (viewIdName == "background") { if (viewIdName == "background") {
val scaled = scaleDrawableToHeight(rawDrawable, 243f) v.background = scaleDrawableToHeight(rawDrawable, 243f)
v.background = scaled } else {
return
}
v.background = rawDrawable v.background = rawDrawable
} }
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable { private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources val res = env.ctx.resources
val dm = res.displayMetrics val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension( val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
TypedValue.COMPLEX_UNIT_DIP,
targetDp,
dm
).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val w = bitmap.width val ratio = targetHeightPx.toFloat() / bitmap.height
val h = 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) }
}
val ratio = targetHeightPx.toFloat() / h // -------------------- 实现主题刷新 --------------------
val targetWidthPx = (w * ratio).toInt() override fun applyKeyBackgroundsForTheme() {
// 刷新数字键
for (i in 0..9) applyKeyBackground(rootView, "key_$i")
val scaledBitmap = Bitmap.createScaledBitmap( // 刷新符号键
bitmap, val symbolKeys = listOf(
targetWidthPx, "key_comma","key_dot","key_minus","key_slash","key_colon","key_semicolon",
targetHeightPx, "key_paren_l","key_paren_r","key_dollar","key_amp","key_at","key_question",
true "key_exclam","key_quote","key_quote_d"
) )
return BitmapDrawable(res, scaledBitmap).apply { symbolKeys.forEach { applyKeyBackground(rootView, it) }
setBounds(0, 0, targetWidthPx, targetHeightPx)
} // 刷新功能键和背景
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) { private fun setupListenersForNumberView(numView: View) {
val res = env.ctx.resources val res = env.ctx.resources
val pkg = env.ctx.packageName val pkg = env.ctx.packageName
// 0~9 // 初始化时设置Revoke按钮的可见性
updateRevokeButtonVisibility(numView, res, pkg)
for (i in 0..9) { for (i in 0..9) {
val id = res.getIdentifier("key_$i", "id", pkg) val id = res.getIdentifier("key_$i", "id", pkg)
numView.findViewById<View?>(id)?.let { v -> numView.findViewById<View?>(id)?.let { attachKeyTouch(it) { i.toString()[0] } }
attachKeyTouch(v) { i.toString()[0] }
}
} }
// 符号键
val symbolMap: List<Pair<String, Char>> = listOf( val symbolMap: List<Pair<String, Char>> = listOf(
"key_comma" to ',', "key_comma" to ',', "key_dot" to '.', "key_minus" to '-', "key_slash" to '/',
"key_dot" to '.', "key_colon" to ':', "key_semicolon" to ';', "key_paren_l" to '(', "key_paren_r" to ')',
"key_minus" to '-', "key_dollar" to '$', "key_amp" to '&', "key_at" to '@', "key_question" to '?',
"key_slash" to '/', "key_exclam" to '!', "key_quote" to '\'', "key_quote_d" 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) -> symbolMap.forEach { (name, ch) ->
val id = res.getIdentifier(name, "id", pkg) val id = res.getIdentifier(name, "id", pkg)
numView.findViewById<View?>(id)?.let { v -> numView.findViewById<View?>(id)?.let { attachKeyTouch(it) { ch } }
attachKeyTouch(v) { ch }
}
} }
// 切换:符号层 // 功能键
numView.findViewById<View?>(res.getIdentifier("key_symbols_more", "id", pkg)) numView.findViewById<View?>(res.getIdentifier("key_symbols_more", "id", pkg))
?.setOnClickListener { ?.setOnClickListener { vibrateKey(); env.showSymbolKeyboard() }
vibrateKey()
env.showSymbolKeyboard()
}
// 切回字母
numView.findViewById<View?>(res.getIdentifier("key_abc", "id", pkg)) numView.findViewById<View?>(res.getIdentifier("key_abc", "id", pkg))
?.setOnClickListener { ?.setOnClickListener { vibrateKey(); env.showMainKeyboard() }
vibrateKey()
env.showMainKeyboard()
}
// 跳 AI
numView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg)) numView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
?.setOnClickListener { ?.setOnClickListener { vibrateKey(); env.showAiKeyboard() }
vibrateKey()
env.showAiKeyboard()
}
// 空格
numView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg)) numView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.commitKey(' ')
env.commitKey(' ') // 输入新内容后更新按钮可见性
updateRevokeButtonVisibility(numView, res, pkg)
} }
// 发送
numView.findViewById<View?>(res.getIdentifier("key_send", "id", pkg)) numView.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.performSendAction()
env.performSendAction() // 发送后更新按钮可见性
updateRevokeButtonVisibility(numView, res, pkg)
} }
// 删除(单击;长按连删在 MyInputMethodService 里挂)
numView.findViewById<View?>(res.getIdentifier("key_del", "id", pkg)) numView.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.deleteOne()
env.deleteOne() // 删除内容后更新按钮可见性
updateRevokeButtonVisibility(numView, res, pkg)
} }
//关闭键盘 numView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
rootView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg)) ?.setOnClickListener { vibrateKey(); env.hideKeyboard() }
numView.findViewById<View?>(res.getIdentifier("key_emoji", "id", pkg))
?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() }
numView.findViewById<View?>(res.getIdentifier("key_revoke", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() // 如果这个方法在当前类里有 vibrateKey(); env.revokeLastClearedText()
env.hideKeyboard() // 回填后更新按钮可见性
updateRevokeButtonVisibility(numView, res, pkg)
} }
} }
// ================= 按键触摸 & 预览 ================= // 更新Revoke按钮的可见性
private fun updateRevokeButtonVisibility(view: View, res: android.content.res.Resources, pkg: String) {
val revokeButton = view.findViewById<View?>(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) { private fun attachKeyTouch(view: View, charProvider: () -> Char) {
view.setOnTouchListener { v, event -> view.setOnTouchListener { v, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
val ch = charProvider() val ch = charProvider()
vibrateKey() // 按下就震一下 vibrateKey()
showKeyPreview(v, ch.toString()) showKeyPreview(v, ch.toString())
v.isPressed = true v.isPressed = true
true true
@@ -288,7 +210,6 @@ class NumberKeyboard(
private fun showKeyPreview(anchor: View, text: String) { private fun showKeyPreview(anchor: View, text: String) {
keyPreviewPopup?.dismiss() keyPreviewPopup?.dismiss()
val tv = TextView(env.ctx).apply { val tv = TextView(env.ctx).apply {
this.text = text this.text = text
textSize = 26f textSize = 26f
@@ -306,14 +227,7 @@ class NumberKeyboard(
val w = (anchor.width * 1.2f).toInt() val w = (anchor.width * 1.2f).toInt()
val h = (anchor.height * 1.2f).toInt() val h = (anchor.height * 1.2f).toInt()
keyPreviewPopup = PopupWindow(tv, w, h, false).apply { keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false }
isClippingEnabled = false keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt())
}
keyPreviewPopup?.showAsDropDown(
anchor,
-(w - anchor.width) / 2,
-(h + anchor.height * 1.1f).toInt()
)
} }
} }

View File

@@ -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()
}
}

View File

@@ -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<String> {
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()
}
}

View File

@@ -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<SimpleStringGridAdapter.VH>() {
private val data = ArrayList<String>()
fun submit(list: List<String>) {
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)
}

View File

@@ -15,7 +15,6 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.PopupWindow import android.widget.PopupWindow
import android.widget.TextView import android.widget.TextView
import com.example.myapplication.R
import com.example.myapplication.theme.ThemeManager import com.example.myapplication.theme.ThemeManager
class SymbolKeyboard( class SymbolKeyboard(
@@ -29,259 +28,163 @@ class SymbolKeyboard(
private var keyPreviewPopup: PopupWindow? = null 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~255100~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 { init {
// 按键背景图片(跟你原来 applyPerKeyBackgroundForSymbolKeyboard 一样)
applyPerKeyBackgroundForSymbolKeyboard(rootView) applyPerKeyBackgroundForSymbolKeyboard(rootView)
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
// 初次创建立刻应用当前主题色
applyTheme(
env.currentTextColor,
env.currentBorderColor,
env.currentBackgroundColor
)
setupListenersForSymbolView(rootView) setupListenersForSymbolView(rootView)
} }
// ================== 背景(完全按你原来的 key 列表) ================== // -------------------- 背景 --------------------
private fun applyPerKeyBackgroundForSymbolKeyboard(root: View) { private fun applyPerKeyBackgroundForSymbolKeyboard(root: View) {
val res = env.ctx.resources
val symbolKeys = listOf( val symbolKeys = listOf(
// 第一行 "key_bracket_l","key_bracket_r","key_brace_l","key_brace_r","key_hash","key_percent",
"key_bracket_l", "key_caret","key_asterisk","key_plus","key_equal",
"key_bracket_r", "key_underscore","key_backslash","key_pipe","key_tilde","key_lt","key_gt",
"key_brace_l", "key_euro","key_pound","key_money","key_bullet",
"key_brace_r", "key_dot","key_comma","key_question","key_exclam","key_quote"
"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") applyKeyBackground(root, "background")
// 功能键
val others = listOf( val others = listOf(
"key_symbols_123", "key_symbols_123","key_emoji","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","key_revoke"
"key_backspace",
"key_abc",
"key_ai",
"key_space",
"key_send",
"Key_collapse"
) )
others.forEach { applyKeyBackground(root, it) }
others.forEach { idName ->
applyKeyBackground(root, idName)
}
} }
private fun applyKeyBackground( private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
root: View,
viewIdName: String,
drawableName: String? = null
) {
val res = env.ctx.resources val res = env.ctx.resources
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName) val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
if (viewId == 0) return
val v = root.findViewById<View?>(viewId) ?: return val v = root.findViewById<View?>(viewId) ?: return
val keyName = drawableName ?: viewIdName val keyName = drawableName ?: viewIdName
val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
if (viewIdName == "background") { if (viewIdName == "background") {
val scaled = scaleDrawableToHeight(rawDrawable, 243f) v.background = scaleDrawableToHeight(rawDrawable, 243f)
v.background = scaled } else {
return
}
v.background = rawDrawable v.background = rawDrawable
} }
}
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable { private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
val res = env.ctx.resources val res = env.ctx.resources
val dm = res.displayMetrics val dm = res.displayMetrics
val targetHeightPx = TypedValue.applyDimension( val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
TypedValue.COMPLEX_UNIT_DIP,
targetDp,
dm
).toInt()
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
val w = bitmap.width val ratio = targetHeightPx.toFloat() / bitmap.height
val h = 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) }
}
val ratio = targetHeightPx.toFloat() / h // -------------------- 实现主题刷新 --------------------
val targetWidthPx = (w * ratio).toInt() override fun applyKeyBackgroundsForTheme() {
// 刷新符号键
val scaledBitmap = Bitmap.createScaledBitmap( val symbolKeys = listOf(
bitmap, "key_bracket_l","key_bracket_r","key_brace_l","key_brace_r","key_hash","key_percent",
targetWidthPx, "key_caret","key_asterisk","key_plus","key_equal",
targetHeightPx, "key_underscore","key_backslash","key_pipe","key_tilde","key_lt","key_gt",
true "key_euro","key_pound","key_money","key_bullet",
"key_dot","key_comma","key_question","key_exclam","key_quote"
) )
return BitmapDrawable(res, scaledBitmap).apply { symbolKeys.forEach { applyKeyBackground(rootView, it) }
setBounds(0, 0, targetWidthPx, targetHeightPx)
} // 刷新功能键和背景
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) { private fun setupListenersForSymbolView(symView: View) {
val res = env.ctx.resources val res = env.ctx.resources
val pkg = env.ctx.packageName val pkg = env.ctx.packageName
val pairs = listOf( // 初始化时设置Revoke按钮的可见性
// 第一行 updateRevokeButtonVisibility(symView, res, pkg)
"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 '=',
// 第二行 val pairs: List<Pair<String, Char>> = listOf(
"key_underscore" to '_', "key_bracket_l" to '[',"key_bracket_r" to ']',"key_brace_l" to '{',"key_brace_r" to '}',
"key_backslash" to '\\', "key_hash" to '#',"key_percent" to '%',"key_caret" to '^',"key_asterisk" to '*',
"key_pipe" to '|', "key_plus" to '+',"key_equal" to '=',
"key_tilde" to '~', "key_underscore" to '_',"key_backslash" to '\\',"key_pipe" to '|',"key_tilde" to '~',
"key_lt" to '<', "key_lt" to '<',"key_gt" to '>',"key_euro" to '€',"key_pound" to '£',
"key_gt" to '>', "key_money" to '¥',"key_bullet" to '',
"key_euro" to '', "key_dot" to '.',"key_comma" to ',',"key_question" to '?',"key_exclam" to '!',"key_quote" 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) -> pairs.forEach { (name, ch) ->
val id = res.getIdentifier(name, "id", pkg) val id = res.getIdentifier(name, "id", pkg)
symView.findViewById<View?>(id)?.let { v -> symView.findViewById<View?>(id)?.let { attachKeyTouch(it) { ch } }
attachKeyTouch(v) { ch }
}
} }
// 切换回数字 // 功能键
symView.findViewById<View?>(res.getIdentifier("key_symbols_123", "id", pkg)) symView.findViewById<View?>(res.getIdentifier("key_symbols_123", "id", pkg))
?.setOnClickListener { ?.setOnClickListener { vibrateKey(); env.showNumberKeyboard() }
vibrateKey()
env.showNumberKeyboard()
}
// 切回字母
symView.findViewById<View?>(res.getIdentifier("key_abc", "id", pkg)) symView.findViewById<View?>(res.getIdentifier("key_abc", "id", pkg))
?.setOnClickListener { ?.setOnClickListener { vibrateKey(); env.showMainKeyboard() }
vibrateKey()
env.showMainKeyboard()
}
// 空格
symView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg)) symView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.commitKey(' ')
env.commitKey(' ') // 输入新内容后更新按钮可见性
updateRevokeButtonVisibility(symView, res, pkg)
} }
// 发送
symView.findViewById<View?>(res.getIdentifier("key_send", "id", pkg)) symView.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.performSendAction()
env.performSendAction() // 发送后更新按钮可见性
updateRevokeButtonVisibility(symView, res, pkg)
} }
// 删除(单击;长按连删在 MyInputMethodService 里统一挂) symView.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))
symView.findViewById<View?>(res.getIdentifier("key_backspace", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.deleteOne()
env.deleteOne() // 删除内容后更新按钮可见性
updateRevokeButtonVisibility(symView, res, pkg)
} }
// 跳 AI 键盘
symView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg)) symView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
?.setOnClickListener { vibrateKey(); env.showAiKeyboard() }
symView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
?.setOnClickListener { vibrateKey(); env.hideKeyboard() }
symView.findViewById<View?>(res.getIdentifier("key_emoji", "id", pkg))
?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() }
symView.findViewById<View?>(res.getIdentifier("key_revoke", "id", pkg))
?.setOnClickListener { ?.setOnClickListener {
vibrateKey() vibrateKey(); env.revokeLastClearedText()
env.showAiKeyboard() // 回填后更新按钮可见性
} updateRevokeButtonVisibility(symView, res, pkg)
//关闭键盘
rootView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
?.setOnClickListener {
vibrateKey() // 如果这个方法在当前类里有
env.hideKeyboard()
} }
} }
// ================== 触摸 + 预览 ================== // 更新Revoke按钮的可见性
private fun updateRevokeButtonVisibility(view: View, res: android.content.res.Resources, pkg: String) {
val revokeButton = view.findViewById<View?>(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) { private fun attachKeyTouch(view: View, charProvider: () -> Char) {
view.setOnTouchListener { v, event -> view.setOnTouchListener { v, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
val ch = charProvider() val ch = charProvider()
vibrateKey() // 按下震动 vibrateKey()
showKeyPreview(v, ch.toString()) showKeyPreview(v, ch.toString())
v.isPressed = true v.isPressed = true
true true
@@ -293,8 +196,7 @@ class SymbolKeyboard(
v.isPressed = false v.isPressed = false
true true
} }
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> {
MotionEvent.ACTION_OUTSIDE -> {
keyPreviewPopup?.dismiss() keyPreviewPopup?.dismiss()
v.isPressed = false v.isPressed = false
true true
@@ -306,7 +208,6 @@ class SymbolKeyboard(
private fun showKeyPreview(anchor: View, text: String) { private fun showKeyPreview(anchor: View, text: String) {
keyPreviewPopup?.dismiss() keyPreviewPopup?.dismiss()
val tv = TextView(env.ctx).apply { val tv = TextView(env.ctx).apply {
this.text = text this.text = text
textSize = 26f textSize = 26f
@@ -324,14 +225,7 @@ class SymbolKeyboard(
val w = (anchor.width * 1.2f).toInt() val w = (anchor.width * 1.2f).toInt()
val h = (anchor.height * 1.2f).toInt() val h = (anchor.height * 1.2f).toInt()
keyPreviewPopup = PopupWindow(tv, w, h, false).apply { keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false }
isClippingEnabled = false keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt())
}
keyPreviewPopup?.showAsDropDown(
anchor,
-(w - anchor.width) / 2,
-(h + anchor.height * 1.1f).toInt()
)
} }
} }

View File

@@ -1,8 +0,0 @@
// ApiResponse.kt
package com.example.myapplication.network
data class ApiResponse<T>(
val code: Int,
val message: String,
val data: T?
)

View File

@@ -20,17 +20,143 @@ interface ApiService {
// @Query("pageSize") pageSize: Int // @Query("pageSize") pageSize: Int
// ): ApiResponse<List<User>> // ): ApiResponse<List<User>>
// POST JSON 示例Body 为 JSON{"username": "...", "password": "..."} //登录
@POST("user/login") @POST("user/login")
suspend fun login( suspend fun login(
@Body body: LoginRequest @Body body: LoginRequest
): ApiResponse<LoginResponse> ): ApiResponse<LoginResponse>
// =========================================用户=================================
//获取用户详情
@GET("user/detail")
suspend fun getUser(
): ApiResponse<User>
//更新用户信息
@POST("user/updateInfo")
suspend fun updateUserInfo(
@Body body: updateInfoRequest
): ApiResponse<User>
//===========================================首页=================================
// 标签列表
@GET("tag/list")
suspend fun tagList(
): ApiResponse<List<Tag>>
//未登录用户按标签查询人设列表
@GET("character/listByTagWithNotLogin")
suspend fun personaListByTag(
@Query("tagId") tagId: Int
): ApiResponse<List<listByTagWithNotLogin>>
//登录用户按标签查询人设列表
@GET("character/listByTag")
suspend fun loggedInPersonaListByTag(
@Query("tagId") tagId: Int
): ApiResponse<List<listByTagWithNotLogin>>
// 人设列表
@GET("character/list")
suspend fun personaByTag(
): ApiResponse<List<listByTagWithNotLogin>>
//未登录用户人设列表
@GET("character/listWithNotLogin")
suspend fun personaListWithNotLogin(
): ApiResponse<List<listByTagWithNotLogin>>
// 人设详情
@GET("character/detail")
suspend fun characterDetail(
@Query("id") id: Int
): ApiResponse<CharacterDetailResponse>
//删除用户人设
@GET("character/delUserCharacter")
suspend fun delUserCharacter(
@Query("id") id: Int
): ApiResponse<Unit>
//添加用户人设
@POST("character/addUserCharacter")
suspend fun addUserCharacter(
@Body body: AddPersonaClick
): ApiResponse<Unit>
//==========================================商城===========================================
//查询钱包余额
@GET("wallet/balance")
suspend fun walletBalance(
): ApiResponse<Wallet>
//查询所有主题风格
@GET("themes/listAllStyles")
suspend fun themeList(
): ApiResponse<List<Theme>>
//按风格查询主题
@GET("themes/listByStyle")
suspend fun themeListByStyle(
@Query("themeStyle") id: Int
): ApiResponse<List<themeStyle>>
//查询主题详情
@GET("themes/detail")
suspend fun themeDetail(
@Query("themeId") id: Int
): ApiResponse<themeDetail>
//推荐主题列表
@GET("themes/recommended")
suspend fun recommendThemeList(
): ApiResponse<List<themeStyle>>
//搜索主题
@GET("themes/search")
suspend fun searchTheme(
@Query("themeName") keyword: String
): ApiResponse<List<themeStyle>>
//查询已购买的主题
@GET("themes/purchased")
suspend fun purchasedThemeList(
): ApiResponse<List<themeStyle>>
// 批量删除用户主题
@POST("user-themes/batch-delete")
suspend fun batchDeleteUserTheme(
@Body body: deleteThemeRequest
): ApiResponse<Boolean>
// 购买主题
@POST("themes/purchase")
suspend fun purchaseTheme(
@Body body: purchaseThemeRequest
): ApiResponse<Unit>
//恢复已删除的主题
@POST("themes/restore")
suspend fun restoreTheme(
@Query("themeId") themeId: Int
): ApiResponse<Unit>
// =========================================文件=============================================
// zip 文件下载(或其它大文件)——必须 @Streaming // zip 文件下载(或其它大文件)——必须 @Streaming
@Streaming @Streaming
@GET("files/{fileName}") @GET("files/{fileName}")
suspend fun downloadZip( suspend fun downloadZip(
@Path("fileName") fileName: String // 比如 "xxx.zip" @Path("fileName") fileName: String // 比如 "xxx.zip"
): Response<ResponseBody> ): Response<ResponseBody>
// 完整 URL 下载
@Streaming
@GET
@Headers(
"Accept-Encoding: identity"
)
suspend fun downloadZipFromUrl(
@Url url: String // 完整的下载 URL
): Response<ResponseBody>
} }

View File

@@ -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<AuthEvent>(
replay = 0,
extraBufferCapacity = 1
)
val events: SharedFlow<AuthEvent> = _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()
}

View File

@@ -16,7 +16,7 @@ object FileDownloader {
/** /**
* 下载 zip 文件并保存到 app 专属目录 * 下载 zip 文件并保存到 app 专属目录
* @param context 用来获取文件目录 * @param context 用来获取文件目录
* @param remoteFileName 服务器上的文件名,比如 "test.zip" * @param remoteFileName 服务器上的文件名或完整URL,比如 "test.zip" 或 "https://example.com/files/test.zip"
* @param localFileName 本地保存名字,比如 "test_local.zip" * @param localFileName 本地保存名字,比如 "test_local.zip"
* @return 保存成功后返回 File失败返回 null * @return 保存成功后返回 File失败返回 null
*/ */
@@ -27,7 +27,13 @@ object FileDownloader {
): File? = withContext(Dispatchers.IO) { ): File? = withContext(Dispatchers.IO) {
val api = RetrofitClient.apiService val api = RetrofitClient.apiService
try { 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) { if (!response.isSuccessful) {
Log.e("Downloader", "download failed: code=${response.code()}") Log.e("Downloader", "download failed: code=${response.code()}")
return@withContext null return@withContext null

View File

@@ -2,24 +2,32 @@
package com.example.myapplication.network package com.example.myapplication.network
import android.util.Log import android.util.Log
import com.google.gson.Gson
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import android.content.Context
/** /**
* 请求拦截器:统一加 Header、token 等 * 请求拦截器:统一加 Header、token 等
*/ */
val requestInterceptor = Interceptor { chain -> fun requestInterceptor(appContext: Context) = Interceptor { chain ->
val original = chain.request() val original = chain.request()
val token = "" // 你的 token val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java)
val token = user?.token.orEmpty()
val newRequest = original.newBuilder() val newRequest = original.newBuilder()
.addHeader("Authorization", "Bearer $token") .apply {
if (token.isNotBlank()) {
addHeader("auth-token", "$token")
}
}
.addHeader("Accept-Language", "lang") .addHeader("Accept-Language", "lang")
.build() .build()
// ====== 打印请求信息 ====== // ===== 打印请求信息 =====
val request = newRequest val request = newRequest
val url = request.url val url = request.url
@@ -28,13 +36,11 @@ val requestInterceptor = Interceptor { chain ->
sb.append("Method: ${request.method}\n") sb.append("Method: ${request.method}\n")
sb.append("URL: $url\n") sb.append("URL: $url\n")
// 打印 Header
sb.append("Headers:\n") sb.append("Headers:\n")
for (name in request.headers.names()) { for (name in request.headers.names()) {
sb.append(" $name: ${request.header(name)}\n") sb.append(" $name: ${request.header(name)}\n")
} }
// 打印 Query 参数
if (url.querySize > 0) { if (url.querySize > 0) {
sb.append("Query Params:\n") sb.append("Query Params:\n")
for (i in 0 until url.querySize) { for (i in 0 until url.querySize) {
@@ -42,20 +48,17 @@ val requestInterceptor = Interceptor { chain ->
} }
} }
// 打印 Body仅限可读类型
val requestBody = request.body val requestBody = request.body
if (requestBody != null) { if (requestBody != null) {
val buffer = okio.Buffer() val buffer = okio.Buffer()
requestBody.writeTo(buffer) requestBody.writeTo(buffer)
sb.append("Body:\n") sb.append("Body:\n")
sb.append(buffer.readUtf8()) sb.append(buffer.readUtf8())
sb.append("\n") sb.append("\n")
} }
sb.append("================================\n") sb.append("================================\n")
Log.d("1314520-OkHttp-Request", sb.toString())
android.util.Log.d("1314520-OkHttp-Request", sb.toString())
chain.proceed(request) chain.proceed(request)
} }
@@ -72,6 +75,14 @@ val responseInterceptor = Interceptor { chain ->
val rawBody = response.body val rawBody = response.body
val mediaType = rawBody?.contentType() val mediaType = rawBody?.contentType()
if (
mediaType?.subtype == "zip" ||
request.url.toString().endsWith(".zip")
) {
return@Interceptor response
}
val bodyString = rawBody?.string() ?: "" val bodyString = rawBody?.string() ?: ""
Log.d( Log.d(
@@ -85,6 +96,33 @@ val responseInterceptor = Interceptor { chain ->
"⬆⬆⬆" "⬆⬆⬆"
) )
// 尝试解析响应体检查是否为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 只能读一次,这里读完后再重新构建一个 // body 只能读一次,这里读完后再重新构建一个
response.newBuilder() response.newBuilder()
.body(bodyString.toResponseBody(mediaType)) .body(bodyString.toResponseBody(mediaType))

View File

@@ -1,19 +1,164 @@
// Models.kt // Models.kt
package com.example.myapplication.network package com.example.myapplication.network
data class User( // 通用API响应模型
val id: String, data class ApiResponse<T>(
val name: String, val code: Int,
val age: Int val message: String,
val data: T?
) )
// 错误响应
data class ErrorResponse(
val code: Int,
val message: String
)
// ======================================登录================================
// 登录 // 登录
data class LoginRequest( data class LoginRequest(
val mail: String, val mail: String,
val password: String val password: String
) )
// 登录响应
data class LoginResponse( data class LoginResponse(
val token: String, val uid: Long,
val user: User 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<Tag>
)
// 人设详情点击事件
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<SubjectTag>?,
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<SubjectTag>?,
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<Int>
)
//购买主题
data class purchaseThemeRequest(
val themeId: Int,
) )

View File

@@ -1,6 +1,6 @@
// RetrofitClient.kt
package com.example.myapplication.network package com.example.myapplication.network
import android.content.Context
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -9,22 +9,30 @@ import java.util.concurrent.TimeUnit
object RetrofitClient { object RetrofitClient {
private const val BASE_URL = "http://192.168.2.21:7529/api/" // 换成你的地址 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 { private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
} }
private val okHttpClient: OkHttpClient by lazy { private val okHttpClient: OkHttpClient by lazy {
check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." }
OkHttpClient.Builder() OkHttpClient.Builder()
// 超时时间自己看需求改
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
// 顺序:请求拦截 -> logging -> 响应拦截 // 顺序:请求拦截 -> logging -> 响应拦截
.addInterceptor(requestInterceptor) .addInterceptor(requestInterceptor(appContext))
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.addInterceptor(responseInterceptor) .addInterceptor(responseInterceptor)
.build() .build()
@@ -41,4 +49,16 @@ object RetrofitClient {
val apiService: ApiService by lazy { val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java) 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()
}
} }

View File

@@ -21,6 +21,17 @@ object ThemeManager {
private var drawableCache: MutableMap<String, Drawable> = mutableMapOf() private var drawableCache: MutableMap<String, Drawable> = mutableMapOf()
// ==================== 外部目录相关 ==================== // ==================== 外部目录相关 ====================
//通知主题更新
private val listeners = mutableSetOf<() -> Unit>()
fun addThemeChangeListener(listener: () -> Unit) {
listeners.add(listener)
}
fun removeThemeChangeListener(listener: () -> Unit) {
listeners.remove(listener)
}
/** 主题根目录:/Android/data/<package>/files/keyboard_themes */ /** 主题根目录:/Android/data/<package>/files/keyboard_themes */
private fun getThemeRootDir(context: Context): File = private fun getThemeRootDir(context: Context): File =
@@ -114,6 +125,8 @@ object ThemeManager {
.apply() .apply()
drawableCache = loadThemeDrawables(context, themeName) drawableCache = loadThemeDrawables(context, themeName)
listeners.forEach { it.invoke() }
} }
fun getCurrentThemeName(): String? = currentThemeName fun getCurrentThemeName(): String? = currentThemeName

View File

@@ -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)
}
}

View File

@@ -1,30 +1,41 @@
package com.example.myapplication.ui.home package com.example.myapplication.ui.home
import android.content.Intent
import android.graphics.drawable.TransitionDrawable
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.HorizontalScrollView
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.widget.NestedScrollView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.example.myapplication.ImeGuideActivity
import com.example.myapplication.R 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.bottomsheet.BottomSheetBehavior
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import android.graphics.drawable.TransitionDrawable import kotlinx.coroutines.launch
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.widget.HorizontalScrollView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import kotlin.math.abs import kotlin.math.abs
import android.content.Intent import com.example.myapplication.network.AddPersonaClick
import com.example.myapplication.ImeGuideActivity
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
@@ -38,16 +49,41 @@ class HomeFragment : Fragment() {
private lateinit var tabList1: TextView private lateinit var tabList1: TextView
private lateinit var tabList2: TextView private lateinit var tabList2: TextView
private lateinit var backgroundImage: ImageView private lateinit var backgroundImage: ImageView
private var preloadJob: kotlinx.coroutines.Job? = null
private var allPersonaCache: List<listByTagWithNotLogin> = emptyList()
private val sharedPool = RecyclerView.RecycledViewPool()
private var parentWidth = 0 private var parentWidth = 0
private var parentHeight = 0 private var parentHeight = 0
// 你点了哪个 tag列表二
private var clickedTagId: Int? = null
// ✅ 列表二:每个 tagId 对应一份 persona 数据,避免串页
private val personaCache = mutableMapOf<Int, List<listByTagWithNotLogin>>()
data class Tag(val id: Int, val tagName: String)
private val tags = mutableListOf<Tag>()
private val dragToCloseThreshold by lazy { private val dragToCloseThreshold by lazy {
val dp = 40f val dp = 40f
(dp * resources.displayMetrics.density) (dp * resources.displayMetrics.density)
} }
// 第二个列表的“标签页”,数量不固定,可以从服务端/本地配置来 private val list1Adapter: List1Adapter by lazy {
private val tags = listOf("标签一", "标签二", "标签三", "标签四", "标签五", "标签六", "标签七", "标签八", "标签九", "标签十") 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -64,7 +100,8 @@ class HomeFragment : Fragment() {
view.findViewById<View>(R.id.rechargeButton).setOnClickListener { view.findViewById<View>(R.id.rechargeButton).setOnClickListener {
findNavController().navigate(R.id.action_global_rechargeFragment) findNavController().navigate(R.id.action_global_rechargeFragment)
} }
//输入法激活跳转
// 输入法激活跳转
view.findViewById<ImageView>(R.id.floatingImage).setOnClickListener { view.findViewById<ImageView>(R.id.floatingImage).setOnClickListener {
if (isAdded) { if (isAdded) {
startActivity(Intent(requireActivity(), ImeGuideActivity::class.java)) startActivity(Intent(requireActivity(), ImeGuideActivity::class.java))
@@ -79,10 +116,11 @@ class HomeFragment : Fragment() {
tabList1 = view.findViewById(R.id.tab_list1) tabList1 = view.findViewById(R.id.tab_list1)
tabList2 = view.findViewById(R.id.tab_list2) tabList2 = view.findViewById(R.id.tab_list2)
viewPager = view.findViewById(R.id.viewPager) viewPager = view.findViewById(R.id.viewPager)
viewPager.isSaveEnabled = false
backgroundImage = bottomSheet.findViewById(R.id.backgroundImage) backgroundImage = bottomSheet.findViewById(R.id.backgroundImage)
val root = view.findViewById<CoordinatorLayout>(R.id.rootCoordinator) val root = view.findViewById<CoordinatorLayout>(R.id.rootCoordinator)
val floatingImage = view.findViewById<ImageView>(R.id.floatingImage) val floatingImage = view.findViewById<ImageView>(R.id.floatingImage)
// 拿到父布局的宽高(需要等布局完成)
root.post { root.post {
parentWidth = root.width parentWidth = root.width
parentHeight = root.height parentHeight = root.height
@@ -90,10 +128,44 @@ class HomeFragment : Fragment() {
initDrag(floatingImage, root) initDrag(floatingImage, root)
setupBottomSheet(view) setupBottomSheet(view)
setupViewPager()
setupTopTabs() setupTopTabs()
// 先把 ViewPager / Tags 初始化为空(避免你下面网络回来前被调用多次)
setupViewPager()
setupTags() 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) { private fun initDrag(target: View, parent: ViewGroup) {
var dX = 0f var dX = 0f
@@ -106,14 +178,9 @@ class HomeFragment : Fragment() {
target.setOnTouchListener { v, event -> target.setOnTouchListener { v, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
// 告诉 CoordinatorLayout别拦截这次事件
parent.requestDisallowInterceptTouchEvent(true) parent.requestDisallowInterceptTouchEvent(true)
// 暂时禁止 BottomSheet 拖动 if (::bottomSheetBehavior.isInitialized) bottomSheetBehavior.isDraggable = false
if (::bottomSheetBehavior.isInitialized) {
bottomSheetBehavior.isDraggable = false
}
dX = v.x - event.rawX dX = v.x - event.rawX
dY = v.y - event.rawY dY = v.y - event.rawY
@@ -134,7 +201,6 @@ class HomeFragment : Fragment() {
var newX = event.rawX + dX var newX = event.rawX + dX
var newY = event.rawY + dY var newY = event.rawY + dY
// 限制在父布局范围内
val maxX = parentWidth - v.width val maxX = parentWidth - v.width
val maxY = parentHeight - v.height val maxY = parentHeight - v.height
@@ -150,20 +216,11 @@ class HomeFragment : Fragment() {
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
MotionEvent.ACTION_CANCEL -> {
// 允许父布局继续拦截之后的事件
parent.requestDisallowInterceptTouchEvent(false) parent.requestDisallowInterceptTouchEvent(false)
// 恢复 BottomSheet 可拖动 if (::bottomSheetBehavior.isInitialized) bottomSheetBehavior.isDraggable = true
if (::bottomSheetBehavior.isInitialized) {
bottomSheetBehavior.isDraggable = true
}
if (!isDragging) { if (!isDragging) v.performClick()
v.performClick()
}
// 手指抬起:吸边
snapToEdge(v) snapToEdge(v)
true true
} }
@@ -173,10 +230,6 @@ class HomeFragment : Fragment() {
} }
} }
/**
* 吸边逻辑:左右贴边(需要上下也吸边可以再扩展)
*/
private fun snapToEdge(v: View) { private fun snapToEdge(v: View) {
if (parentWidth == 0 || parentHeight == 0) return if (parentWidth == 0 || parentHeight == 0) return
@@ -184,205 +237,212 @@ class HomeFragment : Fragment() {
val toLeft = centerX < parentWidth / 2f val toLeft = centerX < parentWidth / 2f
val targetX = if (toLeft) 0f else (parentWidth - v.width).toFloat() val targetX = if (toLeft) 0f else (parentWidth - v.width).toFloat()
// 如果你还想限制上下边距,比如离底部留 80dp 不遮挡 BottomSheet可以再处理 y
val minTop = 0f val minTop = 0f
val maxBottom = (parentHeight - v.height).toFloat() val maxBottom = (parentHeight - v.height).toFloat()
val targetY = v.y.coerceIn(minTop, maxBottom) val targetY = v.y.coerceIn(minTop, maxBottom)
v.animate() v.animate().x(targetX).y(targetY).setDuration(200).start()
.x(targetX)
.y(targetY)
.setDuration(200)
.start()
} }
// ---------------- BottomSheet 行为 ----------------
// ---------------- BottomSheet 行为 ----------------
private fun setupBottomSheet(root: View) { private fun setupBottomSheet(root: View) {
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
// 允许拖拽,允许嵌套滚动控制
bottomSheetBehavior.isDraggable = true bottomSheetBehavior.isDraggable = true
bottomSheetBehavior.isHideable = false bottomSheetBehavior.isHideable = false
bottomSheetBehavior.isFitToContents = false bottomSheetBehavior.isFitToContents = false
// 展开时高度占屏幕 70%
bottomSheetBehavior.halfExpandedRatio = 0.7f bottomSheetBehavior.halfExpandedRatio = 0.7f
// 先等布局完成之后,计算“按钮下面剩余空间”作为 peekHeight
root.post { root.post {
val coordinatorHeight = root.height-40 val coordinatorHeight = root.height - 40
val button = root.findViewById<View>(R.id.rechargeButton) val button = root.findViewById<View>(R.id.rechargeButton)
val buttonBottom = button.bottom val peek = (coordinatorHeight - button.bottom).coerceAtLeast(200)
val peek = (coordinatorHeight - buttonBottom).coerceAtLeast(200)
bottomSheetBehavior.peekHeight = peek bottomSheetBehavior.peekHeight = peek
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
// 监听状态变化,用来控制遮罩显示/隐藏 bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
bottomSheetBehavior.addBottomSheetCallback(object :
BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) { override fun onStateChanged(bottomSheet: View, newState: Int) {
when (newState) { scrim.isVisible = newState != BottomSheetBehavior.STATE_COLLAPSED
BottomSheetBehavior.STATE_COLLAPSED -> {
scrim.isVisible = false
}
BottomSheetBehavior.STATE_DRAGGING,
BottomSheetBehavior.STATE_EXPANDED,
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
scrim.isVisible = true
}
else -> {}
}
} }
override fun onSlide(bottomSheet: View, slideOffset: Float) { 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 { scrim.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
// 简单的“空白区域下滑”关闭:在遮罩上响应手势(简单版,只要 move 就关)
scrim.setOnTouchListener { _, event -> scrim.setOnTouchListener { _, event ->
// 这里可以更精细地判断手势方向,这里简单处理为:有滑动就关闭 if (event.action == MotionEvent.ACTION_MOVE) {
// 如果你想更准,可以根据 down / move 的 dy 判断
// 为了示例就写得简单一点
// MotionEvent.ACTION_MOVE = 2
if (event.action == 2) {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
true true
} else { } else false
false
}
} }
// 点击底部盒子的“头部”,在折叠 / 半展开之间切换
header.setOnClickListener { header.setOnClickListener {
when (bottomSheetBehavior.state) { when (bottomSheetBehavior.state) {
BottomSheetBehavior.STATE_COLLAPSED -> { BottomSheetBehavior.STATE_COLLAPSED ->
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
BottomSheetBehavior.STATE_HALF_EXPANDED, BottomSheetBehavior.STATE_HALF_EXPANDED,
BottomSheetBehavior.STATE_EXPANDED -> { BottomSheetBehavior.STATE_EXPANDED ->
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
else -> {} else -> {}
} }
} }
} }
// ---------------- ViewPager2 + 列表 ---------------- // ---------------- ViewPager2 + Tabs ----------------
private var sheetAdapter: SheetPagerAdapter? = null
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
private fun setupViewPager() { private fun setupViewPager() {
val pageCount = 1 + tags.size // 1 = 第一个列表,剩下的是第二个列表的标签页 if (sheetAdapter == null) {
viewPager.adapter = SheetPagerAdapter(pageCount) sheetAdapter = SheetPagerAdapter(1 + tags.size)
viewPager.adapter = sheetAdapter
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { pageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
super.onPageSelected(position) updateTabsAndTags(position)
updateTabsAndTags(position) // 里面会调用 highlightTag把标签高亮并滚动 }
}
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() { private fun setupTopTabs() {
tabList1.setOnClickListener { tabList1.setOnClickListener { viewPager.currentItem = 0 }
viewPager.currentItem = 0 // 列表一
}
tabList2.setOnClickListener { tabList2.setOnClickListener {
viewPager.currentItem = 1 // 列表二的第一个标签页 // 没有标签就别切
if (tags.isNotEmpty()) viewPager.currentItem = 1
} }
} }
// 顶部标签行(只在第二个列表时可见)
private fun setupTags() { private fun setupTags() {
tagContainer.removeAllViews() tagContainer.removeAllViews()
tags.forEachIndexed { index, tag -> tags.forEachIndexed { index, tag ->
val tv = layoutInflater.inflate( val tv = layoutInflater.inflate(R.layout.item_tag, tagContainer, false) as TextView
R.layout.item_tag, tv.text = tag.tagName
tagContainer,
false
) as TextView
tv.text = tag
tv.setOnClickListener { tv.setOnClickListener {
// 当前位置 = 1 + 标签下标 clickedTagId = tag.id
viewPager.currentItem = 1 + index 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) tagContainer.addView(tv)
} }
// 默认选中列表一,所以标签行默认隐藏
tagScroll.isVisible = false tagScroll.isVisible = false
} }
// 根据当前 page 更新上方两个选项 & 标签高亮/显隐
private fun updateTabsAndTags(position: Int) { private fun updateTabsAndTags(position: Int) {
if (position == 0) { if (position == 0) {
tabList1.setTextColor(requireContext().getColor(R.color.black)) tabList1.setTextColor(requireContext().getColor(R.color.black))
tabList2.setTextColor(requireContext().getColor(R.color.light_black)) tabList2.setTextColor(requireContext().getColor(R.color.light_black))
tagScroll.isVisible = false tagScroll.isVisible = false
fadeImage(backgroundImage, R.drawable.option_background) fadeImage(backgroundImage, R.drawable.option_background)
} else { } else {
tabList1.setTextColor(requireContext().getColor(R.color.light_black)) tabList1.setTextColor(requireContext().getColor(R.color.light_black))
tabList2.setTextColor(requireContext().getColor(R.color.black)) tabList2.setTextColor(requireContext().getColor(R.color.black))
tagScroll.isVisible = true tagScroll.isVisible = true
fadeImage(backgroundImage, R.drawable.option_background_two) fadeImage(backgroundImage, R.drawable.option_background_two)
val tagIndex = position - 1 val tagIndex = position - 1
highlightTag(tagIndex) highlightTag(tagIndex)
} }
} }
//背景淡入淡出
private fun fadeImage(imageView: ImageView, newImageRes: Int) { private fun fadeImage(imageView: ImageView, newImageRes: Int) {
val oldDrawable = imageView.drawable val oldDrawable = imageView.drawable
val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes) val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes) ?: return
if (newDrawable == null) {
return
}
// 第一次还没有旧图,直接设置就好
if (oldDrawable == null) { if (oldDrawable == null) {
imageView.setImageDrawable(newDrawable) imageView.setImageDrawable(newDrawable)
return return
} }
val transitionDrawable = TransitionDrawable(arrayOf(oldDrawable, newDrawable)).apply { val transitionDrawable = TransitionDrawable(arrayOf(oldDrawable, newDrawable)).apply {
// 关键:启用交叉淡入淡出,旧图才会一起淡出
isCrossFadeEnabled = true isCrossFadeEnabled = true
} }
imageView.setImageDrawable(transitionDrawable) imageView.setImageDrawable(transitionDrawable)
transitionDrawable.startTransition(300) // 300ms 淡入淡出 transitionDrawable.startTransition(300)
} }
private fun highlightTag(index: Int) { private fun highlightTag(index: Int) {
for (i in 0 until tagContainer.childCount) { for (i in 0 until tagContainer.childCount) {
val child = tagContainer.getChildAt(i) as TextView val child = tagContainer.getChildAt(i) as TextView
if (i == index) { if (i == index) {
child.setBackgroundResource(R.drawable.tag_selected_bg) child.setBackgroundResource(R.drawable.tag_selected_bg)
child.setTextColor(requireContext().getColor(android.R.color.white)) child.setTextColor(requireContext().getColor(android.R.color.white))
// 关键:把选中的标签滚动到可见(这里我用“居中”效果)
tagScroll.post { tagScroll.post {
val scrollViewWidth = tagScroll.width val scrollViewWidth = tagScroll.width
val childCenter = child.left + child.width / 2 val childCenter = child.left + child.width / 2
val targetScrollX = childCenter - scrollViewWidth / 2 val targetScrollX = childCenter - scrollViewWidth / 2
tagScroll.smoothScrollTo(targetScrollX.coerceAtLeast(0), 0) tagScroll.smoothScrollTo(targetScrollX.coerceAtLeast(0), 0)
} }
} else { } else {
child.setBackgroundResource(R.drawable.tag_unselected_bg) child.setBackgroundResource(R.drawable.tag_unselected_bg)
child.setTextColor(requireContext().getColor(R.color.light_black)) child.setTextColor(requireContext().getColor(R.color.light_black))
@@ -390,42 +450,27 @@ class HomeFragment : Fragment() {
} }
} }
// ---------------- ViewPager Adapter ----------------
// ---------------- 共享的 ViewHolder 类 ----------------
inner class PageViewHolder(val recyclerView: RecyclerView) :
RecyclerView.ViewHolder(recyclerView)
// ---------------- ViewPager2 的 Adapter ----------------
/**
* 每一页都是一个 RecyclerView 卡片列表:
* - position = 0列表一数据 A
* - position >= 1列表二的第 index 个标签页(数据 B[index]
*/
inner class SheetPagerAdapter( inner class SheetPagerAdapter(
private val pageCount: Int private var pageCount: Int
) : RecyclerView.Adapter<SheetPagerAdapter.PageViewHolder>() { ) : RecyclerView.Adapter<SheetPagerAdapter.PageViewHolder>() {
inner class PageViewHolder(val root: View) : RecyclerView.ViewHolder(root) inner class PageViewHolder(val root: View) : RecyclerView.ViewHolder(root)
override fun getItemViewType(position: Int): Int { fun updatePageCount(newCount: Int) {
// 0第一个列表页>0第二个列表的各标签页 pageCount = newCount
return if (position == 0) 0 else 1 notifyDataSetChanged()
} }
override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder {
val layoutId = when (viewType) { val layoutId = if (viewType == 0) {
0 -> R.layout.bottom_page_list1 // 第一个列表的自定义内容 R.layout.bottom_page_list1
else -> R.layout.bottom_page_list2 // 第二个列表各标签页的自定义内容 } else {
R.layout.bottom_page_list2
} }
val root = LayoutInflater.from(parent.context).inflate(layoutId, parent, false)
val root = LayoutInflater.from(parent.context)
.inflate(layoutId, parent, false)
// 如果需要,禁用嵌套滚动(对 NestedScrollView 一般问题不大,可以不写)
// root.findViewById<NestedScrollView>(R.id.scrollContent)?.isNestedScrollingEnabled = false
return PageViewHolder(root) return PageViewHolder(root)
} }
@@ -433,26 +478,63 @@ class HomeFragment : Fragment() {
val root = holder.root val root = holder.root
if (position == 0) { if (position == 0) {
// 这里可以拿到 bottom_page_list1 中的控件,做一些初始化 renderList1(root, allPersonaCache)
// val someView = root.findViewById<TextView>(R.id.xxx)
// someView.text = "xxx"
} else { } else {
// // 第二个列表对应的标签页 val rv2 = root.findViewById<RecyclerView>(R.id.recyclerView)
// val tagIndex = position - 1 val loadingView = root.findViewById<View>(R.id.loadingView)
// val tagName = tags[tagIndex]
// // 示例:把标题改成“标签一的内容 / 标签二的内容 ……” rv2.setHasFixedSize(true)
// val titleView = root.findViewById<TextView>(R.id.pageTitle) rv2.itemAnimator = null
// titleView?.text = "$tagName 的自定义内容" 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 tagIndex = position - 1
val scrollContent = root.findViewById<View>(R.id.scrollContent) if (tagIndex !in tags.indices) {
if (scrollContent != null) { loadingView.isVisible = false
setupPullToClose(scrollContent) 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)
}
} }
} }
@@ -460,46 +542,227 @@ class HomeFragment : Fragment() {
} }
// 通过 tagIndex 取出该页要显示的数据
private fun getPersonaListByTagIndex(tagIndex: Int): List<listByTagWithNotLogin> {
private fun setupPullToClose(scrollable: View) { if (tagIndex !in tags.indices) return emptyList()
var downY = 0f val tagId = tags[tagIndex].id
var isDraggingToClose = false return personaCache[tagId] ?: emptyList()
scrollable.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downY = event.rawY
isDraggingToClose = false
} }
MotionEvent.ACTION_MOVE -> { private fun renderList1(root: View, list: List<listByTagWithNotLogin>) {
// 已经是折叠状态,不拦截,交给内容自己滚(其实也滚不动多少) // 1) 排序rank 小的排前面
if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { val sorted = list.sortedBy { it.rank ?: Int.MAX_VALUE }
return@setOnTouchListener false
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<LinearLayout>(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)
itemView.findViewById<TextView>(R.id.tv_rank).text = (p.rank ?: "--").toString()
itemView.findViewById<TextView>(R.id.tv_name).text = p.characterName ?: ""
itemView.findViewById<TextView>(R.id.tv_desc).text = p.characterBackground ?: ""
// 头像
val iv = itemView.findViewById<de.hdodenhof.circleimageview.CircleImageView>(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")
} }
val dy = event.rawY - downY // 只点“添加”按钮
itemView.findViewById<View>(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) {
// 处理错误
}
}
}
}
if (!scrollable.canScrollVertically(-1) && // 已在顶部 container.addView(itemView)
dy > dragToCloseThreshold && // 向下拉超过阈值 }
(bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED || }
bottomSheetBehavior.state == BottomSheetBehavior.STATE_HALF_EXPANDED)
private fun bindTopItem(
root: View,
avatarId: Int,
nameId: Int,
addBtnId: Int,
container: Int,
item: listByTagWithNotLogin?
) { ) {
isDraggingToClose = true val avatar = root.findViewById<de.hdodenhof.circleimageview.CircleImageView>(avatarId)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED val name = root.findViewById<TextView>(nameId)
return@setOnTouchListener true val addBtn = root.findViewById<View>(addBtnId)
val container = root.findViewById<LinearLayout>(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) {
// 处理错误
}
}
} }
} }
MotionEvent.ACTION_UP, container.setOnClickListener {
MotionEvent.ACTION_CANCEL -> { val id = item.id
isDraggingToClose = false Log.d("HomeFragment", "list1 top click id=$id rank=${item.rank}")
PersonaDetailDialogFragment
.newInstance(id)
.show(childFragmentManager, "persona_detail")
} }
} }
isDraggingToClose
// ---------------- 网络请求 ----------------
private suspend fun fetchPersonaByTag(tagId: Int): List<listByTagWithNotLogin> {
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<listByTagWithNotLogin> {
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<List<listByTagWithNotLogin>>? {
return try {
RetrofitClient.apiService.personaListByTag(id)
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "未登录用户按标签查询人设列表", e)
null
}
}
suspend fun loggedInGetpersonaLis(id: Int): ApiResponse<List<listByTagWithNotLogin>>? {
return try {
RetrofitClient.apiService.loggedInPersonaListByTag(id)
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "登录用户按标签查询人设列表", e)
null
}
}
private fun isLoggedIn(): Boolean {
return EncryptedSharedPreferencesUtil.contains(requireContext(), "user")
}
} }

View File

@@ -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<List1Adapter.VH>() {
private val items = mutableListOf<String>()
fun submitList(list: List<String>) {
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
}

View File

@@ -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<PersonaAdapter.VH>() {
private val items = mutableListOf<listByTagWithNotLogin>()
fun submitList(list: List<listByTagWithNotLogin>) {
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
}

View File

@@ -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<ImageView>(R.id.btnClose)
val ivAvatar = view.findViewById<de.hdodenhof.circleimageview.CircleImageView>(R.id.ivAvatar)
val tvName = view.findViewById<TextView>(R.id.tvName)
val tvBackground = view.findViewById<TextView>(R.id.tvBackground)
val btnAdd = view.findViewById<TextView>(R.id.btnAdd)
val download = view.findViewById<TextView>(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
)
}
}

View File

@@ -1,15 +1,61 @@
package com.example.myapplication.ui.keyboard package com.example.myapplication.ui.keyboard
import android.app.Dialog
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import android.widget.Button
import android.widget.FrameLayout 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.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() { 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -21,8 +67,454 @@ class KeyboardDetailFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
shapeableImageView = view.findViewById<ShapeableImageView>(R.id.iv_keyboard)
tvKeyboardName = view.findViewById<TextView>(R.id.tv_keyboard_name)
tvDownloadCount = view.findViewById<TextView>(R.id.tv_download_count)
layoutTagsContainer = view.findViewById<LinearLayout>(R.id.layout_tags_container)
recyclerRecommendList = view.findViewById<RecyclerView>(R.id.recycler_recommend_list)
tvPrice = view.findViewById<TextView>(R.id.tv_price)
rechargeButton = view.findViewById<LinearLayout>(R.id.rechargeButton)
enabledButton = view.findViewById<LinearLayout>(R.id.enabledButton)
enabledButtonText = view.findViewById<TextView>(R.id.enabledButtonText)
progressBar = view.findViewById<android.widget.ProgressBar>(R.id.progressBar)
swipeRefreshLayout = view.findViewById<SwipeRefreshLayout>(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<FrameLayout>(R.id.iv_close).setOnClickListener { view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack() 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<SubjectTag>) {
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<themeDetail>? {
return try {
RetrofitClient.apiService.themeDetail(themeId)
} catch (e: Exception) {
Log.e("KeyboardDetailFragment", "获取主题详情失败", e)
null
}
}
private suspend fun setrestoreTheme(themeId: Int): ApiResponse<Unit>? {
return try {
RetrofitClient.apiService.restoreTheme(themeId)
} catch (e: Exception) {
Log.e("KeyboardDetailFragment", "恢复已删除的主题失败", e)
null
}
}
private suspend fun getrecommendThemeList(): ApiResponse<List<themeStyle>>? {
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<TextView>(R.id.btn_confirm).setOnClickListener {
// 确认购买逻辑
viewLifecycleOwner.lifecycleScope.launch {
setpurchaseTheme(themeId)
dialog.dismiss()
}
}
dialog.findViewById<TextView>(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")
} }
} }

View File

@@ -17,6 +17,7 @@ import com.example.myapplication.R
import com.example.myapplication.network.ApiResponse import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.LoginRequest import com.example.myapplication.network.LoginRequest
import com.example.myapplication.network.RetrofitClient import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import android.widget.Toast import android.widget.Toast
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,9 +28,16 @@ class LoginFragment : Fragment() {
private lateinit var toggleImageView: ImageView private lateinit var toggleImageView: ImageView
private lateinit var loginButton: TextView private lateinit var loginButton: TextView
private lateinit var emailEditText: EditText private lateinit var emailEditText: EditText
private var loadingOverlay: com.example.myapplication.ui.common.LoadingOverlay? = null//加载遮罩层
private var isPasswordVisible = false private var isPasswordVisible = false
override fun onDestroyView() {
loadingOverlay?.remove()
loadingOverlay = null
super.onDestroyView()
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -40,6 +48,7 @@ class LoginFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
loadingOverlay = com.example.myapplication.ui.common.LoadingOverlay.attach(view as ViewGroup)
// 注册 // 注册
view.findViewById<TextView>(R.id.tv_signup).setOnClickListener { view.findViewById<TextView>(R.id.tv_signup).setOnClickListener {
@@ -51,7 +60,11 @@ class LoginFragment : Fragment() {
} }
// 返回按钮 // 返回按钮
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener { view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack() findNavController().previousBackStackEntry
?.savedStateHandle
?.set("from_login", true)
findNavController().popBackStack()
} }
// 绑定控件id 必须和 xml 里的一样) // 绑定控件id 必须和 xml 里的一样)
passwordEditText = view.findViewById(R.id.et_password) passwordEditText = view.findViewById(R.id.et_password)
@@ -59,6 +72,11 @@ class LoginFragment : Fragment() {
toggleImageView = view.findViewById(R.id.iv_toggle) 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 = passwordEditText.inputType =
@@ -91,6 +109,7 @@ class LoginFragment : Fragment() {
// 输入框不能为空 // 输入框不能为空
Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show()
} else { } else {
loadingOverlay?.show()
// 调用登录API // 调用登录API
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@@ -99,11 +118,19 @@ class LoginFragment : Fragment() {
password = pwd password = pwd
) )
val response = RetrofitClient.apiService.login(loginRequest) val response = RetrofitClient.apiService.login(loginRequest)
Log.d("1314520-LoginFragment", "登录API响应: $response") // 存储登录响应
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) { } catch (e: Exception) {
Log.e("1314520-LoginFragment", "登录请求失败: ${e.message}", e) Log.e("1314520-LoginFragment", "登录请求失败: ${e.message}", e)
Toast.makeText(requireContext(), "登录失败: ${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Login failed: ${e.message}", Toast.LENGTH_SHORT).show()
loadingOverlay?.hide()
} }
} }
} }

View File

@@ -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<View>(R.id.btn_cancel).setOnClickListener {
dismiss()
}
dialog.findViewById<View>(R.id.btn_logout).setOnClickListener {
dismiss()
onConfirm()
}
return dialog
}
}

View File

@@ -6,13 +6,27 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.example.myapplication.R import com.example.myapplication.R
import android.widget.LinearLayout import android.widget.LinearLayout
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.LoginResponse
import de.hdodenhof.circleimageview.CircleImageView 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() { class MineFragment : Fragment() {
private lateinit var nickname: TextView
private lateinit var time: TextView
private lateinit var logout: TextView
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -25,6 +39,78 @@ class MineFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// 判断是否登录(门禁)
if (!isLoggedIn()) {
val nav = findNavController()
// 改用 savedStateHandle 的标记LoginFragment 返回时写入
val fromLogin = nav.currentBackStackEntry
?.savedStateHandle
?.get<Boolean>("from_login") == true
// 用完就清掉
nav.currentBackStackEntry?.savedStateHandle?.remove<Boolean>("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<ImageView>(R.id.imgLeft).setOnClickListener { view.findViewById<ImageView>(R.id.imgLeft).setOnClickListener {
findNavController().navigate(R.id.action_global_rechargeFragment) findNavController().navigate(R.id.action_global_rechargeFragment)
@@ -56,9 +142,12 @@ class MineFragment : Fragment() {
} }
//隐私政策 //隐私政策
view.findViewById<LinearLayout>(R.id.click_Privacy).setOnClickListener { // view.findViewById<LinearLayout>(R.id.click_Privacy).setOnClickListener {
findNavController().navigate(R.id.action_mineFragment_to_loginFragment) // findNavController().navigate(R.id.action_mineFragment_to_loginFragment)
} // }
} }
private fun isLoggedIn(): Boolean {
return EncryptedSharedPreferencesUtil.contains(requireContext(), "user")
}
} }

View File

@@ -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)
}
}

View File

@@ -1,168 +1,261 @@
package com.example.myapplication.ui.shop 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.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.util.Log
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.HorizontalScrollView import android.widget.HorizontalScrollView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController 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 androidx.viewpager2.widget.ViewPager2
import com.example.myapplication.R import com.example.myapplication.R
import androidx.fragment.app.FragmentActivity import com.example.myapplication.network.ApiResponse
import androidx.viewpager2.adapter.FragmentStateAdapter import com.example.myapplication.network.RetrofitClient
import android.graphics.Color import com.example.myapplication.network.Theme
import android.graphics.drawable.GradientDrawable import com.example.myapplication.network.Wallet
import android.animation.ValueAnimator import com.example.myapplication.network.themeStyle
import android.animation.ArgbEvaluator import kotlinx.coroutines.launch
class ShopFragment : Fragment() { class ShopFragment : Fragment(R.layout.fragment_shop) {
private lateinit var viewPager: ViewPager2 private lateinit var viewPager: ViewPager2
private lateinit var tagScroll: HorizontalScrollView private lateinit var tagScroll: HorizontalScrollView
private lateinit var tagContainer: LinearLayout private lateinit var tagContainer: LinearLayout
private lateinit var balance: TextView
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 标签标题,可以根据需要修改 // 风格 tabs
private val tabTitles = listOf("全部", "数码", "服饰", "家居", "美食","数码", "服饰", "家居", "美食") private var tabTitles: List<Theme> = emptyList()
private var styleIds: List<Int> = emptyList()
override fun onCreateView( // ✅ 共享数据/缓存/加载都交给 VM
inflater: LayoutInflater, private val vm: ShopViewModel by viewModels()
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_shop, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// 金币充值按钮点击
view.findViewById<View>(R.id.rechargeButton).setOnClickListener { view.findViewById<View>(R.id.rechargeButton).setOnClickListener {
findNavController().navigate(R.id.action_global_goldCoinRechargeFragment) findNavController().navigate(R.id.action_global_goldCoinRechargeFragment)
} }
// 我的皮肤按钮点击
view.findViewById<View>(R.id.skinButton).setOnClickListener { view.findViewById<View>(R.id.skinButton).setOnClickListener {
findNavController().navigate(R.id.action_shopfragment_to_myskin) findNavController().navigate(R.id.action_shopfragment_to_myskin)
} }
// 搜索按钮点击
view.findViewById<View>(R.id.searchButton).setOnClickListener { view.findViewById<View>(R.id.searchButton).setOnClickListener {
findNavController().navigate(R.id.action_shopfragment_to_searchfragment) findNavController().navigate(R.id.action_shopfragment_to_searchfragment)
} }
tagScroll = view.findViewById(R.id.tagScroll) tagScroll = view.findViewById(R.id.tagScroll)
tagContainer = view.findViewById(R.id.tagContainer) tagContainer = view.findViewById(R.id.tagContainer)
viewPager = view.findViewById(R.id.viewPager) viewPager = view.findViewById(R.id.viewPager)
val rechargeButton = view.findViewById<LinearLayout>(R.id.rechargeButton) balance = view.findViewById(R.id.balance)
rechargeButton.setOnClickListener { swipeRefreshLayout = view.findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
findNavController().navigate(R.id.action_global_goldCoinRechargeFragment)
}
// 1. 设置 ViewPager2 的 Adapter
viewPager.adapter = ShopPagerAdapter(this, tabTitles.size)
// 2. 创建顶部标签 // 设置下拉刷新监听器
swipeRefreshLayout.setOnRefreshListener {
refreshData()
}
// 设置刷新指示器颜色
swipeRefreshLayout.setColorSchemeColors(
Color.parseColor("#02BEAC"),
Color.parseColor("#1B1F1A"),
Color.parseColor("#9F9F9F")
)
// 禁用默认的刷新行为,使用自定义逻辑
swipeRefreshLayout.isEnabled = false
// 设置 ViewPager 的子页面滚动监听
setupViewPagerScrollListener()
loadInitialData()
// 修复 ViewPager2 和 SwipeRefreshLayout 的手势冲突
fixViewPager2SwipeConflict()
}
private fun loadInitialData() {
viewLifecycleOwner.lifecycleScope.launch {
val walletResp = getwalletBalance()
val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString()
balance.text = balanceText
adjustBalanceTextSize(balanceText)
val themeListResp = getThemeList()
tabTitles = themeListResp?.data ?: emptyList()
Log.d("1314520-Shop", "风格列表: $tabTitles")
styleIds = tabTitles.map { it.id }
viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds)
setupTags()
setupViewPager()
// ✅ 默认加载第一个(交给 VM
viewPager.post {
styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) }
}
}
}
/**
* 根据字符数量调整余额文本的字体大小
* 字符数量越多,字体越小
*/
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
}
private fun refreshData() {
viewLifecycleOwner.lifecycleScope.launch {
try {
// 重新获取钱包余额
val walletResp = getwalletBalance()
val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString()
balance.text = balanceText
adjustBalanceTextSize(balanceText)
// 重新获取主题列表
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() setupTags()
// 3. 绑定 ViewPager2 滑动 & 标签联动 // 通知 ViewModel 清除缓存
setupViewPager() vm.clearCache()
// 强制重新加载所有页面的数据
styleIds.forEach { styleId ->
// 强制重新加载,即使有缓存也要重新获取
vm.forceLoadStyle(styleId)
}
} else {
// 主题列表没有变化,强制重新加载当前页面的数据
val currentPosition = viewPager.currentItem
styleIds.getOrNull(currentPosition)?.let { vm.forceLoadStyle(it) }
} }
/** 动态创建标签 TextView */ Log.d("1314520-Shop", "下拉刷新完成")
private fun setupTags() { } catch (e: Exception) {
Log.e("1314520-Shop", "下拉刷新失败", e)
} finally {
// 停止刷新动画
swipeRefreshLayout.isRefreshing = false
}
}
}
/** 子页读取缓存(从 VM 读) */
fun getCachedList(styleId: Int): List<themeStyle> = vm.getCached(styleId)
/** 动态创建标签 */
private fun setupTags() {
tagContainer.removeAllViews() tagContainer.removeAllViews()
val context = requireContext() val context = requireContext()
val density = context.resources.displayMetrics.density val density = context.resources.displayMetrics.density
val paddingHorizontal = (16 * density).toInt()
// ⬇⬇⬇ 你要求的 padding 值(已适配 dp val paddingVertical = (6 * density).toInt()
val paddingHorizontal = (16 * density).toInt() // 左右 16dp val marginEnd = (8 * density).toInt()
val paddingVertical = (6 * density).toInt() // 上下 6dp
val marginEnd = (8 * density).toInt() // 标签之间 8dp 间距
tabTitles.forEachIndexed { index, title -> tabTitles.forEachIndexed { index, title ->
val tv = TextView(context).apply { val tv = TextView(context).apply {
text = title text = title.styleName
textSize = 12f // 字体大小 12sp textSize = 12f
setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
// ✅ 设置内边距左右16dp上下6dp
setPadding(
paddingHorizontal,
paddingVertical,
paddingHorizontal,
paddingVertical
)
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT LinearLayout.LayoutParams.MATCH_PARENT
).apply { ).apply { setMargins(0, 0, marginEnd, 0) }
setMargins(0, 0, marginEnd, 0) // 右侧 8dp 间距
}
gravity = android.view.Gravity.CENTER gravity = android.view.Gravity.CENTER
// 胶囊大圆角背景
background = createCapsuleBackground() background = createCapsuleBackground()
// 初始化选中状态
isSelected = index == 0 isSelected = index == 0
updateTagStyleNoAnim(this, isSelected) // 初始化不用动画,避免闪烁 updateTagStyleNoAnim(this, isSelected)
// 点击切换页面
setOnClickListener { setOnClickListener {
if (viewPager.currentItem != index) { if (viewPager.currentItem != index) viewPager.currentItem = index
viewPager.currentItem = index
} }
} }
}
tagContainer.addView(tv) tagContainer.addView(tv)
} }
} }
private fun createCapsuleBackground(): GradientDrawable { private fun createCapsuleBackground(): GradientDrawable {
val density = resources.displayMetrics.density val density = resources.displayMetrics.density
return GradientDrawable().apply { return GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = 50f * density // 大圆角 cornerRadius = 50f * density
setColor(Color.parseColor("#F1F1F1")) // 默认未选中背景 setColor(Color.parseColor("#F1F1F1"))
setStroke((2 * density).toInt(), Color.parseColor("#F1F1F1")) setStroke((2 * density).toInt(), Color.parseColor("#F1F1F1"))
} }
} }
/** 设置 ViewPager2 的监听,实现滑动联动标签 */
private fun setupViewPager() { private fun setupViewPager() {
// ✅ 只设置一次
viewPager.offscreenPageLimit = 1
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
super.onPageSelected(position) super.onPageSelected(position)
updateTagState(position) updateTagState(position)
// ✅ 切换到某页就按需加载(交给 VM
styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) }
} }
}) })
} }
/** 根据当前位置更新所有标签的选中状态 */
private fun updateTagState(position: Int) { private fun updateTagState(position: Int) {
for (i in 0 until tagContainer.childCount) { for (i in 0 until tagContainer.childCount) {
val child = tagContainer.getChildAt(i) as TextView val child = tagContainer.getChildAt(i) as TextView
val newSelected = i == position val newSelected = i == position
// ✅ 如果这个标签的选中状态没有变化,就不要动它,避免“闪一下”
if (child.isSelected == newSelected) continue if (child.isSelected == newSelected) continue
child.isSelected = newSelected child.isSelected = newSelected
updateTagStyleWithAnim(child, newSelected) updateTagStyleWithAnim(child, newSelected)
if (newSelected) { if (newSelected) {
// 让选中项尽量居中显示
child.post { child.post {
val scrollX = child.left - (tagScroll.width - child.width) / 2 val scrollX = child.left - (tagScroll.width - child.width) / 2
tagScroll.smoothScrollTo(scrollX, 0) tagScroll.smoothScrollTo(scrollX, 0)
@@ -171,83 +264,24 @@ private fun setupTags() {
} }
} }
private fun setupViewPagerScrollListener() {
/** 统一控制标签样式,可根据自己项目主题改颜色/大小 **/ // 监听 AppBarLayout 的展开状态来判断是否在顶部
private fun updateTagStyle(textView: TextView, selected: Boolean) { view?.findViewById<com.google.android.material.appbar.AppBarLayout>(R.id.appBar)?.addOnOffsetChangedListener { appBarLayout, verticalOffset ->
val context = textView.context val isAtTop = verticalOffset == 0
val density = context.resources.displayMetrics.density swipeRefreshLayout.isEnabled = isAtTop
// 确保背景是 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
}
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()
// 字重变化 @SuppressLint("ClickableViewAccessibility")
textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) 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
/** 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}")
} }
} }
@@ -255,18 +289,17 @@ private fun setupTags() {
val density = resources.displayMetrics.density val density = resources.displayMetrics.density
val bg = (textView.background as? GradientDrawable) val bg = (textView.background as? GradientDrawable)
?: createCapsuleBackground().also { textView.background = it } ?: createCapsuleBackground().also { textView.background = it }
val strokeWidth = (2 * density).toInt() val strokeWidth = (2 * density).toInt()
if (selected) { if (selected) {
bg.setColor(Color.parseColor("#FFFFFF")) // 背景白色 bg.setColor(Color.parseColor("#FFFFFF"))
bg.setStroke(strokeWidth, Color.parseColor("#02BEAC")) // 边框 #02BEAC bg.setStroke(strokeWidth, Color.parseColor("#02BEAC"))
textView.setTextColor(Color.parseColor("#1B1F1A")) // 字体 #1B1F1A textView.setTextColor(Color.parseColor("#1B1F1A"))
textView.setTypeface(null, Typeface.BOLD) textView.setTypeface(null, Typeface.BOLD)
} else { } else {
bg.setColor(Color.parseColor("#F1F1F1")) // 背景 #F1F1F1 bg.setColor(Color.parseColor("#F1F1F1"))
bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1")) // 边框 #F1F1F1 bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1"))
textView.setTextColor(Color.parseColor("#9F9F9F")) // 字体 #9F9F9F textView.setTextColor(Color.parseColor("#9F9F9F"))
textView.setTypeface(null, Typeface.NORMAL) textView.setTypeface(null, Typeface.NORMAL)
} }
} }
@@ -275,71 +308,114 @@ private fun setupTags() {
val density = resources.displayMetrics.density val density = resources.displayMetrics.density
val bg = (textView.background as? GradientDrawable) val bg = (textView.background as? GradientDrawable)
?: createCapsuleBackground().also { textView.background = it } ?: createCapsuleBackground().also { textView.background = it }
val strokeWidth = (2 * density).toInt() val strokeWidth = (2 * density).toInt()
// 颜色配置
val selectedTextColor = Color.parseColor("#1B1F1A") val selectedTextColor = Color.parseColor("#1B1F1A")
val unselectedTextColor = Color.parseColor("#9F9F9F") val unselectedTextColor = Color.parseColor("#9F9F9F")
val selectedStrokeColor = Color.parseColor("#02BEAC") val selectedStrokeColor = Color.parseColor("#02BEAC")
val unselectedStrokeColor = Color.parseColor("#F1F1F1") val unselectedStrokeColor = Color.parseColor("#F1F1F1")
val selectedBgColor = Color.parseColor("#FFFFFF") val selectedBgColor = Color.parseColor("#FFFFFF")
val unselectedBgColor = Color.parseColor("#F1F1F1") val unselectedBgColor = Color.parseColor("#F1F1F1")
// 起点、终点颜色我们自己定义,而不是乱读当前值,避免抖动 val colorsArray = if (selected) {
val startTextColor: Int arrayOf(
val endTextColor: Int unselectedTextColor, selectedTextColor,
val startStrokeColor: Int unselectedStrokeColor, selectedStrokeColor,
val endStrokeColor: Int unselectedBgColor, selectedBgColor
val startBgColor: Int )
val endBgColor: Int
if (selected) {
// 未选中 -> 选中
startTextColor = unselectedTextColor
endTextColor = selectedTextColor
startStrokeColor = unselectedStrokeColor
endStrokeColor = selectedStrokeColor
startBgColor = unselectedBgColor
endBgColor = selectedBgColor
} else { } else {
// 选中 -> 未选中 arrayOf(
startTextColor = selectedTextColor selectedTextColor, unselectedTextColor,
endTextColor = unselectedTextColor selectedStrokeColor, unselectedStrokeColor,
selectedBgColor, unselectedBgColor
startStrokeColor = selectedStrokeColor )
endStrokeColor = unselectedStrokeColor
startBgColor = selectedBgColor
endBgColor = 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 evaluator = ArgbEvaluator()
ValueAnimator.ofFloat(0f, 1f).apply {
val animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 200L duration = 200L
addUpdateListener { va -> addUpdateListener { va ->
val fraction = va.animatedFraction val f = va.animatedFraction
textView.setTextColor(evaluator.evaluate(f, startTextColor, endTextColor) as Int)
val currentTextColor = bg.setStroke(strokeWidth, evaluator.evaluate(f, startStrokeColor, endStrokeColor) as Int)
evaluator.evaluate(fraction, startTextColor, endTextColor) as Int bg.setColor(evaluator.evaluate(f, startBgColor, endBgColor) 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)
} }
start()
} }
animator.start()
textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL)
} }
private inner class ShopPagerAdapter(
fragment: Fragment,
private val styleIds: List<Int>
) : 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<Wallet>? {
return try {
RetrofitClient.apiService.walletBalance()
} catch (e: Exception) {
Log.e("1314520-ShopFragment", "获取钱包余额失败", e)
null
}
}
private suspend fun getThemeList(): ApiResponse<List<Theme>>? {
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)
// }
} }

View File

@@ -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) }
}
}
}

View File

@@ -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<Map<Int, List<themeStyle>>>(emptyMap())
val styleData: StateFlow<Map<Int, List<themeStyle>>> = _styleData
private val inFlight = mutableSetOf<Int>()
fun getCached(styleId: Int): List<themeStyle> = _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)
}
}
}
}

View File

@@ -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<TextView>(R.id.textView).text = text
}
}

View File

@@ -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<themeStyle, ThemeCardAdapter.ThemeCardViewHolder>(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<themeStyle>() {
override fun areItemsTheSame(oldItem: themeStyle, newItem: themeStyle): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: themeStyle, newItem: themeStyle): Boolean {
return oldItem == newItem
}
}
}
}

View File

@@ -1,15 +1,30 @@
package com.example.myapplication.ui.shop.myskin package com.example.myapplication.ui.shop.myskin
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment
import android.widget.FrameLayout 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.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() { class MySkin : Fragment() {
private lateinit var adapter: MySkinAdapter
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -21,8 +36,150 @@ class MySkin : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val tvEditor = view.findViewById<TextView>(R.id.tvEditor)
val bottomBar = view.findViewById<View>(R.id.bottomEditBar)
val tvSelectedCount = view.findViewById<TextView>(R.id.tvSelectedCount)
val btnDelete = view.findViewById<TextView>(R.id.btnDelete)
swipeRefreshLayout = view.findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 设置下拉刷新监听器
swipeRefreshLayout.setOnRefreshListener {
refreshData()
}
// 设置刷新指示器颜色
swipeRefreshLayout.setColorSchemeColors(
Color.parseColor("#02BEAC"),
Color.parseColor("#1B1F1A"),
Color.parseColor("#9F9F9F")
)
// 返回
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener { view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
} }
val rv = view.findViewById<RecyclerView>(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()
}
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<List<themeStyle>>? {
return try { RetrofitClient.apiService.purchasedThemeList() }
catch (e: Exception) { Log.e("MySkin", "获取已购买主题失败", e); null }
}
private suspend fun batchDeleteThemes(themeIds: List<Int>): ApiResponse<Boolean>? {
val request = deleteThemeRequest(
themeIds = themeIds
)
return try { RetrofitClient.apiService.batchDeleteUserTheme(request) }
catch (e: Exception) { Log.e("MySkin", "批量删除主题失败", e); null }
}
/**
* 删除本地主题文件
*/
private fun deleteLocalThemeFiles(themeIds: List<Int>) {
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)
}
} }
} }

View File

@@ -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<MySkinAdapter.VH>() {
private val items = mutableListOf<themeStyle>()
private val selectedIds = mutableSetOf<Int>()
var editMode: Boolean = false
private set
fun submitList(list: List<themeStyle>) {
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<Int> = selectedIds.toList()
fun removeByIds(ids: Set<Int>) {
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)
}
}
}
}

View File

@@ -3,18 +3,26 @@ package com.example.myapplication.ui.shop.search
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.example.myapplication.R import androidx.lifecycle.lifecycleScope
import com.google.android.flexbox.FlexboxLayout
import com.google.android.flexbox.FlexboxLayout.LayoutParams
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.core.os.bundleOf 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 lateinit var etInput: EditText
private val prefsName = "search_history" private val prefsName = "search_history"
private lateinit var historySection: LinearLayout private lateinit var historySection: LinearLayout
private lateinit var recyclerRecommendList: RecyclerView
private lateinit var themeCardAdapter: ThemeCardAdapter
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -35,20 +45,34 @@ class SearchFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) 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<FrameLayout>(R.id.iv_close).setOnClickListener { view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
} }
// 详情跳转
view.findViewById<CardView>(R.id.card_view).setOnClickListener {
findNavController().navigate(R.id.action_global_keyboardDetailFragment)
}
historySection = view.findViewById(R.id.layout_history_section) historySection = view.findViewById(R.id.layout_history_section)
historyLayout = view.findViewById(R.id.layout_history_list) historyLayout = view.findViewById(R.id.layout_history_list)
etInput = view.findViewById(R.id.et_input) etInput = view.findViewById(R.id.et_input)
recyclerRecommendList = view.findViewById(R.id.recycler_recommend_list)
// 初始化RecyclerView
setupRecyclerView()
// 加载历史记录 // 加载历史记录
loadHistory() loadHistory()
@@ -145,5 +169,27 @@ class SearchFragment : Fragment() {
historyLayout.removeAllViews() historyLayout.removeAllViews()
historySection.visibility = View.GONE 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<List<themeStyle>>? {
return try {
RetrofitClient.apiService.recommendThemeList()
} catch (e: Exception) {
Log.e("SearchFragment", "获取推荐列表失败", e)
null
}
}
} }

View File

@@ -1,17 +1,34 @@
package com.example.myapplication.ui.shop.search package com.example.myapplication.ui.shop.search
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment 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.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() { class SearchResultFragment : Fragment() {
private lateinit var etInput: EditText 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -30,10 +47,79 @@ class SearchResultFragment : Fragment() {
} }
etInput = view.findViewById(R.id.et_input) etInput = view.findViewById(R.id.et_input)
recyclerSearchResults = view.findViewById(R.id.recycler_search_results)
tvSearch = view.findViewById<TextView>(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") ?: "" val keyword = arguments?.getString("search_keyword") ?: ""
etInput.setText(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<themeStyle>?) {
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<List<themeStyle>>? {
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)
} }
} }

View File

@@ -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 <T> get(context: Context, key: String, clazz: Class<T>): 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<User>、Map<String, Any> 等)
* 用法object : TypeToken<List<User>>() {}.type
*/
fun <T> 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<T>(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", "全部清除成功")
}
}

View File

@@ -1,64 +1,276 @@
package com.example.myapplication.utils 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.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
private const val TAG_UNZIP = "1314520-unzip"
private const val TAG_ZIPLIST = "1314520-ziplist"
// @param zipInputStream zip 的输入流(网络/本地/asset都行 /* =========================
// @param targetDir 解压目标目录 * 1⃣ 打印 zip 内容(调试用)
// @param overwrite 是否覆盖同名文件(默认 true存在则跳过 * ========================= */
fun logZipEntries(zipFile: File) {
fun unzipToDir( Log.e(TAG_ZIPLIST, "========== ZIP CONTENT START ==========")
zipInputStream: InputStream, Log.e(TAG_ZIPLIST, "zipPath=${zipFile.absolutePath} size=${zipFile.length()}")
targetDir: File,
overwrite: Boolean = true
) {
// 确保目标目录存在
if (!targetDir.exists()) targetDir.mkdirs()
// 规范化目标目录路径(用于 Zip Slip 防护)
val canonicalDirPath = targetDir.canonicalFile.path + File.separator
ZipInputStream(BufferedInputStream(zipInputStream)).use { zis ->
val buffer = ByteArray(4096)
try {
FileInputStream(zipFile).use { fis ->
ZipInputStream(BufferedInputStream(fis)).use { zis ->
var count = 0
while (true) { while (true) {
val entry: ZipEntry = zis.nextEntry ?: break val entry = zis.nextEntry ?: break
count++
// entry.name 可能包含 ../ 之类的路径,必须防护 Log.e(
val outFile = File(targetDir, entry.name) TAG_ZIPLIST,
"[$count] ${entry.name} dir=${entry.isDirectory}"
// ===== Zip Slip 防护:确保输出文件仍在 targetDir 内 ===== )
val canonicalOutPath = outFile.canonicalFile.path
if (!canonicalOutPath.startsWith(canonicalDirPath)) {
// 越界路径:直接跳过
zis.closeEntry() zis.closeEntry()
continue }
Log.e(TAG_ZIPLIST, "total entries=$count")
}
}
} catch (e: Exception) {
Log.e(TAG_ZIPLIST, "read zip failed: ${e.message}", e)
} }
if (entry.isDirectory) { Log.e(TAG_ZIPLIST, "========== ZIP CONTENT END ==========")
outFile.mkdirs() }
} else {
outFile.parentFile?.mkdirs()
// 覆盖策略:默认不覆盖(存在就跳过) fun detectArchiveType(file: File): String {
if (outFile.exists() && !overwrite) { FileInputStream(file).use { fis ->
zis.closeEntry() val header = ByteArray(8)
continue val read = fis.read(header)
} if (read < 4) return "UNKNOWN"
FileOutputStream(outFile).use { fos -> fun hex(vararg b: Int) =
while (true) { b.map { it.toByte() }.toByteArray().contentEquals(header.copyOf(b.size))
val count = zis.read(buffer)
if (count == -1) break
fos.write(buffer, 0, count)
}
fos.flush()
}
}
zis.closeEntry() 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()
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E53935"/>
<corners android:radius="18dp"/>
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/white"/>
<corners android:radius="16dp"/>
</shape>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 选中 -->
<item android:state_selected="true">
<shape android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="#1A000000" />
</shape>
</item>
<!-- 未选中 -->
<item>
<shape android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 选中 -->
<item android:state_selected="true">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="#1A2563EB" />
</shape>
</item>
<!-- 未选中 -->
<item>
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="#0F000000" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F1F1F1" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#02BEAC" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,15 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 半透明白色(玻璃感核心) -->
<solid android:color="#66FFFFFF" /> <!-- 40% 透明白 -->
<!-- 圆角(玻璃一般有圆角) -->
<corners
android:radius="4dp" />
<!-- 白色半透明描边,增加玻璃边缘感 -->
<stroke
android:width="1dp"
android:color="#66FFFFFF" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF"/>
<corners android:radius="22dp"/>
</shape>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="oval">
<solid android:color="#222222"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="#33000000"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F1F1F1"/>
<corners android:radius="50dp"/>
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="50dp" />
<solid android:color="#000000" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F8F8F8"/>
<corners android:radius="10dp"/>
</shape>

View File

@@ -10,6 +10,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="52dp"
android:id="@+id/keyboard_container" android:id="@+id/keyboard_container"
android:layout_marginTop="3dp"
android:paddingStart="12dp" android:paddingStart="12dp"
android:paddingEnd="8dp"> android:paddingEnd="8dp">
<ImageView <ImageView
@@ -35,6 +36,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:id="@+id/keyboard_row_1" android:id="@+id/keyboard_row_1"
android:paddingStart="4dp" android:paddingStart="4dp"
android:paddingEnd="4dp" android:paddingEnd="4dp"

View File

@@ -2,7 +2,7 @@
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/scrollContent" android:id="@+id/rvList1"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"> android:fillViewport="true">
@@ -22,6 +22,7 @@
android:orientation="horizontal"> android:orientation="horizontal">
<!-- 第2名 --> <!-- 第2名 -->
<LinearLayout <LinearLayout
android:id="@+id/container_second"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal" android:gravity="center_horizontal"
@@ -29,6 +30,7 @@
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">
<de.hdodenhof.circleimageview.CircleImageView <de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/avatar_second"
android:layout_width="67dp" android:layout_width="67dp"
android:layout_height="67dp" android:layout_height="67dp"
android:elevation="4dp" android:elevation="4dp"
@@ -38,7 +40,7 @@
<ImageView <ImageView
android:id="@+id/backgroundImage" android:id="@+id/bg_second"
android:layout_width="96dp" android:layout_width="96dp"
android:layout_height="148dp" android:layout_height="148dp"
android:layout_marginTop="-33dp" android:layout_marginTop="-33dp"
@@ -48,9 +50,25 @@
android:src="@drawable/second_place" /> android:src="@drawable/second_place" />
<TextView <TextView
android:id="@+id/name_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="70dp"
android:layout_marginTop="-60dp"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
android:elevation="2dp"
android:text="Loading..."
android:textSize="10sp"
android:textColor="#1B1F1A" />
<TextView
android:id="@+id/btn_add_second"
android:layout_width="60dp" android:layout_width="60dp"
android:layout_height="28dp" android:layout_height="28dp"
android:background="@drawable/round_bg_two" android:background="@drawable/round_bg_two"
android:layout_marginTop="50dp"
android:gravity="center" android:gravity="center"
android:text="+" android:text="+"
android:textColor="#6EA0EB" android:textColor="#6EA0EB"
@@ -60,12 +78,14 @@
<!-- 第一名 --> <!-- 第一名 -->
<LinearLayout <LinearLayout
android:id="@+id/container_first"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical"> android:orientation="vertical">
<de.hdodenhof.circleimageview.CircleImageView <de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/avatar_first"
android:layout_width="67dp" android:layout_width="67dp"
android:layout_height="67dp" android:layout_height="67dp"
android:elevation="4dp" android:elevation="4dp"
@@ -74,7 +94,7 @@
app:civ_border_color="#DFF346" /> app:civ_border_color="#DFF346" />
<ImageView <ImageView
android:id="@+id/backgroundImage" android:id="@+id/bg_first"
android:layout_marginTop="-33dp" android:layout_marginTop="-33dp"
android:layout_width="96dp" android:layout_width="96dp"
android:layout_height="148dp" android:layout_height="148dp"
@@ -84,9 +104,25 @@
android:adjustViewBounds="true" /> android:adjustViewBounds="true" />
<TextView <TextView
android:id="@+id/name_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="70dp"
android:layout_marginTop="-60dp"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
android:elevation="2dp"
android:text="Loading..."
android:textSize="10sp"
android:textColor="#1B1F1A" />
<TextView
android:id="@+id/btn_add_first"
android:layout_width="60dp" android:layout_width="60dp"
android:layout_height="28dp" android:layout_height="28dp"
android:gravity="center" android:gravity="center"
android:layout_marginTop="50dp"
android:text="+" android:text="+"
android:textSize="20dp" android:textSize="20dp"
android:textStyle="bold" android:textStyle="bold"
@@ -96,6 +132,7 @@
<!-- 第三名 --> <!-- 第三名 -->
<LinearLayout <LinearLayout
android:id="@+id/container_third"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
@@ -103,6 +140,7 @@
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical"> android:orientation="vertical">
<de.hdodenhof.circleimageview.CircleImageView <de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/avatar_third"
android:layout_width="67dp" android:layout_width="67dp"
android:layout_height="67dp" android:layout_height="67dp"
android:elevation="4dp" android:elevation="4dp"
@@ -111,7 +149,7 @@
app:civ_border_color="#DFF346" /> app:civ_border_color="#DFF346" />
<ImageView <ImageView
android:id="@+id/backgroundImage" android:id="@+id/bg_third"
android:layout_marginTop="-33dp" android:layout_marginTop="-33dp"
android:layout_width="96dp" android:layout_width="96dp"
android:layout_height="148dp" android:layout_height="148dp"
@@ -120,10 +158,25 @@
android:scaleType="fitXY" android:scaleType="fitXY"
android:adjustViewBounds="true" /> android:adjustViewBounds="true" />
<TextView
android:id="@+id/name_third"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="70dp"
android:layout_marginTop="-60dp"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
android:elevation="2dp"
android:text="Loading..."
android:textSize="10sp"
android:textColor="#1B1F1A" />
<TextView <TextView
android:id="@+id/btn_add_third"
android:layout_width="60dp" android:layout_width="60dp"
android:layout_height="28dp" android:layout_height="28dp"
android:layout_marginTop="50dp"
android:gravity="center" android:gravity="center"
android:text="+" android:text="+"
android:textSize="20dp" android:textSize="20dp"
@@ -135,75 +188,14 @@
<!-- 排名靠后 --> <!-- 排名靠后 -->
<LinearLayout <LinearLayout
android:id="@+id/container_others"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="260dp"
android:minHeight="1000dp" android:minHeight="1000dp"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="horizontal">
<!-- 内容卡片 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="90dp"
android:layout_marginTop="20dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_weight="1"
android:text="4"
android:textSize="14sp"
android:textColor="#1B1F1A"
/>
<de.hdodenhof.circleimageview.CircleImageView
android:layout_width="67dp"
android:layout_height="67dp"
android:layout_weight="1"
android:layout_marginEnd="10dp"
android:elevation="4dp"
android:src="@drawable/default_avatar"/>
<LinearLayout
android:layout_width="140dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">
<TextView
android:layout_width="140dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
android:text="NameNameNameNameNameNameNameNameNameNameName"
android:textStyle="bold"
android:textSize="16sp"
android:textColor="#1B1F1A" />
<TextView
android:layout_width="140dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="nullnullnullnullnullnullnullnullnullnullnullvnullnullnullnullnull"
android:textSize="12sp"
android:textColor="#9A9A9A" />
</LinearLayout>
<TextView
android:layout_width="56dp"
android:layout_height="38dp"
android:gravity="center"
android:layout_weight="1"
android:text="+"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="#02BEAC"
android:background="@drawable/round_bg_others" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -1,99 +1,27 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/scrollContent" android:id="@+id/rvList2"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:minHeight="1000dp"
android:padding="14dp"
android:fillViewport="true"> android:fillViewport="true">
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minHeight="1000dp" android:paddingBottom="240dp"
android:orientation="vertical" android:clipToPadding="false"
android:padding="14dp"> android:orientation="vertical" />
<!-- 内容卡片 -->
<LinearLayout
android:layout_width="150dp"
android:layout_height="240dp"
android:gravity="center"
android:orientation="vertical">
<de.hdodenhof.circleimageview.CircleImageView
android:layout_width="67dp"
android:layout_height="67dp"
android:elevation="7dp"
android:src="@drawable/default_avatar"
app:civ_border_width="2dp"
app:civ_border_color="#DFF346" />
<androidx.cardview.widget.CardView <ProgressBar
android:layout_width="150dp" android:id="@+id/loadingView"
android:layout_height="200dp" android:layout_width="40dp"
android:layout_marginTop="-40dp" android:layout_height="40dp"
android:background="#ffffff" android:layout_gravity="center"
app:cardCornerRadius="15dp" android:visibility="gone"/>
app:cardElevation="6dp"
app:cardUseCompatPadding="true">
<LinearLayout </FrameLayout>
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_marginTop="10dp"
android:gravity="center"
android:padding="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="name"
android:singleLine="true"
android:gravity="center"
android:textStyle="bold"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textColor="#1B1F1A" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Be neither too closenor too distant"
android:ellipsize="end"
android:layout_marginTop="5dp"
android:gravity="center"
android:maxLines="2"
android:textSize="12sp"
android:textColor="#9A9A9A" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Be neither too closenor too distant"
android:ellipsize="end"
android:layout_marginTop="5dp"
android:gravity="center"
android:maxLines="2"
android:textColor="#02BEAC"
android:textSize="10sp" />
<TextView
android:layout_width="100dp"
android:layout_height="32dp"
android:text="+"
android:gravity="center"
android:layout_marginTop="5dp"
android:background="@drawable/list_two_bg"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="20sp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,54 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:padding="24dp"
android:background="@drawable/bg_dialog_round"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- 标题 -->
<TextView
android:text="Confirm logging out?"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#222222"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<!-- 描述 -->
<TextView
android:layout_marginTop="12dp"
android:text="You will need to log in again after logging out"
android:textSize="14sp"
android:textColor="#666666"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<!-- 按钮区 -->
<LinearLayout
android:layout_marginTop="24dp"
android:orientation="horizontal"
android:gravity="end"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/btn_cancel"
android:text="Cancel"
android:textSize="14sp"
android:textColor="#666666"
android:padding="12dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/btn_logout"
android:text="Log out"
android:textSize="14sp"
android:textColor="#F44336"
android:padding="12dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="112dp"
android:layout_height="112dp"
android:elevation="4dp"
android:layout_gravity="center_horizontal"/>
<LinearLayout
android:layout_width="301dp"
android:layout_height="358dp"
android:background="@drawable/dialog_persona_detail_bg"
android:layout_marginTop="-56dp"
android:elevation="1dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="68dp"
android:gravity="center"
android:text="Loading..."
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/download"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:text="Loading..."
android:textColor="#02BEAC"
android:textSize="13sp"/>
<LinearLayout
android:layout_width="243dp"
android:layout_height="113dp"
android:layout_marginTop="16dp"
android:background="@drawable/tv_background_bg"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/tvBackground"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Loading..."
android:textSize="14sp"
android:lineSpacingExtra="4dp"
android:hyphenationFrequency="normal" />
</LinearLayout>
<TextView
android:id="@+id/btnAdd"
android:layout_width="245dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:gravity="center"
android:background="@drawable/keyboard_ettings"
android:textColor="#ffffff"
android:text="Loading..." />
</LinearLayout>
<ImageView
android:id="@+id/btnClose"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginTop="18dp"
android:layout_gravity="center"
android:src="@drawable/shut" />
</LinearLayout>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:cardCornerRadius="16dp"
app:cardElevation="8dp"
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/dialog_background"
android:padding="24dp">
<!-- 标题 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Purchase Confirmation"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1B1F1A"
android:gravity="center"
android:layout_marginBottom="16dp" />
<!-- 消息内容 -->
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Have you confirmed your purchase of this theme"
android:textSize="14sp"
android:textColor="#666666"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<!-- 按钮容器 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<!-- 取消按钮 -->
<TextView
android:id="@+id/btn_cancel"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="Cancel"
android:textSize="14sp"
android:textColor="#999999"
android:gravity="center"
android:background="@drawable/button_cancel_background"
android:textStyle="normal" />
<!-- 确认按钮 -->
<TextView
android:id="@+id/btn_confirm"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Confirm"
android:textSize="14sp"
android:textColor="#FFFFFF"
android:gravity="center"
android:background="@drawable/button_confirm_background"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -108,7 +108,7 @@
android:hint="Please enter your email address" android:hint="Please enter your email address"
android:textColorHint="#CBCBCB" android:textColorHint="#CBCBCB"
android:textSize="14sp" android:textSize="14sp"
android:textColor="#CBCBCB" /> android:textColor="#000000" />
<!-- 密码输入框 --> <!-- 密码输入框 -->
<RelativeLayout <RelativeLayout
android:layout_width="315dp" android:layout_width="315dp"

View File

@@ -94,11 +94,17 @@
android:elevation="5dp" android:elevation="5dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:src="@drawable/subscript"/> android:src="@drawable/subscript"/>
<!-- 昵称 -->
<TextView <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/nickname"
android:text="Username" android:text="Username"
android:textColor="#1B1F1A" android:textColor="#1B1F1A"
android:textStyle="bold" android:textStyle="bold"
@@ -107,6 +113,19 @@
android:maxLines="1" android:maxLines="1"
android:layout_weight="1" android:layout_weight="1"
android:textSize="20sp" /> android:textSize="20sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/time"
android:text="Time"
android:textColor="#A4A4A4"
android:ellipsize="end"
android:singleLine="true"
android:maxLines="1"
android:layout_weight="1"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<!-- 充值 --> <!-- 充值 -->
@@ -385,6 +404,7 @@
</LinearLayout> </LinearLayout>
<TextView <TextView
android:id="@+id/logout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="63dp" android:layout_height="63dp"
android:layout_marginTop="20dp" android:layout_marginTop="20dp"

View File

@@ -136,71 +136,11 @@
android:textColor="#1B1F1A" android:textColor="#1B1F1A"
android:textSize="14sp" /> android:textSize="14sp" />
<!-- 推荐皮肤列表 --> <!-- 推荐皮肤列表 -->
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/layout_recommend_list" android:id="@+id/recycler_recommend_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:paddingTop="10dp" />
android:paddingTop="10dp">
<!-- 卡片 -->
<androidx.cardview.widget.CardView
android:id="@+id/card_view"
android:layout_width="170dp"
android:layout_height="wrap_content"
android:background="#F8F8F8"
app:cardCornerRadius="15dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="127dp"
android:scaleType="centerCrop"
android:src="@drawable/bg" />
<TextView
android:layout_width="130dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginStart="6dp"
android:text="Dopamine"
android:textColor="#1B1F1A"
android:textSize="14sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="23dp"
android:layout_marginStart="6dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp"
android:padding="4dp"
android:gravity="center"
android:background="@drawable/gold_coin_background_required"
android:orientation="horizontal">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:scaleType="centerCrop"
android:src="@drawable/gold_coin" />
<TextView
android:layout_width="17dp"
android:layout_height="17dp"
android:layout_marginStart="4dp"
android:text="20"
android:textColor="#02BEAC"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ········· -->
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>

View File

@@ -74,70 +74,33 @@
</LinearLayout> </LinearLayout>
<!-- 搜索结果列表 --> <!-- 搜索结果列表 -->
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/layout_recommend_list" android:id="@+id/recycler_search_results"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:paddingTop="10dp" />
android:paddingTop="10dp"> <!-- 没有搜索结果时显示的提示 -->
<!-- 卡片 -->
<androidx.cardview.widget.CardView
android:layout_width="170dp"
android:layout_height="wrap_content"
android:background="#F8F8F8"
app:cardCornerRadius="15dp"
app:cardUseCompatPadding="true">
<LinearLayout <LinearLayout
android:id="@+id/ll_no_search_result"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="127dp"
android:scaleType="centerCrop"
android:src="@drawable/bg" />
<TextView
android:layout_width="130dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginStart="6dp"
android:text="Dopamine"
android:textColor="#1B1F1A"
android:textSize="14sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="23dp"
android:layout_marginStart="6dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp"
android:padding="4dp"
android:gravity="center" android:gravity="center"
android:background="@drawable/gold_coin_background_required" android:paddingTop="10dp"
android:orientation="horizontal"> android:visibility="gone"
android:orientation="vertical">
<ImageView <ImageView
android:layout_width="17dp" android:layout_width="175dp"
android:layout_height="17dp" android:layout_height="175dp"
android:scaleType="centerCrop" android:src="@drawable/no_search_result" />
android:src="@drawable/gold_coin" />
<TextView <TextView
android:layout_width="17dp" android:layout_width="wrap_content"
android:layout_height="17dp" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:layout_marginTop="10dp"
android:text="20" android:text="No data available for the time being"
android:textColor="#02BEAC" android:textColor="#999999"
android:textSize="14sp" /> android:textSize="14sp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ········· -->
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout <com.example.myapplication.widget.NoHorizontalInterceptSwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" android:id="@+id/swipeRefreshLayout"
android:id="@+id/rootCoordinator"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.home.HomeFragment"> android:layout_marginBottom="40dp">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 背景--> <!-- 背景-->
<ImageView <ImageView
@@ -89,14 +92,22 @@
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="20dp" android:layout_height="20dp"
android:layout_marginStart="62dp" android:layout_marginStart="62dp"
android:src="@drawable/blue_star" /> android:layout_marginTop="60dp"
android:src="@drawable/yellow_star" />
<ImageView <ImageView
android:layout_width="14dp" android:layout_width="18dp"
android:layout_height="14dp" android:layout_height="18dp"
android:layout_marginTop="24dp" android:layout_marginTop="62dp"
android:layout_marginStart="100dp" android:layout_marginStart="2dp"
android:src="@drawable/blue_star" /> android:src="@drawable/yellow_star" />
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginTop="60dp"
android:layout_marginStart="2dp"
android:src="@drawable/yellow_star" />
<ImageView <ImageView
android:layout_width="14dp" android:layout_width="14dp"
@@ -145,15 +156,17 @@
<ImageView <ImageView
android:layout_width="38dp" android:layout_width="38dp"
android:layout_height="38dp" android:layout_height="38dp"
android:layout_marginEnd="11dp"
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
android:src="@drawable/gold_coin" /> android:src="@drawable/gold_coin" />
<TextView <TextView
android:layout_width="wrap_content" android:id="@+id/balance"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:text="88.00" android:layout_marginEnd="10dp"
android:text="0.00"
android:textColor="#02BEAC" android:textColor="#02BEAC"
android:textSize="40sp" /> android:textSize="40sp" />
@@ -162,7 +175,7 @@
android:id="@+id/rechargeButton" android:id="@+id/rechargeButton"
android:layout_width="114dp" android:layout_width="114dp"
android:layout_height="42dp" android:layout_height="42dp"
android:layout_marginStart="30dp" android:layout_marginEnd="10dp"
android:gravity="center" android:gravity="center"
android:background="@drawable/gold_coin_button" android:background="@drawable/gold_coin_button"
android:orientation="horizontal"> android:orientation="horizontal">
@@ -203,11 +216,13 @@
</HorizontalScrollView> </HorizontalScrollView>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<!-- 内容页,放进 ViewPager2 里 --> <!-- 内容页,放进 ViewPager2 里 -->
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager" android:id="@+id/viewPager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginBottom="40dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</com.example.myapplication.widget.NoHorizontalInterceptSwipeRefreshLayout>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:clipToPadding="false"/>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="6dp"
android:layout_height="6dp"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:background="@drawable/dot_bg" />

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:textSize="20sp"
android:maxLines="2"
android:ellipsize="end"
android:includeFontPadding="false" />

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:gravity="center"
android:textSize="14sp"
android:background="@drawable/bg_sub_tab"
android:layout_marginRight="6dp"
android:includeFontPadding="false"/>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="40dp"
android:gravity="center"
android:paddingLeft="2dp"
android:paddingRight="2dp"
android:textSize="10sp"
android:maxLines="4"
android:ellipsize="end"
android:includeFontPadding="false" />

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="170dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="15dp"
app:cardUseCompatPadding="true">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/ivPreview"
android:layout_width="match_parent"
android:layout_height="127dp"
android:scaleType="centerCrop"
android:src="@drawable/default_avatar" />
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:text="Dopamine"
android:textColor="#1B1F1A"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<!-- 选中遮罩(编辑模式才显示) -->
<View
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#66000000"
android:visibility="gone"/>
<!-- 勾(编辑模式才显示) -->
<ImageView
android:id="@+id/ivCheck"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="top|end"
android:layout_margin="10dp"
android:src="@drawable/selected"
android:visibility="gone"/>
</FrameLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,89 @@
<!-- 内容卡片 -->
<!-- <?xml version="1.0" encoding="utf-8"?> -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="150dp"
android:layout_height="240dp"
android:gravity="center"
android:orientation="vertical">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="67dp"
android:layout_height="67dp"
android:elevation="7dp"
android:src="@drawable/default_avatar"
app:civ_border_width="2dp"
app:civ_border_color="#DFF346" />
<androidx.cardview.widget.CardView
android:layout_width="150dp"
android:layout_height="200dp"
android:layout_marginTop="-40dp"
android:background="#ffffff"
app:cardCornerRadius="15dp"
app:cardElevation="6dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_marginTop="10dp"
android:gravity="center"
android:padding="12dp">
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="name"
android:singleLine="true"
android:gravity="center"
android:textStyle="bold"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textColor="#1B1F1A" />
<TextView
android:id="@+id/characterBackground"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Be neither too closenor too distant"
android:ellipsize="end"
android:layout_marginTop="5dp"
android:gravity="center"
android:maxLines="2"
android:textSize="12sp"
android:textColor="#9A9A9A" />
<TextView
android:id="@+id/download"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Be neither too closenor too distant"
android:ellipsize="end"
android:layout_marginTop="5dp"
android:gravity="center"
android:maxLines="2"
android:textColor="#02BEAC"
android:textSize="10sp" />
<TextView
android:id="@+id/operation"
android:layout_width="100dp"
android:layout_height="32dp"
android:text="+"
android:gravity="center"
android:layout_marginTop="5dp"
android:background="@drawable/list_two_bg"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="20sp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>

View File

@@ -0,0 +1,72 @@
<!-- 内容卡片 -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="90dp"
android:layout_marginTop="20dp"
android:layout_weight="1"
android:id="@+id/container_others"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_weight="1"
android:text="Loading..."
android:textSize="14sp"
android:textColor="#1B1F1A"
/>
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/iv_avatar"
android:layout_width="67dp"
android:layout_height="67dp"
android:layout_weight="1"
android:layout_marginEnd="10dp"
android:elevation="4dp"
android:src="@drawable/default_avatar"/>
<LinearLayout
android:layout_width="140dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_name"
android:layout_width="140dp"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="end"
android:maxLines="1"
android:text="Loading..."
android:textStyle="bold"
android:textSize="16sp"
android:textColor="#1B1F1A" />
<TextView
android:id="@+id/tv_desc"
android:layout_width="140dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="Loading..."
android:textSize="12sp"
android:textColor="#9A9A9A" />
</LinearLayout>
<TextView
android:id="@+id/btn_add"
android:layout_width="56dp"
android:layout_height="38dp"
android:gravity="center"
android:layout_weight="1"
android:text="+"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="#02BEAC"
android:background="@drawable/round_bg_others" />
</LinearLayout>
<!-- 内容卡片结束 -->

View File

@@ -1,25 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="600dp"
android:orientation="vertical"
android:padding="16dp"
android:background="#ffffff">
<!-- 卡片内容 --> <!-- 卡片内容 -->
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="170dp" android:layout_width="170dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#F8F8F8" android:background="#F8F8F8"
android:id="@+id/theme_card"
app:cardCornerRadius="15dp" app:cardCornerRadius="15dp"
app:cardUseCompatPadding="true"> app:cardUseCompatPadding="true">
<LinearLayout <LinearLayout
@@ -28,12 +14,14 @@
android:orientation="vertical"> android:orientation="vertical">
<ImageView <ImageView
android:id="@+id/theme_image"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="127dp" android:layout_height="127dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:src="@drawable/bg" /> android:src="@drawable/bg" />
<TextView <TextView
android:id="@+id/theme_name"
android:layout_width="130dp" android:layout_width="130dp"
android:layout_height="20dp" android:layout_height="20dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
@@ -61,17 +49,15 @@
android:src="@drawable/gold_coin" /> android:src="@drawable/gold_coin" />
<TextView <TextView
android:layout_width="17dp" android:id="@+id/theme_price"
android:layout_height="17dp" android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="-2dp"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:text="20" android:text="0.00"
android:textColor="#02BEAC" android:textColor="#02BEAC"
android:textSize="14sp" /> android:textSize="14sp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -8,15 +8,41 @@
<LinearLayout <LinearLayout
android:id="@+id/control_layout" android:id="@+id/control_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_marginTop="3dp"
android:layout_height="50dp" android:layout_height="50dp"
android:orientation="horizontal"> android:orientation="horizontal">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1"> android:paddingStart="10dp"
android:paddingEnd="10dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/key_ai"
android:layout_width="34dp"
android:layout_height="34dp"
android:textSize="12sp"
android:textColor="#A9A9A9"
android:clickable="true"
android:gravity="center"/>
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/key_revoke"
android:layout_width="83dp"
android:layout_height="25dp"
android:paddingEnd="10dp"
android:clickable="true"/>
<!-- 收起键盘 -->
<LinearLayout <LinearLayout
android:id="@+id/collapse_button" android:id="@+id/collapse_button"
android:layout_width="50dp" android:layout_width="50dp"
@@ -30,13 +56,17 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout>
<!-- 补全建议区域(可横向滑动) --> <!-- 补全建议区域(可横向滑动) -->
<HorizontalScrollView <HorizontalScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="50dp" android:layout_height="50dp"
android:layout_marginTop="3dp"
android:scrollbars="none" android:scrollbars="none"
android:overScrollMode="never" android:overScrollMode="never"
android:background="@drawable/complete_bg"
android:id="@+id/completion_scroll"> android:id="@+id/completion_scroll">
<LinearLayout <LinearLayout
@@ -56,7 +86,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_1" android:id="@+id/suggestion_1"
@@ -67,7 +97,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_2" android:id="@+id/suggestion_2"
@@ -78,7 +108,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_3" android:id="@+id/suggestion_3"
@@ -89,7 +119,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_4" android:id="@+id/suggestion_4"
@@ -100,7 +130,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_5" android:id="@+id/suggestion_5"
@@ -111,7 +141,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_6" android:id="@+id/suggestion_6"
@@ -122,7 +152,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_7" android:id="@+id/suggestion_7"
@@ -133,7 +163,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_8" android:id="@+id/suggestion_8"
@@ -144,7 +174,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_9" android:id="@+id/suggestion_9"
@@ -155,7 +185,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_10" android:id="@+id/suggestion_10"
@@ -166,7 +196,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_11" android:id="@+id/suggestion_11"
@@ -177,7 +207,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_12" android:id="@+id/suggestion_12"
@@ -188,7 +218,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_13" android:id="@+id/suggestion_13"
@@ -199,7 +229,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_14" android:id="@+id/suggestion_14"
@@ -210,7 +240,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_15" android:id="@+id/suggestion_15"
@@ -221,7 +251,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_16" android:id="@+id/suggestion_16"
@@ -232,7 +262,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_17" android:id="@+id/suggestion_17"
@@ -243,7 +273,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_18" android:id="@+id/suggestion_18"
@@ -254,7 +284,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_19" android:id="@+id/suggestion_19"
@@ -265,7 +295,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
<TextView <TextView
android:id="@+id/suggestion_20" android:id="@+id/suggestion_20"
@@ -276,7 +306,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true" android:clickable="true"
android:background="@drawable/btn_keyboard" android:background="@drawable/btn_keyboard"
android:textColor="#3C3C3C"/> android:textColor="#FFFFFF"/>
</LinearLayout> </LinearLayout>
</HorizontalScrollView> </HorizontalScrollView>
@@ -286,6 +316,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="horizontal"> android:orientation="horizontal">
@@ -573,7 +604,7 @@
android:gravity="center"/> android:gravity="center"/>
<TextView <TextView
android:id="@+id/key_ai" android:id="@+id/key_emoji"
android:layout_width="42dp" android:layout_width="42dp"
android:layout_height="40dp" android:layout_height="40dp"
android:textSize="12sp" android:textSize="12sp"

View File

@@ -7,11 +7,16 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#F6F7FB" android:background="#F6F7FB"
tools:context=".ui.keyboard.KeyboardDetailFragment"> tools:context=".ui.keyboard.KeyboardDetailFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true" android:fillViewport="true"
android:overScrollMode="never"> android:overScrollMode="never"
android:layout_marginBottom="80dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -70,7 +75,7 @@
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="#1B1F1A" android:textColor="#1B1F1A"
android:text="Dopamine" /> android:text="Loading..." />
<TextView <TextView
android:id="@+id/tv_download_count" android:id="@+id/tv_download_count"
@@ -82,10 +87,16 @@
android:textSize="14sp" android:textSize="14sp"
android:background="@drawable/tv_download_count" android:background="@drawable/tv_download_count"
android:textColor="#02BEAC" android:textColor="#02BEAC"
android:text="Download: 1 million" /> android:text="Loading..." />
</LinearLayout> </LinearLayout>
<!-- 描述标签 --> <!-- 描述标签 -->
<LinearLayout
android:id="@+id/layout_tags_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:padding="0dp" />
<!-- 推荐皮肤 --> <!-- 推荐皮肤 -->
<LinearLayout <LinearLayout
@@ -101,83 +112,31 @@
android:textColor="#1B1F1A" android:textColor="#1B1F1A"
android:textSize="14sp" /> android:textSize="14sp" />
<!-- 推荐皮肤列表 --> <!-- 推荐皮肤列表 -->
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/layout_recommend_list" android:id="@+id/recycler_recommend_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:paddingTop="10dp" />
android:paddingTop="10dp"> </LinearLayout>
<!-- 卡片 -->
<androidx.cardview.widget.CardView
android:id="@+id/card_view"
android:layout_width="170dp"
android:layout_height="wrap_content"
android:background="#F8F8F8"
app:cardCornerRadius="15dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="127dp"
android:scaleType="centerCrop"
android:src="@drawable/bg" />
<TextView
android:layout_width="130dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginStart="6dp"
android:text="Dopamine"
android:textColor="#1B1F1A"
android:textSize="14sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="23dp"
android:layout_marginStart="6dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp"
android:padding="4dp"
android:gravity="center"
android:background="@drawable/gold_coin_background_required"
android:orientation="horizontal">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:scaleType="centerCrop"
android:src="@drawable/gold_coin" />
<TextView
android:layout_width="17dp"
android:layout_height="17dp"
android:layout_marginStart="4dp"
android:text="20"
android:textColor="#02BEAC"
android:textSize="14sp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </androidx.core.widget.NestedScrollView>
</androidx.cardview.widget.CardView> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- ········· -->
</LinearLayout> <!-- 固定在底部的购买按钮 -->
</LinearLayout>
<!-- 购买按钮 -->
<LinearLayout <LinearLayout
android:id="@+id/rechargeButton" android:id="@+id/rechargeButton"
android:layout_marginTop="22dp"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="45dp" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_gravity="bottom"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:gravity="center" android:gravity="center"
android:background="@drawable/my_keyboard_delete" android:background="@drawable/my_keyboard_delete"
android:elevation="4dp" android:elevation="4dp"
android:orientation="horizontal"> android:orientation="horizontal"
android:padding="12dp">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -197,16 +156,47 @@
android:src="@drawable/gold_coin" /> android:src="@drawable/gold_coin" />
<TextView <TextView
android:id="@+id/tv_price"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="15sp" android:textSize="15sp"
android:textStyle="bold" android:textStyle="bold"
android:gravity="center" android:gravity="center"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:text="20" /> android:text="00" />
</LinearLayout>
<!-- 启用主题切换按钮 -->
<LinearLayout
android:id="@+id/enabledButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:gravity="center"
android:background="@drawable/my_keyboard_delete"
android:elevation="4dp"
android:orientation="horizontal"
android:padding="12dp">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:visibility="gone"
android:indeterminateTint="#FFFFFF" />
<TextView
android:id="@+id/enabledButtonText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:gravity="center"
android:textColor="#FFFFFF"
android:text="Enabled" />
</LinearLayout> </LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/background"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="3dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="30dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/key_abc"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:background="@drawable/bg_top_tab"
android:text="ABC"
android:textSize="16sp"
android:layout_marginRight="6dp"/>
<TextView
android:id="@+id/tab_emoji"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Emoji"
android:textSize="15sp"
android:background="@drawable/bg_top_tab"
android:layout_marginRight="6dp"
android:layout_marginLeft="6dp"/>
<TextView
android:id="@+id/tab_kaomoji"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Kaomoji"
android:textSize="15sp"
android:background="@drawable/bg_top_tab"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"/>
<TextView
android:id="@+id/key_del"
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center"
android:layout_weight="1"
android:background="@drawable/bg_top_tab"
android:text="⌫"
android:textSize="18sp"
android:layout_marginLeft="6dp"/>
</LinearLayout>
<!-- 分页内容 -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="176dp" />
<!-- PageIndicator 点点 -->
<!-- <LinearLayout
android:id="@+id/page_indicator"
android:layout_width="match_parent"
android:layout_height="22dp"
android:gravity="center"
android:orientation="horizontal" /> -->
<HorizontalScrollView
android:id="@+id/subcategory_scroll"
android:layout_width="match_parent"
android:layout_height="30dp"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/subcategory_bar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal" />
</HorizontalScrollView>
</LinearLayout>

View File

@@ -1,7 +1,13 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/rootCoordinator" android:id="@+id/rootCoordinator"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@@ -49,6 +55,7 @@
android:textSize="16sp" /> android:textSize="16sp" />
<TextView <TextView
android:id="@+id/tvEditor"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
@@ -60,49 +67,49 @@
</LinearLayout> </LinearLayout>
<!-- 内容 --> <!-- 内容 -->
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvThemes"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="16dp" android:nestedScrollingEnabled="false"
android:orientation="vertical"> tools:listitem="@layout/item_myskin_theme"/>
<!-- 卡片内容 -->
<androidx.cardview.widget.CardView
android:layout_width="170dp"
android:layout_height="wrap_content"
android:background="#F8F8F8"
app:cardCornerRadius="15dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="127dp"
android:scaleType="centerCrop"
android:src="@drawable/default_avatar" />
<TextView
android:layout_width="130dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginStart="6dp"
android:layout_marginBottom="14dp"
android:text="Dopamine"
android:textColor="#1B1F1A"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="15dp"
android:layout_height="15dp"
android:layout_marginTop="-150dp"
android:layout_marginStart="10dp"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<!-- 底部编辑栏 -->
<LinearLayout
android:id="@+id/bottomEditBar"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_gravity="bottom"
android:background="#FFFFFF"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:visibility="gone"
android:elevation="8dp">
<TextView
android:id="@+id/tvSelectedCount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="0 themes selected"
android:textColor="#1B1F1A"
android:textSize="14sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:gravity="center"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:text="Delete"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:background="@drawable/bg_delete_btn"/>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -10,13 +10,40 @@
android:id="@+id/control_layout" android:id="@+id/control_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="50dp" android:layout_height="50dp"
android:layout_marginTop="3dp"
android:orientation="horizontal"> android:orientation="horizontal">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingStart="10dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="10dp"
android:layout_weight="1"> android:layout_weight="1">
<TextView
android:id="@+id/key_ai"
android:layout_width="34dp"
android:layout_height="34dp"
android:textSize="12sp"
android:textColor="#A9A9A9"
android:gravity="center"
android:clickable="true"/>
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/key_revoke"
android:layout_width="83dp"
android:layout_height="25dp"
android:paddingEnd="10dp"
android:clickable="true"/>
<!-- 收起键盘 -->
<LinearLayout <LinearLayout
android:id="@+id/collapse_button" android:id="@+id/collapse_button"
android:layout_width="50dp" android:layout_width="50dp"
@@ -29,10 +56,13 @@
/> />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout>
<!-- 第一行数字键 --> <!-- 第一行数字键 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="horizontal"> android:orientation="horizontal">
@@ -291,7 +321,7 @@
android:gravity="center" android:gravity="center"
android:clickable="true"/> android:clickable="true"/>
<TextView <TextView
android:id="@+id/key_ai" android:id="@+id/key_emoji"
android:layout_width="42dp" android:layout_width="42dp"
android:layout_height="40dp" android:layout_height="40dp"
android:textSize="12sp" android:textSize="12sp"

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/page_grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never" />

View File

@@ -9,15 +9,36 @@
<LinearLayout <LinearLayout
android:id="@+id/control_layout" android:id="@+id/control_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_marginTop="3dp"
android:layout_height="50dp" android:layout_height="50dp"
android:orientation="horizontal"> android:orientation="horizontal">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_weight="1"> android:layout_weight="1">
<!-- ai -->
<TextView android:id="@+id/key_ai" android:layout_width="34dp" android:layout_height="34dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/key_revoke"
android:layout_width="83dp"
android:layout_height="25dp"
android:paddingEnd="10dp"
android:clickable="true"/>
<!-- 收起键盘 -->
<LinearLayout <LinearLayout
android:id="@+id/collapse_button" android:id="@+id/collapse_button"
android:layout_width="50dp" android:layout_width="50dp"
@@ -30,11 +51,13 @@
/> />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout>
<!-- 第一行: [ ] { } # % ^ * + = --> <!-- 第一行: [ ] { } # % ^ * + = -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="horizontal"> android:orientation="horizontal">
@@ -84,7 +107,7 @@
<TextView android:id="@+id/key_question" android:layout_width="47dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_question" android:layout_width="47dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<TextView android:id="@+id/key_exclam" android:layout_width="47dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_exclam" android:layout_width="47dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<TextView android:id="@+id/key_quote" android:layout_width="47dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_quote" android:layout_width="47dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<TextView android:id="@+id/key_backspace" android:layout_width="42dp" android:layout_height="40dp" android:layout_marginStart="12dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_del" android:layout_width="42dp" android:layout_height="40dp" android:layout_marginStart="12dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
</LinearLayout> </LinearLayout>
<!-- 第四行123 | abc | [Space ×3] | send --> <!-- 第四行123 | abc | [Space ×3] | send -->
@@ -95,7 +118,7 @@
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="horizontal"> android:orientation="horizontal">
<TextView android:id="@+id/key_abc" android:layout_width="42dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_abc" android:layout_width="42dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<TextView android:id="@+id/key_ai" android:layout_width="42dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_emoji" android:layout_width="42dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<TextView android:id="@+id/key_space" android:layout_width="181dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_space" android:layout_width="181dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
<TextView android:id="@+id/key_send" android:layout_width="88dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/> <TextView android:id="@+id/key_send" android:layout_width="88dp" android:layout_height="40dp" android:textSize="12sp" android:textColor="#A9A9A9" android:gravity="center" android:clickable="true"/>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#66000000"
android:clickable="true"
android:focusable="true">
<ProgressBar
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@@ -12,6 +12,15 @@
android:label="Home" android:label="Home"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />
<action
android:id="@+id/action_global_homeFragment"
app:destination="@id/homeFragment"
app:enterAnim="@anim/fade_in"
app:exitAnim="@anim/fade_out"
app:popEnterAnim="@anim/fade_in_fast"
app:popExitAnim="@anim/fade_out_fast"/>
<!-- 商城 --> <!-- 商城 -->
<fragment <fragment
android:id="@+id/shopFragment" android:id="@+id/shopFragment"
@@ -132,7 +141,12 @@
android:id="@+id/keyboardDetailFragment" android:id="@+id/keyboardDetailFragment"
android:name="com.example.myapplication.ui.keyboard.KeyboardDetailFragment" android:name="com.example.myapplication.ui.keyboard.KeyboardDetailFragment"
android:label="Keyboard Detail" android:label="Keyboard Detail"
tools:layout="@layout/keyboard_detail" /> tools:layout="@layout/keyboard_detail">
<argument
android:name="themeId"
android:defaultValue="0"
app:argType="integer" />
</fragment>
<!-- 键盘详情全局跳转 --> <!-- 键盘详情全局跳转 -->
<action <action
@@ -186,6 +200,15 @@
android:label="Login" android:label="Login"
tools:layout="@layout/fragment_login" /> tools:layout="@layout/fragment_login" />
<!-- 全局登录跳转 -->
<action
android:id="@+id/action_global_loginFragment"
app:destination="@id/loginFragment"
app:enterAnim="@anim/fade_in"
app:exitAnim="@anim/fade_out"
app:popEnterAnim="@anim/fade_in_fast"
app:popExitAnim="@anim/fade_out_fast"/>
<action <action
android:id="@+id/action_mineFragment_to_loginFragment" android:id="@+id/action_mineFragment_to_loginFragment"
app:destination="@id/loginFragment" app:destination="@id/loginFragment"

View File

@@ -22,5 +22,11 @@
<item name="android:background">@drawable/code_box_bg</item> <item name="android:background">@drawable/code_box_bg</item>
<item name="android:textColor">#000000</item> <item name="android:textColor">#000000</item>
</style> </style>
<style name="PersonaDetailDialog" parent="Theme.MaterialComponents.Light.Dialog">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">#00000000</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources> </resources>