完善
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:name=".MyApp"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MyApplication">
|
||||
android:theme="@style/Theme.MyApplication"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<!-- 启动页 Activity -->
|
||||
<activity
|
||||
|
||||
@@ -20,16 +20,23 @@ import android.view.WindowManager
|
||||
import android.graphics.Rect
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.widget.ImageView
|
||||
import android.text.TextWatcher
|
||||
import android.text.Editable
|
||||
|
||||
class GuideActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var scrollView: NestedScrollView
|
||||
private lateinit var listLayout: LinearLayout
|
||||
private lateinit var inputMessage: EditText
|
||||
private lateinit var btnSend: Button
|
||||
private lateinit var btnSend: ImageView
|
||||
private lateinit var itemAnim: Animation
|
||||
private lateinit var bottomPanel: LinearLayout
|
||||
private lateinit var hintLayout: LinearLayout
|
||||
private lateinit var titleTextView: TextView
|
||||
|
||||
|
||||
// 我方的预设回复
|
||||
private val replyData = listOf(
|
||||
"你好",
|
||||
@@ -51,7 +58,24 @@ class GuideActivity : AppCompatActivity() {
|
||||
inputMessage = findViewById(R.id.input_message)
|
||||
btnSend = findViewById(R.id.btn_send)
|
||||
bottomPanel = findViewById(R.id.bottom_panel)
|
||||
hintLayout = findViewById(R.id.hintLayout)
|
||||
titleTextView = findViewById(R.id.title)
|
||||
val rootView = findViewById<View>(R.id.rootCoordinator)
|
||||
|
||||
inputMessage.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
// 不需要实现
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
// 不需要实现
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
hintLayout.visibility =
|
||||
if (s.isNullOrEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
})
|
||||
// 动画
|
||||
itemAnim = AnimationUtils.loadAnimation(this, R.anim.item_slide_in_up)
|
||||
//自动聚焦
|
||||
@@ -96,8 +120,10 @@ class GuideActivity : AppCompatActivity() {
|
||||
val isKeyboardVisible = keyboardHeight > screenHeight * 0.15
|
||||
|
||||
if (isKeyboardVisible) {
|
||||
// 键盘高度为正,仅仅把 bottomPanel 抬上去
|
||||
bottomPanel.translationY = -keyboardHeight.toFloat()
|
||||
// 键盘高度为正,把 bottomPanel 抬上去,但不要抬太高
|
||||
// 只上移键盘高度减去底部面板高度,让输入框刚好在键盘上方
|
||||
val adjustedTranslation = -(keyboardHeight - bottomPanel.height)
|
||||
bottomPanel.translationY = adjustedTranslation.toFloat()
|
||||
|
||||
// 为了让最后一条消息不被挡住,可以给 scrollView 加个 paddingBottom
|
||||
scrollView.setPadding(
|
||||
@@ -139,6 +165,10 @@ class GuideActivity : AppCompatActivity() {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
fun dp2px(dp: Int): Int {
|
||||
return (dp * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
// 发送消息
|
||||
private fun sendMessage() {
|
||||
val text = inputMessage.text.toString().trim()
|
||||
@@ -155,12 +185,21 @@ class GuideActivity : AppCompatActivity() {
|
||||
inputMessage.setText("")
|
||||
|
||||
val replyText = replyData.random()
|
||||
|
||||
|
||||
// 保存原来的标题文本
|
||||
val originalTitle = titleTextView.text.toString()
|
||||
|
||||
// 延迟执行我方回复
|
||||
scrollView.postDelayed({
|
||||
// 先恢复标题文本
|
||||
titleTextView.text = originalTitle
|
||||
// 然后添加我方回复
|
||||
addOurMessage(replyText)
|
||||
}, 500)
|
||||
}, 1500)
|
||||
|
||||
scrollView.postDelayed({
|
||||
titleTextView.text = "The other party is typing..."
|
||||
}, 500)
|
||||
inputMessage.isFocusable = true
|
||||
inputMessage.isFocusableInTouchMode = true
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@ package com.example.myapplication
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import com.example.myapplication.network.AuthEventBus
|
||||
import com.example.myapplication.network.AuthEvent
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -18,6 +23,23 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
lifecycleScope.launch {
|
||||
AuthEventBus.events.collectLatest { event ->
|
||||
if (event is AuthEvent.TokenExpired) {
|
||||
val navController = (supportFragmentManager
|
||||
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment)
|
||||
.navController
|
||||
|
||||
// 避免重复跳转(比如已经在登录页)
|
||||
if (navController.currentDestination?.id != R.id.loginFragment) {
|
||||
navController.navigate(R.id.action_global_loginFragment)
|
||||
}
|
||||
} else if (event is AuthEvent.GenericError) {
|
||||
android.widget.Toast.makeText(this@MainActivity, "${event.message}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 找到 NavHostFragment
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
|
||||
14
app/src/main/java/com/example/myapplication/MyApp.kt
Normal file
14
app/src/main/java/com/example/myapplication/MyApp.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ import com.example.myapplication.keyboard.AiKeyboard
|
||||
import android.text.InputType
|
||||
import android.view.KeyEvent
|
||||
import android.os.SystemClock
|
||||
import com.example.myapplication.network.AuthEventBus
|
||||
import com.example.myapplication.network.AuthEvent
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import android.content.Intent
|
||||
import android.view.inputmethod.ExtractedTextRequest
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +86,17 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "input_method_channel"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
}
|
||||
// ================= 表情 =================
|
||||
private var emojiKeyboardView: View? = null
|
||||
private var emojiKeyboard: com.example.myapplication.keyboard.EmojiKeyboard? = null
|
||||
// =================上滑清空==================
|
||||
private var swipeHintPopup: PopupWindow? = null
|
||||
private var swipeClearPopup: PopupWindow? = null
|
||||
private var swipeClearPopupShown = false
|
||||
// 备份:上次“清空”前的全文
|
||||
@Volatile private var lastClearedText: String? = null
|
||||
@Volatile private var lastClearedSelStart: Int = 0
|
||||
@Volatile private var lastClearedSelEnd: Int = 0
|
||||
|
||||
// ===== KeyboardEnvironment 实现所需属性 =====
|
||||
|
||||
@@ -102,6 +123,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
private var isDeleting = false
|
||||
private val repeatDelInitialDelay = 350L
|
||||
private val repeatDelInterval = 50L
|
||||
private val refreshAfterEditDelayMs = 16L // 1 帧
|
||||
private val refreshAfterEditRunnable = Runnable { refreshSuggestionsAfterEdit() }
|
||||
|
||||
|
||||
private val repeatDelRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -124,6 +148,22 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
private set
|
||||
|
||||
//主题更新
|
||||
private val themeListener: () -> Unit = {
|
||||
applyThemeAfterThemeChanged()
|
||||
}
|
||||
private fun applyThemeAfterThemeChanged() {
|
||||
mainKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
numberKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
symbolKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
aiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
|
||||
currentKeyboardView?.apply {
|
||||
requestLayout()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
// 键盘关闭
|
||||
override fun getInputConnection(): InputConnection? {
|
||||
return currentInputConnection
|
||||
@@ -173,6 +213,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
|
||||
ThemeManager.ensureBuiltInThemesInstalled(this)
|
||||
ThemeManager.init(this)
|
||||
|
||||
ThemeManager.addThemeChangeListener(themeListener)
|
||||
|
||||
// 异步加载词典与 bigram 模型
|
||||
Thread {
|
||||
@@ -217,10 +259,26 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
|
||||
createNotificationChannelIfNeeded()
|
||||
tryStartForegroundSafe()
|
||||
|
||||
// 监听认证事件
|
||||
// CoroutineScope(Dispatchers.Main).launch {
|
||||
// AuthEventBus.events.collectLatest { event ->
|
||||
// if (event is AuthEvent.TokenExpired) {
|
||||
// // 启动 MainActivity 并跳转到登录页面
|
||||
// val intent = Intent(this@MyInputMethodService, MainActivity::class.java).apply {
|
||||
// flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
// putExtra("navigate_to", "loginFragment")
|
||||
// }
|
||||
// startActivity(intent)
|
||||
// } else if (event is AuthEvent.GenericError) {
|
||||
// // 显示错误提示
|
||||
// android.widget.Toast.makeText(this@MyInputMethodService, "请求失败: ${event.message}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 输入法状态变化
|
||||
private fun createNotificationChannelIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
@@ -321,8 +379,9 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
ThemeManager.removeThemeChangeListener(themeListener)
|
||||
stopRepeatDelete()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ================= KeyboardEnvironment:键盘切换 =================
|
||||
@@ -354,6 +413,162 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
}
|
||||
return mainKeyboard!!
|
||||
}
|
||||
|
||||
// 上滑清空
|
||||
private fun clearAllAndBackup() {
|
||||
val ic = currentInputConnection ?: return
|
||||
|
||||
val et = try {
|
||||
ic.getExtractedText(ExtractedTextRequest(), 0)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
|
||||
val full = et.text?.toString().orEmpty()
|
||||
if (full.isEmpty()) {
|
||||
// 已经空了就不做
|
||||
clearEditorState()
|
||||
return
|
||||
}
|
||||
|
||||
// 备份
|
||||
lastClearedText = full
|
||||
lastClearedSelStart = et.selectionStart.coerceIn(0, full.length)
|
||||
lastClearedSelEnd = et.selectionEnd.coerceIn(0, full.length)
|
||||
|
||||
// 清空:全选 -> 用空串替换
|
||||
ic.beginBatchEdit()
|
||||
try {
|
||||
ic.setSelection(0, full.length)
|
||||
ic.commitText("", 1)
|
||||
} finally {
|
||||
ic.endBatchEdit()
|
||||
}
|
||||
|
||||
clearEditorState()
|
||||
|
||||
// 清空后立即更新所有键盘的按钮可见性
|
||||
mainHandler.post {
|
||||
mainKeyboard?.updateRevokeButtonVisibility()
|
||||
numberKeyboard?.updateRevokeButtonVisibility()
|
||||
symbolKeyboard?.updateRevokeButtonVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
// 回填上次清空的文本
|
||||
override fun revokeLastClearedText() {
|
||||
val ic = currentInputConnection ?: return
|
||||
val text = lastClearedText ?: return
|
||||
|
||||
// 回填文本并恢复光标位置
|
||||
ic.beginBatchEdit()
|
||||
try {
|
||||
// 先清空当前内容
|
||||
val currentText = ic.getTextBeforeCursor(1000, 0)?.toString().orEmpty()
|
||||
if (currentText.isNotEmpty()) {
|
||||
ic.setSelection(0, currentText.length)
|
||||
ic.commitText("", 1)
|
||||
}
|
||||
|
||||
// 回填备份的文本
|
||||
ic.commitText(text, 1)
|
||||
|
||||
// 恢复光标位置
|
||||
val selStart = lastClearedSelStart.coerceIn(0, text.length)
|
||||
val selEnd = lastClearedSelEnd.coerceIn(0, text.length)
|
||||
ic.setSelection(selStart, selEnd)
|
||||
|
||||
// 清空备份,避免重复回填
|
||||
lastClearedText = null
|
||||
lastClearedSelStart = 0
|
||||
lastClearedSelEnd = 0
|
||||
} finally {
|
||||
ic.endBatchEdit()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有可回填的文本
|
||||
override fun hasClearedText(): Boolean {
|
||||
return lastClearedText != null
|
||||
}
|
||||
|
||||
private fun showSwipeClearHint(anchor: View, text: String = "Clear") {
|
||||
mainHandler.post {
|
||||
if (swipeClearPopupShown) return@post
|
||||
swipeClearPopupShown = true
|
||||
|
||||
// 先关旧的
|
||||
swipeClearPopup?.dismiss()
|
||||
swipeClearPopup = null
|
||||
|
||||
val dp = resources.displayMetrics.density
|
||||
|
||||
// ✅ 这里“对标按键预览气泡”:优先用你项目里可能已有的 preview 背景 drawable
|
||||
// 你如果确定资源名,就把 getIdentifier 换成 R.drawable.xxx
|
||||
val previewBgId = resources.getIdentifier("key_preview_bg", "drawable", packageName)
|
||||
.takeIf { it != 0 }
|
||||
?: resources.getIdentifier("popup_preview_bg", "drawable", packageName)
|
||||
.takeIf { it != 0 }
|
||||
|
||||
val tv = TextView(this).apply {
|
||||
this.text = text
|
||||
textSize = 16f
|
||||
setTextColor(Color.BLACK)
|
||||
setPadding(20, 10, 20, 10)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = 16f
|
||||
setColor(Color.WHITE)
|
||||
setStroke(1, Color.parseColor("#33000000"))
|
||||
}
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
tv.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
val w = tv.measuredWidth
|
||||
val h = tv.measuredHeight
|
||||
|
||||
val popup = PopupWindow(
|
||||
tv,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
false
|
||||
).apply {
|
||||
isClippingEnabled = false
|
||||
elevation = 10f
|
||||
}
|
||||
swipeClearPopup = popup
|
||||
|
||||
// ✅ 用 IME 自己的 decorView 做 parent(输入法里最稳)
|
||||
val parent = window?.window?.decorView ?: anchor.rootView
|
||||
|
||||
// ✅ 坐标用 inWindow,跟 decorView 同坐标系
|
||||
val loc = IntArray(2)
|
||||
anchor.getLocationInWindow(loc)
|
||||
|
||||
val x = loc[0] + anchor.width / 2 - w / 2
|
||||
val y = loc[1] - h - (10 * dp).toInt()
|
||||
|
||||
try {
|
||||
popup.showAtLocation(parent, Gravity.NO_GRAVITY, x, y)
|
||||
} catch (t: Throwable) {
|
||||
swipeClearPopupShown = false
|
||||
Log.w(TAG, "showSwipeClearHint failed: ${t.message}", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//松手关闭气泡
|
||||
private fun dismissSwipeClearHint() {
|
||||
mainHandler.post {
|
||||
swipeClearPopup?.dismiss()
|
||||
swipeClearPopup = null
|
||||
swipeClearPopupShown = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun ensureNumberKeyboard(): NumberKeyboard {
|
||||
@@ -373,8 +588,8 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
symbolKeyboard = SymbolKeyboard(this)
|
||||
symbolKeyboardView = symbolKeyboard!!.rootView
|
||||
|
||||
// 符号键盘删除键 key_backspace
|
||||
val delId = resources.getIdentifier("key_backspace", "id", packageName)
|
||||
// 符号键盘删除键 key_del
|
||||
val delId = resources.getIdentifier("key_del", "id", packageName)
|
||||
symbolKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
|
||||
}
|
||||
return symbolKeyboard!!
|
||||
@@ -415,6 +630,27 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
setInputView(kb.rootView)
|
||||
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
}
|
||||
|
||||
override fun showEmojiKeyboard() {
|
||||
val kb = ensureEmojiKeyboard()
|
||||
currentKeyboardView = kb.rootView
|
||||
setInputView(kb.rootView)
|
||||
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
}
|
||||
|
||||
// Emoji 键盘
|
||||
private fun ensureEmojiKeyboard(): com.example.myapplication.keyboard.EmojiKeyboard {
|
||||
if (emojiKeyboard == null) {
|
||||
emojiKeyboard = com.example.myapplication.keyboard.EmojiKeyboard(this)
|
||||
emojiKeyboardView = emojiKeyboard!!.rootView
|
||||
|
||||
// Emoji 页面删除键也支持长按连删(复用你现有 attachRepeatDeleteInternal)
|
||||
val delId = resources.getIdentifier("key_del", "id", packageName)
|
||||
emojiKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
|
||||
}
|
||||
return emojiKeyboard!!
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ================== 文本输入核心逻辑 ==================
|
||||
@@ -445,6 +681,13 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
override fun commitKey(c: Char) {
|
||||
val ic = currentInputConnection ?: return
|
||||
|
||||
// 如果有清空过的文本,用户开始输入新内容时清空备份
|
||||
if (lastClearedText != null) {
|
||||
lastClearedText = null
|
||||
lastClearedSelStart = 0
|
||||
lastClearedSelEnd = 0
|
||||
}
|
||||
|
||||
val toSend = if (isShiftOn && c in 'a'..'z') c.uppercaseChar() else c
|
||||
ic.commitText(toSend.toString(), 1)
|
||||
|
||||
@@ -465,37 +708,77 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
override fun deleteOne() {
|
||||
val ic = currentInputConnection ?: return
|
||||
|
||||
// 1️⃣ 发送一个 DEL 按键(DOWN + UP),让客户端有机会拦截
|
||||
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
|
||||
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL))
|
||||
// 删除时少做 IPC:selectedText 也可能慢,所以只在需要时取
|
||||
val selected = ic.getSelectedText(0)
|
||||
if (!selected.isNullOrEmpty()) {
|
||||
// 删选区
|
||||
ic.commitText("", 1)
|
||||
} else {
|
||||
// 删光标前一个字符(更同步)
|
||||
ic.deleteSurroundingText(1, 0)
|
||||
}
|
||||
|
||||
// 如果你担心有些 EditText 不处理 DEL,可以加一个兜底:
|
||||
// ic.deleteSurroundingText(1, 0)
|
||||
|
||||
// 2️⃣ 你原来的逻辑可以继续保留
|
||||
val prefix = getCurrentWordPrefix()
|
||||
updateCompletionsAndRender(prefix)
|
||||
scheduleRefreshSuggestions()
|
||||
|
||||
playKeyClick()
|
||||
}
|
||||
|
||||
private fun refreshSuggestionsAfterEdit() {
|
||||
val ic = currentInputConnection ?: return
|
||||
|
||||
// ✅ 判空只取 1 个字符,避免 256/256 的 IPC 开销
|
||||
val before1 = ic.getTextBeforeCursor(1, 0)?.toString().orEmpty()
|
||||
val after1 = ic.getTextAfterCursor(1, 0)?.toString().orEmpty()
|
||||
val editorReallyEmpty = before1.isEmpty() && after1.isEmpty()
|
||||
|
||||
if (editorReallyEmpty) {
|
||||
clearEditorState()
|
||||
} else {
|
||||
// prefix 也不要取太长
|
||||
val prefix = getCurrentWordPrefix(maxLen = 64)
|
||||
updateCompletionsAndRender(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleRefreshSuggestions() {
|
||||
mainHandler.removeCallbacks(refreshAfterEditRunnable)
|
||||
mainHandler.postDelayed(refreshAfterEditRunnable, refreshAfterEditDelayMs)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 发送(标准 SEND + 回车 fallback)
|
||||
override fun performSendAction() {
|
||||
val ic = currentInputConnection ?: return
|
||||
|
||||
// 1. 尝试执行标准发送动作(IME_ACTION_SEND)
|
||||
val handled = ic.performEditorAction(EditorInfo.IME_ACTION_SEND)
|
||||
|
||||
if (!handled) {
|
||||
// 2. 如果输入框不支持 SEND,则退回到插入换行
|
||||
ic.commitText("\n", 1)
|
||||
val info = currentInputEditorInfo
|
||||
|
||||
var handled = false
|
||||
|
||||
if (info != null) {
|
||||
// 取出当前 EditText 声明的 action
|
||||
val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION
|
||||
|
||||
// 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||
handled = ic.performEditorAction(actionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false
|
||||
// 就降级为“标准回车”
|
||||
if (!handled) {
|
||||
sendEnterKey(ic)
|
||||
}
|
||||
|
||||
playKeyClick()
|
||||
clearEditorState()
|
||||
}
|
||||
|
||||
private fun sendEnterKey(ic: InputConnection) {
|
||||
// 按下+抬起 KEYCODE_ENTER
|
||||
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
|
||||
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
|
||||
}
|
||||
|
||||
// 按键音效
|
||||
override fun playKeyClick() {
|
||||
@@ -666,26 +949,119 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
}
|
||||
|
||||
// ================== 长按删除 ==================
|
||||
|
||||
// 真正实现逻辑(基本照搬你原来的 attachRepeatDelete)
|
||||
private fun attachRepeatDeleteInternal(view: View) {
|
||||
view.setOnLongClickListener {
|
||||
val dp = resources.displayMetrics.density
|
||||
val triggerUp = 48f * dp // 触发“准备清空”的上滑距离
|
||||
val cancelBack = 48f * dp // 回滑取消阈值(小于 triggerUp,防抖)
|
||||
val maxDx = 48f * dp
|
||||
|
||||
var downX = 0f
|
||||
var downY = 0f
|
||||
|
||||
var pendingSwipeClear = false // 是否处于“准备清空”
|
||||
var resumeDeletingAfterCancel = false // 取消后是否要恢复连删
|
||||
|
||||
fun startRepeatDeleteNow() {
|
||||
if (!isDeleting) {
|
||||
isDeleting = true
|
||||
mainHandler.postDelayed(repeatDelRunnable, repeatDelInitialDelay)
|
||||
deleteOne() // 首次立刻删一次
|
||||
deleteOne()
|
||||
}
|
||||
}
|
||||
|
||||
view.setOnLongClickListener {
|
||||
// 只要不是准备清空,就允许长按连删
|
||||
if (!pendingSwipeClear) {
|
||||
startRepeatDeleteNow()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
view.setOnTouchListener { _, event ->
|
||||
when (event.actionMasked) {
|
||||
|
||||
android.view.MotionEvent.ACTION_DOWN -> {
|
||||
downX = event.x
|
||||
downY = event.y
|
||||
pendingSwipeClear = false
|
||||
resumeDeletingAfterCancel = false
|
||||
dismissSwipeClearHint()
|
||||
false
|
||||
}
|
||||
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
val dy = event.y - downY // 上滑 dy < 0
|
||||
val dx = abs(event.x - downX)
|
||||
if (dx > maxDx) return@setOnTouchListener pendingSwipeClear
|
||||
|
||||
// 1) 还没进入准备清空:检测上滑触发
|
||||
if (!pendingSwipeClear) {
|
||||
if (-dy >= triggerUp) {
|
||||
pendingSwipeClear = true
|
||||
|
||||
// 如果此时正在连删(长按已触发),记录一下,方便取消时恢复
|
||||
resumeDeletingAfterCancel = isDeleting
|
||||
stopRepeatDelete()
|
||||
|
||||
showSwipeClearHint(view, "Clear")
|
||||
return@setOnTouchListener true
|
||||
}
|
||||
return@setOnTouchListener false
|
||||
}
|
||||
|
||||
// 2) 已经进入准备清空:允许“回滑取消”
|
||||
// 当你往下滑回来,dy 变大(不那么负),达到 cancelBack 就取消
|
||||
if (-dy <= cancelBack) {
|
||||
pendingSwipeClear = false
|
||||
dismissSwipeClearHint()
|
||||
|
||||
// 如果之前是长按连删途中进入的准备清空,那取消后恢复连删
|
||||
if (resumeDeletingAfterCancel) {
|
||||
resumeDeletingAfterCancel = false
|
||||
startRepeatDeleteNow()
|
||||
}
|
||||
return@setOnTouchListener false
|
||||
}
|
||||
|
||||
// 仍处于准备清空:持续消费,保证能收到 UP 来决定是否清空
|
||||
true
|
||||
}
|
||||
|
||||
android.view.MotionEvent.ACTION_UP,
|
||||
android.view.MotionEvent.ACTION_CANCEL,
|
||||
android.view.MotionEvent.ACTION_OUTSIDE -> stopRepeatDelete()
|
||||
android.view.MotionEvent.ACTION_OUTSIDE -> {
|
||||
|
||||
// 如果我们处于“准备清空”,才由我们接管结束逻辑
|
||||
if (pendingSwipeClear) {
|
||||
stopRepeatDelete()
|
||||
|
||||
if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
|
||||
clearAllAndBackup()
|
||||
}
|
||||
|
||||
pendingSwipeClear = false
|
||||
resumeDeletingAfterCancel = false
|
||||
dismissSwipeClearHint()
|
||||
|
||||
// 消费 UP,避免 click/longclick 再触发
|
||||
return@setOnTouchListener true
|
||||
}
|
||||
|
||||
// 不在准备清空:不要吃掉 UP/CANCEL,让 View 收到 UP 取消长按检测
|
||||
stopRepeatDelete() // 可留可不留;一般点按不会进入 isDeleting
|
||||
pendingSwipeClear = false
|
||||
resumeDeletingAfterCancel = false
|
||||
dismissSwipeClearHint()
|
||||
|
||||
return@setOnTouchListener false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun stopRepeatDelete() {
|
||||
if (isDeleting) {
|
||||
@@ -771,6 +1147,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
||||
numberKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
symbolKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
aiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
emojiKeyboard?.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
|
||||
}
|
||||
|
||||
// ================== bigram & 联想实现 ==================
|
||||
|
||||
@@ -13,11 +13,173 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.example.myapplication.MainActivity
|
||||
import com.example.myapplication.theme.ThemeManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.ScrollView
|
||||
import com.example.myapplication.network.NetworkClient
|
||||
import com.example.myapplication.network.LlmStreamCallback
|
||||
import okhttp3.Call
|
||||
|
||||
class AiKeyboard(
|
||||
env: KeyboardEnvironment
|
||||
) : BaseKeyboard(env) {
|
||||
|
||||
private var currentStreamCall: Call? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val messagesContainer: LinearLayout by lazy {
|
||||
val res = env.ctx.resources
|
||||
val id = res.getIdentifier("container_messages", "id", env.ctx.packageName)
|
||||
rootView.findViewById(id)
|
||||
}
|
||||
|
||||
private val messagesScrollView: ScrollView by lazy {
|
||||
val res = env.ctx.resources
|
||||
val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName)
|
||||
rootView.findViewById(id)
|
||||
}
|
||||
|
||||
// 当前正在流式更新的那一个 AI 文本
|
||||
private var currentAssistantTextView: TextView? = null
|
||||
|
||||
// 用来处理 <SPLIT> 的缓冲
|
||||
private val streamBuffer = StringBuilder()
|
||||
|
||||
|
||||
//新建一条 AI 消息(空内容),返回里面的 TextView 用来后续流式更新
|
||||
|
||||
private fun addAssistantMessage(initialText: String = ""): TextView {
|
||||
val inflater = env.layoutInflater
|
||||
val res = env.ctx.resources
|
||||
val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName)
|
||||
|
||||
val itemView = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout
|
||||
val tv = itemView.findViewById<TextView>(
|
||||
res.getIdentifier("tv_content", "id", env.ctx.packageName)
|
||||
)
|
||||
tv.text = initialText
|
||||
messagesContainer.addView(itemView)
|
||||
|
||||
scrollToBottom()
|
||||
return tv
|
||||
}
|
||||
|
||||
/**
|
||||
* (可选)如果你也想显示用户提问
|
||||
*/
|
||||
private fun addUserMessage(text: String) {
|
||||
// 简单写:复用同一个 item 布局
|
||||
val tv = addAssistantMessage(text)
|
||||
// 这里可以改成设置 gravity、背景区分用户/AI 等
|
||||
}
|
||||
|
||||
private fun scrollToBottom() {
|
||||
// 延迟一点点执行,保证 addView 完成后再滚动
|
||||
messagesScrollView.post {
|
||||
messagesScrollView.fullScroll(View.FOCUS_DOWN)
|
||||
}
|
||||
}
|
||||
|
||||
//后端每来一个 llm_chunk 的 data,就调用一次这个方法
|
||||
private fun onLlmChunk(data: String) {
|
||||
// 丢掉 data=":\n\n" 这条
|
||||
if (data == ":\n\n") return
|
||||
|
||||
// 确保在主线程更新 UI
|
||||
mainHandler.post {
|
||||
// 如果还没有正在流式的 TextView,就新建一条 AI 消息
|
||||
if (currentAssistantTextView == null) {
|
||||
currentAssistantTextView = addAssistantMessage("")
|
||||
streamBuffer.clear()
|
||||
}
|
||||
|
||||
// 累积到缓冲区
|
||||
streamBuffer.append(data)
|
||||
|
||||
// 先整体把 ":\n\n" 删掉(以防万一有别的地方混进来)
|
||||
var text = streamBuffer.toString().replace(":\n\n", "")
|
||||
|
||||
// 处理 <SPLIT>:代表下一句/下一条消息
|
||||
val splitTag = "<SPLIT>"
|
||||
var index = text.indexOf(splitTag)
|
||||
|
||||
while (index != -1) {
|
||||
// split 前面这一段是上一条消息的最终内容
|
||||
val before = text.substring(0, index)
|
||||
currentAssistantTextView?.text = before
|
||||
scrollToBottom()
|
||||
|
||||
// 开启下一条 AI 消息
|
||||
currentAssistantTextView = addAssistantMessage("")
|
||||
|
||||
// 剩下的留给下一轮
|
||||
text = text.substring(index + splitTag.length)
|
||||
index = text.indexOf(splitTag)
|
||||
}
|
||||
|
||||
// 循环结束后 text 就是「当前这条消息的未完成尾巴」
|
||||
currentAssistantTextView?.text = text
|
||||
scrollToBottom()
|
||||
|
||||
// 缓冲区只保留尾巴(避免无限变长)
|
||||
streamBuffer.clear()
|
||||
streamBuffer.append(text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 收到 type="done" 时调用,表示这一轮回答结束
|
||||
private fun onLlmDone() {
|
||||
mainHandler.post {
|
||||
// 这里目前不需要做太多事,必要的话可以清掉 buffer
|
||||
streamBuffer.clear()
|
||||
currentAssistantTextView = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 开始一次新的 AI 回答流式请求
|
||||
fun startAiStream(userQuestion: String) {
|
||||
// 可选:先把用户问题显示出来
|
||||
addUserMessage(userQuestion)
|
||||
|
||||
// 如果之前有没结束的流,先取消
|
||||
currentStreamCall?.cancel()
|
||||
|
||||
currentStreamCall = NetworkClient.startLlmStream(
|
||||
question = userQuestion,
|
||||
callback = object : LlmStreamCallback {
|
||||
override fun onEvent(type: String, data: String?) {
|
||||
when (type) {
|
||||
"llm_chunk" -> {
|
||||
if (data != null) {
|
||||
onLlmChunk(data) // 这里就是之前写的流式 UI 更新
|
||||
}
|
||||
}
|
||||
"done" -> {
|
||||
onLlmDone() // 一轮结束
|
||||
}
|
||||
"search_result" -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(t: Throwable) {
|
||||
addAssistantMessage("出错了:${t.message}")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 比如键盘关闭时可以调用一次,避免内存泄漏 / 多余请求
|
||||
fun cancelAiStream() {
|
||||
currentStreamCall?.cancel()
|
||||
currentStreamCall = null
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 以下是 BaseKeyboard 的实现
|
||||
override val rootView: View = run {
|
||||
val res = env.ctx.resources
|
||||
val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName)
|
||||
@@ -93,6 +255,17 @@ class AiKeyboard(
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
|
||||
// 获取ai_persona和ai_output视图引用
|
||||
val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
|
||||
val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
|
||||
|
||||
val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById<View?>(aiPersonaId) else null
|
||||
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
|
||||
|
||||
// 初始化显示状态:显示ai_persona,隐藏ai_output
|
||||
aiPersonaView?.visibility = View.VISIBLE
|
||||
aiOutputView?.visibility = View.GONE
|
||||
|
||||
// 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc,就绑定一下
|
||||
val backId = res.getIdentifier("key_abc", "id", pkg)
|
||||
if (backId != 0) {
|
||||
@@ -108,6 +281,59 @@ class AiKeyboard(
|
||||
navigateToRechargeFragment()
|
||||
}
|
||||
}
|
||||
|
||||
//显示切换
|
||||
val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
|
||||
if (returnButtonId != 0) {
|
||||
rootView.findViewById<View?>(returnButtonId)?.let { returnButton ->
|
||||
// 确保按钮可点击且可获得焦点,防止事件穿透
|
||||
returnButton.isClickable = true
|
||||
returnButton.isFocusable = true
|
||||
returnButton.setOnClickListener {
|
||||
// 点击Return_keyboard:先隐藏ai_output,再显示ai_persona(顺序动画)
|
||||
aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
|
||||
aiOutputView?.visibility = View.GONE
|
||||
// 等ai_output完全隐藏后再显示ai_persona
|
||||
aiPersonaView?.visibility = View.VISIBLE
|
||||
aiPersonaView?.alpha = 0f
|
||||
aiPersonaView?.animate()?.alpha(1f)?.setDuration(150)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cardButtonId = res.getIdentifier("card", "id", pkg)
|
||||
if (cardButtonId != 0) {
|
||||
rootView.findViewById<View?>(cardButtonId)?.setOnClickListener {
|
||||
// 点击card:先隐藏ai_persona,再显示ai_output(顺序动画)
|
||||
aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
|
||||
aiPersonaView?.visibility = View.GONE
|
||||
// 等ai_persona完全隐藏后再显示ai_output
|
||||
aiOutputView?.visibility = View.VISIBLE
|
||||
aiOutputView?.alpha = 0f
|
||||
aiOutputView?.animate()?.alpha(1f)?.setDuration(150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// // 假设 ai_keyboard.xml 里有一个发送按钮 key_send
|
||||
// val sendId = res.getIdentifier("key_send", "id", pkg)
|
||||
// val inputId = res.getIdentifier("et_prompt", "id", pkg) // 假设这是你的输入框 id
|
||||
|
||||
// if (sendId != 0 && inputId != 0) {
|
||||
// val inputView = rootView.findViewById<TextView?>(inputId)
|
||||
|
||||
// rootView.findViewById<View?>(sendId)?.setOnClickListener {
|
||||
// val question = inputView?.text?.toString()?.trim().orEmpty()
|
||||
// if (question.isNotEmpty()) {
|
||||
// startAiStream(question)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private fun navigateToRechargeFragment() {
|
||||
@@ -128,6 +354,21 @@ class AiKeyboard(
|
||||
borderColor: ColorStateList,
|
||||
backgroundColor: ColorStateList
|
||||
) {
|
||||
|
||||
applyKeyBackgroundsForTheme()
|
||||
}
|
||||
}
|
||||
// ==============================刷新主题==================================
|
||||
override fun applyKeyBackgroundsForTheme() {
|
||||
// 背景
|
||||
applyKeyBackground(rootView, "background")
|
||||
|
||||
// // AI 键盘上的功能键(按你现有 layout 里出现过的 id 来列)
|
||||
// val others = listOf(
|
||||
// "key_abc", // 返回主键盘
|
||||
// "key_vip", // VIP
|
||||
// "Return_keyboard", // 返回 persona 页
|
||||
// "card" // 切换到 output 页
|
||||
// // 如果后续 ai_keyboard.xml 里还有其它需要换肤的 key id,继续往这里加
|
||||
// )
|
||||
// others.forEach { applyKeyBackground(rootView, it) }
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,47 @@
|
||||
package com.example.myapplication.keyboard
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
|
||||
/**
|
||||
* 所有键盘的基础类:只处理文本颜色、边距 / 内边距 这些通用样式。
|
||||
* 不再直接访问 resources,统一走 env.ctx.resources。
|
||||
*/
|
||||
abstract class BaseKeyboard(
|
||||
protected val env: KeyboardEnvironment
|
||||
) {
|
||||
protected val vibrator: Vibrator? = env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
/** 根布局 */
|
||||
abstract val rootView: View
|
||||
|
||||
/**
|
||||
* 应用主题:文字颜色 + 边框(只调 margin/padding,不动你每个键的图片背景)
|
||||
* 应用主题:文字颜色 + 边框 + 按键背景
|
||||
* 调用后文字颜色、边框立即生效,子类刷新按键背景
|
||||
*/
|
||||
open fun applyTheme(
|
||||
textColor: ColorStateList,
|
||||
borderColor: ColorStateList,
|
||||
backgroundColor: ColorStateList
|
||||
) {
|
||||
// 文字颜色递归设置
|
||||
applyTextColorToAllTextViews(rootView, textColor)
|
||||
|
||||
// 边框(margin / padding)递归设置
|
||||
applyBorderToAllKeyViews(rootView)
|
||||
|
||||
// 子类刷新按键背景(如 ThemeManager 提供的图片)
|
||||
applyKeyBackgroundsForTheme()
|
||||
}
|
||||
|
||||
// 文字颜色递归设置
|
||||
/** 子类实现:刷新按键背景 */
|
||||
abstract fun applyKeyBackgroundsForTheme()
|
||||
|
||||
// ------------------- 工具方法 -------------------
|
||||
|
||||
/** 递归设置 TextView 文字颜色 */
|
||||
protected fun applyTextColorToAllTextViews(root: View?, color: ColorStateList) {
|
||||
if (root == null) return
|
||||
|
||||
@@ -35,11 +49,8 @@ abstract class BaseKeyboard(
|
||||
when (v) {
|
||||
is TextView -> v.setTextColor(color)
|
||||
is ViewGroup -> {
|
||||
val childCount = v.childCount
|
||||
var i = 0
|
||||
while (i < childCount) {
|
||||
for (i in 0 until v.childCount) {
|
||||
dfs(v.getChildAt(i))
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,35 +60,29 @@ abstract class BaseKeyboard(
|
||||
}
|
||||
|
||||
/**
|
||||
* 只设置 margin / padding,不统一改背景,避免覆盖你用 ThemeManager 设置的按键图。
|
||||
* 跟你原来 MyInputMethodService.applyBorderColorToAllKeys 的逻辑保持一致。
|
||||
* 只设置 margin / padding,不改背景,避免覆盖 ThemeManager 设置的按键图
|
||||
*/
|
||||
protected fun applyBorderToAllKeyViews(root: View?) {
|
||||
if (root == null) return
|
||||
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
|
||||
val keyMarginPx = 1.dpToPx()
|
||||
val keyPaddingH = 6.dpToPx()
|
||||
|
||||
// 忽略 suggestion_0..20(联想栏),不改它们背景
|
||||
// 忽略 suggestion_0..20(联想栏)
|
||||
val ignoredIds = HashSet<Int>().apply {
|
||||
var i = 0
|
||||
while (i <= 20) {
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
for (i in 0..20) {
|
||||
val id = res.getIdentifier("suggestion_$i", "id", pkg)
|
||||
if (id != 0) add(id)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
fun dfs(v: View?) {
|
||||
when (v) {
|
||||
is TextView -> {
|
||||
if (ignoredIds.contains(v.id)) {
|
||||
// 联想词不加边距
|
||||
return
|
||||
}
|
||||
if (ignoredIds.contains(v.id)) return
|
||||
|
||||
val lp = v.layoutParams
|
||||
if (lp is LinearLayout.LayoutParams) {
|
||||
lp.setMargins(keyMarginPx, keyMarginPx, keyMarginPx, keyMarginPx)
|
||||
@@ -91,11 +96,8 @@ abstract class BaseKeyboard(
|
||||
)
|
||||
}
|
||||
is ViewGroup -> {
|
||||
val childCount = v.childCount
|
||||
var i = 0
|
||||
while (i < childCount) {
|
||||
for (i in 0 until v.childCount) {
|
||||
dfs(v.getChildAt(i))
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +106,19 @@ abstract class BaseKeyboard(
|
||||
dfs(root)
|
||||
}
|
||||
|
||||
// dp -> px 工具
|
||||
/** dp -> px */
|
||||
protected fun Int.dpToPx(): Int {
|
||||
val density = env.ctx.resources.displayMetrics.density
|
||||
return (this * density + 0.5f).toInt()
|
||||
}
|
||||
|
||||
/** 按键震动 */
|
||||
protected fun vibrateKey() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
vibrator?.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator?.vibrate(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("(>﹏<)","(×_×)","( ̄□ ̄;)","(; ̄Д ̄)","(╯︵╰,)","(╥﹏╥)","(ಠ_ಠ)"))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) 重新提交 pages(span 不变)
|
||||
val span = if (isEmojiNow) emojiSpan else kaomojiSpan
|
||||
val itemLayoutRes = if (isEmojiNow) {
|
||||
R.layout.item_emoji
|
||||
} else {
|
||||
R.layout.item_kaomoji
|
||||
}
|
||||
pagerAdapter.submit(flatPages, span, itemLayoutRes)
|
||||
|
||||
// 5) 回到原来的 realIndex(对应同一位置)
|
||||
val base = pagerAdapter.getBasePosition()
|
||||
pager.setCurrentItem(base + oldRealIndex, false)
|
||||
pager.post { onForceSyncUI(base + oldRealIndex) }
|
||||
}
|
||||
|
||||
|
||||
// 当前模式下的数据
|
||||
private var categories: List<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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) 更新 span(layoutManager 存在则改 spanCount)
|
||||
val lm = (grid.layoutManager as? GridLayoutManager)
|
||||
if (lm != null && curSpan != spanCount) {
|
||||
lm.spanCount = spanCount
|
||||
curSpan = spanCount
|
||||
}
|
||||
|
||||
// 2) 更新 item 布局(需要换 adapter 或让 adapter 支持 setLayoutRes)
|
||||
if (curItemLayoutRes != itemLayoutRes) {
|
||||
curItemLayoutRes = itemLayoutRes
|
||||
adapter = SimpleStringGridAdapter(curItemLayoutRes, onItemClick)
|
||||
grid.adapter = adapter
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(items: List<String>) {
|
||||
adapter.submit(items)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,7 +37,15 @@ interface KeyboardEnvironment {
|
||||
fun showNumberKeyboard()
|
||||
fun showSymbolKeyboard()
|
||||
fun showAiKeyboard()
|
||||
//emoji键盘
|
||||
fun showEmojiKeyboard()
|
||||
|
||||
// 音效
|
||||
fun playKeyClick()
|
||||
|
||||
// 回填上次清空的文本
|
||||
fun revokeLastClearedText()
|
||||
|
||||
// 检查是否有可回填的文本
|
||||
fun hasClearedText(): Boolean
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ import com.example.myapplication.theme.ThemeManager
|
||||
class MainKeyboard(
|
||||
env: KeyboardEnvironment,
|
||||
private val swipeAltMap: Map<Char, Char>,
|
||||
/**
|
||||
* 交给 MyInputMethodService 切换 Shift 状态,并返回最新状态
|
||||
*/
|
||||
private val onToggleShift: () -> Boolean
|
||||
) : BaseKeyboard(env) {
|
||||
|
||||
@@ -34,211 +31,153 @@ class MainKeyboard(
|
||||
private var isShiftOn: Boolean = false
|
||||
private var keyPreviewPopup: PopupWindow? = null
|
||||
|
||||
// ======================== 震动相关 ========================
|
||||
|
||||
private val vibrator: Vibrator? =
|
||||
env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
|
||||
private fun vibrateKey(
|
||||
duration: Long = 30L, // 时间:10~40 推荐
|
||||
amplitude: Int = 255 // 1~255,100~150 最舒服
|
||||
) {
|
||||
val v = vibrator ?: return
|
||||
if (!v.hasVibrator()) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
v.vibrate(
|
||||
VibrationEffect.createOneShot(duration, amplitude)
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
v.vibrate(duration)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// 没权限就自动静音,不崩溃
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
applyPerKeyBackgroundForMainKeyboard(rootView)
|
||||
|
||||
applyTheme(
|
||||
env.currentTextColor,
|
||||
env.currentBorderColor,
|
||||
env.currentBackgroundColor
|
||||
)
|
||||
|
||||
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
|
||||
setupListenersForMain(rootView)
|
||||
}
|
||||
|
||||
// ======================== 背景图 ========================
|
||||
|
||||
// -------------------- 背景 --------------------
|
||||
private fun applyPerKeyBackgroundForMainKeyboard(root: View) {
|
||||
// a..z 小写
|
||||
var c = 'a'
|
||||
while (c <= 'z') {
|
||||
val idName = "key_$c"
|
||||
applyKeyBackground(root, idName)
|
||||
applyKeyBackground(root, "key_$c")
|
||||
c++
|
||||
}
|
||||
|
||||
// 键盘背景
|
||||
applyKeyBackground(root, "background")
|
||||
|
||||
// 其他功能键
|
||||
val others = listOf(
|
||||
"key_space",
|
||||
"key_send",
|
||||
"key_del",
|
||||
"key_up",
|
||||
"key_123",
|
||||
"key_ai",
|
||||
"Key_collapse"
|
||||
"key_space", "key_send", "key_del", "key_up",
|
||||
"key_123", "key_ai", "Key_collapse","key_emoji","key_revoke"
|
||||
)
|
||||
for (idName in others) {
|
||||
applyKeyBackground(root, idName)
|
||||
}
|
||||
others.forEach { applyKeyBackground(root, it) }
|
||||
}
|
||||
|
||||
private fun applyKeyBackground(
|
||||
root: View,
|
||||
viewIdName: String,
|
||||
drawableName: String? = null
|
||||
) {
|
||||
private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
|
||||
val res = env.ctx.resources
|
||||
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
|
||||
if (viewId == 0) return
|
||||
val v = root.findViewById<View?>(viewId) ?: return
|
||||
|
||||
val keyName = drawableName ?: viewIdName
|
||||
val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
|
||||
|
||||
if (viewIdName == "background") {
|
||||
val scaled = scaleDrawableToHeight(rawDrawable, 243f)
|
||||
v.background = scaled
|
||||
return
|
||||
v.background = scaleDrawableToHeight(rawDrawable, 243f)
|
||||
} else {
|
||||
v.background = rawDrawable
|
||||
}
|
||||
v.background = rawDrawable
|
||||
}
|
||||
|
||||
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
|
||||
val res = env.ctx.resources
|
||||
val dm = res.displayMetrics
|
||||
val targetHeightPx = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
targetDp,
|
||||
dm
|
||||
).toInt()
|
||||
|
||||
val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
|
||||
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
|
||||
val ratio = targetHeightPx.toFloat() / h
|
||||
val targetWidthPx = (w * ratio).toInt()
|
||||
|
||||
val ratio = targetHeightPx.toFloat() / bitmap.height
|
||||
val targetWidthPx = (bitmap.width * ratio).toInt()
|
||||
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
|
||||
return BitmapDrawable(res, scaled).apply {
|
||||
setBounds(0, 0, targetWidthPx, targetHeightPx)
|
||||
}
|
||||
return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
|
||||
}
|
||||
|
||||
// ======================== 事件绑定 ========================
|
||||
// -------------------- 实现主题刷新 --------------------
|
||||
override fun applyKeyBackgroundsForTheme() {
|
||||
// 刷新字母背景
|
||||
var c = 'a'
|
||||
while (c <= 'z') {
|
||||
val drawableName = if (isShiftOn) "key_${c}_up" else "key_$c"
|
||||
applyKeyBackground(rootView, "key_$c", drawableName)
|
||||
c++
|
||||
}
|
||||
// 刷新 Shift 键
|
||||
val upDrawableName = if (isShiftOn) "key_up_upper" else "key_up"
|
||||
applyKeyBackground(rootView, "key_up", upDrawableName)
|
||||
|
||||
// 刷新其他功能键
|
||||
val others = listOf("key_space", "key_send", "key_del", "key_123", "key_ai","key_emoji", "Key_collapse", "background","key_revoke")
|
||||
others.forEach { applyKeyBackground(rootView, it) }
|
||||
}
|
||||
|
||||
// -------------------- 事件绑定 --------------------
|
||||
private fun setupListenersForMain(view: View) {
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
|
||||
// a..z:支持上滑副字符
|
||||
// 初始化时设置Revoke按钮的可见性
|
||||
updateRevokeButtonVisibility(view, res, pkg)
|
||||
|
||||
var c = 'a'
|
||||
while (c <= 'z') {
|
||||
val id = res.getIdentifier("key_$c", "id", pkg)
|
||||
val tv = view.findViewById<TextView?>(id)
|
||||
val tv = view.findViewById<TextView?>(res.getIdentifier("key_$c", "id", pkg))
|
||||
if (tv != null) {
|
||||
val baseChar = c
|
||||
val altChar = swipeAltMap[baseChar]
|
||||
|
||||
attachKeyTouchWithSwipe(
|
||||
tv,
|
||||
normalCharProvider = { baseChar },
|
||||
altCharProvider = altChar?.let { ac ->
|
||||
{ ac }
|
||||
}
|
||||
)
|
||||
attachKeyTouchWithSwipe(tv, { baseChar }, altChar?.let { { it } })
|
||||
}
|
||||
c++
|
||||
}
|
||||
|
||||
// space
|
||||
view.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.commitKey(' ')
|
||||
}
|
||||
view.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey(); env.commitKey(' ')
|
||||
// 输入新内容后更新按钮可见性
|
||||
updateRevokeButtonVisibility(view, res, pkg)
|
||||
}
|
||||
|
||||
// Shift
|
||||
val shiftId = res.getIdentifier("key_up", "id", pkg)
|
||||
view.findViewById<View?>(shiftId)?.setOnClickListener {
|
||||
view.findViewById<View?>(res.getIdentifier("key_up", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey()
|
||||
isShiftOn = onToggleShift()
|
||||
it.isActivated = isShiftOn
|
||||
updateKeyBackgroundsForLetters(view)
|
||||
applyKeyBackgroundsForTheme() // 立即刷新主题背景
|
||||
}
|
||||
|
||||
// 删除(单击;长按由 MyInputMethodService 挂)
|
||||
view.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.deleteOne()
|
||||
}
|
||||
|
||||
//关闭键盘
|
||||
rootView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey() // 如果这个方法在当前类里有
|
||||
env.hideKeyboard()
|
||||
view.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey(); env.deleteOne()
|
||||
// 删除内容后更新按钮可见性
|
||||
updateRevokeButtonVisibility(view, res, pkg)
|
||||
}
|
||||
|
||||
view.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey(); env.hideKeyboard()
|
||||
}
|
||||
|
||||
view.findViewById<View?>(res.getIdentifier("key_123", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey(); env.showNumberKeyboard()
|
||||
}
|
||||
|
||||
view.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey(); env.showAiKeyboard()
|
||||
}
|
||||
|
||||
view.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))?.setOnClickListener {
|
||||
vibrateKey(); env.performSendAction()
|
||||
// 发送后更新按钮可见性
|
||||
updateRevokeButtonVisibility(view, res, pkg)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新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
|
||||
}
|
||||
|
||||
// 切换数字键盘
|
||||
view.findViewById<View?>(res.getIdentifier("key_123", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showNumberKeyboard()
|
||||
}
|
||||
|
||||
// 跳 AI
|
||||
view.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showAiKeyboard()
|
||||
}
|
||||
|
||||
// 发送
|
||||
view.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.performSendAction()
|
||||
}
|
||||
// 公共方法:更新Revoke按钮的可见性(供外部调用)
|
||||
fun updateRevokeButtonVisibility() {
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
updateRevokeButtonVisibility(rootView, res, pkg)
|
||||
}
|
||||
|
||||
// Shift 后更新字母按键背景(key_a vs key_a_up)
|
||||
private fun updateKeyBackgroundsForLetters(root: View) {
|
||||
var c = 'a'
|
||||
while (c <= 'z') {
|
||||
val idName = "key_$c"
|
||||
val drawableName = if (isShiftOn) "${idName}_up" else idName
|
||||
applyKeyBackground(root, idName, drawableName)
|
||||
c++
|
||||
}
|
||||
|
||||
val upKeyIdName = "key_up"
|
||||
val upDrawableName = if (isShiftOn) "key_up_upper" else "key_up"
|
||||
applyKeyBackground(root, upKeyIdName, upDrawableName)
|
||||
}
|
||||
|
||||
// ======================== 触摸 + 预览 ========================
|
||||
|
||||
// -------------------- 触摸 + 预览 --------------------
|
||||
private fun attachKeyTouchWithSwipe(
|
||||
view: View,
|
||||
normalCharProvider: () -> Char,
|
||||
@@ -252,10 +191,9 @@ class MainKeyboard(
|
||||
view.setOnTouchListener { v, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downY = event.rawY
|
||||
isAlt = false
|
||||
downY = event.rawY; isAlt = false
|
||||
currentChar = normalCharProvider()
|
||||
vibrateKey() // 按下就震
|
||||
vibrateKey()
|
||||
showKeyPreview(v, currentChar.toString())
|
||||
v.isPressed = true
|
||||
true
|
||||
@@ -278,8 +216,7 @@ class MainKeyboard(
|
||||
v.isPressed = false
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_OUTSIDE -> {
|
||||
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> {
|
||||
keyPreviewPopup?.dismiss()
|
||||
v.isPressed = false
|
||||
true
|
||||
@@ -291,7 +228,6 @@ class MainKeyboard(
|
||||
|
||||
private fun showKeyPreview(anchor: View, text: String) {
|
||||
keyPreviewPopup?.dismiss()
|
||||
|
||||
val tv = TextView(env.ctx).apply {
|
||||
this.text = text
|
||||
textSize = 26f
|
||||
@@ -309,14 +245,7 @@ class MainKeyboard(
|
||||
val w = (anchor.width * 1.2f).toInt()
|
||||
val h = (anchor.height * 1.2f).toInt()
|
||||
|
||||
keyPreviewPopup = PopupWindow(tv, w, h, false).apply {
|
||||
isClippingEnabled = false
|
||||
}
|
||||
|
||||
keyPreviewPopup?.showAsDropDown(
|
||||
anchor,
|
||||
-(w - anchor.width) / 2,
|
||||
-(h + anchor.height * 1.1f).toInt()
|
||||
)
|
||||
keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false }
|
||||
keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.PopupWindow
|
||||
import android.widget.TextView
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.theme.ThemeManager
|
||||
|
||||
class NumberKeyboard(
|
||||
@@ -29,241 +28,164 @@ class NumberKeyboard(
|
||||
|
||||
private var keyPreviewPopup: PopupWindow? = null
|
||||
|
||||
// ================= 震动相关 =================
|
||||
|
||||
private val vibrator: Vibrator? =
|
||||
env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
|
||||
private fun vibrateKey(
|
||||
duration: Long = 30L, // 时间:10~40 推荐
|
||||
amplitude: Int = 255 // 1~255,100~150 最舒服
|
||||
) {
|
||||
val v = vibrator ?: return
|
||||
if (!v.hasVibrator()) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
v.vibrate(
|
||||
VibrationEffect.createOneShot(duration, amplitude)
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
v.vibrate(duration)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// 没权限就自动静音,不崩溃
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
applyPerKeyBackgroundForNumberKeyboard(rootView)
|
||||
|
||||
// 初次创建立刻应用当前主题
|
||||
applyTheme(
|
||||
env.currentTextColor,
|
||||
env.currentBorderColor,
|
||||
env.currentBackgroundColor
|
||||
)
|
||||
|
||||
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
|
||||
setupListenersForNumberView(rootView)
|
||||
}
|
||||
|
||||
// ================= 背景(完全拷贝原逻辑,只是换成 env.ctx.resources) =================
|
||||
|
||||
// -------------------- 背景 --------------------
|
||||
private fun applyPerKeyBackgroundForNumberKeyboard(root: View) {
|
||||
val res = env.ctx.resources
|
||||
|
||||
// 0..9
|
||||
for (i in 0..9) {
|
||||
val idName = "key_$i"
|
||||
applyKeyBackground(root, idName)
|
||||
}
|
||||
|
||||
// 背景
|
||||
for (i in 0..9) applyKeyBackground(root, "key_$i")
|
||||
applyKeyBackground(root, "background")
|
||||
|
||||
// 符号键
|
||||
val symbolKeys = listOf(
|
||||
"key_comma",
|
||||
"key_dot",
|
||||
"key_minus",
|
||||
"key_slash",
|
||||
"key_colon",
|
||||
"key_semicolon",
|
||||
"key_paren_l",
|
||||
"key_paren_r",
|
||||
"key_dollar",
|
||||
"key_amp",
|
||||
"key_at",
|
||||
"key_question",
|
||||
"key_exclam",
|
||||
"key_quote",
|
||||
"key_quote_d"
|
||||
"key_comma","key_dot","key_minus","key_slash","key_colon","key_semicolon",
|
||||
"key_paren_l","key_paren_r","key_dollar","key_amp","key_at","key_question",
|
||||
"key_exclam","key_quote","key_quote_d"
|
||||
)
|
||||
symbolKeys.forEach { idName ->
|
||||
applyKeyBackground(root, idName)
|
||||
}
|
||||
symbolKeys.forEach { applyKeyBackground(root, it) }
|
||||
|
||||
// 功能键
|
||||
val others = listOf(
|
||||
"key_symbols_more",
|
||||
"key_abc",
|
||||
"key_ai",
|
||||
"key_space",
|
||||
"key_send",
|
||||
"key_del",
|
||||
"Key_collapse"
|
||||
"key_symbols_more","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","key_emoji","key_revoke"
|
||||
)
|
||||
others.forEach { idName ->
|
||||
applyKeyBackground(root, idName)
|
||||
}
|
||||
others.forEach { applyKeyBackground(root, it) }
|
||||
}
|
||||
|
||||
private fun applyKeyBackground(
|
||||
root: View,
|
||||
viewIdName: String,
|
||||
drawableName: String? = null
|
||||
) {
|
||||
private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
|
||||
val res = env.ctx.resources
|
||||
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
|
||||
if (viewId == 0) return
|
||||
val v = root.findViewById<View?>(viewId) ?: return
|
||||
|
||||
val keyName = drawableName ?: viewIdName
|
||||
val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
|
||||
|
||||
if (viewIdName == "background") {
|
||||
val scaled = scaleDrawableToHeight(rawDrawable, 243f)
|
||||
v.background = scaled
|
||||
return
|
||||
v.background = scaleDrawableToHeight(rawDrawable, 243f)
|
||||
} else {
|
||||
v.background = rawDrawable
|
||||
}
|
||||
v.background = rawDrawable
|
||||
}
|
||||
|
||||
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
|
||||
val res = env.ctx.resources
|
||||
val dm = res.displayMetrics
|
||||
val targetHeightPx = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
targetDp,
|
||||
dm
|
||||
).toInt()
|
||||
|
||||
val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
|
||||
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
|
||||
val ratio = targetHeightPx.toFloat() / h
|
||||
val targetWidthPx = (w * ratio).toInt()
|
||||
|
||||
val scaledBitmap = Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
targetWidthPx,
|
||||
targetHeightPx,
|
||||
true
|
||||
)
|
||||
return BitmapDrawable(res, scaledBitmap).apply {
|
||||
setBounds(0, 0, targetWidthPx, targetHeightPx)
|
||||
}
|
||||
val ratio = targetHeightPx.toFloat() / bitmap.height
|
||||
val targetWidthPx = (bitmap.width * ratio).toInt()
|
||||
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
|
||||
return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
|
||||
}
|
||||
|
||||
// ================= 按键事件 =================
|
||||
// -------------------- 实现主题刷新 --------------------
|
||||
override fun applyKeyBackgroundsForTheme() {
|
||||
// 刷新数字键
|
||||
for (i in 0..9) applyKeyBackground(rootView, "key_$i")
|
||||
|
||||
// 刷新符号键
|
||||
val symbolKeys = listOf(
|
||||
"key_comma","key_dot","key_minus","key_slash","key_colon","key_semicolon",
|
||||
"key_paren_l","key_paren_r","key_dollar","key_amp","key_at","key_question",
|
||||
"key_exclam","key_quote","key_quote_d"
|
||||
)
|
||||
symbolKeys.forEach { applyKeyBackground(rootView, it) }
|
||||
|
||||
// 刷新功能键和背景
|
||||
val others = listOf(
|
||||
"key_symbols_more","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","background","key_emoji","key_revoke"
|
||||
)
|
||||
others.forEach { applyKeyBackground(rootView, it) }
|
||||
}
|
||||
|
||||
// -------------------- 按键事件 --------------------
|
||||
private fun setupListenersForNumberView(numView: View) {
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
|
||||
// 0~9
|
||||
// 初始化时设置Revoke按钮的可见性
|
||||
updateRevokeButtonVisibility(numView, res, pkg)
|
||||
|
||||
for (i in 0..9) {
|
||||
val id = res.getIdentifier("key_$i", "id", pkg)
|
||||
numView.findViewById<View?>(id)?.let { v ->
|
||||
attachKeyTouch(v) { i.toString()[0] }
|
||||
}
|
||||
numView.findViewById<View?>(id)?.let { attachKeyTouch(it) { i.toString()[0] } }
|
||||
}
|
||||
|
||||
// 符号键
|
||||
val symbolMap: List<Pair<String, Char>> = listOf(
|
||||
"key_comma" to ',',
|
||||
"key_dot" to '.',
|
||||
"key_minus" to '-',
|
||||
"key_slash" to '/',
|
||||
"key_colon" to ':',
|
||||
"key_semicolon" to ';',
|
||||
"key_paren_l" to '(',
|
||||
"key_paren_r" to ')',
|
||||
"key_dollar" to '$',
|
||||
"key_amp" to '&',
|
||||
"key_at" to '@',
|
||||
"key_question" to '?',
|
||||
"key_exclam" to '!',
|
||||
"key_quote" to '\'',
|
||||
"key_quote_d" to '”'
|
||||
"key_comma" to ',', "key_dot" to '.', "key_minus" to '-', "key_slash" to '/',
|
||||
"key_colon" to ':', "key_semicolon" to ';', "key_paren_l" to '(', "key_paren_r" to ')',
|
||||
"key_dollar" to '$', "key_amp" to '&', "key_at" to '@', "key_question" to '?',
|
||||
"key_exclam" to '!', "key_quote" to '\'', "key_quote_d" to '”'
|
||||
)
|
||||
symbolMap.forEach { (name, ch) ->
|
||||
val id = res.getIdentifier(name, "id", pkg)
|
||||
numView.findViewById<View?>(id)?.let { v ->
|
||||
attachKeyTouch(v) { ch }
|
||||
}
|
||||
numView.findViewById<View?>(id)?.let { attachKeyTouch(it) { ch } }
|
||||
}
|
||||
|
||||
// 切换:符号层
|
||||
// 功能键
|
||||
numView.findViewById<View?>(res.getIdentifier("key_symbols_more", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showSymbolKeyboard()
|
||||
}
|
||||
?.setOnClickListener { vibrateKey(); env.showSymbolKeyboard() }
|
||||
|
||||
// 切回字母
|
||||
numView.findViewById<View?>(res.getIdentifier("key_abc", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showMainKeyboard()
|
||||
}
|
||||
?.setOnClickListener { vibrateKey(); env.showMainKeyboard() }
|
||||
|
||||
// 跳 AI
|
||||
numView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showAiKeyboard()
|
||||
}
|
||||
?.setOnClickListener { vibrateKey(); env.showAiKeyboard() }
|
||||
|
||||
// 空格
|
||||
numView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.commitKey(' ')
|
||||
?.setOnClickListener {
|
||||
vibrateKey(); env.commitKey(' ')
|
||||
// 输入新内容后更新按钮可见性
|
||||
updateRevokeButtonVisibility(numView, res, pkg)
|
||||
}
|
||||
|
||||
// 发送
|
||||
numView.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.performSendAction()
|
||||
?.setOnClickListener {
|
||||
vibrateKey(); env.performSendAction()
|
||||
// 发送后更新按钮可见性
|
||||
updateRevokeButtonVisibility(numView, res, pkg)
|
||||
}
|
||||
|
||||
// 删除(单击;长按连删在 MyInputMethodService 里挂)
|
||||
numView.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.deleteOne()
|
||||
?.setOnClickListener {
|
||||
vibrateKey(); env.deleteOne()
|
||||
// 删除内容后更新按钮可见性
|
||||
updateRevokeButtonVisibility(numView, res, pkg)
|
||||
}
|
||||
|
||||
numView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
|
||||
?.setOnClickListener { vibrateKey(); env.hideKeyboard() }
|
||||
|
||||
//关闭键盘
|
||||
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 {
|
||||
vibrateKey(); env.revokeLastClearedText()
|
||||
// 回填后更新按钮可见性
|
||||
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) {
|
||||
view.setOnTouchListener { v, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
val ch = charProvider()
|
||||
vibrateKey() // 按下就震一下
|
||||
vibrateKey()
|
||||
showKeyPreview(v, ch.toString())
|
||||
v.isPressed = true
|
||||
true
|
||||
@@ -288,7 +210,6 @@ class NumberKeyboard(
|
||||
|
||||
private fun showKeyPreview(anchor: View, text: String) {
|
||||
keyPreviewPopup?.dismiss()
|
||||
|
||||
val tv = TextView(env.ctx).apply {
|
||||
this.text = text
|
||||
textSize = 26f
|
||||
@@ -306,14 +227,7 @@ class NumberKeyboard(
|
||||
val w = (anchor.width * 1.2f).toInt()
|
||||
val h = (anchor.height * 1.2f).toInt()
|
||||
|
||||
keyPreviewPopup = PopupWindow(tv, w, h, false).apply {
|
||||
isClippingEnabled = false
|
||||
}
|
||||
|
||||
keyPreviewPopup?.showAsDropDown(
|
||||
anchor,
|
||||
-(w - anchor.width) / 2,
|
||||
-(h + anchor.height * 1.1f).toInt()
|
||||
)
|
||||
keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false }
|
||||
keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.PopupWindow
|
||||
import android.widget.TextView
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.theme.ThemeManager
|
||||
|
||||
class SymbolKeyboard(
|
||||
@@ -29,259 +28,163 @@ class SymbolKeyboard(
|
||||
|
||||
private var keyPreviewPopup: PopupWindow? = null
|
||||
|
||||
// ================== 震动相关 ==================
|
||||
|
||||
private val vibrator: Vibrator? =
|
||||
env.ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
|
||||
private fun vibrateKey(
|
||||
duration: Long = 30L, // 时间:10~40 推荐
|
||||
amplitude: Int = 255 // 1~255,100~150 最舒服
|
||||
) {
|
||||
val v = vibrator ?: return
|
||||
if (!v.hasVibrator()) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
v.vibrate(
|
||||
VibrationEffect.createOneShot(duration, amplitude)
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
v.vibrate(duration)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// 没权限就自动静音,不崩溃
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// 按键背景图片(跟你原来 applyPerKeyBackgroundForSymbolKeyboard 一样)
|
||||
applyPerKeyBackgroundForSymbolKeyboard(rootView)
|
||||
|
||||
// 初次创建立刻应用当前主题色
|
||||
applyTheme(
|
||||
env.currentTextColor,
|
||||
env.currentBorderColor,
|
||||
env.currentBackgroundColor
|
||||
)
|
||||
|
||||
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
|
||||
setupListenersForSymbolView(rootView)
|
||||
}
|
||||
|
||||
// ================== 背景(完全按你原来的 key 列表) ==================
|
||||
|
||||
// -------------------- 背景 --------------------
|
||||
private fun applyPerKeyBackgroundForSymbolKeyboard(root: View) {
|
||||
val res = env.ctx.resources
|
||||
|
||||
val symbolKeys = listOf(
|
||||
// 第一行
|
||||
"key_bracket_l",
|
||||
"key_bracket_r",
|
||||
"key_brace_l",
|
||||
"key_brace_r",
|
||||
"key_hash",
|
||||
"key_percent",
|
||||
"key_caret",
|
||||
"key_asterisk",
|
||||
"key_plus",
|
||||
"key_equal",
|
||||
|
||||
// 第二行
|
||||
"key_underscore",
|
||||
"key_backslash",
|
||||
"key_pipe",
|
||||
"key_tilde",
|
||||
"key_lt",
|
||||
"key_gt",
|
||||
"key_euro",
|
||||
"key_pound",
|
||||
"key_money",
|
||||
"key_bullet",
|
||||
|
||||
// 第三行
|
||||
"key_dot",
|
||||
"key_comma",
|
||||
"key_question",
|
||||
"key_exclam",
|
||||
"key_quote"
|
||||
"key_bracket_l","key_bracket_r","key_brace_l","key_brace_r","key_hash","key_percent",
|
||||
"key_caret","key_asterisk","key_plus","key_equal",
|
||||
"key_underscore","key_backslash","key_pipe","key_tilde","key_lt","key_gt",
|
||||
"key_euro","key_pound","key_money","key_bullet",
|
||||
"key_dot","key_comma","key_question","key_exclam","key_quote"
|
||||
)
|
||||
symbolKeys.forEach { applyKeyBackground(root, it) }
|
||||
|
||||
symbolKeys.forEach { idName ->
|
||||
applyKeyBackground(root, idName)
|
||||
}
|
||||
|
||||
// 背景整体
|
||||
applyKeyBackground(root, "background")
|
||||
|
||||
// 功能键
|
||||
val others = listOf(
|
||||
"key_symbols_123",
|
||||
"key_backspace",
|
||||
"key_abc",
|
||||
"key_ai",
|
||||
"key_space",
|
||||
"key_send",
|
||||
"Key_collapse"
|
||||
"key_symbols_123","key_emoji","key_abc","key_ai","key_space","key_send","key_del","Key_collapse","key_revoke"
|
||||
)
|
||||
|
||||
others.forEach { idName ->
|
||||
applyKeyBackground(root, idName)
|
||||
}
|
||||
others.forEach { applyKeyBackground(root, it) }
|
||||
}
|
||||
|
||||
private fun applyKeyBackground(
|
||||
root: View,
|
||||
viewIdName: String,
|
||||
drawableName: String? = null
|
||||
) {
|
||||
private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
|
||||
val res = env.ctx.resources
|
||||
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
|
||||
if (viewId == 0) return
|
||||
val v = root.findViewById<View?>(viewId) ?: return
|
||||
|
||||
val keyName = drawableName ?: viewIdName
|
||||
val rawDrawable = ThemeManager.getDrawableForKey(env.ctx, keyName) ?: return
|
||||
|
||||
if (viewIdName == "background") {
|
||||
val scaled = scaleDrawableToHeight(rawDrawable, 243f)
|
||||
v.background = scaled
|
||||
return
|
||||
v.background = scaleDrawableToHeight(rawDrawable, 243f)
|
||||
} else {
|
||||
v.background = rawDrawable
|
||||
}
|
||||
v.background = rawDrawable
|
||||
}
|
||||
|
||||
private fun scaleDrawableToHeight(src: Drawable, targetDp: Float): Drawable {
|
||||
val res = env.ctx.resources
|
||||
val dm = res.displayMetrics
|
||||
val targetHeightPx = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
targetDp,
|
||||
dm
|
||||
).toInt()
|
||||
|
||||
val targetHeightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, targetDp, dm).toInt()
|
||||
val bitmap = (src as? BitmapDrawable)?.bitmap ?: return src
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
|
||||
val ratio = targetHeightPx.toFloat() / h
|
||||
val targetWidthPx = (w * ratio).toInt()
|
||||
|
||||
val scaledBitmap = Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
targetWidthPx,
|
||||
targetHeightPx,
|
||||
true
|
||||
)
|
||||
return BitmapDrawable(res, scaledBitmap).apply {
|
||||
setBounds(0, 0, targetWidthPx, targetHeightPx)
|
||||
}
|
||||
val ratio = targetHeightPx.toFloat() / bitmap.height
|
||||
val targetWidthPx = (bitmap.width * ratio).toInt()
|
||||
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidthPx, targetHeightPx, true)
|
||||
return BitmapDrawable(res, scaled).apply { setBounds(0, 0, targetWidthPx, targetHeightPx) }
|
||||
}
|
||||
|
||||
// ================== 符号键盘事件 ==================
|
||||
// -------------------- 实现主题刷新 --------------------
|
||||
override fun applyKeyBackgroundsForTheme() {
|
||||
// 刷新符号键
|
||||
val symbolKeys = listOf(
|
||||
"key_bracket_l","key_bracket_r","key_brace_l","key_brace_r","key_hash","key_percent",
|
||||
"key_caret","key_asterisk","key_plus","key_equal",
|
||||
"key_underscore","key_backslash","key_pipe","key_tilde","key_lt","key_gt",
|
||||
"key_euro","key_pound","key_money","key_bullet",
|
||||
"key_dot","key_comma","key_question","key_exclam","key_quote"
|
||||
)
|
||||
symbolKeys.forEach { applyKeyBackground(rootView, it) }
|
||||
|
||||
// 刷新功能键和背景
|
||||
val others = listOf(
|
||||
"key_symbols_123","key_emoji","key_abc","key_ai","key_space","key_send","Key_collapse","key_del","background","key_revoke"
|
||||
)
|
||||
others.forEach { applyKeyBackground(rootView, it) }
|
||||
}
|
||||
|
||||
// -------------------- 符号键盘事件 --------------------
|
||||
private fun setupListenersForSymbolView(symView: View) {
|
||||
val res = env.ctx.resources
|
||||
val pkg = env.ctx.packageName
|
||||
|
||||
val pairs = listOf(
|
||||
// 第一行
|
||||
"key_bracket_l" to '[',
|
||||
"key_bracket_r" to ']',
|
||||
"key_brace_l" to '{',
|
||||
"key_brace_r" to '}',
|
||||
"key_hash" to '#',
|
||||
"key_percent" to '%',
|
||||
"key_caret" to '^',
|
||||
"key_asterisk" to '*',
|
||||
"key_plus" to '+',
|
||||
"key_equal" to '=',
|
||||
// 初始化时设置Revoke按钮的可见性
|
||||
updateRevokeButtonVisibility(symView, res, pkg)
|
||||
|
||||
// 第二行
|
||||
"key_underscore" to '_',
|
||||
"key_backslash" to '\\',
|
||||
"key_pipe" to '|',
|
||||
"key_tilde" to '~',
|
||||
"key_lt" to '<',
|
||||
"key_gt" to '>',
|
||||
"key_euro" to '€',
|
||||
"key_pound" to '£',
|
||||
"key_money" to '¥',
|
||||
"key_bullet" to '•',
|
||||
|
||||
// 第三行
|
||||
"key_dot" to '.',
|
||||
"key_comma" to ',',
|
||||
"key_question" to '?',
|
||||
"key_exclam" to '!',
|
||||
"key_quote" to '\''
|
||||
val pairs: List<Pair<String, Char>> = listOf(
|
||||
"key_bracket_l" to '[',"key_bracket_r" to ']',"key_brace_l" to '{',"key_brace_r" to '}',
|
||||
"key_hash" to '#',"key_percent" to '%',"key_caret" to '^',"key_asterisk" to '*',
|
||||
"key_plus" to '+',"key_equal" to '=',
|
||||
"key_underscore" to '_',"key_backslash" to '\\',"key_pipe" to '|',"key_tilde" to '~',
|
||||
"key_lt" to '<',"key_gt" to '>',"key_euro" to '€',"key_pound" to '£',
|
||||
"key_money" to '¥',"key_bullet" to '•',
|
||||
"key_dot" to '.',"key_comma" to ',',"key_question" to '?',"key_exclam" to '!',"key_quote" to '\''
|
||||
)
|
||||
|
||||
pairs.forEach { (name, ch) ->
|
||||
val id = res.getIdentifier(name, "id", pkg)
|
||||
symView.findViewById<View?>(id)?.let { v ->
|
||||
attachKeyTouch(v) { ch }
|
||||
}
|
||||
symView.findViewById<View?>(id)?.let { attachKeyTouch(it) { ch } }
|
||||
}
|
||||
|
||||
// 切换回数字
|
||||
// 功能键
|
||||
symView.findViewById<View?>(res.getIdentifier("key_symbols_123", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showNumberKeyboard()
|
||||
}
|
||||
?.setOnClickListener { vibrateKey(); env.showNumberKeyboard() }
|
||||
|
||||
// 切回字母
|
||||
symView.findViewById<View?>(res.getIdentifier("key_abc", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showMainKeyboard()
|
||||
}
|
||||
?.setOnClickListener { vibrateKey(); env.showMainKeyboard() }
|
||||
|
||||
// 空格
|
||||
symView.findViewById<View?>(res.getIdentifier("key_space", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.commitKey(' ')
|
||||
?.setOnClickListener {
|
||||
vibrateKey(); env.commitKey(' ')
|
||||
// 输入新内容后更新按钮可见性
|
||||
updateRevokeButtonVisibility(symView, res, pkg)
|
||||
}
|
||||
|
||||
// 发送
|
||||
symView.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.performSendAction()
|
||||
?.setOnClickListener {
|
||||
vibrateKey(); env.performSendAction()
|
||||
// 发送后更新按钮可见性
|
||||
updateRevokeButtonVisibility(symView, res, pkg)
|
||||
}
|
||||
|
||||
// 删除(单击;长按连删在 MyInputMethodService 里统一挂)
|
||||
symView.findViewById<View?>(res.getIdentifier("key_backspace", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.deleteOne()
|
||||
symView.findViewById<View?>(res.getIdentifier("key_del", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey(); env.deleteOne()
|
||||
// 删除内容后更新按钮可见性
|
||||
updateRevokeButtonVisibility(symView, res, pkg)
|
||||
}
|
||||
|
||||
// 跳 AI 键盘
|
||||
symView.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey()
|
||||
env.showAiKeyboard()
|
||||
}
|
||||
?.setOnClickListener { vibrateKey(); env.showAiKeyboard() }
|
||||
|
||||
//关闭键盘
|
||||
rootView.findViewById<View?>(res.getIdentifier("collapse_button", "id", pkg))
|
||||
?.setOnClickListener {
|
||||
vibrateKey() // 如果这个方法在当前类里有
|
||||
env.hideKeyboard()
|
||||
}
|
||||
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 {
|
||||
vibrateKey(); env.revokeLastClearedText()
|
||||
// 回填后更新按钮可见性
|
||||
updateRevokeButtonVisibility(symView, 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) {
|
||||
view.setOnTouchListener { v, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
val ch = charProvider()
|
||||
vibrateKey() // 按下震动
|
||||
vibrateKey()
|
||||
showKeyPreview(v, ch.toString())
|
||||
v.isPressed = true
|
||||
true
|
||||
@@ -293,8 +196,7 @@ class SymbolKeyboard(
|
||||
v.isPressed = false
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_OUTSIDE -> {
|
||||
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_OUTSIDE -> {
|
||||
keyPreviewPopup?.dismiss()
|
||||
v.isPressed = false
|
||||
true
|
||||
@@ -306,7 +208,6 @@ class SymbolKeyboard(
|
||||
|
||||
private fun showKeyPreview(anchor: View, text: String) {
|
||||
keyPreviewPopup?.dismiss()
|
||||
|
||||
val tv = TextView(env.ctx).apply {
|
||||
this.text = text
|
||||
textSize = 26f
|
||||
@@ -324,14 +225,7 @@ class SymbolKeyboard(
|
||||
val w = (anchor.width * 1.2f).toInt()
|
||||
val h = (anchor.height * 1.2f).toInt()
|
||||
|
||||
keyPreviewPopup = PopupWindow(tv, w, h, false).apply {
|
||||
isClippingEnabled = false
|
||||
}
|
||||
|
||||
keyPreviewPopup?.showAsDropDown(
|
||||
anchor,
|
||||
-(w - anchor.width) / 2,
|
||||
-(h + anchor.height * 1.1f).toInt()
|
||||
)
|
||||
keyPreviewPopup = PopupWindow(tv, w, h, false).apply { isClippingEnabled = false }
|
||||
keyPreviewPopup?.showAsDropDown(anchor, -(w - anchor.width) / 2, -(h + anchor.height * 1.1f).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// ApiResponse.kt
|
||||
package com.example.network
|
||||
|
||||
data class ApiResponse<T>(
|
||||
val code: Int,
|
||||
val msg: String,
|
||||
val data: T?
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
// 请求方法
|
||||
package com.example.network
|
||||
package com.example.myapplication.network
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
@@ -8,36 +8,155 @@ import retrofit2.http.*
|
||||
interface ApiService {
|
||||
|
||||
// GET 示例:/users/{id}
|
||||
@GET("users/{id}")
|
||||
suspend fun getUser(
|
||||
@Path("id") id: String
|
||||
): ApiResponse<User>
|
||||
// @GET("users/{id}")
|
||||
// suspend fun getUser(
|
||||
// @Path("id") id: String
|
||||
// ): ApiResponse<User>
|
||||
|
||||
// GET 示例:带查询参数 /users?page=1&pageSize=20
|
||||
@GET("users")
|
||||
suspend fun getUsers(
|
||||
@Query("page") page: Int,
|
||||
@Query("pageSize") pageSize: Int
|
||||
): ApiResponse<List<User>>
|
||||
// @GET("users")
|
||||
// suspend fun getUsers(
|
||||
// @Query("page") page: Int,
|
||||
// @Query("pageSize") pageSize: Int
|
||||
// ): ApiResponse<List<User>>
|
||||
|
||||
// POST JSON 示例:Body 为 JSON:{"username": "...", "password": "..."}
|
||||
@POST("auth/login")
|
||||
//登录
|
||||
@POST("user/login")
|
||||
suspend fun login(
|
||||
@Body body: LoginRequest
|
||||
): ApiResponse<LoginResponse>
|
||||
|
||||
// POST 表单示例:x-www-form-urlencoded
|
||||
@FormUrlEncoded
|
||||
@POST("auth/loginForm")
|
||||
suspend fun loginForm(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String
|
||||
): 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
|
||||
@Streaming
|
||||
@GET("files/{fileName}")
|
||||
suspend fun downloadZip(
|
||||
@Path("fileName") fileName: String // 比如 "xxx.zip"
|
||||
): Response<ResponseBody>
|
||||
|
||||
// 完整 URL 下载
|
||||
@Streaming
|
||||
@GET
|
||||
@Headers(
|
||||
"Accept-Encoding: identity"
|
||||
)
|
||||
suspend fun downloadZipFromUrl(
|
||||
@Url url: String // 完整的下载 URL
|
||||
): Response<ResponseBody>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// zip 文件下载器
|
||||
package com.example.network
|
||||
package com.example.myapplication.network
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
@@ -16,7 +16,7 @@ object FileDownloader {
|
||||
/**
|
||||
* 下载 zip 文件并保存到 app 专属目录
|
||||
* @param context 用来获取文件目录
|
||||
* @param remoteFileName 服务器上的文件名,比如 "test.zip"
|
||||
* @param remoteFileName 服务器上的文件名或完整URL,比如 "test.zip" 或 "https://example.com/files/test.zip"
|
||||
* @param localFileName 本地保存名字,比如 "test_local.zip"
|
||||
* @return 保存成功后返回 File,失败返回 null
|
||||
*/
|
||||
@@ -27,7 +27,13 @@ object FileDownloader {
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
val api = RetrofitClient.apiService
|
||||
try {
|
||||
val response = api.downloadZip(remoteFileName)
|
||||
val response = if (remoteFileName.startsWith("http")) {
|
||||
// 完整 URL 下载 - 使用 @Url 注解,Retrofit 会忽略 base URL
|
||||
api.downloadZipFromUrl(remoteFileName)
|
||||
} else {
|
||||
// 文件名下载
|
||||
api.downloadZip(remoteFileName)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("Downloader", "download failed: code=${response.code()}")
|
||||
return@withContext null
|
||||
|
||||
@@ -1,27 +1,69 @@
|
||||
// 定义请求 & 响应拦截器
|
||||
package com.example.network
|
||||
package com.example.myapplication.network
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* 请求拦截器:统一加 Header、token 等
|
||||
*/
|
||||
val requestInterceptor = Interceptor { chain ->
|
||||
fun requestInterceptor(appContext: Context) = Interceptor { chain ->
|
||||
val original = chain.request()
|
||||
val token = "your_token" // 这里换成你自己的 token
|
||||
|
||||
val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java)
|
||||
val token = user?.token.orEmpty()
|
||||
|
||||
val newRequest = original.newBuilder()
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
// 这里加你自己的 token,如果没有就注释掉
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.apply {
|
||||
if (token.isNotBlank()) {
|
||||
addHeader("auth-token", "$token")
|
||||
}
|
||||
}
|
||||
.addHeader("Accept-Language", "lang")
|
||||
.build()
|
||||
|
||||
chain.proceed(newRequest)
|
||||
// ===== 打印请求信息 =====
|
||||
val request = newRequest
|
||||
val url = request.url
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append("\n======== HTTP Request ========\n")
|
||||
sb.append("Method: ${request.method}\n")
|
||||
sb.append("URL: $url\n")
|
||||
|
||||
sb.append("Headers:\n")
|
||||
for (name in request.headers.names()) {
|
||||
sb.append(" $name: ${request.header(name)}\n")
|
||||
}
|
||||
|
||||
if (url.querySize > 0) {
|
||||
sb.append("Query Params:\n")
|
||||
for (i in 0 until url.querySize) {
|
||||
sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n")
|
||||
}
|
||||
}
|
||||
|
||||
val requestBody = request.body
|
||||
if (requestBody != null) {
|
||||
val buffer = okio.Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
sb.append("Body:\n")
|
||||
sb.append(buffer.readUtf8())
|
||||
sb.append("\n")
|
||||
}
|
||||
|
||||
sb.append("================================\n")
|
||||
Log.d("1314520-OkHttp-Request", sb.toString())
|
||||
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 响应拦截器:统一打印日志、做一些简单的错误处理
|
||||
*/
|
||||
@@ -33,10 +75,18 @@ val responseInterceptor = Interceptor { chain ->
|
||||
|
||||
val rawBody = response.body
|
||||
val mediaType = rawBody?.contentType()
|
||||
|
||||
if (
|
||||
mediaType?.subtype == "zip" ||
|
||||
request.url.toString().endsWith(".zip")
|
||||
) {
|
||||
return@Interceptor response
|
||||
}
|
||||
|
||||
val bodyString = rawBody?.string() ?: ""
|
||||
|
||||
Log.d(
|
||||
"HTTP",
|
||||
"1314520-HTTP",
|
||||
"⬇⬇⬇\n" +
|
||||
"URL : ${request.url}\n" +
|
||||
"Method: ${request.method}\n" +
|
||||
@@ -45,6 +95,33 @@ val responseInterceptor = Interceptor { chain ->
|
||||
"Body : $bodyString\n" +
|
||||
"⬆⬆⬆"
|
||||
)
|
||||
|
||||
// 尝试解析响应体,检查是否为token过期错误
|
||||
try {
|
||||
val gson = Gson()
|
||||
val errorResponse = gson.fromJson(bodyString, ErrorResponse::class.java)
|
||||
|
||||
if (errorResponse.code == 40102) {
|
||||
Log.w("1314520-HTTP", "token 过期: ${errorResponse.message}")
|
||||
|
||||
// 只发事件,UI 层去跳转
|
||||
AuthEventBus.emit(AuthEvent.TokenExpired(errorResponse.message))
|
||||
|
||||
return@Interceptor response.newBuilder()
|
||||
.code(401)
|
||||
.message("Login expired: ${errorResponse.message}")
|
||||
.body(bodyString.toResponseBody(mediaType))
|
||||
.build()
|
||||
}
|
||||
// 其他非0的错误码,通过事件总线发送错误信息
|
||||
else if (errorResponse.code!= 0) {
|
||||
AuthEventBus.emit(AuthEvent.GenericError(errorResponse.message ?: "未知错误"))
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 如果解析失败,忽略错误继续正常处理
|
||||
Log.d("1314520-HTTP", "解析JSON失败: ${e.message}")
|
||||
}
|
||||
|
||||
// body 只能读一次,这里读完后再重新构建一个
|
||||
response.newBuilder()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.myapplication.network
|
||||
|
||||
interface LlmStreamCallback {
|
||||
fun onEvent(type: String, data: String?)
|
||||
fun onError(t: Throwable)
|
||||
}
|
||||
@@ -1,18 +1,164 @@
|
||||
// Models.kt
|
||||
package com.example.network
|
||||
package com.example.myapplication.network
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val age: Int
|
||||
// 通用API响应模型
|
||||
data class ApiResponse<T>(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: T?
|
||||
)
|
||||
|
||||
// 错误响应
|
||||
data class ErrorResponse(
|
||||
val code: Int,
|
||||
val message: String
|
||||
)
|
||||
|
||||
// ======================================登录================================
|
||||
// 登录
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val mail: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
// 登录响应
|
||||
data class LoginResponse(
|
||||
val token: String,
|
||||
val user: User
|
||||
val uid: Long,
|
||||
val nickName: String,
|
||||
val gender: Int,
|
||||
val avatarUrl: String?,
|
||||
val email: String,
|
||||
val emailVerified: Boolean,
|
||||
val isVip: Boolean,
|
||||
val vipExpiry: String,
|
||||
val token: String
|
||||
)
|
||||
|
||||
// ======================================用户===================================
|
||||
//获取用户详情
|
||||
data class User(
|
||||
val uid: Long,
|
||||
val nickName: String,
|
||||
val gender: Int,
|
||||
val avatarUrl: String?,
|
||||
val email: String,
|
||||
val emailVerified: Boolean,
|
||||
val isVip: Boolean,
|
||||
val vipExpiry: String,
|
||||
val token: String
|
||||
)
|
||||
|
||||
//更新用户
|
||||
data class updateInfoRequest(
|
||||
val uid: Long,
|
||||
val nickName: String,
|
||||
val gender: Int,
|
||||
val avatarUrl: String?,
|
||||
)
|
||||
|
||||
// =======================================首页======================================
|
||||
//标签列表
|
||||
data class Tag(
|
||||
val id: Int,
|
||||
val tagName: String
|
||||
)
|
||||
|
||||
data class TagList(
|
||||
val data: List<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,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.example.myapplication.network
|
||||
|
||||
import okhttp3.*
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
object NetworkClient {
|
||||
|
||||
// 你自己后端的 base url
|
||||
private const val BASE_URL = "http://192.168.2.21:7529/api"
|
||||
|
||||
// 专门用于 SSE 的 OkHttpClient:readTimeout = 0 代表不超时,一直保持连接
|
||||
private val sseClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须不能有读超时
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一次 SSE 流式请求
|
||||
* @param question 用户问题(你要传给后端的)
|
||||
* @return Call,可用于取消(比如用户关闭键盘时)
|
||||
*/
|
||||
fun startLlmStream(
|
||||
question: String,
|
||||
callback: LlmStreamCallback
|
||||
): Call {
|
||||
// 根据你后端的接口改:是 POST 还是 GET,参数格式是什么
|
||||
val json = JSONObject().apply {
|
||||
put("query", question) // 假设你后端字段叫 query
|
||||
}
|
||||
|
||||
val requestBody = json.toString()
|
||||
.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/llm/stream") // TODO: 换成你真实的 SSE 路径
|
||||
.post(requestBody)
|
||||
// 有些 SSE 接口会要求 Accept
|
||||
.addHeader("Accept", "text/event-stream")
|
||||
.build()
|
||||
|
||||
val call = sseClient.newCall(request)
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
if (call.isCanceled()) return // 被主动取消就不用回调错误了
|
||||
callback.onError(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
callback.onError(IOException("SSE failed: ${response.code}"))
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
|
||||
val body = response.body ?: run {
|
||||
callback.onError(IOException("Empty body"))
|
||||
return
|
||||
}
|
||||
|
||||
// 长连接读取:一行一行读,直到服务器关闭或我们取消
|
||||
body.use { b ->
|
||||
val source = b.source()
|
||||
try {
|
||||
while (!source.exhausted() && !call.isCanceled()) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
if (line.isBlank()) {
|
||||
// SSE 中空行代表一个 event 结束,这里可以忽略
|
||||
continue
|
||||
}
|
||||
|
||||
// 兼容两种格式:
|
||||
// 1) 标准 SSE: "data: { ... }"
|
||||
// 2) 服务器直接一行一个 JSON: "{ ... }"
|
||||
val payload = if (line.startsWith("data:")) {
|
||||
line.substringAfter("data:").trim()
|
||||
} else {
|
||||
line.trim()
|
||||
}
|
||||
|
||||
// 你日志里是:
|
||||
// {"type":"llm_chunk","data":"Her"}
|
||||
// {"type":"done","data":null}
|
||||
try {
|
||||
val jsonObj = JSONObject(payload)
|
||||
val type = jsonObj.optString("type")
|
||||
val data =
|
||||
if (jsonObj.has("data") && !jsonObj.isNull("data"))
|
||||
jsonObj.getString("data")
|
||||
else
|
||||
null
|
||||
|
||||
callback.onEvent(type, data)
|
||||
} catch (e: Exception) {
|
||||
// 解析失败就忽略这一行(或者你可以打印下日志)
|
||||
// Log.e("NetworkClient", "Bad SSE line: $payload", e)
|
||||
}
|
||||
}
|
||||
} catch (ioe: IOException) {
|
||||
if (!call.isCanceled()) {
|
||||
callback.onError(ioe)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return call
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// RetrofitClient.kt
|
||||
package com.example.network
|
||||
package com.example.myapplication.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
@@ -9,22 +9,30 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
|
||||
private const val BASE_URL = "https://api.example.com/" // 换成你的地址
|
||||
private const val BASE_URL = "http://192.168.2.21:7529/api/"
|
||||
|
||||
// 保存 ApplicationContext
|
||||
@Volatile
|
||||
private lateinit var appContext: Context
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
// 日志拦截器(可选)
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
check(::appContext.isInitialized) { "RetrofitClient not initialized. Call RetrofitClient.init(context) first." }
|
||||
|
||||
OkHttpClient.Builder()
|
||||
// 超时时间自己看需求改
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
|
||||
// 顺序:请求拦截 -> logging -> 响应拦截
|
||||
.addInterceptor(requestInterceptor)
|
||||
.addInterceptor(requestInterceptor(appContext))
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor(responseInterceptor)
|
||||
.build()
|
||||
@@ -41,4 +49,16 @@ object RetrofitClient {
|
||||
val apiService: ApiService by lazy {
|
||||
retrofit.create(ApiService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支持完整 URL 下载的 Retrofit 实例
|
||||
* @param baseUrl 完整的下载 URL
|
||||
*/
|
||||
fun createRetrofitForUrl(baseUrl: String): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,27 +21,26 @@ object ThemeManager {
|
||||
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 */
|
||||
private fun getThemeRootDir(context: Context): File =
|
||||
File(context.getExternalFilesDir(null), "keyboard_themes")
|
||||
File(context.filesDir, "keyboard_themes")
|
||||
|
||||
/** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */
|
||||
private fun getThemeDir(context: Context, themeName: String): File =
|
||||
File(getThemeRootDir(context), themeName)
|
||||
|
||||
// ==================== 内置主题拷贝(assets -> 外部目录) ====================
|
||||
|
||||
/**
|
||||
* 确保 APK 自带的主题(assets/keyboard_themes/...) 已经复制到
|
||||
* /Android/data/.../files/keyboard_themes 目录下。
|
||||
*
|
||||
* 行为:
|
||||
* - 如果主题目录不存在:整套拷贝过去。
|
||||
* - 如果主题目录已经存在:只复制“新增文件”,不会覆盖已有文件。
|
||||
*
|
||||
* 建议在 IME 的 onCreate() 里调用一次。
|
||||
*/
|
||||
fun ensureBuiltInThemesInstalled(context: Context) {
|
||||
val am = context.assets
|
||||
val rootName = "keyboard_themes"
|
||||
@@ -126,6 +125,8 @@ object ThemeManager {
|
||||
.apply()
|
||||
|
||||
drawableCache = loadThemeDrawables(context, themeName)
|
||||
|
||||
listeners.forEach { it.invoke() }
|
||||
}
|
||||
|
||||
fun getCurrentThemeName(): String? = currentThemeName
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
package com.example.myapplication.ui.home
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.TransitionDrawable
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.ViewGroup
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.example.myapplication.ImeGuideActivity
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.listByTagWithNotLogin
|
||||
import com.example.myapplication.network.PersonaClick
|
||||
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import android.graphics.drawable.TransitionDrawable
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.widget.HorizontalScrollView
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import android.content.Intent
|
||||
import com.example.myapplication.ImeGuideActivity
|
||||
import com.example.myapplication.network.AddPersonaClick
|
||||
|
||||
class HomeFragment : Fragment() {
|
||||
|
||||
@@ -38,16 +49,41 @@ class HomeFragment : Fragment() {
|
||||
private lateinit var tabList1: TextView
|
||||
private lateinit var tabList2: TextView
|
||||
private lateinit var backgroundImage: ImageView
|
||||
private var preloadJob: kotlinx.coroutines.Job? = null
|
||||
private var allPersonaCache: List<listByTagWithNotLogin> = emptyList()
|
||||
private val sharedPool = RecyclerView.RecycledViewPool()
|
||||
|
||||
private var parentWidth = 0
|
||||
private var parentHeight = 0
|
||||
|
||||
// 你点了哪个 tag(列表二)
|
||||
private var clickedTagId: Int? = null
|
||||
|
||||
// ✅ 列表二:每个 tagId 对应一份 persona 数据,避免串页
|
||||
private val personaCache = mutableMapOf<Int, List<listByTagWithNotLogin>>()
|
||||
|
||||
data class Tag(val id: Int, val tagName: String)
|
||||
|
||||
private val tags = mutableListOf<Tag>()
|
||||
|
||||
private val dragToCloseThreshold by lazy {
|
||||
val dp = 40f
|
||||
(dp * resources.displayMetrics.density)
|
||||
}
|
||||
|
||||
// 第二个列表的“标签页”,数量不固定,可以从服务端/本地配置来
|
||||
private val tags = listOf("标签一", "标签二", "标签三", "标签四", "标签五", "标签六", "标签七", "标签八", "标签九", "标签十")
|
||||
private val list1Adapter: List1Adapter by lazy {
|
||||
List1Adapter { item: String ->
|
||||
Log.d("HomeFragment", "list1 click: $item")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
preloadJob?.cancel()
|
||||
pageChangeCallback?.let { viewPager.unregisterOnPageChangeCallback(it) }
|
||||
pageChangeCallback = null
|
||||
sheetAdapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -59,12 +95,13 @@ class HomeFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
|
||||
// 充值按钮点击
|
||||
view.findViewById<View>(R.id.rechargeButton).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_global_rechargeFragment)
|
||||
}
|
||||
//输入法激活跳转
|
||||
|
||||
// 输入法激活跳转
|
||||
view.findViewById<ImageView>(R.id.floatingImage).setOnClickListener {
|
||||
if (isAdded) {
|
||||
startActivity(Intent(requireActivity(), ImeGuideActivity::class.java))
|
||||
@@ -79,10 +116,11 @@ class HomeFragment : Fragment() {
|
||||
tabList1 = view.findViewById(R.id.tab_list1)
|
||||
tabList2 = view.findViewById(R.id.tab_list2)
|
||||
viewPager = view.findViewById(R.id.viewPager)
|
||||
viewPager.isSaveEnabled = false
|
||||
backgroundImage = bottomSheet.findViewById(R.id.backgroundImage)
|
||||
val root = view.findViewById<CoordinatorLayout>(R.id.rootCoordinator)
|
||||
val floatingImage = view.findViewById<ImageView>(R.id.floatingImage)
|
||||
// 拿到父布局的宽高(需要等布局完成)
|
||||
|
||||
root.post {
|
||||
parentWidth = root.width
|
||||
parentHeight = root.height
|
||||
@@ -90,10 +128,44 @@ class HomeFragment : Fragment() {
|
||||
|
||||
initDrag(floatingImage, root)
|
||||
setupBottomSheet(view)
|
||||
setupViewPager()
|
||||
setupTopTabs()
|
||||
|
||||
// 先把 ViewPager / Tags 初始化为空(避免你下面网络回来前被调用多次)
|
||||
setupViewPager()
|
||||
setupTags()
|
||||
|
||||
//刚进来强制显示列表1
|
||||
viewPager.setCurrentItem(0, false)
|
||||
updateTabsAndTags(0)
|
||||
|
||||
// 加载标签列表(列表一)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val list = fetchAllPersonaList()
|
||||
allPersonaCache = list
|
||||
viewPager.adapter?.notifyItemChanged(0) // 只刷新第一页
|
||||
} catch (e: Exception) {
|
||||
Log.e("1314520-HomeFragment", "获取列表一失败", e)
|
||||
}
|
||||
}
|
||||
// 拉标签 + 默认加载第一个 tag 的 persona(列表二第一个页)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val response = RetrofitClient.apiService.tagList()
|
||||
tags.clear()
|
||||
response.data?.let { networkTags ->
|
||||
tags.addAll(networkTags.map { Tag(it.id, it.tagName) })
|
||||
}
|
||||
// 刷新:页数和标签栏
|
||||
setupViewPager()
|
||||
setupTags()
|
||||
startPreloadAllTags()
|
||||
} catch (e: Exception) {
|
||||
Log.e("1314520-HomeFragment", "获取标签失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 拖拽效果 ----------------
|
||||
private fun initDrag(target: View, parent: ViewGroup) {
|
||||
var dX = 0f
|
||||
@@ -101,20 +173,15 @@ class HomeFragment : Fragment() {
|
||||
var lastRawX = 0f
|
||||
var lastRawY = 0f
|
||||
var isDragging = false
|
||||
|
||||
|
||||
val touchSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop
|
||||
|
||||
|
||||
target.setOnTouchListener { v, event ->
|
||||
when (event.actionMasked) {
|
||||
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
// 告诉 CoordinatorLayout:别拦截这次事件
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
// 暂时禁止 BottomSheet 拖动
|
||||
if (::bottomSheetBehavior.isInitialized) {
|
||||
bottomSheetBehavior.isDraggable = false
|
||||
}
|
||||
|
||||
if (::bottomSheetBehavior.isInitialized) bottomSheetBehavior.isDraggable = false
|
||||
|
||||
dX = v.x - event.rawX
|
||||
dY = v.y - event.rawY
|
||||
lastRawX = event.rawX
|
||||
@@ -122,61 +189,47 @@ class HomeFragment : Fragment() {
|
||||
isDragging = false
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val dxMove = event.rawX - lastRawX
|
||||
val dyMove = event.rawY - lastRawY
|
||||
if (!isDragging && (abs(dxMove) > touchSlop || abs(dyMove) > touchSlop)) {
|
||||
isDragging = true
|
||||
}
|
||||
|
||||
|
||||
if (isDragging) {
|
||||
var newX = event.rawX + dX
|
||||
var newY = event.rawY + dY
|
||||
|
||||
// 限制在父布局范围内
|
||||
|
||||
val maxX = parentWidth - v.width
|
||||
val maxY = parentHeight - v.height
|
||||
|
||||
|
||||
newX = newX.coerceIn(0f, maxX.toFloat())
|
||||
newY = newY.coerceIn(0f, maxY.toFloat())
|
||||
|
||||
|
||||
v.x = newX
|
||||
v.y = newY
|
||||
}
|
||||
|
||||
|
||||
lastRawX = event.rawX
|
||||
lastRawY = event.rawY
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
// 允许父布局继续拦截之后的事件
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
parent.requestDisallowInterceptTouchEvent(false)
|
||||
// 恢复 BottomSheet 可拖动
|
||||
if (::bottomSheetBehavior.isInitialized) {
|
||||
bottomSheetBehavior.isDraggable = true
|
||||
}
|
||||
|
||||
if (!isDragging) {
|
||||
v.performClick()
|
||||
}
|
||||
|
||||
// 手指抬起:吸边
|
||||
if (::bottomSheetBehavior.isInitialized) bottomSheetBehavior.isDraggable = true
|
||||
|
||||
if (!isDragging) v.performClick()
|
||||
snapToEdge(v)
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 吸边逻辑:左右贴边(需要上下也吸边可以再扩展)
|
||||
*/
|
||||
private fun snapToEdge(v: View) {
|
||||
if (parentWidth == 0 || parentHeight == 0) return
|
||||
|
||||
@@ -184,248 +237,240 @@ class HomeFragment : Fragment() {
|
||||
val toLeft = centerX < parentWidth / 2f
|
||||
|
||||
val targetX = if (toLeft) 0f else (parentWidth - v.width).toFloat()
|
||||
|
||||
// 如果你还想限制上下边距,比如离底部留 80dp 不遮挡 BottomSheet,可以再处理 y
|
||||
val minTop = 0f
|
||||
val maxBottom = (parentHeight - v.height).toFloat()
|
||||
val targetY = v.y.coerceIn(minTop, maxBottom)
|
||||
|
||||
v.animate()
|
||||
.x(targetX)
|
||||
.y(targetY)
|
||||
.setDuration(200)
|
||||
.start()
|
||||
v.animate().x(targetX).y(targetY).setDuration(200).start()
|
||||
}
|
||||
// ---------------- BottomSheet 行为 ----------------
|
||||
|
||||
// ---------------- BottomSheet 行为 ----------------
|
||||
private fun setupBottomSheet(root: View) {
|
||||
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
|
||||
|
||||
// 允许拖拽,允许嵌套滚动控制
|
||||
bottomSheetBehavior.isDraggable = true
|
||||
bottomSheetBehavior.isHideable = false
|
||||
bottomSheetBehavior.isFitToContents = false
|
||||
// 展开时高度占屏幕 70%
|
||||
bottomSheetBehavior.halfExpandedRatio = 0.7f
|
||||
|
||||
// 先等布局完成之后,计算“按钮下面剩余空间”作为 peekHeight
|
||||
root.post {
|
||||
val coordinatorHeight = root.height-40
|
||||
val coordinatorHeight = root.height - 40
|
||||
val button = root.findViewById<View>(R.id.rechargeButton)
|
||||
val buttonBottom = button.bottom
|
||||
val peek = (coordinatorHeight - buttonBottom).coerceAtLeast(200)
|
||||
val peek = (coordinatorHeight - button.bottom).coerceAtLeast(200)
|
||||
bottomSheetBehavior.peekHeight = peek
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
// 监听状态变化,用来控制遮罩显示/隐藏
|
||||
bottomSheetBehavior.addBottomSheetCallback(object :
|
||||
BottomSheetBehavior.BottomSheetCallback() {
|
||||
bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
when (newState) {
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> {
|
||||
scrim.isVisible = false
|
||||
}
|
||||
BottomSheetBehavior.STATE_DRAGGING,
|
||||
BottomSheetBehavior.STATE_EXPANDED,
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
|
||||
scrim.isVisible = true
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
scrim.isVisible = newState != BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
// 跟随滑动渐变遮罩透明度
|
||||
if (slideOffset >= 0f) {
|
||||
scrim.alpha = slideOffset.coerceIn(0f, 1f)
|
||||
}
|
||||
if (slideOffset >= 0f) scrim.alpha = slideOffset.coerceIn(0f, 1f)
|
||||
}
|
||||
})
|
||||
|
||||
// 点击遮罩,关闭回原位
|
||||
scrim.setOnClickListener {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
// 简单的“空白区域下滑”关闭:在遮罩上响应手势(简单版,只要 move 就关)
|
||||
scrim.setOnTouchListener { _, event ->
|
||||
// 这里可以更精细地判断手势方向,这里简单处理为:有滑动就关闭
|
||||
// 如果你想更准,可以根据 down / move 的 dy 判断
|
||||
// 为了示例就写得简单一点
|
||||
// MotionEvent.ACTION_MOVE = 2
|
||||
if (event.action == 2) {
|
||||
if (event.action == MotionEvent.ACTION_MOVE) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
|
||||
// 点击底部盒子的“头部”,在折叠 / 半展开之间切换
|
||||
header.setOnClickListener {
|
||||
when (bottomSheetBehavior.state) {
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> {
|
||||
BottomSheetBehavior.STATE_COLLAPSED ->
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
}
|
||||
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED,
|
||||
BottomSheetBehavior.STATE_EXPANDED -> {
|
||||
BottomSheetBehavior.STATE_EXPANDED ->
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ViewPager2 + 列表 ----------------
|
||||
// ---------------- ViewPager2 + Tabs ----------------
|
||||
private var sheetAdapter: SheetPagerAdapter? = null
|
||||
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
|
||||
|
||||
private fun setupViewPager() {
|
||||
val pageCount = 1 + tags.size // 1 = 第一个列表,剩下的是第二个列表的标签页
|
||||
viewPager.adapter = SheetPagerAdapter(pageCount)
|
||||
if (sheetAdapter == null) {
|
||||
sheetAdapter = SheetPagerAdapter(1 + tags.size)
|
||||
viewPager.adapter = sheetAdapter
|
||||
|
||||
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
super.onPageSelected(position)
|
||||
updateTabsAndTags(position) // 里面会调用 highlightTag,把标签高亮并滚动
|
||||
pageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
updateTabsAndTags(position)
|
||||
}
|
||||
}
|
||||
})
|
||||
viewPager.registerOnPageChangeCallback(pageChangeCallback!!)
|
||||
} else {
|
||||
// tags 数量变了,只更新 pageCount 并刷新一次即可
|
||||
sheetAdapter!!.updatePageCount(1 + tags.size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun startPreloadAllTags() {
|
||||
preloadJob?.cancel()
|
||||
|
||||
// 限制并发,避免一下子打爆网络/主线程调度抖动
|
||||
val semaphore = kotlinx.coroutines.sync.Semaphore(permits = 2)
|
||||
|
||||
preloadJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||
// tags 还没拿到就别跑
|
||||
if (tags.isEmpty()) return@launch
|
||||
|
||||
// 逐个 tag 预拉取(并发=2)
|
||||
tags.forEachIndexed { index, tag ->
|
||||
// 已经有缓存就跳过
|
||||
if (personaCache.containsKey(tag.id)) return@forEachIndexed
|
||||
launch {
|
||||
semaphore.acquire()
|
||||
try {
|
||||
val list = fetchPersonaByTag(tag.id)
|
||||
personaCache[tag.id] = list
|
||||
|
||||
val pagePos = 1 + index
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
||||
viewPager.adapter?.notifyItemChanged(pagePos)
|
||||
}
|
||||
} finally {
|
||||
semaphore.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 顶部“列表一 / 列表二”选项栏点击
|
||||
private fun setupTopTabs() {
|
||||
tabList1.setOnClickListener {
|
||||
viewPager.currentItem = 0 // 列表一
|
||||
}
|
||||
tabList1.setOnClickListener { viewPager.currentItem = 0 }
|
||||
tabList2.setOnClickListener {
|
||||
viewPager.currentItem = 1 // 列表二的第一个标签页
|
||||
// 没有标签就别切
|
||||
if (tags.isNotEmpty()) viewPager.currentItem = 1
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部标签行(只在第二个列表时可见)
|
||||
private fun setupTags() {
|
||||
tagContainer.removeAllViews()
|
||||
|
||||
tags.forEachIndexed { index, tag ->
|
||||
val tv = layoutInflater.inflate(
|
||||
R.layout.item_tag,
|
||||
tagContainer,
|
||||
false
|
||||
) as TextView
|
||||
tv.text = tag
|
||||
val tv = layoutInflater.inflate(R.layout.item_tag, tagContainer, false) as TextView
|
||||
tv.text = tag.tagName
|
||||
|
||||
tv.setOnClickListener {
|
||||
// 当前位置 = 1 + 标签下标
|
||||
viewPager.currentItem = 1 + index
|
||||
clickedTagId = tag.id
|
||||
val pagePos = 1 + index
|
||||
|
||||
// ✅ 先切页:用户体感立刻响应
|
||||
viewPager.setCurrentItem(pagePos, true)
|
||||
|
||||
// ✅ 有缓存就不阻塞(可选:同时后台刷新)
|
||||
val cached = personaCache[tag.id]
|
||||
if (cached != null) {
|
||||
viewPager.adapter?.notifyItemChanged(pagePos)
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
// ✅ 没缓存:页内显示 loading(你 onBind 已经处理 cached==null 的 loading)
|
||||
viewPager.adapter?.notifyItemChanged(pagePos)
|
||||
|
||||
// 后台拉取,回来只刷新这一页
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val list = fetchPersonaByTag(tag.id)
|
||||
personaCache[tag.id] = list
|
||||
viewPager.adapter?.notifyItemChanged(pagePos) // ✅ 只刷新这一页
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tagContainer.addView(tv)
|
||||
}
|
||||
// 默认选中列表一,所以标签行默认隐藏
|
||||
|
||||
tagScroll.isVisible = false
|
||||
}
|
||||
|
||||
// 根据当前 page 更新上方两个选项 & 标签高亮/显隐
|
||||
private fun updateTabsAndTags(position: Int) {
|
||||
|
||||
if (position == 0) {
|
||||
tabList1.setTextColor(requireContext().getColor(R.color.black))
|
||||
tabList2.setTextColor(requireContext().getColor(R.color.light_black))
|
||||
tagScroll.isVisible = false
|
||||
|
||||
fadeImage(backgroundImage, R.drawable.option_background)
|
||||
|
||||
} else {
|
||||
|
||||
tabList1.setTextColor(requireContext().getColor(R.color.light_black))
|
||||
tabList2.setTextColor(requireContext().getColor(R.color.black))
|
||||
tagScroll.isVisible = true
|
||||
|
||||
fadeImage(backgroundImage, R.drawable.option_background_two)
|
||||
|
||||
|
||||
val tagIndex = position - 1
|
||||
highlightTag(tagIndex)
|
||||
}
|
||||
}
|
||||
//背景淡入淡出
|
||||
|
||||
private fun fadeImage(imageView: ImageView, newImageRes: Int) {
|
||||
val oldDrawable = imageView.drawable
|
||||
val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes)
|
||||
|
||||
if (newDrawable == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次还没有旧图,直接设置就好
|
||||
val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes) ?: return
|
||||
|
||||
if (oldDrawable == null) {
|
||||
imageView.setImageDrawable(newDrawable)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val transitionDrawable = TransitionDrawable(arrayOf(oldDrawable, newDrawable)).apply {
|
||||
// 关键:启用交叉淡入淡出,旧图才会一起淡出
|
||||
isCrossFadeEnabled = true
|
||||
}
|
||||
|
||||
imageView.setImageDrawable(transitionDrawable)
|
||||
transitionDrawable.startTransition(300) // 300ms 淡入淡出
|
||||
transitionDrawable.startTransition(300)
|
||||
}
|
||||
|
||||
|
||||
private fun highlightTag(index: Int) {
|
||||
for (i in 0 until tagContainer.childCount) {
|
||||
val child = tagContainer.getChildAt(i) as TextView
|
||||
if (i == index) {
|
||||
child.setBackgroundResource(R.drawable.tag_selected_bg)
|
||||
child.setTextColor(requireContext().getColor(android.R.color.white))
|
||||
|
||||
// 关键:把选中的标签滚动到可见(这里我用“居中”效果)
|
||||
tagScroll.post {
|
||||
val scrollViewWidth = tagScroll.width
|
||||
val childCenter = child.left + child.width / 2
|
||||
val targetScrollX = childCenter - scrollViewWidth / 2
|
||||
tagScroll.smoothScrollTo(targetScrollX.coerceAtLeast(0), 0)
|
||||
}
|
||||
|
||||
} else {
|
||||
child.setBackgroundResource(R.drawable.tag_unselected_bg)
|
||||
child.setTextColor(requireContext().getColor(R.color.light_black))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------- 共享的 ViewHolder 类 ----------------
|
||||
|
||||
inner class PageViewHolder(val recyclerView: RecyclerView) :
|
||||
RecyclerView.ViewHolder(recyclerView)
|
||||
|
||||
// ---------------- ViewPager2 的 Adapter ----------------
|
||||
|
||||
/**
|
||||
* 每一页都是一个 RecyclerView 卡片列表:
|
||||
* - position = 0:列表一(数据 A)
|
||||
* - position >= 1:列表二的第 index 个标签页(数据 B[index])
|
||||
*/
|
||||
// ---------------- ViewPager Adapter ----------------
|
||||
inner class SheetPagerAdapter(
|
||||
private val pageCount: Int
|
||||
private var pageCount: Int
|
||||
) : RecyclerView.Adapter<SheetPagerAdapter.PageViewHolder>() {
|
||||
|
||||
inner class PageViewHolder(val root: View) : RecyclerView.ViewHolder(root)
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
// 0:第一个列表页,>0:第二个列表的各标签页
|
||||
return if (position == 0) 0 else 1
|
||||
fun updatePageCount(newCount: Int) {
|
||||
pageCount = newCount
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder {
|
||||
val layoutId = when (viewType) {
|
||||
0 -> R.layout.bottom_page_list1 // 第一个列表的自定义内容
|
||||
else -> R.layout.bottom_page_list2 // 第二个列表各标签页的自定义内容
|
||||
val layoutId = if (viewType == 0) {
|
||||
R.layout.bottom_page_list1
|
||||
} else {
|
||||
R.layout.bottom_page_list2
|
||||
}
|
||||
|
||||
val root = LayoutInflater.from(parent.context)
|
||||
.inflate(layoutId, parent, false)
|
||||
|
||||
// 如果需要,禁用嵌套滚动(对 NestedScrollView 一般问题不大,可以不写)
|
||||
// root.findViewById<NestedScrollView>(R.id.scrollContent)?.isNestedScrollingEnabled = false
|
||||
|
||||
val root = LayoutInflater.from(parent.context).inflate(layoutId, parent, false)
|
||||
return PageViewHolder(root)
|
||||
}
|
||||
|
||||
@@ -433,73 +478,291 @@ class HomeFragment : Fragment() {
|
||||
val root = holder.root
|
||||
|
||||
if (position == 0) {
|
||||
// 这里可以拿到 bottom_page_list1 中的控件,做一些初始化
|
||||
// val someView = root.findViewById<TextView>(R.id.xxx)
|
||||
// someView.text = "xxx"
|
||||
|
||||
renderList1(root, allPersonaCache)
|
||||
} else {
|
||||
// // 第二个列表对应的标签页
|
||||
// val tagIndex = position - 1
|
||||
// val tagName = tags[tagIndex]
|
||||
val rv2 = root.findViewById<RecyclerView>(R.id.recyclerView)
|
||||
val loadingView = root.findViewById<View>(R.id.loadingView)
|
||||
|
||||
// // 示例:把标题改成“标签一的内容 / 标签二的内容 ……”
|
||||
// val titleView = root.findViewById<TextView>(R.id.pageTitle)
|
||||
// titleView?.text = "$tagName 的自定义内容"
|
||||
rv2.setHasFixedSize(true)
|
||||
rv2.itemAnimator = null
|
||||
rv2.isNestedScrollingEnabled = false
|
||||
|
||||
// // 你也可以根据 tagIndex,显示/隐藏不同区域
|
||||
}
|
||||
var adapter = rv2.adapter as? PersonaAdapter
|
||||
if (adapter == null) {
|
||||
adapter = PersonaAdapter { click ->
|
||||
when (click) {
|
||||
is PersonaClick.Item -> {
|
||||
val id = click.persona.id
|
||||
PersonaDetailDialogFragment
|
||||
.newInstance(id)
|
||||
.show(childFragmentManager, "persona_detail")
|
||||
}
|
||||
is PersonaClick.Add -> {
|
||||
lifecycleScope.launch {
|
||||
if (click.persona.added == true) {
|
||||
click.persona.id?.let { id ->
|
||||
RetrofitClient.apiService.delUserCharacter(id.toInt())
|
||||
}
|
||||
} else {
|
||||
val req = AddPersonaClick(
|
||||
characterId = click.persona.id?.toInt() ?: 0,
|
||||
emoji = click.persona.emoji ?: ""
|
||||
)
|
||||
RetrofitClient.apiService.addUserCharacter(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rv2.layoutManager = GridLayoutManager(root.context, 2)
|
||||
rv2.adapter = adapter
|
||||
}
|
||||
|
||||
//让当前页里的滚动容器具备“下拉关闭 BottomSheet”的能力
|
||||
val scrollContent = root.findViewById<View>(R.id.scrollContent)
|
||||
if (scrollContent != null) {
|
||||
setupPullToClose(scrollContent)
|
||||
val tagIndex = position - 1
|
||||
if (tagIndex !in tags.indices) {
|
||||
loadingView.isVisible = false
|
||||
adapter.submitList(emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
val tagId = tags[tagIndex].id
|
||||
val cached = personaCache[tagId]
|
||||
|
||||
if (cached == null) {
|
||||
loadingView.isVisible = true
|
||||
adapter.submitList(emptyList())
|
||||
} else {
|
||||
loadingView.isVisible = false
|
||||
adapter.submitList(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = pageCount
|
||||
}
|
||||
|
||||
|
||||
// 通过 tagIndex 取出该页要显示的数据
|
||||
private fun getPersonaListByTagIndex(tagIndex: Int): List<listByTagWithNotLogin> {
|
||||
if (tagIndex !in tags.indices) return emptyList()
|
||||
val tagId = tags[tagIndex].id
|
||||
return personaCache[tagId] ?: emptyList()
|
||||
}
|
||||
|
||||
private fun renderList1(root: View, list: List<listByTagWithNotLogin>) {
|
||||
// 1) 排序:rank 小的排前面
|
||||
val sorted = list.sortedBy { it.rank ?: Int.MAX_VALUE }
|
||||
|
||||
val top3 = sorted.take(3)
|
||||
val others = if (sorted.size > 3) sorted.drop(3) else emptyList()
|
||||
|
||||
// 2) 绑定前三名(注意:你的 UI 排列是:第二/第一/第三)
|
||||
bindTopItem(root,
|
||||
avatarId = R.id.avatar_first,
|
||||
nameId = R.id.name_first,
|
||||
addBtnId = R.id.btn_add_first,
|
||||
container = R.id.container_first,
|
||||
item = top3.getOrNull(0) // rank 最小 = 第一名
|
||||
)
|
||||
|
||||
bindTopItem(root,
|
||||
avatarId = R.id.avatar_second,
|
||||
nameId = R.id.name_second,
|
||||
addBtnId = R.id.btn_add_second,
|
||||
container = R.id.container_second,
|
||||
item = top3.getOrNull(1) // 第二名
|
||||
)
|
||||
|
||||
bindTopItem(root,
|
||||
avatarId = R.id.avatar_third,
|
||||
nameId = R.id.name_third,
|
||||
addBtnId = R.id.btn_add_third,
|
||||
container = R.id.container_third,
|
||||
item = top3.getOrNull(2) // 第三名
|
||||
)
|
||||
|
||||
// 3) 渲染后面的内容卡片
|
||||
val container = root.findViewById<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)
|
||||
|
||||
|
||||
private fun setupPullToClose(scrollable: View) {
|
||||
var downY = 0f
|
||||
var isDraggingToClose = false
|
||||
|
||||
scrollable.setOnTouchListener { _, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downY = event.rawY
|
||||
isDraggingToClose = false
|
||||
}
|
||||
itemView.findViewById<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 ?: ""
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
// 已经是折叠状态,不拦截,交给内容自己滚(其实也滚不动多少)
|
||||
if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
return@setOnTouchListener false
|
||||
// 头像
|
||||
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")
|
||||
}
|
||||
|
||||
// 只点“添加”按钮
|
||||
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) {
|
||||
// 处理错误
|
||||
}
|
||||
}
|
||||
|
||||
val dy = event.rawY - downY
|
||||
|
||||
if (!scrollable.canScrollVertically(-1) && // 已在顶部
|
||||
dy > dragToCloseThreshold && // 向下拉超过阈值
|
||||
(bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
bottomSheetBehavior.state == BottomSheetBehavior.STATE_HALF_EXPANDED)
|
||||
) {
|
||||
isDraggingToClose = true
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
return@setOnTouchListener true
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
isDraggingToClose = false
|
||||
}
|
||||
}
|
||||
|
||||
isDraggingToClose
|
||||
container.addView(itemView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindTopItem(
|
||||
root: View,
|
||||
avatarId: Int,
|
||||
nameId: Int,
|
||||
addBtnId: Int,
|
||||
container: Int,
|
||||
item: listByTagWithNotLogin?
|
||||
) {
|
||||
val avatar = root.findViewById<de.hdodenhof.circleimageview.CircleImageView>(avatarId)
|
||||
val name = root.findViewById<TextView>(nameId)
|
||||
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) {
|
||||
// 处理错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.setOnClickListener {
|
||||
val id = item.id
|
||||
Log.d("HomeFragment", "list1 top click id=$id rank=${item.rank}")
|
||||
PersonaDetailDialogFragment
|
||||
.newInstance(id)
|
||||
.show(childFragmentManager, "persona_detail")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------- 网络请求 ----------------
|
||||
private suspend fun fetchPersonaByTag(tagId: Int): List<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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,60 @@
|
||||
package com.example.myapplication.ui.keyboard
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.bumptech.glide.Glide
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.SubjectTag
|
||||
import com.example.myapplication.network.themeDetail
|
||||
import com.example.myapplication.network.purchaseThemeRequest
|
||||
import com.example.myapplication.ui.shop.ThemeCardAdapter
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import kotlinx.coroutines.launch
|
||||
import com.example.myapplication.GuideActivity
|
||||
import com.example.myapplication.network.themeStyle
|
||||
import com.example.myapplication.network.FileDownloader
|
||||
import com.example.myapplication.theme.ThemeManager
|
||||
import com.example.myapplication.utils.unzipThemeSmart
|
||||
import com.example.myapplication.utils.logZipEntries
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.FileInputStream
|
||||
|
||||
class KeyboardDetailFragment : Fragment() {
|
||||
|
||||
private lateinit var shapeableImageView: ShapeableImageView
|
||||
private lateinit var tvKeyboardName: TextView
|
||||
private lateinit var tvDownloadCount: TextView
|
||||
private lateinit var layoutTagsContainer: LinearLayout
|
||||
private lateinit var recyclerRecommendList: RecyclerView
|
||||
private lateinit var themeCardAdapter: ThemeCardAdapter
|
||||
private lateinit var tvPrice: TextView
|
||||
private lateinit var rechargeButton: LinearLayout
|
||||
private lateinit var enabledButton: LinearLayout
|
||||
private lateinit var enabledButtonText: TextView
|
||||
private lateinit var progressBar: android.widget.ProgressBar
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -21,8 +67,454 @@ class KeyboardDetailFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
shapeableImageView = view.findViewById<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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,41 @@ package com.example.myapplication.ui.login
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.LoginRequest
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginFragment : Fragment() {
|
||||
|
||||
private lateinit var passwordEditText: EditText
|
||||
private lateinit var toggleImageView: ImageView
|
||||
private lateinit var loginButton: MaterialButton // 如果你 XML 里有这个按钮 id: btn_login
|
||||
private lateinit var loginButton: TextView
|
||||
private lateinit var emailEditText: EditText
|
||||
private var loadingOverlay: com.example.myapplication.ui.common.LoadingOverlay? = null//加载遮罩层
|
||||
|
||||
private var isPasswordVisible = false
|
||||
|
||||
override fun onDestroyView() {
|
||||
loadingOverlay?.remove()
|
||||
loadingOverlay = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -32,6 +48,7 @@ class LoginFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loadingOverlay = com.example.myapplication.ui.common.LoadingOverlay.attach(view as ViewGroup)
|
||||
|
||||
// 注册
|
||||
view.findViewById<TextView>(R.id.tv_signup).setOnClickListener {
|
||||
@@ -43,12 +60,23 @@ class LoginFragment : Fragment() {
|
||||
}
|
||||
// 返回按钮
|
||||
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
findNavController().previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("from_login", true)
|
||||
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
// 绑定控件(id 必须和 xml 里的一样)
|
||||
passwordEditText = view.findViewById(R.id.et_password)
|
||||
emailEditText = view.findViewById(R.id.et_email)
|
||||
toggleImageView = view.findViewById(R.id.iv_toggle)
|
||||
// loginButton = view.findViewById(R.id.btn_login) // 如果没有这个按钮就把这一行和变量删了
|
||||
loginButton = view.findViewById(R.id.btn_login)
|
||||
|
||||
// 账号回填
|
||||
val savedEmail = EncryptedSharedPreferencesUtil.get(requireContext(), "email", String::class.java)
|
||||
savedEmail?.let { email ->
|
||||
emailEditText.setText(email)
|
||||
}
|
||||
|
||||
// 初始是隐藏密码状态
|
||||
passwordEditText.inputType =
|
||||
@@ -73,10 +101,39 @@ class LoginFragment : Fragment() {
|
||||
passwordEditText.setSelection(passwordEditText.text?.length ?: 0)
|
||||
}
|
||||
|
||||
// // 登录按钮逻辑你自己填
|
||||
// loginButton.setOnClickListener {
|
||||
// val pwd = passwordEditText.text?.toString().orEmpty()
|
||||
// // TODO: 登录处理
|
||||
// }
|
||||
// // 登录按钮逻辑
|
||||
loginButton.setOnClickListener {
|
||||
val pwd = passwordEditText.text?.toString().orEmpty()
|
||||
val email = emailEditText.text?.toString().orEmpty()
|
||||
if (pwd.isEmpty() || email.isEmpty()) {
|
||||
// 输入框不能为空
|
||||
Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
loadingOverlay?.show()
|
||||
// 调用登录API
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val loginRequest = LoginRequest(
|
||||
mail = email, // 使用email作为username
|
||||
password = pwd
|
||||
)
|
||||
val response = RetrofitClient.apiService.login(loginRequest)
|
||||
// 存储登录响应
|
||||
if (response.code == 0) {
|
||||
EncryptedSharedPreferencesUtil.save(requireContext(), "user", response.data)
|
||||
EncryptedSharedPreferencesUtil.save(requireContext(), "email",email)
|
||||
findNavController().popBackStack()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "Login failed: ${response.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
loadingOverlay?.hide()
|
||||
} catch (e: Exception) {
|
||||
Log.e("1314520-LoginFragment", "登录请求失败: ${e.message}", e)
|
||||
Toast.makeText(requireContext(), "Login failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
loadingOverlay?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,27 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.example.myapplication.R
|
||||
import android.widget.LinearLayout
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.LoginResponse
|
||||
import de.hdodenhof.circleimageview.CircleImageView
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.launch
|
||||
import android.widget.TextView
|
||||
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
|
||||
import androidx.navigation.navOptions
|
||||
|
||||
class MineFragment : Fragment() {
|
||||
|
||||
private lateinit var nickname: TextView
|
||||
private lateinit var time: TextView
|
||||
private lateinit var logout: TextView
|
||||
|
||||
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
@@ -24,6 +38,78 @@ class MineFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// 判断是否登录(门禁)
|
||||
if (!isLoggedIn()) {
|
||||
val nav = findNavController()
|
||||
|
||||
// 改用 savedStateHandle 的标记:LoginFragment 返回时写入
|
||||
val fromLogin = nav.currentBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.get<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 {
|
||||
@@ -56,9 +142,12 @@ class MineFragment : Fragment() {
|
||||
}
|
||||
|
||||
//隐私政策
|
||||
view.findViewById<LinearLayout>(R.id.click_Privacy).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_mineFragment_to_loginFragment)
|
||||
}
|
||||
|
||||
// view.findViewById<LinearLayout>(R.id.click_Privacy).setOnClickListener {
|
||||
// findNavController().navigate(R.id.action_mineFragment_to_loginFragment)
|
||||
// }
|
||||
|
||||
}
|
||||
private fun isLoggedIn(): Boolean {
|
||||
return EncryptedSharedPreferencesUtil.contains(requireContext(), "user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +1,261 @@
|
||||
package com.example.myapplication.ui.shop
|
||||
|
||||
import android.content.Intent
|
||||
import android.annotation.SuppressLint
|
||||
import android.animation.ArgbEvaluator
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.example.myapplication.R
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.animation.ValueAnimator
|
||||
import android.animation.ArgbEvaluator
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.Theme
|
||||
import com.example.myapplication.network.Wallet
|
||||
import com.example.myapplication.network.themeStyle
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ShopFragment : Fragment() {
|
||||
class ShopFragment : Fragment(R.layout.fragment_shop) {
|
||||
|
||||
private lateinit var viewPager: ViewPager2
|
||||
private lateinit var tagScroll: HorizontalScrollView
|
||||
private lateinit var tagContainer: LinearLayout
|
||||
private lateinit var balance: TextView
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
// 标签标题,可以根据需要修改
|
||||
private val tabTitles = listOf("全部", "数码", "服饰", "家居", "美食","数码", "服饰", "家居", "美食")
|
||||
// 风格 tabs
|
||||
private var tabTitles: List<Theme> = emptyList()
|
||||
private var styleIds: List<Int> = emptyList()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_shop, container, false)
|
||||
}
|
||||
// ✅ 共享数据/缓存/加载都交给 VM
|
||||
private val vm: ShopViewModel by viewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// 金币充值按钮点击
|
||||
|
||||
view.findViewById<View>(R.id.rechargeButton).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_global_goldCoinRechargeFragment)
|
||||
}
|
||||
// 我的皮肤按钮点击
|
||||
view.findViewById<View>(R.id.skinButton).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_shopfragment_to_myskin)
|
||||
}
|
||||
// 搜索按钮点击
|
||||
view.findViewById<View>(R.id.searchButton).setOnClickListener {
|
||||
findNavController().navigate(R.id.action_shopfragment_to_searchfragment)
|
||||
}
|
||||
|
||||
|
||||
|
||||
tagScroll = view.findViewById(R.id.tagScroll)
|
||||
tagContainer = view.findViewById(R.id.tagContainer)
|
||||
viewPager = view.findViewById(R.id.viewPager)
|
||||
val rechargeButton = view.findViewById<LinearLayout>(R.id.rechargeButton)
|
||||
rechargeButton.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_global_goldCoinRechargeFragment)
|
||||
balance = view.findViewById(R.id.balance)
|
||||
swipeRefreshLayout = view.findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
||||
|
||||
// 设置下拉刷新监听器
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
refreshData()
|
||||
}
|
||||
// 1. 设置 ViewPager2 的 Adapter
|
||||
viewPager.adapter = ShopPagerAdapter(this, tabTitles.size)
|
||||
|
||||
// 2. 创建顶部标签
|
||||
setupTags()
|
||||
// 设置刷新指示器颜色
|
||||
swipeRefreshLayout.setColorSchemeColors(
|
||||
Color.parseColor("#02BEAC"),
|
||||
Color.parseColor("#1B1F1A"),
|
||||
Color.parseColor("#9F9F9F")
|
||||
)
|
||||
|
||||
// 3. 绑定 ViewPager2 滑动 & 标签联动
|
||||
setupViewPager()
|
||||
// 禁用默认的刷新行为,使用自定义逻辑
|
||||
swipeRefreshLayout.isEnabled = false
|
||||
|
||||
// 设置 ViewPager 的子页面滚动监听
|
||||
setupViewPagerScrollListener()
|
||||
|
||||
loadInitialData()
|
||||
|
||||
// 修复 ViewPager2 和 SwipeRefreshLayout 的手势冲突
|
||||
fixViewPager2SwipeConflict()
|
||||
}
|
||||
|
||||
/** 动态创建标签 TextView */
|
||||
private fun setupTags() {
|
||||
tagContainer.removeAllViews()
|
||||
private fun loadInitialData() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val walletResp = getwalletBalance()
|
||||
val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString()
|
||||
balance.text = balanceText
|
||||
adjustBalanceTextSize(balanceText)
|
||||
|
||||
val context = requireContext()
|
||||
val density = context.resources.displayMetrics.density
|
||||
val themeListResp = getThemeList()
|
||||
tabTitles = themeListResp?.data ?: emptyList()
|
||||
Log.d("1314520-Shop", "风格列表: $tabTitles")
|
||||
|
||||
// ⬇⬇⬇ 你要求的 padding 值(已适配 dp)
|
||||
val paddingHorizontal = (16 * density).toInt() // 左右 16dp
|
||||
val paddingVertical = (6 * density).toInt() // 上下 6dp
|
||||
val marginEnd = (8 * density).toInt() // 标签之间 8dp 间距
|
||||
styleIds = tabTitles.map { it.id }
|
||||
|
||||
tabTitles.forEachIndexed { index, title ->
|
||||
val tv = TextView(context).apply {
|
||||
text = title
|
||||
textSize = 12f // 字体大小 12sp
|
||||
viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds)
|
||||
|
||||
// ✅ 设置内边距(左右16dp,上下6dp)
|
||||
setPadding(
|
||||
paddingHorizontal,
|
||||
paddingVertical,
|
||||
paddingHorizontal,
|
||||
paddingVertical
|
||||
)
|
||||
setupTags()
|
||||
setupViewPager()
|
||||
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
).apply {
|
||||
setMargins(0, 0, marginEnd, 0) // 右侧 8dp 间距
|
||||
// ✅ 默认加载第一个(交给 VM)
|
||||
viewPager.post {
|
||||
styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gravity = android.view.Gravity.CENTER
|
||||
/**
|
||||
* 根据字符数量调整余额文本的字体大小
|
||||
* 字符数量越多,字体越小
|
||||
*/
|
||||
private fun adjustBalanceTextSize(text: String) {
|
||||
val maxFontSize = 40f // 最大字体大小(sp)
|
||||
val minFontSize = 16f // 最小字体大小(sp)
|
||||
|
||||
// 根据字符数量计算字体大小
|
||||
val fontSize = when (text.length) {
|
||||
0, 1, 2, 3 -> maxFontSize // 0-3个字符使用最大字体
|
||||
4 -> 36f
|
||||
5 -> 32f
|
||||
6 -> 28f
|
||||
7 -> 24f
|
||||
8 -> 22f
|
||||
9 -> 20f
|
||||
else -> minFontSize // 10个字符及以上使用最小字体
|
||||
}
|
||||
|
||||
balance.textSize = fontSize
|
||||
}
|
||||
|
||||
// 胶囊大圆角背景
|
||||
background = createCapsuleBackground()
|
||||
private fun refreshData() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
// 重新获取钱包余额
|
||||
val walletResp = getwalletBalance()
|
||||
val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString()
|
||||
balance.text = balanceText
|
||||
adjustBalanceTextSize(balanceText)
|
||||
|
||||
// 初始化选中状态
|
||||
isSelected = index == 0
|
||||
updateTagStyleNoAnim(this, isSelected) // 初始化不用动画,避免闪烁
|
||||
// 重新获取主题列表
|
||||
val themeListResp = getThemeList()
|
||||
val newTabTitles = themeListResp?.data ?: emptyList()
|
||||
|
||||
// 检查主题列表是否有变化
|
||||
if (newTabTitles != tabTitles) {
|
||||
tabTitles = newTabTitles
|
||||
styleIds = tabTitles.map { it.id }
|
||||
|
||||
// 重新设置适配器
|
||||
viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds)
|
||||
|
||||
// 重新设置标签
|
||||
setupTags()
|
||||
|
||||
// 通知 ViewModel 清除缓存
|
||||
vm.clearCache()
|
||||
|
||||
// 强制重新加载所有页面的数据
|
||||
styleIds.forEach { styleId ->
|
||||
// 强制重新加载,即使有缓存也要重新获取
|
||||
vm.forceLoadStyle(styleId)
|
||||
}
|
||||
} else {
|
||||
// 主题列表没有变化,强制重新加载当前页面的数据
|
||||
val currentPosition = viewPager.currentItem
|
||||
styleIds.getOrNull(currentPosition)?.let { vm.forceLoadStyle(it) }
|
||||
}
|
||||
|
||||
Log.d("1314520-Shop", "下拉刷新完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e("1314520-Shop", "下拉刷新失败", e)
|
||||
} finally {
|
||||
// 停止刷新动画
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击切换页面
|
||||
setOnClickListener {
|
||||
if (viewPager.currentItem != index) {
|
||||
viewPager.currentItem = index
|
||||
/** 子页读取缓存(从 VM 读) */
|
||||
fun getCachedList(styleId: Int): List<themeStyle> = vm.getCached(styleId)
|
||||
|
||||
/** 动态创建标签 */
|
||||
private fun setupTags() {
|
||||
tagContainer.removeAllViews()
|
||||
|
||||
val context = requireContext()
|
||||
val density = context.resources.displayMetrics.density
|
||||
val paddingHorizontal = (16 * density).toInt()
|
||||
val paddingVertical = (6 * density).toInt()
|
||||
val marginEnd = (8 * density).toInt()
|
||||
|
||||
tabTitles.forEachIndexed { index, title ->
|
||||
val tv = TextView(context).apply {
|
||||
text = title.styleName
|
||||
textSize = 12f
|
||||
setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
|
||||
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
).apply { setMargins(0, 0, marginEnd, 0) }
|
||||
|
||||
gravity = android.view.Gravity.CENTER
|
||||
background = createCapsuleBackground()
|
||||
|
||||
isSelected = index == 0
|
||||
updateTagStyleNoAnim(this, isSelected)
|
||||
|
||||
setOnClickListener {
|
||||
if (viewPager.currentItem != index) viewPager.currentItem = index
|
||||
}
|
||||
}
|
||||
tagContainer.addView(tv)
|
||||
}
|
||||
|
||||
tagContainer.addView(tv)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun createCapsuleBackground(): GradientDrawable {
|
||||
val density = resources.displayMetrics.density
|
||||
return GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = 50f * density // 大圆角
|
||||
setColor(Color.parseColor("#F1F1F1")) // 默认未选中背景
|
||||
cornerRadius = 50f * density
|
||||
setColor(Color.parseColor("#F1F1F1"))
|
||||
setStroke((2 * density).toInt(), Color.parseColor("#F1F1F1"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** 设置 ViewPager2 的监听,实现滑动联动标签 */
|
||||
private fun setupViewPager() {
|
||||
// ✅ 只设置一次
|
||||
viewPager.offscreenPageLimit = 1
|
||||
|
||||
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
super.onPageSelected(position)
|
||||
updateTagState(position)
|
||||
|
||||
// ✅ 切换到某页就按需加载(交给 VM)
|
||||
styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据当前位置更新所有标签的选中状态 */
|
||||
private fun updateTagState(position: Int) {
|
||||
for (i in 0 until tagContainer.childCount) {
|
||||
val child = tagContainer.getChildAt(i) as TextView
|
||||
val newSelected = i == position
|
||||
|
||||
// ✅ 如果这个标签的选中状态没有变化,就不要动它,避免“闪一下”
|
||||
if (child.isSelected == newSelected) continue
|
||||
|
||||
|
||||
child.isSelected = newSelected
|
||||
updateTagStyleWithAnim(child, newSelected)
|
||||
|
||||
|
||||
if (newSelected) {
|
||||
// 让选中项尽量居中显示
|
||||
child.post {
|
||||
val scrollX = child.left - (tagScroll.width - child.width) / 2
|
||||
tagScroll.smoothScrollTo(scrollX, 0)
|
||||
@@ -170,84 +263,25 @@ private fun setupTags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** 统一控制标签样式,可根据自己项目主题改颜色/大小 **/
|
||||
private fun updateTagStyle(textView: TextView, selected: Boolean) {
|
||||
val context = textView.context
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
// 确保背景是 GradientDrawable,方便改边框和背景色
|
||||
val bg = (textView.background as? GradientDrawable)
|
||||
?: createCapsuleBackground().also { textView.background = it }
|
||||
|
||||
// 颜色配置(按你要求)
|
||||
val selectedTextColor = Color.parseColor("#1B1F1A")
|
||||
val unselectedTextColor = Color.parseColor("#9F9F9F")
|
||||
|
||||
val selectedStrokeColor = Color.parseColor("#02BEAC")
|
||||
val unselectedStrokeColor = Color.parseColor("#F1F1F1")
|
||||
|
||||
val selectedBgColor = Color.parseColor("#FFFFFF")
|
||||
val unselectedBgColor = Color.parseColor("#F1F1F1")
|
||||
|
||||
// 当前颜色作为起点
|
||||
val startTextColor = textView.currentTextColor
|
||||
val startStrokeColor = try {
|
||||
// 没有方便的 getter,这里通过 isSelected 反推一个“起点”
|
||||
if (selected) unselectedStrokeColor else selectedStrokeColor
|
||||
} catch (e: Exception) {
|
||||
if (selected) unselectedStrokeColor else selectedStrokeColor
|
||||
private fun setupViewPagerScrollListener() {
|
||||
// 监听 AppBarLayout 的展开状态来判断是否在顶部
|
||||
view?.findViewById<com.google.android.material.appbar.AppBarLayout>(R.id.appBar)?.addOnOffsetChangedListener { appBarLayout, verticalOffset ->
|
||||
val isAtTop = verticalOffset == 0
|
||||
swipeRefreshLayout.isEnabled = isAtTop
|
||||
}
|
||||
val startBgColor = if (selected) unselectedBgColor else selectedBgColor
|
||||
|
||||
// 目标颜色
|
||||
val endTextColor = if (selected) selectedTextColor else unselectedTextColor
|
||||
val endStrokeColor = if (selected) selectedStrokeColor else unselectedStrokeColor
|
||||
val endBgColor = if (selected) selectedBgColor else unselectedBgColor
|
||||
|
||||
val strokeWidth = (2 * density).toInt()
|
||||
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 200L // 动画时长可以自己调
|
||||
addUpdateListener { va ->
|
||||
val fraction = va.animatedFraction
|
||||
val evaluator = ArgbEvaluator()
|
||||
|
||||
val currentTextColor =
|
||||
evaluator.evaluate(fraction, startTextColor, endTextColor) as Int
|
||||
val currentStrokeColor =
|
||||
evaluator.evaluate(fraction, startStrokeColor, endStrokeColor) as Int
|
||||
val currentBgColor =
|
||||
evaluator.evaluate(fraction, startBgColor, endBgColor) as Int
|
||||
|
||||
textView.setTextColor(currentTextColor)
|
||||
bg.setStroke(strokeWidth, currentStrokeColor)
|
||||
bg.setColor(currentBgColor)
|
||||
}
|
||||
}
|
||||
animator.start()
|
||||
|
||||
// 字重变化
|
||||
textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/** ViewPager2 的 Adapter,可以替换成你的真实 Fragment */
|
||||
private class ShopPagerAdapter(
|
||||
fragment: Fragment,
|
||||
private val pageCount: Int
|
||||
) : FragmentStateAdapter(fragment) {
|
||||
|
||||
override fun getItemCount(): Int = pageCount
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
// 根据 position 返回不同的页面 Fragment
|
||||
|
||||
// 这里先用一个简单的占位示例
|
||||
return SimplePageFragment.newInstance("当前页:${position + 1}")
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun fixViewPager2SwipeConflict() {
|
||||
val rv = viewPager.getChildAt(0) as? RecyclerView ?: return
|
||||
rv.setOnTouchListener { v, ev ->
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> v.parent?.requestDisallowInterceptTouchEvent(true)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
|
||||
v.parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,18 +289,17 @@ private fun setupTags() {
|
||||
val density = resources.displayMetrics.density
|
||||
val bg = (textView.background as? GradientDrawable)
|
||||
?: createCapsuleBackground().also { textView.background = it }
|
||||
|
||||
val strokeWidth = (2 * density).toInt()
|
||||
|
||||
|
||||
if (selected) {
|
||||
bg.setColor(Color.parseColor("#FFFFFF")) // 背景白色
|
||||
bg.setStroke(strokeWidth, Color.parseColor("#02BEAC")) // 边框 #02BEAC
|
||||
textView.setTextColor(Color.parseColor("#1B1F1A")) // 字体 #1B1F1A
|
||||
bg.setColor(Color.parseColor("#FFFFFF"))
|
||||
bg.setStroke(strokeWidth, Color.parseColor("#02BEAC"))
|
||||
textView.setTextColor(Color.parseColor("#1B1F1A"))
|
||||
textView.setTypeface(null, Typeface.BOLD)
|
||||
} else {
|
||||
bg.setColor(Color.parseColor("#F1F1F1")) // 背景 #F1F1F1
|
||||
bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1")) // 边框 #F1F1F1
|
||||
textView.setTextColor(Color.parseColor("#9F9F9F")) // 字体 #9F9F9F
|
||||
bg.setColor(Color.parseColor("#F1F1F1"))
|
||||
bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1"))
|
||||
textView.setTextColor(Color.parseColor("#9F9F9F"))
|
||||
textView.setTypeface(null, Typeface.NORMAL)
|
||||
}
|
||||
}
|
||||
@@ -275,71 +308,114 @@ private fun setupTags() {
|
||||
val density = resources.displayMetrics.density
|
||||
val bg = (textView.background as? GradientDrawable)
|
||||
?: createCapsuleBackground().also { textView.background = it }
|
||||
|
||||
val strokeWidth = (2 * density).toInt()
|
||||
|
||||
// 颜色配置
|
||||
|
||||
val selectedTextColor = Color.parseColor("#1B1F1A")
|
||||
val unselectedTextColor = Color.parseColor("#9F9F9F")
|
||||
|
||||
val selectedStrokeColor = Color.parseColor("#02BEAC")
|
||||
val unselectedStrokeColor = Color.parseColor("#F1F1F1")
|
||||
|
||||
val selectedBgColor = Color.parseColor("#FFFFFF")
|
||||
val unselectedBgColor = Color.parseColor("#F1F1F1")
|
||||
|
||||
// 起点、终点颜色我们自己定义,而不是乱读当前值,避免抖动
|
||||
val startTextColor: Int
|
||||
val endTextColor: Int
|
||||
val startStrokeColor: Int
|
||||
val endStrokeColor: Int
|
||||
val startBgColor: Int
|
||||
val endBgColor: Int
|
||||
|
||||
if (selected) {
|
||||
// 未选中 -> 选中
|
||||
startTextColor = unselectedTextColor
|
||||
endTextColor = selectedTextColor
|
||||
|
||||
startStrokeColor = unselectedStrokeColor
|
||||
endStrokeColor = selectedStrokeColor
|
||||
|
||||
startBgColor = unselectedBgColor
|
||||
endBgColor = selectedBgColor
|
||||
|
||||
val colorsArray = if (selected) {
|
||||
arrayOf(
|
||||
unselectedTextColor, selectedTextColor,
|
||||
unselectedStrokeColor, selectedStrokeColor,
|
||||
unselectedBgColor, selectedBgColor
|
||||
)
|
||||
} else {
|
||||
// 选中 -> 未选中
|
||||
startTextColor = selectedTextColor
|
||||
endTextColor = unselectedTextColor
|
||||
|
||||
startStrokeColor = selectedStrokeColor
|
||||
endStrokeColor = unselectedStrokeColor
|
||||
|
||||
startBgColor = selectedBgColor
|
||||
endBgColor = unselectedBgColor
|
||||
arrayOf(
|
||||
selectedTextColor, unselectedTextColor,
|
||||
selectedStrokeColor, unselectedStrokeColor,
|
||||
selectedBgColor, unselectedBgColor
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val startTextColor = colorsArray[0]
|
||||
val endTextColor = colorsArray[1]
|
||||
val startStrokeColor = colorsArray[2]
|
||||
val endStrokeColor = colorsArray[3]
|
||||
val startBgColor = colorsArray[4]
|
||||
val endBgColor = colorsArray[5]
|
||||
|
||||
val evaluator = ArgbEvaluator()
|
||||
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 200L
|
||||
addUpdateListener { va ->
|
||||
val fraction = va.animatedFraction
|
||||
|
||||
val currentTextColor =
|
||||
evaluator.evaluate(fraction, startTextColor, endTextColor) as Int
|
||||
val currentStrokeColor =
|
||||
evaluator.evaluate(fraction, startStrokeColor, endStrokeColor) as Int
|
||||
val currentBgColor =
|
||||
evaluator.evaluate(fraction, startBgColor, endBgColor) as Int
|
||||
|
||||
textView.setTextColor(currentTextColor)
|
||||
bg.setStroke(strokeWidth, currentStrokeColor)
|
||||
bg.setColor(currentBgColor)
|
||||
val f = va.animatedFraction
|
||||
textView.setTextColor(evaluator.evaluate(f, startTextColor, endTextColor) as Int)
|
||||
bg.setStroke(strokeWidth, evaluator.evaluate(f, startStrokeColor, endStrokeColor) as Int)
|
||||
bg.setColor(evaluator.evaluate(f, startBgColor, endBgColor) as Int)
|
||||
}
|
||||
start()
|
||||
}
|
||||
animator.start()
|
||||
|
||||
|
||||
textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL)
|
||||
}
|
||||
|
||||
|
||||
private inner class ShopPagerAdapter(
|
||||
fragment: Fragment,
|
||||
private val styleIds: List<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)
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
package com.example.myapplication.ui.shop.myskin
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.themeStyle
|
||||
import com.example.myapplication.network.deleteThemeRequest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MySkin : Fragment() {
|
||||
|
||||
|
||||
private lateinit var adapter: MySkinAdapter
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
@@ -20,9 +35,151 @@ class MySkin : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
|
||||
val tvEditor = view.findViewById<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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,26 @@ package com.example.myapplication.ui.shop.search
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.example.myapplication.R
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.flexbox.FlexboxLayout.LayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.themeStyle
|
||||
import com.example.myapplication.ui.shop.ThemeCardAdapter
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.flexbox.FlexboxLayout.LayoutParams
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +32,8 @@ class SearchFragment : Fragment() {
|
||||
private lateinit var etInput: EditText
|
||||
private val prefsName = "search_history"
|
||||
private lateinit var historySection: LinearLayout
|
||||
private lateinit var recyclerRecommendList: RecyclerView
|
||||
private lateinit var themeCardAdapter: ThemeCardAdapter
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -35,20 +45,34 @@ class SearchFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// 推荐主题列表
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val recommendThemeListResp = getrecommendThemeList()?.data
|
||||
// 渲染推荐主题列表
|
||||
recommendThemeListResp?.let { themes ->
|
||||
themeCardAdapter.submitList(themes)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SearchFragment", "获取推荐主题列表异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
|
||||
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)
|
||||
historyLayout = view.findViewById(R.id.layout_history_list)
|
||||
etInput = view.findViewById(R.id.et_input)
|
||||
recyclerRecommendList = view.findViewById(R.id.recycler_recommend_list)
|
||||
|
||||
// 初始化RecyclerView
|
||||
setupRecyclerView()
|
||||
|
||||
// 加载历史记录
|
||||
loadHistory()
|
||||
@@ -145,5 +169,27 @@ class SearchFragment : Fragment() {
|
||||
historyLayout.removeAllViews()
|
||||
historySection.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
// 设置GridLayoutManager,每行显示2个item
|
||||
val layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
recyclerRecommendList.layoutManager = layoutManager
|
||||
|
||||
// 初始化ThemeCardAdapter
|
||||
themeCardAdapter = ThemeCardAdapter()
|
||||
recyclerRecommendList.adapter = themeCardAdapter
|
||||
|
||||
// 设置item间距(可选)
|
||||
recyclerRecommendList.setPadding(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
private suspend fun getrecommendThemeList(): ApiResponse<List<themeStyle>>? {
|
||||
return try {
|
||||
RetrofitClient.apiService.recommendThemeList()
|
||||
} catch (e: Exception) {
|
||||
Log.e("SearchFragment", "获取推荐列表失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
package com.example.myapplication.ui.shop.search
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.myapplication.R
|
||||
import com.example.myapplication.network.ApiResponse
|
||||
import com.example.myapplication.network.RetrofitClient
|
||||
import com.example.myapplication.network.themeStyle
|
||||
import com.example.myapplication.ui.shop.ThemeCardAdapter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SearchResultFragment : Fragment() {
|
||||
|
||||
private lateinit var etInput: EditText
|
||||
private lateinit var recyclerSearchResults: RecyclerView
|
||||
private lateinit var themeCardAdapter: ThemeCardAdapter
|
||||
private lateinit var tvSearch: TextView
|
||||
private lateinit var llNoSearchResult: LinearLayout
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -30,10 +47,79 @@ class SearchResultFragment : Fragment() {
|
||||
}
|
||||
|
||||
etInput = view.findViewById(R.id.et_input)
|
||||
recyclerSearchResults = view.findViewById(R.id.recycler_search_results)
|
||||
tvSearch = view.findViewById<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") ?: ""
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "全部清除成功")
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,276 @@
|
||||
package com.example.myapplication.utils
|
||||
|
||||
import java.io.*
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
fun unzipToDir(zipInputStream: InputStream, targetDir: File) {
|
||||
ZipInputStream(BufferedInputStream(zipInputStream)).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
val buffer = ByteArray(4096)
|
||||
private const val TAG_UNZIP = "1314520-unzip"
|
||||
private const val TAG_ZIPLIST = "1314520-ziplist"
|
||||
|
||||
while (entry != null) {
|
||||
val file = File(targetDir, entry.name)
|
||||
/* =========================
|
||||
* 1️⃣ 打印 zip 内容(调试用)
|
||||
* ========================= */
|
||||
fun logZipEntries(zipFile: File) {
|
||||
Log.e(TAG_ZIPLIST, "========== ZIP CONTENT START ==========")
|
||||
Log.e(TAG_ZIPLIST, "zipPath=${zipFile.absolutePath} size=${zipFile.length()}")
|
||||
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
} else {
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { fos ->
|
||||
var count: Int
|
||||
while (zis.read(buffer).also { count = it } != -1) {
|
||||
fos.write(buffer, 0, count)
|
||||
}
|
||||
try {
|
||||
FileInputStream(zipFile).use { fis ->
|
||||
ZipInputStream(BufferedInputStream(fis)).use { zis ->
|
||||
var count = 0
|
||||
while (true) {
|
||||
val entry = zis.nextEntry ?: break
|
||||
count++
|
||||
Log.e(
|
||||
TAG_ZIPLIST,
|
||||
"[$count] ${entry.name} dir=${entry.isDirectory}"
|
||||
)
|
||||
zis.closeEntry()
|
||||
}
|
||||
Log.e(TAG_ZIPLIST, "total entries=$count")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG_ZIPLIST, "read zip failed: ${e.message}", e)
|
||||
}
|
||||
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
Log.e(TAG_ZIPLIST, "========== ZIP CONTENT END ==========")
|
||||
}
|
||||
|
||||
fun detectArchiveType(file: File): String {
|
||||
FileInputStream(file).use { fis ->
|
||||
val header = ByteArray(8)
|
||||
val read = fis.read(header)
|
||||
if (read < 4) return "UNKNOWN"
|
||||
|
||||
fun hex(vararg b: Int) =
|
||||
b.map { it.toByte() }.toByteArray().contentEquals(header.copyOf(b.size))
|
||||
|
||||
return when {
|
||||
hex(0x50, 0x4B, 0x03, 0x04) -> "ZIP"
|
||||
hex(0x50, 0x4B, 0x05, 0x06) -> "ZIP_EMPTY"
|
||||
hex(0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7Z"
|
||||
hex(0x52, 0x61, 0x72, 0x21) -> "RAR"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun validateZipWithZipFile(zip: File) {
|
||||
try {
|
||||
ZipFile(zip).use { zf ->
|
||||
val e = zf.entries()
|
||||
var count = 0
|
||||
while (e.hasMoreElements()) {
|
||||
val entry = e.nextElement()
|
||||
Log.e("1314520-zipfile", "entry=${entry.name}")
|
||||
count++
|
||||
}
|
||||
Log.e("1314520-zipfile", "ZipFile entries=$count")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("1314520-zipfile", "ZipFile FAILED: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
* 2️⃣ 是否是垃圾文件
|
||||
* ========================= */
|
||||
private fun isJunkEntry(name: String): Boolean {
|
||||
if (name.startsWith("__MACOSX/")) return true
|
||||
val last = name.substringAfterLast('/')
|
||||
if (last.startsWith("._")) return true
|
||||
if (last == ".DS_Store") return true
|
||||
return false
|
||||
}
|
||||
|
||||
/* =========================
|
||||
* 3️⃣ 是否是「zip 里套 zip」
|
||||
* ========================= */
|
||||
private fun findSingleInnerZip(zipFile: File): String? {
|
||||
ZipFile(zipFile).use { zip ->
|
||||
val entries = zip.entries().toList()
|
||||
if (entries.size == 1) {
|
||||
val e = entries.first()
|
||||
if (!e.isDirectory && e.name.endsWith(".zip", ignoreCase = true)) {
|
||||
return e.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* =========================
|
||||
* 4️⃣ 解出内层 zip
|
||||
* ========================= */
|
||||
private fun extractInnerZip(
|
||||
zipFile: File,
|
||||
entryName: String,
|
||||
outFile: File
|
||||
) {
|
||||
ZipFile(zipFile).use { zip ->
|
||||
val entry = zip.getEntry(entryName)
|
||||
?: error("Inner zip not found: $entryName")
|
||||
|
||||
zip.getInputStream(entry).use { input ->
|
||||
FileOutputStream(outFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
* 5️⃣ 智能入口(唯一对外)
|
||||
* ========================= */
|
||||
fun unzipThemeSmart(
|
||||
context: Context,
|
||||
zipFile: File,
|
||||
themeId: Int,
|
||||
targetBaseDir: File = File(context.filesDir, "keyboard_themes")
|
||||
): String {
|
||||
// 👉 检测嵌套 zip
|
||||
val innerZipName = findSingleInnerZip(zipFile)
|
||||
if (innerZipName != null) {
|
||||
|
||||
val tempInnerZip = File(
|
||||
context.cacheDir,
|
||||
"inner_${System.currentTimeMillis()}.zip"
|
||||
)
|
||||
|
||||
extractInnerZip(zipFile, innerZipName, tempInnerZip)
|
||||
|
||||
val result = unzipThemeSmart(
|
||||
context = context,
|
||||
zipFile = tempInnerZip,
|
||||
themeId = themeId,
|
||||
targetBaseDir = targetBaseDir
|
||||
)
|
||||
|
||||
tempInnerZip.delete()
|
||||
return result
|
||||
}
|
||||
|
||||
// 👉 普通主题 zip
|
||||
return unzipThemeFromFileOverwrite_ZIS(
|
||||
context = context,
|
||||
zipFile = zipFile,
|
||||
themeId = themeId,
|
||||
targetBaseDir = targetBaseDir
|
||||
)
|
||||
}
|
||||
|
||||
/* =========================
|
||||
* 6️⃣ 真正的主题解压(你原逻辑)
|
||||
* ========================= */
|
||||
fun unzipThemeFromFileOverwrite_ZIS(
|
||||
context: Context,
|
||||
zipFile: File,
|
||||
themeId: Int,
|
||||
targetBaseDir: File
|
||||
): String {
|
||||
|
||||
val tempOut = File(context.cacheDir, "tmp_theme_out").apply {
|
||||
if (exists()) deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
val canonicalTempOut = tempOut.canonicalFile.path + File.separator
|
||||
|
||||
fun findIconsRelativePath(entryName: String): String? {
|
||||
val n = entryName.replace('\\', '/')
|
||||
val lower = n.lowercase()
|
||||
|
||||
val idxIcons = lower.indexOf("/icons/")
|
||||
val idxIcon = lower.indexOf("/icon/")
|
||||
|
||||
val idx = when {
|
||||
idxIcons >= 0 -> idxIcons + 7
|
||||
idxIcon >= 0 -> idxIcon + 6
|
||||
lower.startsWith("icons/") -> 6
|
||||
lower.startsWith("icon/") -> 5
|
||||
else -> return null
|
||||
}
|
||||
return n.substring(idx)
|
||||
}
|
||||
|
||||
fun isBackground(entryName: String): Boolean =
|
||||
entryName.substringAfterLast('/')
|
||||
.equals("background.png", ignoreCase = true)
|
||||
|
||||
var sawAnyEntry = false
|
||||
var extractedAnyIcons = false
|
||||
|
||||
try {
|
||||
ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis ->
|
||||
val buffer = ByteArray(8192)
|
||||
|
||||
while (true) {
|
||||
val entry: ZipEntry = zis.nextEntry ?: break
|
||||
sawAnyEntry = true
|
||||
val name = entry.name
|
||||
|
||||
if (isJunkEntry(name)) {
|
||||
zis.closeEntry(); continue
|
||||
}
|
||||
|
||||
val iconsRel = findIconsRelativePath(name)
|
||||
val isIcons = iconsRel != null
|
||||
val isBg = isBackground(name)
|
||||
|
||||
if (!isIcons && !isBg) {
|
||||
zis.closeEntry(); continue
|
||||
}
|
||||
|
||||
val relativeOut = if (isIcons) iconsRel!! else "background.png"
|
||||
val outFile = File(tempOut, relativeOut)
|
||||
|
||||
val canonicalOut = outFile.canonicalFile.path
|
||||
if (!canonicalOut.startsWith(canonicalTempOut)) {
|
||||
zis.closeEntry(); continue
|
||||
}
|
||||
|
||||
if (!entry.isDirectory) {
|
||||
outFile.parentFile?.mkdirs()
|
||||
FileOutputStream(outFile).use { fos ->
|
||||
while (true) {
|
||||
val c = zis.read(buffer)
|
||||
if (c == -1) break
|
||||
fos.write(buffer, 0, c)
|
||||
}
|
||||
}
|
||||
if (isIcons) extractedAnyIcons = true
|
||||
}
|
||||
zis.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawAnyEntry)
|
||||
throw IllegalStateException("zip 为空或损坏")
|
||||
if (!extractedAnyIcons)
|
||||
throw IllegalStateException("未找到 icons/icon 文件(请看 ziplist 日志)")
|
||||
|
||||
val finalDir = File(targetBaseDir, themeId.toString())
|
||||
if (finalDir.exists()) finalDir.deleteRecursively()
|
||||
finalDir.parentFile?.mkdirs()
|
||||
|
||||
if (!tempOut.renameTo(finalDir)) {
|
||||
finalDir.mkdirs()
|
||||
tempOut.copyRecursively(finalDir, overwrite = true)
|
||||
tempOut.deleteRecursively()
|
||||
}
|
||||
return themeId.toString()
|
||||
|
||||
} catch (e: Exception) {
|
||||
logZipEntries(zipFile)
|
||||
Log.e(TAG_UNZIP, "解压失败: ${e.message}", e)
|
||||
throw e
|
||||
} finally {
|
||||
if (tempOut.exists()) tempOut.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<translate
|
||||
android:fromYDelta="20%"
|
||||
android:toYDelta="0"
|
||||
android:duration="250" />
|
||||
android:duration="300" />
|
||||
|
||||
<!-- 透明度动画:从透明到不透明 -->
|
||||
<alpha
|
||||
android:fromAlpha="0"
|
||||
android:toAlpha="1"
|
||||
android:duration="250" />
|
||||
android:duration="300" />
|
||||
|
||||
</set>
|
||||
|
||||
5
app/src/main/res/drawable/bg_delete_btn.xml
Normal file
5
app/src/main/res/drawable/bg_delete_btn.xml
Normal 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>
|
||||
4
app/src/main/res/drawable/bg_dialog_round.xml
Normal file
4
app/src/main/res/drawable/bg_dialog_round.xml
Normal 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>
|
||||
18
app/src/main/res/drawable/bg_sub_tab.xml
Normal file
18
app/src/main/res/drawable/bg_sub_tab.xml
Normal 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>
|
||||
18
app/src/main/res/drawable/bg_top_tab.xml
Normal file
18
app/src/main/res/drawable/bg_top_tab.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/button_cancel_background.xml
Normal file
5
app/src/main/res/drawable/button_cancel_background.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/button_confirm_background.xml
Normal file
5
app/src/main/res/drawable/button_confirm_background.xml
Normal 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>
|
||||
15
app/src/main/res/drawable/complete_bg.xml
Normal file
15
app/src/main/res/drawable/complete_bg.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/dialog_background.xml
Normal file
5
app/src/main/res/drawable/dialog_background.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/dialog_persona_detail_bg.xml
Normal file
5
app/src/main/res/drawable/dialog_persona_detail_bg.xml
Normal 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>
|
||||
13
app/src/main/res/drawable/dot_bg.xml
Normal file
13
app/src/main/res/drawable/dot_bg.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/ic_added.xml
Normal file
5
app/src/main/res/drawable/ic_added.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable/input_icon.png
Normal file
BIN
app/src/main/res/drawable/input_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
5
app/src/main/res/drawable/input_message_bg.xml
Normal file
5
app/src/main/res/drawable/input_message_bg.xml
Normal 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="#EDEDED"/>
|
||||
<corners android:radius="100dp"/>
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/keyboard_button_bg4.xml
Normal file
5
app/src/main/res/drawable/keyboard_button_bg4.xml
Normal 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="#8002BEAC"/>
|
||||
<corners android:radius="50dp"/>
|
||||
</shape>
|
||||
BIN
app/src/main/res/drawable/no_search_result.png
Normal file
BIN
app/src/main/res/drawable/no_search_result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
app/src/main/res/drawable/selected.png
Normal file
BIN
app/src/main/res/drawable/selected.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src/main/res/drawable/send_icon.png
Normal file
BIN
app/src/main/res/drawable/send_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/drawable/shut.png
Normal file
BIN
app/src/main/res/drawable/shut.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
5
app/src/main/res/drawable/tag_background.xml
Normal file
5
app/src/main/res/drawable/tag_background.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/tv_background_bg.xml
Normal file
5
app/src/main/res/drawable/tv_background_bg.xml
Normal 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>
|
||||
@@ -33,6 +33,17 @@
|
||||
android:rotation="180"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="49dp"
|
||||
android:gravity="center"
|
||||
android:textStyle="bold"
|
||||
android:text="Key of love"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
@@ -52,6 +63,7 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
<!-- 头像 -->
|
||||
@@ -127,32 +139,61 @@
|
||||
android:id="@+id/bottom_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/input_message_bg"
|
||||
android:padding="5dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:paddingStart="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:layout_gravity="bottom">
|
||||
<EditText
|
||||
android:id="@+id/input_message"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="52dp"
|
||||
android:background="@drawable/input_box_bg"
|
||||
android:padding="15dp"
|
||||
android:hint="Please enter your content"
|
||||
android:textColorHint="#CBCBCB"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#CBCBCB"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:cursorVisible="true"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="text"/>
|
||||
<Button
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/input_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#00000000"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:cursorVisible="true"
|
||||
android:imeOptions="actionSend"
|
||||
android:textColor="#CBCBCB"
|
||||
android:textSize="12sp"
|
||||
android:inputType="text"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/hintLayout"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="19dp"
|
||||
android:src="@drawable/input_icon"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hint_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="Please enter your content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#CBCBCB"/>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
<ImageView
|
||||
android:id="@+id/btn_send"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="52dp"
|
||||
android:text="Send"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/send_icon"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="text"/>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/background"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:id="@+id/keyboard_container"
|
||||
android:layout_marginTop="3dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="8dp">
|
||||
<ImageView
|
||||
@@ -30,31 +32,33 @@
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:id="@+id/keyboard_row_1"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:orientation="horizontal">
|
||||
<!-- 粘贴框和人设 -->
|
||||
android:orientation="vertical">
|
||||
<!-- 粘贴框-->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
android:orientation="horizontal">
|
||||
<!-- 粘贴框 -->
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/completion_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="41dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:background="@drawable/keyboard_button_bg3"
|
||||
android:fillViewport="false">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/completion_container"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -76,9 +80,35 @@
|
||||
</LinearLayout>
|
||||
</HorizontalScrollView>
|
||||
|
||||
<!-- 人设 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/keyboard_button_Paste"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="41dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/keyboard_button_bg1">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Paste"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<!-- 人设和操作 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/ai_persona"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_height="131dp"
|
||||
android:scrollbars="none">
|
||||
@@ -89,9 +119,9 @@
|
||||
app:flexDirection="row"
|
||||
app:flexWrap="wrap"
|
||||
app:justifyContent="space_between">
|
||||
|
||||
<!-- 卡片 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/card"
|
||||
android:layout_width="90dp"
|
||||
android:layout_height="41dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
@@ -115,35 +145,16 @@
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="13sp"/>
|
||||
</LinearLayout>
|
||||
<!-- ```````````````` -->
|
||||
|
||||
</com.google.android.flexbox.FlexboxLayout>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
<!-- 操作 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/keyboard_button_1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:orientation="vertical">
|
||||
<LinearLayout
|
||||
android:id="@+id/keyboard_button_Paste"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="41dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/keyboard_button_bg1">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Paste"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/keyboard_button_Delete"
|
||||
android:layout_width="60dp"
|
||||
@@ -190,5 +201,41 @@
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<!-- `````````````````````````````````````````````````````````````````````````````````````` -->
|
||||
<LinearLayout
|
||||
android:id="@+id/ai_output"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="131dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="@drawable/keyboard_button_bg3"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scroll_messages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_messages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"/>
|
||||
</ScrollView>
|
||||
<!-- 切换按钮 -->
|
||||
<TextView
|
||||
android:id="@+id/Return_keyboard"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="30dp"
|
||||
android:layout_marginStart="-55dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:background="@drawable/keyboard_button_bg4"
|
||||
android:gravity="center"
|
||||
android:text="Return"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
</LinearLayout>
|
||||
<!-- `````````````````````````````````````````````````````````````````````````````````````` -->
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<androidx.core.widget.NestedScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/scrollContent"
|
||||
android:id="@+id/rvList1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
@@ -22,6 +22,7 @@
|
||||
android:orientation="horizontal">
|
||||
<!-- 第2名 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/container_second"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
@@ -29,6 +30,7 @@
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/avatar_second"
|
||||
android:layout_width="67dp"
|
||||
android:layout_height="67dp"
|
||||
android:elevation="4dp"
|
||||
@@ -38,7 +40,7 @@
|
||||
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/backgroundImage"
|
||||
android:id="@+id/bg_second"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="148dp"
|
||||
android:layout_marginTop="-33dp"
|
||||
@@ -48,9 +50,25 @@
|
||||
android:src="@drawable/second_place" />
|
||||
|
||||
<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_height="28dp"
|
||||
android:background="@drawable/round_bg_two"
|
||||
android:layout_marginTop="50dp"
|
||||
android:gravity="center"
|
||||
android:text="+"
|
||||
android:textColor="#6EA0EB"
|
||||
@@ -60,12 +78,14 @@
|
||||
|
||||
<!-- 第一名 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/container_first"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/avatar_first"
|
||||
android:layout_width="67dp"
|
||||
android:layout_height="67dp"
|
||||
android:elevation="4dp"
|
||||
@@ -74,7 +94,7 @@
|
||||
app:civ_border_color="#DFF346" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/backgroundImage"
|
||||
android:id="@+id/bg_first"
|
||||
android:layout_marginTop="-33dp"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="148dp"
|
||||
@@ -82,11 +102,27 @@
|
||||
android:src="@drawable/first_place"
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="true" />
|
||||
|
||||
<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_height="28dp"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="50dp"
|
||||
android:text="+"
|
||||
android:textSize="20dp"
|
||||
android:textStyle="bold"
|
||||
@@ -96,6 +132,7 @@
|
||||
|
||||
<!-- 第三名 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/container_third"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
@@ -103,6 +140,7 @@
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/avatar_third"
|
||||
android:layout_width="67dp"
|
||||
android:layout_height="67dp"
|
||||
android:elevation="4dp"
|
||||
@@ -111,7 +149,7 @@
|
||||
app:civ_border_color="#DFF346" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/backgroundImage"
|
||||
android:id="@+id/bg_third"
|
||||
android:layout_marginTop="-33dp"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="148dp"
|
||||
@@ -120,10 +158,25 @@
|
||||
android:scaleType="fitXY"
|
||||
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
|
||||
android:id="@+id/btn_add_third"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginTop="50dp"
|
||||
android:gravity="center"
|
||||
android:text="+"
|
||||
android:textSize="20dp"
|
||||
@@ -135,75 +188,14 @@
|
||||
|
||||
<!-- 排名靠后 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/container_others"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="260dp"
|
||||
android:minHeight="1000dp"
|
||||
android:layout_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">
|
||||
<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>
|
||||
android:orientation="vertical">
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,99 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/scrollContent"
|
||||
android:id="@+id/rvList2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="1000dp"
|
||||
android:padding="14dp"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="1000dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="14dp">
|
||||
<!-- 内容卡片 -->
|
||||
<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" />
|
||||
android:paddingBottom="240dp"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<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">
|
||||
<ProgressBar
|
||||
android:id="@+id/loadingView"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<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: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>
|
||||
</FrameLayout>
|
||||
|
||||
54
app/src/main/res/layout/dialog_logout.xml
Normal file
54
app/src/main/res/layout/dialog_logout.xml
Normal 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>
|
||||
90
app/src/main/res/layout/dialog_persona_detail.xml
Normal file
90
app/src/main/res/layout/dialog_persona_detail.xml
Normal 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>
|
||||
80
app/src/main/res/layout/dialog_purchase_confirmation.xml
Normal file
80
app/src/main/res/layout/dialog_purchase_confirmation.xml
Normal 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>
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<!-- 输入框 -->
|
||||
<EditText
|
||||
android:id="@+id/et_username"
|
||||
android:id="@+id/et_email"
|
||||
android:layout_width="315dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginTop="20dp"
|
||||
@@ -108,7 +108,7 @@
|
||||
android:hint="Please enter your email address"
|
||||
android:textColorHint="#CBCBCB"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#CBCBCB" />
|
||||
android:textColor="#000000" />
|
||||
<!-- 密码输入框 -->
|
||||
<RelativeLayout
|
||||
android:layout_width="315dp"
|
||||
|
||||
@@ -94,19 +94,38 @@
|
||||
android:elevation="5dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/subscript"/>
|
||||
|
||||
<TextView
|
||||
<!-- 昵称 -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:text="Username"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textStyle="bold"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:maxLines="1"
|
||||
android:layout_weight="1"
|
||||
android:textSize="20sp" />
|
||||
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:textColor="#1B1F1A"
|
||||
android:textStyle="bold"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:maxLines="1"
|
||||
android:layout_weight="1"
|
||||
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>
|
||||
|
||||
<!-- 充值 -->
|
||||
@@ -385,6 +404,7 @@
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/logout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="63dp"
|
||||
android:layout_marginTop="20dp"
|
||||
|
||||
@@ -136,71 +136,11 @@
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="14sp" />
|
||||
<!-- 推荐皮肤列表 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_recommend_list"
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_recommend_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
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>
|
||||
android:paddingTop="10dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
@@ -74,70 +74,33 @@
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_recommend_list"
|
||||
android:layout_width="match_parent"
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_search_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="10dp" />
|
||||
<!-- 没有搜索结果时显示的提示 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_no_search_result"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:paddingTop="10dp"
|
||||
android:visibility="gone"
|
||||
android:orientation="vertical">
|
||||
<ImageView
|
||||
android:layout_width="175dp"
|
||||
android:layout_height="175dp"
|
||||
android:src="@drawable/no_search_result" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="No data available for the time being"
|
||||
android:textColor="#999999"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<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>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,213 +1,228 @@
|
||||
<?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:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/rootCoordinator"
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.home.HomeFragment">
|
||||
android:layout_marginBottom="40dp">
|
||||
|
||||
<!-- 背景-->
|
||||
<ImageView
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:src="@drawable/bg"
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="true" />
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!-- 头部 + 标签行,放进 AppBarLayout 里 -->
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent">
|
||||
<!-- 背景-->
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:src="@drawable/bg"
|
||||
android:scaleType="fitXY"
|
||||
android:adjustViewBounds="true" />
|
||||
|
||||
<!-- 这一块会跟着滚动,滑出屏幕 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/headerContainer"
|
||||
<!-- 头部 + 标签行,放进 AppBarLayout 里 -->
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_scrollFlags="scroll|exitUntilCollapsed">
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<!-- 搜索、皮肤栏 -->
|
||||
<!-- 这一块会跟着滚动,滑出屏幕 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/headerContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:gravity="end|bottom"
|
||||
android:orientation="horizontal">
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_scrollFlags="scroll|exitUntilCollapsed">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/searchButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/search" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/skinButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/skin" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 小星星 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="110dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:elevation="1dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginTop="62dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:src="@drawable/yellow_star" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="68dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="Points Mall"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textStyle="bold"
|
||||
android:textSize="22sp" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/yellow_star" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginStart="62dp"
|
||||
android:src="@drawable/blue_star" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginStart="100dp"
|
||||
android:src="@drawable/blue_star" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginTop="54dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:src="@drawable/blue_star" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Points 卡片 -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="238dp"
|
||||
android:layout_marginTop="-110dp"
|
||||
android:elevation="10dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- 点数背景 -->
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/points" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<!-- 搜索、皮肤栏 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="110dp"
|
||||
android:orientation="vertical">
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:gravity="end|bottom"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/searchButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/search" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/skinButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/skin" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 小星星 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="110dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:elevation="1dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginTop="62dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:src="@drawable/yellow_star" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="68dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="My points"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="Points Mall"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="14sp"
|
||||
android:padding="20dp" />
|
||||
android:textStyle="bold"
|
||||
android:textSize="22sp" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/yellow_star" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginStart="62dp"
|
||||
android:layout_marginTop="60dp"
|
||||
android:src="@drawable/yellow_star" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginTop="62dp"
|
||||
android:layout_marginStart="2dp"
|
||||
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
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginTop="54dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:src="@drawable/blue_star" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Points 卡片 -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="238dp"
|
||||
android:layout_marginTop="-110dp"
|
||||
android:elevation="10dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- 点数背景 -->
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/points" />
|
||||
|
||||
<!-- 内容 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="38dp"
|
||||
android:layout_height="38dp"
|
||||
android:layout_marginEnd="11dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:src="@drawable/gold_coin" />
|
||||
android:layout_marginTop="110dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:text="88.00"
|
||||
android:textColor="#02BEAC"
|
||||
android:textSize="40sp" />
|
||||
android:text="My points"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="14sp"
|
||||
android:padding="20dp" />
|
||||
|
||||
<!-- 按钮 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/rechargeButton"
|
||||
android:layout_width="114dp"
|
||||
android:layout_height="42dp"
|
||||
android:layout_marginStart="30dp"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/gold_coin_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="38dp"
|
||||
android:layout_height="38dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:src="@drawable/gold_coin" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:id="@+id/balance"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:text="0.00"
|
||||
android:textColor="#02BEAC"
|
||||
android:textSize="40sp" />
|
||||
|
||||
<!-- 按钮 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/rechargeButton"
|
||||
android:layout_width="114dp"
|
||||
android:layout_height="42dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#1B1F1A"
|
||||
android:text="Recharge" />
|
||||
android:background="@drawable/gold_coin_button"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:textColor="#1B1F1A"
|
||||
android:text="Recharge" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 标签行:仍然在 points 卡片下面,并设置成吸顶 -->
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/tagScroll"
|
||||
<!-- 标签行:仍然在 points 卡片下面,并设置成吸顶 -->
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/tagScroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:fillViewport="true"
|
||||
android:scrollbars="none"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/tagContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical" />
|
||||
</HorizontalScrollView>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<!-- 内容页,放进 ViewPager2 里 -->
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/viewPager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:fillViewport="true"
|
||||
android:scrollbars="none"
|
||||
android:overScrollMode="never">
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="40dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/tagContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical" />
|
||||
</HorizontalScrollView>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<!-- 内容页,放进 ViewPager2 里 -->
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/viewPager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</com.example.myapplication.widget.NoHorizontalInterceptSwipeRefreshLayout>
|
||||
|
||||
7
app/src/main/res/layout/fragment_shop_style_page.xml
Normal file
7
app/src/main/res/layout/fragment_shop_style_page.xml
Normal 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"/>
|
||||
15
app/src/main/res/layout/item_ai_message.xml
Normal file
15
app/src/main/res/layout/item_ai_message.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:background="#f5f5f5"
|
||||
android:padding="10dp" />
|
||||
</LinearLayout>
|
||||
7
app/src/main/res/layout/item_dot.xml
Normal file
7
app/src/main/res/layout/item_dot.xml
Normal 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" />
|
||||
11
app/src/main/res/layout/item_emoji.xml
Normal file
11
app/src/main/res/layout/item_emoji.xml
Normal 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" />
|
||||
12
app/src/main/res/layout/item_emoji_tab.xml
Normal file
12
app/src/main/res/layout/item_emoji_tab.xml
Normal 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"/>
|
||||
|
||||
11
app/src/main/res/layout/item_kaomoji.xml
Normal file
11
app/src/main/res/layout/item_kaomoji.xml
Normal 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" />
|
||||
58
app/src/main/res/layout/item_myskin_theme.xml
Normal file
58
app/src/main/res/layout/item_myskin_theme.xml
Normal 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>
|
||||
@@ -3,7 +3,8 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="40dp"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="end"
|
||||
android:orientation="vertical">
|
||||
<!-- 头像 -->
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="40dp"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
<!-- 头像 -->
|
||||
|
||||
89
app/src/main/res/layout/item_persona.xml
Normal file
89
app/src/main/res/layout/item_persona.xml
Normal 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>
|
||||
72
app/src/main/res/layout/item_rank_other.xml
Normal file
72
app/src/main/res/layout/item_rank_other.xml
Normal 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>
|
||||
<!-- 内容卡片结束 -->
|
||||
@@ -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
|
||||
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:background="#F8F8F8"
|
||||
android:id="@+id/theme_card"
|
||||
app:cardCornerRadius="15dp"
|
||||
app:cardUseCompatPadding="true">
|
||||
<LinearLayout
|
||||
@@ -28,12 +14,14 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/theme_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="127dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/theme_name"
|
||||
android:layout_width="130dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginTop="8dp"
|
||||
@@ -61,17 +49,15 @@
|
||||
android:src="@drawable/gold_coin" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:id="@+id/theme_price"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-2dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="20"
|
||||
android:text="0.00"
|
||||
android:textColor="#02BEAC"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -8,26 +8,54 @@
|
||||
<LinearLayout
|
||||
android:id="@+id/control_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_height="50dp"
|
||||
android:orientation="horizontal">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
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
|
||||
android:id="@+id/collapse_button"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center">
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
<TextView
|
||||
android:id="@+id/Key_collapse"
|
||||
android:layout_width="19dp"
|
||||
android:layout_height="10dp"
|
||||
/>
|
||||
android:id="@+id/key_revoke"
|
||||
android:layout_width="83dp"
|
||||
android:layout_height="25dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:clickable="true"/>
|
||||
|
||||
|
||||
<!-- 收起键盘 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/collapse_button"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center">
|
||||
<TextView
|
||||
android:id="@+id/Key_collapse"
|
||||
android:layout_width="19dp"
|
||||
android:layout_height="10dp"
|
||||
/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -35,8 +63,10 @@
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:scrollbars="none"
|
||||
android:overScrollMode="never"
|
||||
android:background="@drawable/complete_bg"
|
||||
android:id="@+id/completion_scroll">
|
||||
|
||||
<LinearLayout
|
||||
@@ -56,7 +86,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_1"
|
||||
@@ -67,7 +97,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_2"
|
||||
@@ -78,7 +108,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_3"
|
||||
@@ -89,7 +119,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_4"
|
||||
@@ -100,7 +130,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_5"
|
||||
@@ -111,7 +141,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_6"
|
||||
@@ -122,7 +152,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_7"
|
||||
@@ -133,7 +163,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_8"
|
||||
@@ -144,7 +174,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_9"
|
||||
@@ -155,7 +185,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_10"
|
||||
@@ -166,7 +196,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_11"
|
||||
@@ -177,7 +207,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_12"
|
||||
@@ -188,7 +218,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_13"
|
||||
@@ -199,7 +229,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_14"
|
||||
@@ -210,7 +240,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_15"
|
||||
@@ -221,7 +251,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_16"
|
||||
@@ -232,7 +262,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_17"
|
||||
@@ -243,7 +273,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_18"
|
||||
@@ -254,7 +284,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_19"
|
||||
@@ -265,7 +295,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/suggestion_20"
|
||||
@@ -276,7 +306,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/btn_keyboard"
|
||||
android:textColor="#3C3C3C"/>
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
</LinearLayout>
|
||||
</HorizontalScrollView>
|
||||
@@ -286,6 +316,7 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="horizontal">
|
||||
@@ -573,7 +604,7 @@
|
||||
android:gravity="center"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/key_ai"
|
||||
android:id="@+id/key_emoji"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="40dp"
|
||||
android:textSize="12sp"
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F6F7FB"
|
||||
tools:context=".ui.keyboard.KeyboardDetailFragment">
|
||||
<androidx.core.widget.NestedScrollView
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
android:layout_height="match_parent">
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never"
|
||||
android:layout_marginBottom="80dp">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -70,7 +75,7 @@
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1B1F1A"
|
||||
android:text="Dopamine" />
|
||||
android:text="Loading..." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_download_count"
|
||||
@@ -82,10 +87,16 @@
|
||||
android:textSize="14sp"
|
||||
android:background="@drawable/tv_download_count"
|
||||
android:textColor="#02BEAC"
|
||||
android:text="Download: 1 million" />
|
||||
android:text="Loading..." />
|
||||
</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
|
||||
@@ -101,112 +112,91 @@
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="14sp" />
|
||||
<!-- 推荐皮肤列表 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_recommend_list"
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_recommend_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
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
|
||||
android:id="@+id/rechargeButton"
|
||||
android:layout_marginTop="22dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="45dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/my_keyboard_delete"
|
||||
android:elevation="4dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
android:text="Download" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/gold_coin" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
android:text="20" />
|
||||
|
||||
android:paddingTop="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<!-- 固定在底部的购买按钮 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/rechargeButton"
|
||||
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">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
android:text="Download" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="17dp"
|
||||
android:layout_height="17dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/gold_coin" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_price"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
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>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
90
app/src/main/res/layout/keyboard_emoji.xml
Normal file
90
app/src/main/res/layout/keyboard_emoji.xml
Normal 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>
|
||||
@@ -1,108 +1,115 @@
|
||||
<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:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/rootCoordinator"
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F6F7FB"
|
||||
tools:context=".ui.shop.myskin.MySkin">
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:id="@+id/rootCoordinator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
<LinearLayout
|
||||
android:background="#F6F7FB"
|
||||
tools:context=".ui.shop.myskin.MySkin">
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
<!-- 标题和返回 -->
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
<!-- 返回按钮 -->
|
||||
<FrameLayout
|
||||
android:id="@+id/iv_close"
|
||||
android:layout_width="46dp"
|
||||
android:layout_height="46dp">
|
||||
<ImageView
|
||||
android:layout_width="13dp"
|
||||
android:layout_height="13dp"
|
||||
android:layout_gravity="center"
|
||||
android:src="@drawable/more_icons"
|
||||
android:rotation="180"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:textStyle="bold"
|
||||
android:text="MySkin"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:gravity="center"
|
||||
android:textStyle="bold"
|
||||
android:text="Editor"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 内容 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
<!-- 卡片内容 -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="170dp"
|
||||
<!-- 标题和返回 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
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">
|
||||
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
<!-- 返回按钮 -->
|
||||
<FrameLayout
|
||||
android:id="@+id/iv_close"
|
||||
android:layout_width="46dp"
|
||||
android:layout_height="46dp">
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="127dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/default_avatar" />
|
||||
android:layout_width="13dp"
|
||||
android:layout_height="13dp"
|
||||
android:layout_gravity="center"
|
||||
android:src="@drawable/more_icons"
|
||||
android:rotation="180"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
|
||||
<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="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:textStyle="bold"
|
||||
android:text="MySkin"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="15dp"
|
||||
android:layout_height="15dp"
|
||||
android:layout_marginTop="-150dp"
|
||||
android:layout_marginStart="10dp"/>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
<TextView
|
||||
android:id="@+id/tvEditor"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:gravity="center"
|
||||
android:textStyle="bold"
|
||||
android:text="Editor"
|
||||
android:textColor="#1B1F1A"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 内容 -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvThemes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false"
|
||||
tools:listitem="@layout/item_myskin_theme"/>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<!-- 底部编辑栏 -->
|
||||
<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.core.widget.NestedScrollView>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
@@ -10,29 +10,59 @@
|
||||
android:id="@+id/control_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:orientation="horizontal">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingStart="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="10dp"
|
||||
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
|
||||
android:id="@+id/collapse_button"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center">
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
<TextView
|
||||
android:id="@+id/Key_collapse"
|
||||
android:layout_width="19dp"
|
||||
android:layout_height="10dp"
|
||||
/>
|
||||
android:id="@+id/key_revoke"
|
||||
android:layout_width="83dp"
|
||||
android:layout_height="25dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:clickable="true"/>
|
||||
|
||||
|
||||
<!-- 收起键盘 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/collapse_button"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center">
|
||||
<TextView
|
||||
android:id="@+id/Key_collapse"
|
||||
android:layout_width="19dp"
|
||||
android:layout_height="10dp"
|
||||
/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
<!-- 第一行数字键 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="horizontal">
|
||||
@@ -291,7 +321,7 @@
|
||||
android:gravity="center"
|
||||
android:clickable="true"/>
|
||||
<TextView
|
||||
android:id="@+id/key_ai"
|
||||
android:id="@+id/key_emoji"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="40dp"
|
||||
android:textSize="12sp"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user