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

*#YI3Mt>a?Idd2*p9}%yQ!(f?I0Wx0)UqrK` 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 z zcI&}$mxv^+8}}DAjB=u#scs|63Tq#d|MVXnXXBChrVecW-PIiqaFbKgpL5!FXh&c} zT(shuY_{v_+Rcg^J12uz^`s}jL{MiX{pah*s%ddB8BgU1!{{9l{fD<|`B_ZgJIWpE zcG%8i2DO(0J$0;bu)|KeH>J}yi-2R>Zz&>&pp)u2^9wpxLP%m1dj;J)Uttr1>8_A)L zk;sx@!sEJ+3RATGVg)iwX@2>hfUY>|1H7^zC9!pI#@oj1+QI%%`rx6wgoDo7q;ZId z2;iR~&gXs5JJ zO3W1cmE~XtiYqNeY|R@6NmxNt#gxw6vqE&g)0 zzFL${9N2c>3c`A&4O*Hcqzv?hoi6`8d3CrHm{Sg3aU?y7DG%Hel-`-j2tpkXFprTG zX>P5k(myEZ#rhYer^nw;SRB?>S-Pje(t>BozW#Ls#n#X)*ro3y`Xab=#GMI5WydulD(*EEXIV^9rJob$9rD@(~F#|>L&(?9v@{C%@AyIPYI<#ct zn`w8o8+lO%B{Mzi{LVw7W=95jOhtFB-&<>Isxh$7damHtk{xuYTxfx0^n-zX*5)aESgaj$3V z&ayAyzeInkGKA>1^V2vjE0qa4{Y07*)c5y0$0RNp-#9#5nGv9}#SP}h%zh;pu=oyD zleqr@PLNJ|*MO{zQ}uNPs(8{W0Fm=C652Snm-?UqG|_{Junq|1`Hg@-DU}RPrEMU^ z!(WrPv1=!T<`n)w4u#9gjHmxtNsc}rC-Dek%F_|2Y(43!KunQaQy!pGr%=Y3O9B)9 zBIFgY0&?Z~mmBJ>{8Jv`V`GZP_(DmKN1wp;4(r{kg};E3*wio1^f?Ra)ZXC62B(K! zZM!Ox2^q~}d2F1zDjPyc=w=ya!a*@j1ran~1^6vY4j2~&Pawx;4|adqXV`2De9}Vc!};(3-MT_H6?W-45Be^7pyTLxc15-!dz5_b(vZ zT$*I&{;#hGasKu#24^{!7rplYO(V_rkqqyh9&H3MZuurUR&Q(UuXvipG{wc`FC31H zmaud%gqr_a6**xrWe>bcvRzjMVe{8G?IMdK;K7_-Hv!MDUPMc0WfRVvSP*VE2R6Yv zqGT3Ht1ZywAW{y0#H^Eessdvhr#Y*nF{ibYO#q^QGDz&Hm&ugKKis$J$Q~k>=<4R7 znNPB5fH^1?IUlFES4Yr1MAgoQt7Ej3wd82zaw`t;-o1lr0j?hyQ4BVpsksP_Z;J7s=#_J>a85T}~F-%|G*XXU3rQJS3)5vdI zN4=gA?CopB+_8d0_6*iKmo=4y&xSvCa=8>3YbAmAH@P_g#WtN|4t^uyZf@}PR~*)n z{jYK0*?_lLhlTEw?0$MLn6dO(%>teFFsEnxcd^UtF2Z$o2gq`Ej6PgQHkzvK1O)cC zZ?|+|T>~642AO09Q5Wsz{Ry7<+V*Mz78`CjwyV&tz8xWugo{@}pqQ>(MMaaW>PVfU zGtKS|QkYla7Qkl{l6ecVk_M1pb@C+}mWDAx^IqrM|E`~%4&>E`O86F#nIr}H4(SH@ z)t8%oN=DQ83fFk^0&iQ*?C`Y6c5Ydd%ZM8Ey~e2np)Qiw%G3@l6xabhO|PR_k4*US zyuAm^*f$o)DnDGte34D9*9)-d!DE8#wbmrS_P#Jz$RPsCbh+qEN1~ER-)bkJ@{2Yw zx*)!T()78?;RHuRKo6%Z(6N~boTR$oX$L_Xnzr&Lt~~j*z*MqbOnvr>Lq>G!nNq1A zGp&pdcO>K|D&qAS=^I_Rxf*3;n7PnMgJiz=0C8}lIKg>;J*RuXqW;NU=H7XvaMm3n zuOq{%Kh#bPdQR&^<|~a!33!_D(`hSL1OcgWK-Uf<8FBgY*m^j=1t7tD-_1(pt(-Z_ z{kBZ_NSE#xG`=~R))u%vK=W1`D!COcQOR1_Vu;;XoCkkozRXsjY02jZ4A&`4J!Z$o zw85=R6*Q`iJ>=}zL|X#m0v(?)F}sG-FmU`G$FVs(_pwFMSF3%dujRD|3P$NilV5cY zh5K+1uof=;)?XPPz0nAEGtGAT3UuVsBVJ+KW#7WwlV_U*!%r`Ke_8{h=i zxAH=q&ejG&O6^;q9j(m_^iGNvc^zp^lYQ@thWC)c4Bud>F|+Cy=A*ktZ>Kd(2bNcm zN}mrJ7jPi4wf|+cYP<1nJBbl^;h0IBo=gWo_qw`+7aRfQ8(okHJ};)*d|)r2u|m*C z5=;72U<@Pp$Hmp^8U*Si{ClzkU_k=;n zKkk#i_QEzKVngrwNV)n)vQ80FUe22aRgnGH#c;fxZTxsUd&g6SrH0AZ>O=!kbP{S* znN5?SnOw~i3}mA*y&YJ*_ScJ?Btm3rD&@0a`q-qz- zuv)Xb4Jm(i>p`I!JbP5PWU|1TG4{tktvoo@Z+oLF9b4Jv9D{;lozN7-)9r|NyPi~( zMAPz6Ftw*8S%B82{8a0UxLOliO+i5Ptli;ZTSL^()4#x?HCs2>k#v@1W&=&mx(fUL zfTl(CC)6K}zE+6SdT>)-62}A(W&QUG*c)Yr3%H|y{}VFwurC8d<5%o^GcWi zg0Q1TS|TtK;HHxrZTcL!_*Q@Ru@_I&ZU;N;?@n>w-G1iSGc lXYcJ<|drIB}|^9 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/selected.png b/app/src/main/res/drawable/selected.png new file mode 100644 index 0000000000000000000000000000000000000000..630181ecf3024bb8f628df0050de36edf9f7582d GIT binary patch literal 4225 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%0DC0i6EN)r9k&DZ^@l21g;Y_4%VuMs zuj_>5SpZ$Y%yk4z0ij0EIH{YNcM{PSfZvjWHe$zDTXD-RhxPCao@(>~((&RHf!LKS za5(`N0~i^+8vAkF$G`@Xcuk^vHImnV6tX;?*hdo}+4gCIc@Dr00M7BG0sWmn0H|FO ztwMdn`}*1W%ON@d=~yvV)C~VQnC1dJ!(TN7MU6Itc%|$)@Enr!x`N{Bg^&3F>1>-J zNH&8h1;Vjj25U@T#z7FLC5xpu|@CJ6K(0T=2Ru&Fc|c$2~RA>Pz!l8oj6B-36?kPj1(G)X~0%fdhhL)?vc z!@EKAF2*l>fUI9NPIOZXAZ`OVe0fZ;3cvtAA!FQv*>k=OQ>0K0Qjscbx`^qno4EV^pr%;-Kd|lKZvmf1CY*_ z&Jfh|3V^eM7}OBpv0ZY_)ks|TvF@?z2S_G;g#ezfE-Aj7vP%LtBA!~~JLLj`S^*-m z>1IeQ0YT^-#E_-UkPuiX>r+d0=|CGmL`UmG5VS~_;8nT>8j!qLCY$cnp%X0t5t-J< z0Q!{}gK}nD4isReClwz`R^;_~2%=v`8DRZ8h-~ zAR?1i9=|F$Y^h#TBL$K~lW#Wg4ItHJMU%l@k@au6@dY5&4XGh0vy#?>=GtfMUB zQN5$BQg@{R!ku1^z}^6&oM4Pst9}kw=F{JaAtm7|FU~ag0Li(}->P~@_1oDahc3xA z7r1rHy90>2cw@1tdbi@ybisAsE=kUGbHV2dAR^oLWc5}yIP3btN!r378_@ZmZqk!g+qLY=LzRl@{b zn#PcBR`_C~>2kZ`*ak>VrhPs`h88?)cR`L<4FthNtR11urx%4P7CH9BR| zYlnI|WSCae$=gcvjHgQGCY1(ArZpizMrNJsRGeGd6Nep(Q-|d$RGd0&xb=7Up##?6 zZ@Ld-&!L0XUz0uwPL@g-F9{IJq~927S@z|OS36>qWqkE_`iRk(_soWAJi;y&A9QQ%@XAdzwX~(BC&OSxn}zQ&p0Ugzz+siUpxvRl@epBW{PW% zACEgGObTQ#;ni~L_&avPpS+Us*1t#$>(Kzow7nD6I;43}%sqLcu`@*}3(nZofp_&?iXdedeZZ#=J%M1O!-6^!M3o_K`NdEQAAVcMR;jT+)xUdQX-eC4LAZ+#?*a|!Bu%)V zl-+0MU#aMuK}1Ko8N#d#IR%XSPdyv+PM#R9#scDQ!XCsLs>Gc#nJY@u2N24n|4hJD z0qLS7o1q4byfRDy2m`NjJoS?!0Lf-!g1f#B`!SxO3XD8JPT#mX)H|7iU+q4*vumt+ zIV(4VtZSPoB>y$6*<+{!Bd>SmC^IEqL<;&5E^XXo0YqfdcL2~<2(1+G?mKlVnkP*5 z^%^}apnKtKgeSHC0Geey_4{6cv^@{-&%6xKXoKfUWz#!FiiZdI^O7 zAmfcUS^%NW^oK;48kC`wFJsB%+95w{6zW?=nKit>F(?z1<-?3^oJgHb$YzHL{(2w4 zxi@1qe`-m&|Fp9;bBP}LrF(Z_;VvVdi0M@xbxRsEl3=AZ$gM%B^o~;|;gQL;L9mJ( z4^y0Eg^H69(+SzOlwfYvtx>h-*gP{?T~-EBv_VJQJ_#jJ6NyaQuL1s@Zh$PAREy>b zlXSE3mHvu1e0H8?oj}F3}Ee5k>8n_h*XTm1AqSz z5ANR94{$1mRgJ^%`ydtFt^$|M`{;3^Z2Bc)T&r8S&grv_iUBP(-eTa$x4&fc@yOI& z;;XvM+o0x#$Amu;b+*1qM6-MyaS>usSz=0oLk&yqr9PQ;rLXEzvxf;uR09Xa%o~Y1 z(>sVT#n%xRA*OsE%ch>Mn~f*><-nncE5}}@Znh^r13R1KVlR}986T0zw0;KAiN20_ z5Mm$C`myipw_kGG0f*w%e(o$QLf)^>JP&F=hReiGUAf5EVqdF+E_Oz>`CS6$!?GN;rTUH#v+Lx$X zUzK_9cQBDj;4t_8Wxz2vC`Xwca13md&&u}$EGB_NjnnYstz`qr`>2_v zcEo7QNu{d)I4SmG8aULrOm(mKn?Ed9dq9_UUI8+&e;zQ?M~vz%88xoxvDrj$sO9C1 zjjN4`U?0pj)@5B#*ZqD2q!_(?(K%I$QW;_0|BJ-gDG;Z_Lt1LQD$Zp7_P2vjiANsv zcwhlT9wpV*J!o)5WqHXdobCxMkYK9JwEa8sDv8C_x=TKX!*2h0{y!jEf#A_KgT7amubTb+rpCx-Ck}%aOz> zD>paz)Jt8)w?lW?S?7mRb0iARu_O8`NWBa*dE1Ub{cfYG6{*zEpufm0FFC~=N!|{- z{6o-&(h-?nZVb`5dnwqU7M6d2Aq8a0DcP_8D=%!<#Xt3noGm(bJ-W7t>2I`8(%3Pm zukSXp1eN~r!%Bd1a-ZW>CbJ{Q`j77D5>#rVnoT!@%_?H&)hMO<2YHf-lJCRPS+OE^ zFt`k+4xp%8VU@xV#22y6DTK=%c|b2^NZ5v)Y^WOD(0VCW}^EXh*LxBPU14H?pS&KlzR?j zh@ED1r-DXpa|$~T3gOLFW5j>N-b#9stkf?@Im~oq_(kq$YxhaA+%i+Sj~WgHi>`f$ z(K)w8e=&9+HB_BDUz{N*R`JiGs-f!GhnVopY5Dy|{}zs(MO9sg%EQV!{2_)s3}hQ4 z&L37*q=(Sc?0(+ML~{0U^EA6Pi)7Mo5OASwC`PoQpZ7AxCu9$>5d&{>Jay4vrYrt5 zd%kt1H6cJ95w*K*vHgk6WbZ!eESifqV=oact-4n_YjPn28-tI2lyAum{{VhsE z|3b#(G|Zm!WjUek0R%uzCOw~lXUY+b^8ej9&7!z5C162MJhjY@t!x9N*Ln6D5tZE3 z*kNjN8>h2K)Rq=zUdxH5%kA305kRo!g$W`S+X|vFb{RyiofFS$^n!uVB@Z92!xcB| zwQCn=08vZLy0)8G@=Ci5qS($(*8hsn_CUGRqx2Ee+@3_^vrc4o3=l0N%LlCscDf4Q3 zjT(@=SSFk9^~%8)fLI_I0{1mul^tAvd-p7MjuL z6Lv}9MlVh{}bmVc$v?{4fU+^06%CD&Z-)_v|ohF}1qBvKcAsCl^3Z-JdC z-&I?6D~q1H-CVr6;TIf0dZn}CNqXd?l^k^B#t=68bP>~CPUAUj&@I^{aDZ6Sta)Jq z#bVD?dibJOxwj_~UL_~3K zLo~a$RlJ48Vk8@ltfF8pK=O9A%3;+sgDF)ph4mN*L7bK>mLk!(*<=Wt50Jd}RQ1L0 z#7$tD3-An+l{&a=v>C)JWzT`&x|w+=5p4nZEh%Utc+3{{Qj;gE7}UA408&h`+2zB)qs|2J zWWhKcz(gh<56LG2G?s`*0W=(h?|~_1ks$5{<2!&4Fw?gHcR|qS#PlhEJ(BPVn0A7W j+kyJ}Lk8K?cTxTa&WdVrnY?|O00000NkvXXu0mjftOY!y literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/shut.png b/app/src/main/res/drawable/shut.png new file mode 100644 index 0000000000000000000000000000000000000000..b61d93173872b054d02c3accecdeb16409cbc864 GIT binary patch literal 2806 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 IM6N<$g1+b_b^rhX literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/tag_background.xml b/app/src/main/res/drawable/tag_background.xml new file mode 100644 index 0000000..b4838ba --- /dev/null +++ b/app/src/main/res/drawable/tag_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/tv_background_bg.xml b/app/src/main/res/drawable/tv_background_bg.xml new file mode 100644 index 0000000..c13b660 --- /dev/null +++ b/app/src/main/res/drawable/tv_background_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_guide.xml b/app/src/main/res/layout/activity_guide.xml index d2488af..54d24e8 100644 --- a/app/src/main/res/layout/activity_guide.xml +++ b/app/src/main/res/layout/activity_guide.xml @@ -33,6 +33,17 @@ android:rotation="180" android:scaleType="fitCenter" /> + @@ -127,32 +139,61 @@ android:id="@+id/bottom_panel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:gravity="center_horizontal" + android:gravity="center_vertical" + android:background="@drawable/input_message_bg" + android:padding="5dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="16dp" + android:paddingStart="16dp" android:orientation="horizontal" - android:padding="16dp" android:layout_gravity="bottom"> - -